Adds the frontend's first linter and formatter — the Rust half has had cargo fmt --check and clippy in CI for a while, while 274 TS/Svelte files had only svelte-check. ESLint runs clean; 159 findings are recorded as warnings rather than suppressed, so the backlog is visible without painting CI red. Also: `bun run test` no longer drops into watch mode (the "Before Committing" list told people to run a command that never returns), the traceability ratchet moves 82% -> 88%, a pre-commit hook enforces the fast half of that list instead of relying on memory, the dead webdriverio e2e suite and its five devDeps are removed, and the Rust toolchain is pinned to 1.97.1 so the developer machine and the CI builder image stop being five releases apart. TRACES: | DR-205, DR-206, DR-207
402 lines
13 KiB
TypeScript
402 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);
|
|
}
|
|
}
|
|
}
|