Files
jellytau/src/lib/services/playerEvents.ts
T
dtourolle d54d8cc7c4 refactor(logging): route frontend console calls through the logger
TRACES: | DR-204

484 ungated `console.*` calls across 63 non-test frontend files shipped to
end users with no way to turn them off. Mechanical substitution, no control
flow, error handling or message semantics changed:

  console.log / console.debug -> log.debug
  console.info                -> log.info
  console.warn                -> log.warn
  console.error               -> log.error

Hand-written `"[Scope] …"` prefixes are dropped where the logger's scope
now carries them; scope names that already existed are preserved verbatim
(`[Auth]`, `[VideoPlayer]`, `[PiP]`, …) and inferred from the filename
where a file had none. `src/routes/player/[id]/+page.svelte` keeps its
`NextEpisode` and `AutoPlay` sub-scopes as separate loggers rather than
flattening them into the page scope.

`grep -rn 'console\.' src/` now matches nothing outside the tests and the
facade itself.
2026-08-20 19:29:59 +02:00

400 lines
13 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, DR-097
*/
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";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("playerEvents");
// 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) {
log.warn("Player events already initialized");
return;
}
try {
unlistenFn = await events.playerStatusEvent.listen((event) => {
handlePlayerEvent(event.payload);
});
isInitialized = true;
log.debug("Player event listener initialized");
} catch (e) {
log.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
log.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.durationMs ? currentItem.durationMs / 1000 : 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") {
log.debug("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
log.debug("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") {
log.debug("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
log.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) {
log.error("Failed to handle playback ended:", e);
// Fallback: set idle state on error
player.setIdle();
}
}
/**
* Handle error events.
*
* A recoverable error gets one chance at recovery before anything is torn down.
* Backends whose event thread cannot reach the controller (MpvBackend is built
* before PlayerController exists) report the failure and rely on this echo to
* put the decision back in Rust — the same shape as PlaybackEnded →
* playerOnPlaybackEnded. Nothing is decided here: if Rust re-opened the stream
* it says so, and stopping the player would kill the playback it just restored.
*
* TRACES: UR-004, UR-040 | DR-130
*/
async function handleError(message: string, recoverable: boolean): Promise<void> {
log.error(`Playback error (recoverable: ${recoverable}): ${message}`);
if (recoverable) {
try {
if (await commands.playerRecoverStream()) {
log.debug("Stream re-opened after a recoverable error - not stopping");
return;
}
} catch (e) {
// Fall through to the normal stop: a failed recovery attempt is still an
// error, and leaving the player running would strand it mid-failure.
log.error("Stream recovery attempt failed:", e);
}
}
player.setError(message);
// Stop backend player to prevent orphaned playback
// This also reports playback stopped to Jellyfin server
try {
await commands.playerStop();
log.debug("Backend player stopped after error");
} catch (e) {
log.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 can drive the webview <video>/<audio> element that Rust cannot
* reach directly. No-op when no adapter is active (native playback is already
* fully backend-driven).
*
* This is the EXECUTION half of transport authority: for webview-rendered media
* the Rust controller decides play-vs-pause from the state the element reported
* and emits it here as a ControlCommand. UI intents go *to* the backend (see the
* facade in $lib/player) and come back through this path — never short-circuited
* in the webview, which is what caused the DR-097 pause loop.
*/
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:
log.warn("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);
}
}
}