mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-02 03:58:36 +01:00
docs(casting): add session reporting & remote control plan
10-task plan for sub-project D: PlaybackController registry, pure remote-command mapper, useRemoteControl dispatch, per-player registration, PlayMethod fix, conditional episode buttons, loadEpisode race fix.
This commit is contained in:
@@ -0,0 +1,843 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user