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.
26 KiB
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:
/**
* 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
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:
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:
/**
* 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
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:
/**
* 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'stoastexport name differs, check an existing usage withgrep -rn "sonner-native" --include="*.tsx" components app | head -3and match it.
- Step 3: Commit
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:
import { useRemoteControl } from "@/hooks/useRemoteControl";
Inside the WebSocketProvider component body (after lastMessage is declared), add:
// Route Jellyfin remote-control messages to the active player.
useRemoteControl(lastMessage);
- Step 2: Expand advertised capabilities
In the postFullCapabilities call, replace:
SupportedCommands: ["Play"],
with:
SupportedCommands: [
"Play",
"DisplayMessage",
"SetVolume",
"ToggleMute",
"Mute",
"Unmute",
],
- Step 3: Verify types
Run: bun run typecheck
Expected: PASS — no errors.
SupportedCommandsis typed asGeneralCommandType[]by the Jellyfin SDK. The six strings above are all validGeneralCommandTypevalues, so the array literal type-checks. If the SDK rejects a literal, that value is not a validGeneralCommandType— remove it and report it.
- Step 4: Commit
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:
import { useMemo } from "react";
import {
type PlaybackController,
useRegisterPlaybackController,
} from "@/utils/playback/playbackController";
Build the controller (place this near the other useMemos, after castingControls
and the episode/selection hooks are available):
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 anduseCastEpisodes's return — the methods listed above are what those hooks expose.castState/CastStateare already imported in this file. IfcastingControlsdoes not exposevolume, derive the mute-toggle from whatever volume value it does expose, or fromcurrentSelection— 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
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).
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
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.
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
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
playMethodin customData
In utils/casting/mediaInfo.ts, add an optional parameter to buildCastMediaInfo,
alongside playSessionId and selection:
/** "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
playMethodincastLoad.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:
const playMethod: "Transcode" | "DirectPlay" = data.mediaSource?.TranscodingUrl
? "Transcode"
: "DirectPlay";
Pass playMethod into the buildCastMediaInfo({ ... }) call.
getStreamUrlreturns{ url, sessionId, mediaSource };mediaSourceis aMediaSourceInfowhoseTranscodingUrlis set when the server chose to transcode. Confirm the returned shape inutils/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:
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
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
nextEpisodeis 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
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(auseRef<string | null>(null)). - When
loadEpisodeis invoked, setpendingEpisodeIdRef.currentto the target episode id. (Wrap theloadEpisodecall sites, or wrap the function the cast player passes toCastPlayerEpisodeControls/ 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.currentis set and does not matchcurrentItem?.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, untilcurrentItemmatches.
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
useCastEpisodesexpose aloadingEpisodeIdrather 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
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 testis Bun's native runner;remoteCommands.tsis pure (no imports) so its test runs cleanly. Do not write a test that importsuseRemoteControl.tsorplaybackController.ts— they pull React / jotai.- Tasks 5-7 are integration: reuse each player's existing control functions, do not
reimplement playback logic. A
setVolume/toggleMuteno-op is acceptable where a player has no volume API. - Do NOT add a
Co-Authored-Bytrailer to commit messages. - Out of scope: track-switching remote commands, the custom receiver.