mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-18 11:50:26 +01:00
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.
844 lines
26 KiB
Markdown
844 lines
26 KiB
Markdown
# 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.
|