Skip to content

Latest commit

 

History

History
568 lines (459 loc) · 21.5 KB

File metadata and controls

568 lines (459 loc) · 21.5 KB

Smart TV Application (Android TV / Google TV / Fire TV)

Overview

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.

Architecture

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]
Loading

Detection

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)
Loading

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.

Routing Strategy

TV pages are gated at the route level using two patterns:

  1. TvPageGate (most pages) — renders TV content on TV, web children on non-TV:

    <TvPageGate tvContent={<TvSearch />}>
      <WebSearchPage />
    </TvPageGate>
  2. Inline isTV() check (player pages) — immediate branch in the component:

    if (isTV() && streamUrl) return <TvWatch streamUrl={streamUrl} ... />;

Spatial Navigation

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]
Loading

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 area
  • TV_PLAYER_CONTROLS — player control bar
  • TV_SEARCH_INPUT / TV_LETTER_GRID — search page keyboard

Source Structure

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

Video Player (TvPlayer)

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
Loading

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])

Features

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

Watch-Together Mode

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
Loading

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

What's excluded on TV (vs web watch-party):

  • ❌ Sketch/draw overlay (touch-only)
  • ❌ Chat text input (no keyboard)
  • ❌ Sidebar tabs (too complex for D-pad)
  • ❌ Floating tiles / drag
  • ❌ Soundboard

Music Player

Mini Player (TvMusicMiniPlayer)

  • 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

Full Player (TvMusicFullPlayer)

  • 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

Search (Letter Grid)

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-focusable class for consistent focus indicators)
  • Voice search uses Web Speech API (shows "Listening..." indicator)
  • Results capped at 30 items for performance

Live TV

Channel Grid

  • 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 Detail (fullscreen overlay)

  • Channel icon + name + category
  • "Watch Solo" → navigates to live player
  • "Watch Together" → creates watch party room

Manga Reader

Title Page (TvMangaTitle)

  • Cover image + metadata
  • Focusable chapter list (scroll into view on focus)

Chapter Reader (TvMangaReader)

  • Fullscreen single-page view
  • Left/Right or Up/Down arrows to navigate pages
  • Page counter overlay (e.g., "5 / 23")
  • Back/Escape to exit

CSS & Styling

TV styles are in src/platforms/smart-tv/styles/tv.css and activated when html.tv class is present.

Overscan Safe Area

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.

Focus States

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);
}

Reduced Motion

@media (prefers-reduced-motion: reduce) {
  html.tv .tv-focusable--focused { transform: none; box-shadow: 0 0 0 3px var(--tv-focus-color); }
}

Navbar

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.

Performance Optimizations

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)

Android Configuration

AndroidManifest.xml

<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>

Build Configuration

// android/app/build.gradle
applicationId project.hasProperty('tvBuild') ? "com.nightwatch.in.tv" : "com.nightwatch.in"

CI/CD

# .github/workflows/build-android-tv.yml
# Triggered manually or via: gh workflow run build-android-tv.yml

Authentication (QR Code)

sequenceDiagram
    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
Loading

Local Development

# 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();

Testing on Physical Device

# 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.apk

Video Cast (Phone/Desktop → TV)

A Spotify Connect-like feature for video. Users can send a video from their phone or desktop to the TV.

Architecture

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

Socket Events

Event Direction Payload
remote:tv_available TV → Phone/Desktop { socketId, deviceName }
remote:cast_content Phone/Desktop → TV { movieId, streamUrl?, title }

Implementation

  • TV side (use-tv-video-presence.ts): Always mounted in TvRootLayout. Emits remote:tv_available every 60s. Listens for remote:cast_content → navigates to watch page.
  • Client side (use-available-tvs.ts): Discovers TVs via remote:tv_available events. Provides tvs array and castToTv() 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.

Profile Activity Heatmap

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 Parity Matrix

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

Debugging D-Pad Navigation

If spatial navigation stops working (focus gets stuck, keys stop responding), follow this guide:

1. Enable Visual Debug

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.

2. Check addFocusable Logs

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.

3. Root Cause Pattern

The most common cause of broken navigation:

A component calls useFocusable() but conditionally returns null AFTER 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 />}

4. Focus Target Issues

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.

5. Key Map Reference

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).

6. Quick Checklist

  • initSpatialNavigation() called before any useFocusable() hook
  • Root <FocusContext.Provider> wraps all focusable children
  • No conditional returns after useFocusable() calls
  • focusKey values are unique across the tree
  • setFocus() target exists and has a mounted DOM node