Files
jellytau/src/lib/player/index.ts
T
dtourolle fecd6022fe chore(traceability): shift this branch's ids clear of master's
Master allocated DR-224 and UT-211 while this branch was in flight — the third
collision on this work. Everything here moves up by one: DR-224..236 become
DR-225..237, UT-211..213 become UT-212..214. UR-079, UR-080 and IR-033 were
still free and are unchanged.

Mechanical, and matched on each row's own text rather than on its number, so a
row cannot be shifted twice or the wrong one caught. Master's DR-224 (the
background-audio toggle) and UT-211 are untouched.
2026-08-22 13:45:04 +02:00

388 lines
13 KiB
TypeScript

/**
* 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, DR-097 | UT-091
*/
import { get } from "svelte/store";
import { commands } from "$lib/api/bindings";
import type {
PlayTracksContext,
PlayAlbumTrackRequest,
PlayItemRequest,
StreamingQuality,
StreamSelection,
} 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)
// ---------------------------------------------------------------------------
// Transport intents ALWAYS go to the backend, in both native and HTML5 modes.
//
// These used to short-circuit into the active video adapter, which made the
// webview the decider: `adapter.toggle()` read `el.paused` off the DOM and
// flipped the element, so Rust never saw the intent. `el.paused` flips
// transiently while an element buffers or settles a seek, so two intents
// ~150ms apart could read different values and take opposing actions — a
// self-sustaining play/pause loop.
//
// Now Rust decides from PlayerController state and drives the element back
// through a `ControlCommand` event (handled in playerEvents.ts), the same
// "backend decides, adapter executes the primitive" split used by
// player_seek_video. Do NOT reintroduce an adapter short-circuit here.
async function play() {
await commands.playerPlay();
}
async function pause() {
await commands.playerPause();
}
async function 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<void> {
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 `seek_offset` snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") {
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
// the element's clock: the reloaded stream starts at the item's zero since
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
await adapter.reloadSource(response.selection, 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<void> {
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.selection, response.position!);
}
}
/**
* Change the bandwidth ceiling of the video playing now. The backend re-opens
* the stream at the new quality and decides who reloads: it handles a native
* backend itself, and hands HTML5 a selection for the same `reloadSource`
* primitive the audio-track switch uses. Requires an active video adapter.
*
* The change applies to **this playback only** — the backend sets a per-playback
* override that the next item clears, leaving the durable Settings default
* alone. Returns the negotiated selection so the caller can show what it
* actually got, which is not always what was asked for: a ceiling above the
* source bitrate is the source.
*
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/
async function setStreamQuality(
quality: StreamingQuality,
currentPosition: number | null,
mediaSourceId: string | null,
audioTrackIndex: number | null,
): Promise<StreamSelection | null> {
const adapter = activeAdapter;
if (!adapter) return null;
const response = (await commands.playerSetStreamQuality(
requireHandle(),
quality,
adapter.kind === "html5",
currentPosition,
mediaSourceId,
audioTrackIndex,
)) as any;
if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
return response.selection;
}
// The native backend reloaded itself, but still reports what it opened — the
// caller needs it to show the rung actually in force.
return response.selection ?? null;
}
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,
setStreamQuality,
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";