Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
/**
|
|
* 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<PlayerStore>(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;
|
|
},
|
|
);
|