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

321 lines
12 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, SubtitleTarget, SubtitleWriteView } from "@umt/contracts";
import { FileUp, LoaderCircle, Undo2 } from "lucide-react";
import { useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
useApplySubtitles,
useParseSubtitles,
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.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,
}: {
entityId: string;
title: string;
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
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 list = targets.data?.targets ?? [];
const busy = parse.isPending || apply.isPending;
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,
})),
});
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">
<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>
{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>
);
}