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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user