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:
2026-07-02 19:56:20 +02:00
co-authored by Claude Opus 4.8
parent 1f6977cd01
commit a64e1b1fb4
15 changed files with 1198 additions and 301 deletions
+95 -215
View File
@@ -15,6 +15,9 @@
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player";
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
interface Props {
media: MediaItem | null;
@@ -67,7 +70,10 @@
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
let isSeeking = $state(false);
let currentStreamUrl = $state(streamUrl);
// Capture only the initial streamUrl prop; later prop changes are applied via
// the $effect below (untrack keeps this a one-time snapshot, matching
// reportMediaId above and silencing state_referenced_locally).
let currentStreamUrl = $state(untrack(() => streamUrl));
let hasReportedStart = $state(false);
let progressInterval: ReturnType<typeof setInterval> | null = null;
let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
@@ -106,6 +112,31 @@
let hls: Hls | null = null; // HLS.js instance for streaming HLS content
let hlsFatalRecoveryAttempts = 0; // Track recovery attempts to prevent infinite restarts
// ===== Player adapter (control boundary) =====
// The adapter owns the high-level control contract (play/pause/seek/track).
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
// registers the adapter with the facade so control intents — from UI OR from a
// backend control event (lockscreen/remote/sleep) — reach this element.
let playerAdapter: Html5PlayerAdapter | null = null;
function tearDownHls() {
if (hls) {
hls.detachMedia();
hls.stopLoad();
hls.destroy();
hls = null;
}
}
const adapterBridge: Html5ElementBridge = {
getElement: () => videoElement,
getSeekOffset: () => seekOffset,
setSeekOffset: (o) => { seekOffset = o; },
setStreamUrl: (u) => { currentStreamUrl = u; },
destroyHls: tearDownHls,
getMediaSourceId: () => mediaSourceId ?? null,
};
// Audio track selection
let showAudioTrackMenu = $state(false);
let selectedAudioTrackIndex = $state<number | null>(null);
@@ -222,20 +253,11 @@
});
// Pause playback when the time-based sleep timer expires. The backend stops
// its own (MPV/ExoPlayer) playback itself, but the HTML5 <video> element
// plays in the webview outside the backend's control, so it must be paused
// here or the sleep timer never actually stops video playback on Linux.
let lastSleepExpirySeen = $sleepTimerExpiredSignal;
$effect(() => {
if ($sleepTimerExpiredSignal !== lastSleepExpirySeen) {
lastSleepExpirySeen = $sleepTimerExpiredSignal;
if (useHtml5Element && videoElement && !videoElement.paused) {
console.log("[VideoPlayer] Sleep timer expired - pausing playback");
videoElement.pause();
}
}
});
// Sleep-timer expiry pause is now driven by the backend through the player
// adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
// pause() (see handleControlCommand / the sleep_timer_expired case). This
// removes the component's direct videoElement.pause() reach-in — the backend
// has control authority over the webview element via the adapter boundary.
// Native backend (Android ExoPlayer): drive the seek bar from the player
// store, which is fed by the backend's PositionUpdate events. The legacy
@@ -545,6 +567,18 @@
didStartNativePlayback = true; // Track that we need to stop backend on unmount
}
// Register the HTML5 player adapter with the facade so control intents
// (UI or backend lockscreen/remote/sleep events) route to this element.
if (useHtml5Element) {
const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
});
playerAdapter = new Html5PlayerAdapter(host, adapterBridge);
playerAdapter.attach(videoElement);
playerController.setActiveAdapter(playerAdapter);
}
if (!useHtml5Element) {
// Using native backend, subscribe to player events
didStartNativePlayback = true; // Track that we started native playback
@@ -633,6 +667,12 @@
// Stop RAF loop
stopTimeUpdates();
// Unregister the adapter from the facade (guarded so we only clear our own).
if (playerAdapter) {
playerController.clearActiveAdapter(playerAdapter);
playerAdapter = null;
}
if (progressInterval) {
clearInterval(progressInterval);
}
@@ -969,8 +1009,13 @@
function handleEnded() {
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when ended
html5Adapter.reportState("stopped", reportMediaId ?? null);
// Report stop when video ends (skip for live - no resume tracking)
// NOTE: do NOT report a "stopped" player state here. Natural end-of-video is
// an autoplay handoff, not a stop: the backend's on_video_playback_ended
// decides whether to advance to the next episode (incl. sleep-timer episode
// counting). Emitting StateChanged{stopped} would flip the player/mode to
// idle mid-handoff and suppress the next-episode auto-advance (pauses at the
// end of an episode instead of continuing). onReportStop below still reports
// progress to Jellyfin; notifyEnded() drives the autoplay decision.
if (!isLive && onReportStop) {
onReportStop(currentTime, reportMediaId);
}
@@ -979,19 +1024,13 @@
}
async function togglePlayPause() {
if (!useHtml5Element) {
try {
const response = (await commands.playerToggle()) as any;
isPlaying = response.state === "playing";
} catch (err) {
console.error("[VideoPlayer] Failed to toggle native player:", err);
}
} else if (videoElement) {
if (videoElement.paused) {
videoElement.play();
} else {
videoElement.pause();
}
// Route through the facade → active adapter so the toggle goes through the
// one control boundary (and the adapter reports the resulting element state
// back into Rust). The element's own play/pause handlers update isPlaying.
try {
await playerController.toggle();
} catch (err) {
console.error("[VideoPlayer] Failed to toggle playback:", err);
}
}
@@ -1011,133 +1050,29 @@
isDraggingSeekBar = false;
try {
console.log("[VideoPlayer] Seeking to:", targetTime.toFixed(2), "useHtml5Element:", useHtml5Element);
console.log("[VideoPlayer] Seeking to:", targetTime.toFixed(2));
const repo = auth.getRepository();
if (!repo) {
console.error("[VideoPlayer] No repository available");
return;
}
// Optimistic display; the primitive updates currentTime/seekOffset as it
// completes (reloadSource drives the stream URL via the adapter bridge).
currentTime = targetTime;
stopTimeUpdates(); // pause RAF while the seek settles
// Backend smart seeking handles both native and HTML5
const response = (await commands.playerSeekVideo(
repo.getHandle(),
// The BACKEND decides the strategy (in-place vs transcode reload); the
// facade dispatches the matching adapter PRIMITIVE. This is the shared
// decision-in-Rust design — no strategy branch lives here anymore.
lastNativeSeekAt = Date.now();
await playerController.seekVideo(
targetTime,
mediaSourceId ?? null,
selectedAudioTrackIndex ?? null,
useHtml5Element
)) as any;
selectedAudioTrackIndex ?? null
);
console.log("[VideoPlayer] Backend seek response:", response);
// For native backend, the backend handles everything internally
if (!useHtml5Element) {
// Backend already stopped, reloaded, and seeked if needed
lastNativeSeekAt = Date.now();
currentTime = response.position ?? targetTime;
if (response.strategy === "reloadStream") {
// Serde keeps these fields snake_case (only the "strategy" tag is camelCase)
seekOffset = response.seek_offset ?? targetTime;
currentStreamUrl = response.new_url ?? currentStreamUrl;
} else {
seekOffset = 0;
}
console.log("[VideoPlayer] Native backend seek completed at position:", currentTime);
return;
// Resume smooth updates if still playing after the seek settled.
if (videoElement && !videoElement.paused) {
startTimeUpdates();
}
// HTML5 backend - handle video element management
if (!videoElement) {
console.warn("[VideoPlayer] Cannot seek - video element not available");
return;
}
if (response.strategy === "reloadStream") {
// Transcoded stream - reload with new URL
console.log("[VideoPlayer] Reloading HTML5 stream from position:", targetTime);
const wasPlaying = !videoElement.paused;
// CRITICAL: Stop playback completely to prevent dual audio
videoElement.pause();
stopTimeUpdates(); // Stop RAF updates
// CRITICAL: Destroy old HLS instance completely to prevent dual audio
if (hls) {
console.log("[VideoPlayer] Destroying old HLS instance for seek");
hls.detachMedia(); // Detach from video element
hls.stopLoad(); // Stop loading fragments
hls.destroy(); // Completely destroy the instance
hls = null; // Clear reference
}
// CRITICAL: Clear video element buffers completely
if (videoElement.src) {
videoElement.removeAttribute('src');
videoElement.load(); // Reset and flush all buffers
}
// Small delay to ensure cleanup completes before creating new HLS instance
await new Promise(resolve => setTimeout(resolve, 100));
// Update stream URL (this will trigger $effect to create new HLS instance)
// Serde keeps these fields snake_case (only the "strategy" tag is camelCase)
seekOffset = response.seek_offset ?? targetTime;
currentStreamUrl = response.new_url ?? currentStreamUrl;
currentTime = targetTime;
// Wait for video to be ready
await new Promise<void>((resolve) => {
const onCanPlay = () => {
console.log("[VideoPlayer] Transcoded video loaded after seek");
videoElement?.removeEventListener("canplay", onCanPlay);
resolve();
};
if (videoElement) {
videoElement.addEventListener("canplay", onCanPlay);
}
setTimeout(() => {
console.warn("[VideoPlayer] Transcoded seek timeout");
videoElement?.removeEventListener("canplay", onCanPlay);
resolve();
}, 10000);
});
if (wasPlaying && videoElement) {
await videoElement.play();
startTimeUpdates(); // Restart RAF updates
}
} else {
// Native browser seeking
console.log("[VideoPlayer] Using native HTML5 seek to:", targetTime.toFixed(2));
videoElement.currentTime = targetTime;
currentTime = targetTime;
seekOffset = 0;
// Wait for seek to complete
await new Promise<void>((resolve) => {
const onSeeked = () => {
console.log("[VideoPlayer] Native seek completed");
videoElement?.removeEventListener("seeked", onSeeked);
resolve();
};
if (videoElement) {
videoElement.addEventListener("seeked", onSeeked);
}
setTimeout(() => {
videoElement?.removeEventListener("seeked", onSeeked);
resolve();
}, 2000);
});
}
console.log("[VideoPlayer] HTML5 seek completed:", {
strategy: response.strategy,
targetTime: targetTime.toFixed(2),
actualTime: videoElement.currentTime.toFixed(2),
seekOffset,
});
console.log("[VideoPlayer] Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
} catch (err) {
console.error("[VideoPlayer] Seek failed:", err);
} finally {
@@ -1307,74 +1242,19 @@
showAudioTrackMenu = false;
try {
const repo = auth.getRepository();
if (!repo) throw new Error("Not authenticated");
// Call unified backend command
const response = (await commands.playerSwitchAudioTrack(
repo.getHandle(),
// The BACKEND decides whether the audio-track switch needs a transcode
// reload; the facade dispatches the resulting adapter PRIMITIVE
// (reloadSource) which runs the invariant dual-audio teardown sequence.
// No strategy branch lives here anymore.
stopTimeUpdates();
await playerController.switchAudioTrack(
streamIndex,
arrayIndex,
useHtml5Element,
useHtml5Element && videoElement ? videoElement.currentTime + seekOffset : null,
videoElement ? videoElement.currentTime + seekOffset : null,
mediaSourceId ?? null
)) as any;
// Handle response based on strategy
if (response.strategy === "reloadStream" && useHtml5Element && videoElement) {
console.log("[VideoPlayer] Switching audio track - reloading stream");
// Save state before reload
const wasPlaying = !videoElement.paused;
// CRITICAL: Stop playback completely to prevent dual audio
videoElement.pause();
stopTimeUpdates(); // Stop RAF updates
// CRITICAL: Destroy old HLS instance completely to prevent dual audio
if (hls) {
console.log("[VideoPlayer] Destroying old HLS instance for audio track switch");
hls.detachMedia(); // Detach from video element
hls.stopLoad(); // Stop loading fragments
hls.destroy(); // Completely destroy the instance
hls = null; // Clear reference
}
// CRITICAL: Clear video element buffers completely
if (videoElement.src) {
videoElement.removeAttribute('src');
videoElement.load(); // Reset and flush all buffers
}
// Small delay to ensure cleanup completes before creating new HLS instance
await new Promise(resolve => setTimeout(resolve, 100));
// Update stream URL (this will trigger $effect to create new HLS instance)
// Serde keeps new_url snake_case (only the "strategy" tag is camelCase)
currentStreamUrl = response.new_url!;
seekOffset = response.position!;
// Wait for video to be ready
await new Promise<void>((resolve) => {
const onCanPlay = () => {
console.log("[VideoPlayer] Video reloaded with new audio track");
videoElement?.removeEventListener("canplay", onCanPlay);
resolve();
};
videoElement!.addEventListener("canplay", onCanPlay);
// Timeout fallback
setTimeout(() => {
videoElement?.removeEventListener("canplay", onCanPlay);
resolve();
}, 5000);
});
// Resume playback if it was playing
if (wasPlaying) {
await videoElement.play();
startTimeUpdates(); // Restart RAF updates
}
);
if (videoElement && !videoElement.paused) {
startTimeUpdates();
}
console.log("[VideoPlayer] Successfully changed audio track");