fix(downloads): media-library UI review fixes (E7)

Deep-review remediation on the frontend, all findings adversarially verified:

- LibraryPlayer picks audio vs video from the canonical media_kind (single source), not a
  hardcoded container whitelist, so an uploaded audio file with an uncommon/absent container
  renders in <audio> instead of a broken video box (legacy null media_kind keeps the heuristic).
- UploadDialog aborts the in-flight upload when the dialog is closed by X/ESC/backdrop/Back
  (not only the footer Cancel); drag highlight uses an enter/leave depth counter to stop the
  flicker; the progress bar gets a stable role=progressbar aria-label with the value on aria-valuenow.
- DownloadCenter's Shared tab renders honest loading/error states instead of a blank panel, and
  the library/shared row-action gating moves to a single tested helper (lib/downloadActions).

Tests: downloadActions gating matrix, the uploadReq XHR path (abort/401/422-localize/onerror),
and the media_kind-driven audio/video branch.

i18n: downloads.upload.uploadProgress (HU + EN).
This commit is contained in:
2026-08-29 21:52:13 +02:00
parent 614c5528ad
commit f41c81eb56
9 changed files with 338 additions and 40 deletions
+13 -8
View File
@@ -8,19 +8,24 @@ import { jobTitle } from "./downloadRow";
// 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.
// <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: 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.
// 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?.width && asset?.height) &&
!!asset?.container &&
["m4a", "mp3", "opus", "ogg", "wav", "flac", "aac"].includes(asset.container.toLowerCase());
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);