* 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>
204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { AudioLines } from "lucide-react";
|
|
import type { PlayerAudioTrack } from "../types";
|
|
import { formatChannels, mapAudioLabel } from "@/lib/mediaFormat";
|
|
import {
|
|
audioTitle,
|
|
compactAudioMeta,
|
|
formatLanguageName,
|
|
} from "@/pages/ItemDetail/components/versionFormatUtils";
|
|
|
|
interface AudioTrackMenuProps {
|
|
tracks: PlayerAudioTrack[];
|
|
activeIndex: number;
|
|
onSelect: (index: number, currentPosition: number) => void;
|
|
currentPosition: number;
|
|
}
|
|
|
|
/**
|
|
* Rich per-track descriptor used for the menu rows.
|
|
* - `title` — human-readable primary label (falls back gracefully).
|
|
* - `meta` — "Language · layout · bitrate · sample-rate · bit-depth".
|
|
* - `badges` — codec / channel / default pills.
|
|
*
|
|
* This mirrors what the item detail page surfaces so the audio track names
|
|
* stay consistent across the app instead of collapsing to an opaque embedded
|
|
* label like `SyncUP` or `????`.
|
|
*/
|
|
interface TrackDescriptor {
|
|
title: string;
|
|
meta: string;
|
|
codecLabel: string;
|
|
channelsLabel: string;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
function describeTrack(track: PlayerAudioTrack, index: number): TrackDescriptor {
|
|
const title = audioTitle(track) || `Track ${index + 1}`;
|
|
const language = formatLanguageName(track.language ?? "");
|
|
const metaParts = [
|
|
language && language.toLowerCase() !== title.toLowerCase() ? language : "",
|
|
compactAudioMeta(track),
|
|
].filter(Boolean);
|
|
return {
|
|
title,
|
|
meta: metaParts.join(" \u00B7 "),
|
|
codecLabel: track.codec ? mapAudioLabel(track.codec) : "",
|
|
channelsLabel: formatChannels(track.channels),
|
|
isDefault: Boolean(track.default),
|
|
};
|
|
}
|
|
|
|
export function AudioTrackMenu({
|
|
tracks,
|
|
activeIndex,
|
|
onSelect,
|
|
currentPosition,
|
|
}: AudioTrackMenuProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleSelect = useCallback(
|
|
(index: number) => {
|
|
onSelect(index, currentPosition);
|
|
setOpen(false);
|
|
},
|
|
[currentPosition, onSelect],
|
|
);
|
|
|
|
const handleBlur = useCallback((e: React.FocusEvent) => {
|
|
if (!menuRef.current?.contains(e.relatedTarget as Node)) {
|
|
setOpen(false);
|
|
}
|
|
}, []);
|
|
|
|
// Close on Escape.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [open]);
|
|
|
|
const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
|
|
|
const handleMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
const items = menuItemsRef.current.filter(Boolean) as HTMLButtonElement[];
|
|
if (items.length === 0) return;
|
|
const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
let nextIndex: number | null = null;
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
|
break;
|
|
case "ArrowUp":
|
|
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
|
break;
|
|
case "Home":
|
|
nextIndex = 0;
|
|
break;
|
|
case "End":
|
|
nextIndex = items.length - 1;
|
|
break;
|
|
case "Escape":
|
|
setOpen(false);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
items[nextIndex]?.focus();
|
|
}, []);
|
|
|
|
if (tracks.length === 0) return null;
|
|
|
|
const disabled = tracks.length <= 1;
|
|
|
|
return (
|
|
<div ref={menuRef} className="relative" onBlur={handleBlur}>
|
|
<button
|
|
type="button"
|
|
className={`player-utility-btn ${disabled ? "cursor-default opacity-40" : ""}`}
|
|
onClick={disabled ? undefined : () => setOpen((v) => !v)}
|
|
aria-label="Audio tracks"
|
|
aria-expanded={open}
|
|
aria-disabled={disabled}
|
|
aria-haspopup="menu"
|
|
>
|
|
<AudioLines className="h-[18px] w-[18px]" />
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
role="menu"
|
|
className="absolute right-0 bottom-full mb-2 max-w-[min(360px,calc(100vw-1rem))] min-w-[280px] rounded-lg bg-black/90 py-1.5 shadow-xl backdrop-blur-sm"
|
|
onKeyDown={handleMenuKeyDown}
|
|
>
|
|
<div className="px-3 py-1.5 text-xs font-medium tracking-wide text-white/50 uppercase">
|
|
Audio
|
|
</div>
|
|
{tracks.map((track, index) => {
|
|
const descriptor = describeTrack(track, index);
|
|
const isActive = index === activeIndex;
|
|
return (
|
|
<button
|
|
key={index}
|
|
ref={(el) => {
|
|
menuItemsRef.current[index] = el;
|
|
}}
|
|
role="menuitem"
|
|
type="button"
|
|
className={`flex w-full items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
|
|
isActive ? "text-blue-400" : "text-white/85"
|
|
}`}
|
|
onClick={() => handleSelect(index)}
|
|
>
|
|
<span className="mt-[3px] w-4 shrink-0 text-center text-sm leading-none">
|
|
{isActive ? "\u2713" : ""}
|
|
</span>
|
|
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<span className="flex flex-wrap items-center gap-1.5">
|
|
<span className="truncate text-sm font-medium">{descriptor.title}</span>
|
|
{descriptor.codecLabel && <TrackBadge>{descriptor.codecLabel}</TrackBadge>}
|
|
{descriptor.channelsLabel && (
|
|
<TrackBadge>{descriptor.channelsLabel}</TrackBadge>
|
|
)}
|
|
{descriptor.isDefault && <TrackBadge variant="outline">Default</TrackBadge>}
|
|
</span>
|
|
{descriptor.meta && (
|
|
<span className="truncate text-[11px] leading-snug text-white/55">
|
|
{descriptor.meta}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Compact pill used to tag tracks with codec/channel/default metadata. The
|
|
* `outline` variant is used for the DEFAULT marker so it reads as a
|
|
* qualifier rather than another content facet. */
|
|
function TrackBadge({
|
|
children,
|
|
variant = "solid",
|
|
}: {
|
|
children: React.ReactNode;
|
|
variant?: "solid" | "outline";
|
|
}) {
|
|
const base =
|
|
"inline-flex items-center rounded px-1.5 py-[1px] text-[9.5px] font-semibold tracking-wide whitespace-nowrap uppercase leading-4";
|
|
const skin =
|
|
variant === "outline" ? "border border-white/25 text-white/70" : "bg-white/10 text-white/75";
|
|
return <span className={`${base} ${skin}`}>{children}</span>;
|
|
}
|