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
+24 -6
View File
@@ -36,6 +36,7 @@ export type KeyScope =
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "mediaPlayer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -48,7 +49,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
type ShortcutWhen = "episode" | "video";
export interface Shortcut {
/** Unique within its scope; the handler key `dispatch()` demands. */
@@ -213,6 +214,23 @@ export const SHORTCUTS = [
allowInInput: true,
},
// --- a player on the browser's own media element (the library's, a shared link's) -----------
// The basic set (`lib/mediaKeys.ts`): the element's own controls carry the rest. The step is the
// YouTube player's 5 s, the other short-form player; Escape is the dialog's.
{ id: "playPause", scope: "mediaPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "seekBack", scope: "mediaPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "mediaPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
// An audio file has no picture to fill the screen with: the key does nothing there, so the sheet
// does not promise it.
{
id: "fullscreen",
scope: "mediaPlayer",
keys: ["f"],
labelKey: "keys.fullscreen",
when: "video",
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
@@ -540,14 +558,14 @@ export function useGlobalKeymap(
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
const helpConditions = createStore<HelpConditions>({});
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
* sheet lives in App and cannot ask, so the surface tells it. */
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode? the
* library's: is it a video?). The sheet lives in App and cannot ask, so the surface tells it. */
export function useHelpConditions(conditions: HelpConditions): void {
const episode = conditions.episode;
const { episode, video } = conditions;
useEffect(() => {
helpConditions.set({ episode });
helpConditions.set({ episode, video });
return () => helpConditions.set({});
}, [episode]);
}, [episode, video]);
}
// ── The module's search box ───────────────────────────────────────────────────────────────────