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,201 @@
|
||||
/**
|
||||
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
|
||||
* implementation. It owns the high-level control surface for an HTML5 `<video>`
|
||||
* element and reports the element's lifecycle back into Rust via its
|
||||
* {@link AdapterHost}.
|
||||
*
|
||||
* Design note on the split with VideoPlayer.svelte:
|
||||
* The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
|
||||
* "reload stream" seek/audio-track dance with its dual-audio teardown and
|
||||
* canplay waits) are inherently coupled to Svelte reactive state and the DOM
|
||||
* element. Rather than relocate that reactive machinery wholesale (high
|
||||
* regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
|
||||
* callbacks the owning component supplies. The adapter is the single OWNER of the
|
||||
* control contract (play/pause/seek/track/volume) and of reporting; the bridge is
|
||||
* the seam to the component's element/HLS/reactive state. This keeps all control
|
||||
* intents flowing through the PlayerAdapter interface while preserving the
|
||||
* hard-won element behavior verbatim.
|
||||
*
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
||||
*/
|
||||
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
|
||||
/**
|
||||
* Narrow seam the owning component provides so the adapter can execute the
|
||||
* element/HLS-coupled parts of a control action without re-implementing the
|
||||
* component's reactive HLS lifecycle. Every function here is a thin wrapper over
|
||||
* work the component already does.
|
||||
*/
|
||||
export interface Html5ElementBridge {
|
||||
/** The bound <video> element, or null before mount / after teardown. */
|
||||
getElement(): HTMLVideoElement | null;
|
||||
/** Current seek offset (seconds) for transcoded streams. */
|
||||
getSeekOffset(): number;
|
||||
setSeekOffset(offset: number): void;
|
||||
/** Update the stream URL the component renders (triggers its HLS $effect). */
|
||||
setStreamUrl(url: string): void;
|
||||
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
|
||||
destroyHls(): void;
|
||||
/** Media source id for seek/audio-track URLs. */
|
||||
getMediaSourceId(): string | null;
|
||||
}
|
||||
|
||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
readonly kind = "html5" as const;
|
||||
|
||||
private attachedElement: HTMLVideoElement | null = null;
|
||||
private host: AdapterHost;
|
||||
private bridge: Html5ElementBridge;
|
||||
|
||||
constructor(host: AdapterHost, bridge: Html5ElementBridge) {
|
||||
this.host = host;
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the LIVE <video> element. The bridge's `getElement()` returns the
|
||||
* component's current reactive `videoElement`, which is authoritative: the
|
||||
* element can be re-bound when the {#if} block re-renders, so a value captured
|
||||
* once in `attach()` may go stale (this caused play/pause to silently no-op).
|
||||
* Falls back to the attach()-captured element for unit tests whose bridge
|
||||
* returns null.
|
||||
*/
|
||||
private get element(): HTMLVideoElement | null {
|
||||
return this.bridge.getElement() ?? this.attachedElement;
|
||||
}
|
||||
|
||||
attach(element: HTMLVideoElement | null): void {
|
||||
this.attachedElement = element;
|
||||
}
|
||||
|
||||
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
|
||||
// The component's reactive HLS $effect performs the actual attach/load when
|
||||
// the stream URL is set; loading is therefore driven by setStreamUrl. The
|
||||
// component's canplay/frag-buffered path reports readiness through the host.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamUrl(streamUrl);
|
||||
this.host.onState("loading");
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
this.element?.pause();
|
||||
}
|
||||
|
||||
async toggle(): Promise<boolean> {
|
||||
const el = this.element;
|
||||
if (!el) return false;
|
||||
if (el.paused) {
|
||||
await this.play();
|
||||
return true;
|
||||
}
|
||||
await this.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* PRIMITIVE: in-place element seek (no reload). The backend already decided
|
||||
* this seek does not need a transcode reload.
|
||||
*/
|
||||
async seekElement(positionSeconds: number, offset: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
el.currentTime = positionSeconds;
|
||||
this.bridge.setSeekOffset(offset);
|
||||
await this.waitForEvent(el, "seeked", 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* PRIMITIVE: compound reload — the invariant HTML5 sequence to swap the source
|
||||
* and resume at `offset`. Contains NO strategy decision; the backend already
|
||||
* decided to reload and supplied the url/offset. Preserves the hard-won
|
||||
* dual-audio teardown and canplay wait.
|
||||
*/
|
||||
async reloadSource(url: string, offset: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) {
|
||||
// Still update the stream URL so the component's HLS $effect can pick it up.
|
||||
this.bridge.setSeekOffset(offset);
|
||||
this.bridge.setStreamUrl(url);
|
||||
return;
|
||||
}
|
||||
const wasPlaying = !el.paused;
|
||||
el.pause();
|
||||
this.bridge.destroyHls();
|
||||
if (el.src) {
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
this.bridge.setSeekOffset(offset);
|
||||
this.bridge.setStreamUrl(url);
|
||||
await this.waitForEvent(el, "canplay", 10000);
|
||||
if (wasPlaying) await el.play();
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (this.element) this.element.muted = muted;
|
||||
}
|
||||
|
||||
/** Subtitle selection: HTML5 toggles textTracks on the element directly. */
|
||||
async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el || !el.textTracks) return;
|
||||
for (let i = 0; i < el.textTracks.length; i++) {
|
||||
el.textTracks[i].mode = "disabled";
|
||||
}
|
||||
if (streamIndex !== null) {
|
||||
const tracks = el.querySelectorAll("track");
|
||||
tracks.forEach((track) => {
|
||||
const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||
if (idx === streamIndex && track.track) {
|
||||
track.track.mode = "showing";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getPosition(): number {
|
||||
const el = this.element;
|
||||
if (!el) return 0;
|
||||
return el.currentTime + this.bridge.getSeekOffset();
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.bridge.destroyHls();
|
||||
const el = this.element;
|
||||
if (el) {
|
||||
el.pause();
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
this.attachedElement = null;
|
||||
}
|
||||
|
||||
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
|
||||
private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
const done = () => {
|
||||
el.removeEventListener(event, done);
|
||||
resolve();
|
||||
};
|
||||
el.addEventListener(event, done);
|
||||
setTimeout(done, timeoutMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user