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:
@@ -0,0 +1,161 @@
|
||||
// What is playing, told to the operating system through the Media Session API: the lock screen, the
|
||||
// notification shade, a headset's buttons and the keyboard's media keys show the title and the
|
||||
// picture and control the player — play, pause, skip back and forward, and a seek from the OS's own
|
||||
// progress bar. One definition for the Plex, library and shared-link players.
|
||||
//
|
||||
// The YouTube player is not one of them, by measurement: the browser routes the OS controls to the
|
||||
// frame that plays the sound, which is YouTube's own iframe, and that iframe already says its title,
|
||||
// channel and picture and takes play/pause/seek itself. Ours, set on the page, would never be shown.
|
||||
//
|
||||
// It is also what lets a browser's own "background playback" keep the sound going when the page is
|
||||
// backgrounded — how youtube.com stays playing on Brave iOS. The sound stays on a plain media element
|
||||
// for that reason: routing it through the Web Audio API (`createMediaElementSource`) pulls it out of
|
||||
// the pipeline that background playback hooks into, and iOS then suspends the AudioContext — which
|
||||
// STOPPED playback instead of keeping it alive (measured on iPad Brave).
|
||||
import { useEffect, useEffectEvent } from "react";
|
||||
|
||||
export interface MediaSessionInfo {
|
||||
title: string;
|
||||
/** The line under the title: a channel, a show, an uploader. */
|
||||
artist?: string | null;
|
||||
/** A picture of it (same-origin or absolute URL). */
|
||||
artwork?: string | null;
|
||||
}
|
||||
|
||||
/** How the OS's controls drive a player whose playhead is not simply its element's (a Plex
|
||||
* transcode starts its stream at the resume point, and a seek outside it restarts the stream). */
|
||||
export interface MediaSessionControls {
|
||||
play(): void;
|
||||
pause(): void;
|
||||
seekTo(seconds: number): void;
|
||||
/** Where it is, in the item's own seconds. */
|
||||
position(): number;
|
||||
/** How long the item is; 0 while unknown. */
|
||||
duration(): number;
|
||||
}
|
||||
|
||||
/** The OS's skip back / forward step when it does not say one. */
|
||||
export const SESSION_SKIP_S = 10;
|
||||
|
||||
/** The controls of a plain media element: its own clock, its own seek. */
|
||||
function elementControls(media: HTMLMediaElement): MediaSessionControls {
|
||||
return {
|
||||
play: () => void media.play().catch(() => {}),
|
||||
pause: () => media.pause(),
|
||||
seekTo: (s) => {
|
||||
media.currentTime = s;
|
||||
},
|
||||
position: () => media.currentTime,
|
||||
duration: () => (Number.isFinite(media.duration) ? media.duration : 0),
|
||||
};
|
||||
}
|
||||
|
||||
// The events after which the OS's progress bar is re-anchored. Between them it runs on by itself at
|
||||
// the stated rate, so nothing is sent per frame.
|
||||
const POSITION_EVENTS = [
|
||||
"loadedmetadata",
|
||||
"durationchange",
|
||||
"play",
|
||||
"pause",
|
||||
"seeked",
|
||||
"ratechange",
|
||||
] as const;
|
||||
|
||||
const ACTIONS = ["play", "pause", "seekbackward", "seekforward", "seekto"] as const;
|
||||
|
||||
// The API's throwing calls, each in a module helper of its own: a `try` inside the hook makes the
|
||||
// React Compiler skip it (see CLAUDE.md's async-handler row).
|
||||
|
||||
/** The metadata, told to the OS — or null when this browser refuses it (then no session at all,
|
||||
* rather than half of one). */
|
||||
function claim(session: MediaSession, init: MediaMetadataInit): MediaMetadata | null {
|
||||
try {
|
||||
const metadata = new MediaMetadata(init);
|
||||
session.metadata = metadata;
|
||||
return metadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function handle(
|
||||
session: MediaSession,
|
||||
action: MediaSessionAction,
|
||||
handler: MediaSessionActionHandler | null,
|
||||
): void {
|
||||
try {
|
||||
session.setActionHandler(action, handler);
|
||||
} catch {
|
||||
/* an action this browser does not know */
|
||||
}
|
||||
}
|
||||
|
||||
function setPosition(session: MediaSession, state?: MediaPositionState): void {
|
||||
try {
|
||||
session.setPositionState(state);
|
||||
} catch {
|
||||
/* a position the browser refuses (it checks position <= duration itself), or nothing to clear */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the OS that `media` plays `info`, for as long as both are given; `controls` (default: the
|
||||
* element's own) is what its buttons do. Cleared on unmount, unless another player has already
|
||||
* claimed the session since.
|
||||
*/
|
||||
export function useMediaSession(
|
||||
media: HTMLMediaElement | null,
|
||||
info: MediaSessionInfo | null | undefined,
|
||||
controls?: MediaSessionControls,
|
||||
): void {
|
||||
const title = info?.title ?? null;
|
||||
const artist = info?.artist ?? "";
|
||||
const artwork = info?.artwork ?? null;
|
||||
// Read when an action arrives, not bound at the effect: the Plex player's controls are fresh
|
||||
// closures every render, and re-telling the OS on each render would be churn.
|
||||
const ctl = useEffectEvent(
|
||||
(): MediaSessionControls | null => controls ?? (media ? elementControls(media) : null),
|
||||
);
|
||||
useEffect(() => {
|
||||
const session = typeof navigator !== "undefined" ? navigator.mediaSession : undefined;
|
||||
if (!session || !media || title == null || typeof MediaMetadata === "undefined") return;
|
||||
const metadata = claim(session, {
|
||||
title,
|
||||
artist,
|
||||
artwork: artwork ? [{ src: new URL(artwork, document.baseURI).href }] : [],
|
||||
});
|
||||
if (!metadata) return;
|
||||
const skip = (dir: -1 | 1) => (d: MediaSessionActionDetails) => {
|
||||
const c = ctl();
|
||||
if (!c) return;
|
||||
const end = c.duration() || Infinity;
|
||||
c.seekTo(Math.min(end, Math.max(0, c.position() + dir * (d.seekOffset ?? SESSION_SKIP_S))));
|
||||
};
|
||||
handle(session, "play", () => ctl()?.play());
|
||||
handle(session, "pause", () => ctl()?.pause());
|
||||
handle(session, "seekbackward", skip(-1));
|
||||
handle(session, "seekforward", skip(1));
|
||||
handle(session, "seekto", (d) => {
|
||||
if (d.seekTime != null) ctl()?.seekTo(d.seekTime);
|
||||
});
|
||||
const anchor = () => {
|
||||
const c = ctl();
|
||||
const duration = c?.duration() ?? 0;
|
||||
if (!c || !(duration > 0)) return;
|
||||
setPosition(session, {
|
||||
duration,
|
||||
position: Math.min(duration, Math.max(0, c.position())),
|
||||
playbackRate: media.playbackRate || 1,
|
||||
});
|
||||
};
|
||||
for (const type of POSITION_EVENTS) media.addEventListener(type, anchor);
|
||||
anchor();
|
||||
return () => {
|
||||
for (const type of POSITION_EVENTS) media.removeEventListener(type, anchor);
|
||||
if (session.metadata !== metadata) return; // another player has the session now
|
||||
session.metadata = null;
|
||||
for (const action of ACTIONS) handle(session, action, null);
|
||||
setPosition(session);
|
||||
};
|
||||
}, [media, title, artist, artwork]);
|
||||
}
|
||||
Reference in New Issue
Block a user