/** * 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, MediaKind } 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; imageId: 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 { // NowPlayingItem is remote-session data still carrying Jellyfin field names // (Type, runTimeTicks, primaryImageTag). Map it onto the neutral MediaItem the // UI consumes. Only the coarse audio/video split matters here for display. const kind: MediaKind = npi.Type === "Movie" ? "movie" : npi.Type === "Episode" ? "episode" : npi.Type === "MusicAlbum" ? "album" : "track"; return { id: npi.id ?? "", name: npi.name ?? "", kind, serverId: "", albumName: npi.album, albumId: npi.albumId, artists: npi.artists, imageId: npi.primaryImageTag ?? npi.albumPrimaryImageTag, durationMs: npi.runTimeTicks != null ? Math.floor(npi.runTimeTicks / 10000) : null, } 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; }, ); /** * Whether an item is video content and therefore must NOT appear in the audio * mini player. Checks the Jellyfin item type (Movie/Episode/live TV) AND the * backend queue item's `mediaType` discriminator. * * The `mediaType` check is essential: a video started via `player_play_item` * (every Movie/Episode goes through VideoPlayer) is pushed onto the backend * queue as a PlayerMediaItem with NO `type` field but `mediaType: "video"`. * Without this, that queue item's absent `type` slips past the Movie/Episode * check and the video surfaces in the audio mini player after leaving /player. */ function isVideoItem(item: MediaItem | null): boolean { if (!item) return false; const kind = item.kind; if (kind === "movie" || kind === "episode" || kind === "liveChannel") { return true; } // Backend PlayerMediaItem carries a lowercase mediaType discriminator that is // present even when the Jellyfin `type` is absent (video-only play requests). const mediaType = (item as { mediaType?: string }).mediaType; return mediaType === "video"; } /** * Should show audio miniplayer - state machine gated * Only true when: * 1. In remote mode with an active session playing media, OR * 2. There is an audio item loaded/queued and we are not in a genuine * stopped state. The bar stays visible through transient idle/loading/ * seeking blips so it never flickers while advancing between tracks. */ 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; } // Determine media type from the player state, falling back to the queue // item (the player store can momentarily lack media during transitions). const item = $media ?? $queueItem; if (isVideoItem(item)) { return false; } const state = $player.state; // A genuine stop clears the queue too, so when the player reports // idle/error we only hide if there is also no queue item to fall back to. // This keeps the bar visible through a transient idle/stopped blip emitted // mid-transition (e.g. sleep-timer churn or a stop-then-load track change), // while still hiding once playback has truly ended and the queue is empty. if (state.kind === "idle" || state.kind === "error") { return $queueItem != null; } // playing / paused / loading / seeking — audio is active, show the bar. return true; }, );