feat(player): the OS media controls, the basic keys and resume for the library and shared-link players

Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
This commit is contained in:
2026-10-06 23:55:41 +02:00
parent 02feaa9a1c
commit 59b27467ab
32 changed files with 1489 additions and 133 deletions
+31 -1
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { req, uploadReq, getActiveAccount } from "./core";
import { beacon, req, uploadReq, getActiveAccount } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -97,6 +97,9 @@ export interface DownloadJob {
asset: DownloadAsset | null;
shared?: boolean;
user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
position_seconds?: number;
}
export interface ShareRecipient {
@@ -274,6 +277,33 @@ export const downloadsApi = {
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
// The library player's resume checkpoint. A position too early or too close to the end clears it.
saveDownloadProgress: (
id: number,
positionSeconds: number,
durationSeconds: number,
): Promise<{ ok: true; job_id: number; position_seconds: number }> =>
req(
`/api/downloads/${id}/progress`,
{
method: "POST",
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
},
{ idempotent: true },
),
// The same, as a keepalive POST that survives the page going away (a reload, a closed tab).
saveDownloadProgressBeacon: (
id: number,
positionSeconds: number,
durationSeconds: number,
): void =>
beacon(`/api/downloads/${id}/progress`, {
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
// account via ?account= (the server honours it only for an account in this browser's wallet) —
// otherwise the download resolves to the session-default account and 404s for a per-tab