First working POC
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 { handleEpisodeEnded as showNextEpisodePopup } from "$lib/services/nextEpisodeService";
|
||||
import { preloadUpcomingTracks } from "$lib/services/preload";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
/**
|
||||
* Event types emitted by the player backend.
|
||||
* Must match PlayerStatusEvent in src-tauri/src/player/events.rs
|
||||
*/
|
||||
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<void> {
|
||||
if (isInitialized) {
|
||||
console.warn("Player events already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
unlistenFn = await listen<PlayerStatusEvent>(
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
function handleStateChanged(state: string, mediaId: string | null): 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) {
|
||||
// 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(() => {
|
||||
// Preload failures are non-critical, already logged in the service
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
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).
|
||||
*/
|
||||
async function handlePlaybackEnded(): Promise<void> {
|
||||
// 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.
|
||||
*/
|
||||
function handleError(message: string, recoverable: boolean): void {
|
||||
console.error(`Playback error (recoverable: ${recoverable}): ${message}`);
|
||||
player.setError(message);
|
||||
|
||||
if (!recoverable) {
|
||||
// For non-recoverable errors, return to idle
|
||||
player.setIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle sleep timer changed event.
|
||||
*/
|
||||
function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number): void {
|
||||
sleepTimer.set({ mode, remainingSeconds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle show next episode popup event.
|
||||
*/
|
||||
function handleShowNextEpisodePopup(
|
||||
currentEpisode: MediaItem,
|
||||
nextEpisode: MediaItem,
|
||||
countdownSeconds: number,
|
||||
autoAdvance: boolean
|
||||
): void {
|
||||
// Update next episode store to show popup
|
||||
nextEpisode.showPopup(currentEpisode, nextEpisode, countdownSeconds, autoAdvance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle countdown tick event.
|
||||
*/
|
||||
function handleCountdownTick(remainingSeconds: number): void {
|
||||
// Update next episode store with new countdown value
|
||||
nextEpisode.updateCountdown(remainingSeconds);
|
||||
}
|
||||
Reference in New Issue
Block a user