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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,9 +23,10 @@ import { SURFACE } from "./surfaces";
|
||||
/** Where a dialog opens focused (C4, by purpose — WAI-ARIA APG): `field` = the body's first control,
|
||||
* or the one marked `data-initial-focus` (a form), `close` = the header's ✕ (anything to read or
|
||||
* browse), `action` = the child that takes `autoFocus` itself (a confirm's least destructive button;
|
||||
* the ✕ if none does). Required, so every dialog states its purpose instead of inheriting "the first
|
||||
* the ✕ if none does), `player` = the panel itself (a player: its keys reach it, and Space presses no
|
||||
* button). Required, so every dialog states its purpose instead of inheriting "the first
|
||||
* focusable", which is always the ✕. */
|
||||
export type ModalFocus = "field" | "close" | "action";
|
||||
export type ModalFocus = "field" | "close" | "action" | "player";
|
||||
|
||||
// The footer slot of the open dialog. `ModalFooter` portals into it from any depth, so a body that
|
||||
// owns its own state (the issue composer) pins its buttons without lifting that state into the caller.
|
||||
@@ -101,6 +102,7 @@ export default function Modal({
|
||||
// Only a rendered target (the trap's own `offsetParent` test); none falls back to the hook's
|
||||
// first-focusable rule. `action` lands on the ✕ here and its child's autoFocus then takes over.
|
||||
initial: () =>
|
||||
(initialFocus === "player" ? panelRef.current : null) ??
|
||||
(initialFocus === "field" && bodyRef.current ? firstFieldIn(bodyRef.current) : null) ??
|
||||
(closeRef.current?.offsetParent != null ? closeRef.current : null),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user