On iPad Brave, a shared video stopped the moment the browser left the foreground, even with Brave's "Enable Background Audio" on, while an mp3 kept playing. Brave iOS keeps a web <video> going by re-playing one the OS paused (brave-core ios/browser/web/media/resources/media_backgrounding.ts), but it only hooks a <video> present at document start or added as a MutationObserver addedNode that IS a video. React inserts a player's <video> inside its freshly mounted wrapper in one go, so Brave never saw it. lib/browserBackgroundPlay re-inserts a mounted <video> at its own position, once per element: it becomes an added node, the DOM React manages does not change, and playback is not interrupted. The watch page, the downloads library player and the Plex player hand their <video> to it. Measured on the owner's iPad (Brave) with a prototype of the same idea: the video kept playing in another app and with the screen locked. The YouTube modal is not ours to change: its <video> lives in YouTube's cross-origin iframe, and nothing of ours pauses it on backgrounding. The prototype's two audio-element variants (a muted video following an <audio>, and an <audio> taking over on hide) went silent in the background on the iPad and are removed. The watch page gets a Repeat toggle, on by default and remembered per browser (LS.watchRepeat; the page has no account): it sets `loop` on the <video> or the audio-only <audio>.
65 lines
3.0 KiB
TypeScript
65 lines
3.0 KiB
TypeScript
import { useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import Modal from "./ui/Modal";
|
|
import { api, type DownloadJob } from "../lib/api";
|
|
import { jobTitle } from "./downloadRow";
|
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
|
|
|
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
|
|
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
|
|
// APIs and can't point at a local file. Instead we reuse the shared a11y shell (`Modal`: focus-trap,
|
|
// ESC/back-to-close, scroll-lock, layer-stack, portal) and stream the local file into a NATIVE
|
|
// <video>/<audio controls> — the same element VideoEditor already plays this URL in. Downloads are a
|
|
// browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via the range-aware
|
|
// /file endpoint, volume, fullscreen, speed) works with zero transcode; an uploaded media item may be
|
|
// any codec (best-effort playback).
|
|
export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
|
|
const { t } = useTranslation();
|
|
const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
|
|
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
|
// WatchPage/downloadRow/DownloadCenter) — NOT a re-derived container list, which would misclassify
|
|
// an uploaded audio file whose container is outside a hardcoded set (uploads accept any container).
|
|
// Only fall back to the old dimension+container heuristic for a legacy asset with a null media_kind.
|
|
const asset = job.asset;
|
|
const isAudio =
|
|
asset?.media_kind === "audio" ||
|
|
(asset?.media_kind == null &&
|
|
!(asset?.width && asset?.height) &&
|
|
!!asset?.container &&
|
|
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase()));
|
|
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
|
|
// the fallback. Same precedence as the library-row thumbnail.
|
|
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
|
|
|
|
return (
|
|
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
|
{isAudio ? (
|
|
<div className="flex flex-col items-center gap-4 py-2">
|
|
{poster ? (
|
|
<img
|
|
src={poster}
|
|
alt=""
|
|
className="w-40 h-40 rounded-xl object-cover border border-border"
|
|
/>
|
|
) : null}
|
|
<audio src={fileUrl} controls autoPlay className="w-full">
|
|
{t("downloads.player.unsupported")}
|
|
</audio>
|
|
</div>
|
|
) : (
|
|
<video
|
|
ref={exposeVideoToBackgroundPlay}
|
|
src={fileUrl}
|
|
poster={poster}
|
|
controls
|
|
autoPlay
|
|
playsInline
|
|
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
|
|
>
|
|
{t("downloads.player.unsupported")}
|
|
</video>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|