Video streams were opened at a fixed allowance nobody could change: MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device profile that let the server direct-play a source of any size. On a metered or slow connection there was no way to spend less. StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/ 4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the audio share of it and the resolution that budget can carry. Those numbers are Jellyfin encoding vocabulary, so they live in Rust and the frontend only names a variant; labels and details come back over IPC from player_get_streaming_qualities, the same arrangement as the EQ presets. The cap has to reach the *negotiation*, not just the transcode URL: max_static_bitrate in the device profile is what makes the server refuse to direct-play a file fatter than the cap, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot. So it is applied at all four places that decide bandwidth — the HLS URL builder, PlaybackInfo, the Live TV stream, and the background-audio handoff (which takes the lower of the cap and its own 384 kbps). Video bitrate is the total minus the audio share so the two together honour the ceiling rather than overshooting it. The ceiling is process-wide rather than a repository field: it is a preference about this device's connection, must survive a repository rebuilt on re-login, and every URL builder plus the negotiation have to agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE. Two ways in. Settings holds the durable default, persisted to app_settings and restored at startup — unlike the rest of VideoSettings, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to see. The in-player menu is the "this film, this connection" override: a cap is a property of the stream the server is producing, so it cannot apply to one already in flight — player_set_stream_quality re-opens the stream at the new quality and resumes at the current position, reloading the native backend itself and handing HTML5 a URL for the same reloadSource primitive the audio-track switch uses. Tests pin the URL parameters at a capped and an uncapped step, the handoff taking the lower of the two, the ladder's internal consistency (video + audio == cap, resolution descending with bitrate) and the persisted token's round trip. The ceiling is process-wide, so the tests that depend on it serialise on a guard that restores the default. TRACES: UR-074 | DR-160 | UT-156, UT-157
378 lines
12 KiB
TypeScript
378 lines
12 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,
|
|
} 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 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<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.new_url!, 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 URL for the same `reloadSource` primitive
|
|
* the audio-track switch uses. Requires an active video adapter.
|
|
*
|
|
* TRACES: UR-074 | DR-160
|
|
*/
|
|
async function setStreamQuality(
|
|
quality: StreamingQuality,
|
|
currentPosition: number | null,
|
|
mediaSourceId: string | null,
|
|
audioTrackIndex: number | null
|
|
): Promise<void> {
|
|
const adapter = activeAdapter;
|
|
if (!adapter) return;
|
|
const response = (await commands.playerSetStreamQuality(
|
|
requireHandle(),
|
|
quality,
|
|
adapter.kind === "html5",
|
|
currentPosition,
|
|
mediaSourceId,
|
|
audioTrackIndex
|
|
)) as any;
|
|
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
|
if (response.strategy === "reloadStream") {
|
|
await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0);
|
|
}
|
|
}
|
|
|
|
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";
|