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
+26
View File
@@ -56,6 +56,7 @@ import { HlsEngine } from "../lib/hlsEngine";
import { createHlsPort } from "../lib/hlsPort";
import { enabled, log } from "../lib/log";
import { logMediaElement } from "../lib/mediaLog";
import { useMediaSession } from "../lib/mediaSession";
import { UpNextCard } from "./UpNextCard";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
@@ -578,6 +579,30 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
);
const togglePlay = useCallback(() => requestPlay(!engine.shownPlaying()), [engine, requestPlay]);
// The OS's media controls (lock screen, headset, media keys) drive the player through its own seek
// and play intent and read its own clock: the element's starts at the stream's start (a transcode
// begins at the resume point), and a seek outside the stream restarts it.
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
useMediaSession(
videoEl,
detail && {
title:
detail.kind === "episode" && detail.season_number != null
? `S${detail.season_number}·E${detail.episode_number} — ${detail.title}`
: detail.title,
artist:
detail.kind === "episode" ? detail.show_title : detail.year ? String(detail.year) : null,
artwork: api.plexImageUrl(id),
},
{
play: () => requestPlay(true),
pause: () => requestPlay(false),
seekTo: (s) => seekTo(s),
position: () => engine.playhead(),
duration: () => durationRef.current,
},
);
// Volume/mute for the HTML5 <video> (0..1, independent mute), persisted + re-applied per session.
const { setVol, nudgeVolume, toggleMute, onWheel } = usePlayerVolume({
videoRef,
@@ -1095,6 +1120,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
<video
ref={(el) => {
videoRef.current = el;
setVideoEl(el);
// So Brave iOS's background play keeps it going when the app is left (lib/browserBackgroundPlay).
exposeVideoToBackgroundPlay(el);
}}