Tapping the video surface pause-looped: it would unpause and bounce straight back to paused about a second later. Long-press unpaused fine, which is what pinned it to the tap path rather than the media pipeline. The gesture handler deferred the first tap's play/pause behind a 300ms timer so a second tap could cancel it and seek instead. But the timer callback cleared its own handle *before* invoking the toggle, and handleVideoClick used exactly that handle (`tapTimeout !== null`) to suppress the compatibility click Android's WebView synthesizes after a touch. So the guard was already open when the late click arrived, and it toggled a second time. Replace the deferral with immediate action — there are only first and second taps: 1st tap: toggle play/pause 2nd tap: seek, then toggle play/pause again The second toggle undoes the first, so a double tap seeks while leaving the play state exactly as it was: playing jumps and keeps playing, paused jumps and stays paused. No timer, no window race, no loop. Click suppression no longer depends on the timer: ignore detail === 0 and any click within 700ms of a touch tap, since Android can deliver the synthesized click late and with a real detail value. A swipe now undoes the touchstart toggle (latched on swipeGestureActive so it happens once, not per touchmove frame), keeping brightness swipes from changing the play state. UT-085..087 described the old deferred behaviour and are updated to the new contract. UT-091 is used for the DR-097 facade tests, since UT-089 and UT-090 were already claimed by extract-traces.test.ts.
346 lines
11 KiB
TypeScript
346 lines
11 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,
|
|
} 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!);
|
|
}
|
|
}
|
|
|
|
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";
|