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.
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
// 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]);
|
|
}
|