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:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* An {@link AdapterHost} implementation that forwards a player adapter's outward
|
||||
* lifecycle events into the Rust `PlayerController` via the `player_report_*`
|
||||
* commands. The controller re-emits the same `PlayerStatusEvent`s the native
|
||||
* backends emit, so the frontend `player` store is fed from ONE pipeline
|
||||
* (playerEvents.ts) in both native and HTML5 modes — keeping Rust the single
|
||||
* source of truth.
|
||||
*
|
||||
* This is the sole place that talks to the report commands; adapters depend only
|
||||
* on the {@link AdapterHost} interface, never on `commands` directly, which keeps
|
||||
* them unit-testable with a mock host.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-001, DR-028
|
||||
*/
|
||||
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
const POSITION_REPORT_INTERVAL_MS = 250;
|
||||
|
||||
/** Report options that let a caller bypass throttling for discrete events. */
|
||||
export interface ReportPositionOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level report helpers, exported so the legacy `$lib/player/html5Adapter`
|
||||
* shim can keep its function-style API while there are still direct callers.
|
||||
* Prefer {@link createRustReportHost} for new adapter code.
|
||||
*/
|
||||
let lastPositionReport = 0;
|
||||
|
||||
export async function reportState(
|
||||
state: "playing" | "paused" | "loading" | "stopped" | "idle",
|
||||
mediaId: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
await commands.playerReportState(state, mediaId);
|
||||
} catch (err) {
|
||||
console.warn("[rustReportHost] Failed to report state:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportPosition(
|
||||
position: number,
|
||||
duration: number,
|
||||
{ force = false }: ReportPositionOptions = {}
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastPositionReport < POSITION_REPORT_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastPositionReport = now;
|
||||
try {
|
||||
await commands.playerReportPosition(position, Number.isFinite(duration) ? duration : 0);
|
||||
} catch (err) {
|
||||
console.warn("[rustReportHost] Failed to report position:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reportMediaLoaded(duration: number): Promise<void> {
|
||||
try {
|
||||
await commands.playerReportMediaLoaded(Number.isFinite(duration) ? duration : 0);
|
||||
} catch (err) {
|
||||
console.warn("[rustReportHost] Failed to report media loaded:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetReporting(): void {
|
||||
lastPositionReport = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link AdapterHost} bound to a specific media id that forwards adapter
|
||||
* events to Rust. `onStreamUrlChanged`, `onBuffering`, and `onReady` are wired by
|
||||
* the owning view (they affect the `<video src>` / spinner), so this host accepts
|
||||
* optional view callbacks and defaults them to no-ops.
|
||||
*/
|
||||
export function createRustReportHost(
|
||||
mediaId: string,
|
||||
view: Partial<Pick<AdapterHost, "onStreamUrlChanged" | "onBuffering" | "onReady" | "onEnded" | "onError">> = {}
|
||||
): AdapterHost {
|
||||
return {
|
||||
onState: (state) => void reportState(state, mediaId),
|
||||
onPosition: (position, duration) => void reportPosition(position, duration),
|
||||
onMediaLoaded: (duration) => void reportMediaLoaded(duration),
|
||||
onEnded: view.onEnded ?? (() => {}),
|
||||
onError: view.onError ?? ((message) => console.warn("[rustReportHost] adapter error:", message)),
|
||||
onStreamUrlChanged: view.onStreamUrlChanged ?? (() => {}),
|
||||
onBuffering: view.onBuffering ?? (() => {}),
|
||||
onReady: view.onReady ?? (() => {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user