feat(web): watch rematch candidates, import persist, danmaku filters

This commit is contained in:
noelorin 2026-09-26 21:03:01 +08:00
parent 714f51a1a6
commit c236f50ec6
6 changed files with 366 additions and 49 deletions

View File

@ -12,6 +12,7 @@
"lint": "biome check ." "lint": "biome check ."
}, },
"dependencies": { "dependencies": {
"@app/danmaku": "workspace:*",
"@app/design-tokens": "workspace:*", "@app/design-tokens": "workspace:*",
"@app/i18n": "workspace:*", "@app/i18n": "workspace:*",
"@app/seo-geo": "workspace:*", "@app/seo-geo": "workspace:*",

View File

@ -1,11 +1,17 @@
import type { Locale } from "@app/i18n"; import type { Locale } from "@app/i18n";
import { t } from "@app/i18n"; import { t } from "@app/i18n";
import { Button, toast } from "@app/ui"; import { Button, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Artplayer from "artplayer"; import Artplayer from "artplayer";
import artplayerPluginDanmuku from "artplayer-plugin-danmuku"; import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { parseDanmakuXml, thinDanmaku } from "~/lib/danmaku-render"; import {
filterComments,
parseDanmakuXml,
thinDanmaku,
toDanmukuItems,
type FilterOptions,
} from "~/lib/danmaku-render";
import { go, queryParam } from "~/lib/nav"; import { go, queryParam } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
@ -115,14 +121,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const progressStartRef = useRef(0); const progressStartRef = useRef(0);
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs()); const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
const [localXml, setLocalXml] = useState(""); const [localXml, setLocalXml] = useState("");
/** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */
const [matchXml, setMatchXml] = useState("");
const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled); const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled);
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density); const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
const [ended, setEnded] = useState(false); const [ended, setEnded] = useState(false);
const [playError, setPlayError] = useState(false); const [playError, setPlayError] = useState(false);
const [matchOpen, setMatchOpen] = useState(false);
const [kwInput, setKwInput] = useState("");
const [filterDraft, setFilterDraft] = useState<FilterOptions | null>(null);
// 进入/切换条目时恢复本地导入的弹幕 // 进入/切换条目时恢复本地导入的弹幕
useEffect(() => { useEffect(() => {
setLocalXml(readLocalXml(mediaItemId)); setLocalXml(readLocalXml(mediaItemId));
setMatchXml("");
setFilterDraft(null);
setMatchOpen(false);
setPlayError(false); setPlayError(false);
setEnded(false); setEnded(false);
}, [mediaItemId]); }, [mediaItemId]);
@ -130,8 +144,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId }); const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
const progress = trpc.media.playbackGet.useQuery({ mediaItemId }); const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn }); const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn });
const prefs = trpc.media.danmakuGetSettings.useQuery();
const report = trpc.media.playbackReport.useMutation(); const report = trpc.media.playbackReport.useMutation();
const importMeta = trpc.media.danmakuImportMeta.useMutation(); const importXml = trpc.media.danmakuImport.useMutation();
const matchMut = trpc.media.danmakuMatch.useMutation();
const selectMut = trpc.media.danmakuSelectMatch.useMutation();
const reportRef = useRef(report); const reportRef = useRef(report);
useEffect(() => { useEffect(() => {
reportRef.current = report; reportRef.current = report;
@ -321,11 +338,21 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
} }
}, [progress.data]); }, [progress.data]);
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : ""; const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : "";
const items = useMemo(() => { const items = useMemo(() => {
const all = xmlSource ? parseDanmakuXml(xmlSource) : []; const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
return thinDanmaku(all, danmakuDensity); raw.sort((a, b) => a.time - b.time);
}, [xmlSource, danmakuDensity]); const filters: FilterOptions | null = filterDraft
? filterDraft
: prefs.data
? {
blockKeywords: prefs.data.blockKeywords,
blockTypes: prefs.data.blockTypes,
}
: null;
const blocked = filters ? filterComments(raw, filters) : raw;
return toDanmukuItems(thinDanmaku(blocked, danmakuDensity));
}, [xmlSource, prefs.data, filterDraft, danmakuDensity]);
const danmakuCount = items.length; const danmakuCount = items.length;
// 弹幕数据变化时交给官方插件渲染 // 弹幕数据变化时交给官方插件渲染
@ -352,19 +379,105 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const text = String(reader.result ?? ""); const text = String(reader.result ?? "");
setLocalXml(text); importXml.mutate(
writeLocalXml(mediaItemId, text); { mediaItemId, xml: text, fileName: file.name },
importMeta.mutate(
{ mediaItemId, byteSize: file.size },
{ {
onSuccess: () => toast.success(t(locale, "watch.importOk")), onSuccess: () => {
setLocalXml(text);
writeLocalXml(mediaItemId, text);
setMatchXml("");
toast.success(t(locale, "watch.importSaved"));
},
onError: () => toast.error(t(locale, "watch.importFail")), onError: () => toast.error(t(locale, "watch.importFail")),
}, },
); );
}; };
reader.readAsText(file); reader.readAsText(file);
}, },
[importMeta, mediaItemId, locale], [importXml, mediaItemId, locale],
);
const openRematch = useCallback(() => {
setMatchOpen(true);
matchMut.mutate(
{ mediaItemId },
{
onError: () => {
setMatchOpen(false);
toast.error(t(locale, "watch.matchFailed"));
},
},
);
}, [matchMut, mediaItemId, locale]);
const onSelectCandidate = useCallback(
(episodeId: number) => {
selectMut.mutate(
{ mediaItemId, episodeId },
{
onSuccess: (data) => {
setMatchOpen(false);
if (data.ok && data.xml) {
setMatchXml(data.xml);
setLocalXml("");
}
void openDanmaku.refetch();
toast.success(t(locale, "watch.matchManual"));
},
onError: () => toast.error(t(locale, "watch.matchFailed")),
},
);
},
[selectMut, mediaItemId, locale, openDanmaku],
);
const activeFilters: FilterOptions | null = filterDraft
? filterDraft
: prefs.data
? {
blockKeywords: prefs.data.blockKeywords,
blockTypes: prefs.data.blockTypes,
}
: null;
const addBlockKeyword = useCallback(() => {
const kw = kwInput.trim();
if (!kw) return;
setFilterDraft((prev) => {
const base: FilterOptions = prev ??
activeFilters ?? { blockKeywords: [], blockTypes: [] };
if (base.blockKeywords.includes(kw)) return base;
return { ...base, blockKeywords: [...base.blockKeywords, kw] };
});
setKwInput("");
}, [kwInput, activeFilters]);
const removeBlockKeyword = useCallback(
(kw: string) => {
setFilterDraft((prev) => {
const base: FilterOptions = prev ??
activeFilters ?? { blockKeywords: [], blockTypes: [] };
return { ...base, blockKeywords: base.blockKeywords.filter((k) => k !== kw) };
});
},
[activeFilters],
);
const toggleBlockType = useCallback(
(kind: "scroll" | "top" | "bottom") => {
setFilterDraft((prev) => {
const base: FilterOptions = prev ??
activeFilters ?? {
blockKeywords: [],
blockTypes: [],
};
const blockTypes = base.blockTypes.includes(kind)
? base.blockTypes.filter((k) => k !== kind)
: [...base.blockTypes, kind];
return { ...base, blockTypes };
});
},
[activeFilters],
); );
const replay = useCallback(() => { const replay = useCallback(() => {
@ -425,19 +538,41 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
); );
} }
const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml";
const fetchMessage = openDanmaku.data?.message ?? "";
const showRematch =
danmakuOn &&
!localXml &&
openDanmaku.data?.source === "none" &&
(fetchMessage.includes("手选") || fetchMessage.includes("未匹配"));
const candidates = matchMut.data?.candidates ?? [];
const mediaTitle = detail.data.title;
const sameNameEpisodes = new Set(
candidates
.filter(
(c) =>
c.animeTitle != null &&
c.animeTitle.length > 0 &&
(mediaTitle.includes(c.animeTitle) || c.animeTitle.includes(mediaTitle)),
)
.map((c) => c.episodeId),
);
const sourceLabel = !danmakuOn const sourceLabel = !danmakuOn
? localXml ? localXml || isLocalSource
? t(locale, "watch.danmakuDisabledHint") ? t(locale, "watch.danmakuDisabledHint")
: t(locale, "watch.danmakuOff") : t(locale, "watch.danmakuOff")
: localXml : isLocalSource
? t(locale, "watch.danmakuCount", { count: danmakuCount }) ? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
: openDanmaku.data?.source === "none" : matchXml
? t(locale, "watch.noDanmakuImport") ? t(locale, "watch.danmakuOpenNetwork")
: openDanmaku.data?.source === "cache" : openDanmaku.data?.source === "none"
? t(locale, "watch.danmakuCache") ? t(locale, "watch.noDanmakuImport")
: openDanmaku.data?.source === "open-network" : openDanmaku.data?.source === "cache"
? t(locale, "watch.danmakuOpenNetwork") ? t(locale, "watch.danmakuCache")
: t(locale, "watch.danmakuLoading"); : openDanmaku.data?.source === "open-network"
? t(locale, "watch.danmakuOpenNetwork")
: t(locale, "watch.danmakuLoading");
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -489,10 +624,157 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
)} )}
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground"> <div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{sourceLabel}</span> <span className="flex flex-wrap items-center gap-2">
<span>{sourceLabel}</span>
{showRematch && (
<Button size="sm" variant="outline" onClick={openRematch}>
{t(locale, "watch.rematch")}
</Button>
)}
</span>
<span>{t(locale, "watch.progressHint")}</span> <span>{t(locale, "watch.progressHint")}</span>
</div> </div>
<section className="space-y-2 rounded-lg border border-border p-3">
<p className="u-label">{t(locale, "watch.filterTitle")}</p>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t(locale, "danmaku.blockKeywords")}
</p>
<div className="flex flex-wrap items-center gap-2">
{(activeFilters?.blockKeywords ?? []).map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs"
>
{kw}
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeBlockKeyword(kw)}
>
×
</button>
</span>
))}
<Input
className="h-8 w-40 text-sm"
value={kwInput}
placeholder={t(locale, "watch.blockKeywordPlaceholder")}
onChange={(e) => setKwInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addBlockKeyword();
}
}}
/>
<Button size="sm" variant="outline" onClick={addBlockKeyword}>
{t(locale, "watch.blockKeywordAdd")}
</Button>
</div>
<p className="text-sm text-muted-foreground">
{t(locale, "danmaku.blockTypes")}
</p>
<div className="flex flex-wrap gap-2">
{(
[
["scroll", "danmaku.blockTypeScroll"],
["top", "danmaku.blockTypeTop"],
["bottom", "danmaku.blockTypeBottom"],
] as const
).map(([kind, labelKey]) => {
const blocked = activeFilters?.blockTypes.includes(kind) ?? false;
return (
<button
key={kind}
type="button"
className={cn(
"rounded-md border px-3 py-1.5 text-sm transition-colors",
blocked
? "border-primary bg-primary/10 font-medium text-primary"
: "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground",
)}
onClick={() => toggleBlockType(kind)}
>
{t(locale, labelKey)}
</button>
);
})}
</div>
</div>
</section>
{matchOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-lg space-y-3 rounded-lg border border-border bg-background p-4 shadow-lg">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold">
{t(locale, "watch.matchCandidates")}
</h2>
<Button
size="sm"
variant="ghost"
className="ml-auto"
onClick={() => setMatchOpen(false)}
>
×
</Button>
</div>
{matchMut.data?.highConfidence && (
<p className="text-xs text-primary">
{t(locale, "watch.matchAutoBound")}
</p>
)}
{matchMut.isPending && (
<p className="text-sm text-muted-foreground">
{t(locale, "watch.loading")}
</p>
)}
{matchMut.isError && (
<p className="text-sm text-destructive">
{t(locale, "watch.matchFailed")}
</p>
)}
{matchMut.isSuccess && candidates.length === 0 && (
<p className="text-sm text-muted-foreground">
{t(locale, "watch.matchEmpty")}
</p>
)}
{candidates.length > 0 && (
<ul className="max-h-80 space-y-2 overflow-y-auto">
{candidates.map((c) => (
<li key={c.episodeId}>
<button
type="button"
className="w-full rounded-md border border-border px-3 py-2 text-left text-sm transition-colors hover:border-primary/60 hover:bg-primary/5"
disabled={selectMut.isPending}
onClick={() => onSelectCandidate(c.episodeId)}
>
<span className="font-medium">
{c.animeTitle ?? `#${c.episodeId}`}
</span>
<span className="ml-2 text-muted-foreground">
{c.episodeTitle ??
t(locale, "watch.episodeN", { n: c.episodeId })}
</span>
<span className="ml-2 text-xs text-muted-foreground">
episodeId {c.episodeId}
</span>
{sameNameEpisodes.has(c.episodeId) && (
<span className="ml-2 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
{t(locale, "watch.foundSameName")}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
)}
{episodes.length > 0 && ( {episodes.length > 0 && (
<section className="space-y-2"> <section className="space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@ -1,7 +1,18 @@
import {
filterComments,
parseDanmakuXml,
serializeDanmakuXml,
type DanmakuComment,
type FilterOptions,
} from "@app/danmaku";
export { filterComments, parseDanmakuXml, serializeDanmakuXml };
export type { DanmakuComment, FilterOptions };
/** artplayer-plugin-danmuku 条目(mode 0 滚动 / 1 顶部 / 2 底部) */
export type DanmakuItem = { export type DanmakuItem = {
time: number; time: number;
text: string; text: string;
/** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */
mode: 0 | 1 | 2; mode: 0 | 1 | 2;
color?: string | undefined; color?: string | undefined;
}; };
@ -19,31 +30,19 @@ function toHexColor(decimal: number): string | undefined {
return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`; return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`;
} }
/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */ /** DanmakuComment(含颜色/模式)→ 插件条目 */
export function parseDanmakuXml(xml: string): DanmakuItem[] { export function toDanmukuItems(list: DanmakuComment[]): DanmakuItem[] {
const doc = new DOMParser().parseFromString(xml, "application/xml"); return list.map((c) => ({
const nodes = Array.from(doc.querySelectorAll("d")); time: c.time,
const out: DanmakuItem[] = []; text: c.text,
for (const n of nodes) { mode: toPluginMode(c.mode),
const p = n.getAttribute("p") ?? ""; color: toHexColor(c.color),
const [t, modeRaw, , colorRaw] = p.split(","); }));
const time = Number(t);
if (!Number.isFinite(time)) continue;
const text = (n.textContent ?? "").trim();
if (!text) continue;
out.push({
time,
text,
mode: toPluginMode(Number(modeRaw) || 1),
color: toHexColor(Number(colorRaw)),
});
}
return out.sort((a, b) => a.time - b.time);
} }
/** density < 1 时按比例抽稀,避免同屏过密 */ /** density 0.1–1:按时间均匀抽稀 */
export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] { export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] {
if (density >= 1) return items; if (density >= 1) return list;
const keepEvery = Math.max(1, Math.round(1 / density)); const keepEvery = Math.max(1, Math.round(1 / density));
return items.filter((_, i) => i % keepEvery === 0); return list.filter((_, i) => i % keepEvery === 0);
} }

View File

@ -175,6 +175,18 @@ export const en: Record<MessageKey, string> = {
"watch.playbackFinished": "Episode finished", "watch.playbackFinished": "Episode finished",
"watch.danmakuDisabledHint": "watch.danmakuDisabledHint":
"Danmaku is off — enable it via the button above or in danmaku settings", "Danmaku is off — enable it via the button above or in danmaku settings",
"watch.danmakuLocal": "Danmaku (local)",
"watch.rematch": "Rematch",
"watch.matchCandidates": "Pick episode",
"watch.matchAutoBound": "Auto-matched danmaku",
"watch.matchManual": "Manual match",
"watch.importSaved": "Danmaku saved",
"watch.foundSameName": "Linked same-name danmaku",
"watch.filterTitle": "Danmaku filters",
"watch.blockKeywordAdd": "Add block word",
"watch.matchFailed": "Danmaku match failed",
"watch.matchEmpty": "No episode candidates found",
"watch.blockKeywordPlaceholder": "Type a block word and press Enter",
"danmaku.title": "Danmaku settings", "danmaku.title": "Danmaku settings",
"danmaku.opacity": "Opacity", "danmaku.opacity": "Opacity",
@ -186,6 +198,11 @@ export const en: Record<MessageKey, string> = {
"danmaku.saved": "Saved (local session)", "danmaku.saved": "Saved (local session)",
"danmaku.localImportHint": "danmaku.localImportHint":
"Import local XML on the watch page; this saves the default toggle and display options.", "Import local XML on the watch page; this saves the default toggle and display options.",
"danmaku.blockKeywords": "Blocked words",
"danmaku.blockTypes": "Block types",
"danmaku.blockTypeScroll": "Scroll",
"danmaku.blockTypeTop": "Top",
"danmaku.blockTypeBottom": "Bottom",
"auth.login.title": "Sign in", "auth.login.title": "Sign in",
"auth.login.heading": "Sign in", "auth.login.heading": "Sign in",

View File

@ -170,6 +170,18 @@ export const zhCN = {
"watch.replay": "重新播放", "watch.replay": "重新播放",
"watch.playbackFinished": "本集已播放完毕", "watch.playbackFinished": "本集已播放完毕",
"watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启", "watch.danmakuDisabledHint": "弹幕已关闭,可在上方按钮或弹幕设置中开启",
"watch.danmakuLocal": "弹幕(本地)",
"watch.rematch": "重新匹配",
"watch.matchCandidates": "选择弹幕集",
"watch.matchAutoBound": "已自动匹配弹幕",
"watch.matchManual": "手动匹配",
"watch.importSaved": "弹幕已保存",
"watch.foundSameName": "发现同名弹幕,已自动关联",
"watch.filterTitle": "弹幕过滤",
"watch.blockKeywordAdd": "添加屏蔽词",
"watch.matchFailed": "弹幕匹配失败",
"watch.matchEmpty": "未找到可选手集",
"watch.blockKeywordPlaceholder": "输入屏蔽词后回车",
"danmaku.title": "弹幕设置", "danmaku.title": "弹幕设置",
"danmaku.opacity": "不透明度", "danmaku.opacity": "不透明度",
@ -180,6 +192,11 @@ export const zhCN = {
"danmaku.save": "保存", "danmaku.save": "保存",
"danmaku.saved": "已保存(本地会话)", "danmaku.saved": "已保存(本地会话)",
"danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。", "danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。",
"danmaku.blockKeywords": "屏蔽词",
"danmaku.blockTypes": "类型屏蔽",
"danmaku.blockTypeScroll": "滚动",
"danmaku.blockTypeTop": "顶部",
"danmaku.blockTypeBottom": "底部",
"auth.login.title": "登录", "auth.login.title": "登录",
"auth.login.heading": "登录", "auth.login.heading": "登录",

View File

@ -377,6 +377,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@app/web@workspace:app/web" resolution: "@app/web@workspace:app/web"
dependencies: dependencies:
"@app/danmaku": "workspace:*"
"@app/design-tokens": "workspace:*" "@app/design-tokens": "workspace:*"
"@app/i18n": "workspace:*" "@app/i18n": "workspace:*"
"@app/seo-geo": "workspace:*" "@app/seo-geo": "workspace:*"