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:
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
||||
import { useMediaSession } from "../lib/mediaSession";
|
||||
import { useMediaKeys } from "../lib/mediaKeys";
|
||||
import { linkPoint, saveLinkPoint, useResume } from "../lib/resume";
|
||||
import { LS, readJSON, writeJSON } from "../lib/storage";
|
||||
import { send } from "../lib/http";
|
||||
import { Input } from "./ui/Input";
|
||||
@@ -15,40 +18,6 @@ import { T, lang } from "./watchStrings";
|
||||
// watch.html): no auth, no providers, none of the app's bundle. Uses plain fetch and a tiny
|
||||
// self-contained i18n dict (`watchStrings`: the public viewer has no app language preference).
|
||||
|
||||
// Advertise the playing video to the OS via the Media Session API: lock-screen / control-center
|
||||
// metadata + play/pause handlers. This is also what lets a browser's own "background playback"
|
||||
// feature keep the audio going when the page is backgrounded — it's exactly how youtube.com stays
|
||||
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
|
||||
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
|
||||
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
|
||||
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
|
||||
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
|
||||
// so it drives either the video or the audio-only player.
|
||||
function useMediaSession(
|
||||
videoRef: React.RefObject<HTMLMediaElement | null>,
|
||||
meta: Meta | null,
|
||||
ready: boolean,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!ready || !meta || !("mediaSession" in navigator)) return;
|
||||
const video = videoRef.current;
|
||||
const init: MediaMetadataInit = {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url
|
||||
? [{ src: meta.poster_url, sizes: "512x512", type: "image/jpeg" }]
|
||||
: [],
|
||||
};
|
||||
try {
|
||||
navigator.mediaSession.metadata = new MediaMetadata(init);
|
||||
navigator.mediaSession.setActionHandler("play", () => void video?.play());
|
||||
navigator.mediaSession.setActionHandler("pause", () => video?.pause());
|
||||
} catch {
|
||||
/* MediaMetadata / handlers unsupported — ignore */
|
||||
}
|
||||
}, [ready, meta, videoRef]);
|
||||
}
|
||||
|
||||
type Meta = {
|
||||
needs_password: boolean;
|
||||
title?: string | null;
|
||||
@@ -95,13 +64,24 @@ export default function WatchPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorId = useId();
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
|
||||
// both without a per-element typed ref.
|
||||
const mediaRef = useRef<HTMLMediaElement | null>(null);
|
||||
const setMedia = (el: HTMLMediaElement | null) => {
|
||||
mediaRef.current = el;
|
||||
};
|
||||
useMediaSession(mediaRef, meta, status === "ready");
|
||||
// Whichever element renders (video or audio-only), as state: the OS's media controls, the keys and
|
||||
// the resume bind to it once it exists.
|
||||
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
||||
useMediaSession(
|
||||
status === "ready" ? media : null,
|
||||
meta && {
|
||||
title: meta.title || "Video",
|
||||
artist: meta.uploader || "Siftlode",
|
||||
artwork: meta.poster_url,
|
||||
},
|
||||
);
|
||||
useMediaKeys(media);
|
||||
// It opens where this browser stopped in this link (the viewer has no account to keep it in).
|
||||
// Read once per link, at render: the point a later checkpoint writes must not move the start.
|
||||
const [start] = useState(() => (token ? linkPoint(token) : 0));
|
||||
useResume(media, token || null, start, (position, duration) =>
|
||||
saveLinkPoint(token, position, duration),
|
||||
);
|
||||
|
||||
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
|
||||
// (this page has no account); an unreadable storage just means the default.
|
||||
|
||||
Reference in New Issue
Block a user