jellytau/src/lib/stores/player.ts
Duncan Tourolle 6d1c618a3a Implement Phase 1-2 of backend migration refactoring
CRITICAL FIXES (Previous):
- Fix nextEpisode event handlers (was calling undefined methods)
- Replace queue polling with event-based updates (90% reduction in backend calls)
- Move device ID to Tauri secure storage (security fix)
- Fix event listener memory leaks with proper cleanup
- Replace browser alerts with toast notifications
- Remove silent error handlers and improve logging
- Fix race condition in downloads store with request queuing
- Centralize duration formatting utility
- Add input validation to image URLs (prevent injection attacks)

PHASE 1: BACKEND SORTING & FILTERING 
- Created Jellyfin field mapping utility (src/lib/utils/jellyfinFieldMapping.ts)
  - Maps frontend sort keys to Jellyfin API field names
  - Provides item type constants and groups
  - Includes 20+ test cases for comprehensive coverage
- Updated route components to use backend sorting:
  - src/routes/library/music/tracks/+page.svelte
  - src/routes/library/music/albums/+page.svelte
  - src/routes/library/music/artists/+page.svelte
- Refactored GenericMediaListPage.svelte:
  - Removed client-side sorting/filtering logic
  - Removed filteredItems and applySortAndFilter()
  - Now passes sort parameters to backend
  - Uses backend search instead of client-side filtering
  - Added sortOrder state for Ascending/Descending toggle

PHASE 3: SEARCH (Already Implemented) 
- Search now uses backend repository_search command
- Replaced client-side filtering with backend calls
- Set up for debouncing implementation

PHASE 2: BACKEND URL CONSTRUCTION (Started)
- Converted getImageUrl() to async backend call
- Removed sync URL construction with credentials
- Next: Update 12+ components to handle async image URLs

UNIT TESTS ADDED:
- jellyfinFieldMapping.test.ts (20+ test cases)
- duration.test.ts (15+ test cases)
- validation.test.ts (25+ test cases)
- deviceId.test.ts (8+ test cases)
- playerEvents.test.ts (event initialization tests)

SUMMARY:
- Eliminated all client-side sorting/filtering logic
- Improved security by removing frontend URL construction
- Reduced backend polling load significantly
- Fixed critical bugs (nextEpisode, race conditions, memory leaks)
- 80+ new unit tests across utilities and services
- Comprehensive infrastructure for future phases

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-13 23:34:18 +01:00

254 lines
6.9 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 } 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";
}
// 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
*/
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;
}
);