Files
silo-server/web/src/components/DownloadVersionPicker.tsx
T
af0e3e75a9 feat(web): Media Info dialog + consolidated media-spec formatters (#323)
* refactor(web): consolidate media-spec formatters into lib/mediaFormat

Media-spec formatting was duplicated with divergent behavior across six
modules: versionFormatUtils (GB/"" fallback), playback-info (GiB/"—"
fallback, CODEC_LABELS), overlay tech.ts (prettyResolution,
compactHdrSuffix), versionRankingUtils (mapAudioLabel), plus private
formatBitrate copies in ItemCard and useTranscodeQuality.

One canonical module now owns these helpers; the deliberate per-surface
display differences (fallback text, GB vs GiB labels) are parameters
instead of copies. All call sites migrated, including two cross-boundary
imports (adminActivityPresentation reaching into @/player/playback-info,
player components reaching into page-level utils). The quality-menu Mbps
label in useTranscodeQuality stays local as a distinct display policy,
renamed formatQualityBitrate. Also removes the unused TrackSection/
TrackRow components and renames versionFormatUtils.tsx to .ts.

Behavior-preserving: playback-info.test.ts and VersionFlyout.test.ts are
unmodified and assert exact rendered strings ("7.1 GiB", "640 kbps",
"Dolby Vision Profile 8.1 (HDR10)").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): add Media Info dialog with detailed per-file technical specs

Adds a curator/admin-gated "Media Info" dialog to movie and episode
detail pages showing full technical specs per file: per-stream bitrates,
Dolby Vision profile with base-layer compatibility and EL flags, HDR10+,
chroma subsampling derived from pixel format, color primaries/transfer/
space, bit depth, codec profile/level (H.264, HEVC, and AV1 level
decoding), audio layouts/sample rates, and subtitle stream details.

Frontend-only: the item-detail response already serialized all of this;
VersionVideoTrack in api/types.ts was just missing the DV/HDR10+/
video_range_type fields the backend sends. Entry points: a "Media Info"
item in the detail-page overflow menu (opens on the selected version)
and a per-row info button in Media locations (opens on that file). All
versions render as accordion sections with the target expanded.

Spec rows are built by pure section builders in mediaSpecSections.ts
with fixture tests covering SDR/HDR10/DV 7/DV 8.1/HDR10+/multi-track/
external-subtitle cases; the dialog is a thin renderer over them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): recover DV compatibility detail from video_range_type

Review follow-ups on the Media Info dialog's dynamic-range labels:

- When ffprobe omits dv_bl_signal_compatibility_id, the scanner still
  derives video_range_type (DOVIWithHDR10/HLG/SDR) from color_transfer.
  formatDolbyVisionLabel now falls back to that enum for the
  compatibility detail instead of showing a bare "Dolby Vision
  Profile 8"; explicit DV side-data fields still win when present.
- formatVideoRangeLabel checks the video_range_type mapping before the
  standalone HDR10+ shortcut, so DOVIWithHDR10Plus tracks without a raw
  dolby_vision string keep their Dolby Vision hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:50:11 -04:00

105 lines
3.6 KiB
TypeScript

import { useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { toast } from "sonner";
import type { FileVersion } from "@/api/types";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { formatFileSize } from "@/lib/mediaFormat";
import { buildDirectDownloadUrl } from "@/hooks/queries/downloads";
import { buildQualitySummary, sortByResolution } from "@/pages/ItemDetail/components/VersionFlyout";
interface DownloadVersionPickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
versions: FileVersion[];
title?: string;
summaryBuilder?: (version: FileVersion) => string;
}
export default function DownloadVersionPicker({
open,
onOpenChange,
versions,
title,
summaryBuilder,
}: DownloadVersionPickerProps) {
const sorted = sortByResolution(versions);
const [downloading, setDownloading] = useState<number | null>(null);
const handleDownload = async (version: FileVersion) => {
const url = buildDirectDownloadUrl(version.file_id);
setDownloading(version.file_id);
try {
const res = await fetch(url, { method: "HEAD" });
if (!res.ok) {
if (res.status === 403) toast.error("You are not allowed to download this file");
else if (res.status === 429) toast.error("Download limit reached. Try again later");
else toast.error("Download failed. Try again later");
return;
}
const a = document.createElement("a");
a.href = url;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
onOpenChange(false);
} catch {
toast.error("Network error. Check your connection and try again");
} finally {
setDownloading(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Download{title ? `: ${title}` : ""}</DialogTitle>
<DialogDescription>
Choose a file to download. Make sure you have enough disk space.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{sorted.map((version) => {
const quality = summaryBuilder?.(version) || buildQualitySummary(version);
const size = summaryBuilder ? "" : formatFileSize(version.file_size);
return (
<button
key={version.file_id}
type="button"
onClick={() => handleDownload(version)}
disabled={downloading !== null}
className="border-border/50 bg-accent/30 hover:bg-accent/60 flex w-full items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors disabled:opacity-50"
>
<span className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-full">
{downloading === version.file_id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Download className="size-4" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="text-foreground block text-sm font-medium">{quality}</span>
{size && <span className="text-muted-foreground block text-xs">{size}</span>}
</span>
</button>
);
})}
</div>
{sorted.length > 1 && (
<p className="text-muted-foreground text-xs">Larger files require more storage space.</p>
)}
</DialogContent>
</Dialog>
);
}