Files
siftlode/frontend/src/lib/mediaSession.ts
T
peter 59b27467ab 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.
2026-10-06 23:55:41 +02:00

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]);
}