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 -9
View File
@@ -13,6 +13,7 @@ import {
RotateCcw,
Scissors,
Share2,
SkipBack,
Trash2,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
@@ -76,7 +77,7 @@ export function useDownloadActions({
onEditDetails,
onManageShares,
}: {
onPlay: (job: DownloadJob) => void;
onPlay: (job: DownloadJob, fromStart?: boolean) => void;
onEdit: (job: DownloadJob) => void;
onEditDetails: (job: DownloadJob) => void;
onManageShares: (job: DownloadJob) => void;
@@ -128,12 +129,28 @@ export function useDownloadActions({
};
const icon = (I: typeof Play) => <I className="w-4 h-4" />;
const play = (job: DownloadJob): ActionMenuItem => ({
id: "play",
label: t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
});
// A started item continues, and can also start over — the card menus' words and glyph (C5).
const play = (job: DownloadJob): ActionMenuItem[] => {
const started = (job.position_seconds ?? 0) > 0;
return [
{
id: "play",
label: started ? t("card.continue") : t("downloads.actions.play"),
icon: icon(PlayCircle),
onSelect: () => onPlay(job),
},
...(started
? [
{
id: "restart",
label: t("card.restartTitle"),
icon: icon(SkipBack),
onSelect: () => onPlay(job, true),
},
]
: []),
];
};
// The same link the row's save button is, followed inside the click: the server answers it as an
// attachment, so the page stays where it is.
const save = (job: DownloadJob): ActionMenuItem => ({
@@ -200,7 +217,7 @@ export function useDownloadActions({
// The row's own buttons first, in the row's order: sharing the link is the library's main
// errand, ahead of saving the file.
[
...(a.play ? [play(job)] : []),
...(a.play ? play(job) : []),
{
id: "share",
label: t("downloads.actions.share"),
@@ -269,7 +286,7 @@ export function useDownloadActions({
// Same media_kind + can_download gate as the library, via the shared helper.
const a = sharedRowActions(job);
return menuGroups(
[...(a.play ? [play(job)] : []), ...(a.save ? [save(job)] : [])],
[...(a.play ? play(job) : []), ...(a.save ? [save(job)] : [])],
a.edit ? [edit(job)] : [],
copies(job),
[