Nightwatch ships a native Android TV application that runs the same Next.js web app inside a native WebView, optimized for D-pad (remote control) navigation. The app uses @noriginmedia/norigin-spatial-navigation for directional focus management and provides a completely separate set of UI components tailored for the 10-foot living room experience.
The TV build uses a separate application ID (com.nightwatch.in.tv) and coexists with the mobile app on the Play Store.
graph TD
A[Android TV Shell] --> B[Capacitor WebView]
B --> C[Next.js App - SSR]
C --> D[TvRootLayout]
D --> E[TvNavbar - Collapsible Sidebar]
D --> F[TV Page Content - Spatial Navigation]
D --> G[TvMusicMiniPlayer - Persistent Bar]
D --> H[TvScreensaver - Idle 5min]
F --> I[TvHome / TvSearch / TvLive / TvMusic / etc.]
F --> J[TvPlayer - Solo + Watch-Together]
F --> K[TvMusicFullPlayer - Overlay]
sequenceDiagram
participant Native as Android TV MainActivity
participant WebView as Capacitor WebView
participant App as Next.js App
Native->>WebView: Load URL
Native->>WebView: evaluateJavascript("window.__ANDROID_TV__=true;localStorage.setItem(...)")
WebView->>App: Page renders
App->>App: isTV() checks window.__ANDROID_TV__ || localStorage
App->>App: Renders TV components (TvRootLayout)
The native Android MainActivity injects window.__ANDROID_TV__ = true into the WebView on launch (via UiModeManager.getCurrentModeType() == UI_MODE_TYPE_TELEVISION). The web app detects this via:
// src/platforms/smart-tv/lib/detection.ts
export function isTV(): boolean {
if (typeof window === 'undefined') return false;
if (window.__ANDROID_TV__ === true) return true;
return localStorage.getItem('__ANDROID_TV__') === 'true';
}For browser testing: localStorage.setItem('__ANDROID_TV__', 'true') then reload.
TV pages are gated at the route level using two patterns:
-
TvPageGate(most pages) — renders TV content on TV, web children on non-TV:<TvPageGate tvContent={<TvSearch />}> <WebSearchPage /> </TvPageGate>
-
Inline
isTV()check (player pages) — immediate branch in the component:if (isTV() && streamUrl) return <TvWatch streamUrl={streamUrl} ... />;
flowchart TD
A[User presses Escape/GoBack] --> B{Music Full Player open?}
B -->|Yes| C[Close full player - capture phase]
B -->|No| D{Video Player open?}
D -->|Yes| E{Panel open?}
E -->|Yes| F[Close panel]
E -->|No| G{Controls visible?}
G -->|Yes| H[Hide controls]
G -->|No| I[Exit player]
D -->|No| J{On home page?}
J -->|Yes| K[Do nothing - native handles exit]
J -->|No| L[router.back]
All D-pad interaction is powered by @noriginmedia/norigin-spatial-navigation:
// src/platforms/smart-tv/lib/spatial-navigation.ts
init({
throttle: 150,
throttleKeypresses: true,
useGetBoundingClientRect: true,
shouldFocusDOMNode: true,
distanceCalculationMethod: 'center',
});
setKeyMap({ left: 37, right: 39, up: 38, down: 40, enter: 13 });Every interactive element uses useFocusable(). Focus keys follow the pattern:
TV_SIDEBAR— navbar items (prefix for focus-memory guard)TV_CONTENT— main page content areaTV_PLAYER_CONTROLS— player control barTV_SEARCH_INPUT/TV_LETTER_GRID— search page keyboard
src/platforms/smart-tv/
├── components/ # Reusable TV UI primitives
│ ├── TvPlayer.tsx # Full-featured video player (solo + watch-together)
│ ├── TvPlayerControls.tsx # Seek bar, quality/subtitle panels, clip button
│ ├── TvPlayerOverlay.tsx # Title bar + participant avatars
│ ├── TvCard.tsx # Content card with lazy loading, progress bar, prefetch
│ ├── TvRow.tsx # Horizontal scrollable row with auto-scroll on focus
│ ├── TvHero.tsx # Auto-sliding banner with D-pad navigation
│ ├── TvGrid.tsx # Auto-fill grid layout
│ ├── TvButton.tsx # Generic focusable button
│ ├── TvActionButton.tsx # Neo-brutalist action button (Watch Solo, etc.)
│ ├── TvMusicFullPlayer.tsx # Fullscreen music player (lyrics, queue, volume)
│ ├── TvMusicMiniPlayer.tsx # Persistent bottom bar (always shows when music plays)
│ ├── TvParticipantStrip.tsx # PiP video tiles for watch-together
│ ├── TvEmojiReactions.tsx # Emoji bar + float animation for watch-together
│ ├── TvScreensaver.tsx # Idle screensaver (bouncing logo + clock)
│ ├── TvErrorBoundary.tsx # Error boundary with focusable retry + Crashlytics
│ ├── TvMusicCommandHandler.tsx # Music remote command receiver (phone → TV)
│ ├── TvActivityHeatmap.tsx # Profile activity heatmap (same as web laptop version)
│ ├── TvSkeleton.tsx # Loading skeletons (row, grid, page)
│ └── TvPageGate.tsx # TV/Web router gate
├── pages/ # Full-page TV views
│ ├── TvHome.tsx # Home (hero, continue watching, trending, sections)
│ ├── TvSearch.tsx # Letter grid keyboard + results
│ ├── TvLive.tsx # IPTV channel grid + channel detail + number keys
│ ├── TvMusic.tsx # Music browse (now playing, rows)
│ ├── TvMusicDetail.tsx # Playlist/album/artist track list
│ ├── TvManga.tsx # Manga browse (tabs, search, grid)
│ ├── TvMangaTitle.tsx # Manga title detail + chapter list
│ ├── TvMangaReader.tsx # Fullscreen manga page reader (D-pad)
│ ├── TvWatch.tsx # Solo video player wrapper
│ ├── TvWatchTogether.tsx # Watch party (room code, join requests, emojis)
│ ├── TvWatchlist.tsx # User watchlist grid
│ ├── TvLibrary.tsx # Clips library
│ ├── TvContentDetail.tsx # Movie/series detail (season selector, episodes)
│ ├── TvProfile.tsx # User profile + sign out
│ ├── TvPreferences.tsx # Theme, language, gapless toggle
│ ├── TvAskAi.tsx # Voice AI assistant (orb + transcripts)
│ └── TvLogin.tsx # QR code sign-in
├── layouts/
│ ├── TvRootLayout.tsx # Root: safe area + navbar + screensaver + mini player + video presence
│ └── TvNavbar.tsx # Floating rounded sidebar navigation (rounded-3xl card)
├── hooks/
│ ├── use-tv-focus.ts # Per-page focus memory (save/restore)
│ ├── use-tv-back.ts # Back/Escape key handler
│ ├── use-tv-idle.ts # 5-minute idle detection
│ ├── use-tv-remote-receiver.ts # Remote control receiver (phone → TV player)
│ └── use-tv-video-presence.ts # Global TV presence for video cast (phone → TV)
├── lib/
│ ├── detection.ts # isTV() + waitForTvFlag()
│ ├── spatial-navigation.ts # norigin init config
│ └── focus-keys.ts # Well-known focus key constants
├── styles/
│ └── tv.css # TV-specific CSS (safe area, focus states, navbar, animations)
└── index.ts # Barrel exports
stateDiagram-v2
[*] --> Loading: Stream URL loaded
Loading --> Playing: canplay event
Loading --> Error: load error
Playing --> Paused: pause / MediaPlayPause
Paused --> Playing: play / MediaPlayPause
Playing --> Buffering: waiting event
Buffering --> Playing: canplay event
Playing --> Ended: ended event
Ended --> Countdown: has next episode
Countdown --> Playing: auto-advance (10s)
Countdown --> Paused: user cancels
Error --> Loading: retry pressed
Playing --> [*]: exit / MediaStop
The TV player (TvPlayer.tsx) is a unified component used for:
- Solo VOD playback (
/watch/[id]) - Solo live streaming (
/live/[id]) - Watch-together sessions (
/watch-party/[id]) - Clip playback (
/clip/[id])
| Feature | Implementation |
|---|---|
| Play/Pause | Center button + MediaPlayPause key |
| Seek ±10s | Rewind/Forward buttons + MediaRewind/MediaFastForward |
| Seek bar | Hold Left/Right arrows to scrub (2% per tick) |
| Quality picker | Panel with D-pad focusable items (Auto + HLS levels) |
| Audio track selector | Panel with available audio dubs (Hindi, English, etc.) |
| Subtitle selector | Panel with Off + available text tracks |
| Next/Prev episode | Skip buttons (series only) |
| Auto-play countdown | 10-second countdown overlay before next episode |
| Live clipping | Red record button with duration timer |
| Watch progress | Syncs via useWatchProgress — resume position saved/restored |
| Error recovery | Retry button + Go Back button on error |
| Error reporting | Errors reported to Firebase Crashlytics + analytics |
| Controls auto-hide | 5s timeout, any key shows them |
| Media keys | MediaPlayPause, MediaRewind, MediaFastForward, MediaStop |
| Back button layers | Panel → Controls → Exit (capture phase, stops propagation) |
| DVR seek (live) | Clamps seeking within seekable/buffered range |
| Poster image | Shown on video element during initial load |
sequenceDiagram
participant Host as TV Host
participant Server as Socket.IO Server
participant Guest as Guest Device
Host->>Server: Create room (random ID)
Host->>Host: Show room code badge
Guest->>Server: Request join (room ID)
Server->>Host: pendingMembers update
Host->>Host: Show join request popup
Host->>Server: Approve member
Server->>Guest: Room joined + stream URL
Note over Host,Guest: Synchronized playback
Host->>Server: play/pause/seek event
Server->>Guest: state update
Guest->>Guest: Apply state + drift correction (5s interval)
Note over Host,Guest: Emoji reactions
Guest->>Server: emoji reaction
Server->>Host: emoji broadcast
Host->>Host: Float animation
When used via TvWatchTogether, the player additionally shows:
- Room code badge (top-right, always visible)
- Participant video strip (PiP tiles on right edge, Agora RTC)
- Join request popup (host only, D-pad approve/reject)
- Emoji reaction bar (6 emojis, float-up animation)
- Host sync — only host emits play/pause/seek events
- Guest drift correction — periodic 5s check, corrects if drift > 3s
- ❌ Sketch/draw overlay (touch-only)
- ❌ Chat text input (no keyboard)
- ❌ Sidebar tabs (too complex for D-pad)
- ❌ Floating tiles / drag
- ❌ Soundboard
- Persistent bar at the bottom of every page
- Shows album art, title, artist, play/pause, skip
- The entire bar is one focusable — press Enter to expand
- Progress bar at the top of the bar
- Fullscreen overlay triggered by Enter on mini player or NowPlaying card
- Left side: Album art (300×300), title, artist, album, seek bar, transport controls (shuffle, prev, play/pause, next, repeat), volume slider, sleep timer
- Right side: Synced lyrics (auto-scroll to active line) OR queue list (if no lyrics)
- Close via Back key (capture phase) or ↓ button
- Volume keys (
AudioVolumeUp/AudioVolumeDown) adjust in-app volume
TV search replaces the text input with a D-pad-friendly letter grid:
A B C D E F G
H I J K L M N
O P Q R S T U
V W X Y Z 1 2
3 4 5 6 7 8 9
0
[Space] [Delete] [Clear] [🎤 Voice]
- Each letter is a focusable button (theme-aware:
bg-secondary text-foreground, works in both light/dark) - Results appear on the right side as a grid (uses
tv-focusableclass for consistent focus indicators) - Voice search uses Web Speech API (shows "Listening..." indicator)
- Results capped at 30 items for performance
- Channel cards with icons in a responsive grid
- Number key switching: Type digits → 1.5s debounce → jumps to channel #N
- Number overlay shown while typing
- Channel icon + name + category
- "Watch Solo" → navigates to live player
- "Watch Together" → creates watch party room
- Cover image + metadata
- Focusable chapter list (scroll into view on focus)
- Fullscreen single-page view
- Left/Right or Up/Down arrows to navigate pages
- Page counter overlay (e.g., "5 / 23")
- Back/Escape to exit
TV styles are in src/platforms/smart-tv/styles/tv.css and activated when html.tv class is present.
html.tv { --tv-safe-x: 48px; --tv-safe-y: 27px; }
html.tv .tv-safe-area { padding: var(--tv-safe-y) var(--tv-safe-x); }Applied at root layout level to prevent content being cut off on physical TV panels.
html.tv .tv-focusable--focused {
transform: scale(1.05);
box-shadow: 0 0 0 3px var(--tv-focus-color), 0 0 20px var(--tv-focus-glow);
}@media (prefers-reduced-motion: reduce) {
html.tv .tv-focusable--focused { transform: none; box-shadow: 0 0 0 3px var(--tv-focus-color); }
}Floating card design with rounded-3xl corners, backdrop-blur-md, and subtle border/shadow. Nav items use rounded-2xl with scale-[1.03] + shadow-lg on focus, active state uses bg-secondary/80 (works in both light and dark themes). Collapses from 240px → 72px (icons only) when focus leaves the sidebar.
| Optimization | Detail |
|---|---|
React.memo |
Applied to TvCard and TvRow to prevent re-renders from parent state changes |
loading="lazy" |
All images except first visible row (which uses eager) |
decoding="async" |
All images |
router.prefetch() |
Cards prefetch their target route on focus |
| Hero image preload | Next slide's image preloaded via new Image() |
| List capping | Search: 30, episodes: 50, queue: 50, rows: 15 items max |
| Vertical scroll: smooth | TvRow uses behavior: "smooth" for both vertical and horizontal; main content has scroll-smooth |
retry: false |
All TV queries skip TanStack Query retry (fail fast on TV) |
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<application android:banner="@drawable/tv_banner">
<activity>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>// android/app/build.gradle
applicationId project.hasProperty('tvBuild') ? "com.nightwatch.in.tv" : "com.nightwatch.in"# .github/workflows/build-android-tv.yml
# Triggered manually or via: gh workflow run build-android-tv.ymlsequenceDiagram
participant TV as Android TV
participant API as Backend API
participant Phone as User's Phone
TV->>API: qrInitiate()
API-->>TV: { code: "ABC123" }
TV->>TV: Generate QR image (nightwatch.in/auth/qr?code=ABC123)
TV->>TV: Display QR on screen
loop Every 3 seconds
TV->>API: qrPollStatus("ABC123")
API-->>TV: { status: "pending" }
end
Phone->>Phone: Scan QR code
Phone->>API: Authorize code "ABC123"
TV->>API: qrPollStatus("ABC123")
API-->>TV: { status: "authorized" }
TV->>API: getProfile()
API-->>TV: { user: {...} }
TV->>TV: Set auth store, redirect to /home
# Start Next.js dev server
pnpm dev
# In browser, enable TV mode:
# Open DevTools Console:
localStorage.setItem('__ANDROID_TV__', 'true');
location.reload();
# To exit TV mode:
localStorage.removeItem('__ANDROID_TV__');
location.reload();# Sync Capacitor
npx cap sync android
# Build TV APK
cd android && ./gradlew assembleRelease -PtvBuild
# Install on connected Android TV
adb install -r app/build/outputs/apk/release/app-release.apkA Spotify Connect-like feature for video. Users can send a video from their phone or desktop to the TV.
Phone/Desktop Server (Socket.IO) Android TV
───────────── ────────────────── ──────────────
useTvVideoPresence mounts
emit remote:tv_available (60s)
← broadcast to user room ←
useAvailableTvs detects TV online
"Play on TV" button appears (mobile portrait player only)
User taps "Play on TV" ─────────→ remote:cast_content ──────────→ router.push(/watch/{movieId})
TvWatch renders
| Event | Direction | Payload |
|---|---|---|
remote:tv_available |
TV → Phone/Desktop | { socketId, deviceName } |
remote:cast_content |
Phone/Desktop → TV | { movieId, streamUrl?, title } |
- TV side (
use-tv-video-presence.ts): Always mounted in TvRootLayout. Emitsremote:tv_availableevery 60s. Listens forremote:cast_content→ navigates to watch page. - Client side (
use-available-tvs.ts): Discovers TVs viaremote:tv_availableevents. Providestvsarray andcastToTv()function. - UI (
PlayOnTvButton.tsx): Renders only in mobile portrait video player (under the video). Only visible when a TV is online. Hidden on desktop/laptop entirely.
The TV profile page includes the same GitHub-style activity heatmap as the web laptop version — full 365-day grid with watch and music color legends. Uses the same ActivityGraph component and same data queries (['profile', 'activity', 'watch'] and ['profile', 'activity', 'music']).
| Feature | Web | Mobile | Desktop | TV |
|---|---|---|---|---|
| VOD Playback | ✅ | ✅ | ✅ | ✅ |
| Live TV (IPTV) | ✅ | ✅ | ✅ | ✅ |
| Watch Party | ✅ | ✅ | ✅ | ✅ (no sketch/chat) |
| Music Player | ✅ | ✅ | ✅ | ✅ |
| Music Device Sync | ✅ | ✅ | ✅ | ✅ (receive + commands) |
| Synced Lyrics | ✅ | ✅ | ✅ | ✅ |
| Search | ✅ | ✅ | ✅ | ✅ (letter grid) |
| Manga Reader | ✅ | ✅ | ✅ | ✅ (D-pad pages) |
| Live Clipping | ✅ | ✅ | ✅ | ✅ |
| Watchlist | ✅ | ✅ | ✅ | ✅ |
| Watch Progress | ✅ | ✅ | ✅ | ✅ |
| Audio Track Selection | ✅ | ✅ | ✅ | ✅ |
| Profile/Prefs | ✅ | ✅ | ✅ | ✅ (simplified) |
| Ask AI | ✅ | ✅ | ✅ | ✅ (voice orb) |
| Remote Control | ✅ | ✅ (sender) | ✅ (receiver) | ✅ (receiver) |
| Video Cast | ❌ | ✅ (sender) | ❌ | ✅ (receiver) |
| Activity Heatmap | ✅ | ❌ | ✅ | ✅ |
| Friends/Voice | ✅ | ✅ | ✅ | ❌ |
| Games | ✅ | ❌ | ✅ | ❌ |
If spatial navigation stops working (focus gets stuck, keys stop responding), follow this guide:
In src/platforms/smart-tv/lib/spatial-navigation.ts, set:
init({
debug: true,
visualDebug: true,
// ...rest
});This draws colored overlays on focusable elements and logs navigation decisions to the console.
Every component that calls useFocusable() should appear in the console with an addFocusable log. If a component shows node: null, its ref isn't attached to a DOM element.
The most common cause of broken navigation:
A component calls
useFocusable()but conditionally returnsnullAFTER the hook call.
This registers a focusable with node: null in the spatial navigation tree, creating a dead zone.
Fix: Don't mount the component at all — use conditional rendering from the parent instead of returning null inside the component.
// ❌ BAD — hook runs but ref never attaches
function MyButton({ visible }) {
const { ref, focused } = useFocusable();
if (!visible) return null; // node: null registered!
return <div ref={ref}>...</div>;
}
// ✅ GOOD — component never mounts, no dead focusable
{visible && <MyButton />}setFocus(key) and focusSelf() pick the first child by tree order. If that child has node: null, focus gets stuck silently. Always ensure the initial focus target has a valid DOM node.
| Key | Code | Event |
|---|---|---|
| Left | 37 | ArrowLeft |
| Right | 39 | ArrowRight |
| Up | 38 | ArrowUp |
| Down | 40 | ArrowDown |
| Enter/Select | 13 | Enter |
Test: Press ArrowDown in Chrome DevTools, check if smartNavigate appears in console (with debug: true).
-
initSpatialNavigation()called before anyuseFocusable()hook - Root
<FocusContext.Provider>wraps all focusable children - No conditional returns after
useFocusable()calls -
focusKeyvalues are unique across the tree -
setFocus()target exists and has a mounted DOM node