Establish a decoupled player boundary so UI and backend interact with video through one contract, with the HTML5 (Linux/interim-Android) and native (ExoPlayer) providers as interchangeable primitive-executor adapters. - PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource, play/pause, setVolume, selectSubtitle); it never branches on strategy. - Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track return a strategy); the facade dispatches the chosen primitive to the active adapter. Both providers share the one decision path — logic lives once, in Rust. - Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets backend control (lockscreen/remote/sleep) drive the webview <video> element. - Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause silently no-opping when the element was re-bound). - Do not emit a "stopped" player state on natural end-of-video: it flipped the player/mode to idle mid-handoff and suppressed next-episode auto-advance under a sleep timer. Jellyfin progress reporting is preserved; the backend's on_video_playback_ended owns the transition. - VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter). - Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
335 lines
10 KiB
TypeScript
335 lines
10 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
|
|
*/
|
|
|
|
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<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!);
|
|
}
|
|
}
|
|
|
|
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";
|