Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described.
123 lines
5.5 KiB
TypeScript
123 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";
|
|
|
|
// 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/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);
|
|
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 ? (
|
|
<img
|
|
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>
|
|
);
|
|
}
|