diff --git a/docs/superpowers/chromecast-refactor-handoff.md b/docs/superpowers/chromecast-refactor-handoff.md deleted file mode 100644 index bb4f4cdd8..000000000 --- a/docs/superpowers/chromecast-refactor-handoff.md +++ /dev/null @@ -1,199 +0,0 @@ -# Chromecast Refactor — Handoff & Resume Document - -**Branch:** `refactor-chromecast` · **PR:** #1402 (draft) · **Last updated:** 2026-05-22 - -This document captures the full state of the Chromecast refactor so the work can be -resumed in a later session. Specs and plans for each sub-project live in -`docs/superpowers/specs/` and `docs/superpowers/plans/`. - ---- - -## 1. Summary - -The Chromecast casting code (PR #1402) was refactored across five sub-projects, a -prep task, and small cleanups. All sub-projects are **implemented, type-checked, and -unit-tested**; the branch has dozens of commits unpushed to -`origin/refactor-chromecast` (run `git log origin/refactor-chromecast..HEAD`). - -| Sub-project | What | Status | -|---|---|---| -| **A** — Device profiles | Per-device capability detection, profile builder, unified `loadCastMedia`, crash fixes (status 2100, 5.1, bitrate) | ✅ done, **verified on hardware** | -| **Prep #1367** — Segment-skip | Backport segment-skip fixes to PR #1367, remove 177 dead lines | ✅ done, **pushed to #1367** | -| **B** — Track switching | `CastSelection` source-of-truth via customData, audio/subtitle/quality/version switching, multi-version | ✅ done, **verified on hardware** | -| **C** — Player split | `casting-player.tsx` 1428→574 lines: 6 components + 4 hooks | ✅ done, **needs manual re-test** | -| **D** — Session & remote control | Correct PlayMethod, conditional episode buttons, `loadEpisode` race fix, app-wide Jellyfin remote control | ✅ done, **needs manual test** | -| **UX player** — Trickplay & mini-player | Trickplay bubble clamp via `bubbleWidth`, shared `CastTrickplayBubble`, plain-text time, mini-player stop button, `DEBUG_TOUCH_ZONES` overlay | ✅ done, **needs manual test** | -| Loose ends | Dead `liveProgress` export removed, `BitRateSheet` duplicate removed, full-width labelled stop button for movies | ✅ done | - -Verification gate for the whole branch: `bun run typecheck` ✅, `bun test utils/` ✅ -(32 tests). Note: project-wide `bun run check` shows ~124 pre-existing CRLF errors — -a Windows `core.autocrlf` artifact, unrelated to this work (see §6). - ---- - -## 2. Sub-project A — Device profiles & capability detection - -**Spec:** `specs/2026-05-21-chromecast-profiles-design.md` · **Plan:** -`plans/2026-05-21-chromecast-profiles.md` · **Commits:** `bcfa8c6d`..`73214f5d` - -Replaced the two static device profiles with `detectCapabilities()` + -`buildChromecastProfile()` (`utils/casting/capabilities.ts`, `buildProfile.ts`), a -unified `loadCastMedia()` (`utils/casting/castLoad.ts`) with downgrade-on-failure, -and `chromecastProfile` / `chromecastMaxBitrate` settings replacing -`enableH265ForChromecast`. Fixed the audio-index, media-source, and PlaySessionId -load-path bugs. - -**Verified:** all 7 test-matrix files cast successfully on the Chromecast HD, -including a 50 Mb/s HEVC-10bit movie that previously crashed with status 2100. - ---- - -## 3. Prep — Segment-skip reconciliation (PR #1367) - -The chromecast branch and PR #1367 (`autoskip`) both carried segment-skip; #1367 was -behind. The chromecast branch's fixes were backported to the `autoskip` branch -(`useSegmentSkipper` auto-skip-by-identity, `Controls` stale-closure fix, `segments` -cleanup) and the two dead hooks `useIntroSkipper`/`useCreditSkipper` (177 lines) were -removed. **Commit `0990e479` is pushed to `origin/autoskip` — PR #1367 is updated.** -The `page.tsx` plugin-lock lines were not backported (depend on a newer -`PlatformDropdown` not on #1367's base — will arrive when #1367 rebases on develop). - ---- - -## 4. Sub-project B — Track switching & multi-version - -**Spec:** `specs/2026-05-21-chromecast-track-switching-design.md` · **Plan:** -`plans/2026-05-21-chromecast-track-switching.md` · **Commits:** `3d65c3bb`..`23b4f20d` - -`CastSelection` (`utils/casting/selection.ts`) is the single source of truth, carried -in cast `customData` and read back by `hooks/useCastSelection.ts` with an optimistic -pending overlay. Audio / subtitle / quality / version switching all reflect reality. -The shared `BITRATES` ladder (`components/BitrateSelector.tsx`) was expanded; the cast -quality menu filters it by device capability and media bitrate. - -**Verified:** audio (Bleach JP↔FR), SubRip subtitles, and bitrate switching all work -on hardware — which also confirms the `customData` round-trip. - ---- - -## 5. Sub-project C — `casting-player.tsx` split - -**Spec:** `specs/2026-05-22-chromecast-player-split-design.md` · **Plan:** -`plans/2026-05-22-chromecast-player-split.md` · **Commits:** `02df2477`..`1ea7f0f4` - -Decomposed the 1428-line god-component into a 574-line orchestrator + 6 -presentational components (`components/casting/player/`) + 4 hooks -(`useCastPlayerItem`, `useCastEpisodes`, `useCastDismissGesture`, -`useCastPlayerProgress`). Purely structural — zero behaviour change. - -**Needs manual re-test:** the cast player has no unit tests; re-test cast + episode -playback, track switching, scrub/trickplay, dismiss — behaviour must match pre-split. - ---- - -## 6. Sub-project D — Session reporting & remote control - -**Spec:** `specs/2026-05-22-chromecast-session-remote-control-design.md` · **Plan:** -`plans/2026-05-22-chromecast-session-remote-control.md` · **Commits:** -`288b390e`..`8b94f491` - -- Cast sessions now report the real `PlayMethod` (`Transcode`/`DirectPlay`). -- Episode Previous/Next buttons render only when an adjacent episode exists. -- `loadEpisode`/`currentItem` stale-flash race fixed via `loadingEpisodeId`. -- **App-wide Jellyfin remote control:** a `PlaybackController` contract - (`utils/playback/playbackController.ts`); a pure WS-message mapper - (`utils/playback/remoteCommands.ts`, unit-tested); `hooks/useRemoteControl.ts` - dispatches to whichever player (cast / native video / music) is registered. - `WebSocketProvider` advertises the commands and routes the messages. - -**Needs manual test:** from the Jellyfin dashboard's active-session panel — pause / -stop / seek / next / volume / "send message" against the cast and the native player. - ---- - -## 7. Pending work (queue) - -- **UX player sub-project** — ✅ done (see the UX player row in §1; spec/plan - `2026-05-22-chromecast-ux-player`). Trickplay truncation, time display, mini-player - trickplay + stop button all fixed. Remaining there: the user hand-calibrates the - slider `panHitSlop` using the `DEBUG_TOUCH_ZONES` overlay (flag in - `utils/casting/debug.ts` — flip to `true`, calibrate, flip back). -- **Repo hygiene** — add `* text=auto eol=lf` to `.gitattributes` (fixes the ~124 - CRLF errors in `bun run check`); this is best done as a separate PR off `develop`, - since a `git add --renormalize` touches the whole tree. (The `BitRateSheet.tsx` - duplicate and the unused `liveProgress` export have already been removed.) -- **Custom Cast receiver** (deferred from sub-project A) — PR #1521 builds a custom - CAF receiver. Decided to defer; revisit as its own sub-project. It would own - subtitle rendering/styling (ASS, custom style — issues #1452, #1543) and cleaner - session integrity. Sender-side work so far does not block it. -- **Open review notes** (low severity, not fixed): - - D: the `PlaybackController` registry is last-write-wins; the spec's "cast - precedence" is not actually enforced (acceptable — one player at a time). - - D: remote next/previous works for cast but is a no-op for the native video - player (its episode navigation lives in `Controls`, not the screen). - - A: `getStreamUrl` still takes `MediaSources[0]` internally — fine because - `getPlaybackInfo` is called with `mediaSourceId`. - ---- - -## 8. How to resume - -1. **Test the branch.** Build from `refactor-chromecast` (`bun run android`). Run the - sub-project C manual re-test and the sub-project D dashboard remote-control test. - Sub-project A's matrix is `docs/chromecast-test-matrix.md`. -2. **Push.** When satisfied, push `refactor-chromecast` to update draft PR #1402 - (46 commits unpushed). Decide first what to do with the two uncommitted files - (see §9). -3. **Continue.** Pick the next sub-project from §7 — likely the UX player sub-project. - Each sub-project follows the brainstorm → spec → plan → subagent-execution cycle; - specs/plans go in `docs/superpowers/`. - ---- - -## 9. Working-tree / repo notes - -- **Uncommitted, intentionally left for the user to decide:** - - `docs/chromecast-test-matrix.md` — modified to list the user's actual library - titles (anime/movies). If PR #1402 should not expose the personal library, revert - this file to its generic committed form before pushing. - - `scripts/find-test-media.ts` — untracked helper that queries Jellyfin and buckets - the library against the test matrix. Commit it if useful, or leave it local. -- **Commits:** this project does **not** use a `Co-Authored-By` trailer. -- **GPG:** committing requires a warm `gpg-agent` cache; `~/.gnupg/gpg-agent.conf` - was set to an 8h TTL. - ---- - -## 10. Key decisions - -- **#1367 is the source of truth for segment-skip** — backport flows chromecast → - #1367, never the reverse. -- **Custom receiver deferred** — the crash fixes (A) were decoupled from it; it is a - future sub-project, not a blocker. -- **Long-term goal:** support AirPlay and other cast protocols — the casting layer is - kept protocol-agnostic (`CastProtocol`). The user has an iOS phone and an - AirPlay-capable Samsung screen for testing. - ---- - -## 11. Player feature ideas (proposed, not yet scoped) - -Candidate enhancements for the cast player — each would be its own brainstorm → -spec → plan → execute cycle. The user reacted positively to all of these. - -1. **Autoplay next episode + countdown** — an "Next episode in 10s · Cancel" overlay - when an episode ends on the cast. The native player already has go-to-next-episode - countdown logic to port. -2. **Sleep timer** — "stop after this episode / in 30 min". -3. **Resume prompt** — "Resume at 12:34 / Start over" when casting a partially-watched - item. -4. **Chapter markers on the progress bar** — Jellyfin exposes chapters; show ticks + - tap-to-jump. -5. **OS media controls** — control the cast from the OS media notification / - lock screen. **Must be cross-platform:** Android media notification *and* iOS - Now Playing / Control Center. iOS infrastructure already exists - (`modules/mpv-player/ios/MPVNowPlayingManager.swift`) and there are existing PRs - touching the iOS mini-controller / now-playing — reference them before designing. -6. **SyncPlay on cast** (larger) — synchronised group viewing; a `feat/syncplay` - branch exists. - diff --git a/docs/superpowers/plans/2026-05-21-chromecast-profiles.md b/docs/superpowers/plans/2026-05-21-chromecast-profiles.md deleted file mode 100644 index 52006b209..000000000 --- a/docs/superpowers/plans/2026-05-21-chromecast-profiles.md +++ /dev/null @@ -1,1313 +0,0 @@ -# Chromecast Device Profiles & Capability Detection — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the two static Chromecast device profiles with per-device capability detection and a unified cast-load path, so casting stops crashing (status 2100, 5.1 audio, high bitrate) on all Chromecast generations. - -**Architecture:** A pure capability registry maps `device.modelName` to a `ChromecastCapabilities` object, falling back to a conservative baseline (H.264 / 1080p / 2-channel). A pure builder turns capabilities into a Jellyfin `DeviceProfile`. A single `loadCastMedia()` function owns the `getStreamUrl → buildCastMediaInfo → loadMedia` sequence, with a downgrade-on-failure retry. Three duplicated call sites are rewired to it. - -**Tech Stack:** TypeScript (strict), React Native / Expo, `@jellyfin/sdk`, `react-native-google-cast`, Jotai. Tests use Bun's native test runner (`bun test`) — no new dependency. - -**Spec:** `docs/superpowers/specs/2026-05-21-chromecast-profiles-design.md` - ---- - -## File Structure - -| File | Responsibility | Task | -|---|---|---| -| `utils/casting/capabilities.ts` | Capability type, conservative default, model registry, `detectCapabilities()` | 1 | -| `utils/casting/capabilities.test.ts` | Unit tests for capability detection | 1 | -| `utils/casting/buildProfile.ts` | `buildChromecastProfile()` → `DeviceProfile` | 2 | -| `utils/casting/buildProfile.test.ts` | Unit tests for the profile builder | 2 | -| `utils/atoms/settings.ts` | New `chromecastProfile` + `chromecastMaxBitrate` settings | 3 | -| `utils/casting/castErrors.ts` | `isLoadFailedError()` — pure error classifier | 4 | -| `utils/casting/castErrors.test.ts` | Unit tests for the error classifier | 4 | -| `utils/casting/castLoad.ts` | Unified `loadCastMedia()` | 4 | -| `utils/casting/mediaInfo.ts` | `buildCastMediaInfo()` gains `playSessionId` | 4 | -| `components/PlayButton.tsx` | Rewired to `loadCastMedia()` | 5 | -| `app/(auth)/casting-player.tsx` | `reloadWithSettings` / `loadEpisode` rewired | 5 | -| `hooks/useCasting.ts` | Progress reporting reads real `playSessionId` | 5 | -| `utils/profiles/chromecast.ts`, `chromecasth265.ts` | **Deleted** | 5 | -| `components/settings/ChromecastSettings.tsx` | Profile-mode dropdown + max-bitrate field | 6 | -| `docs/chromecast-test-matrix.md` | Manual cast verification matrix | 7 | - ---- - -## Task 1: Capability model, conservative default & registry - -**Files:** -- Create: `utils/casting/capabilities.ts` -- Test: `utils/casting/capabilities.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `utils/casting/capabilities.test.ts`: - -```ts -import { describe, expect, test } from "bun:test"; -import { - CONSERVATIVE_CAPABILITIES, - detectCapabilities, -} from "./capabilities"; - -describe("detectCapabilities", () => { - test("unknown device falls back to the conservative baseline", () => { - const caps = detectCapabilities( - { modelName: "Some Unknown TV" }, - { profileMode: "auto" }, - ); - expect(caps).toEqual(CONSERVATIVE_CAPABILITIES); - }); - - test("null device falls back to the conservative baseline", () => { - const caps = detectCapabilities(null, { profileMode: "auto" }); - expect(caps).toEqual(CONSERVATIVE_CAPABILITIES); - }); - - test('plain "Chromecast" (gen 1/2/3) gets the conservative baseline', () => { - const caps = detectCapabilities( - { modelName: "Chromecast" }, - { profileMode: "auto" }, - ); - expect(caps.hevc).toBe(false); - expect(caps.maxResolution).toBe(1080); - expect(caps.maxAudioChannels).toBe(2); - }); - - test("Chromecast Ultra is recognised with HEVC + 4K", () => { - const caps = detectCapabilities( - { modelName: "Chromecast Ultra" }, - { profileMode: "auto" }, - ); - expect(caps.hevc).toBe(true); - expect(caps.maxResolution).toBe(2160); - }); - - test('"force-h264" override disables HEVC even on a capable device', () => { - const caps = detectCapabilities( - { modelName: "Chromecast Ultra" }, - { profileMode: "force-h264" }, - ); - expect(caps.hevc).toBe(false); - expect(caps.hevc10bit).toBe(false); - }); - - test('"force-hevc" override enables HEVC on the conservative baseline', () => { - const caps = detectCapabilities( - { modelName: "Chromecast" }, - { profileMode: "force-hevc" }, - ); - expect(caps.hevc).toBe(true); - }); - - test("maxBitrate override clamps but never raises the bitrate", () => { - const lowered = detectCapabilities( - { modelName: "Chromecast" }, - { profileMode: "auto", maxBitrate: 3_000_000 }, - ); - expect(lowered.maxVideoBitrate).toBe(3_000_000); - - const raised = detectCapabilities( - { modelName: "Chromecast" }, - { profileMode: "auto", maxBitrate: 999_000_000 }, - ); - expect(raised.maxVideoBitrate).toBe(CONSERVATIVE_CAPABILITIES.maxVideoBitrate); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `bun test utils/casting/capabilities.test.ts` -Expected: FAIL — `Export named 'detectCapabilities' not found` (module does not exist yet). - -- [ ] **Step 3: Write the implementation** - -Create `utils/casting/capabilities.ts`: - -```ts -/** - * Chromecast device capability detection. - * - * The Cast SDK exposes a device's `modelName` but no codec-level capability API. - * We map known model names to a capability profile and fall back to a conservative - * baseline (H.264 / 1080p / stereo) for anything unrecognised — a baseline that - * cannot produce an unplayable stream on any Cast receiver. - */ - -/** Profile selection mode, surfaced as an advanced setting. */ -export type ChromecastProfileMode = "auto" | "force-hevc" | "force-h264"; - -export interface ChromecastCapabilities { - /** HEVC 8-bit (Main profile) decode support. */ - hevc: boolean; - /** HEVC 10-bit (Main10) decode support. */ - hevc10bit: boolean; - /** Maximum video resolution height. */ - maxResolution: 1080 | 2160; - /** Maximum video bitrate in bits per second. */ - maxVideoBitrate: number; - /** Maximum audio channels the receiver can output. */ - maxAudioChannels: number; -} - -/** Minimal shape we need from the Cast SDK `Device` — keeps this module import-free. */ -interface DeviceLike { - modelName?: string; -} - -/** Overrides derived from user settings. */ -export interface CapabilityOverrides { - profileMode: ChromecastProfileMode; - /** Optional manual cap in bits per second. */ - maxBitrate?: number; -} - -/** - * Baseline for a 1st/2nd/3rd-gen Chromecast and any unrecognised device. - * `maxVideoBitrate` is an initial estimate — see docs/chromecast-test-matrix.md. - */ -export const CONSERVATIVE_CAPABILITIES: ChromecastCapabilities = { - hevc: false, - hevc10bit: false, - maxResolution: 1080, - maxVideoBitrate: 8_000_000, - maxAudioChannels: 2, -}; - -/** Known Cast devices keyed by `Device.modelName`. Unlisted models stay conservative. */ -const CHROMECAST_REGISTRY: Record = { - "Chromecast Ultra": { - hevc: true, - hevc10bit: false, - maxResolution: 2160, - maxVideoBitrate: 20_000_000, - maxAudioChannels: 6, - }, - "Chromecast with Google TV": { - hevc: true, - hevc10bit: true, - maxResolution: 2160, - maxVideoBitrate: 20_000_000, - maxAudioChannels: 6, - }, - "Google TV Streamer": { - hevc: true, - hevc10bit: true, - maxResolution: 2160, - maxVideoBitrate: 25_000_000, - maxAudioChannels: 8, - }, -}; - -/** - * Resolve the effective capabilities for a Cast device. - * Registry lookup → conservative fallback → user overrides applied last. - */ -export const detectCapabilities = ( - device: DeviceLike | null, - overrides: CapabilityOverrides, -): ChromecastCapabilities => { - const base = - (device?.modelName && CHROMECAST_REGISTRY[device.modelName]) || - CONSERVATIVE_CAPABILITIES; - - const caps: ChromecastCapabilities = { ...base }; - - if (overrides.profileMode === "force-hevc") { - caps.hevc = true; - } else if (overrides.profileMode === "force-h264") { - caps.hevc = false; - caps.hevc10bit = false; - } - - if (overrides.maxBitrate && overrides.maxBitrate > 0) { - caps.maxVideoBitrate = Math.min(caps.maxVideoBitrate, overrides.maxBitrate); - } - - return caps; -}; -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `bun test utils/casting/capabilities.test.ts` -Expected: PASS — 7 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add utils/casting/capabilities.ts utils/casting/capabilities.test.ts -git commit -m "feat(casting): add Chromecast capability detection" -``` - ---- - -## Task 2: Profile builder - -**Files:** -- Create: `utils/casting/buildProfile.ts` -- Test: `utils/casting/buildProfile.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `utils/casting/buildProfile.test.ts`: - -```ts -import { describe, expect, test } from "bun:test"; -import { buildChromecastProfile } from "./buildProfile"; -import { CONSERVATIVE_CAPABILITIES } from "./capabilities"; - -describe("buildChromecastProfile", () => { - test("conservative caps produce an H.264-only video codec list", () => { - const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES); - const videoCodecProfile = profile.CodecProfiles?.find( - (c) => c.Type === "Video", - ); - expect(videoCodecProfile?.Codec).toBe("h264"); - }); - - test("HEVC-capable caps include hevc in the video codec list", () => { - const profile = buildChromecastProfile({ - ...CONSERVATIVE_CAPABILITIES, - hevc: true, - }); - const videoCodecProfile = profile.CodecProfiles?.find( - (c) => c.Type === "Video", - ); - expect(videoCodecProfile?.Codec).toContain("hevc"); - }); - - test("maxVideoBitrate drives MaxStreamingBitrate", () => { - const profile = buildChromecastProfile({ - ...CONSERVATIVE_CAPABILITIES, - maxVideoBitrate: 5_000_000, - }); - expect(profile.MaxStreamingBitrate).toBe(5_000_000); - }); - - test("maxAudioChannels constrains transcoding profiles", () => { - const profile = buildChromecastProfile(CONSERVATIVE_CAPABILITIES); - const videoTranscode = profile.TranscodingProfiles?.find( - (p) => p.Type === "Video", - ); - expect(videoTranscode?.MaxAudioChannels).toBe("2"); - }); - - test("non-10bit HEVC caps add a video bit-depth condition", () => { - const profile = buildChromecastProfile({ - ...CONSERVATIVE_CAPABILITIES, - hevc: true, - hevc10bit: false, - }); - const videoCodecProfile = profile.CodecProfiles?.find( - (c) => c.Type === "Video", - ); - const bitDepthCondition = videoCodecProfile?.Conditions?.find( - (cond) => cond.Property === "VideoBitDepth", - ); - expect(bitDepthCondition).toBeDefined(); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `bun test utils/casting/buildProfile.test.ts` -Expected: FAIL — `Export named 'buildChromecastProfile' not found`. - -- [ ] **Step 3: Write the implementation** - -Create `utils/casting/buildProfile.ts`: - -```ts -import type { - DeviceProfile, - ProfileCondition, -} from "@jellyfin/sdk/lib/generated-client/models"; -import type { ChromecastCapabilities } from "./capabilities"; - -/** - * Build a Jellyfin `DeviceProfile` for a Chromecast from its detected capabilities. - * Replaces the former static `chromecast.ts` / `chromecasth265.ts` profiles. - */ -export const buildChromecastProfile = ( - caps: ChromecastCapabilities, -): DeviceProfile => { - const videoCodecs = caps.hevc ? "hevc,h264" : "h264"; - const maxHeight = caps.maxResolution === 2160 ? "2160" : "1080"; - const maxChannels = String(caps.maxAudioChannels); - - const videoConditions: ProfileCondition[] = [ - { - Condition: "LessThanEqual", - Property: "Height", - Value: maxHeight, - IsRequired: false, - }, - ]; - // When HEVC is allowed but 10-bit is not, force the server to transcode - // 10-bit sources down to 8-bit. - if (caps.hevc && !caps.hevc10bit) { - videoConditions.push({ - Condition: "LessThanEqual", - Property: "VideoBitDepth", - Value: "8", - IsRequired: false, - }); - } - - return { - Name: "Chromecast Video Profile", - MaxStreamingBitrate: caps.maxVideoBitrate, - MaxStaticBitrate: caps.maxVideoBitrate, - MusicStreamingTranscodingBitrate: 384000, - CodecProfiles: [ - { - Type: "Video", - Codec: videoCodecs, - Conditions: videoConditions, - }, - { - Type: "Audio", - Codec: "aac,mp3,flac,opus,vorbis", - // Force transcode of multichannel audio the receiver cannot output. - Conditions: [ - { - Condition: "LessThanEqual", - Property: "AudioChannels", - Value: maxChannels, - IsRequired: false, - }, - ], - }, - ], - ContainerProfiles: [], - DirectPlayProfiles: [ - { - Container: caps.hevc ? "mp4,mkv" : "mp4", - Type: "Video", - VideoCodec: videoCodecs, - AudioCodec: "aac,mp3,opus,vorbis", - }, - { Container: "mp3", Type: "Audio" }, - { Container: "aac", Type: "Audio" }, - { Container: "flac", Type: "Audio" }, - { Container: "wav", Type: "Audio" }, - ], - TranscodingProfiles: [ - { - Container: "ts", - Type: "Video", - VideoCodec: videoCodecs, - AudioCodec: "aac,mp3", - Protocol: "hls", - Context: "Streaming", - MaxAudioChannels: maxChannels, - MinSegments: 2, - BreakOnNonKeyFrames: true, - }, - { - Container: "mp3", - Type: "Audio", - AudioCodec: "mp3", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: maxChannels, - }, - { - Container: "aac", - Type: "Audio", - AudioCodec: "aac", - Protocol: "http", - Context: "Streaming", - MaxAudioChannels: maxChannels, - }, - ], - SubtitleProfiles: [{ Format: "vtt", Method: "Encode" }], - }; -}; -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `bun test utils/casting/buildProfile.test.ts` -Expected: PASS — 5 tests pass. - -- [ ] **Step 5: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 6: Commit** - -```bash -git add utils/casting/buildProfile.ts utils/casting/buildProfile.test.ts -git commit -m "feat(casting): add Chromecast device profile builder" -``` - ---- - -## Task 3: Settings — add new fields (additive) - -**Files:** -- Modify: `utils/atoms/settings.ts` - -This task is purely additive. `enableH265ForChromecast` is left in place until Task 6, so type-checking stays green throughout. - -- [ ] **Step 1: Add the new fields to the `Settings` type** - -In `utils/atoms/settings.ts`, find this line (around line 183): - -```ts - enableH265ForChromecast: boolean; -``` - -Replace it with: - -```ts - enableH265ForChromecast: boolean; - /** Chromecast profile selection mode. "auto" detects per device. */ - chromecastProfile: ChromecastProfileMode; - /** Optional manual Chromecast video bitrate cap, in bits per second. */ - chromecastMaxBitrate?: number; -``` - -- [ ] **Step 2: Import the `ChromecastProfileMode` type** - -At the top of `utils/atoms/settings.ts`, with the other imports, add: - -```ts -import type { ChromecastProfileMode } from "@/utils/casting/capabilities"; -``` - -- [ ] **Step 3: Add defaults** - -In `utils/atoms/settings.ts`, find this line in `defaultValues` (around line 274): - -```ts - enableH265ForChromecast: false, -``` - -Replace it with: - -```ts - enableH265ForChromecast: false, - chromecastProfile: "auto", - chromecastMaxBitrate: undefined, -``` - -- [ ] **Step 4: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 5: Commit** - -```bash -git add utils/atoms/settings.ts -git commit -m "feat(casting): add chromecastProfile and chromecastMaxBitrate settings" -``` - ---- - -## Task 4: Unified `loadCastMedia()` - -**Files:** -- Create: `utils/casting/castErrors.ts`, `utils/casting/castLoad.ts` -- Test: `utils/casting/castErrors.test.ts` -- Modify: `utils/casting/mediaInfo.ts` - -- [ ] **Step 1: Add `playSessionId` to `buildCastMediaInfo`** - -In `utils/casting/mediaInfo.ts`, find the parameter object of `buildCastMediaInfo`: - -```ts - /** Set true for live TV streams to use MediaStreamType.LIVE. */ - isLive?: boolean; -}) => { -``` - -Replace it with: - -```ts - /** Set true for live TV streams to use MediaStreamType.LIVE. */ - isLive?: boolean; - /** Jellyfin PlaySessionId, embedded in customData for progress reporting. */ - playSessionId?: string; -}) => { -``` - -- [ ] **Step 2: Destructure and embed `playSessionId`** - -In `utils/casting/mediaInfo.ts`, find the function signature opening: - -```ts -export const buildCastMediaInfo = ({ - item, - streamUrl, - api, - contentType, - isLive = false, -}: { -``` - -Replace it with: - -```ts -export const buildCastMediaInfo = ({ - item, - streamUrl, - api, - contentType, - isLive = false, - playSessionId, -}: { -``` - -Then find the `slimCustomData` declaration: - -```ts - const slimCustomData: Partial = { - Id: item.Id, -``` - -Replace it with: - -```ts - const slimCustomData: Partial & { playSessionId?: string } = { - playSessionId, - Id: item.Id, -``` - -- [ ] **Step 3: Write the failing test** - -Create `utils/casting/castErrors.test.ts`: - -```ts -import { describe, expect, test } from "bun:test"; -import { isLoadFailedError } from "./castErrors"; - -describe("isLoadFailedError", () => { - test("recognises a status 2100 error message", () => { - const error = new Error( - "java.lang.Exception: Media control channel status code 2100", - ); - expect(isLoadFailedError(error)).toBe(true); - }); - - test("returns false for unrelated errors", () => { - expect(isLoadFailedError(new Error("network timeout"))).toBe(false); - }); - - test("handles non-Error values without throwing", () => { - expect(isLoadFailedError("status code 2100")).toBe(true); - expect(isLoadFailedError(null)).toBe(false); - }); -}); -``` - -- [ ] **Step 4: Run the test to verify it fails** - -Run: `bun test utils/casting/castErrors.test.ts` -Expected: FAIL — `Cannot find module './castErrors'`. - -- [ ] **Step 5: Create the error classifier** - -Create `utils/casting/castErrors.ts`: - -```ts -/** - * Cast load error classification. Kept dependency-free so it is unit-testable - * without pulling React Native modules into the test runtime. - */ - -/** True when an error is a Cast "LOAD_FAILED" (status 2100) rejection. */ -export const isLoadFailedError = (error: unknown): boolean => { - if (error == null) return false; - const message = error instanceof Error ? error.message : String(error); - return message.includes("2100"); -}; -``` - -- [ ] **Step 6: Run the test to verify it passes** - -Run: `bun test utils/casting/castErrors.test.ts` -Expected: PASS — 3 tests pass. - -- [ ] **Step 7: Write `loadCastMedia`** - -Create `utils/casting/castLoad.ts`: - -```ts -/** - * Unified Chromecast media loading. - * - * Owns the getStreamUrl → buildCastMediaInfo → loadMedia sequence that was - * previously duplicated across PlayButton and the casting player. Builds the - * device profile from detected capabilities and retries once with a forced - * conservative profile when the receiver rejects the initial load (status 2100). - */ - -import type { Api } from "@jellyfin/sdk"; -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import type { RemoteMediaClient } from "react-native-google-cast"; -import { buildChromecastProfile } from "@/utils/casting/buildProfile"; -import { - type ChromecastProfileMode, - detectCapabilities, -} from "@/utils/casting/capabilities"; -import { isLoadFailedError } from "@/utils/casting/castErrors"; -import { buildCastMediaInfo } from "@/utils/casting/mediaInfo"; -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; - -export interface CastLoadOptions { - audioStreamIndex?: number; - subtitleStreamIndex?: number; - maxBitrate?: number; - mediaSourceId?: string; - startPositionMs?: number; -} - -export interface CastLoadParams { - client: RemoteMediaClient; - /** Cast device — only `modelName` is read, for capability detection. */ - device: { modelName?: string } | null; - api: Api; - item: BaseItemDto; - userId: string; - profileMode: ChromecastProfileMode; - /** Manual bitrate cap from settings, in bits per second. */ - maxBitrateSetting?: number; - options?: CastLoadOptions; -} - -export type CastLoadResult = { ok: true } | { ok: false; error: unknown }; - -/** - * Resolve the default audio stream index for an item. - * Fixes the previous `audioStreamIndex = 0` default, which selected the video stream. - */ -export const resolveDefaultAudioIndex = ( - item: BaseItemDto, - mediaSourceId?: string, -): number | undefined => { - const source = mediaSourceId - ? item.MediaSources?.find((s) => s.Id === mediaSourceId) - : item.MediaSources?.[0]; - if (source?.DefaultAudioStreamIndex != null) { - return source.DefaultAudioStreamIndex; - } - const audio = - item.MediaStreams?.find((s) => s.Type === "Audio" && s.IsDefault) ?? - item.MediaStreams?.find((s) => s.Type === "Audio"); - return audio?.Index ?? undefined; -}; - -const attemptLoad = async ( - params: CastLoadParams, - caps: Parameters[0], -): Promise => { - const { api, item, userId, client, options } = params; - const profile = buildChromecastProfile(caps); - const audioStreamIndex = - options?.audioStreamIndex ?? - resolveDefaultAudioIndex(item, options?.mediaSourceId); - const startPositionMs = options?.startPositionMs ?? 0; - - const data = await getStreamUrl({ - api, - item, - userId, - startTimeTicks: Math.floor(startPositionMs * 10000), - deviceProfile: profile, - audioStreamIndex, - subtitleStreamIndex: options?.subtitleStreamIndex, - maxStreamingBitrate: options?.maxBitrate, - mediaSourceId: options?.mediaSourceId, - }); - - if (!data?.url) { - throw new Error("getStreamUrl returned no URL"); - } - - await client.loadMedia({ - mediaInfo: buildCastMediaInfo({ - item, - streamUrl: data.url, - api, - playSessionId: data.sessionId ?? undefined, - }), - startTime: startPositionMs / 1000, - }); -}; - -/** - * Load media onto the connected Chromecast. - * On a status-2100 rejection, retries once with a forced conservative profile. - */ -export const loadCastMedia = async ( - params: CastLoadParams, -): Promise => { - const caps = detectCapabilities(params.device, { - profileMode: params.profileMode, - maxBitrate: params.maxBitrateSetting, - }); - - try { - await attemptLoad(params, caps); - return { ok: true }; - } catch (error) { - if (!isLoadFailedError(error)) { - return { ok: false, error }; - } - // Downgrade-on-failure: one retry with the safest possible profile. - try { - const fallback = detectCapabilities(params.device, { - profileMode: "force-h264", - }); - await attemptLoad(params, { - ...fallback, - maxVideoBitrate: 4_000_000, - maxAudioChannels: 2, - }); - return { ok: true }; - } catch (retryError) { - return { ok: false, error: retryError }; - } - } -}; -``` - -- [ ] **Step 8: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 9: Commit** - -```bash -git add utils/casting/castErrors.ts utils/casting/castErrors.test.ts utils/casting/castLoad.ts utils/casting/mediaInfo.ts -git commit -m "feat(casting): add unified loadCastMedia with downgrade-on-failure" -``` - ---- - -## Task 5: Rewire call sites & remove old profiles - -**Files:** -- Modify: `components/PlayButton.tsx` -- Modify: `app/(auth)/casting-player.tsx` -- Modify: `hooks/useCasting.ts` -- Delete: `utils/profiles/chromecast.ts`, `utils/profiles/chromecasth265.ts` - -- [ ] **Step 1: Rewire `PlayButton.tsx` imports** - -In `components/PlayButton.tsx`, find these import lines (around lines 36-38): - -```ts -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; -``` - -Replace them with: - -```ts -import { loadCastMedia } from "@/utils/casting/castLoad"; -``` - -> If `getStreamUrl` is still used elsewhere in `PlayButton.tsx`, keep its import. -> Verify with: `grep -n "getStreamUrl" components/PlayButton.tsx` — if Step 2 removes -> the only use, the import above is correctly dropped. - -- [ ] **Step 2: Replace the cast-load block in `PlayButton.tsx`** - -In `components/PlayButton.tsx`, replace the entire block from `// Check if user wants H265 for Chromecast` through the closing of its `try`/`catch` (currently lines ~141-219) with: - -```tsx - if (!api || !user?.Id || !item?.Id) { - console.warn("Missing parameters for Chromecast streaming"); - Alert.alert( - t("player.client_error"), - t("player.missing_parameters"), - ); - return; - } - - const startPositionMs = - (item.UserData?.PlaybackPositionTicks ?? 0) / 10000; - - const result = await loadCastMedia({ - client, - device: castDevice, - api, - item, - userId: user.Id, - profileMode: settings.chromecastProfile, - maxBitrateSetting: settings.chromecastMaxBitrate, - options: { - audioStreamIndex: selectedOptions.audioIndex, - subtitleStreamIndex: selectedOptions.subtitleIndex, - maxBitrate: selectedOptions.bitrate?.value, - mediaSourceId: selectedOptions.mediaSource?.Id, - startPositionMs, - }, - }); - - if (!result.ok) { - console.error("[PlayButton] cast load failed:", result.error); - Alert.alert( - t("player.client_error"), - t("player.could_not_create_stream_for_chromecast"), - ); - return; - } - - if (!isOpeningCurrentlyPlayingMedia) { - router.push("/casting-player"); - } -``` - -> `castDevice` must be available in this scope. If `PlayButton.tsx` does not already -> call `useCastDevice()`, add `const castDevice = useCastDevice();` with the other -> cast hooks near the top of the component, and import `useCastDevice` from -> `react-native-google-cast`. Verify the existing cast imports with: -> `grep -n "react-native-google-cast" components/PlayButton.tsx`. - -- [ ] **Step 3: Verify `PlayButton.tsx` types** - -Run: `bun run typecheck` -Expected: PASS for `PlayButton.tsx`. (Errors in `casting-player.tsx` are expected until Step 5.) - -- [ ] **Step 4: Rewire `casting-player.tsx` imports** - -In `app/(auth)/casting-player.tsx`, find these import lines (around lines 55-57): - -```ts -import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl"; -import { chromecast } from "@/utils/profiles/chromecast"; -import { chromecasth265 } from "@/utils/profiles/chromecasth265"; -``` - -Replace them with: - -```ts -import { loadCastMedia } from "@/utils/casting/castLoad"; -``` - -> Keep the `getStreamUrl` import only if `grep -n "getStreamUrl" app/(auth)/casting-player.tsx` -> shows uses outside `reloadWithSettings` / `loadEpisode`. - -- [ ] **Step 5: Replace `reloadWithSettings` body** - -In `app/(auth)/casting-player.tsx`, replace the body of the `reloadWithSettings` -`useCallback` (the `try { ... } catch { ... }` block, currently lines ~294-341) with: - -```tsx - try { - const currentPosition = mediaStatus?.streamPosition ?? 0; - - let resolvedSubtitleIndex: number | undefined; - if (options.subtitleIndex === undefined) { - resolvedSubtitleIndex = selectedSubtitleTrackIndex ?? undefined; - } else if (options.subtitleIndex === null) { - resolvedSubtitleIndex = -1; - } else { - resolvedSubtitleIndex = options.subtitleIndex; - } - - const result = await loadCastMedia({ - client: remoteMediaClient, - device: castDevice, - api, - item: currentItem, - userId: user.Id, - profileMode: settings.chromecastProfile, - maxBitrateSetting: settings.chromecastMaxBitrate, - options: { - audioStreamIndex: - options.audioIndex ?? selectedAudioTrackIndex ?? undefined, - subtitleStreamIndex: resolvedSubtitleIndex, - maxBitrate: options.bitrateValue, - startPositionMs: currentPosition * 1000, - }, - }); - - if (!result.ok) { - console.error("[Casting Player] Failed to reload stream:", result.error); - } - } catch (error) { - console.error("[Casting Player] Failed to reload stream:", error); - } -``` - -Then update the `useCallback` dependency array of `reloadWithSettings`: remove -`settings.enableH265ForChromecast` and add `settings.chromecastProfile`, -`settings.chromecastMaxBitrate`, and `castDevice`. The array becomes: - -```tsx - [ - api, - user?.Id, - currentItem, - remoteMediaClient, - castDevice, - mediaStatus?.streamPosition, - settings.chromecastProfile, - settings.chromecastMaxBitrate, - selectedAudioTrackIndex, - selectedSubtitleTrackIndex, - ], -``` - -- [ ] **Step 6: Replace `loadEpisode` body** - -In `app/(auth)/casting-player.tsx`, replace the body of the `loadEpisode` -`useCallback` (the `try { ... } catch { ... }` block, currently lines ~360-391) with: - -```tsx - try { - const startPositionMs = - (episode.UserData?.PlaybackPositionTicks ?? 0) / 10000; - - const result = await loadCastMedia({ - client: remoteMediaClient, - device: castDevice, - api, - item: episode, - userId: user.Id, - profileMode: settings.chromecastProfile, - maxBitrateSetting: settings.chromecastMaxBitrate, - options: { startPositionMs }, - }); - - if (!result.ok) { - console.error( - "[Casting Player] Failed to load episode:", - result.error, - ); - return; - } - - setSelectedAudioTrackIndex(null); - setSelectedSubtitleTrackIndex(null); - } catch (error) { - console.error("[Casting Player] Failed to load episode:", error); - } -``` - -Then update the `loadEpisode` dependency array: remove -`settings.enableH265ForChromecast`, add `settings.chromecastProfile`, -`settings.chromecastMaxBitrate`, `castDevice`. The array becomes: - -```tsx - [ - api, - user?.Id, - remoteMediaClient, - castDevice, - settings.chromecastProfile, - settings.chromecastMaxBitrate, - ], -``` - -> `castDevice` must exist in `casting-player.tsx`. If it does not, add -> `const castDevice = useCastDevice();` near the other cast hooks and import -> `useCastDevice` from `react-native-google-cast`. Verify with: -> `grep -n "useCastDevice\|react-native-google-cast" app/(auth)/casting-player.tsx`. - -- [ ] **Step 7: Fix `PlaySessionId` in `useCasting.ts`** - -In `hooks/useCasting.ts`, the progress reporting uses `mediaStatus?.mediaInfo?.contentId` -as `PlaySessionId` in two places (`reportPlaybackStart` and `reportPlaybackProgress`). -Add this helper near the top of the `useCasting` function body, after the hooks: - -```ts - // Real Jellyfin PlaySessionId, embedded in customData by buildCastMediaInfo. - const playSessionId = - (mediaStatus?.mediaInfo?.customData as { playSessionId?: string } | undefined) - ?.playSessionId ?? mediaStatus?.mediaInfo?.contentId; -``` - -Then replace both occurrences of: - -```ts - PlaySessionId: mediaStatus?.mediaInfo?.contentId, -``` - -with: - -```ts - PlaySessionId: playSessionId, -``` - -Add `playSessionId` to the dependency array of the progress-reporting `useEffect` -(replace `mediaStatus?.mediaInfo?.contentId` in that array with `playSessionId`). - -- [ ] **Step 8: Delete the old profile files** - -```bash -git rm utils/profiles/chromecast.ts utils/profiles/chromecasth265.ts -``` - -- [ ] **Step 9: Verify types and lint** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bun run check` -Expected: PASS — no errors. - -> If typecheck reports a remaining import of the deleted profile files, find it with -> `grep -rn "profiles/chromecast" --include="*.ts" --include="*.tsx" .` and remove it. - -- [ ] **Step 10: Commit** - -```bash -git add components/PlayButton.tsx "app/(auth)/casting-player.tsx" hooks/useCasting.ts utils/profiles/ -git commit -m "refactor(casting): route all cast loads through loadCastMedia" -``` - ---- - -## Task 6: Settings UI & remove `enableH265ForChromecast` - -**Files:** -- Modify: `components/settings/ChromecastSettings.tsx` -- Modify: `utils/atoms/settings.ts` - -- [ ] **Step 1: Rewrite `ChromecastSettings.tsx`** - -Replace the entire contents of `components/settings/ChromecastSettings.tsx` with: - -```tsx -import { Ionicons } from "@expo/vector-icons"; -import { useMemo } from "react"; -import { View } from "react-native"; -import type { ChromecastProfileMode } from "@/utils/casting/capabilities"; -import { useSettings } from "@/utils/atoms/settings"; -import { Text } from "../common/Text"; -import { ListGroup } from "../list/ListGroup"; -import { ListItem } from "../list/ListItem"; -import { PlatformDropdown } from "../PlatformDropdown"; - -const PROFILE_LABELS: Record = { - auto: "Automatic (recommended)", - "force-hevc": "Force HEVC / H265", - "force-h264": "Force H264", -}; - -export const ChromecastSettings: React.FC = ({ ...props }) => { - const { settings, updateSettings } = useSettings(); - - const profileOptions = useMemo( - () => [ - { - options: ( - ["auto", "force-hevc", "force-h264"] as ChromecastProfileMode[] - ).map((mode) => ({ - type: "radio" as const, - label: PROFILE_LABELS[mode], - value: mode, - selected: (settings.chromecastProfile ?? "auto") === mode, - onPress: () => updateSettings({ chromecastProfile: mode }), - })), - }, - ], - [settings.chromecastProfile, updateSettings], - ); - - return ( - - - - - - {PROFILE_LABELS[settings.chromecastProfile ?? "auto"]} - - - - } - /> - - - - ); -}; -``` - -> The max-bitrate setting (`chromecastMaxBitrate`) is intentionally not surfaced in -> the UI yet — it has no numeric-input ListItem pattern in this codebase, and the -> spec calibrates the conservative default via the test matrix first. The setting -> exists and is honoured by `loadCastMedia`; a UI control for it belongs to a later -> UX sub-project. - -- [ ] **Step 2: Remove `enableH265ForChromecast` from the settings type** - -In `utils/atoms/settings.ts`, find: - -```ts - enableH265ForChromecast: boolean; - /** Chromecast profile selection mode. "auto" detects per device. */ - chromecastProfile: ChromecastProfileMode; -``` - -Replace it with: - -```ts - /** Chromecast profile selection mode. "auto" detects per device. */ - chromecastProfile: ChromecastProfileMode; -``` - -- [ ] **Step 3: Remove the `enableH265ForChromecast` default** - -In `utils/atoms/settings.ts`, find: - -```ts - enableH265ForChromecast: false, - chromecastProfile: "auto", -``` - -Replace it with: - -```ts - chromecastProfile: "auto", -``` - -- [ ] **Step 4: Verify no readers remain** - -Run: `grep -rn "enableH265ForChromecast" --include="*.ts" --include="*.tsx" .` -Expected: no matches (outside `node_modules`). - -- [ ] **Step 5: Verify types and lint** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bun run check` -Expected: PASS — no errors. - -- [ ] **Step 6: Commit** - -```bash -git add components/settings/ChromecastSettings.tsx utils/atoms/settings.ts -git commit -m "feat(casting): replace H265 toggle with Chromecast profile selector" -``` - ---- - -## Task 7: Test matrix document - -**Files:** -- Create: `docs/chromecast-test-matrix.md` - -- [ ] **Step 1: Create the test matrix** - -Create `docs/chromecast-test-matrix.md`: - -```markdown -# Chromecast Cast Test Matrix - -Manual verification for the device-profile work. Run each row by casting the -matching media from the app to a physical Chromecast and recording the result. - -**Test device:** ___________________ (model name as reported by the app) -**App build / commit:** ___________________ -**Date:** ___________________ - -## How to run - -1. Pick a library item matching the row's codec / audio / container. -2. Cast it. Note whether it direct-plays or transcodes (server logs show - `Video is being transcoded` vs `Video is being direct played`). -3. Record the load result: OK / 2100 / infinite-loading / other. - -## Matrix - -| # | Video codec | Audio | Container | Approx bitrate | Direct/Transcode | Result | Notes | -|---|---|---|---|---|---|---|---| -| 1 | H.264 1080p | AAC stereo | MP4 | ~4 Mb/s | | | | -| 2 | H.264 1080p | AAC stereo | MKV | ~8 Mb/s | | | | -| 3 | H.264 1080p | AAC 5.1 | MKV | ~8 Mb/s | | | | -| 4 | H.264 1080p | AC3 5.1 | MKV | ~10 Mb/s | | | | -| 5 | H.264 1080p | DTS 5.1 | MKV | ~12 Mb/s | | | | -| 6 | H.264 1080p | TrueHD 7.1 | MKV | ~20 Mb/s | | | | -| 7 | H.264 1080p | AAC stereo | MP4 | ~16 Mb/s | | | | -| 8 | H.264 1080p | AAC stereo | MP4 | source-max | | | | -| 9 | HEVC 8-bit | AAC stereo | MKV | ~10 Mb/s | | | | -| 10 | HEVC 10-bit | AAC stereo | MKV | ~15 Mb/s | | | | - -## Outcome - -- Highest video bitrate that loads reliably on the test device: ___________ - → update `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` in - `utils/casting/capabilities.ts` accordingly. -- Confirmed cause of issue #1423 (≤ 2 Mb/s): ___________ -- Confirmed cause of the 5.1 crash (#1085): ___________ -- Cases where downgrade-on-failure retry rescued playback: ___________ -``` - -- [ ] **Step 2: Commit** - -```bash -git add docs/chromecast-test-matrix.md -git commit -m "docs(casting): add Chromecast cast test matrix" -``` - ---- - -## Final verification - -- [ ] **Run the full check suite** - -Run: `bun test utils/casting/` -Expected: PASS — all `utils/casting/*.test.ts` suites pass. (Scope to the -`utils/casting/` path — the repo's unrelated legacy `ThemedText-test.tsx` uses -Jest APIs and is not part of this work.) - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bun run check` -Expected: PASS — no errors. - -- [ ] **Manual verification** - -Follow `docs/chromecast-test-matrix.md` on the Chromecast HD test device. The -previously failing movie (status 2100) must now play. Record results and, if -needed, adjust `CONSERVATIVE_CAPABILITIES.maxVideoBitrate` and commit that change. - ---- - -## Notes for the implementer - -- **Line numbers drift.** All line numbers are approximate anchors from the spec - date. Match on the quoted code, not the number. -- **`bun test`** is Bun's built-in runner — no config or dependency needed. It - resolves `tsconfig.json` path aliases. The three test files only import pure - modules (no React Native runtime), so they run cleanly under Bun. -- **Out of scope** (do not touch): audio/subtitle/quality track-switching UX, the - `casting-player.tsx` file split, episode navigation, the custom receiver. These - are later sub-projects. -- **`getStreamUrl` is not modified.** Spec §7 mentions selecting the requested - `MediaSource` instead of `[0]`. `loadCastMedia` passes `mediaSourceId` to - `getStreamUrl`, and Jellyfin's `getPlaybackInfo` returns the requested source — - the intent is satisfied without editing the shared `getStreamUrl` file. This is - a deliberate, low-risk deviation. -- After this plan: the queued prep task is reconciling the segment-skip code with - PR #1367 — separate from this plan. diff --git a/docs/superpowers/plans/2026-05-21-chromecast-track-switching.md b/docs/superpowers/plans/2026-05-21-chromecast-track-switching.md deleted file mode 100644 index 604450730..000000000 --- a/docs/superpowers/plans/2026-05-21-chromecast-track-switching.md +++ /dev/null @@ -1,1216 +0,0 @@ -# Chromecast Track Switching & Multi-Version — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make Chromecast audio / subtitle / quality / version switching reliable by carrying a single `CastSelection` source of truth in the cast `customData`, with an optimistic pending overlay. - -**Architecture:** A pure `CastSelection` model is resolved by `loadCastMedia` and embedded in the cast `customData`. The receiver echoes it back in `mediaStatus`, so the UI always reflects what is actually loaded. A `useCastSelection` hook holds the truth plus an optimistic `pending` selection during a reload. The casting player exposes real `MediaSources` as versions and reuses the app-wide `BITRATES` ladder for quality. - -**Tech Stack:** TypeScript (strict), React Native / Expo, `@jellyfin/sdk`, `react-native-google-cast`, Jotai. Tests use Bun's native runner (`bun test`). - -**Spec:** `docs/superpowers/specs/2026-05-21-chromecast-track-switching-design.md` - -**Environment note:** Windows checkout, `core.autocrlf=true` — a project-wide `bun run check` reports ~124 pre-existing CRLF errors unrelated to this work. The correctness gate is `bun run typecheck` (must be green) plus Biome on the files each task edits. - ---- - -## File Structure - -| File | Responsibility | Task | -|---|---|---| -| `utils/casting/types.ts` | Add the `CastSelection` interface | 1 | -| `utils/casting/selection.ts` | `resolveDefaultAudioIndex`, `resolveSelection`, `selectionsEqual` | 1 | -| `utils/casting/selection.test.ts` | Unit tests for the selection helpers | 1 | -| `utils/casting/castLoad.ts` | Import audio-index helper from `selection.ts`; resolve + embed `CastSelection` | 1, 3 | -| `utils/casting/mediaInfo.ts` | `buildCastMediaInfo` customData carries `selection` | 2 | -| `hooks/useCastSelection.ts` | New — selection state (customData truth + optimistic pending) | 4 | -| `components/BitrateSelector.tsx` | Expand the shared `BITRATES` ladder | 5 | -| `app/(auth)/casting-player.tsx` | Use `useCastSelection`; real versions + quality from `BITRATES` | 6 | -| `components/chromecast/ChromecastSettingsMenu.tsx` | Version / Quality / Audio / Subtitle sections from the selection | 6 | - ---- - -## Task 1: `CastSelection` model & selection helpers - -**Files:** -- Modify: `utils/casting/types.ts` -- Create: `utils/casting/selection.ts` -- Test: `utils/casting/selection.test.ts` -- Modify: `utils/casting/castLoad.ts` - -- [ ] **Step 1: Add the `CastSelection` type** - -In `utils/casting/types.ts`, append before the final line: - -```ts -/** - * What is currently loaded on the cast — the single source of truth for - * audio / subtitle / quality / version selection. - */ -export interface CastSelection { - /** MediaSource (version) id. */ - mediaSourceId: string; - /** Absolute MediaStream index of the audio track. */ - audioStreamIndex: number; - /** Absolute MediaStream index of the subtitle track; -1 = subtitles off. */ - subtitleStreamIndex: number; - /** Quality cap in bits/second; undefined = unconstrained. */ - maxBitrate?: number; -} -``` - -- [ ] **Step 2: Write the failing test** - -Create `utils/casting/selection.test.ts`: - -```ts -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { describe, expect, test } from "bun:test"; -import { - resolveDefaultAudioIndex, - resolveSelection, - selectionsEqual, -} from "./selection"; - -const item: BaseItemDto = { - Id: "item-1", - MediaSources: [ - { - Id: "src-a", - DefaultAudioStreamIndex: 2, - DefaultSubtitleStreamIndex: 3, - MediaStreams: [ - { Type: "Video", Index: 0 }, - { Type: "Audio", Index: 1, IsDefault: false }, - { Type: "Audio", Index: 2, IsDefault: true }, - { Type: "Subtitle", Index: 3 }, - ], - }, - { - Id: "src-b", - MediaStreams: [ - { Type: "Video", Index: 0 }, - { Type: "Audio", Index: 1, IsDefault: false }, - ], - }, - ], -}; - -describe("resolveDefaultAudioIndex", () => { - test("uses the source's DefaultAudioStreamIndex when present", () => { - expect(resolveDefaultAudioIndex(item, "src-a")).toBe(2); - }); - - test("falls back to the first audio stream when no default flag", () => { - expect(resolveDefaultAudioIndex(item, "src-b")).toBe(1); - }); -}); - -describe("resolveSelection", () => { - test("fills every field from server defaults of the first source", () => { - const sel = resolveSelection(item, {}); - expect(sel.mediaSourceId).toBe("src-a"); - expect(sel.audioStreamIndex).toBe(2); - expect(sel.subtitleStreamIndex).toBe(3); - expect(sel.maxBitrate).toBeUndefined(); - }); - - test("a partial overrides defaults and keeps the rest", () => { - const sel = resolveSelection(item, { audioStreamIndex: 1, maxBitrate: 4_000_000 }); - expect(sel.audioStreamIndex).toBe(1); - expect(sel.maxBitrate).toBe(4_000_000); - expect(sel.subtitleStreamIndex).toBe(3); - }); - - test("switching version resolves that version's defaults", () => { - const sel = resolveSelection(item, { mediaSourceId: "src-b" }); - expect(sel.mediaSourceId).toBe("src-b"); - expect(sel.audioStreamIndex).toBe(1); - expect(sel.subtitleStreamIndex).toBe(-1); - }); -}); - -describe("selectionsEqual", () => { - test("true for identical selections", () => { - const a = { mediaSourceId: "s", audioStreamIndex: 1, subtitleStreamIndex: -1 }; - expect(selectionsEqual(a, { ...a })).toBe(true); - }); - - test("false when any field differs", () => { - const a = { mediaSourceId: "s", audioStreamIndex: 1, subtitleStreamIndex: -1 }; - expect(selectionsEqual(a, { ...a, audioStreamIndex: 2 })).toBe(false); - }); -}); -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `bun test utils/casting/selection.test.ts` -Expected: FAIL — `Cannot find module './selection'`. - -- [ ] **Step 4: Write the implementation** - -Create `utils/casting/selection.ts`: - -```ts -/** - * Cast selection resolution — pure helpers, no React Native imports, so they - * are unit-testable under `bun test`. - */ - -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import type { CastSelection } from "@/utils/casting/types"; - -/** - * Resolve the default audio stream index for an item / media source. - * Prefers the source's `DefaultAudioStreamIndex`, then the first audio stream. - */ -export const resolveDefaultAudioIndex = ( - item: BaseItemDto, - mediaSourceId?: string, -): number | undefined => { - const source = mediaSourceId - ? item.MediaSources?.find((s) => s.Id === mediaSourceId) - : item.MediaSources?.[0]; - if (source?.DefaultAudioStreamIndex != null) { - return source.DefaultAudioStreamIndex; - } - const streams = source?.MediaStreams ?? item.MediaStreams; - const audio = - streams?.find((s) => s.Type === "Audio" && s.IsDefault) ?? - streams?.find((s) => s.Type === "Audio"); - return audio?.Index ?? undefined; -}; - -/** - * Complete a partial selection with the item's server defaults. - * Used on first load, on episode change, and when switching version. - */ -export const resolveSelection = ( - item: BaseItemDto, - partial: Partial, -): CastSelection => { - const mediaSourceId = - partial.mediaSourceId ?? item.MediaSources?.[0]?.Id ?? ""; - const source = item.MediaSources?.find((s) => s.Id === mediaSourceId); - - return { - mediaSourceId, - audioStreamIndex: - partial.audioStreamIndex ?? - resolveDefaultAudioIndex(item, mediaSourceId) ?? - -1, - subtitleStreamIndex: - partial.subtitleStreamIndex ?? source?.DefaultSubtitleStreamIndex ?? -1, - maxBitrate: partial.maxBitrate, - }; -}; - -/** True when two selections are equivalent — used to reconcile optimistic state. */ -export const selectionsEqual = ( - a: CastSelection, - b: CastSelection, -): boolean => - a.mediaSourceId === b.mediaSourceId && - a.audioStreamIndex === b.audioStreamIndex && - a.subtitleStreamIndex === b.subtitleStreamIndex && - a.maxBitrate === b.maxBitrate; -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `bun test utils/casting/selection.test.ts` -Expected: PASS — all suites pass. - -- [ ] **Step 6: Move `resolveDefaultAudioIndex` out of `castLoad.ts`** - -`castLoad.ts` currently defines and exports its own `resolveDefaultAudioIndex`. Delete that definition from `utils/casting/castLoad.ts` (the whole `export const resolveDefaultAudioIndex = ( ... );` block) and import it instead. Add to the imports of `castLoad.ts`: - -```ts -import { resolveDefaultAudioIndex } from "@/utils/casting/selection"; -``` - -> Place the import in alphabetical order among the `@/utils/casting/*` imports. -> The Biome pre-commit hook will fix ordering if needed. - -- [ ] **Step 7: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -> If typecheck reports `resolveDefaultAudioIndex` imported elsewhere from `castLoad`, -> repoint that import to `@/utils/casting/selection`. - -- [ ] **Step 8: Commit** - -```bash -git add utils/casting/types.ts utils/casting/selection.ts utils/casting/selection.test.ts utils/casting/castLoad.ts -git commit -m "feat(casting): add CastSelection model and resolution helpers" -``` - ---- - -## Task 2: customData carries the selection - -**Files:** -- Modify: `utils/casting/mediaInfo.ts` - -- [ ] **Step 1: Add the `selection` parameter** - -In `utils/casting/mediaInfo.ts`, add the import: - -```ts -import type { CastSelection } from "@/utils/casting/types"; -``` - -Find the parameter object's `playSessionId` entry: - -```ts - /** Jellyfin PlaySessionId, embedded in customData for progress reporting. */ - playSessionId?: string; -}) => { -``` - -Replace it with: - -```ts - /** Jellyfin PlaySessionId, embedded in customData for progress reporting. */ - playSessionId?: string; - /** Active track / quality / version selection, embedded in customData. */ - selection?: CastSelection; -}) => { -``` - -- [ ] **Step 2: Destructure and embed `selection`** - -Find the function signature opening and add `selection` to the destructured params: - -```ts -export const buildCastMediaInfo = ({ - item, - streamUrl, - api, - contentType, - isLive = false, - playSessionId, -}: { -``` - -Replace with: - -```ts -export const buildCastMediaInfo = ({ - item, - streamUrl, - api, - contentType, - isLive = false, - playSessionId, - selection, -}: { -``` - -Then find the `slimCustomData` declaration: - -```ts - const slimCustomData: Partial & { playSessionId?: string } = { - playSessionId, - Id: item.Id, -``` - -Replace it with: - -```ts - const slimCustomData: Partial & { - playSessionId?: string; - selection?: CastSelection; - } = { - playSessionId, - selection, - Id: item.Id, -``` - -- [ ] **Step 3: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 4: Commit** - -```bash -git add utils/casting/mediaInfo.ts -git commit -m "feat(casting): embed CastSelection in cast customData" -``` - ---- - -## Task 3: `loadCastMedia` resolves and embeds the selection - -**Files:** -- Modify: `utils/casting/castLoad.ts` - -- [ ] **Step 1: Import `resolveSelection`** - -In `utils/casting/castLoad.ts`, add to the imports: - -```ts -import { resolveDefaultAudioIndex, resolveSelection } from "@/utils/casting/selection"; -``` - -(This replaces the `resolveDefaultAudioIndex`-only import added in Task 1 — merge them into one line.) - -- [ ] **Step 2: Resolve and use the selection in `attemptLoad`** - -In `castLoad.ts`, the `attemptLoad` function currently computes `audioStreamIndex` -and calls `getStreamUrl` then `buildCastMediaInfo`. Replace the body of `attemptLoad` -(everything between the opening `{` and the closing `}` of the arrow function) with: - -```ts - const { api, item, userId, client, options } = params; - const profile = buildChromecastProfile(caps); - - const selection = resolveSelection(item, { - mediaSourceId: options?.mediaSourceId, - audioStreamIndex: options?.audioStreamIndex, - subtitleStreamIndex: options?.subtitleStreamIndex, - maxBitrate: options?.maxBitrate, - }); - - const startPositionMs = options?.startPositionMs ?? 0; - - const data = await getStreamUrl({ - api, - item, - userId, - startTimeTicks: Math.floor(startPositionMs * 10000), - deviceProfile: profile, - audioStreamIndex: selection.audioStreamIndex, - subtitleStreamIndex: selection.subtitleStreamIndex, - maxStreamingBitrate: selection.maxBitrate, - mediaSourceId: selection.mediaSourceId, - }); - - if (!data?.url) { - throw new Error("getStreamUrl returned no URL"); - } - - await client.loadMedia({ - mediaInfo: buildCastMediaInfo({ - item, - streamUrl: data.url, - api, - playSessionId: data.sessionId ?? undefined, - selection, - }), - startTime: startPositionMs / 1000, - }); -``` - -> `resolveDefaultAudioIndex` is now used only inside `resolveSelection`. After this -> change `castLoad.ts` no longer references `resolveDefaultAudioIndex` directly — if -> typecheck flags it as an unused import, remove it from the import in Step 1 and -> import only `resolveSelection`. - -- [ ] **Step 3: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 4: Run the casting tests** - -Run: `bun test utils/casting/` -Expected: PASS — all suites pass. - -- [ ] **Step 5: Commit** - -```bash -git add utils/casting/castLoad.ts -git commit -m "feat(casting): resolve and embed full CastSelection on load" -``` - ---- - -## Task 4: `useCastSelection` hook - -**Files:** -- Create: `hooks/useCastSelection.ts` - -- [ ] **Step 1: Write the hook** - -Create `hooks/useCastSelection.ts`: - -```ts -/** - * Source of truth for the active cast track / quality / version selection. - * - * Truth = the CastSelection echoed back in the cast media customData. A local - * `pending` selection is shown optimistically while a reload re-transcodes, then - * cleared once the cast reports it (reconciled) or the reload fails. - */ - -import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; -import { useCallback, useEffect, useState } from "react"; -import type { MediaStatus } from "react-native-google-cast"; -import { resolveSelection, selectionsEqual } from "@/utils/casting/selection"; -import type { CastSelection } from "@/utils/casting/types"; - -interface UseCastSelectionParams { - currentItem: BaseItemDto | null; - mediaStatus: MediaStatus | null | undefined; - /** Reload the cast stream with the given selection. Resolves true on success. */ - reload: (selection: CastSelection) => Promise; -} - -interface UseCastSelectionResult { - /** Effective selection: optimistic pending, else cast truth, else default. */ - currentSelection: CastSelection | null; - /** Merge a partial selection, show it optimistically, and reload the stream. */ - applySelection: (partial: Partial) => void; -} - -export const useCastSelection = ({ - currentItem, - mediaStatus, - reload, -}: UseCastSelectionParams): UseCastSelectionResult => { - const [pending, setPending] = useState(null); - - // Truth: the selection the cast reports as loaded, via customData. - const truth = - ( - mediaStatus?.mediaInfo?.customData as - | { selection?: CastSelection } - | undefined - )?.selection ?? null; - - const currentSelection: CastSelection | null = - pending ?? - truth ?? - (currentItem ? resolveSelection(currentItem, {}) : null); - - // A new media item invalidates any pending selection from the previous one. - // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on item id only - useEffect(() => { - setPending(null); - }, [currentItem?.Id]); - - // Reconcile: once the cast reports the pending selection as loaded, clear it. - useEffect(() => { - if (pending && truth && selectionsEqual(pending, truth)) { - setPending(null); - } - }, [pending, truth]); - - const applySelection = useCallback( - (partial: Partial) => { - if (!currentSelection) return; - const next: CastSelection = { ...currentSelection, ...partial }; - setPending(next); - reload(next).then((ok) => { - if (!ok) setPending(null); - }); - }, - [currentSelection, reload], - ); - - return { currentSelection, applySelection }; -}; -``` - -- [ ] **Step 2: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 3: Commit** - -```bash -git add hooks/useCastSelection.ts -git commit -m "feat(casting): add useCastSelection hook" -``` - ---- - -## Task 5: Expand the shared `BITRATES` ladder - -**Files:** -- Modify: `components/BitrateSelector.tsx` - -- [ ] **Step 1: Replace the `BITRATES` array** - -In `components/BitrateSelector.tsx`, replace the entire `export const BITRATES: Bitrate[] = [ ... ];` -declaration (including its trailing `.sort(...)`) with: - -```ts -export const BITRATES: Bitrate[] = [ - { key: "Max", value: undefined }, - { key: "200 Mb/s", value: 200000000 }, - { key: "180 Mb/s", value: 180000000 }, - { key: "140 Mb/s", value: 140000000 }, - { key: "120 Mb/s", value: 120000000 }, - { key: "110 Mb/s", value: 110000000 }, - { key: "100 Mb/s", value: 100000000 }, - { key: "90 Mb/s", value: 90000000 }, - { key: "80 Mb/s", value: 80000000 }, - { key: "70 Mb/s", value: 70000000 }, - { key: "60 Mb/s", value: 60000000 }, - { key: "50 Mb/s", value: 50000000 }, - { key: "40 Mb/s", value: 40000000 }, - { key: "30 Mb/s", value: 30000000 }, - { key: "20 Mb/s", value: 20000000 }, - { key: "15 Mb/s", value: 15000000 }, - { key: "10 Mb/s", value: 10000000 }, - { key: "8 Mb/s", value: 8000000 }, - { key: "5 Mb/s", value: 5000000 }, - { key: "4 Mb/s", value: 4000000 }, - { key: "3 Mb/s", value: 3000000 }, - { key: "2 Mb/s", value: 2000000 }, - { key: "1 Mb/s", value: 1000000 }, - { key: "720 Kb/s", value: 720000 }, - { key: "420 Kb/s", value: 420000 }, -].sort( - (a, b) => - (b.value || Number.POSITIVE_INFINITY) - - (a.value || Number.POSITIVE_INFINITY), -); -``` - -> This drops the stray `height: 1080` properties on the old `8`/`4 Mb/s` entries — -> they were not part of the `Bitrate` type and are unused. - -- [ ] **Step 2: Verify types and Biome** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bunx biome check components/BitrateSelector.tsx` -Expected: PASS — no errors on this file. - -- [ ] **Step 3: Commit** - -```bash -git add components/BitrateSelector.tsx -git commit -m "feat(player): expand shared BITRATES ladder" -``` - ---- - -## Task 6: Casting player & settings menu rework - -**Files:** -- Modify: `components/chromecast/ChromecastSettingsMenu.tsx` -- Modify: `app/(auth)/casting-player.tsx` - -This task is interdependent — `casting-player.tsx` passes the new props that -`ChromecastSettingsMenu.tsx` defines, so both change together. `bun run typecheck` -must be green only at the end (Step 5). - -- [ ] **Step 1: Rewrite `ChromecastSettingsMenu.tsx`** - -Replace the entire contents of `components/chromecast/ChromecastSettingsMenu.tsx` with: - -```tsx -/** - * Chromecast Settings Menu - * Configure version, quality (bitrate cap), audio, subtitles, and playback speed. - * Every "selected" row is driven by the active CastSelection — no [0] fallbacks. - */ - -import { Ionicons } from "@expo/vector-icons"; -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Modal, Pressable, ScrollView, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { Text } from "@/components/common/Text"; -import type { AudioTrack, SubtitleTrack } from "@/utils/casting/types"; - -export interface VersionOption { - id: string; - name: string; -} - -export interface QualityOption { - key: string; - value: number | undefined; -} - -interface ChromecastSettingsMenuProps { - visible: boolean; - onClose: () => void; - versions: VersionOption[]; - selectedVersionId: string; - onVersionChange: (id: string) => void; - qualities: QualityOption[]; - selectedMaxBitrate: number | undefined; - onQualityChange: (value: number | undefined) => void; - audioTracks: AudioTrack[]; - selectedAudioIndex: number; - onAudioChange: (index: number) => void; - subtitleTracks: SubtitleTrack[]; - /** -1 = subtitles off. */ - selectedSubtitleIndex: number; - onSubtitleChange: (index: number) => void; - playbackSpeed: number; - onPlaybackSpeedChange: (speed: number) => void; -} - -const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; -const ACCENT = "#a855f7"; - -export const ChromecastSettingsMenu: React.FC = ({ - visible, - onClose, - versions, - selectedVersionId, - onVersionChange, - qualities, - selectedMaxBitrate, - onQualityChange, - audioTracks, - selectedAudioIndex, - onAudioChange, - subtitleTracks, - selectedSubtitleIndex, - onSubtitleChange, - playbackSpeed, - onPlaybackSpeedChange, -}) => { - const insets = useSafeAreaInsets(); - const { t } = useTranslation(); - const [expandedSection, setExpandedSection] = useState(null); - - const toggleSection = (section: string) => { - setExpandedSection(expandedSection === section ? null : section); - }; - - const renderSectionHeader = ( - title: string, - icon: keyof typeof Ionicons.glyphMap, - sectionKey: string, - ) => ( - toggleSection(sectionKey)} - style={{ - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - borderBottomWidth: 1, - borderBottomColor: "#333", - }} - > - - - - {title} - - - - - ); - - const renderRow = ( - key: string | number, - label: string, - sublabel: string | null, - selected: boolean, - onPress: () => void, - ) => ( - { - onPress(); - setExpandedSection(null); - }} - style={{ - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - padding: 16, - backgroundColor: selected ? "#2a2a2a" : "transparent", - }} - > - - {label} - {sublabel ? ( - - {sublabel} - - ) : null} - - {selected ? ( - - ) : null} - - ); - - return ( - - - e.stopPropagation()} - > - - - {t("casting_player.playback_settings")} - - - - - - - - {/* Version — only when the item has more than one MediaSource */} - {versions.length > 1 && - renderSectionHeader( - t("casting_player.version"), - "albums-outline", - "version", - )} - {versions.length > 1 && expandedSection === "version" && ( - - {versions.map((v) => - renderRow( - v.id, - v.name, - null, - v.id === selectedVersionId, - () => onVersionChange(v.id), - ), - )} - - )} - - {/* Quality (bitrate cap) */} - {renderSectionHeader( - t("casting_player.quality"), - "film-outline", - "quality", - )} - {expandedSection === "quality" && ( - - {qualities.map((q) => - renderRow( - q.key, - q.key, - null, - q.value === selectedMaxBitrate, - () => onQualityChange(q.value), - ), - )} - - )} - - {/* Audio — only when more than one track */} - {audioTracks.length > 1 && - renderSectionHeader( - t("casting_player.audio"), - "musical-notes", - "audio", - )} - {audioTracks.length > 1 && expandedSection === "audio" && ( - - {audioTracks.map((track) => - renderRow( - track.index, - track.displayTitle || - track.language || - t("casting_player.unknown"), - track.codec ? track.codec.toUpperCase() : null, - track.index === selectedAudioIndex, - () => onAudioChange(track.index), - ), - )} - - )} - - {/* Subtitles */} - {subtitleTracks.length > 0 && - renderSectionHeader( - t("casting_player.subtitles"), - "text", - "subtitles", - )} - {subtitleTracks.length > 0 && expandedSection === "subtitles" && ( - - {renderRow( - "off", - t("casting_player.none"), - null, - selectedSubtitleIndex < 0, - () => onSubtitleChange(-1), - )} - {subtitleTracks.map((track) => - renderRow( - track.index, - track.displayTitle || - track.language || - t("casting_player.unknown"), - [ - track.codec ? track.codec.toUpperCase() : "", - track.isForced ? t("casting_player.forced") : "", - ] - .filter(Boolean) - .join(" • ") || null, - track.index === selectedSubtitleIndex, - () => onSubtitleChange(track.index), - ), - )} - - )} - - {/* Playback speed */} - {renderSectionHeader( - t("casting_player.playback_speed"), - "speedometer", - "speed", - )} - {expandedSection === "speed" && ( - - {PLAYBACK_SPEEDS.map((speed) => - renderRow( - speed, - speed === 1 ? t("casting_player.normal") : `${speed}x`, - null, - Math.abs(playbackSpeed - speed) < 0.01, - () => onPlaybackSpeedChange(speed), - ), - )} - - )} - - - - - ); -}; -``` - -> The new translation key `casting_player.version` is used. Add it to -> `translations/en.json` under the existing `casting_player` object, value -> `"Version"`. Do not touch other locale files — English is the source locale and -> the app falls back to it. - -- [ ] **Step 2: Rework track state in `casting-player.tsx`** - -In `app/(auth)/casting-player.tsx`: - -**(a)** Add imports (alphabetical order among existing `@/` imports): - -```ts -import { useCastSelection } from "@/hooks/useCastSelection"; -import { BITRATES } from "@/components/BitrateSelector"; -import { detectCapabilities } from "@/utils/casting/capabilities"; -import { resolveSelection } from "@/utils/casting/selection"; -import type { CastSelection } from "@/utils/casting/types"; -``` - -**(b)** Remove the two track-index state declarations: - -```ts - const [selectedAudioTrackIndex, setSelectedAudioTrackIndex] = useState< - number | null - >(null); - const [selectedSubtitleTrackIndex, setSelectedSubtitleTrackIndex] = useState< - number | null - >(null); -``` - -Keep `const [currentPlaybackSpeed, setCurrentPlaybackSpeed] = useState(1);`. - -**(c)** Remove the entire `useEffect` block titled `// Initialize track selection -from server defaults when item data arrives` (it begins `useEffect(() => { if -(!fetchedItem) return; ...` and sets `setSelectedAudioTrackIndex` / -`setSelectedSubtitleTrackIndex`). - -- [ ] **Step 3: Replace `reloadWithSettings` with `reloadWithSelection`** - -In `casting-player.tsx`, replace the whole `const reloadWithSettings = useCallback( -... );` block with: - -```tsx - // Reload the cast stream with a full selection; resolves true on success. - const reloadWithSelection = useCallback( - async (selection: CastSelection): Promise => { - if (!api || !user?.Id || !currentItem?.Id || !remoteMediaClient) { - console.warn("[Casting Player] Cannot reload - missing required data"); - return false; - } - const currentPosition = mediaStatus?.streamPosition ?? 0; - const result = await loadCastMedia({ - client: remoteMediaClient, - device: castDevice, - api, - item: currentItem, - userId: user.Id, - profileMode: settings.chromecastProfile, - maxBitrateSetting: settings.chromecastMaxBitrate, - options: { - mediaSourceId: selection.mediaSourceId, - audioStreamIndex: selection.audioStreamIndex, - subtitleStreamIndex: selection.subtitleStreamIndex, - maxBitrate: selection.maxBitrate, - startPositionMs: currentPosition * 1000, - }, - }); - if (!result.ok) { - console.error( - "[Casting Player] Failed to reload stream:", - result.error, - ); - return false; - } - return true; - }, - [ - api, - user?.Id, - currentItem, - remoteMediaClient, - castDevice, - mediaStatus?.streamPosition, - settings.chromecastProfile, - settings.chromecastMaxBitrate, - ], - ); - - const { currentSelection, applySelection } = useCastSelection({ - currentItem, - mediaStatus, - reload: reloadWithSelection, - }); -``` - -- [ ] **Step 4: Update `loadEpisode`, the track memos, and the menu JSX** - -**(a)** In `loadEpisode`, remove these two lines (after the `if (!result.ok)` block): - -```ts - setSelectedAudioTrackIndex(null); - setSelectedSubtitleTrackIndex(null); -``` - -(`useCastSelection` resets on item change — no manual reset needed.) - -**(b)** Replace the `availableMediaSources` `useMemo` block entirely with these three -memos: - -```tsx - // The MediaSource currently selected, for deriving its tracks. - const selectedSource = useMemo( - () => - currentItem?.MediaSources?.find( - (s) => s.Id === currentSelection?.mediaSourceId, - ) ?? - currentItem?.MediaSources?.[0] ?? - null, - [currentItem?.MediaSources, currentSelection?.mediaSourceId], - ); - - // Real alternate versions (multi-version items). - const availableVersions = useMemo( - () => - (currentItem?.MediaSources ?? []).map((s, i) => ({ - id: s.Id ?? `source-${i}`, - name: s.Name || `${t("casting_player.version")} ${i + 1}`, - })), - [currentItem?.MediaSources, t], - ); - - // Quality tiers from the shared ladder, capped to BOTH the device's - // capability and the media's own bitrate — a tier above either ceiling - // would behave identically to "Max", so it is not offered. - const availableQualities = useMemo(() => { - const caps = detectCapabilities(castDevice, { - profileMode: settings.chromecastProfile, - maxBitrate: settings.chromecastMaxBitrate, - }); - const mediaBitrate = - selectedSource?.Bitrate ?? - currentItem?.MediaStreams?.find((s) => s.Type === "Video")?.BitRate ?? - Number.POSITIVE_INFINITY; - const ceiling = Math.min(caps.maxVideoBitrate, mediaBitrate); - return BITRATES.filter( - (b) => b.value === undefined || b.value <= ceiling, - ); - }, [ - castDevice, - settings.chromecastProfile, - settings.chromecastMaxBitrate, - selectedSource, - currentItem?.MediaStreams, - ]); -``` - -**(c)** Replace the `availableAudioTracks` and `availableSubtitleTracks` `useMemo` -blocks entirely with (they now derive from the selected version's streams): - -```tsx - const availableAudioTracks = useMemo(() => { - const streams = selectedSource?.MediaStreams ?? currentItem?.MediaStreams; - if (!streams) return []; - return streams - .filter((stream) => stream.Type === "Audio") - .map((stream) => ({ - index: stream.Index ?? 0, - language: stream.Language || "Unknown", - displayTitle: - stream.DisplayTitle || - `${stream.Language || "Unknown"} ${stream.Codec || ""}`.trim(), - codec: stream.Codec || "Unknown", - channels: stream.Channels, - bitrate: stream.BitRate, - })); - }, [selectedSource, currentItem?.MediaStreams]); - - const availableSubtitleTracks = useMemo(() => { - const streams = selectedSource?.MediaStreams ?? currentItem?.MediaStreams; - if (!streams) return []; - return streams - .filter((stream) => stream.Type === "Subtitle") - .map((stream) => ({ - index: stream.Index ?? 0, - language: stream.Language || "Unknown", - displayTitle: - stream.DisplayTitle || - [ - stream.Language || "Unknown", - stream.IsForced ? " (Forced)" : "", - stream.Title ? ` - ${stream.Title}` : "", - ].join(""), - codec: stream.Codec || "Unknown", - isForced: stream.IsForced || false, - isExternal: stream.IsExternal || false, - })); - }, [selectedSource, currentItem?.MediaStreams]); -``` - -**(d)** Replace the entire `` element with: - -```tsx - setShowSettings(false)} - versions={availableVersions} - selectedVersionId={currentSelection?.mediaSourceId ?? ""} - onVersionChange={(id) => { - if (!currentItem) return; - applySelection(resolveSelection(currentItem, { mediaSourceId: id })); - }} - qualities={availableQualities} - selectedMaxBitrate={currentSelection?.maxBitrate} - onQualityChange={(value) => applySelection({ maxBitrate: value })} - audioTracks={availableAudioTracks} - selectedAudioIndex={currentSelection?.audioStreamIndex ?? -1} - onAudioChange={(index) => - applySelection({ audioStreamIndex: index }) - } - subtitleTracks={availableSubtitleTracks} - selectedSubtitleIndex={currentSelection?.subtitleStreamIndex ?? -1} - onSubtitleChange={(index) => - applySelection({ subtitleStreamIndex: index }) - } - playbackSpeed={currentPlaybackSpeed} - onPlaybackSpeedChange={(speed) => { - setCurrentPlaybackSpeed(speed); - remoteMediaClient?.setPlaybackRate(speed).catch(console.error); - }} - /> -``` - -- [ ] **Step 5: Verify types, tests and Biome** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -> If typecheck flags an unused symbol (e.g. a now-unused `useState` import, or -> `fetchedItem` if the removed init effect was its only use), remove the dead -> reference. If it flags a leftover `reloadWithSettings` / `selectedAudioTrackIndex` -> reference, that line was missed in Steps 2-4 — find and rework it. - -Run: `bun test utils/casting/` -Expected: PASS — all suites pass. - -Run: `bunx biome check "app/(auth)/casting-player.tsx" components/chromecast/ChromecastSettingsMenu.tsx translations/en.json` -Expected: PASS — no errors on these files. - -- [ ] **Step 6: Commit** - -```bash -git add "app/(auth)/casting-player.tsx" components/chromecast/ChromecastSettingsMenu.tsx translations/en.json -git commit -m "feat(casting): reliable track switching with CastSelection truth" -``` - ---- - -## Final verification - -- [ ] **Run the full check suite** - -Run: `bun test utils/casting/` -Expected: PASS — `selection`, `buildProfile`, `capabilities`, `castErrors` suites all pass. - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Manual verification** - -Cast a multi-audio episode to the Chromecast HD: -- The audio menu's checkmark matches the language actually playing. -- Switch audio → after the reload, the menu reflects the new track and it plays. -- Switch subtitle, quality, and (on a multi-version movie) version — each reflects - and applies. -- Change episode → the menu shows the new episode's real default tracks (no - Japanese/French desync). -- Leave the casting player and re-enter → the selection display is still correct. - ---- - -## Notes for the implementer - -- **Line numbers drift.** Match on quoted code, not line numbers. -- **`bun test`** is Bun's built-in runner. `selection.ts` is pure (type-only imports) - so its test runs cleanly. Do not add a test that imports `castLoad.ts` or - `useCastSelection.ts` — they pull React Native modules the runner cannot load. -- **Out of scope:** subtitle rendering / styling / sidecar VTT (custom-receiver - sub-project), the `casting-player.tsx` file split (sub-project C), the remote - control panel (sub-project D). Do not start them. -- Speed is intentionally not part of `CastSelection` — it is a live Cast playback - rate (`setPlaybackRate`), not a stream-reload property. -- **Known duplication (do not fix here):** `BITRATES` / `Bitrate` are defined twice - — in `components/BitrateSelector.tsx` (the live one, imported by the native player - and settings) and a stale near-copy in `components/BitRateSheet.tsx`. Task 5 edits - only `BitrateSelector.tsx`. Removing the duplicate is out of scope for sub-project - B — flag it for a later cleanup. diff --git a/docs/superpowers/plans/2026-05-22-chromecast-player-split.md b/docs/superpowers/plans/2026-05-22-chromecast-player-split.md deleted file mode 100644 index 0c7c8b685..000000000 --- a/docs/superpowers/plans/2026-05-22-chromecast-player-split.md +++ /dev/null @@ -1,409 +0,0 @@ -# Chromecast Player Split — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Decompose the 1428-line `app/(auth)/casting-player.tsx` god-component into 4 hooks + 6 presentational components + a thin orchestrator, with zero behaviour change. - -**Architecture:** Each task extracts one JSX section into a presentational component (`components/casting/player/`) or one logic cluster into a hook (`hooks/`), then rewires `casting-player.tsx` to use it. Purely mechanical — moved code, not new code. State flows orchestrator → components by typed props. - -**Tech Stack:** TypeScript (strict), React Native / Expo, `react-native-google-cast`, `react-native-reanimated`, `react-native-gesture-handler`. - -**Spec:** `docs/superpowers/specs/2026-05-22-chromecast-player-split-design.md` - -**Environment note:** Windows checkout, `core.autocrlf=true` — project-wide `bun run check` reports ~124 pre-existing CRLF errors unrelated to this work. The gate is `bun run typecheck` (fully green) plus Biome on the files each task edits. - ---- - -## Hard rules for every task - -1. **Zero behaviour change.** This is a mechanical extraction. Move code; do not rewrite logic, rename behaviour, fix bugs, or "improve" anything. Known issues stay untouched (they belong to a later UX sub-project). -2. **Read `app/(auth)/casting-player.tsx` first.** It is large (~1400 lines). Locate the section by the quoted `{/* comment */}` anchor, not line numbers. -3. **Derive interfaces from real usage.** A component's props = exactly the values the extracted JSX references from the surrounding scope (state, derived values, handlers). A hook's return = exactly the values the rest of `casting-player.tsx` still needs. Type every prop / return field explicitly — no `any`. -4. **`bun run typecheck` must be fully green** before each commit. It is the safety net that catches a missed prop or broken wiring. -5. Preserve imports: when a section moves out, move its imports too; remove imports from `casting-player.tsx` that it no longer uses. - ---- - -## File Structure - -| File | Responsibility | Task | -|---|---|---| -| `components/casting/player/CastPlayerHeader.tsx` | Dismiss chevron, connection indicator, settings button | 1 | -| `components/casting/player/CastPlayerTitle.tsx` | Title + episode/season info | 1 | -| `components/casting/player/CastPlayerPoster.tsx` | Poster image, buffering overlay, skip intro/credits bar | 2 | -| `components/casting/player/CastPlayerEpisodeControls.tsx` | 4-button row (Episodes / Previous / Next / Stop) | 3 | -| `components/casting/player/CastPlayerProgressBar.tsx` | Slider, trickplay preview, time display | 4 | -| `components/casting/player/CastPlayerTransportControls.tsx` | Rewind / play-pause / forward | 5 | -| `hooks/useCastPlayerItem.ts` | `fetchedItem` + fetch + `currentItem` derivation | 6 | -| `hooks/useCastEpisodes.ts` | `episodes`/`nextEpisode`/`seasonData` + fetches + `loadEpisode` | 7 | -| `hooks/useCastDismissGesture.ts` | dismiss gesture / animated style | 8 | -| `hooks/useCastPlayerProgress.ts` | slider, scrubbing, live progress, trickplay | 9 | -| `app/(auth)/casting-player.tsx` | Thin orchestrator | all | - -Tasks 1-5 extract components (the orchestrator keeps all logic, just renders ``). -Tasks 6-9 extract hooks. Task 10 finalises and verifies. - ---- - -## Task 1: Extract `CastPlayerHeader` and `CastPlayerTitle` - -**Files:** -- Create: `components/casting/player/CastPlayerHeader.tsx`, `components/casting/player/CastPlayerTitle.tsx` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create `CastPlayerHeader.tsx`** - -In `casting-player.tsx`, locate the JSX under `{/* Header - Fixed at top */}` (the `` containing the dismiss `Pressable` with the `chevron-down` icon, the connection-indicator `Pressable`, and the settings-button `Pressable` with `settings-outline`). - -Create `components/casting/player/CastPlayerHeader.tsx` as a presentational component containing exactly that JSX. Define a `CastPlayerHeaderProps` interface for every value the JSX references from the outer scope — e.g. the dismiss handler, the connection state / device name shown in the indicator, the connection-indicator press handler, the settings press handler, and any style insets. Type each explicitly. Move the icon/`Text`/`Pressable` imports the component needs. - -- [ ] **Step 2: Create `CastPlayerTitle.tsx`** - -Locate the JSX under `{/* Title Area */}` (the `` with the title `Text` and the grey episode/season info `Text`). Create `components/casting/player/CastPlayerTitle.tsx` the same way — a `CastPlayerTitleProps` interface for exactly what it references (title string, episode/season info string or the item fields it derives them from — keep the derivation identical to the original). - -- [ ] **Step 3: Rewire `casting-player.tsx`** - -Replace the `{/* Header - Fixed at top */}` block with `` and the `{/* Title Area */}` block with ``, passing the props. Add the two imports. Remove now-unused imports from `casting-player.tsx`. - -- [ ] **Step 4: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check components/casting/player/CastPlayerHeader.tsx components/casting/player/CastPlayerTitle.tsx "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 5: Commit** - -```bash -git add components/casting/player/ "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract CastPlayerHeader and CastPlayerTitle" -``` - ---- - -## Task 2: Extract `CastPlayerPoster` - -**Files:** -- Create: `components/casting/player/CastPlayerPoster.tsx` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create the component** - -Locate the JSX under `{/* Poster with buffering overlay */}` (inside the scrollable content area) — the poster `Image`, the empty-poster fallback `View` with the `film-outline` icon, the `{/* Skip intro/credits bar */}` block, and the `{/* Buffering overlay */}` block with the `ActivityIndicator`. - -Create `components/casting/player/CastPlayerPoster.tsx` containing that JSX. Define `CastPlayerPosterProps` for everything it references: the poster URL, buffering state, the skip-segment data and skip handlers, the translation function if used, etc. Type each field. Move the imports it needs. - -- [ ] **Step 2: Rewire** - -Replace the poster block in `casting-player.tsx` with ``. Add the import; drop unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check components/casting/player/CastPlayerPoster.tsx "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add components/casting/player/CastPlayerPoster.tsx "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract CastPlayerPoster" -``` - ---- - -## Task 3: Extract `CastPlayerEpisodeControls` - -**Files:** -- Create: `components/casting/player/CastPlayerEpisodeControls.tsx` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create the component** - -Locate the JSX under `{/* Fixed 4-button control row for episodes - positioned independently */}` — the `` with the four `Pressable`s: Episodes (`list` icon), Previous episode (`play-skip-back`), Next episode (`play-skip-forward`), Stop (`stop-circle`). - -Create `components/casting/player/CastPlayerEpisodeControls.tsx` with that JSX. Define `CastPlayerEpisodeControlsProps` for each button's press handler and each button's enabled/visible condition exactly as the original computes it (e.g. whether a previous/next episode exists). Keep the conditions identical. Move the imports. - -- [ ] **Step 2: Rewire** - -Replace the 4-button block in `casting-player.tsx` with ``. Add the import; drop unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check components/casting/player/CastPlayerEpisodeControls.tsx "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add components/casting/player/CastPlayerEpisodeControls.tsx "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract CastPlayerEpisodeControls" -``` - ---- - -## Task 4: Extract `CastPlayerProgressBar` - -**Files:** -- Create: `components/casting/player/CastPlayerProgressBar.tsx` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create the component** - -Locate the JSX under `{/* Progress slider with trickplay preview */}` and the adjacent `{/* Time display */}` block (both inside `{/* Fixed bottom controls area */}`). This includes the `` with its trickplay-preview render callback and the two time `Text`s. - -Create `components/casting/player/CastPlayerProgressBar.tsx` with that JSX. Define `CastPlayerProgressBarProps` for everything it references: the slider shared values, the scrub handlers, the trickplay URL/time/info, the formatted current/end time strings (or the values they derive from), `protocolColor`, etc. Type each field. Move the imports (`Slider`, reanimated, `Image`, `Text`, …). - -> The slider passes `react-native-reanimated` shared values. They pass through props unchanged — keep their types (`SharedValue`). - -- [ ] **Step 2: Rewire** - -Replace the progress-slider + time-display JSX in `casting-player.tsx` with `` (keep it inside the bottom-controls ``). Add the import; drop unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check components/casting/player/CastPlayerProgressBar.tsx "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add components/casting/player/CastPlayerProgressBar.tsx "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract CastPlayerProgressBar" -``` - ---- - -## Task 5: Extract `CastPlayerTransportControls` - -**Files:** -- Create: `components/casting/player/CastPlayerTransportControls.tsx` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create the component** - -Locate the JSX under `{/* Playback controls */}` — the `` with the Rewind `Pressable`, the Play/Pause `Pressable`, and the Forward `Pressable` (the rewind/forward show the configured skip seconds). - -Create `components/casting/player/CastPlayerTransportControls.tsx` with that JSX. Define `CastPlayerTransportControlsProps` for: the play/pause state, the play/pause handler, the rewind/forward handlers, the rewind/forward skip-second values shown on the buttons. Type each. Move the imports. - -- [ ] **Step 2: Rewire** - -Replace the playback-controls block in `casting-player.tsx` with ``. Add the import; drop unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check components/casting/player/CastPlayerTransportControls.tsx "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add components/casting/player/CastPlayerTransportControls.tsx "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract CastPlayerTransportControls" -``` - ---- - -## Task 6: Extract `useCastPlayerItem` - -**Files:** -- Create: `hooks/useCastPlayerItem.ts` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create the hook** - -In `casting-player.tsx`, identify: the `fetchedItem` `useState`, the `useEffect` that fetches the full item from the Jellyfin API into `fetchedItem`, and the `currentItem` `useMemo` (which derives the effective item from `fetchedItem` and the cast `customData`). - -Create `hooks/useCastPlayerItem.ts` exporting `useCastPlayerItem`. Move that state, effect, and memo into it. The hook takes whatever inputs those blocks reference (e.g. `api`, `user`, `mediaStatus`, the route params) as parameters, and returns `{ fetchedItem, currentItem }`. Keep the fetch logic and the derivation byte-for-byte identical. - -- [ ] **Step 2: Rewire** - -In `casting-player.tsx`, replace the moved `useState` / `useEffect` / `useMemo` with `const { fetchedItem, currentItem } = useCastPlayerItem({ ... });`. Add the import; drop now-unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check hooks/useCastPlayerItem.ts "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add hooks/useCastPlayerItem.ts "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract useCastPlayerItem hook" -``` - ---- - -## Task 7: Extract `useCastEpisodes` - -**Files:** -- Create: `hooks/useCastEpisodes.ts` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create the hook** - -Identify in `casting-player.tsx`: the `episodes`, `nextEpisode`, and `seasonData` `useState`s; the `useEffect`(s) that fetch the season / episode list; and the `loadEpisode` `useCallback`. - -Create `hooks/useCastEpisodes.ts` exporting `useCastEpisodes`. Move that state, those effects, and `loadEpisode` into it. The hook takes the inputs they reference (`api`, `user`, `currentItem`, `remoteMediaClient`, `castDevice`, `settings`, …) as parameters and returns `{ episodes, nextEpisode, seasonData, loadEpisode }`. Keep all logic identical, including the `loadEpisode` body. - -- [ ] **Step 2: Rewire** - -Replace the moved declarations with `const { episodes, nextEpisode, seasonData, loadEpisode } = useCastEpisodes({ ... });`. Add the import; drop unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check hooks/useCastEpisodes.ts "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add hooks/useCastEpisodes.ts "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract useCastEpisodes hook" -``` - ---- - -## Task 8: Extract `useCastDismissGesture` - -**Files:** -- Create: `hooks/useCastDismissGesture.ts` -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Create the hook** - -Identify in `casting-player.tsx`: the `translateY` and `context` shared values, the `dismissModal` `useCallback`, the `panGesture` (`Gesture.Pan()...`), and the `animatedStyle` (`useAnimatedStyle`). - -Create `hooks/useCastDismissGesture.ts` exporting `useCastDismissGesture`. Move those into it. The hook takes whatever the dismiss logic references (e.g. the navigation/router used to close the screen) as parameters and returns `{ panGesture, animatedStyle, dismissModal }`. Keep the gesture thresholds and animation logic identical. - -- [ ] **Step 2: Rewire** - -Replace the moved declarations with `const { panGesture, animatedStyle, dismissModal } = useCastDismissGesture({ ... });`. Add the import; drop unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check hooks/useCastDismissGesture.ts "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add hooks/useCastDismissGesture.ts "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract useCastDismissGesture hook" -``` - ---- - -## Task 9: Extract `useCastPlayerProgress` - -**Files:** -- Create: `hooks/useCastPlayerProgress.ts` -- Modify: `app/(auth)/casting-player.tsx` - -> This is the most intricate extraction — reanimated shared values, timing refs, -> the live-progress interpolation, scrubbing, and the `useTrickplay` integration. -> Move it as one cohesive cluster; do not split or simplify it. - -- [ ] **Step 1: Create the hook** - -Identify in `casting-player.tsx` the progress/slider cluster: the `sliderProgress` / `sliderMin` / `sliderMax` shared values; the `isScrubbing` ref; the `trickplayTime` and `scrubPercentage` state; the `liveProgress` state with `lastSyncPositionRef` / `lastSyncTimestampRef`; the `resumePositionRef`; the effects that sync `liveProgress` from `mediaStatus` and update the refs; the `useTrickplay(...)` call; and any scrub-start/move/end handlers. - -Create `hooks/useCastPlayerProgress.ts` exporting `useCastPlayerProgress`. Move that whole cluster in. The hook takes the inputs the cluster references (`mediaStatus`, `duration`, the trickplay inputs, …) as parameters and returns everything the JSX and the rest of the orchestrator still need — e.g. `{ sliderProgress, sliderMin, sliderMax, isScrubbing, trickplayTime, scrubPercentage, progress, liveProgress, resumePositionRef, trickPlayUrl, calculateTrickplayUrl, trickplayInfo, ...scrub handlers }`. Keep every effect, ref, and computation byte-for-byte identical and in the same order. - -- [ ] **Step 2: Rewire** - -Replace the moved cluster with `const { ... } = useCastPlayerProgress({ ... });`. The `CastPlayerProgressBar` component (Task 4) and `reloadWithSelection` consume these values — make sure they still receive them. Add the import; drop unused imports. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bun test utils/casting/` -Expected: PASS — all suites pass (the pure-logic suites are unaffected). - -Run: `bunx biome check hooks/useCastPlayerProgress.ts "app/(auth)/casting-player.tsx"` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add hooks/useCastPlayerProgress.ts "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): extract useCastPlayerProgress hook" -``` - ---- - -## Task 10: Finalise the orchestrator - -**Files:** -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Clean up** - -`casting-player.tsx` should now be a thin orchestrator: hook calls, a small amount of wiring, and the JSX tree composing the 6 new components plus the 3 modal components (`ChromecastDeviceSheet`, `ChromecastEpisodeList`, `ChromecastSettingsMenu`) inside the `GestureDetector` / `Animated.View`. - -Read the whole file. Remove any now-dead code: unused imports, leftover variables, commented-out fragments, intermediate values that are no longer referenced. Do not change behaviour — only delete what is provably unused. - -- [ ] **Step 2: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bun test utils/casting/` -Expected: PASS. - -Run: `bunx biome check "app/(auth)/casting-player.tsx"` -Expected: PASS. - -Confirm `casting-player.tsx` is in the ~150-250 line range and contains no large inline JSX section or logic cluster. - -- [ ] **Step 3: Commit** - -```bash -git add "app/(auth)/casting-player.tsx" -git commit -m "refactor(casting): finalise casting-player orchestrator" -``` - ---- - -## Final verification - -- [ ] **Checks** - -Run: `bun run typecheck` → PASS. -Run: `bun test utils/casting/` → PASS. - -- [ ] **Manual re-test** (behaviour must be identical to before the split) - -Cast a movie and an episode to the Chromecast. Verify: playback starts; the header / title / poster render; the 4 episode buttons work; audio / subtitle / quality / version switching still works; episode navigation works; the progress slider scrubs and shows trickplay; play/pause/rewind/forward work; the buffering overlay appears; swiping down dismisses the player. Nothing should behave differently from before sub-project C. - ---- - -## Notes for the implementer - -- Line numbers drift across tasks — always Read the file and match on the quoted `{/* comment */}` anchors. -- This is a refactor: there is no new behaviour to unit-test. `bun run typecheck` per task is the safety net; the final manual re-test is the behavioural check. -- If extracting a section reveals it is entangled with another (a shared variable that does not cleanly belong to one unit), keep that variable in the orchestrator and pass it as a prop / hook argument — do not duplicate it. -- Out of scope: the trickplay truncation bug, the progress-bar touch-overlap bug, the time-label position, mini-player changes, the `loadEpisode`/`currentItem` race. Do not fix them here — they belong to the later UX sub-project. diff --git a/docs/superpowers/plans/2026-05-22-chromecast-session-remote-control.md b/docs/superpowers/plans/2026-05-22-chromecast-session-remote-control.md deleted file mode 100644 index ec32961dd..000000000 --- a/docs/superpowers/plans/2026-05-22-chromecast-session-remote-control.md +++ /dev/null @@ -1,843 +0,0 @@ -# Casting Session Reporting & Remote Control — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Implement app-wide Jellyfin remote control (Playstate / GeneralCommand) routed through a `PlaybackController` registry, fix the cast `PlayMethod` report, make the episode buttons conditional, and fix the `loadEpisode` race. - -**Architecture:** A `PlaybackController` interface is the canonical control surface; each player (cast, native video, music) registers an implementation into a Jotai atom while it is active. A pure mapper turns WebSocket remote-control messages into typed actions; a `useRemoteControl` hook dispatches them to the active controller. Small cast fixes ride alongside. - -**Tech Stack:** TypeScript (strict), React Native / Expo, Jotai, `@jellyfin/sdk`, `react-native-google-cast`, `sonner-native`. Pure logic is unit-tested with `bun test`. - -**Spec:** `docs/superpowers/specs/2026-05-22-chromecast-session-remote-control-design.md` - -**Environment note:** Windows checkout, `core.autocrlf=true` — project-wide `bun run check` reports ~124 pre-existing CRLF errors unrelated to this work. The gate is `bun run typecheck` (fully green) plus Biome on the files each task edits. - -**Commit note:** Do NOT add a `Co-Authored-By` trailer to any commit message in this project. - ---- - -## File Structure - -| File | Responsibility | Task | -|---|---|---| -| `utils/playback/playbackController.ts` | `PlaybackController` interface, atom, `useRegisterPlaybackController` | 1 | -| `utils/playback/remoteCommands.ts` | Pure WS-message → `RemoteAction` mapper | 2 | -| `utils/playback/remoteCommands.test.ts` | Unit tests for the mapper | 2 | -| `hooks/useRemoteControl.ts` | Dispatch remote actions to the active controller | 3 | -| `providers/WebSocketProvider.tsx` | Consume `useRemoteControl`; expand `SupportedCommands` | 4 | -| `app/(auth)/casting-player.tsx` | Register the cast controller; fix `loadEpisode` race | 5, 10 | -| `app/(auth)/player/direct-player.tsx` | Register the native-video controller | 6 | -| `providers/MusicPlayerProvider.tsx` | Register the music controller | 7 | -| `utils/casting/castLoad.ts`, `utils/casting/mediaInfo.ts` | Embed `playMethod` in customData | 8 | -| `hooks/useCasting.ts` | Report the real `PlayMethod` | 8 | -| `components/casting/player/CastPlayerEpisodeControls.tsx` | Conditional Previous / Next buttons | 9 | - ---- - -## Task 1: `PlaybackController` contract & registry - -**Files:** -- Create: `utils/playback/playbackController.ts` - -- [ ] **Step 1: Write the module** - -Create `utils/playback/playbackController.ts`: - -```ts -/** - * The canonical playback-control surface. Every player (cast, native video, - * music) implements this interface and registers itself as the active - * controller while it is playing, so remote-control commands can be routed to - * whatever is currently playing. - */ - -import { atom, useSetAtom } from "jotai"; -import { useEffect } from "react"; - -export interface PlaybackController { - playPause(): void; - pause(): void; - unpause(): void; - stop(): void; - /** Absolute seek position in milliseconds. */ - seek(positionMs: number): void; - next(): void; - previous(): void; - /** Volume 0-1. */ - setVolume(level: number): void; - toggleMute(): void; -} - -/** The currently-active playback controller, or null when nothing is playing. */ -export const activePlaybackControllerAtom = atom( - null, -); - -/** - * Register `controller` as the active playback controller while `active` is - * true. Clears the atom on unmount or when `active` becomes false — but only if - * the atom still holds this exact controller (so a newer registration wins). - */ -export const useRegisterPlaybackController = ( - controller: PlaybackController | null, - active: boolean, -): void => { - const setController = useSetAtom(activePlaybackControllerAtom); - useEffect(() => { - if (!active || !controller) return; - setController(controller); - return () => { - setController((current) => (current === controller ? null : current)); - }; - }, [active, controller, setController]); -}; -``` - -- [ ] **Step 2: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 3: Commit** - -```bash -git add utils/playback/playbackController.ts -git commit -m "feat(playback): add PlaybackController contract and registry" -``` - ---- - -## Task 2: Pure remote-command mapper - -**Files:** -- Create: `utils/playback/remoteCommands.ts` -- Test: `utils/playback/remoteCommands.test.ts` - -- [ ] **Step 1: Write the failing test** - -Create `utils/playback/remoteCommands.test.ts`: - -```ts -import { describe, expect, test } from "bun:test"; -import { mapRemoteCommand } from "./remoteCommands"; - -describe("mapRemoteCommand — Playstate", () => { - test("maps Pause", () => { - expect(mapRemoteCommand({ MessageType: "Playstate", Data: { Command: "Pause" } })) - .toEqual({ kind: "pause" }); - }); - - test("maps Stop, PlayPause, Unpause, NextTrack, PreviousTrack", () => { - const m = (c: string) => - mapRemoteCommand({ MessageType: "Playstate", Data: { Command: c } }); - expect(m("Stop")).toEqual({ kind: "stop" }); - expect(m("PlayPause")).toEqual({ kind: "playPause" }); - expect(m("Unpause")).toEqual({ kind: "unpause" }); - expect(m("NextTrack")).toEqual({ kind: "next" }); - expect(m("PreviousTrack")).toEqual({ kind: "previous" }); - }); - - test("maps Seek, converting ticks to milliseconds", () => { - expect( - mapRemoteCommand({ - MessageType: "Playstate", - Data: { Command: "Seek", SeekPositionTicks: 600_000_000 }, - }), - ).toEqual({ kind: "seek", positionMs: 60_000 }); - }); - - test("returns null for Seek with no position", () => { - expect( - mapRemoteCommand({ MessageType: "Playstate", Data: { Command: "Seek" } }), - ).toBeNull(); - }); - - test("returns null for an unknown command", () => { - expect( - mapRemoteCommand({ MessageType: "Playstate", Data: { Command: "Wat" } }), - ).toBeNull(); - }); -}); - -describe("mapRemoteCommand — GeneralCommand", () => { - test("maps SetVolume, converting 0-100 to 0-1", () => { - expect( - mapRemoteCommand({ - MessageType: "GeneralCommand", - Data: { Name: "SetVolume", Arguments: { Volume: "40" } }, - }), - ).toEqual({ kind: "setVolume", level: 0.4 }); - }); - - test("clamps SetVolume to 0-1", () => { - const r = mapRemoteCommand({ - MessageType: "GeneralCommand", - Data: { Name: "SetVolume", Arguments: { Volume: "250" } }, - }); - expect(r).toEqual({ kind: "setVolume", level: 1 }); - }); - - test("maps ToggleMute / Mute / Unmute to toggleMute", () => { - const m = (n: string) => - mapRemoteCommand({ MessageType: "GeneralCommand", Data: { Name: n } }); - expect(m("ToggleMute")).toEqual({ kind: "toggleMute" }); - expect(m("Mute")).toEqual({ kind: "toggleMute" }); - expect(m("Unmute")).toEqual({ kind: "toggleMute" }); - }); - - test("maps DisplayMessage from Arguments.Text", () => { - expect( - mapRemoteCommand({ - MessageType: "GeneralCommand", - Data: { Name: "DisplayMessage", Arguments: { Text: "Hello" } }, - }), - ).toEqual({ kind: "displayMessage", text: "Hello" }); - }); -}); - -describe("mapRemoteCommand — other", () => { - test("returns null for unrelated message types", () => { - expect(mapRemoteCommand({ MessageType: "KeepAlive", Data: {} })).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `bun test utils/playback/remoteCommands.test.ts` -Expected: FAIL — `Cannot find module './remoteCommands'`. - -- [ ] **Step 3: Write the implementation** - -Create `utils/playback/remoteCommands.ts`: - -```ts -/** - * Pure mapping from a Jellyfin remote-control WebSocket message to a typed - * action. Dependency-free so it is unit-testable under `bun test`. - */ - -/** A WebSocket message envelope (subset). */ -export interface RemoteWsMessage { - MessageType: string; - Data?: unknown; -} - -export type RemoteAction = - | { kind: "playPause" } - | { kind: "pause" } - | { kind: "unpause" } - | { kind: "stop" } - | { kind: "seek"; positionMs: number } - | { kind: "next" } - | { kind: "previous" } - | { kind: "setVolume"; level: number } - | { kind: "toggleMute" } - | { kind: "displayMessage"; text: string }; - -const clamp01 = (n: number): number => Math.min(1, Math.max(0, n)); - -const mapPlaystate = (data: Record): RemoteAction | null => { - switch (data.Command) { - case "PlayPause": - return { kind: "playPause" }; - case "Pause": - return { kind: "pause" }; - case "Unpause": - return { kind: "unpause" }; - case "Stop": - return { kind: "stop" }; - case "NextTrack": - return { kind: "next" }; - case "PreviousTrack": - return { kind: "previous" }; - case "Seek": { - const ticks = data.SeekPositionTicks; - if (typeof ticks !== "number") return null; - return { kind: "seek", positionMs: Math.floor(ticks / 10000) }; - } - default: - return null; - } -}; - -const mapGeneralCommand = ( - data: Record, -): RemoteAction | null => { - const args = (data.Arguments ?? {}) as Record; - switch (data.Name) { - case "SetVolume": { - const volume = Number(args.Volume); - if (!Number.isFinite(volume)) return null; - return { kind: "setVolume", level: clamp01(volume / 100) }; - } - case "Mute": - case "Unmute": - case "ToggleMute": - return { kind: "toggleMute" }; - case "DisplayMessage": { - const text = args.Text ?? args.Header; - if (!text) return null; - return { kind: "displayMessage", text: String(text) }; - } - default: - return null; - } -}; - -/** Map a remote-control WS message to a typed action, or null if unhandled. */ -export const mapRemoteCommand = ( - message: RemoteWsMessage, -): RemoteAction | null => { - const data = (message.Data ?? {}) as Record; - if (message.MessageType === "Playstate") return mapPlaystate(data); - if (message.MessageType === "GeneralCommand") return mapGeneralCommand(data); - return null; -}; -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `bun test utils/playback/remoteCommands.test.ts` -Expected: PASS — all suites pass. - -- [ ] **Step 5: Commit** - -```bash -git add utils/playback/remoteCommands.ts utils/playback/remoteCommands.test.ts -git commit -m "feat(playback): add pure remote-command mapper" -``` - ---- - -## Task 3: `useRemoteControl` hook - -**Files:** -- Create: `hooks/useRemoteControl.ts` - -- [ ] **Step 1: Write the hook** - -Create `hooks/useRemoteControl.ts`: - -```ts -/** - * Dispatches Jellyfin remote-control WebSocket messages to the active - * PlaybackController. DisplayMessage is shown as an in-app toast and needs no - * controller. - */ - -import { useAtomValue } from "jotai"; -import { useEffect } from "react"; -import { toast } from "sonner-native"; -import { activePlaybackControllerAtom } from "@/utils/playback/playbackController"; -import { - mapRemoteCommand, - type RemoteWsMessage, -} from "@/utils/playback/remoteCommands"; - -/** Handle one remote-control message (call it whenever a new WS message arrives). */ -export const useRemoteControl = (lastMessage: RemoteWsMessage | null): void => { - const controller = useAtomValue(activePlaybackControllerAtom); - - useEffect(() => { - if (!lastMessage) return; - const action = mapRemoteCommand(lastMessage); - if (!action) return; - - if (action.kind === "displayMessage") { - toast(action.text); - return; - } - - if (!controller) return; - - switch (action.kind) { - case "playPause": - controller.playPause(); - break; - case "pause": - controller.pause(); - break; - case "unpause": - controller.unpause(); - break; - case "stop": - controller.stop(); - break; - case "seek": - controller.seek(action.positionMs); - break; - case "next": - controller.next(); - break; - case "previous": - controller.previous(); - break; - case "setVolume": - controller.setVolume(action.level); - break; - case "toggleMute": - controller.toggleMute(); - break; - } - }, [lastMessage, controller]); -}; -``` - -- [ ] **Step 2: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -> If `sonner-native`'s `toast` export name differs, check an existing usage with -> `grep -rn "sonner-native" --include="*.tsx" components app | head -3` and match it. - -- [ ] **Step 3: Commit** - -```bash -git add hooks/useRemoteControl.ts -git commit -m "feat(playback): add useRemoteControl dispatch hook" -``` - ---- - -## Task 4: Wire remote control into `WebSocketProvider` - -**Files:** -- Modify: `providers/WebSocketProvider.tsx` - -- [ ] **Step 1: Consume the hook** - -In `providers/WebSocketProvider.tsx`, add the import: - -```ts -import { useRemoteControl } from "@/hooks/useRemoteControl"; -``` - -Inside the `WebSocketProvider` component body (after `lastMessage` is declared), add: - -```ts - // Route Jellyfin remote-control messages to the active player. - useRemoteControl(lastMessage); -``` - -- [ ] **Step 2: Expand advertised capabilities** - -In the `postFullCapabilities` call, replace: - -```ts - SupportedCommands: ["Play"], -``` - -with: - -```ts - SupportedCommands: [ - "Play", - "DisplayMessage", - "SetVolume", - "ToggleMute", - "Mute", - "Unmute", - ], -``` - -- [ ] **Step 3: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -> `SupportedCommands` is typed as `GeneralCommandType[]` by the Jellyfin SDK. The -> six strings above are all valid `GeneralCommandType` values, so the array -> literal type-checks. If the SDK rejects a literal, that value is not a valid -> `GeneralCommandType` — remove it and report it. - -- [ ] **Step 4: Commit** - -```bash -git add providers/WebSocketProvider.tsx -git commit -m "feat(playback): handle remote-control messages over WebSocket" -``` - ---- - -## Task 5: Register the cast `PlaybackController` - -**Files:** -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Build and register the controller** - -Read `app/(auth)/casting-player.tsx`. It uses `useCasting` (which exposes -`togglePlayPause`, `pause`, `play`, `seek`, `skipForward`, `skipBackward`, `stop`, -`setVolume`, `progress`, `volume`) and `useCastEpisodes` (`loadEpisode`, `episodes`, -`nextEpisode`), and has `currentItem`. - -Add a memoised `PlaybackController` built from those, and register it while a cast -session is active. Concretely: - -```tsx -import { useMemo } from "react"; -import { - type PlaybackController, - useRegisterPlaybackController, -} from "@/utils/playback/playbackController"; -``` - -Build the controller (place this near the other `useMemo`s, after `castingControls` -and the episode/selection hooks are available): - -```tsx - const castController = useMemo( - () => ({ - playPause: () => { - castingControls.togglePlayPause(); - }, - pause: () => { - castingControls.pause(); - }, - unpause: () => { - castingControls.play(); - }, - stop: () => { - castingControls.stop(); - }, - seek: (positionMs) => { - castingControls.seek(positionMs); - }, - next: () => { - if (nextEpisode) loadEpisode(nextEpisode); - }, - previous: () => { - const idx = episodes.findIndex((e) => e.Id === currentItem?.Id); - if (idx > 0) loadEpisode(episodes[idx - 1]); - }, - setVolume: (level) => { - castingControls.setVolume(level); - }, - toggleMute: () => { - castingControls.setVolume(castingControls.volume > 0 ? 0 : 1); - }, - }), - [castingControls, episodes, nextEpisode, loadEpisode, currentItem?.Id], - ); - - useRegisterPlaybackController(castController, castState === CastState.CONNECTED); -``` - -> Verify the exact names against `useCasting`'s return and `useCastEpisodes`'s -> return — the methods listed above are what those hooks expose. `castState` / -> `CastState` are already imported in this file. If `castingControls` does not -> expose `volume`, derive the mute-toggle from whatever volume value it does -> expose, or from `currentSelection` — keep the intent (mute ↔ unmute). - -- [ ] **Step 2: Verify** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bunx biome check "app/(auth)/casting-player.tsx"` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add "app/(auth)/casting-player.tsx" -git commit -m "feat(casting): register cast PlaybackController for remote control" -``` - ---- - -## Task 6: Register the native-video `PlaybackController` - -**Files:** -- Modify: `app/(auth)/player/direct-player.tsx` - -- [ ] **Step 1: Build and register the controller** - -Read `app/(auth)/player/direct-player.tsx` and locate its playback controls — the -play, pause, seek, and (if present) next/previous-episode handlers it already uses -for its on-screen controls (it has a video player ref and `usePlaybackManager` for -`previousItem` / `nextItem`). - -Add a memoised `PlaybackController` wrapping those existing handlers, and register -it with `useRegisterPlaybackController(controller, true)` — `true` because the -controller should be active for the whole lifetime of the player screen (the hook -clears it automatically on unmount). - -```tsx -import { - type PlaybackController, - useRegisterPlaybackController, -} from "@/utils/playback/playbackController"; -``` - -Build the controller from the screen's existing control functions (do not invent -new playback logic — reuse what the on-screen buttons call). For `setVolume` / -`toggleMute`, if the native player exposes no volume control, implement them as -no-ops (the dashboard volume slider then simply has no effect on local video — that -is acceptable and honest). `seek` takes milliseconds — convert to whatever unit the -player's seek expects. - -- [ ] **Step 2: Verify** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bunx biome check "app/(auth)/player/direct-player.tsx"` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add "app/(auth)/player/direct-player.tsx" -git commit -m "feat(player): register native-video PlaybackController" -``` - ---- - -## Task 7: Register the music `PlaybackController` - -**Files:** -- Modify: `providers/MusicPlayerProvider.tsx` - -- [ ] **Step 1: Build and register the controller** - -Read `providers/MusicPlayerProvider.tsx` and locate its playback controls (play / -pause / stop / seek / skip-next / skip-previous / volume — whatever it exposes for -the music UI). - -Add a memoised `PlaybackController` wrapping those, and register it with -`useRegisterPlaybackController(controller, isMusicActive)` where `isMusicActive` is -the provider's existing "is a track loaded / playing" condition. Reuse the -provider's existing control functions — do not add new playback logic. - -```tsx -import { - type PlaybackController, - useRegisterPlaybackController, -} from "@/utils/playback/playbackController"; -``` - -If the music player exposes no volume API, make `setVolume` / `toggleMute` no-ops. - -- [ ] **Step 2: Verify** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bunx biome check providers/MusicPlayerProvider.tsx` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add providers/MusicPlayerProvider.tsx -git commit -m "feat(music): register music PlaybackController" -``` - ---- - -## Task 8: Report the real cast `PlayMethod` - -**Files:** -- Modify: `utils/casting/castLoad.ts`, `utils/casting/mediaInfo.ts`, `hooks/useCasting.ts` - -- [ ] **Step 1: Carry `playMethod` in customData** - -In `utils/casting/mediaInfo.ts`, add an optional parameter to `buildCastMediaInfo`, -alongside `playSessionId` and `selection`: - -```ts - /** "Transcode" when the stream is a server transcode, else "DirectPlay". */ - playMethod?: "Transcode" | "DirectPlay"; -``` - -Destructure `playMethod` in the function signature, and add it to the -`slimCustomData` object (extend its inline type with `playMethod?: "Transcode" | -"DirectPlay"`, the same way `selection` was added). - -- [ ] **Step 2: Determine and pass `playMethod` in `castLoad.ts`** - -In `utils/casting/castLoad.ts`, inside `attemptLoad`, after `getStreamUrl` returns -`data`, determine the play method from whether the resolved media source is a -transcode: - -```ts - const playMethod: "Transcode" | "DirectPlay" = data.mediaSource?.TranscodingUrl - ? "Transcode" - : "DirectPlay"; -``` - -Pass `playMethod` into the `buildCastMediaInfo({ ... })` call. - -> `getStreamUrl` returns `{ url, sessionId, mediaSource }`; `mediaSource` is a -> `MediaSourceInfo` whose `TranscodingUrl` is set when the server chose to -> transcode. Confirm the returned shape in `utils/jellyfin/media/getStreamUrl.ts`. - -- [ ] **Step 3: Report it in `useCasting.ts`** - -In `hooks/useCasting.ts`, the progress reporting currently sets -`PlayMethod: activeProtocol === "chromecast" ? "DirectStream" : "DirectPlay"` in -both `reportPlaybackStart` and `reportPlaybackProgress`. Read the real play method -from customData near the existing `playSessionId` derivation: - -```ts - const playMethod = - (mediaStatus?.mediaInfo?.customData as - | { playMethod?: "Transcode" | "DirectPlay" } - | undefined)?.playMethod ?? "Transcode"; -``` - -Replace both hardcoded `PlayMethod:` expressions with `PlayMethod: playMethod`. Add -`playMethod` to the progress-reporting `useEffect` dependency array. - -> Default to `"Transcode"` — a cast stream is a transcode far more often than not, -> so it is the safer fallback when customData has not yet arrived. - -- [ ] **Step 4: Verify** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bun test utils/casting/` -Expected: PASS — all suites pass. - -- [ ] **Step 5: Commit** - -```bash -git add utils/casting/castLoad.ts utils/casting/mediaInfo.ts hooks/useCasting.ts -git commit -m "fix(casting): report the real PlayMethod to Jellyfin" -``` - ---- - -## Task 9: Conditional episode buttons - -**Files:** -- Modify: `components/casting/player/CastPlayerEpisodeControls.tsx` - -- [ ] **Step 1: Make Previous / Next conditional** - -Read `components/casting/player/CastPlayerEpisodeControls.tsx`. It receives -`episodes`, `nextEpisode`, and `currentItemId`. The Previous button currently -renders always (disabled when `episodes.findIndex(...) <= 0`); the Next button -renders always (disabled when `!nextEpisode`). - -Change both so they are **not rendered at all** when there is no adjacent episode: -- Previous: render only when `episodes.findIndex((e) => e.Id === currentItemId) > 0`. -- Next: render only when `nextEpisode` is truthy. - -Keep the Episodes-list and Stop buttons unconditional. Preserve the row layout — -if the row uses fixed spacing, ensure removing a button does not break alignment -(the remaining buttons should stay evenly placed; adjust the container's -`justifyContent` / gap only if needed, behaviour of the remaining buttons unchanged). - -- [ ] **Step 2: Verify** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bunx biome check components/casting/player/CastPlayerEpisodeControls.tsx` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add components/casting/player/CastPlayerEpisodeControls.tsx -git commit -m "feat(casting): hide episode buttons when no adjacent episode" -``` - ---- - -## Task 10: Fix the `loadEpisode` / `currentItem` race - -**Files:** -- Modify: `app/(auth)/casting-player.tsx` - -- [ ] **Step 1: Guard against the stale `currentItem`** - -Read `app/(auth)/casting-player.tsx`. `loadEpisode` (from `useCastEpisodes`) loads a -new episode on the cast; `currentItem` is derived from the cast `customData` and -updates only once the cast reports the new episode — leaving a window where -`currentItem` still describes the previous episode. - -Fix: track the id of the episode being loaded, and treat `currentItem` as "pending" -until the cast's `customData` item id matches it. Concretely: - -- Add a ref `pendingEpisodeIdRef` (a `useRef(null)`). -- When `loadEpisode` is invoked, set `pendingEpisodeIdRef.current` to the target - episode id. (Wrap the `loadEpisode` call sites, or wrap the function the cast - player passes to `CastPlayerEpisodeControls` / the episode list, so the ref is set - whenever an episode load starts.) -- Clear the ref once `currentItem?.Id === pendingEpisodeIdRef.current` (the cast has - caught up). -- While `pendingEpisodeIdRef.current` is set and does not match `currentItem?.Id`, - the player is mid-transition — guard the derivations that would act on the stale - item (the episode-controls indices and the selection menu) so they do not flash - the previous episode's data. The simplest correct guard: while pending, suppress - rendering the episode-dependent secondary UI, or show the loading state, until - `currentItem` matches. - -Keep this minimal and behaviour-preserving for the non-racing path — when no load -is pending, nothing changes. - -> This is a real but small bug. If, on reading the file, the cleanest fix is to -> have `useCastEpisodes` expose a `loadingEpisodeId` rather than a ref in the -> screen, do that instead — keep the fix where it reads most naturally. Report -> which approach you took. - -- [ ] **Step 2: Verify** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -Run: `bun test utils/casting/` -Expected: PASS. - -Run: `bunx biome check "app/(auth)/casting-player.tsx"` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add "app/(auth)/casting-player.tsx" -git commit -m "fix(casting): guard against stale currentItem during episode load" -``` - ---- - -## Final verification - -- [ ] **Checks** - -Run: `bun test utils/` → PASS (`remoteCommands` plus the existing `casting` suites). -Run: `bun run typecheck` → PASS. - -- [ ] **Manual verification** - -From the Jellyfin web dashboard's "Devices" / active-session view, with the app -casting: -- The session shows `Transcode` (not direct-play) for a transcoded stream. -- The remote panel's pause / play / stop / seek / next / previous control the cast. -- The volume control changes the cast volume; mute toggles it. -- "Send message" shows a toast in the app. -- Repeat with the native video player open (not casting): transport + message work; - volume is a no-op if the native player has no volume API. -- In the cast player, Previous is absent on the first episode, Next absent on the - last. -- Changing episode shows no flash of the previous episode's title/tracks. - ---- - -## Notes for the implementer - -- Line numbers drift — match on quoted code. -- `bun test` is Bun's native runner; `remoteCommands.ts` is pure (no imports) so its - test runs cleanly. Do not write a test that imports `useRemoteControl.ts` or - `playbackController.ts` — they pull React / jotai. -- Tasks 5-7 are integration: reuse each player's existing control functions, do not - reimplement playback logic. A `setVolume` / `toggleMute` no-op is acceptable where - a player has no volume API. -- Do NOT add a `Co-Authored-By` trailer to commit messages. -- Out of scope: track-switching remote commands, the custom receiver. diff --git a/docs/superpowers/plans/2026-05-22-chromecast-ux-player.md b/docs/superpowers/plans/2026-05-22-chromecast-ux-player.md deleted file mode 100644 index ba02cb084..000000000 --- a/docs/superpowers/plans/2026-05-22-chromecast-ux-player.md +++ /dev/null @@ -1,406 +0,0 @@ -# Chromecast Player UX — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Fix the trickplay/scrub-bubble positioning on both casting progress bars, share the bubble in one component, lighten the time display, add a mini-player stop button, and add a developer touch-zone overlay. - -**Architecture:** A shared `CastTrickplayBubble` component renders the scrub preview; both sliders feed it via `renderBubble` and set `bubbleWidth` so `react-native-awesome-slider` clamps the bubble itself (no manual positioning). A `DEBUG_TOUCH_ZONES` flag draws red hit-area outlines for hand-calibration. - -**Tech Stack:** TypeScript (strict), React Native / Expo, `react-native-awesome-slider`, `expo-image`. - -**Spec:** `docs/superpowers/specs/2026-05-22-chromecast-ux-player-design.md` - -**Environment note:** Windows checkout, `core.autocrlf=true` — project-wide `bun run check` reports ~124 pre-existing CRLF errors, unrelated. Gate: `bun run typecheck` (fully green) + Biome on edited files. - -**Commit note:** Do NOT add a `Co-Authored-By` trailer to commit messages. - ---- - -## File Structure - -| File | Responsibility | Task | -|---|---|---| -| `components/casting/player/CastTrickplayBubble.tsx` | Shared scrub-preview bubble (trickplay tile or plain time text) | 1 | -| `utils/casting/debug.ts` | `DEBUG_TOUCH_ZONES` flag | 4 | -| `components/casting/player/CastPlayerProgressBar.tsx` | Use the shared bubble + `bubbleWidth`; drop manual positioning; debug overlay | 2, 4 | -| `components/casting/CastingMiniPlayer.tsx` | Same bubble fix; add a stop button | 3 | -| `app/(auth)/casting-player.tsx` | Drop the now-dead `scrubPercentage` plumbing | 2 | -| `hooks/useCastPlayerProgress.ts` | Drop the now-dead `scrubPercentage` state/return | 2 | - ---- - -## Task 1: Shared `CastTrickplayBubble` component - -**Files:** -- Create: `components/casting/player/CastTrickplayBubble.tsx` - -- [ ] **Step 1: Create the component** - -Create `components/casting/player/CastTrickplayBubble.tsx`: - -```tsx -/** - * Shared scrub-preview bubble for the casting progress bars. - * - * Renders the trickplay tile (when trickplay data is available) with the scrub - * time as plain text above it, or just the scrub time as plain text. It does NO - * positioning of its own — the slider places it via its `bubbleWidth` prop. - */ - -import { Image } from "expo-image"; -import { View } from "react-native"; -import { Text } from "@/components/common/Text"; -import type { useTrickplay } from "@/hooks/useTrickplay"; -import { formatTrickplayTime } from "@/utils/casting/helpers"; - -type TrickplayReturn = ReturnType; - -interface CastTrickplayBubbleProps { - /** Current trickplay image URL/coordinates, or null. */ - trickPlayUrl: TrickplayReturn["trickPlayUrl"]; - /** Parsed trickplay metadata, or null. */ - trickplayInfo: TrickplayReturn["trickplayInfo"]; - /** Scrub time to display. */ - trickplayTime: { hours: number; minutes: number; seconds: number }; - /** Trickplay tile width in px (220 main player, 140 mini-player). */ - tileWidth: number; -} - -export function CastTrickplayBubble({ - trickPlayUrl, - trickplayInfo, - trickplayTime, - tileWidth, -}: CastTrickplayBubbleProps) { - const timeText = ( - - {formatTrickplayTime(trickplayTime)} - - ); - - // No trickplay: just the plain time text. - if (!trickPlayUrl || !trickplayInfo) { - return timeText; - } - - const { x, y, url } = trickPlayUrl; - const tileHeight = tileWidth / (trickplayInfo.aspectRatio ?? 1.78); - - return ( - - {timeText} - - - - - ); -} -``` - -- [ ] **Step 2: Verify types** - -Run: `bun run typecheck` -Expected: PASS — no errors. - -- [ ] **Step 3: Commit** - -```bash -git add components/casting/player/CastTrickplayBubble.tsx -git commit -m "feat(casting): add shared CastTrickplayBubble component" -``` - ---- - -## Task 2: Rework `CastPlayerProgressBar` - -**Files:** -- Modify: `components/casting/player/CastPlayerProgressBar.tsx` -- Modify: `app/(auth)/casting-player.tsx` -- Modify: `hooks/useCastPlayerProgress.ts` - -- [ ] **Step 1: Rework the slider in `CastPlayerProgressBar.tsx`** - -Read `components/casting/player/CastPlayerProgressBar.tsx`. Make these changes: - -**(a)** Replace the entire `renderBubble={() => { ... }}` prop (the ~125-line callback that builds the bubble with manual `position: "absolute"` / `left` / `thumbPosition` maths) with: - -```tsx - renderBubble={() => ( - - )} - bubbleWidth={trickPlayUrl && trickplayInfo ? 220 : 64} -``` - -(`bubbleWidth` tells the slider to clamp the bubble within the track; 220 = trickplay tile width, 64 = plain time-text width.) - -**(b)** Reduce `panHitSlop` to a sane default that does not overlap the controls above the bar — change it to: - -```tsx - panHitSlop={{ top: 12, bottom: 12, left: 10, right: 10 }} -``` - -(The exact values will be hand-calibrated by the user with the Task 4 overlay.) - -**(c)** `scrubPercentage` was only used by the deleted manual positioning. Remove `scrubPercentage` and `setScrubPercentage` from `CastPlayerProgressBarProps` and the destructured params, and remove the `setScrubPercentage(...)` call inside `onValueChange` (keep the rest of `onValueChange` — the `calculateTrickplayUrl` and `setTrickplayTime` logic). - -**(d)** Add the import `import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";`. Remove now-unused imports: `Image` (`expo-image`), `Dimensions`, `formatTrickplayTime`. Keep `Text`, `View`, `formatTime`, `calculateEndingTime`, `msToTicks`, `ticksToSeconds` — still used by the static time row and `onValueChange`. - -**(e)** The static current/ending/total time row at the bottom is unchanged. - -- [ ] **Step 2: Remove the dead `scrubPercentage` plumbing** - -In `app/(auth)/casting-player.tsx`: the `` element passes `scrubPercentage` and `setScrubPercentage` props — remove those two props from the element. - -In `hooks/useCastPlayerProgress.ts`: remove the `scrubPercentage` / `setScrubPercentage` state and their entries in the hook's return object and return-type interface. (If `scrubPercentage` turns out to be read somewhere else, leave it and report — but the only consumer was `CastPlayerProgressBar`'s manual positioning.) - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check components/casting/player/CastPlayerProgressBar.tsx "app/(auth)/casting-player.tsx" hooks/useCastPlayerProgress.ts` -Expected: PASS on these files. - -- [ ] **Step 4: Commit** - -```bash -git add components/casting/player/CastPlayerProgressBar.tsx "app/(auth)/casting-player.tsx" hooks/useCastPlayerProgress.ts -git commit -m "fix(casting): clamp trickplay bubble via slider bubbleWidth" -``` - ---- - -## Task 3: Rework `CastingMiniPlayer` - -**Files:** -- Modify: `components/casting/CastingMiniPlayer.tsx` - -- [ ] **Step 1: Apply the same bubble fix** - -Read `components/casting/CastingMiniPlayer.tsx`. Make these changes: - -**(a)** Replace the entire `renderBubble={() => { ... }}` prop (the ~110-line manual-positioning callback) with: - -```tsx - renderBubble={() => ( - - )} - bubbleWidth={trickPlayUrl && trickplayInfo ? 140 : 60} -``` - -**(b)** `scrubPercentage` is local state here (`const [scrubPercentage, setScrubPercentage] = useState(0);`) used only by the deleted positioning maths. Remove that `useState` and the `setScrubPercentage(...)` call inside `onValueChange` (keep the `calculateTrickplayUrl` / `setTrickplayTime` logic). - -**(c)** Add the import `import { CastTrickplayBubble } from "@/components/casting/player/CastTrickplayBubble";`. Remove now-unused imports: `Image` (only if no longer used — the mini-player still renders a poster `Image`, so keep `Image`), `Dimensions`, `formatTrickplayTime`. Verify each with a quick search before removing. - -- [ ] **Step 2: Add the stop button** - -In the mini-player's control area there is one button — the play/pause `Pressable` (icon `play`/`pause`). Add a **stop** button immediately before it (so the row reads: stop, play/pause), inside the same controls container: - -```tsx - {/* Stop button */} - { - e.stopPropagation(); - remoteMediaClient?.stop()?.catch((error: unknown) => { - console.error("[CastingMiniPlayer] Stop error:", error); - }); - }} - style={{ padding: 8 }} - > - - -``` - -Once the media stops, `mediaStatus.playerState` becomes `IDLE` and the component already returns `null` (it hides itself). `e.stopPropagation()` prevents the row's "open player" press from also firing. - -- [ ] **Step 3: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Run: `bunx biome check components/casting/CastingMiniPlayer.tsx` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add components/casting/CastingMiniPlayer.tsx -git commit -m "feat(casting): mini-player trickplay fix and stop button" -``` - ---- - -## Task 4: Touch-zone debug overlay - -**Files:** -- Create: `utils/casting/debug.ts` -- Modify: `components/casting/player/CastPlayerProgressBar.tsx` -- Modify: `components/casting/player/CastPlayerEpisodeControls.tsx` - -- [ ] **Step 1: Create the debug flag** - -Create `utils/casting/debug.ts`: - -```ts -/** - * Developer flag for visualising touch zones in the casting player. - * - * Flip to `true` to draw red outlines over the slider hit area and the control - * row, run the app, hand-calibrate `panHitSlop`, then flip back to `false`. - * Gate every use with `__DEV__` so it can never render in a release build. - */ -export const DEBUG_TOUCH_ZONES = false; -``` - -- [ ] **Step 2: Draw the slider hit-zone overlay in `CastPlayerProgressBar.tsx`** - -In `components/casting/player/CastPlayerProgressBar.tsx`, add the import: - -```tsx -import { DEBUG_TOUCH_ZONES } from "@/utils/casting/debug"; -``` - -The slider sits inside ``. The slider's -effective touch area is the slider plus its `panHitSlop`. Inside that container -`View`, after the ``, add a debug overlay that traces the hit area — -use the *same* `panHitSlop` values the `Slider` is given so the box matches: - -```tsx - {__DEV__ && DEBUG_TOUCH_ZONES && ( - - )} -``` - -(The `top/bottom/left/right` offsets mirror the `panHitSlop` from Task 2 step 1b — -if `panHitSlop` is changed, change these to match so the overlay stays accurate.) - -- [ ] **Step 3: Draw the control-row overlay in `CastPlayerEpisodeControls.tsx`** - -In `components/casting/player/CastPlayerEpisodeControls.tsx`, add the import: - -```tsx -import { DEBUG_TOUCH_ZONES } from "@/utils/casting/debug"; -``` - -The component's root is a `` (the absolute-positioned button row). As the -last child of that root `View`, add: - -```tsx - {__DEV__ && DEBUG_TOUCH_ZONES && ( - - )} -``` - -- [ ] **Step 4: Verify** - -Run: `bun run typecheck` -Expected: PASS — fully green. - -Temporarily set `DEBUG_TOUCH_ZONES` to `true`, run `bun run typecheck` again to -confirm both `__DEV__ && DEBUG_TOUCH_ZONES` branches still type-check, then set it -back to `false` before committing. - -Run: `bunx biome check utils/casting/debug.ts components/casting/player/CastPlayerProgressBar.tsx components/casting/player/CastPlayerEpisodeControls.tsx` -Expected: PASS on these files. - -- [ ] **Step 5: Commit** - -```bash -git add utils/casting/debug.ts components/casting/player/CastPlayerProgressBar.tsx components/casting/player/CastPlayerEpisodeControls.tsx -git commit -m "feat(casting): add DEBUG_TOUCH_ZONES overlay for hit-area calibration" -``` - ---- - -## Final verification - -- [ ] **Checks** - -Run: `bun run typecheck` → PASS. -Run: `bun test utils/` → PASS (unchanged suites). - -- [ ] **Manual verification** (Android emulator) - -- Scrub the main progress bar to the far left and far right — the trickplay - preview is never clipped at the screen edge and tracks the cursor; the time text - is plain white, legible, above the preview. -- Same on the mini-player progress bar. -- The mini-player stop button stops playback and the mini-player disappears. -- The 4 episode-row buttons are tappable (no slider-slop overlap). -- Flip `DEBUG_TOUCH_ZONES` to `true`: red outline on the slider hit area, lime - outline on the control row are visible — use them to confirm / hand-tune - `panHitSlop`, then set the flag back to `false`. - ---- - -## Notes for the implementer - -- Line numbers drift — match on quoted code. -- This is UI work; there is no pure logic to unit-test. `bun run typecheck` per task - plus the manual checks are the gate. -- Do NOT add a `Co-Authored-By` trailer to commit messages. -- The `renderBubble` blocks in `CastPlayerProgressBar.tsx` and `CastingMiniPlayer.tsx` - are near-identical today — both are replaced by the shared `CastTrickplayBubble`. -- Out of scope: the custom receiver, the queued feature ideas. diff --git a/docs/superpowers/specs/2026-05-21-chromecast-profiles-design.md b/docs/superpowers/specs/2026-05-21-chromecast-profiles-design.md deleted file mode 100644 index b794ded1a..000000000 --- a/docs/superpowers/specs/2026-05-21-chromecast-profiles-design.md +++ /dev/null @@ -1,251 +0,0 @@ -# Chromecast Device Profiles & Capability Detection — Design - -**Date:** 2026-05-21 -**Branch:** `refactor-chromecast` (PR #1402) -**Sub-project:** A (cornerstone) of the Chromecast refactor -**Status:** Approved design — pending implementation plan - ---- - -## 1. Problem - -Casting to Chromecast crashes for a large class of media: - -- `Media control channel status code 2100` (LOAD_FAILED) on movies and high-bitrate media. -- 5.1 / multichannel audio crashes the receiver (issue #1085). -- Casting reportedly only works at video bitrate ≤ 2 Mb/s (issue #1423). -- Movies fail to load entirely; some media loads forever. - -**Root cause:** the sender ships a device profile that does not match the actual -Chromecast. Two near-identical static profiles (`utils/profiles/chromecast.ts`, -`utils/profiles/chromecasth265.ts`) are selected by a single global boolean -`enableH265ForChromecast`. A 1st/2nd/3rd-gen Chromecast (H.264-only, 1080p, 2-channel -audio, no HEVC) can receive an HEVC / 10-bit / high-bitrate stream it cannot decode, -producing status 2100. - -Secondary defects in the load path: - -- `getStreamUrl` defaults `audioStreamIndex` to `0`, which is the video stream — wrong - audio track selection. -- `getStreamUrl` always takes `MediaSources[0]`, ignoring a requested `mediaSourceId`. -- Progress reporting uses `mediaInfo.contentId` (the item id) as `PlaySessionId` — the - Jellyfin dashboard cannot correlate the session. -- The load sequence (`getStreamUrl` → `buildCastMediaInfo` → `client.loadMedia`) is - duplicated in three places: `PlayButton.tsx`, and `casting-player.tsx` - (`reloadWithSettings` and `loadEpisode`). -- `loadMedia` failures are swallowed into `console.error` — the user sees nothing. - -## 2. Scope - -**In scope** - -- Per-device Chromecast capability detection. -- A profile builder replacing the two static profile files. -- A single unified cast-load function. -- The load-path bug fixes listed above. -- Settings migration from `enableH265ForChromecast` to an explicit profile mode. -- Failure resilience (downgrade-on-failure retry, user-visible errors). -- A test matrix to empirically calibrate conservative defaults. - -**Out of scope** (later sub-projects) - -- Audio / subtitle / quality track switching — sub-project B. -- Splitting the 52 KB `casting-player.tsx` — sub-project C. -- Episode navigation, remote controller, UX features — sub-project D. -- Custom Cast receiver (PR #1521) — separate later sub-project. - -## 3. Approach - -Approach 3 of the brainstorm: a **profile builder** driven by a **capability -registry** with a **conservative default**, plus **advanced override settings**. - -Detection produces smart defaults; power users can override. An unknown device always -falls back to a safe baseline (H.264 / 1080p / 2 channels) that cannot crash the -receiver. The user's test hardware (Chromecast 1/2/3 HD) maps exactly onto that -baseline. - -## 4. Architecture - -### New modules - -| File | Responsibility | -|---|---| -| `utils/casting/capabilities.ts` | `ChromecastCapabilities` type, model registry, `detectCapabilities(device, settings)` | -| `utils/casting/buildProfile.ts` | `buildChromecastProfile(caps)` → Jellyfin `DeviceProfile` | -| `utils/casting/castLoad.ts` | `loadCastMedia(...)` — unified load, used by all call sites | - -### Removed - -- `utils/profiles/chromecast.ts` -- `utils/profiles/chromecasth265.ts` - -### Modified - -- `components/PlayButton.tsx` — use `loadCastMedia`. -- `app/(auth)/casting-player.tsx` — `reloadWithSettings` and `loadEpisode` use `loadCastMedia`. -- `utils/atoms/settings.ts` — replace `enableH265ForChromecast`. -- Chromecast settings UI (`ChromecastSettings.tsx` / `ChromecastSettingsMenu.tsx`) — new controls. - -## 5. Capability model - -```ts -export interface ChromecastCapabilities { - /** HEVC 8-bit (Main profile) decode support. */ - hevc: boolean; - /** HEVC 10-bit (Main10) decode support. */ - hevc10bit: boolean; - /** Maximum video resolution height. */ - maxResolution: 1080 | 2160; - /** Maximum video bitrate in bits/second. */ - maxVideoBitrate: number; - /** Maximum audio channels the receiver can output. */ - maxAudioChannels: number; -} -``` - -### Conservative default - -Used for any unrecognised device. Equals a 1st/2nd/3rd-gen Chromecast: - -```ts -const CONSERVATIVE: ChromecastCapabilities = { - hevc: false, - hevc10bit: false, - maxResolution: 1080, - maxVideoBitrate: 8_000_000, // initial guess — calibrated by the test matrix - maxAudioChannels: 2, -}; -``` - -> `maxVideoBitrate` starts at 8 Mb/s. Issue #1423 (≤ 2 Mb/s) is suspicious — 2 Mb/s is -> below any real hardware limit and likely points to a transcode-readiness or HLS -> issue rather than a bitrate ceiling. The test matrix (§9) confirms the true value -> before this number is finalised. - -### Registry - -Keyed by `device.modelName`. Initial entries: - -- `"Chromecast"` — gen 1/2/3 → conservative baseline. -- `"Chromecast Ultra"` — HEVC, 2160p, higher bitrate, multichannel passthrough. -- `"Chromecast with Google TV"` — HEVC, HEVC 10-bit. -- `"Google TV Streamer"` — HEVC, HEVC 10-bit, 2160p. - -`modelName` is ambiguous (gen 1/2/3 all report `"Chromecast"`, and Google TV devices -may report a TV model string). This is acceptable: the registry only *upgrades* away -from the conservative baseline for confidently-identified devices; everything else -stays safe. - -### `detectCapabilities(device, settings)` - -1. Look up `device.modelName` in the registry; fall back to `CONSERVATIVE`. -2. Apply advanced settings overrides: - - `chromecastProfile: "force-hevc"` → `hevc: true`. - - `chromecastProfile: "force-h264"` → `hevc: false, hevc10bit: false`. - - `chromecastMaxBitrate` set → clamp `maxVideoBitrate`. -3. Return the merged capabilities. - -## 6. Profile builder - -`buildChromecastProfile(caps)` returns a Jellyfin `DeviceProfile`: - -- **Video codecs:** `h264` always; `hevc` only when `caps.hevc`. When `hevc` is - enabled but `caps.hevc10bit` is false, add a codec condition rejecting 10-bit so the - server transcodes 10-bit sources down. -- **Audio:** both `DirectPlayProfiles` and `CodecProfiles` constrained to - `caps.maxAudioChannels`. This forces multichannel audio to transcode to stereo — - fixes the 5.1 crash (#1085). The current profiles only constrain `CodecProfiles`, - leaving a direct-play path open. -- **Bitrate:** `MaxStreamingBitrate` / `MaxStaticBitrate` = `caps.maxVideoBitrate`. -- **Resolution:** codec condition capping height at `caps.maxResolution`. -- Subtitle and container profiles preserved from the current working profile. - -## 7. Unified cast load - -```ts -loadCastMedia({ - client, // RemoteMediaClient - device, // Cast Device — for capability detection - api, item, userId, - settings, - options: { audioStreamIndex?, subtitleStreamIndex?, maxBitrate?, mediaSourceId?, startPositionMs? }, -}): Promise<{ ok: true } | { ok: false; error }> -``` - -Sequence: - -1. `detectCapabilities(device, settings)` → `buildChromecastProfile`. -2. `getStreamUrl` with the built profile. -3. `buildCastMediaInfo`. -4. `client.loadMedia`. - -Replaces all three duplicated call sites. Bug fixes folded in: - -- **Audio index:** when `options.audioStreamIndex` is absent, resolve the media - source's `DefaultAudioStreamIndex` (or the first `Audio`-type stream) instead of - defaulting to `0`. -- **Media source:** pass and honour `mediaSourceId`; select the matching - `MediaSource` rather than `[0]`. -- **PlaySessionId:** use the real `PlaySessionId` returned by `getStreamUrl`'s - `getPlaybackInfo` call, not `contentId`. - -## 8. Settings - -Replace `enableH265ForChromecast: boolean` with: - -| Setting | Type | Default | -|---|---|---| -| `chromecastProfile` | `"auto" \| "force-hevc" \| "force-h264"` | `"auto"` | -| `chromecastMaxBitrate` | `number \| undefined` | `undefined` | - -**No migration.** `enableH265ForChromecast` is removed outright — no migration code. -`chromecastProfile` defaults to `"auto"`, the correct behaviour for almost every user. -Anyone who had H265 forced reconfigures it once. Avoids carrying migration logic for a -niche setting. - -UI: a profile-mode selector and an optional max-bitrate field in the Chromecast -settings screen. `"auto"` is presented as the recommended default; the overrides are -advanced controls. - -## 9. Resilience - -- **Downgrade-on-failure:** if `client.loadMedia` rejects with status 2100, retry - exactly once with a forced conservative profile (`force-h264`, 2 channels, low - bitrate). Handles unknown / mis-detected devices gracefully. A second failure - surfaces to the user. -- **User-visible errors:** a failed load shows an alert with an actionable message - instead of a silent `console.error`. - -## 10. Verification — test matrix - -A document, `docs/chromecast-test-matrix.md`, lists sample-file casting tests run -against the Chromecast HD test device: - -| Dimension | Values | -|---|---| -| Video codec | H.264, HEVC 8-bit, HEVC 10-bit | -| Audio | AAC stereo, AAC 5.1, AC3 5.1, DTS, TrueHD | -| Container | MP4, MKV, TS | -| Bitrate | low, ~8 Mb/s, ~16 Mb/s, source-max | - -Each row records: direct-play vs transcode, load result (OK / 2100 / infinite), notes. -Results calibrate `CONSERVATIVE.maxVideoBitrate` and confirm the true cause of #1423 -and the 5.1 crash before the conservative defaults are finalised. - -## 11. Success criteria - -- A movie that previously failed with 2100 plays on the Chromecast HD. -- 5.1 / multichannel media plays (transcoded to stereo) without crashing. -- High-bitrate media plays or transcodes cleanly within the calibrated limit. -- Audio track defaults to the correct (non-video) stream. -- The Jellyfin dashboard shows a correctly correlated cast session. -- The load sequence exists in exactly one place. -- `bun run typecheck` and `bun run check` pass. - -## 12. Risks - -- The true cause of #1423 / the 5.1 crash is not yet confirmed; the test matrix - (§10) is the gate before defaults are locked. If the cause is HLS handling rather - than a profile mismatch, the design extends with a targeted fix at that point. -- `modelName` ambiguity limits registry precision — mitigated by the conservative - default never producing an unplayable stream. diff --git a/docs/superpowers/specs/2026-05-21-chromecast-track-switching-design.md b/docs/superpowers/specs/2026-05-21-chromecast-track-switching-design.md deleted file mode 100644 index 03005dd86..000000000 --- a/docs/superpowers/specs/2026-05-21-chromecast-track-switching-design.md +++ /dev/null @@ -1,222 +0,0 @@ -# Chromecast Track Switching & Multi-Version — Design - -**Date:** 2026-05-21 -**Branch:** `refactor-chromecast` (PR #1402) -**Sub-project:** B of the Chromecast refactor -**Status:** Approved design — pending implementation plan - ---- - -## 1. Problem - -Audio, subtitle, and quality switching on the Chromecast player are unreliable. The -visible symptom: the UI labels a track (e.g. Japanese audio) while the cast actually -plays a different one (e.g. French). - -Root cause — track selection is built on React state that desyncs from what is -actually loaded on the receiver: - -- **Audio.** `selectedAudioTrackIndex` is reset to `null` by `loadEpisode`. It is - re-initialised only by a `useEffect` keyed on `fetchedItem`, which does **not** - refresh on an episode change (`currentItem` updates from cast `customData`, not a - refetch). So after an episode change the index stays `null` and the menu falls back - to `availableAudioTracks[0]` — an arbitrary track, not the server default that is - actually playing. -- **Quality.** `selectedMediaSource` is hardcoded to `availableMediaSources[0]` - ("Max"). There is no state for it at all — picking a quality never updates the UI. -- **`availableMediaSources` is fake.** It is a list of synthetic bitrate variants - (Max / 8 / 4 / 2 / 1 Mb/s) dressed up as `MediaSource` objects. The item's real - `MediaSources` (multi-version files) are ignored — only `[0]` is ever used. -- **No source of truth.** A transcoded cast stream collapses to a single baked-in - audio track, so the only truth for "what is selected" is the set of indices last - sent to `getStreamUrl`. Today that truth is scattered across React state that drifts. - -## 2. Scope - -**In scope** - -- Reliable switching of audio track, subtitle track, quality (bitrate cap), and - version (real `MediaSource`). -- A single source of truth for the active selection, so the UI always reflects what - is actually loaded on the cast. -- A real multi-version selector built from the item's actual `MediaSources`, - separated from the bitrate "Quality" axis. - -**Out of scope** (other sub-projects) - -- Subtitle rendering and styling — sub-project: custom receiver. -- Sidecar VTT vs burned-in vs receiver-rendered subtitle delivery — a single - decision belonging to the custom-receiver sub-project (see §10). -- Splitting `casting-player.tsx` (52 KB) — sub-project C. -- Remote-control panel, episode navigation — sub-project D. - -## 3. Subtitle delivery - -Sub-project B keeps the **current burned-in** delivery: the server burns the chosen -subtitle into the transcoded video; a change triggers a stream reload like any other. -B treats a subtitle purely as an index in the selection model — it is **receiver -delivery-agnostic** and contains no burned-in/sidecar branching. - -The burned-in vs sidecar-VTT vs receiver-rendered decision is deferred in full to the -custom-receiver sub-project, where an actual receiver type exists to detect. This -mirrors sub-project A, which decoupled the custom receiver from the crash fixes. - -## 4. The selection model - -A single object represents everything loaded on the cast: - -```ts -export interface CastSelection { - /** Which MediaSource (version) is playing. */ - mediaSourceId: string; - /** Absolute MediaStream index of the audio track. */ - audioStreamIndex: number; - /** Absolute MediaStream index of the subtitle track; -1 = subtitles off. */ - subtitleStreamIndex: number; - /** Quality cap in bits/second; undefined = unconstrained. */ - maxBitrate?: number; -} -``` - -`resolveSelection(item, partial)` produces a complete `CastSelection` from a partial -one by filling missing fields with server defaults: - -- `mediaSourceId` → the requested source, else the item's first `MediaSource`. -- `audioStreamIndex` → `resolveDefaultAudioIndex(item, mediaSourceId)` (the helper - added in sub-project A — reuse it). -- `subtitleStreamIndex` → the source's `DefaultSubtitleStreamIndex`, else `-1`. -- `maxBitrate` → passed through (`undefined` allowed). - -Used on first load and on every episode change. On an explicit switch the caller -already holds the full current selection, so it merges `{...current, ...partial}`. - -## 5. Source of truth — customData (approach A3) - -`loadCastMedia` knows the exact `CastSelection` it sent. It embeds the resolved -selection into the Cast `customData`, exactly as sub-project A did with -`playSessionId`. `buildCastMediaInfo`'s slim `customData` gains `selection`. - -The Default Media Receiver echoes `customData` back in `mediaStatus.mediaInfo`. -`casting-player` reads `mediaStatus.mediaInfo.customData.selection` — that is the -**truth**: what is actually loaded on the cast. It cannot desync, it survives -leaving and re-entering the player, and it survives app backgrounding. - -### Optimistic pending state - -A switch triggers a stream reload (re-transcode), which takes a few seconds. To keep -the UI responsive: - -1. The user picks a track. The UI sets a local `pendingSelection` (the chosen value) - and triggers the reload. -2. The UI renders `pendingSelection` immediately. -3. When the new `mediaStatus` arrives with a `customData.selection` that matches the - request, `pendingSelection` is cleared and the UI reads the truth again. -4. If the reload fails, `pendingSelection` is cleared and the UI reverts to the truth. - -The UI's effective selection is: `pendingSelection ?? customData.selection ?? -`. - -## 6. `useCastSelection` hook - -A new hook, `hooks/useCastSelection.ts`, encapsulates approach A3: - -- Reads `customData.selection` from `mediaStatus` as the truth. -- Holds the `pendingSelection` and clears it on reconciliation or failure. -- Exposes `currentSelection` (the effective selection) and `applySelection(partial)`, - which merges the partial into the current selection, sets pending, and invokes a - caller-supplied reload callback. - -The reload itself stays in `casting-player` (it owns `remoteMediaClient`, -`castDevice`, `api`). The hook owns only selection state. This keeps `casting-player` -thinner and gives sub-project C (the file split) a clean unit to extract. - -## 7. `casting-player.tsx` rework - -- **Remove:** raw `selectedAudioTrackIndex` / `selectedSubtitleTrackIndex` state, the - synthetic `availableMediaSources` bitrate generator, the hardcoded - `selectedMediaSource={availableMediaSources[0]}`, and the `setSelected…(null)` calls - in `loadEpisode`. -- **Add:** `useCastSelection` for the active selection. -- `availableVersions` — the item's real `currentItem.MediaSources` (id, name, - bitrate, container). -- `availableQualities` — the bitrate-cap options, a **separate axis** from version; - this is the real `maxStreamingBitrate` transcode cap, no longer disguised as media - sources. It reuses the app-wide `BITRATES` constant - (`components/BitrateSelector.tsx`) — no cast-specific list — and `BITRATES` is - expanded to the fuller Jellyfin Android TV bitrate ladder so every surface (cast, - native player, downloads) gains the extra tiers. The cast menu filters `BITRATES` - to tiers at or below **both** the connected device's `maxVideoBitrate` (from - sub-project A's `detectCapabilities`) and the media's own bitrate, so only - meaningful tiers are shown. -- `availableAudioTracks` / `availableSubtitleTracks` — derived from the **selected - version's** `MediaStreams`, not always `MediaSources[0]`. -- Every "selected" indicator reads from `currentSelection`. - -## 8. `ChromecastSettingsMenu.tsx` rework - -Distinct sections: **Version** (shown only when the item has more than one -`MediaSource`), **Quality** (bitrate cap), **Audio**, **Subtitles**, **Speed**. Each -section's selected row is driven by `currentSelection` — the `[0]` fallback is gone. - -## 9. Episode change - -`loadEpisode` calls `loadCastMedia`, which resolves the new episode's defaults via -`resolveSelection` and embeds them in `customData`. `useCastSelection` reads the new -`customData.selection`, so the UI re-syncs automatically. The `setSelected…(null)` -calls are removed. This fixes the "UI says Japanese, plays French" bug at its root. - -## 10. Reload semantics - -Every audio / subtitle / quality / version change is a `loadCastMedia` reload -(re-transcode) that resumes at the current position — the same mechanism -`loadEpisode` already uses. Burned-in subtitles mean a subtitle change reloads too; -this is consistent with audio. The `pendingSelection` covers the re-buffer gap. - -## 11. Files - -| File | Change | -|---|---| -| `utils/casting/types.ts` | Add `CastSelection` | -| `utils/casting/castLoad.ts` | Add `resolveSelection`; embed the resolved selection in the load | -| `utils/casting/mediaInfo.ts` | `buildCastMediaInfo` customData carries `selection` | -| `hooks/useCastSelection.ts` | New — A3 selection state (truth + pending) | -| `app/(auth)/casting-player.tsx` | Replace track state with `useCastSelection`; real versions + separate quality axis | -| `components/chromecast/ChromecastSettingsMenu.tsx` | Separate Version / Quality sections; selected rows from `currentSelection` | -| `components/BitrateSelector.tsx` | Expand the shared `BITRATES` ladder (Jellyfin Android TV tiers) | - -The `BITRATES` expansion is a one-array change to a shared constant. It is -deliberately app-wide: the native player and downloads bitrate menus gain the same -tiers, which is the requested behaviour. - -## 12. Testing - -Pure, unit-testable with `bun test`: - -- `resolveSelection` — default resolution for each field, partial merge. -- The effective-selection merge (`pending ?? truth ?? default`). - -UI and integration paths are verified by `bun run typecheck` and manual casting. - -## 13. Success criteria - -- Switching audio: the UI label always matches the track that actually plays. -- Switching subtitle, quality, and version all reflect in the UI and apply to the - stream. -- After an episode change, the UI shows the new episode's real default tracks — the - Japanese/French desync is gone. -- Leaving and re-entering the casting player preserves the correct selection display. -- A multi-version item shows a working Version selector. -- `bun run typecheck` passes; `bun test utils/casting/` passes. - -## 14. Risks - -- The customData round-trip depends on the receiver echoing `customData` in - `mediaStatus` — confirmed working in sub-project A via `playSessionId`. -- During a reload there is a brief window where `customData` is stale; the - `pendingSelection` overlay covers it. -- Multi-version **episodes** require Jellyfin 12.0 (PR #16828); multi-version - **movies** work on current Jellyfin. The Version selector simply shows whatever - `MediaSources` the item exposes, so it degrades gracefully on older servers. -- The downgrade-on-failure retry (sub-project A) clamps bitrate internally; the - `customData.selection` reflects the user's intended selection, not the retry's - internal clamp. This is intentional — the selection model represents user choice. diff --git a/docs/superpowers/specs/2026-05-22-chromecast-autoplay-countdown-design.md b/docs/superpowers/specs/2026-05-22-chromecast-autoplay-countdown-design.md deleted file mode 100644 index 5fb674c0d..000000000 --- a/docs/superpowers/specs/2026-05-22-chromecast-autoplay-countdown-design.md +++ /dev/null @@ -1,176 +0,0 @@ -# Chromecast Autoplay & Next-Episode Countdown — Design - -**Date:** 2026-05-22 -**Branch:** `refactor-chromecast` (PR #1402) -**Sub-project:** Autoplay + countdown, of the Chromecast refactor -**Status:** DEFERRED — paused pending the chapters sub-project. - -> This design is sound but on hold. During brainstorming it became clear the -> autoplay trigger should fire at the **Outro / credits media segment** (so the -> countdown runs over the credits, no black screen), not only at the hard -> `IDLE + FINISHED` end. That trigger builds naturally on chapter / media-segment -> work, so the **chapters sub-project is being done first**. When resuming: -> revise §3 to a two-path trigger — (a) playback enters the Outro segment when -> `skipOutro` is not `auto`, (b) `IDLE + FINISHED` as fallback — and fold §9 into -> §3. The next episode's intro is already auto-skipped by the existing -> `useSegmentSkipper` (`skipIntro`), so binge-watching needs no extra intro work. - ---- - -## 1. Problem & goal - -When an episode finishes on the Chromecast, playback stops — there is no -auto-advance to the next episode. Binge-watching requires the user to pick up the -phone and load the next episode manually. - -**Goal:** when a cast episode finishes, automatically continue to the next episode, -with a cancellable countdown. This must work **whenever casting**, regardless of -which app screen the phone shows — the video is on the TV, the phone may be on the -home screen or locked. - -## 2. Scope - -**In scope:** detect a finished cast episode, resolve the next episode, run a -cancellable countdown, load the next episode, honour the shared autoplay settings. - -**Out of scope (future evolution — see §9):** triggering the prompt at the start of -the credits/outro instead of at the hard end of the episode; a "skip credits" -button. These depend on chapter / media-segment work not yet in place. - -## 3. Episode-end detection - -A watcher subscribes to `useMediaStatus()` from `react-native-google-cast`. - -- An episode has **finished** when `playerState === MediaPlayerState.IDLE` **and** - `idleReason === MediaPlayerIdleReason.FINISHED`. Other idle reasons - (`CANCELLED`, `INTERRUPTED`, `ERROR`) mean the user stopped or a failure — **no - autoplay**. -- At the moment `IDLE` is reported, `mediaStatus.mediaInfo` is already cleared. So - the watcher must **continuously capture the currently-playing item** (and its - series context) while playback is active, and use that captured item when the - `IDLE + FINISHED` transition fires. - -## 4. Always-mounted watcher - -Autoplay must fire regardless of the active screen, so the logic cannot live in the -casting-player screen (not always mounted) nor in `CastingMiniPlayer` (it returns -`null` — and unmounts its render — exactly when the episode ends). - -- `useCastAutoplay` — a hook holding the watcher logic. -- `CastAutoplayWatcher` — a tiny component that calls `useCastAutoplay()` and - renders `null`. It is mounted once, next to where `CastingMiniPlayer` is mounted - in the layout, so it runs for the whole app lifetime. - -On an `IDLE + FINISHED` transition the watcher: confirms the finished item is an -episode, resolves the next episode (§7), checks the settings (§6), and — if all -clear — starts the countdown (§5). - -## 5. Countdown & overlay - -The countdown state lives in a Jotai atom so the watcher can drive it and any -screen can render it: - -```ts -// utils/atoms/castAutoplay.ts -type CastAutoplayState = { - nextEpisode: BaseItemDto; - secondsRemaining: number; -} | null; -``` - -- The watcher sets the atom to `{ nextEpisode, secondsRemaining: 30 }` and runs a - 1-second interval decrementing `secondsRemaining`. -- At `0`: the watcher loads `nextEpisode` on the cast (`loadCastMedia`), increments - `autoPlayEpisodeCount`, and clears the atom. -- **Countdown duration: 30 seconds**, a single named constant. - -**Overlay** — `CastAutoplayCountdown`, rendered inside `casting-player.tsx`. When -the atom is set it shows: the next episode's poster + title, "Next episode in -{n}s", and two buttons — **Play now** (load immediately) and **Cancel** (clear the -atom; no load). Because the overlay lives in the casting-player screen, it is -visible only when that screen is open. - -When the casting-player screen is **not** open, the watcher still counts down and -loads — there is no intrusive full-screen overlay; a short toast is shown when the -next episode actually loads ("Playing next episode"). The toast fires -unconditionally (harmless when the overlay was also shown). - -## 6. Settings (shared with the native player) - -Reuse the existing settings in `utils/atoms/settings.ts` — no new settings: - -- `autoPlayNextEpisode` (boolean, default `true`) — master on/off. When `false`, - the watcher does nothing. -- `autoPlayEpisodeCount` (number) — running count of consecutive autoplays. - Incremented each time the countdown completes a load. -- `maxAutoPlayEpisodeCount` (`{ key, value }`, default `3`) — when - `autoPlayEpisodeCount >= maxAutoPlayEpisodeCount.value`, the watcher does **not** - start a countdown (autoplay pauses; the user resumes manually). This mirrors the - native player's "are you still watching?" gate. -- The count resets to `0` on any **manual** play — "Play now" in the overlay, or - loading an episode from the episode list. (Manual intent restarts the streak.) - -## 7. Next-episode resolution - -`useCastEpisodes` already fetches a series' episodes and computes the next one, but -it is tied to the casting-player screen. Extract the reusable part — "given an -episode item, fetch its series' episodes and return the next one" — into a shared -async helper (e.g. `utils/casting/episodes.ts`). Both `useCastEpisodes` and -`useCastAutoplay` use it, so the next-episode logic exists once. - -## 8. Units & files - -**Created** -- `utils/atoms/castAutoplay.ts` — the countdown-state atom + the duration constant. -- `utils/casting/episodes.ts` — reusable "resolve next episode" helper. -- `hooks/useCastAutoplay.ts` — the watcher hook. -- `components/casting/CastAutoplayWatcher.tsx` — always-mounted host (renders null). -- `components/casting/player/CastAutoplayCountdown.tsx` — the countdown overlay. - -**Modified** -- The layout file where `CastingMiniPlayer` is mounted — also mount - `CastAutoplayWatcher`. -- `app/(auth)/casting-player.tsx` — render `CastAutoplayCountdown`. -- `hooks/useCastEpisodes.ts` — use the extracted `utils/casting/episodes.ts` helper. - -## 9. Future evolution — credits trigger - -The watcher's **trigger point** is deliberately isolated: today it is the -`IDLE + FINISHED` transition. When Jellyfin media-segment / chapter data for -"Outro" / "Credits" is exploited on the cast, the trigger should move to the -**start of the credits** — the countdown overlay appears over the still-playing -credits, with an added "skip credits" action. Only the trigger changes; the -countdown, atom, overlay, settings and load path stay as designed. The -implementation must keep the trigger detection in one clearly-separable place so -this swap is a localised change. - -## 10. Testing - -- Unit-test the pure logic with `bun test`: the next-episode resolution (given an - episode list + current episode → the correct next episode, or none for the last - episode) and the "should autoplay?" decision (settings + idle reason → start - countdown or not). -- Manual: cast an episode, let it finish — the countdown overlay appears, counts - from 30, and the next episode loads; "Cancel" stops it; "Play now" loads - immediately; with the casting-player screen closed the next episode still loads; - after `maxAutoPlayEpisodeCount` consecutive autoplays it stops. - -## 11. Success criteria - -- A finished cast episode auto-advances to the next, with a 30s cancellable - countdown, regardless of the active app screen. -- Autoplay respects `autoPlayNextEpisode` and stops after `maxAutoPlayEpisodeCount`. -- `CANCELLED` / `ERROR` idle reasons never trigger autoplay. -- The next-episode resolution lives in one shared helper. -- `bun run typecheck` and `bun test` pass. - -## 12. Risks - -- The captured-item timing: the watcher must capture the playing item *before* - `IDLE`. Capture it on every `mediaStatus` change while a real item is playing. -- The watcher and the casting-player screen could both react to the same media - state — the atom is the single source of truth; only the watcher writes the - countdown, only the overlay reads it. -- Loading the next episode requires the API/user context outside the player - screen — `loadCastMedia` is a standalone function and the watcher can obtain - `api` / `user` from the Jotai atoms, so this is available app-wide. diff --git a/docs/superpowers/specs/2026-05-22-chromecast-player-split-design.md b/docs/superpowers/specs/2026-05-22-chromecast-player-split-design.md deleted file mode 100644 index 37aed5171..000000000 --- a/docs/superpowers/specs/2026-05-22-chromecast-player-split-design.md +++ /dev/null @@ -1,118 +0,0 @@ -# Chromecast Player Split — Design - -**Date:** 2026-05-22 -**Branch:** `refactor-chromecast` (PR #1402) -**Sub-project:** C of the Chromecast refactor -**Status:** Approved design — pending implementation plan - ---- - -## 1. Problem - -`app/(auth)/casting-player.tsx` is a 1428-line god-component. One file owns: cast SDK -hooks, item fetching, live-progress tracking, slider/scrubbing, trickplay, track -selection, episode/season fetching, segment skipping, the dismiss gesture, and ~780 -lines of JSX. It is hard to read, hard to change safely, and every sub-project so far -has had to navigate it. - -## 2. Scope - -**In scope:** a purely structural decomposition of `casting-player.tsx` into 4 custom -hooks, 6 presentational components, and a thin orchestrator. - -**Out of scope — and a hard constraint:** **zero behaviour change.** This is a -mechanical extraction. No bug fixes, no feature changes, no logic edits. Known issues -in this code (the `loadEpisode` / `currentItem` race from sub-project A's review; the -trickplay window truncation, the progress-bar touch overlap, the time-label position) -are explicitly NOT touched here — they belong to a later UX sub-project, and are -easier to fix once the code lives in small focused files. - -## 3. Architecture - -`casting-player.tsx` becomes an orchestrator (~150-200 lines): it calls the hooks, -assembles the data, and renders the 6 components plus the 3 existing modal components. - -State flows orchestrator → components by **props** (approved approach 1) — no React -context. Presentational components take typed props; all logic lives in hooks. - -## 4. Hooks (`hooks/`) - -| Hook | Extracts | Returns (shape) | -|---|---|---| -| `useCastPlayerItem` | `fetchedItem` state, its fetch effect, the `currentItem` derivation | `{ fetchedItem, currentItem }` | -| `useCastPlayerProgress` | slider shared values, `isScrubbing`, `trickplayTime`, `scrubPercentage`, `liveProgress`, the sync refs/effects, the `useTrickplay` call | slider state, scrub handlers, `progress`, trickplay data | -| `useCastEpisodes` | `episodes` / `nextEpisode` / `seasonData` state, their fetch effects, `loadEpisode` | `{ episodes, nextEpisode, seasonData, loadEpisode }` | -| `useCastDismissGesture` | `translateY`, `context`, `panGesture`, `dismissModal`, `animatedStyle` | `{ panGesture, animatedStyle, dismissModal }` | - -Existing hooks are reused unchanged: `useCasting`, `useCastSelection`, -`useChromecastSegments`, `useTrickplay` (the latter called inside -`useCastPlayerProgress`). - -`useCastPlayerProgress` is the most intricate hook (shared values, refs, the -live-progress interpolation) — it must be extracted with care and reviewed closely. - -## 5. Components (`components/casting/player/`) - -| Component | Extracts (JSX section) | -|---|---| -| `CastPlayerHeader` | dismiss chevron, connection indicator, settings button | -| `CastPlayerTitle` | title + episode/season info | -| `CastPlayerPoster` | poster image, buffering overlay, skip intro/credits bar | -| `CastPlayerEpisodeControls` | the 4-button row (Episodes / Previous / Next / Stop) | -| `CastPlayerProgressBar` | slider, trickplay preview, time display | -| `CastPlayerTransportControls` | rewind / play-pause / forward | - -Each is a pure presentational component with typed props. The 3 modal components -(`ChromecastDeviceSheet`, `ChromecastEpisodeList`, `ChromecastSettingsMenu`) already -exist and stay as-is — the orchestrator keeps rendering them. - -## 6. Constraints - -- **Mechanical extraction only.** Each component's / hook's props and returns mirror - exactly what the inline code used. No new logic, no renamed behaviour, no fixes. -- Component prop interfaces are derived from what the extracted JSX references in the - original component scope. -- Follow existing repo conventions: hooks flat in `hooks/`; the new components grouped - under `components/casting/player/`. - -## 7. Verification - -The casting UI has no unit tests (consistent with the rest of the casting UI — it is -React Native / SDK-heavy). Verification is: - -- `bun run typecheck` — green after every task (catches wiring/prop errors). -- `bun test utils/casting/` — stays green (the pure-logic suites are untouched). -- A full **manual re-test** of the cast player after the split: cast a movie and an - episode, switch audio / subtitle / quality / version, navigate episodes, scrub the - progress bar, trigger trickplay, dismiss the player. Behaviour must be identical to - before the split. - -## 8. Files - -**Created:** -- `hooks/useCastPlayerItem.ts`, `hooks/useCastPlayerProgress.ts`, - `hooks/useCastEpisodes.ts`, `hooks/useCastDismissGesture.ts` -- `components/casting/player/CastPlayerHeader.tsx`, `CastPlayerTitle.tsx`, - `CastPlayerPoster.tsx`, `CastPlayerEpisodeControls.tsx`, - `CastPlayerProgressBar.tsx`, `CastPlayerTransportControls.tsx` - -**Modified:** -- `app/(auth)/casting-player.tsx` → thin orchestrator. - -## 9. Success criteria - -- `casting-player.tsx` is ~150-200 lines and contains no inline logic clusters or - large JSX sections — only hook calls and component composition. -- Each new file has one clear responsibility and a typed interface. -- `bun run typecheck` and `bun test utils/casting/` pass. -- The manual re-test shows behaviour identical to before the split. - -## 10. Risks - -- The hooks share state (progress feeds the slider, the time display, and segment - skipping). The extraction must preserve the exact data flow and effect ordering. - Mitigation: one hook / component per task, `bun run typecheck` after each, and a - full manual re-test at the end. -- `useCastPlayerProgress` carries reanimated shared values and timing refs — the - highest-risk extraction; its task gets the closest review. -- No unit tests guard the UI — the manual re-test is the only behavioural safety net. diff --git a/docs/superpowers/specs/2026-05-22-chromecast-session-remote-control-design.md b/docs/superpowers/specs/2026-05-22-chromecast-session-remote-control-design.md deleted file mode 100644 index 5a33d2734..000000000 --- a/docs/superpowers/specs/2026-05-22-chromecast-session-remote-control-design.md +++ /dev/null @@ -1,181 +0,0 @@ -# Casting Session Reporting & Remote Control — Design - -**Date:** 2026-05-22 -**Branch:** `refactor-chromecast` (PR #1402) -**Sub-project:** D of the Chromecast refactor -**Status:** Approved design — pending implementation plan - ---- - -## 1. Problem - -Three issues plus one missing feature: - -- **PlayMethod is wrong.** `useCasting.ts` reports a hardcoded `PlayMethod: - "DirectStream"` for every cast session. When the cast stream is a transcode - (almost always, given a HEVC-heavy library), the Jellyfin dashboard still labels - it "direct-play". The dashboard only reflects what the app reports. -- **Episode buttons are unconditional.** The Previous / Next buttons in the cast - player are always shown and active, even when there is no adjacent episode. -- **`loadEpisode` / `currentItem` race.** During an episode change, `currentItem` - (derived from cast `customData`) briefly still points at the previous episode - while the new one loads — flagged in sub-project B's review. -- **Remote control does not exist.** `WebSocketProvider` advertises - `SupportedCommands: ["Play"]` and handles only the `Play` WebSocket message. The - Jellyfin dashboard's remote-control panel (pause / stop / seek / message) sends - `Playstate` and `GeneralCommand` messages the app ignores — for the native player - too, not just casting. The panel does nothing because the feature was never built. - -## 2. Scope - -**In scope** - -- Report the correct `PlayMethod` (`Transcode` vs `DirectPlay`) for cast sessions. -- Show / hide the Previous / Next episode buttons based on adjacent-episode - availability. -- Fix the `loadEpisode` / `currentItem` stale-window race. -- Implement app-wide Jellyfin remote control: handle `Playstate` and - `GeneralCommand` WebSocket messages (transport + volume + DisplayMessage), routed - to whichever player is active — cast, native video, or music. - -**Out of scope** - -- Track-switching remote commands (`SetAudioStreamIndex` / `SetSubtitleStreamIndex`). -- The custom Cast receiver. - -## 3. The `PlaybackController` contract - -The app has no unified playback-control abstraction — `usePlaybackManager` only does -progress reporting and metadata. Remote control needs one. Introduce a single -interface — the canonical control surface every player implements: - -```ts -export interface PlaybackController { - playPause(): void; - pause(): void; - unpause(): void; - stop(): void; - /** Absolute seek position in milliseconds. */ - seek(positionMs: number): void; - next(): void; - previous(): void; - /** Volume 0–1. */ - setVolume(level: number): void; - toggleMute(): void; -} -``` - -A Jotai atom holds the currently-active controller: - -```ts -export const activePlaybackControllerAtom = atom(null); -``` - -This is not duplicated code — each player *implements* the same small contract; the -cast already has every method in `useCasting`. - -## 4. Registration - -Each playback source registers its controller into the atom when it becomes the -active playback, and clears it when it stops: - -- **Cast** — a controller built from `useCasting` (`togglePlayPause`, `pause`, - `play`, `stop`, `seek`, `setVolume`) plus episode `next` / `previous`. Registered - while a cast session is playing; cleared on disconnect. -- **Native video player** — `app/(auth)/player/direct-player.tsx` registers a - controller wrapping its player controls on mount; clears on unmount. -- **Music** — `MusicPlayerProvider` registers a controller while music plays. - -**Priority:** a cast session takes precedence (if you are casting, the dashboard -controls the cast); then the native video player; then music. In practice only one -plays at a time, so registration is effectively last-active-wins with cast given -precedence. - -## 5. WebSocket remote-control handler - -A dedicated hook `useRemoteControl`, consumed by `WebSocketProvider`, handles the -incoming messages: - -- `Playstate` — map `Data.Command` to the active controller: - `PlayPause`/`Pause`/`Unpause`/`Stop`, `Seek` (uses `Data.SeekPositionTicks`), - `NextTrack`/`PreviousTrack` → `next`/`previous`. -- `GeneralCommand` — `Data.Name`: `SetVolume` (→ `setVolume`, from - `Data.Arguments.Volume`), `ToggleMute`/`Mute`/`Unmute` (→ `toggleMute`), - `DisplayMessage` → an in-app toast via `sonner-native` (no controller involved). -- The existing `Play` handling is preserved unchanged. -- If no controller is registered, transport/volume commands are ignored; - `DisplayMessage` and `Play` still work. - -The string→action mapping is a pure function, unit-tested. - -## 6. Capabilities - -`WebSocketProvider.postFullCapabilities` currently sends `SupportedCommands: -["Play"]`. Expand it to the `GeneralCommandType` values actually handled — -`["DisplayMessage", "SetVolume", "ToggleMute", "Mute", "Unmute"]` (plus `Play`). -`SupportsMediaControl: true` is already set, which is what enables the dashboard to -send `Playstate` commands. - -## 7. Small fixes - -- **PlayMethod.** `loadCastMedia` already knows whether the resolved stream is a - transcode (`getStreamUrl`'s media source carries a `TranscodingUrl`). Embed a - `playMethod: "Transcode" | "DirectPlay"` in the cast `customData` — the same - pattern as `playSessionId` and `selection` from sub-projects A and B. `useCasting` - reads it and reports it in `reportPlaybackStart` / `reportPlaybackProgress` - instead of the hardcoded `"DirectStream"`. -- **Conditional episode buttons.** `CastPlayerEpisodeControls` already receives - `episodes` and `nextEpisode`. Hide (or disable) Previous when the current item is - the first episode, and Next when there is no next episode. -- **`loadEpisode` race.** While an episode load is in flight, guard against acting - on a stale `currentItem`: track the loading episode id and treat `currentItem` as - pending until the cast `customData` reflects the new episode. - -## 8. Files - -**Created** -- `utils/playback/playbackController.ts` — the `PlaybackController` interface and - `activePlaybackControllerAtom`. -- `hooks/useRemoteControl.ts` — the WebSocket remote-control message handler. - -**Modified** -- `providers/WebSocketProvider.tsx` — consume `useRemoteControl`; expand - `SupportedCommands`. -- `hooks/useCasting.ts` — report the real `PlayMethod`. -- `utils/casting/castLoad.ts`, `utils/casting/mediaInfo.ts` — embed `playMethod` in - `customData`. -- `components/casting/player/CastPlayerEpisodeControls.tsx` — conditional buttons. -- `app/(auth)/casting-player.tsx` — register the cast `PlaybackController`; fix the - `loadEpisode` race. -- `app/(auth)/player/direct-player.tsx` — register the native-video controller. -- `providers/MusicPlayerProvider.tsx` — register the music controller. - -## 9. Testing - -Unit-testable with `bun test`: -- The remote-command mapping (WS `Command` / `Name` string → controller action). - -Routing, registration, capabilities, and the `DisplayMessage` toast are verified by -`bun run typecheck` and manual testing (drive the cast and native player from the -Jellyfin dashboard's remote-control panel). - -## 10. Success criteria - -- The Jellyfin dashboard shows `Transcode` for a transcoded cast session. -- The dashboard remote panel's pause / stop / seek / next / previous / volume - buttons control the active player (cast or native). -- A dashboard "Send message" appears as an in-app toast. -- Previous / Next cast buttons are hidden or disabled when no adjacent episode - exists. -- Changing episode no longer briefly shows the previous episode's data. -- `bun run typecheck` and `bun test utils/` pass. - -## 11. Risks - -- The native video player is screen-scoped; its controller is only registered while - the player screen is mounted. Remote commands arriving when no player is open are - correctly ignored — this is intended, not a bug. -- Multiple sources playing at once (e.g. music plus an idle cast session) is an - edge case; the cast-precedence rule resolves it deterministically. -- `customData` round-trip for `playMethod` relies on the receiver echoing - `customData` — already confirmed working in sub-project B. diff --git a/docs/superpowers/specs/2026-05-22-chromecast-ux-player-design.md b/docs/superpowers/specs/2026-05-22-chromecast-ux-player-design.md deleted file mode 100644 index 9ec37683b..000000000 --- a/docs/superpowers/specs/2026-05-22-chromecast-ux-player-design.md +++ /dev/null @@ -1,129 +0,0 @@ -# Chromecast Player UX — Trickplay, Bubble & Mini-Player — Design - -**Date:** 2026-05-22 -**Branch:** `refactor-chromecast` (PR #1402) -**Sub-project:** UX player, of the Chromecast refactor -**Status:** Approved design — pending implementation plan - ---- - -## 1. Problem - -- **Trickplay preview is truncated.** When the scrub position is far right, the - trickplay preview window is cut off at the screen edge and does not track the - cursor cleanly. Root cause: `renderBubble` in `CastPlayerProgressBar.tsx` (and the - identical copy in `CastingMiniPlayer.tsx`) does its **own** absolute positioning - (`position: "absolute", left: clampedLeft`) on top of the slider's bubble - placement. `react-native-awesome-slider` already clamps the bubble within the - track **if `bubbleWidth` is set** — but the code never sets it, so the library - centres the bubble on the thumb with no clamp, and the manual offset fights it. -- **Duplication.** The ~110-line `renderBubble` block is copy-pasted between - `CastPlayerProgressBar.tsx` and `CastingMiniPlayer.tsx`. -- **Time bubble is heavy.** The scrub-time indicator is a purple - (`protocolColor`-background) bubble that takes too much visible space. -- **Touch zone overlaps.** The progress slider's `panHitSlop` is generous - (`top: 30` main / `top: 20` mini), so its touch area overlaps the 4-button - episode row — the buttons become hard or impossible to tap. The exact slop - values need hand-calibration on a device. -- **Mini-player has no stop button.** `CastingMiniPlayer` exposes only play/pause. - -## 2. Scope - -**In scope:** fix the trickplay/scrub-bubble positioning on both progress bars, -extract the duplicated bubble into a shared component, lighten the time display, -add a stop button to the mini-player, and add a developer overlay that visualises -the touch zones so `panHitSlop` can be hand-calibrated. - -**Out of scope:** the custom Cast receiver, any visual redesign beyond the items -above, the queued feature ideas (autoplay countdown, sleep timer, …). - -## 3. Bubble positioning fix - -`react-native-awesome-slider`'s `` accepts `bubbleWidth?: number` — "if you -set this value, bubble positioning left & right will be clamped." The fix: - -- Set `bubbleWidth` on both sliders, **dynamically**: the trickplay tile width when - trickplay is available, the time-text width otherwise. -- `renderBubble` returns **only the bubble content** — no `position: "absolute"`, - no `left: clampedLeft`, no `thumbPosition` / `minLeft` / `maxLeft` maths. All of - that manual positioning is deleted; the slider clamps the bubble itself. - -Result: the trickplay preview is never truncated, it tracks the cursor, and it is -clamped to the track at the edges. - -## 4. Shared `CastTrickplayBubble` component - -Create `components/casting/player/CastTrickplayBubble.tsx` — a single presentational -component that renders **either** the trickplay tile **or** the plain time text, -given the trickplay data and the current scrub time. Both `CastPlayerProgressBar` -and `CastingMiniPlayer` use it via `renderBubble`, with a `tileWidth` prop (220 for -the main player, 140 for the mini-player). This removes the ~220 lines of duplicated -`renderBubble` code and means the positioning is fixed in one place. - -## 5. Time display - -The scrub-time indicator becomes **plain white text** — no purple background bubble. -It is positioned **above** the preview: above the trickplay tile when trickplay is -shown, above the thumb otherwise. The purple `protocolColor` background bubble is -removed. The static current/ending/total time row below the slider is unchanged. - -## 6. Mini-player stop button - -`CastingMiniPlayer` gains a **stop** button (`stop-circle` icon) beside the existing -play/pause button. It calls `remoteMediaClient.stop()`; once the media stops the -mini-player hides itself (it already returns `null` on the `IDLE` state). The button -stops `stopPropagation` so it does not also trigger the row's "open player" press. - -## 7. Touch-zone debug overlay - -To let the touch zones be hand-calibrated, add a developer overlay: - -- A module-level constant `DEBUG_TOUCH_ZONES`, default `false`, and gated by - `__DEV__` so it can never be active in a release build. -- When enabled, the casting player renders coloured-border overlay `View`s - (`pointerEvents: "none"` — they capture nothing, they only draw) tracing the - touch zones that matter for the overlap: the progress slider's effective hit area - (the slider box expanded by its `panHitSlop`) and the 4-button episode row. -- The developer flips the flag on, runs the Android emulator, sees the red-bordered - zones, adjusts `panHitSlop` until correct, then flips the flag back off. - -`panHitSlop` itself is given a sensible default in this work; the precise values are -expected to be hand-tuned by the user with the overlay. - -## 8. Files - -**Created** -- `components/casting/player/CastTrickplayBubble.tsx` — shared trickplay/time bubble. - -**Modified** -- `components/casting/player/CastPlayerProgressBar.tsx` — use `CastTrickplayBubble`, - set `bubbleWidth`, drop the manual positioning, plain-text time, sane `panHitSlop`. -- `components/casting/CastingMiniPlayer.tsx` — same bubble fix; add the stop button. -- `app/(auth)/casting-player.tsx` — the `DEBUG_TOUCH_ZONES` overlay. - -## 9. Testing - -This is UI work with no pure logic to unit-test. Verification is `bun run typecheck` -and manual testing on the Android emulator: -- Trickplay preview at the far-left and far-right of the bar — never truncated, - tracks the cursor. -- Time text reads clearly above the cursor. -- Mini-player stop button stops playback and hides the mini-player. -- With `DEBUG_TOUCH_ZONES` on, the touch zones are visible and `panHitSlop` can be - calibrated so the 4 buttons are reliably tappable. - -## 10. Success criteria - -- The trickplay window is never clipped at the screen edges and follows the cursor. -- The time indicator is unobtrusive plain text above the cursor. -- The 4 episode-row buttons are reliably tappable (no slider-slop overlap). -- The mini-player has a working stop button. -- The duplicated `renderBubble` code exists in exactly one place. -- `bun run typecheck` passes. - -## 11. Risks - -- `bubbleWidth` is a single value but the bubble has two sizes (trickplay tile vs - time text); it is set dynamically per render, which the slider supports. -- The debug overlay must be genuinely inert in release builds — the `__DEV__` gate - plus the default-`false` constant ensures it neither renders nor ships active.