Files
siftlode/frontend/src/components/LibraryPlayer.tsx
T
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00

74 lines
3.3 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadTitle";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
// 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);
const logMedia = useMediaLogRef("library");
const videoRef = useCallback(
(el: HTMLVideoElement | null) => {
exposeVideoToBackgroundPlay(el);
logMedia(el);
},
[logMedia],
);
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 ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")}
</audio>
</div>
) : (
<video
ref={videoRef}
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>
);
}