/** * 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 { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import { player, playbackPosition } from "$lib/stores/player"; import { queue, currentQueueItem } from "$lib/stores/queue"; import { playbackMode } from "$lib/stores/playbackMode"; import { sleepTimer } from "$lib/stores/sleepTimer"; import { nextEpisode } from "$lib/stores/nextEpisode"; import { preloadUpcomingTracks } from "$lib/services/preload"; import type { MediaItem } from "$lib/api/types"; import { get } from "svelte/store"; /** * Event types emitted by the player backend. * Must match PlayerStatusEvent in src-tauri/src/player/events.rs * * TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 */ export type PlayerStatusEvent = | { type: "position_update"; position: number; duration: number } | { type: "state_changed"; state: string; media_id: string | null } | { type: "media_loaded"; duration: number } | { type: "playback_ended" } | { type: "buffering"; percent: number } | { type: "error"; message: string; recoverable: boolean } | { type: "volume_changed"; volume: number; muted: boolean } | { type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } | { type: "show_next_episode_popup"; current_episode: MediaItem; next_episode: MediaItem; countdown_seconds: number; auto_advance: boolean } | { type: "countdown_tick"; remaining_seconds: number }; // Sleep timer mode type export type SleepTimerMode = | { kind: "off" } | { kind: "time"; endTime: number } | { kind: "endOfTrack" } | { kind: "episodes"; remaining: number }; /** Event name for player status events from backend */ const PLAYER_EVENT_NAME = "player-event"; 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 { if (isInitialized) { console.warn("Player events already initialized"); return; } try { unlistenFn = await listen( PLAYER_EVENT_NAME, (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 "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; } } /** * 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 } /** * Handle state change events. * * TRACES: UR-005 | DR-001 */ async function handleStateChanged(state: string, _mediaId: string | null): Promise { // 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) { // Use 0 for position/duration - will be updated by position_update events const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; player.setPlaying(currentItem, 0, 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 from store const currentPosition = get(playbackPosition); const initialDuration = currentItem.runTimeTicks ? currentItem.runTimeTicks / 10000000 : 0; player.setPaused(currentItem, currentPosition, initialDuration); } else if (state === "loading" && currentItem) { player.setLoading(currentItem); } // Update queue status on state change await updateQueueStatus(); 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"); } // Update queue status on state change await updateQueueStatus(); break; } } /** * Update queue status from backend. * Called on state changes instead of polling. */ async function updateQueueStatus(): Promise { try { const queueStatus = await invoke<{ hasNext: boolean; hasPrevious: boolean; shuffle: boolean; repeat: string; }>("player_get_queue_status"); // Import appState stores dynamically to avoid circular imports const { hasNext, hasPrevious, shuffle, repeat } = await import("$lib/stores/appState"); hasNext.set(queueStatus.hasNext); hasPrevious.set(queueStatus.hasPrevious); shuffle.set(queueStatus.shuffle); repeat.set(queueStatus.repeat as "off" | "all" | "one"); } catch (e) { console.error("[playerEvents] Failed to update queue status:", e); } } /** * 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 { // Call backend to handle autoplay decision (queue advance, sleep timer, episode popup, etc.) try { await invoke("player_on_playback_ended"); } 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 { 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 invoke("player_stop"); 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 }); } /** * 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. */ function handleCountdownTick(remainingSeconds: number): void { // Update next episode store with new countdown value nextEpisode.updateCountdown(remainingSeconds); }