/** * Unified frontend player API (the boundary). * * This is the single write-side entry point for playback. Every UI component * that wants to *control* the player calls a method here; nothing else should * invoke `commands.player*` directly. The Rust `PlayerController` remains the * single source of truth — these methods only send intent-level commands and * let state flow back through `PlayerStatusEvent` → `playerEvents.ts` → the * `player`/`queue` stores. * * Reads stay on the established stores: this module re-exports the read-only * derived + merged (remote-session-aware) stores so UI can import state and * actions from one place, in both local and remote modes. * * TRACES: UR-005 | DR-001, DR-009 */ import { get } from "svelte/store"; import { commands } from "$lib/api/bindings"; import type { PlayTracksContext, PlayAlbumTrackRequest, PlayItemRequest, } from "$lib/api/bindings"; import { auth } from "$lib/stores/auth"; import type { PlayerAdapter } from "./adapters/types"; // --------------------------------------------------------------------------- // Active player adapter registry // // When a video is playing, VideoPlayer registers its PlayerAdapter here so that // control intents — whether from UI or routed from a backend control event // (lockscreen/remote/sleep-timer) — reach the actual player element/surface. // When no adapter is registered (audio-only playback), control falls through to // the queue-level backend commands, which is the correct behavior there. // --------------------------------------------------------------------------- let activeAdapter: PlayerAdapter | null = null; function setActiveAdapter(adapter: PlayerAdapter): void { activeAdapter = adapter; } function clearActiveAdapter(adapter?: PlayerAdapter): void { // Only clear if it's still the one we think is active (guards against a newly // mounted player's adapter being cleared by the outgoing player's teardown). if (!adapter || activeAdapter === adapter) { activeAdapter = null; } } function getActiveAdapter(): PlayerAdapter | null { return activeAdapter; } /** * Resolve the current repository handle, throwing a clear error if the user is * not authenticated. Centralizes the `auth.getRepository().getHandle()` dance * that was previously duplicated across every context-play call site. */ function requireHandle(): string { // The repository is the source of truth for the handle. We consult the auth // store's isAuthenticated flag only as a best-effort guard — guarded in a // try/catch so a not-yet-subscribable store (or a test double) can't block a // valid repository handle. try { const authState = get(auth); if (authState && authState.isAuthenticated === false) { throw new Error("User not authenticated"); } } catch (err) { // get(auth) failed (e.g. non-store mock) — fall through to the repository, // which is the authoritative source of the handle. if (err instanceof Error && err.message === "User not authenticated") throw err; } const repo = auth.getRepository(); if (!repo) { throw new Error("No repository available"); } return repo.getHandle(); } // --------------------------------------------------------------------------- // Transport controls (no repository handle required) // --------------------------------------------------------------------------- async function play() { if (activeAdapter) return void (await activeAdapter.play()); await commands.playerPlay(); } async function pause() { if (activeAdapter) return void (await activeAdapter.pause()); await commands.playerPause(); } async function toggle() { if (activeAdapter) return void (await activeAdapter.toggle()); await commands.playerToggle(); } async function stop() { // Stop is a queue/session-level action (clears playback); always go to backend. // The adapter is disposed by VideoPlayer's own teardown. await commands.playerStop(); } async function seek(positionSeconds: number) { // Audio path: backend seeks the native backend directly. if (!activeAdapter) { await commands.playerSeek(positionSeconds); return; } // Video path: ask the backend to DECIDE the strategy (in-place vs reload), then // execute the matching adapter primitive. The decision logic stays in Rust // (player_seek_video); the adapter only runs the chosen mechanical primitive. await seekVideo(positionSeconds, null, null); } /** * Video seek: backend decides strategy, facade dispatches the chosen adapter * primitive. `mediaSourceId`/`audioTrackIndex` come from the video view (they are * needed for the transcode reload URL). Requires an active video adapter. */ async function seekVideo( positionSeconds: number, mediaSourceId: string | null, audioTrackIndex: number | null ): Promise { const adapter = activeAdapter; if (!adapter) { await commands.playerSeek(positionSeconds); return; } const response = (await commands.playerSeekVideo( requireHandle(), positionSeconds, mediaSourceId, audioTrackIndex, adapter.kind === "html5" )) as any; // Serde keeps these snake_case (only the "strategy" tag is camelCase). if (response.strategy === "reloadStream") { await adapter.reloadSource(response.new_url ?? "", response.seek_offset ?? positionSeconds); } else { await adapter.seekElement(response.position ?? positionSeconds, 0); } } /** * Switch audio track: backend decides (may reload the stream), facade dispatches * the resulting primitive. Requires an active video adapter. */ async function switchAudioTrack( streamIndex: number, arrayIndex: number, currentPosition: number | null, mediaSourceId: string | null ): Promise { const adapter = activeAdapter; if (!adapter) return; const response = (await commands.playerSwitchAudioTrack( requireHandle(), streamIndex, arrayIndex, adapter.kind === "html5", currentPosition, mediaSourceId )) as any; if (response.strategy === "reloadStream") { await adapter.reloadSource(response.new_url!, response.position!); } } async function next() { await commands.playerNext(); } async function previous() { await commands.playerPrevious(); } async function skipTo(index: number) { await commands.playerSkipTo(index); } // --------------------------------------------------------------------------- // Queue mode controls // --------------------------------------------------------------------------- async function toggleShuffle() { await commands.playerToggleShuffle(); } async function cycleRepeat() { await commands.playerCycleRepeat(); } async function removeFromQueue(index: number) { await commands.playerRemoveFromQueue(index); } async function moveInQueue(fromIndex: number, toIndex: number) { await commands.playerMoveInQueue(fromIndex, toIndex); } // --------------------------------------------------------------------------- // Volume // --------------------------------------------------------------------------- async function setVolume(volume: number) { if (activeAdapter) activeAdapter.setVolume(volume); await commands.playerSetVolume(volume); } async function toggleMute() { await commands.playerToggleMute(); } // --------------------------------------------------------------------------- // Track selection (video) — dispatch to the active video adapter when present // --------------------------------------------------------------------------- async function setSubtitleTrack(streamIndex: number | null) { if (activeAdapter) return void (await activeAdapter.selectSubtitle(streamIndex)); await commands.playerSetSubtitleTrack(streamIndex); } // --------------------------------------------------------------------------- // Context-aware playback (repository handle required — resolved internally) // --------------------------------------------------------------------------- /** * Play a set of tracks by ID with an explicit queue context. The backend * fetches all metadata and builds the queue; the frontend queue store updates * from the resulting `queue_changed` event. */ async function playTracks(request: { trackIds: string[]; startIndex: number; shuffle: boolean; context: PlayTracksContext; startPosition?: number; }) { await commands.playerPlayTracks(requireHandle(), request); } /** Play a single track within its album context (more efficient than playTracks). */ async function playAlbumTrack(request: PlayAlbumTrackRequest) { await commands.playerPlayAlbumTrack(requireHandle(), request); } /** Play a single explicit media item (used by the video path). */ async function playItem(request: PlayItemRequest) { return commands.playerPlayItem(request); } /** Add a single track to the queue by ID. */ async function addTrackById(trackId: string, position: "next" | "end" = "end") { await commands.playerAddTrackById(requireHandle(), { trackId, position }); } /** Add multiple tracks to the queue by ID. */ async function addTracksByIds( trackIds: string[], position: "next" | "end" = "end" ) { await commands.playerAddTracksByIds(requireHandle(), { trackIds, position }); } /** * The unified player facade. Import this and call its methods instead of * reaching for `commands.player*` in UI code. */ export const playerController = { play, pause, toggle, stop, seek, next, previous, skipTo, toggleShuffle, cycleRepeat, removeFromQueue, moveInQueue, setVolume, toggleMute, setSubtitleTrack, seekVideo, switchAudioTrack, playTracks, playAlbumTrack, playItem, addTrackById, addTracksByIds, // Active-adapter registry (used by VideoPlayer to register its element adapter // and by playerEvents.ts to route backend control commands to it). setActiveAdapter, clearActiveAdapter, getActiveAdapter, }; // --------------------------------------------------------------------------- // Read-side re-exports: UI reads state from ONE place, in both local & remote // modes. These remain the single source of truth fed by playerEvents.ts. // --------------------------------------------------------------------------- export { playerState, currentMedia, isPlaying, isPaused, isLoading, playbackPosition, playbackDuration, volume, isMuted, mergedMedia, mergedIsPlaying, mergedPosition, mergedDuration, mergedVolume, } from "$lib/stores/player"; export { queueItems, currentQueueIndex, currentQueueItem, isShuffle, repeatMode, hasNext, hasPrevious, } from "$lib/stores/queue";