/** * 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. * * TRACES: UR-005 | DR-001, DR-009 */ import { writable, derived } from "svelte/store"; import type { MediaItem, ItemType } from "$lib/api/types"; import type { NowPlayingItem } from "$lib/api/bindings"; import { isRemoteMode } from "./playbackMode"; import { selectedSession } from "./sessions"; import { currentQueueItem } from "./queue"; 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"; } // TRACES: UR-005 | DR-001 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 */ /** * Normalize a remote session's NowPlayingItem into the MediaItem shape the UI * renders, so display components can treat local and remote items uniformly. * (NowPlayingItem has `album`/`artists` but no `albumName`/`artistItems`; the UI * falls back to the `artists` string list when `artistItems` is absent.) */ function nowPlayingToMediaItem(npi: NowPlayingItem): MediaItem { return { id: npi.id ?? "", name: npi.name ?? "", type: (npi.Type ?? "Audio") as ItemType, serverId: "", albumName: npi.album, albumId: npi.albumId, artists: npi.artists, primaryImageTag: npi.primaryImageTag ?? npi.albumPrimaryImageTag, runTimeTicks: npi.runTimeTicks, } as MediaItem; } export const mergedMedia = derived< [typeof isRemoteMode, typeof selectedSession, typeof currentMedia], MediaItem | null >( [isRemoteMode, selectedSession, currentMedia], ([$isRemote, $session, $local]) => { if ($isRemote && $session?.nowPlayingItem) { return nowPlayingToMediaItem($session.nowPlayingItem); } return $local ?? null; } ); /** * 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. In remote mode with an active session playing media, OR * 2. Player is in playing or paused state (not idle, loading, error) * AND current media is audio (not video: Movie or Episode) */ export const shouldShowAudioMiniPlayer = derived( [player, currentMedia, currentQueueItem, isRemoteMode, selectedSession], ([$player, $media, $queueItem, $isRemote, $session]) => { // In remote mode, show if the remote session has a now-playing item if ($isRemote && $session?.nowPlayingItem) { return true; } // Local mode: hide only when there is genuinely nothing loaded // (idle/stopped/error). Keep showing through loading/seeking transitions // so the mini player doesn't blink out when advancing between tracks. const state = $player.state; if (state.kind === "idle" || state.kind === "error") { return false; } // Determine media type from the player state, falling back to the queue // item (the player store can momentarily lack media during transitions). const mediaType = $media?.type ?? $queueItem?.type; if (mediaType === "Movie" || mediaType === "Episode") { return false; } // Show for audio content return true; } );