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
+22 -42
View File
@@ -1,9 +1,12 @@
import { useEffect, useId, useRef, useState } from "react";
import { useEffect, useId, useState } from "react";
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaSession } from "../lib/mediaSession";
import { useMediaKeys } from "../lib/mediaKeys";
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
import { Input } from "./ui/Input";
@@ -15,40 +18,6 @@ import { T, lang } from "./watchStrings";
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
// so it drives either the video or the audio-only player.
function useMediaSession(
videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null,
ready: boolean,
) {
useEffect(() => {
if (!ready || !meta || !("mediaSession" in navigator)) return;
const video = videoRef.current;
const init: MediaMetadataInit = {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
: [],
};
try {
navigator.mediaSession.metadata = new MediaMetadata(init);
navigator.mediaSession.setActionHandler("play", () => void video?.play());
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
} catch {
/* MediaMetadata / handlers unsupported — ignore */
}
}, [ready, meta, videoRef]);
}
type Meta = {
needs_password: boolean;
title?: string | null;
@@ -95,13 +64,24 @@ export default function WatchPage() {
const [error, setError] = useState<string | null>(null);
const errorId = useId();
const [unlocking, setUnlocking] = useState(false);
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
const mediaRef = useRef<HTMLMediaElement | null>(null);
const setMedia = (el: HTMLMediaElement | null) => {
mediaRef.current = el;
};
useMediaSession(mediaRef, meta, status === "ready");
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
// the resume bind to it once it exists.
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaSession(
status === "ready" ? media : null,
meta && {
title: meta.title || "Video",
artist: meta.uploader || "Siftlode",
artwork: meta.poster_url,
},
);
useMediaKeys(media);
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
// Read once per link, at render: the point a later checkpoint writes must not move the start.
const [start] = useState(() => (token ? linkPoint(token) : 0));
useResume(media, token || null, start, (position, duration) =>
saveLinkPoint(token, position, duration),
);
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.