import { type MediaCredit, tmdbImageUrl } from "@umt/contracts"; import { ChevronDown, ExternalLink, ImageOff, Lock, PencilLine, Star, UserRound } from "lucide-react"; import { type CSSProperties, type ReactNode, useLayoutEffect, useRef, 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);背景图渐隐到页面底色(--mat-hero-*),文字用常规前景色,随日夜翻转。 /** TMDB status 原文 → 界面用词 */ export const AIR_STATUS: Record = { "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 }: { text: string; lines?: 2 | 3 | 4; className?: string }) { const [open, setOpen] = useState(false); const ref = useRef(null); // 首屏(含服务端渲染)按字数估计,挂载后按实际排版量:内容高度超过 lines 行才折叠 const [long, setLong] = useState(() => text.length > lines * 38); useLayoutEffect(() => { const el = ref.current; if (!el || typeof ResizeObserver === "undefined") return; const measure = () => { const cs = getComputedStyle(el); const lh = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.5; setLong(el.scrollHeight > lines * lh + 1); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [lines]); const clamped = long && !open; return (

{text}

{long ? ( ) : null}
); } /** * 头部(方案 A,2026-10-07):有背景图时剧照铺在内容区顶部,上下和右缘渐隐到页面底色,左侧文字区压近乎不透明的底色; * 没有背景图时是普通头部。海报、标题、事实行(评分 · 播出状态 · 页面给的状态徽标 · 类型年份)、标语、简介、日期与外链、 * 操作按钮都压在背景上;文字用常规前景色 token,日夜同一结构。badges 并进事实行,actions 放在外链行下方。 */ export function MediaHero({ facts, badges, actions }: { facts: MediaFacts; badges?: ReactNode; actions?: 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 (
{bg ? (
setBgFailed(true)} className="absolute inset-0 h-full w-full object-cover object-top" />
) : null}

{facts.title} {facts.year ? ({facts.year}) : null}

{sub.length ?

{sub.join(" · ")}

: null}
{facts.voteAverage ? ( {facts.voteAverage.toFixed(1)} {facts.voteCount ? ( ({facts.voteCount} 人评分) ) : null} ) : null} {status || badges ? ( {status ? {status.label} : null} {badges} ) : null} {line.length ? {line.join(" · ")} : null}
{facts.genres.length ? (
    {facts.genres.map((g) => (
  • {g}
  • ))}
) : null} {facts.tagline ? (

{facts.tagline}

) : null} {facts.overview ? ( ) : (

暂无简介

)} {dates.length || facts.externalLinks.length ? (
{dates.map((d) => ( {d} ))} {facts.externalLinks.map((l) => ( {l.label} ))}
) : null} {actions ? (
{actions}
) : null}
); } function Avatar({ path, name }: { path: string | null; name: string }) { const [failed, setFailed] = useState(false); const src = failed ? null : tmdbImageUrl(path, "w185"); return src ? ( setFailed(true)} className="h-16 w-16 shrink-0 rounded-full border border-border bg-foreground/[0.05] object-cover" /> ) : ( ); } const CREW_ROLE: Record = { 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 (
    {people.map((p) => (
  • {p.id > 0 ? ( {p.name} ) : ( // 元数据补全写的主演没有 TMDB 人物 ID(负数占位),不链接 {p.name} )} {p.role ? {p.role} : null}
  • ))}
); } 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 (
{src ? ( setFailed(true)} className="h-full w-full object-cover" /> ) : (
)}
); } /** 每集一行:剧照 · 集号与标题 · 播出日期 · 时长 · 简介(折叠)· 库内状态 */ 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 (
  • S{pad(e.seasonNumber)}E{pad(e.episodeNumber)} {e.name ?? `第 ${e.episodeNumber} 集`} {e.locked ? : null}

    {status ? {status.label} : null} {e.onEdit ? ( ) : null}

    {meta.join(" · ")}

    {e.overview ? ( ) : (

    暂无简介

    )} {e.extra}
  • ); } 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 (
    {open ? (
    {s.summary ?
    {s.summary}
    : null} {s.overview ? ( ) : null} {children}
    ) : null}
    ); }