feat(downloads): in-app player for library items (E7 S4b)

Play a completed library item in-app: a Play button on Library rows (and
Shared rows with download access) opens a modal that streams the local file
into a native <video controls> (or <audio> for audio-only assets).

Rather than the YouTube IFrame PlayerModal or the Plex HLS PlexPlayer — both
bound to their own domain APIs and unable to point at a local file — this
reuses the shared Modal a11y shell (focus-trap, ESC, layer-stack, portal) and
the range-aware /api/downloads/{id}/file endpoint. S4a's universal-codec
guarantee (mp4/H.264/AAC + m4a/mp3) makes native playback always work, so seek,
volume, fullscreen and speed come for free with zero transcode.
This commit is contained in:
2026-08-29 07:18:58 +02:00
parent fdca12968b
commit 9eaa01617c
5 changed files with 175 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
// 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. S4a guarantees
// every download is a browser-native container (mp4/H.264/AAC + m4a/mp3), so native playback (seek via
// the range-aware /file endpoint, volume, fullscreen, speed) always works with zero transcode.
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: a video asset carries real pixel dimensions; audio (m4a/mp3) has none. Fall
// back to the container kind so a dimension-less-but-video asset still gets the <video> element.
const asset = job.asset;
const isAudio =
!(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
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>
);
}