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:
@@ -17,6 +17,7 @@ import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||
import { nextEpisode, nextEpisodeItem as nextEpisodeItemStore } from "$lib/stores/nextEpisode";
|
||||
import { autoPlayNext } from "$lib/services/nextEpisodeService";
|
||||
import { preloadUpcomingTracks } from "$lib/services/preload";
|
||||
import { playerController } from "$lib/player";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
@@ -116,9 +117,20 @@ function handlePlayerEvent(event: PlayerStatusEvent): void {
|
||||
case "sleep_timer_expired":
|
||||
// Backend stops its own playback; this signal lets HTML5 video (which
|
||||
// plays outside the backend on Linux) pause itself too.
|
||||
// Preferred path: drive the active video adapter directly so the backend
|
||||
// has real control authority over the webview element. The legacy
|
||||
// sleepTimerExpiredSignal is kept for any remaining subscribers.
|
||||
playerController.getActiveAdapter()?.pause();
|
||||
sleepTimerExpiredSignal.update((n) => n + 1);
|
||||
break;
|
||||
|
||||
case "control_command":
|
||||
// Backend-originated control targeting the active frontend player adapter
|
||||
// (lockscreen/remote/sleep). Route it to the adapter so a backend intent
|
||||
// reaches the webview <video> element.
|
||||
handleControlCommand(event.action, event.position);
|
||||
break;
|
||||
|
||||
case "show_next_episode_popup":
|
||||
handleShowNextEpisodePopup(
|
||||
event.current_episode,
|
||||
@@ -292,6 +304,36 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
|
||||
sleepTimer.set({ mode, remainingSeconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a backend-originated control command to the active player adapter, so a
|
||||
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
|
||||
* that Rust cannot reach directly. No-op when no video adapter is active (audio
|
||||
* playback is already fully backend-driven).
|
||||
*/
|
||||
function handleControlCommand(action: string, position: number | null): void {
|
||||
const adapter = playerController.getActiveAdapter();
|
||||
if (!adapter) return;
|
||||
switch (action) {
|
||||
case "play":
|
||||
void adapter.play();
|
||||
break;
|
||||
case "pause":
|
||||
void adapter.pause();
|
||||
break;
|
||||
case "seek":
|
||||
// Backend-driven in-place seek (e.g. lockscreen scrub). The backend has
|
||||
// already decided this is a simple position change, so use the element
|
||||
// seek primitive with no transcode offset.
|
||||
if (position != null) void adapter.seekElement(position, 0);
|
||||
break;
|
||||
case "stop":
|
||||
void adapter.pause();
|
||||
break;
|
||||
default:
|
||||
console.warn("[playerEvents] Unknown control command:", action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle show next episode popup event.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user