First working POC
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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<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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user