Files
UHD_Media_Tool/apps/web/app/components/media/media-detail.tsx
T
rootandClaude Opus 5.5 bf9e73e86e feat(library): show stored versions per episode and movie (LIBV-1)
- domain versionSpecOf: file name + ffprobe + ingest quality -> version spec;
  summarizeVersions; matchVersions (satisfied / missing / upgradeable, single target)
- GET /library/{id}/versions: per-season/episode versions, summaries, matches[]
- organize writes full spec into media_asset.delivery (specVersion 1)
- scripts/asset-spec-backfill.ts: dry-run by default, --probe local only, --apply
- web: season summary line, one-line spec per episode, N versions expand,
  tech info details (TODO: DEAI-2 TechInfo), movie versions panel

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 21:02:37 +08:00

412 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type MediaCredit, tmdbImageUrl } from "@umt/contracts";
import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react";
import { type ReactNode, useState } from "react";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { CoverPlaceholder } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
// 媒体详情(MD-1,对照 MP 媒体详情页):背景图 + 海报 + 标题 / 原名 / 年份 / 类型 / 评分 / 状态 / 集数 + 简介、
// 主创、季折叠与每集行。媒体库详情(本地作品)与仅 TMDB 的作品详情共用;数据由页面整理成下面的结构传入。
// 图片一律经图片代理(tmdbImageUrl);背景图上压 scrim 遮罩,文字用 on-media 色(不随日夜翻转)。
/** TMDB status 原文 → 界面用词 */
export const AIR_STATUS: Record<string, { label: string; tone: StatusTone }> = {
"Returning Series": { label: "连载中", tone: "success" },
"In Production": { label: "制作中", tone: "neutral" },
Planned: { label: "计划中", tone: "neutral" },
Pilot: { label: "试播", tone: "neutral" },
Ended: { label: "已完结", tone: "neutral" },
Canceled: { label: "已取消", tone: "warning" },
Released: { label: "已上映", tone: "neutral" },
"Post Production": { label: "后期制作", tone: "neutral" },
Rumored: { label: "传闻", tone: "neutral" },
};
export interface MediaFacts {
title: string;
originalTitle: string | null;
/** 中文名与标题不同时显示在原名一行 */
titleZh?: string | null;
year: number | null;
/** movie / series / variety … */
kind: string;
kindLabel: string;
genres: string[];
voteAverage: number | null;
voteCount: number | null;
airStatus: string | null;
runtime: number | null;
seasonCount: number | null;
episodeCount: number | null;
releaseDate: string | null;
lastAirDate: string | null;
platform: string | null;
originCountry: string[];
overview: string | null;
tagline: string | null;
posterPath: string | null;
backdropPath: string | null;
externalLinks: { label: string; href: string }[];
}
const pad = (n: number) => String(n).padStart(2, "0");
export const today = () => new Date().toISOString().slice(0, 10);
function runtimeText(min: number | null, kind: string): string | null {
if (!min) return null;
const s = min >= 60 ? `${Math.floor(min / 60)} 小时${min % 60 ? ` ${min % 60} 分钟` : ""}` : `${min} 分钟`;
return kind === "movie" ? s : `单集 ${s}`;
}
/** 可折叠的长文本:默认最多 lines 行,超出给「展开」 */
export function ClampText({
text,
lines = 3,
className,
onMedia = false,
}: {
text: string;
lines?: 2 | 3 | 4;
className?: string;
onMedia?: boolean;
}) {
const [open, setOpen] = useState(false);
const long = text.length > lines * 38;
const clamp = lines === 2 ? "line-clamp-2" : lines === 3 ? "line-clamp-3" : "line-clamp-4";
return (
<div className={className}>
<p className={cn("break-words whitespace-pre-line", !open && long && clamp)}>{text}</p>
{long ? (
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={cn(
"mt-0.5 cursor-pointer text-meta underline-offset-4 hover:underline",
onMedia
? "text-on-media-tertiary hover:text-on-media-strong"
: "text-muted-foreground hover:text-foreground",
)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
/** 头部:背景图(压暗)+ 海报 + 标题与事实行 + 简介 + 外部链接;badges 放标题下方,操作按钮由页面放在头部之后 */
export function MediaHero({ facts, badges }: { facts: MediaFacts; badges?: ReactNode }) {
const [bgFailed, setBgFailed] = useState(false);
const bg = bgFailed ? null : tmdbImageUrl(facts.backdropPath, "w1280");
const status = facts.airStatus ? (AIR_STATUS[facts.airStatus] ?? { label: facts.airStatus, tone: "neutral" }) : null;
const sub = [
facts.titleZh && facts.titleZh !== facts.title ? facts.titleZh : null,
facts.originalTitle && facts.originalTitle !== facts.title && facts.originalTitle !== facts.titleZh
? facts.originalTitle
: null,
].filter(Boolean);
const line = [
facts.kindLabel,
facts.year ? String(facts.year) : null,
facts.kind !== "movie" && facts.seasonCount ? `${facts.seasonCount} 季` : null,
facts.kind !== "movie" && facts.episodeCount ? `${facts.episodeCount} 集` : null,
runtimeText(facts.runtime, facts.kind),
facts.platform,
facts.originCountry.join(" / ") || null,
].filter(Boolean);
const dates = [
facts.releaseDate ? `${facts.kind === "movie" ? "上映" : "首播"} ${facts.releaseDate}` : null,
facts.kind !== "movie" && facts.lastAirDate ? `最近播出 ${facts.lastAirDate}` : null,
].filter(Boolean);
return (
<section className="relative isolate overflow-hidden rounded-2xl border border-border bg-scrim text-on-media">
{bg ? (
<img
src={bg}
alt=""
aria-hidden
onError={() => setBgFailed(true)}
className="absolute inset-0 -z-10 h-full w-full object-cover object-top opacity-55"
/>
) : null}
<div aria-hidden className="absolute inset-0 -z-10 bg-gradient-to-r from-scrim/90 via-scrim/70 to-scrim/35" />
<div
aria-hidden
className="absolute inset-0 -z-10 bg-gradient-to-t from-scrim/80 via-transparent to-transparent"
/>
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:gap-6 sm:p-6">
<CoverPlaceholder
title={facts.title}
kind={facts.kind}
posterPath={facts.posterPath}
className="h-40 w-28 self-start border-on-media-wash/20 shadow-lg sm:h-60 sm:w-40"
/>
<div className="min-w-0 flex-1">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-on-media-strong sm:text-headline-sm">
{facts.title}
{facts.year ? <span className="font-normal text-on-media-tertiary"> ({facts.year})</span> : null}
</h1>
{sub.length ? <p className="mt-1 text-body break-words text-on-media-secondary">{sub.join(" · ")}</p> : null}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-caption text-on-media-secondary">
{facts.voteAverage ? (
<span className="inline-flex items-center gap-1 font-medium text-on-media-strong">
<Star className="h-3.5 w-3.5 fill-current" aria-hidden />
{facts.voteAverage.toFixed(1)}
{facts.voteCount ? (
<span className="font-normal text-on-media-tertiary">({facts.voteCount} 人评分)</span>
) : null}
</span>
) : null}
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{line.length ? <span>{line.join(" · ")}</span> : null}
</div>
{facts.genres.length ? (
<ul className="mt-2 flex flex-wrap gap-1.5" aria-label="类型">
{facts.genres.map((g) => (
<li
key={g}
className="rounded-full border border-on-media-wash/25 px-2 py-0.5 text-meta text-on-media-secondary"
>
{g}
</li>
))}
</ul>
) : null}
{badges ? <div className="mt-2 flex flex-wrap items-center gap-1.5">{badges}</div> : null}
{facts.tagline ? <p className="mt-3 text-caption text-on-media-tertiary italic">{facts.tagline}</p> : null}
{facts.overview ? (
<ClampText
text={facts.overview}
lines={4}
onMedia
className="mt-2 max-w-3xl text-caption leading-relaxed text-on-media"
/>
) : (
<p className="mt-3 text-caption text-on-media-tertiary">没有简介。</p>
)}
{dates.length || facts.externalLinks.length ? (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-on-media-tertiary">
{dates.map((d) => (
<span key={d}>{d}</span>
))}
{facts.externalLinks.map((l) => (
<a
key={l.href}
href={l.href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 underline-offset-4 hover:text-on-media-strong hover:underline"
>
{l.label}
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
))}
</div>
) : null}
</div>
</div>
</section>
);
}
function Avatar({ path, name }: { path: string | null; name: string }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(path, "w185");
return src ? (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="h-16 w-16 shrink-0 rounded-full border border-border bg-foreground/[0.05] object-cover"
/>
) : (
<span
aria-hidden
title={name}
className="grid h-16 w-16 shrink-0 place-items-center rounded-full border border-border bg-foreground/[0.05] text-muted-foreground"
>
<UserRound className="h-6 w-6" />
</span>
);
}
const CREW_ROLE: Record<string, string> = { Director: "导演", Creator: "主创" };
/** 主创:导演 / 主创在前,演员前 10;头像经图片代理,点姓名去 TMDB 人物页 */
export function CreditsStrip({ cast, crew }: { cast: MediaCredit[]; crew: MediaCredit[] }) {
const people = [
...crew.map((c) => ({ ...c, role: c.role ? (CREW_ROLE[c.role] ?? c.role) : null, key: `c${c.id}` })),
...cast.slice(0, 10).map((c) => ({ ...c, key: `a${c.id}` })),
];
if (!people.length) return null;
return (
<ul className="scroll-bleed -mx-1 flex gap-3 overflow-x-auto px-1 pb-1 sm:flex-wrap sm:overflow-visible">
{people.map((p) => (
<li key={p.key} className="flex w-20 shrink-0 flex-col items-center text-center">
<Avatar path={p.profilePath} name={p.name} />
<a
href={`https://www.themoviedb.org/person/${p.id}`}
target="_blank"
rel="noreferrer noopener"
className="mt-1.5 line-clamp-2 text-meta font-medium break-words text-foreground hover:underline"
>
{p.name}
</a>
{p.role ? <span className="line-clamp-2 text-micro break-words text-muted-foreground">{p.role}</span> : null}
</li>
))}
</ul>
);
}
export interface EpisodeRowData {
key: string;
seasonNumber: number;
episodeNumber: number;
name: string | null;
overview: string | null;
airDate: string | null;
runtime: number | null;
stillPath: string | null;
voteAverage: number | null;
/** 库内状态;null = 不显示(仅 TMDB 作品未收录) */
status?: { label: string; tone: StatusTone } | null;
/** 有锁定的字段 */
locked?: boolean;
onEdit?: () => void;
/** 简介下方的附加内容(入库版本) */
extra?: ReactNode;
}
function Still({ path }: { path: string | null }) {
const [failed, setFailed] = useState(false);
const src = failed ? null : tmdbImageUrl(path, "w300");
return (
<div className="aspect-video w-28 shrink-0 overflow-hidden rounded-lg border border-border bg-foreground/[0.05] sm:w-40">
{src ? (
<img src={src} alt="" loading="lazy" onError={() => setFailed(true)} className="h-full w-full object-cover" />
) : (
<div aria-hidden className="grid h-full w-full place-items-center text-muted-foreground">
<ImageOff className="h-4 w-4 opacity-60" />
</div>
)}
</div>
);
}
/** 每集一行:剧照 · 集号与标题 · 播出日期 · 时长 · 简介(折叠)· 库内状态 */
export function EpisodeRow({ e }: { e: EpisodeRowData }) {
const unaired = !!e.airDate && e.airDate > today();
const meta = [
e.airDate ?? "播出日期未知",
e.runtime ? `${e.runtime} 分钟` : null,
e.voteAverage ? `★ ${e.voteAverage.toFixed(1)}` : null,
].filter(Boolean);
const status = e.status === undefined ? (unaired ? { label: "未播出", tone: "neutral" as const } : null) : e.status;
return (
<li className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">
<Still path={e.stillPath} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
<p className="min-w-0 text-sm font-medium break-words text-foreground">
<span className="mr-1.5 font-mono text-caption text-muted-foreground">
S{pad(e.seasonNumber)}E{pad(e.episodeNumber)}
</span>
{e.name ?? `第 ${e.episodeNumber} 集`}
{e.locked ? <Lock className="ml-1 inline h-3 w-3 text-muted-foreground" aria-label="有锁定字段" /> : null}
</p>
<div className="flex shrink-0 items-center gap-1">
{status ? <StatusBadge tone={status.tone}>{status.label}</StatusBadge> : null}
{e.onEdit ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={`修改 S${pad(e.seasonNumber)}E${pad(e.episodeNumber)}`}
onClick={e.onEdit}
>
<PencilLine />
</Button>
) : null}
</div>
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">{meta.join(" · ")}</p>
{e.overview ? <ClampText text={e.overview} lines={2} className="mt-1 text-caption text-foreground/85" /> : null}
{e.extra}
</div>
</li>
);
}
export interface SeasonHeaderData {
seasonNumber: number;
name: string | null;
airDate: string | null;
episodeCount: number | null;
posterPath: string | null;
overview: string | null;
/** 季头右侧的摘要,如「已入库 5/9」 */
summary?: ReactNode;
/** 季头第二行下方的一行说明(入库版本汇总) */
detail?: ReactNode;
}
/** 季折叠:季头(海报缩略、季名、首播年、集数、摘要)+ 展开后的内容(集列表由页面传入) */
export function SeasonSection({
s,
open,
onToggle,
children,
}: {
s: SeasonHeaderData;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
const title = s.name ?? (s.seasonNumber === 0 ? "特别篇" : `第 ${s.seasonNumber} 季`);
return (
<section className="rounded-xl border border-border/70">
<button
type="button"
aria-expanded={open}
onClick={onToggle}
className="flex w-full cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-base hover:bg-wash/[0.04]"
>
<CoverPlaceholder title={title} kind="series" small posterPath={s.posterPath} className="h-14 w-10" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium break-words text-foreground">
{title}
<span className="ml-2 font-mono text-meta font-normal text-muted-foreground">S{pad(s.seasonNumber)}</span>
</p>
<p className="text-meta text-muted-foreground">
{[s.airDate ? s.airDate.slice(0, 4) : null, s.episodeCount !== null ? `${s.episodeCount} 集` : null]
.filter(Boolean)
.join(" · ") || "—"}
</p>
{s.detail ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">{s.detail}</p>
) : null}
</div>
{s.summary ? <div className="hidden shrink-0 sm:block">{s.summary}</div> : null}
<ChevronDown
aria-hidden
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-base",
open && "rotate-180",
)}
/>
</button>
{open ? (
<div className="border-t border-border/60 px-3 py-3">
{s.summary ? <div className="mb-2 sm:hidden">{s.summary}</div> : null}
{s.overview ? (
<ClampText text={s.overview} lines={2} className="mb-3 text-caption text-muted-foreground" />
) : null}
{children}
</div>
) : null}
</section>
);
}