Files
UHD_Media_Tool/apps/web/app/components/subtitles/subtitle-dialog.tsx
T

515 lines
20 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 {
SubtitleLanguage,
SubtitleParsedItem,
SubtitleSearchResult,
SubtitleTarget,
SubtitleWriteView,
} from "@umt/contracts";
import { Download, ExternalLink, FileUp, LoaderCircle, Search, Undo2 } from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
useApplySubtitles,
useFetchSubtitle,
useParseSubtitles,
useSearchSubtitles,
useSubtitleTargets,
useSubtitleWrites,
useUndoSubtitle,
} from "~/api/subtitles.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
// 补字幕(docs/plan/subtitles.md):上传字幕 / zip → 预览(语言、写到哪一集,可改)→ 写到视频同目录;下面是历史,可撤销。
export const SUBTITLE_LANGUAGE_LABEL: Record<SubtitleLanguage, string> = {
"zh-Hans": "简中",
"zh-Hant": "繁中",
"zh-Hans&en": "简英双语",
"zh-Hant&en": "繁英双语",
"zh&ja": "中日双语",
"zh&ko": "中韩双语",
en: "英文",
ja: "日文",
ko: "韩文",
unknown: "未知",
};
const SOURCE_LABEL = { upload: "上传", subhd: "SubHD", zimuku: "字幕库" } as const;
const SKIP = "__skip__";
const ACCEPT = ".srt,.ass,.ssa,.vtt,.sub,.sbv,.zip";
const targetLabel = (t: SubtitleTarget) =>
t.episode !== null
? `S${String(t.season ?? 1).padStart(2, "0")}E${String(t.episode).padStart(2, "0")} · ${t.fileName}`
: t.fileName;
type Row = SubtitleParsedItem & { target: string };
/** 同一后缀的语言(写出来是同一个文件名):双语按中文,与整理规则一致 */
const suffixGroup = (l: SubtitleLanguage) => (l.startsWith("zh-Hant") ? "zh-tw" : l.startsWith("zh") ? "zh-cn" : l);
export function SubtitleDialog({
entityId,
title,
open,
onOpenChange,
fromSession,
}: {
entityId: string;
title: string;
open: boolean;
onOpenChange: (o: boolean) => void;
/** 原站视图会话:打开时自动下载它当前页的字幕并配对 */
fromSession?: string;
}) {
const inputId = useId();
const input = useRef<HTMLInputElement>(null);
const targets = useSubtitleTargets(entityId, open);
const writes = useSubtitleWrites(entityId, open);
const parse = useParseSubtitles();
const apply = useApplySubtitles();
const undo = useUndoSubtitle(entityId);
const [rows, setRows] = useState<Row[]>([]);
const [skipped, setSkipped] = useState<{ name: string; reason: string }[]>([]);
const [dragging, setDragging] = useState(false);
const [tab, setTab] = useState<"upload" | "online">("upload");
const list = targets.data?.targets ?? [];
const fetchPage = useFetchSubtitle();
const busy = parse.isPending || apply.isPending || fetchPage.isPending;
const fetchedFrom = useRef<string | null>(null);
useEffect(() => {
if (!open || !fromSession || fetchedFrom.current === fromSession) return;
fetchedFrom.current = fromSession;
fetchPage
.mutateAsync({ entityId, sessionId: fromSession })
.then((r) => {
setRows((prev) => [...prev, ...r.items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
setSkipped((prev) => [...prev, ...r.skipped]);
})
.catch((err) => toast.error(errorMessage(err, "下载当前字幕失败")));
}, [open, fromSession, entityId, fetchPage]);
const load = async (files: File[]) => {
if (!files.length) return;
try {
const r = await parse.mutateAsync({ entityId, files });
setRows((prev) => [...prev, ...r.items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
setSkipped((prev) => [...prev, ...r.skipped]);
} catch (err) {
toast.error(errorMessage(err, "解析字幕失败"));
}
};
const patch = (id: string, p: Partial<Row>) => setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...p } : r)));
const chosen = rows.filter((r) => r.target !== SKIP);
// 同一个视频选了两份同语言字幕:写入时后一份会被拒绝,先提示
const dup = useMemo(() => {
const seen = new Set<string>();
const out = new Set<string>();
for (const r of chosen) {
const k = `${r.target}|${r.language}|${r.ext}`;
if (seen.has(k)) out.add(r.id);
seen.add(k);
}
return out;
}, [chosen]);
const submit = async () => {
try {
const r = await apply.mutateAsync({
entityId,
source: "upload",
items: chosen.map((x) => ({
name: x.name,
ext: x.ext,
language: x.language,
content: x.content,
targetLocationId: x.target,
...(x.source ? { source: x.source } : {}),
...(x.sourceRef ? { sourceRef: x.sourceRef } : {}),
})),
});
if (r.written.length) toast.success(`已写入 ${r.written.length} 个字幕`);
if (r.failed.length) toast.error(r.failed.map((f) => `${f.name}:${f.reason}`).join("\n"));
setRows((rs) => rs.filter((x) => r.failed.some((f) => f.name === x.name)));
setSkipped([]);
} catch (err) {
toast.error(errorMessage(err, "写入失败"));
}
};
return (
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>补字幕</DialogTitle>
<DialogDescription>
{title}:字幕写到视频同目录,文件名是视频名加语言后缀;同名字幕会先备份,可以撤销。
</DialogDescription>
</DialogHeader>
{targets.isPending ? (
<LoadingState rows={3} />
) : targets.error ? (
<ErrorState error={targets.error} onRetry={() => void targets.refetch()} />
) : !list.length ? (
<EmptyState title="媒体库里还没有这部作品的视频" description="整理后才能补字幕。" />
) : (
<div className="min-w-0 space-y-4">
<Tabs value={tab} onValueChange={(v) => setTab(v as "upload" | "online")}>
<TabsList>
<TabsTrigger value="upload">上传</TabsTrigger>
<TabsTrigger value="online">在线搜索</TabsTrigger>
</TabsList>
</Tabs>
{tab === "upload" ? (
<label
htmlFor={inputId}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
void load([...e.dataTransfer.files]);
}}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-lg border border-dashed border-border px-4 py-6 text-center transition-colors hover:border-foreground/40",
dragging && "border-foreground/60 bg-accent",
)}
>
{parse.isPending ? <LoaderCircle className="size-5 animate-spin" /> : <FileUp className="size-5" />}
<span className="text-caption text-foreground">选择或拖入字幕文件</span>
<span className="text-meta text-muted-foreground">
支持 srt / ass / ssa / vtt / sub / sbv 和 zip,可一次选多个
</span>
<input
ref={input}
id={inputId}
type="file"
multiple
accept={ACCEPT}
className="sr-only"
onChange={(e) => {
void load([...(e.target.files ?? [])]);
if (input.current) input.current.value = "";
}}
/>
</label>
) : (
<OnlinePanel
entityId={entityId}
seasons={[...new Set(list.map((t) => t.season).filter((x): x is number => x !== null))]}
onFetched={(items, skip) => {
setRows((prev) => [...prev, ...items.map((it) => ({ ...it, target: it.targetLocationId ?? SKIP }))]);
setSkipped((prev) => [...prev, ...skip]);
}}
/>
)}
{fetchPage.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
正在下载原站视图当前页的字幕…
</p>
) : null}
{rows.length ? (
<ul className="space-y-2">
{rows.map((r) => (
<li
key={r.id}
className="grid gap-2 rounded-lg border border-border/70 px-3 py-2 sm:grid-cols-[minmax(0,1fr)_8rem_minmax(0,1.2fr)]"
>
<div className="min-w-0">
<p className="truncate text-caption text-foreground" title={r.name}>
{r.name.split("/").pop()}
</p>
<p className="truncate text-meta text-muted-foreground">
{[
r.archive ? `来自 ${r.archive}` : null,
r.episode !== null ? `识别为第 ${r.episode} 集` : null,
r.encoding !== "utf-8" ? `${r.encoding} 已转 UTF-8` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
{dup.has(r.id) ? <p className="text-meta text-warning">同一集已选了同语言的字幕</p> : null}
{(() => {
const old = list
.find((t) => t.locationId === r.target)
?.existing.find(
(x) =>
suffixGroup(x.language) === suffixGroup(r.language) && x.fileName.endsWith(`.${r.ext}`),
);
return old ? (
<p className="truncate text-meta text-warning">会覆盖已有的 {old.fileName}(先备份)</p>
) : null;
})()}
</div>
<Select value={r.language} onValueChange={(v) => patch(r.id, { language: v as SubtitleLanguage })}>
<SelectTrigger aria-label="语言" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{(Object.keys(SUBTITLE_LANGUAGE_LABEL) as SubtitleLanguage[]).map((k) => (
<SelectItem key={k} value={k}>
{SUBTITLE_LANGUAGE_LABEL[k]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={r.target} onValueChange={(v) => patch(r.id, { target: v })}>
<SelectTrigger aria-label="写到" className="w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={SKIP}>不写</SelectItem>
{list.map((t) => (
<SelectItem key={t.locationId} value={t.locationId}>
{targetLabel(t)}
</SelectItem>
))}
</SelectContent>
</Select>
</li>
))}
</ul>
) : null}
{skipped.length ? (
<p className="text-meta text-muted-foreground">
跳过:{skipped.map((s) => `${s.name}(${s.reason})`).join("、")}
</p>
) : null}
<History items={writes.data?.items ?? []} onUndo={(w) => undo.mutate(w.id)} undoing={undo.isPending} />
</div>
)}
<DialogFooter>
{rows.length ? (
<Button
variant="ghost"
onClick={() => {
setRows([]);
setSkipped([]);
}}
disabled={busy}
>
清空
</Button>
) : null}
<Button onClick={() => void submit()} disabled={busy || !chosen.length}>
{apply.isPending ? <LoaderCircle className="animate-spin" /> : null}
{chosen.length ? `写入 ${chosen.length} 个字幕` : "写入"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function History({
items,
onUndo,
undoing,
}: {
items: SubtitleWriteView[];
onUndo: (w: SubtitleWriteView) => void;
undoing: boolean;
}) {
if (!items.length) return null;
return (
<section aria-label="补字幕记录" className="border-t border-border/60 pt-3">
<h3 className="mb-2 text-meta font-semibold text-muted-foreground">补字幕记录</h3>
<ul className="space-y-1">
{items.slice(0, 30).map((w) => (
<li key={w.id} className="flex min-w-0 items-center gap-2 text-caption">
<span
className={cn("min-w-0 flex-1 truncate", w.undoneAt && "text-muted-foreground line-through")}
title={w.relativePath}
>
{w.fileName}
</span>
<span className="shrink-0 text-meta text-muted-foreground">
{SOURCE_LABEL[w.source]} · {relTime(w.createdAt)}
</span>
{w.replaced && !w.undoneAt ? <StatusBadge className="shrink-0">覆盖了旧字幕</StatusBadge> : null}
{w.undoneAt ? (
<StatusBadge className="shrink-0">已撤销</StatusBadge>
) : (
<Button
variant="ghost"
size="xs"
disabled={undoing}
onClick={() => onUndo(w)}
title={w.replaced ? "删掉这次写入,还原旧字幕" : "删掉这次写入的字幕"}
>
<Undo2 />
撤销
</Button>
)}
</li>
))}
</ul>
</section>
);
}
/** 在线搜索:已启用的字幕站(SubHD、字幕库);下载的字幕进上面同一个预览 */
function OnlinePanel({
entityId,
seasons,
onFetched,
}: {
entityId: string;
seasons: number[];
onFetched: (items: SubtitleParsedItem[], skipped: { name: string; reason: string }[]) => void;
}) {
const search = useSearchSubtitles();
const fetch = useFetchSubtitle();
const [keyword, setKeyword] = useState("");
const [season, setSeason] = useState<string>(seasons.length === 1 ? String(seasons[0]) : "all");
const [episode, setEpisode] = useState("");
const [fetching, setFetching] = useState<string | null>(null);
const data = search.data;
const run = () =>
search.mutate(
{
entityId,
...(keyword.trim() ? { keyword: keyword.trim() } : {}),
...(season !== "all" ? { season: Number(season) } : {}),
...(episode.trim() && /^\d+$/.test(episode.trim()) ? { episode: Number(episode.trim()) } : {}),
},
{ onError: (err) => toast.error(errorMessage(err, "搜索失败")) },
);
const take = async (r: SubtitleSearchResult) => {
setFetching(`${r.siteId}${r.ref}`);
try {
const p = await fetch.mutateAsync({ entityId, siteId: r.siteId, ref: r.ref, title: r.title });
if (!p.items.length) toast.error("这个字幕包里没有能用的字幕文件");
else toast.success(`已下载 ${p.items.length} 个字幕,检查下面的配对后写入`);
onFetched(p.items, p.skipped);
} catch (err) {
toast.error(errorMessage(err, "下载失败"));
} finally {
setFetching(null);
}
};
return (
<div className="min-w-0 space-y-3">
<form
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
run();
}}
>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="默认按作品名搜,可另填关键词"
aria-label="关键词"
className="min-w-48 flex-1"
/>
{seasons.length ? (
<>
<Select value={season} onValueChange={setSeason}>
<SelectTrigger aria-label="季" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">全部季</SelectItem>
{seasons.map((n) => (
<SelectItem key={n} value={String(n)}>
第 {n} 季
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={episode}
onChange={(e) => setEpisode(e.target.value)}
placeholder="集(可空)"
aria-label="集"
inputMode="numeric"
className="w-24"
/>
</>
) : null}
<Button type="submit" disabled={search.isPending}>
{search.isPending ? <LoaderCircle className="animate-spin" /> : <Search />}
搜索
</Button>
</form>
{data?.noSites ? (
<p className="text-caption text-muted-foreground">
还没有启用的字幕站。到「站点 → 添加站点 → 字幕站」添加 SubHD 或字幕库。
</p>
) : null}
{data?.errors.length ? (
<p className="text-meta text-warning">{data.errors.map((e) => `${e.siteName}:${e.reason}`).join(";")}</p>
) : null}
{data && !data.noSites && !data.results.length ? (
<p className="text-caption text-muted-foreground">
没有找到字幕(搜索词:{data.keywords.join("、") || "无"})。换个关键词,或去掉季集限制再试。
</p>
) : null}
{data?.results.length ? (
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
{data.results.map((r) => {
const key = `${r.siteId}${r.ref}`;
return (
<li key={key} className="flex min-w-0 items-center gap-2 rounded-lg border border-border/70 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-caption text-foreground" title={r.title}>
{r.title}
</p>
<p className="truncate text-meta text-muted-foreground">
{[
r.siteName,
r.languages.join(" / ") || null,
r.format,
r.downloads !== null ? `下载 ${r.downloads}` : null,
r.publishedAt ? (r.publishedAt.includes("T") ? relTime(r.publishedAt) : r.publishedAt) : null,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<Button asChild variant="ghost" size="icon-sm" title="在字幕站打开">
<a href={r.pageUrl} target="_blank" rel="noreferrer" aria-label="在字幕站打开">
<ExternalLink />
</a>
</Button>
<Button size="sm" variant="outline" disabled={fetching !== null} onClick={() => void take(r)}>
{fetching === key ? <LoaderCircle className="animate-spin" /> : <Download />}
下载
</Button>
</li>
);
})}
</ul>
) : null}
</div>
);
}