Full Munib Tracker product app on living-room devices via the React Native TV fork and Expo’s Build for TV guide.
| Item | Detail |
|---|---|
| Package | Same apps/app (not a separate monorepo app) |
| RN | "react-native": "npm:react-native-tvos@0.86.0-2" (Expo SDK 57) |
| Toggle | EXPO_TV=1 during prebuild / EAS |
| Plugin | @react-native-tvos/config-tv with Leanback banner + Apple TV icons/top shelf |
| Chrome | Side rail always (WideAppTabs); focus rings on PressableScale |
Phone ↔ TV always needs a clean prebuild (pnpm prebuild:app:tv vs pnpm cleanbuild:app:android / ios).
Marketing semver is unified across phone, TV, and web via Release Please
(expo release type + .env.example x-release-please-version markers). Build /
version-code integers stay per surface and are not owned by Release Please
(bump manually or via EAS autoIncrement on store upload).
| Surface | Marketing semver (same product version) | Build / version code (independent) |
|---|---|---|
| Android phone | EXPO_ANDROID_APP_VERSION |
EXPO_ANDROID_VERSION_CODE |
| Android TV / Fire TV | EXPO_ANDROID_TV_APP_VERSION |
EXPO_ANDROID_TV_VERSION_CODE |
| iPhone | EXPO_IOS_APP_VERSION |
EXPO_IOS_APP_BUILD_NUMBER |
| Apple TV | EXPO_TVOS_APP_VERSION |
EXPO_TVOS_APP_BUILD_NUMBER |
Phone + Android TV share package app.munibtracker on Play — versionCodes must stay unique and increasing across both form factors (do not reuse a phone code on TV or vice versa). See apps/app/.env.example and RELEASES.md.
Generated by pnpm generate:app:brand-assets into apps/app/assets/images/tv/:
| File | Role |
|---|---|
android-banner.png |
Leanback row banner (320×180) — in APK |
android-icon.png |
Android TV launcher icon (512×512) — in APK |
tvos-icon-*.png |
Apple TV App Icon layers |
tvos-topshelf-*.png |
Top Shelf / wide Top Shelf |
firetv-background-1920x1080.png |
Amazon console background (not in APK) |
firetv-icon-1280x720.png |
Amazon console large icon |
firetv-icon-512.png / firetv-icon-114.png |
Amazon console icons |
Do not run brand generation unless you intend to overwrite PNGs. Fire TV files are for Amazon Developer Console → Appstore Details only.
Store / screenshot notes: STORE_ASSETS.md.
Scripts validate by default and do not write images until RUN_CAPTURE=1:
pnpm screenshots:tvos # validate Apple TV pipeline
pnpm screenshots:android-tv # validate Android TV / Fire TV pipeline
pnpm screenshots:tv # both (still validate-only without RUN_CAPTURE)
pnpm screenshots:validate # includes TV structure checks
# Later, when submitting TV SKUs (requires EXPO_TV=1 build + TV sim/emulator):
RUN_CAPTURE=1 pnpm screenshots:tvos
RUN_CAPTURE=1 LOCALES=en THEMES=dark pnpm screenshots:android-tvOutput (only with RUN_CAPTURE=1):
store-assets/captures-native/tvos|android-tv/<locale>/<theme>/store-assets/ios/screenshots/apple-tv-1080p/<locale>/store-assets/android/screenshots/android-tv-1080p/<locale>/
Scenes: lib/tv-scenes.mjs (home, tracker, library, quran, zikr, settings). Not CI-automated; not Maestro on TV.
# Generate tvOS + Leanback native projects (always --clean)
pnpm prebuild:app:tv
pnpm prebuild:app:tv:android
pnpm prebuild:app:tv:ios
# aliases:
pnpm cleanbuild:app:tv
pnpm cleanbuild:app:tv:android
pnpm cleanbuild:app:tv:ios
# Run (after TV prebuild)
pnpm dev:app:tv:android
pnpm dev:app:tv:ios
# Local signed release (mirrors phone release:app:*)
pnpm release:app:android-tv # Leanback AAB → android/play-upload/tv/
pnpm release:app:android-tv:apk # Leanback APK (Amazon Fire TV / sideload)
pnpm release:app:android-tv:upload # Play internal track (same package; enable TV form factor)
pnpm release:app:tvos # tvOS IPA → ios/app-store-export-tvos/ (macOS)
pnpm release:app:tvos:upload # altool --type appletvos
pnpm release:app:tvos:upload:validate
# EAS (profiles development_tv / preview_tv / production_tv)
pnpm build:app:android:tv
pnpm build:app:ios:tv
pnpm build:app:android:preview:tv
pnpm build:app:ios:preview:tv
pnpm submit:app:android:tv
pnpm submit:app:ios:tv
# Back to phone
pnpm cleanbuild:app:android
pnpm cleanbuild:app:iosGuards: Phone release:app:android / release:app:ios refuse a Leanback/tvOS tree; TV release scripts refuse a phone tree. Switch with a clean prebuild.
Artifacts
| Command | Output |
|---|---|
release:app:android-tv |
apps/app/android/play-upload/tv/app-release.aab |
release:app:android-tv:apk |
apps/app/android/play-upload/tv/app-release.apk |
release:app:tvos |
apps/app/ios/app-store-export-tvos/*.ipa |
Fire TV banner/icons stay Amazon-console-only (firetv-*); the APK is the Leanback binary above.
EAS profiles: development_tv, preview_tv, production_tv in apps/app/eas.json (env.EXPO_TV=1). TV builds strip extra.eas.build.experimental.ios.appExtensions (widgets/Watch/Intents). submit.production_tv mirrors phone submit.
tvOS credentials: same bundle ID / certs as iOS, but different provisioning profiles. Automatic signing fails without a registered Apple TV development device — pnpm release:app:tvos uses manual signing with a TVOS_APP_STORE profile (ExportOptions.tvos.plist, override name via TVOS_PROVISIONING_PROFILE). When shipping both iOS and tvOS, keep tvOS credentials local (EAS-stored profiles are iOS-only).
The emulator sidebar buttons (◁ Back · ○ Home · □ Overview) are phone navigation. On Android TV AVDs they often do nothing — that is expected.
Use a remote / D-pad instead:
- Click the TV emulator window so it has keyboard focus.
- Keyboard: Arrow keys = D-pad, Enter = select, Esc = back.
- Or send keys via adb:
pnpm --filter app tv:remote home
pnpm --filter app tv:remote back
pnpm --filter app tv:remote up # also: down left right select appsOptional: ANDROID_SERIAL=emulator-5554 if several devices are connected.
If the Munib splash stays forever, Metro may be unreachable — pnpm dev:app:tv:android now sets REACT_NATIVE_PACKAGER_HOSTNAME=10.0.2.2 and adb reverse for emulators. Manually:
adb reverse tcp:8081 tcp:8081
adb shell am start -a android.intent.action.VIEW \
-d 'exp+munib-tracker://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8081'Do not open the LAN IP (192.168.x.x) from the TV emulator — Dev Client hangs on splash, then ANRs.
react-native.config.js excludes widgets / quick-actions / speech-recognition / react-native-webview / expo-web-browser when EXPO_TV=1 (needs a TV prebuild / pod install to drop those from Fabric codegen). Without excluding webview, RCTThirdPartyComponentsProvider registers RNCWebView with a nil Class and aborts at JS load. On tvOS, plugins/withTvExpoModuleExcludes.cjs also patches use_expo_modules!(exclude: …) because some podspecs (e.g. expo-quick-actions) incorrectly declare :tvos and would still link without that.
@bacons/apple-targets, App Intents, external commands, Wear OS, Android widgets, expo-quick-actions.
Metro (EXPO_TV=1): phone-only packages resolve to stubs under apps/app/src/lib/tv-stubs/ so imports cannot crash Leanback/tvOS JS. Covered today: expo-speech-recognition, react-native-android-widget, expo-quick-actions, expo-web-browser, react-native-webview, react-native-pager-view, expo-notifications, expo-location, expo-sensors, expo-haptics, expo-local-authentication, expo-image-picker, expo-document-picker, expo-sharing, expo-store-review, expo-speech, expo-apple-authentication, expo-navigation-bar. Autolink excludes the same packages via react-native.config.js (+ Podfile use_expo_modules!(exclude: …) for modules whose podspecs incorrectly claim tvOS). plugins/withTvNilSafeFabricComponents.cjs also nil-safes RCTThirdPartyComponentsProvider.mm so any remaining unlinked Fabric Class does not abort at JS load.
| Feature | TV behavior |
|---|---|
| Location | Manual city search only (no GPS) |
| Notifications / Live Activity / push token | Settings empty state; scheduler no-op |
| Widgets / Watch / Wear / Siri / quick actions | Not built / not synced |
| Qibla | Static bearing from city; no live compass |
| Verse detector | Unavailable empty state |
| App lock / biometrics / voice shortcuts | Hidden in Settings |
| Camera / gallery / document pickers | Canceled / toast |
| Haptics / store review | No-op |
| Social OAuth | Best-effort on remote; QR companion login preferred (POST /auth/tv/pairing + phone /tv-pair) |
| Side rail | Collapses to icons when focus is in content; expands with labels + brand when focused |
| Back / Menu | Ladder: stack pop → expand rail + preferred tab focus → confirm exit (TvRemoteBridge) |
| Play/Pause remote | Toggles in-app audio when a track is loaded |
| Lock-screen media | Intentional no-op on TV |
| Focus | Rings + scale-on-focus (reduce-motion gated); hasTVPreferredFocus; TvFocusGuide autoFocus (focus memory) |
| Copy | tTv() prefers *.tv / *Tv Select wording; high-traffic “Tap …” strings have TV variants |
| Piece | Where |
|---|---|
| Preferred focus | Button.preferredFocus, Continue cards, rail after Back, confirm-dialog Cancel, first Settings/Library rows |
| Focus guides | tv-focus-guide.tsx — autoFocus (focus memory), trap edges on chips / segmented / content; sheets auto-focus |
| Non-focusable scroll | tv-scroll-view.tsx / tv-flat-list.tsx — scroll chrome must not be a D-pad target (focusable={false} on TV). Used by ScreenLayout, sheets, home, search, lists, and toolbars. Keep scrollEnabled so focus moves between children and the view still scrolls to them. |
| Viewport CTAs | Short screens (intro): also scrollEnabled={false} + CTA in TvFocusGuide autoFocus |
nextFocus* |
Rail tabs nextFocusRight → content guide |
| Scale on focus | pressable-scale.tsx (~1.08×, skipped when reduce motion) |
| Remote bridge | tv-remote-bridge.tsx — Play/Pause → audio; Menu/Back ladder |
| QR login | TV login shows QR; phone opens /tv-pair?code=… and claims via API |
Simulator tip: click the TV window so it has keyboard focus, then Arrow keys = D-pad and Enter = Select. Mouse clicks alone often do not advance focus.
| Token / rule | Value / behavior |
|---|---|
TvLayout.contentPaddingX |
48 — overscan inset on ScreenLayout / sheets |
| ScreenLayout default on TV | Uncapped (fills the tab pane). Learn prose routes stay at MaxContentWidth (800) for readability. |
TvLayout.listDetailMaxWidth |
1720 — Qur’an / Hadith list–detail shells |
TvLayout.detailPaneMinWidth / MaxWidth |
420 / 720 — secondary panes |
| Selected vs focused | Soft fill (accentSoft) + hairline for selected; solid accent / thick ring only for D-pad focus. Never paint accent borders on selected rows that also receive a focus ring. |
| Search placeholders | Prefer short keys on TV (e.g. quran.search vs long phone placeholders) |
- Android TV emulator: cold start → home side rail; D-pad between tabs
- Apple TV simulator: same
- Back from nested screen pops stack; from tab content expands rail; from rail root shows exit confirm
- Play/Pause toggles Qur’an / audio mini-player when media is active
- Focus scale + ring on primary tiles; reduce-motion disables scale
- Continue card receives preferred focus when present
- Mark Salah / tracker update (Select wording; large prayer rows)
- Manual city change updates prayer times (no GPS chrome)
- Open Qur’an surah, search, dua/zikr lists with focus
- Settings appearance + language; phone-only rows empty-stated
- Login: QR pairing panel; guest secondary; phone
/tv-pairclaims session - Intro: guest-first on last slide; location step overscan + no reminders prompt
- Tasbeeh: Press Select to count
- Qibla shows TV hint (no magnetometer)
- Sheets / mini-player respect overscan padding
- Leanback banner / Apple TV icon appear after TV prebuild
- Phone prebuild still includes widgets / Watch after reverting
EXPO_TV
Related: DEVICES.md · NATIVE_SURFACES.md · STORE_ASSETS.md · apps/app/AGENTS.md