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
@@ -15,6 +15,8 @@ import {
type KeyScope,
} from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { useMediaKeys } from "../../lib/mediaKeys";
import { useState } from "react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -179,4 +181,32 @@ describe("KeyboardHelp", () => {
pressH();
expect(descriptions()).toContain("keys.episode");
});
// The library player's F fills the screen with a video; an audio file has no picture, so the
// sheet over an audio file does not offer it. Through the real hook, which publishes the answer.
it("offers Fullscreen over a video and not over an audio file", () => {
function Player({ tag }: { tag: "video" | "audio" }) {
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
useMediaKeys(media);
return tag === "video" ? <video ref={setMedia} /> : <audio ref={setMedia} />;
}
const { unmount } = render(
<Host>
<Player tag="audio" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.playPause");
expect(descriptions()).not.toContain("keys.fullscreen");
unmount();
closeKeyboardHelp();
render(
<Host>
<Player tag="video" />
</Host>,
);
pressH();
expect(descriptions()).toContain("keys.fullscreen");
});
});