All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
326 lines
10 KiB
TypeScript
326 lines
10 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 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.
|
|
sleepTimerExpiredSignal.update((n) => n + 1);
|
|
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 });
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
}
|