mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
chore: stop tracking docs/superpowers workflow artifacts
Local superpowers planning docs (handoff/plans/specs), never meant for the repo. Untrack them; files kept locally.
This commit is contained in:
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 `<X/>`).
|
||||
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 `<View>` 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 `<View>` 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 `<CastPlayerHeader ... />` and the `{/* Title Area */}` block with `<CastPlayerTitle ... />`, 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 `<CastPlayerPoster ... />`. 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 `<View>` 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 `<CastPlayerEpisodeControls ... />`. 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 `<Slider>` 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<number>`).
|
||||
|
||||
- [ ] **Step 2: Rewire**
|
||||
|
||||
Replace the progress-slider + time-display JSX in `casting-player.tsx` with `<CastPlayerProgressBar ... />` (keep it inside the bottom-controls `<View>`). 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 `<View>` 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 `<CastPlayerTransportControls ... />`. 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.
|
||||
@@ -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<PlaybackController | null>(
|
||||
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<string, unknown>): 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<string, unknown>,
|
||||
): RemoteAction | null => {
|
||||
const args = (data.Arguments ?? {}) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<PlaybackController>(
|
||||
() => ({
|
||||
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<string | null>(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.
|
||||
@@ -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<typeof useTrickplay>;
|
||||
|
||||
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 = (
|
||||
<Text
|
||||
style={{
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
textShadowColor: "rgba(0, 0, 0, 0.85)",
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
}}
|
||||
>
|
||||
{formatTrickplayTime(trickplayTime)}
|
||||
</Text>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<View style={{ width: tileWidth, alignItems: "center", gap: 4 }}>
|
||||
{timeText}
|
||||
<View
|
||||
style={{
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#1a1a1a",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
cachePolicy='memory-disk'
|
||||
style={{
|
||||
width: tileWidth * (trickplayInfo.data?.TileWidth ?? 1),
|
||||
height: tileHeight * (trickplayInfo.data?.TileHeight ?? 1),
|
||||
transform: [
|
||||
{ translateX: -x * tileWidth },
|
||||
{ translateY: -y * tileHeight },
|
||||
],
|
||||
}}
|
||||
source={{ uri: url }}
|
||||
contentFit='cover'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={220}
|
||||
/>
|
||||
)}
|
||||
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 `<CastPlayerProgressBar .../>` 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={() => (
|
||||
<CastTrickplayBubble
|
||||
trickPlayUrl={trickPlayUrl}
|
||||
trickplayInfo={trickplayInfo}
|
||||
trickplayTime={trickplayTime}
|
||||
tileWidth={140}
|
||||
/>
|
||||
)}
|
||||
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 */}
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
remoteMediaClient?.stop()?.catch((error: unknown) => {
|
||||
console.error("[CastingMiniPlayer] Stop error:", error);
|
||||
});
|
||||
}}
|
||||
style={{ padding: 8 }}
|
||||
>
|
||||
<Ionicons name='stop' size={24} color='white' />
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
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 `<View style={{ marginTop: 8, height: 40 }}>`. The slider's
|
||||
effective touch area is the slider plus its `panHitSlop`. Inside that container
|
||||
`View`, after the `<Slider .../>`, 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 && (
|
||||
<View
|
||||
pointerEvents='none'
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -12,
|
||||
bottom: -12,
|
||||
left: -10,
|
||||
right: -10,
|
||||
borderWidth: 1,
|
||||
borderColor: "red",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
(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 `<View>` (the absolute-positioned button row). As the
|
||||
last child of that root `View`, add:
|
||||
|
||||
```tsx
|
||||
{__DEV__ && DEBUG_TOUCH_ZONES && (
|
||||
<View
|
||||
pointerEvents='none'
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderWidth: 1,
|
||||
borderColor: "lime",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **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.
|
||||
@@ -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.
|
||||
@@ -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 ??
|
||||
<default derived from currentItem>`.
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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<PlaybackController | null>(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.
|
||||
@@ -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 `<Slider>` 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.
|
||||
Reference in New Issue
Block a user