A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses a bare <img>.
124 lines
5.5 KiB
TypeScript
124 lines
5.5 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import Modal from "./ui/Modal";
|
|
import { api, type DownloadJob } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { jobTitle } from "./downloadTitle";
|
|
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
|
|
import { useMediaLogRef } from "../lib/mediaLog";
|
|
import { useMediaSession } from "../lib/mediaSession";
|
|
import { useMediaKeys } from "../lib/mediaKeys";
|
|
import { keptPosition, useResume } from "../lib/resume";
|
|
import RemoteImage from "./ui/RemoteImage";
|
|
|
|
// 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).
|
|
//
|
|
// It opens where the viewer stopped (`job.position_seconds`, from the library listing) unless
|
|
// `fromStart`, and keeps the point on the server as it plays (`lib/resume.ts`); the OS's media
|
|
// controls and the basic keys drive it like the other players.
|
|
export default function LibraryPlayer({
|
|
job,
|
|
fromStart = false,
|
|
onClose,
|
|
}: {
|
|
job: DownloadJob;
|
|
fromStart?: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]);
|
|
// Video vs audio-only comes from the stored media_kind (the single source every sibling reads —
|
|
// WatchPage/downloadRow/the tray) — 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);
|
|
const logMedia = useMediaLogRef("library");
|
|
// The element as STATE, so the session, the keys and the resume bind to it once it exists.
|
|
const [media, setMedia] = useState<HTMLMediaElement | null>(null);
|
|
const mediaRef = useCallback(
|
|
(el: HTMLMediaElement | null) => {
|
|
if (el instanceof HTMLVideoElement) exposeVideoToBackgroundPlay(el);
|
|
logMedia(el);
|
|
setMedia(el);
|
|
},
|
|
[logMedia],
|
|
);
|
|
|
|
const title = jobTitle(job);
|
|
useMediaSession(media, {
|
|
title,
|
|
artist: job.display_uploader ?? asset?.uploader ?? null,
|
|
artwork: poster ?? null,
|
|
});
|
|
useMediaKeys(media);
|
|
// The library row's bar follows the point at once — every library listing this file may sit in
|
|
// (each folder, search and the shared view is its own key under `files`) — rather than after the
|
|
// next refetch. On the way out a listing read may already be out, having read the server before
|
|
// the closing point lands: it is cancelled, or its answer replaced the row's point with the older
|
|
// one, Continue opened there, and its first checkpoint wrote it back over the right one. Once the
|
|
// point is on the server, the listings are read again.
|
|
const lists = { queryKey: qk.files() };
|
|
const keepPoint = (position: number, duration: number, leaving: boolean) => {
|
|
const at = keptPosition(position, duration);
|
|
if (leaving) void qc.cancelQueries(lists);
|
|
qc.setQueriesData<DownloadJob[]>(lists, (jobs) =>
|
|
jobs?.map((j) => (j.id === job.id ? { ...j, position_seconds: at } : j)),
|
|
);
|
|
if (leaving) {
|
|
api.saveDownloadProgressBeacon(job.id, position, duration, () => {
|
|
void qc.invalidateQueries(lists);
|
|
});
|
|
} else void api.saveDownloadProgress(job.id, position, duration).catch(() => {});
|
|
};
|
|
useResume(media, String(job.id), fromStart ? 0 : (job.position_seconds ?? 0), keepPoint);
|
|
|
|
return (
|
|
<Modal initialFocus="player" title={title} onClose={onClose} maxWidth="max-w-4xl">
|
|
{isAudio ? (
|
|
<div className="flex flex-col items-center gap-4 py-2">
|
|
{poster ? (
|
|
<RemoteImage
|
|
src={poster}
|
|
alt=""
|
|
className="w-40 h-40 rounded-xl object-cover border border-border"
|
|
/>
|
|
) : null}
|
|
<audio ref={mediaRef} src={fileUrl} controls autoPlay className="w-full">
|
|
{t("downloads.player.unsupported")}
|
|
</audio>
|
|
</div>
|
|
) : (
|
|
<video
|
|
ref={mediaRef}
|
|
src={fileUrl}
|
|
poster={poster}
|
|
controls
|
|
autoPlay
|
|
playsInline
|
|
className="w-full max-h-[70dvh] rounded-lg bg-black object-contain"
|
|
>
|
|
{t("downloads.player.unsupported")}
|
|
</video>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|