mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-07-17 09:53:02 +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