/** * 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 { 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