Rust: PlayerMediaItem and MergedMediaItem gain image_id (dual-carry), populated from primary_image_tag at every construction/conversion site. Regenerated bindings. Frontend: all catalog + player + merged readers now use imageId. The NowPlayingItem->MediaItem bridge (player.ts) properly maps the remote session's Jellyfin fields (Type, runTimeTicks, primaryImageTag) onto the neutral kind/durationMs/imageId. Types that are genuinely out of scope (Person, NowPlayingItem, PlayItemRequest) keep primaryImageTag. Rust 456, frontend 644, check clean.
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;
|
|
}
|
|
);
|