Introduce PlayerAdapter contract; decision logic shared in Rust backend
Establish a decoupled player boundary so UI and backend interact with video through one contract, with the HTML5 (Linux/interim-Android) and native (ExoPlayer) providers as interchangeable primitive-executor adapters. - PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource, play/pause, setVolume, selectSubtitle); it never branches on strategy. - Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track return a strategy); the facade dispatches the chosen primitive to the active adapter. Both providers share the one decision path — logic lives once, in Rust. - Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets backend control (lockscreen/remote/sleep) drive the webview <video> element. - Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause silently no-opping when the element was re-bound). - Do not emit a "stopped" player state on natural end-of-video: it flipped the player/mode to idle mid-handoff and suppressed next-episode auto-advance under a sleep timer. Jellyfin progress reporting is preserved; the backend's on_video_playback_ended owns the transition. - VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter). - Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+120
-5
@@ -23,6 +23,34 @@ import type {
|
||||
PlayItemRequest,
|
||||
} from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { PlayerAdapter } from "./adapters/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active player adapter registry
|
||||
//
|
||||
// When a video is playing, VideoPlayer registers its PlayerAdapter here so that
|
||||
// control intents — whether from UI or routed from a backend control event
|
||||
// (lockscreen/remote/sleep-timer) — reach the actual player element/surface.
|
||||
// When no adapter is registered (audio-only playback), control falls through to
|
||||
// the queue-level backend commands, which is the correct behavior there.
|
||||
// ---------------------------------------------------------------------------
|
||||
let activeAdapter: PlayerAdapter | null = null;
|
||||
|
||||
function setActiveAdapter(adapter: PlayerAdapter): void {
|
||||
activeAdapter = adapter;
|
||||
}
|
||||
|
||||
function clearActiveAdapter(adapter?: PlayerAdapter): void {
|
||||
// Only clear if it's still the one we think is active (guards against a newly
|
||||
// mounted player's adapter being cleared by the outgoing player's teardown).
|
||||
if (!adapter || activeAdapter === adapter) {
|
||||
activeAdapter = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveAdapter(): PlayerAdapter | null {
|
||||
return activeAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current repository handle, throwing a clear error if the user is
|
||||
@@ -30,9 +58,19 @@ import { auth } from "$lib/stores/auth";
|
||||
* that was previously duplicated across every context-play call site.
|
||||
*/
|
||||
function requireHandle(): string {
|
||||
const authState = get(auth);
|
||||
if (!authState.isAuthenticated) {
|
||||
throw new Error("User not authenticated");
|
||||
// The repository is the source of truth for the handle. We consult the auth
|
||||
// store's isAuthenticated flag only as a best-effort guard — guarded in a
|
||||
// try/catch so a not-yet-subscribable store (or a test double) can't block a
|
||||
// valid repository handle.
|
||||
try {
|
||||
const authState = get(auth);
|
||||
if (authState && authState.isAuthenticated === false) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
} catch (err) {
|
||||
// get(auth) failed (e.g. non-store mock) — fall through to the repository,
|
||||
// which is the authoritative source of the handle.
|
||||
if (err instanceof Error && err.message === "User not authenticated") throw err;
|
||||
}
|
||||
const repo = auth.getRepository();
|
||||
if (!repo) {
|
||||
@@ -46,23 +84,91 @@ function requireHandle(): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function play() {
|
||||
if (activeAdapter) return void (await activeAdapter.play());
|
||||
await commands.playerPlay();
|
||||
}
|
||||
|
||||
async function pause() {
|
||||
if (activeAdapter) return void (await activeAdapter.pause());
|
||||
await commands.playerPause();
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (activeAdapter) return void (await activeAdapter.toggle());
|
||||
await commands.playerToggle();
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
// Stop is a queue/session-level action (clears playback); always go to backend.
|
||||
// The adapter is disposed by VideoPlayer's own teardown.
|
||||
await commands.playerStop();
|
||||
}
|
||||
|
||||
async function seek(positionSeconds: number) {
|
||||
await commands.playerSeek(positionSeconds);
|
||||
// Audio path: backend seeks the native backend directly.
|
||||
if (!activeAdapter) {
|
||||
await commands.playerSeek(positionSeconds);
|
||||
return;
|
||||
}
|
||||
// Video path: ask the backend to DECIDE the strategy (in-place vs reload), then
|
||||
// execute the matching adapter primitive. The decision logic stays in Rust
|
||||
// (player_seek_video); the adapter only runs the chosen mechanical primitive.
|
||||
await seekVideo(positionSeconds, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Video seek: backend decides strategy, facade dispatches the chosen adapter
|
||||
* primitive. `mediaSourceId`/`audioTrackIndex` come from the video view (they are
|
||||
* needed for the transcode reload URL). Requires an active video adapter.
|
||||
*/
|
||||
async function seekVideo(
|
||||
positionSeconds: number,
|
||||
mediaSourceId: string | null,
|
||||
audioTrackIndex: number | null
|
||||
): Promise<void> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) {
|
||||
await commands.playerSeek(positionSeconds);
|
||||
return;
|
||||
}
|
||||
const response = (await commands.playerSeekVideo(
|
||||
requireHandle(),
|
||||
positionSeconds,
|
||||
mediaSourceId,
|
||||
audioTrackIndex,
|
||||
adapter.kind === "html5"
|
||||
)) as any;
|
||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds);
|
||||
} else {
|
||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch audio track: backend decides (may reload the stream), facade dispatches
|
||||
* the resulting primitive. Requires an active video adapter.
|
||||
*/
|
||||
async function switchAudioTrack(
|
||||
streamIndex: number,
|
||||
arrayIndex: number,
|
||||
currentPosition: number | null,
|
||||
mediaSourceId: string | null
|
||||
): Promise<void> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) return;
|
||||
const response = (await commands.playerSwitchAudioTrack(
|
||||
requireHandle(),
|
||||
streamIndex,
|
||||
arrayIndex,
|
||||
adapter.kind === "html5",
|
||||
currentPosition,
|
||||
mediaSourceId
|
||||
)) as any;
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.new_url!, response.position!);
|
||||
}
|
||||
}
|
||||
|
||||
async function next() {
|
||||
@@ -102,6 +208,7 @@ async function moveInQueue(fromIndex: number, toIndex: number) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setVolume(volume: number) {
|
||||
if (activeAdapter) activeAdapter.setVolume(volume);
|
||||
await commands.playerSetVolume(volume);
|
||||
}
|
||||
|
||||
@@ -110,10 +217,11 @@ async function toggleMute() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track selection (video)
|
||||
// Track selection (video) — dispatch to the active video adapter when present
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setSubtitleTrack(streamIndex: number | null) {
|
||||
if (activeAdapter) return void (await activeAdapter.selectSubtitle(streamIndex));
|
||||
await commands.playerSetSubtitleTrack(streamIndex);
|
||||
}
|
||||
|
||||
@@ -179,11 +287,18 @@ export const playerController = {
|
||||
setVolume,
|
||||
toggleMute,
|
||||
setSubtitleTrack,
|
||||
seekVideo,
|
||||
switchAudioTrack,
|
||||
playTracks,
|
||||
playAlbumTrack,
|
||||
playItem,
|
||||
addTrackById,
|
||||
addTracksByIds,
|
||||
// Active-adapter registry (used by VideoPlayer to register its element adapter
|
||||
// and by playerEvents.ts to route backend control commands to it).
|
||||
setActiveAdapter,
|
||||
clearActiveAdapter,
|
||||
getActiveAdapter,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user