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.
59 lines
2.6 KiB
TypeScript
59 lines
2.6 KiB
TypeScript
// The keys of a player built on the browser's own media element — the library's and a shared link's:
|
|
// the keymap's `mediaPlayer` rows (play/pause, a 5 s step, fullscreen, mute), bound to the element.
|
|
// Its own controls do the rest; Escape stays with the dialog around it.
|
|
//
|
|
// While the element ITSELF has the focus (a click on its controls puts it there), Space and the
|
|
// arrows are its own: the browser's controls act on them whatever a listener does with the keydown,
|
|
// so ours as well played and paused on one Space, and stepped twice on one arrow (measured in Chrome).
|
|
import { useEffect, useEffectEvent } from "react";
|
|
|
|
import { dispatch, isActivationTarget, useHelpConditions, useKeyScope } from "./keymap";
|
|
import { exitFullscreen, fullscreenElement, requestFullscreen } from "./fullscreen";
|
|
|
|
export const MEDIA_STEP_S = 5;
|
|
|
|
/** Bind the `mediaPlayer` keys to `media` while it is mounted. */
|
|
export function useMediaKeys(media: HTMLMediaElement | null): void {
|
|
useKeyScope("mediaPlayer", media != null);
|
|
useHelpConditions({ video: media instanceof HTMLVideoElement });
|
|
const onKey = useEffectEvent((e: KeyboardEvent) => {
|
|
if (!media) return;
|
|
const step = (ev: KeyboardEvent, by: number) => {
|
|
if (ev.target === media) return;
|
|
ev.preventDefault();
|
|
const end = Number.isFinite(media.duration) ? media.duration : Infinity;
|
|
media.currentTime = Math.min(end, Math.max(0, media.currentTime + by));
|
|
};
|
|
const ownKey = (ev: KeyboardEvent) => ev.target === media && ev.key !== "k";
|
|
dispatch(e, "mediaPlayer", {
|
|
playPause: (ev) => {
|
|
// Space on a focused button presses it (the dialog's ✕, a link's Download), as in every
|
|
// player here.
|
|
if (isActivationTarget(ev) || ownKey(ev)) return;
|
|
ev.preventDefault();
|
|
if (media.paused) void media.play().catch(() => {});
|
|
else media.pause();
|
|
},
|
|
seekBack: (ev) => step(ev, -MEDIA_STEP_S),
|
|
seekFwd: (ev) => step(ev, MEDIA_STEP_S),
|
|
fullscreen: (ev) => {
|
|
// An audio file has no picture to fill the screen with.
|
|
if (!(media instanceof HTMLVideoElement)) return;
|
|
ev.preventDefault();
|
|
if (fullscreenElement()) exitFullscreen();
|
|
else requestFullscreen(media);
|
|
},
|
|
mute: (ev) => {
|
|
ev.preventDefault();
|
|
media.muted = !media.muted;
|
|
},
|
|
});
|
|
});
|
|
useEffect(() => {
|
|
if (!media) return;
|
|
const handler = (e: KeyboardEvent) => onKey(e);
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [media]);
|
|
}
|