Playback fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* HTML5 <video> → Rust reporting adapter ("html5+rust internal module").
|
||||
*
|
||||
* On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
||||
* <video>; and, per the current interim behavior, Android too), the real player
|
||||
* is the DOM element, which the Rust backend cannot observe directly. This
|
||||
* module is the single place that reports the element's lifecycle back into
|
||||
* Rust, so the `PlayerController` stays the source of truth and the frontend
|
||||
* `player` store is fed from ONE pipeline (playerEvents.ts) in both native and
|
||||
* HTML5 modes.
|
||||
*
|
||||
* The VideoPlayer component owns the element and its UI; it calls these
|
||||
* functions from its DOM event handlers. Keeping the `commands.playerReport*`
|
||||
* calls here (rather than scattered in the component) is the boundary: UI code
|
||||
* never talks to the report commands directly.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-001, DR-028
|
||||
*/
|
||||
|
||||
import { commands } from "$lib/api/bindings";
|
||||
|
||||
/** Player states mirrored to Rust (must match the strings playerEvents.ts handles). */
|
||||
export type Html5PlayerState = "playing" | "paused" | "loading" | "stopped" | "idle";
|
||||
|
||||
/**
|
||||
* Report an HTML5 <video> state transition to Rust. The controller re-emits a
|
||||
* `StateChanged` event identical to the native backends', so the frontend
|
||||
* player store updates through its normal path.
|
||||
*/
|
||||
export async function reportState(
|
||||
state: Html5PlayerState,
|
||||
mediaId: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
await commands.playerReportState(state, mediaId);
|
||||
} catch (err) {
|
||||
console.warn("[html5Adapter] Failed to report state:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Position reporting is throttled to ~250ms to match the native backends'
|
||||
* cadence and avoid flooding the IPC channel from the 60fps RAF loop.
|
||||
*/
|
||||
let lastPositionReport = 0;
|
||||
const POSITION_REPORT_INTERVAL_MS = 250;
|
||||
|
||||
/**
|
||||
* Report an HTML5 <video> position tick to Rust (throttled). Safe to call every
|
||||
* animation frame; only forwards at most every {@link POSITION_REPORT_INTERVAL_MS}.
|
||||
*/
|
||||
export async function reportPosition(
|
||||
position: number,
|
||||
duration: number,
|
||||
{ force = false }: { force?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastPositionReport < POSITION_REPORT_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastPositionReport = now;
|
||||
try {
|
||||
await commands.playerReportPosition(position, Number.isFinite(duration) ? duration : 0);
|
||||
} catch (err) {
|
||||
console.warn("[html5Adapter] Failed to report position:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report that the HTML5 <video> finished loading metadata and knows its
|
||||
* duration. Mirrors the native `MediaLoaded` event.
|
||||
*/
|
||||
export async function reportMediaLoaded(duration: number): Promise<void> {
|
||||
try {
|
||||
await commands.playerReportMediaLoaded(Number.isFinite(duration) ? duration : 0);
|
||||
} catch (err) {
|
||||
console.warn("[html5Adapter] Failed to report media loaded:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset internal throttle state (call when a new stream loads). */
|
||||
export function resetReporting(): void {
|
||||
lastPositionReport = 0;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const authState = get(auth);
|
||||
if (!authState.isAuthenticated) {
|
||||
throw new Error("User not authenticated");
|
||||
}
|
||||
const repo = auth.getRepository();
|
||||
if (!repo) {
|
||||
throw new Error("No repository available");
|
||||
}
|
||||
return repo.getHandle();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport controls (no repository handle required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function play() {
|
||||
await commands.playerPlay();
|
||||
}
|
||||
|
||||
async function pause() {
|
||||
await commands.playerPause();
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
await commands.playerToggle();
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
await commands.playerStop();
|
||||
}
|
||||
|
||||
async function seek(positionSeconds: number) {
|
||||
await commands.playerSeek(positionSeconds);
|
||||
}
|
||||
|
||||
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) {
|
||||
await commands.playerSetVolume(volume);
|
||||
}
|
||||
|
||||
async function toggleMute() {
|
||||
await commands.playerToggleMute();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track selection (video)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function setSubtitleTrack(streamIndex: number | null) {
|
||||
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,
|
||||
playTracks,
|
||||
playAlbumTrack,
|
||||
playItem,
|
||||
addTrackById,
|
||||
addTracksByIds,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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";
|
||||
Reference in New Issue
Block a user