jellytau/src/lib/services/playerEvents.ts
Duncan Tourolle a64e1b1fb4 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>
2026-07-02 19:56:20 +02:00

368 lines
12 KiB
TypeScript

/**
* Player Event Service
*
* Listens for Tauri events from the player backend and updates the
* frontend stores accordingly. This enables push-based updates instead
* of polling.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
*/
import { type UnlistenFn } from "@tauri-apps/api/event";
import { commands, events, type PlayerStatusEvent, type SleepTimerMode } from "$lib/api/bindings";
import { player, playbackPosition, playbackDuration, currentMedia } from "$lib/stores/player";
import { queue, currentQueueItem } from "$lib/stores/queue";
import { playbackMode } from "$lib/stores/playbackMode";
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";
// PlayerStatusEvent and SleepTimerMode are generated by tauri-specta and
// imported from $lib/api/bindings — they are the authoritative shapes emitted
// by src-tauri/src/player/events.rs.
let unlistenFn: UnlistenFn | null = null;
let isInitialized = false;
/**
* Initialize the player event listener.
* Should be called once when the app starts (e.g., in +layout.svelte).
*/
export async function initPlayerEvents(): Promise<void> {
if (isInitialized) {
console.warn("Player events already initialized");
return;
}
try {
unlistenFn = await events.playerStatusEvent.listen((event) => {
handlePlayerEvent(event.payload);
});
isInitialized = true;
console.log("Player event listener initialized");
} catch (e) {
console.error("Failed to initialize player events:", e);
}
}
/**
* Clean up the player event listener.
* Should be called when the app is destroyed.
*/
export function cleanupPlayerEvents(): void {
if (unlistenFn) {
unlistenFn();
unlistenFn = null;
}
isInitialized = false;
}
/**
* Check if the event listener is initialized.
*/
export function isPlayerEventsInitialized(): boolean {
return isInitialized;
}
/**
* Handle incoming player events and update stores.
*/
function handlePlayerEvent(event: PlayerStatusEvent): void {
// Skip local player events when in remote mode to prevent conflicts
// EXCEPT during transfer (when local playback is starting)
const mode = get(playbackMode);
if (mode.mode === "remote" && !mode.isTransferring) {
return;
}
switch (event.type) {
case "position_update":
handlePositionUpdate(event.position, event.duration);
break;
case "state_changed":
handleStateChanged(event.state, event.media_id);
break;
case "media_loaded":
handleMediaLoaded(event.duration);
break;
case "playback_ended":
handlePlaybackEnded();
break;
case "buffering":
// Could show buffering indicator in UI
console.debug(`Buffering: ${event.percent}%`);
break;
case "error":
handleError(event.message, event.recoverable);
break;
case "volume_changed":
player.setVolume(event.volume);
player.setMuted(event.muted);
break;
case "sleep_timer_changed":
handleSleepTimerChanged(event.mode, event.remaining_seconds);
break;
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,
event.next_episode,
event.countdown_seconds,
event.auto_advance
);
break;
case "countdown_tick":
handleCountdownTick(event.remaining_seconds);
break;
// queue_changed is handled by the queue store's own listener
// ($lib/stores/queue), which is the single source of truth for
// shuffle/repeat/next/previous. No action needed here.
}
}
/**
* Handle position update events.
*
* TRACES: UR-005, UR-025 | DR-028
*/
function handlePositionUpdate(position: number, duration: number): void {
player.updatePosition(position, duration);
// Note: Sleep timer logic is now handled entirely in the Rust backend
}
/**
* Resolve the duration to seed playing/paused state with.
*
* For the same track that is already loaded, the live store duration (kept
* fresh by media_loaded / position_update events) is authoritative and is
* preferred — falling back to the runTimeTicks estimate only when the store
* has no usable duration yet. This avoids clobbering a known-good duration
* with 0 when runTimeTicks is missing (which would zero the slider's max).
*/
function resolveDuration(currentItem: MediaItem, isSameTrack: boolean): number {
const estimate = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0;
if (isSameTrack) {
const live = get(playbackDuration);
if (live > 0) {
return live;
}
}
return estimate;
}
/**
* Handle state change events.
*
* TRACES: UR-005 | DR-001
*/
async function handleStateChanged(state: string, _mediaId: string | null): Promise<void> {
// Get current media from queue store
const currentItem = get(currentQueueItem);
switch (state) {
case "playing":
case "paused":
case "loading":
// When local playback starts, ensure mode is set to local
const mode = get(playbackMode);
if (mode.mode !== "local") {
console.log("Setting playback mode to local");
playbackMode.setMode("local");
}
if (state === "playing" && currentItem) {
// Preserve the current position when the same track is already loaded
// (e.g. resuming from pause, or a spurious PlaybackRestart). Only reset
// to 0 when switching to a different track. position_update events keep
// it fresh either way, but resetting unconditionally caused the time to
// flash to 0:00 on pause/resume.
const previous = get(currentMedia);
const isSameTrack = previous?.id === currentItem.id;
const startPosition = isSameTrack ? get(playbackPosition) : 0;
const initialDuration = resolveDuration(currentItem, isSameTrack);
player.setPlaying(currentItem, startPosition, initialDuration);
// Trigger preloading of upcoming tracks in the background
preloadUpcomingTracks().catch((e) => {
// Preload failures are non-critical, already logged in the service
console.debug("[playerEvents] Preload failed (non-critical):", e);
});
} else if (state === "paused" && currentItem) {
// Keep current position and duration from store. The same track is
// already loaded on pause, so its live duration (from media_loaded /
// position_update) is authoritative — recomputing from runTimeTicks
// would clobber it with 0 when runTimeTicks is missing, forcing the
// slider's max to 0 and flashing the thumb to the start.
const previous = get(currentMedia);
const isSameTrack = previous?.id === currentItem.id;
const currentPosition = get(playbackPosition);
const initialDuration = resolveDuration(currentItem, isSameTrack);
player.setPaused(currentItem, currentPosition, initialDuration);
} else if (state === "loading" && currentItem) {
player.setLoading(currentItem);
}
break;
case "idle":
case "stopped":
player.setIdle();
// When local playback stops, revert to idle mode
const currentMode = get(playbackMode);
if (currentMode.mode === "local") {
console.log("Setting playback mode to idle");
playbackMode.setMode("idle");
}
break;
}
}
/**
* Handle media loaded event.
*/
function handleMediaLoaded(duration: number): void {
// Media is now loaded and ready
// The state_changed event will handle setting the playing state
console.debug(`Media loaded, duration: ${duration}s`);
}
/**
* Handle playback ended event.
* Calls backend to handle autoplay decisions (sleep timer, queue advance, episode popup).
*
* TRACES: UR-023, UR-026 | DR-047, DR-029
*/
async function handlePlaybackEnded(): Promise<void> {
// Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.)
try {
await commands.playerOnPlaybackEnded(null, null);
} catch (e) {
console.error("[playerEvents] Failed to handle playback ended:", e);
// Fallback: set idle state on error
player.setIdle();
}
}
/**
* Handle error events.
*/
async function handleError(message: string, recoverable: boolean): Promise<void> {
console.error(`Playback error (recoverable: ${recoverable}): ${message}`);
player.setError(message);
// Stop backend player to prevent orphaned playback
// This also reports playback stopped to Jellyfin server
try {
await commands.playerStop();
console.log("Backend player stopped after error");
} catch (e) {
console.error("Failed to stop player after error:", e);
// Continue with state cleanup even if stop fails
}
// Always return to idle after an error
player.setIdle();
}
/**
* Handle sleep timer changed event.
*
* TRACES: UR-026 | DR-029
*/
function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number): void {
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.
*
* TRACES: UR-023 | DR-047, DR-048
*/
function handleShowNextEpisodePopup(
currentEpisodeItem: MediaItem,
nextEpisodeItem: MediaItem,
countdownSeconds: number,
autoAdvance: boolean
): void {
// Update next episode store to show popup
nextEpisode.showPopup(currentEpisodeItem, nextEpisodeItem, countdownSeconds, autoAdvance);
}
/**
* Handle countdown tick event.
* When countdown reaches 0, automatically trigger playback of the next episode.
*/
function handleCountdownTick(remainingSeconds: number): void {
// Update next episode store with new countdown value
nextEpisode.updateCountdown(remainingSeconds);
// Auto-play when countdown reaches 0
if (remainingSeconds === 0) {
const episode = get(nextEpisodeItemStore);
if (episode) {
autoPlayNext(episode);
}
}
}