Design for sub-project D: correct PlayMethod reporting, conditional episode buttons, loadEpisode race fix, and app-wide Jellyfin remote control (Playstate/GeneralCommand) routed via a PlaybackController.
7.9 KiB
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.tsreports a hardcodedPlayMethod: "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/currentItemrace. During an episode change,currentItem(derived from castcustomData) briefly still points at the previous episode while the new one loads — flagged in sub-project B's review.- Remote control does not exist.
WebSocketProvideradvertisesSupportedCommands: ["Play"]and handles only thePlayWebSocket message. The Jellyfin dashboard's remote-control panel (pause / stop / seek / message) sendsPlaystateandGeneralCommandmessages 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(TranscodevsDirectPlay) for cast sessions. - Show / hide the Previous / Next episode buttons based on adjacent-episode availability.
- Fix the
loadEpisode/currentItemstale-window race. - Implement app-wide Jellyfin remote control: handle
PlaystateandGeneralCommandWebSocket 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:
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:
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 episodenext/previous. Registered while a cast session is playing; cleared on disconnect. - Native video player —
app/(auth)/player/direct-player.tsxregisters a controller wrapping its player controls on mount; clears on unmount. - Music —
MusicPlayerProviderregisters 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— mapData.Commandto the active controller:PlayPause/Pause/Unpause/Stop,Seek(usesData.SeekPositionTicks),NextTrack/PreviousTrack→next/previous.GeneralCommand—Data.Name:SetVolume(→setVolume, fromData.Arguments.Volume),ToggleMute/Mute/Unmute(→toggleMute),DisplayMessage→ an in-app toast viasonner-native(no controller involved).- The existing
Playhandling is preserved unchanged. - If no controller is registered, transport/volume commands are ignored;
DisplayMessageandPlaystill 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.
loadCastMediaalready knows whether the resolved stream is a transcode (getStreamUrl's media source carries aTranscodingUrl). Embed aplayMethod: "Transcode" | "DirectPlay"in the castcustomData— the same pattern asplaySessionIdandselectionfrom sub-projects A and B.useCastingreads it and reports it inreportPlaybackStart/reportPlaybackProgressinstead of the hardcoded"DirectStream". - Conditional episode buttons.
CastPlayerEpisodeControlsalready receivesepisodesandnextEpisode. Hide (or disable) Previous when the current item is the first episode, and Next when there is no next episode. loadEpisoderace. While an episode load is in flight, guard against acting on a stalecurrentItem: track the loading episode id and treatcurrentItemas pending until the castcustomDatareflects the new episode.
8. Files
Created
utils/playback/playbackController.ts— thePlaybackControllerinterface andactivePlaybackControllerAtom.hooks/useRemoteControl.ts— the WebSocket remote-control message handler.
Modified
providers/WebSocketProvider.tsx— consumeuseRemoteControl; expandSupportedCommands.hooks/useCasting.ts— report the realPlayMethod.utils/casting/castLoad.ts,utils/casting/mediaInfo.ts— embedplayMethodincustomData.components/casting/player/CastPlayerEpisodeControls.tsx— conditional buttons.app/(auth)/casting-player.tsx— register the castPlaybackController; fix theloadEpisoderace.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/Namestring → 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
Transcodefor 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 typecheckandbun 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.
customDataround-trip forplayMethodrelies on the receiver echoingcustomData— already confirmed working in sub-project B.