/** * Player state store - Thin wrapper over Rust PlayerController * * This store is display-only for most fields, receiving updates from * backend events via playerEvents.ts. User actions are sent as commands * to the Rust backend, which drives state changes. * * @req: UR-005 - Control media playback (pause, play, skip, scrub) * @req: DR-001 - Player state machine (idle, loading, playing, paused, seeking, error) * @req: DR-009 - Audio player UI (mini player, full screen) */ import { writable, derived } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; import { isRemoteMode } from "./playbackMode"; import { selectedSession } from "./sessions"; import { ticksToSeconds } from "$lib/utils/playbackUnits"; // Merged media item from backend (matches Rust MergedMediaItem) export interface MergedMediaItem { id: string; title: string; artist: string | null; album: string | null; albumId: string | null; duration: number | null; primaryImageTag: string | null; mediaType: "audio" | "video"; } export type PlayerState = | { kind: "idle" } | { kind: "loading"; media: MediaItem } | { kind: "playing"; media: MediaItem; position: number; duration: number } | { kind: "paused"; media: MediaItem; position: number; duration: number } | { kind: "seeking"; media: MediaItem; target: number } | { kind: "error"; media: MediaItem | null; error: string }; export type RepeatMode = "off" | "all" | "one"; interface PlayerStore { state: PlayerState; volume: number; muted: boolean; } function createPlayerStore() { const initialState: PlayerStore = { state: { kind: "idle" }, volume: 1.0, muted: false, }; const { subscribe, set, update } = writable(initialState); function setIdle() { update((s) => ({ ...s, state: { kind: "idle" } })); } function setLoading(media: MediaItem) { update((s) => ({ ...s, state: { kind: "loading", media } })); } function setPlaying(media: MediaItem, position: number, duration: number) { update((s) => ({ ...s, state: { kind: "playing", media, position, duration }, })); } function setPaused(media: MediaItem, position: number, duration: number) { update((s) => ({ ...s, state: { kind: "paused", media, position, duration }, })); } function setSeeking(media: MediaItem, target: number) { update((s) => ({ ...s, state: { kind: "seeking", media, target } })); } function setError(error: string, media: MediaItem | null = null) { update((s) => ({ ...s, state: { kind: "error", media, error } })); } function updatePosition(position: number, duration?: number) { update((s) => { if (s.state.kind === "playing" || s.state.kind === "paused") { return { ...s, state: { ...s.state, position, // Update duration if provided and valid duration: duration !== undefined && duration > 0 ? duration : s.state.duration }, }; } return s; }); } function setVolume(volume: number) { update((s) => ({ ...s, volume: Math.max(0, Math.min(1, volume)) })); } function setMuted(muted: boolean) { update((s) => ({ ...s, muted })); } function toggleMute() { update((s) => ({ ...s, muted: !s.muted })); } return { subscribe, setIdle, setLoading, setPlaying, setPaused, setSeeking, setError, updatePosition, setVolume, setMuted, toggleMute, }; } export const player = createPlayerStore(); // Derived stores export const playerState = derived(player, ($p) => $p.state); export const currentMedia = derived(player, ($p) => { const state = $p.state; if (state.kind === "idle") return null; return state.media; }); export const isPlaying = derived(player, ($p) => $p.state.kind === "playing"); export const isPaused = derived(player, ($p) => $p.state.kind === "paused"); export const isLoading = derived(player, ($p) => $p.state.kind === "loading"); export const playbackPosition = derived(player, ($p) => { const state = $p.state; if (state.kind === "playing" || state.kind === "paused") { return state.position; } return 0; }); export const playbackDuration = derived(player, ($p) => { const state = $p.state; if (state.kind === "playing" || state.kind === "paused") { return state.duration; } return 0; }); export const volume = derived(player, ($p) => $p.volume); export const isMuted = derived(player, ($p) => $p.muted); // Merged playback state (combines local and remote based on playback mode) // These stores replace the mergedPlaybackState.ts helper functions /** * Merged media item - prefers remote session when in remote mode */ export const mergedMedia = derived( [isRemoteMode, selectedSession, currentMedia], ([$isRemote, $session, $local]) => { if ($isRemote && $session?.nowPlayingItem) { return $session.nowPlayingItem; } return $local; } ); /** * Merged isPlaying state - prefers remote session when in remote mode */ export const mergedIsPlaying = derived( [isRemoteMode, selectedSession, isPlaying], ([$isRemote, $session, $localIsPlaying]) => { if ($isRemote && $session?.playState) { return !$session.playState.isPaused; } return $localIsPlaying; } ); /** * Merged position - prefers remote session when in remote mode */ export const mergedPosition = derived( [isRemoteMode, selectedSession, playbackPosition], ([$isRemote, $session, $localPosition]) => { if ($isRemote && $session?.playState) { return ticksToSeconds($session.playState.positionTicks ?? 0); } return $localPosition; } ); /** * Merged duration - prefers remote session when in remote mode */ export const mergedDuration = derived( [isRemoteMode, selectedSession, playbackDuration], ([$isRemote, $session, $localDuration]) => { if ($isRemote && $session?.nowPlayingItem?.runTimeTicks) { return ticksToSeconds($session.nowPlayingItem.runTimeTicks); } return $localDuration; } ); /** * Merged volume - prefers remote session when in remote mode * Both local and remote use 0-1 normalized range */ export const mergedVolume = derived( [isRemoteMode, selectedSession, volume], ([$isRemote, $session, $localVolume]) => { if ($isRemote && $session?.playState) { // Convert remote 0-100 to normalized 0-1 return ($session.playState.volumeLevel ?? 100) / 100; } return $localVolume; } ); /** * Should show audio miniplayer - state machine gated * Only true when: * 1. Player is in playing or paused state (not idle, loading, error) * 2. Current media is audio (not video: Movie or Episode) */ export const shouldShowAudioMiniPlayer = derived( [player, currentMedia], ([$player, $media]) => { const state = $player.state; // Only show when actively playing or paused if (state.kind !== "playing" && state.kind !== "paused") { return false; } // Don't show for video content const mediaType = $media?.type; if (mediaType === "Movie" || mediaType === "Episode") { return false; } // Show for audio content return true; } );