From c236f50ec6ffb69574466daebee24b35dc2f290e Mon Sep 17 00:00:00 2001 From: noelorin Date: Sat, 26 Sep 2026 21:03:01 +0800 Subject: [PATCH] feat(web): watch rematch candidates, import persist, danmaku filters --- app/web/package.json | 1 + app/web/src/components/pages/WatchBody.tsx | 330 +++++++++++++++++++-- app/web/src/lib/danmaku-render.ts | 49 ++- packages/i18n/src/messages/en.ts | 17 ++ packages/i18n/src/messages/zh-CN.ts | 17 ++ yarn.lock | 1 + 6 files changed, 366 insertions(+), 49 deletions(-) diff --git a/app/web/package.json b/app/web/package.json index de9683e..a92a3e8 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -12,6 +12,7 @@ "lint": "biome check ." }, "dependencies": { + "@app/danmaku": "workspace:*", "@app/design-tokens": "workspace:*", "@app/i18n": "workspace:*", "@app/seo-geo": "workspace:*", diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 7c85e3a..979a4ce 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -1,11 +1,17 @@ import type { Locale } 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 Artplayer from "artplayer"; import artplayerPluginDanmuku from "artplayer-plugin-danmuku"; 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 { createTRPCReactClient, trpc } from "~/lib/trpc"; import { cn } from "~/lib/utils"; @@ -115,14 +121,22 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const progressStartRef = useRef(0); const prefsRef = useRef(readDanmakuPrefs()); const [localXml, setLocalXml] = useState(""); + /** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */ + const [matchXml, setMatchXml] = useState(""); const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled); const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density); const [ended, setEnded] = useState(false); const [playError, setPlayError] = useState(false); + const [matchOpen, setMatchOpen] = useState(false); + const [kwInput, setKwInput] = useState(""); + const [filterDraft, setFilterDraft] = useState(null); // 进入/切换条目时恢复本地导入的弹幕 useEffect(() => { setLocalXml(readLocalXml(mediaItemId)); + setMatchXml(""); + setFilterDraft(null); + setMatchOpen(false); setPlayError(false); setEnded(false); }, [mediaItemId]); @@ -130,8 +144,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId }); const progress = trpc.media.playbackGet.useQuery({ mediaItemId }); const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn }); + const prefs = trpc.media.danmakuGetSettings.useQuery(); 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); useEffect(() => { reportRef.current = report; @@ -321,11 +338,21 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { } }, [progress.data]); - const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : ""; + const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : ""; const items = useMemo(() => { - const all = xmlSource ? parseDanmakuXml(xmlSource) : []; - return thinDanmaku(all, danmakuDensity); - }, [xmlSource, danmakuDensity]); + const raw = xmlSource ? parseDanmakuXml(xmlSource) : []; + raw.sort((a, b) => a.time - b.time); + 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; // 弹幕数据变化时交给官方插件渲染 @@ -352,19 +379,105 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const reader = new FileReader(); reader.onload = () => { const text = String(reader.result ?? ""); - setLocalXml(text); - writeLocalXml(mediaItemId, text); - importMeta.mutate( - { mediaItemId, byteSize: file.size }, + importXml.mutate( + { mediaItemId, xml: text, fileName: file.name }, { - 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")), }, ); }; 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(() => { @@ -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 - ? localXml + ? localXml || isLocalSource ? t(locale, "watch.danmakuDisabledHint") : t(locale, "watch.danmakuOff") - : localXml - ? t(locale, "watch.danmakuCount", { count: danmakuCount }) - : openDanmaku.data?.source === "none" - ? t(locale, "watch.noDanmakuImport") - : openDanmaku.data?.source === "cache" - ? t(locale, "watch.danmakuCache") - : openDanmaku.data?.source === "open-network" - ? t(locale, "watch.danmakuOpenNetwork") - : t(locale, "watch.danmakuLoading"); + : isLocalSource + ? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}` + : matchXml + ? t(locale, "watch.danmakuOpenNetwork") + : openDanmaku.data?.source === "none" + ? t(locale, "watch.noDanmakuImport") + : openDanmaku.data?.source === "cache" + ? t(locale, "watch.danmakuCache") + : openDanmaku.data?.source === "open-network" + ? t(locale, "watch.danmakuOpenNetwork") + : t(locale, "watch.danmakuLoading"); return (
@@ -489,10 +624,157 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { )}
- {sourceLabel} + + {sourceLabel} + {showRematch && ( + + )} + {t(locale, "watch.progressHint")}
+
+

{t(locale, "watch.filterTitle")}

+
+

+ {t(locale, "danmaku.blockKeywords")} +

+
+ {(activeFilters?.blockKeywords ?? []).map((kw) => ( + + {kw} + + + ))} + setKwInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addBlockKeyword(); + } + }} + /> + +
+

+ {t(locale, "danmaku.blockTypes")} +

+
+ {( + [ + ["scroll", "danmaku.blockTypeScroll"], + ["top", "danmaku.blockTypeTop"], + ["bottom", "danmaku.blockTypeBottom"], + ] as const + ).map(([kind, labelKey]) => { + const blocked = activeFilters?.blockTypes.includes(kind) ?? false; + return ( + + ); + })} +
+
+
+ + {matchOpen && ( +
+
+
+

+ {t(locale, "watch.matchCandidates")} +

+ +
+ {matchMut.data?.highConfidence && ( +

+ {t(locale, "watch.matchAutoBound")} +

+ )} + {matchMut.isPending && ( +

+ {t(locale, "watch.loading")} +

+ )} + {matchMut.isError && ( +

+ {t(locale, "watch.matchFailed")} +

+ )} + {matchMut.isSuccess && candidates.length === 0 && ( +

+ {t(locale, "watch.matchEmpty")} +

+ )} + {candidates.length > 0 && ( +
    + {candidates.map((c) => ( +
  • + +
  • + ))} +
+ )} +
+
+ )} + {episodes.length > 0 && (
diff --git a/app/web/src/lib/danmaku-render.ts b/app/web/src/lib/danmaku-render.ts index 1530aaa..5bffec1 100644 --- a/app/web/src/lib/danmaku-render.ts +++ b/app/web/src/lib/danmaku-render.ts @@ -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 = { time: number; text: string; - /** 0 滚动,1 顶部,2 底部(artplayer-plugin-danmuku) */ mode: 0 | 1 | 2; color?: string | undefined; }; @@ -19,31 +30,19 @@ function toHexColor(decimal: number): string | undefined { return `#${decimal.toString(16).padStart(6, "0").toUpperCase()}`; } -/** 解析 B 站弹幕 XML(含颜色/模式),供 artplayer-plugin-danmuku 使用 */ -export function parseDanmakuXml(xml: string): DanmakuItem[] { - const doc = new DOMParser().parseFromString(xml, "application/xml"); - const nodes = Array.from(doc.querySelectorAll("d")); - const out: DanmakuItem[] = []; - for (const n of nodes) { - const p = n.getAttribute("p") ?? ""; - 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); +/** DanmakuComment(含颜色/模式)→ 插件条目 */ +export function toDanmukuItems(list: DanmakuComment[]): DanmakuItem[] { + return list.map((c) => ({ + time: c.time, + text: c.text, + mode: toPluginMode(c.mode), + color: toHexColor(c.color), + })); } -/** density < 1 时按比例抽稀,避免同屏过密 */ -export function thinDanmaku(items: DanmakuItem[], density: number): DanmakuItem[] { - if (density >= 1) return items; +/** density 0.1–1:按时间均匀抽稀 */ +export function thinDanmaku(list: DanmakuComment[], density: number): DanmakuComment[] { + if (density >= 1) return list; const keepEvery = Math.max(1, Math.round(1 / density)); - return items.filter((_, i) => i % keepEvery === 0); + return list.filter((_, i) => i % keepEvery === 0); } diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index c415a6f..2649c62 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -175,6 +175,18 @@ export const en: Record = { "watch.playbackFinished": "Episode finished", "watch.danmakuDisabledHint": "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.opacity": "Opacity", @@ -186,6 +198,11 @@ export const en: Record = { "danmaku.saved": "Saved (local session)", "danmaku.localImportHint": "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.heading": "Sign in", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 09a2db0..3b40d0a 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -170,6 +170,18 @@ export const zhCN = { "watch.replay": "重新播放", "watch.playbackFinished": "本集已播放完毕", "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.opacity": "不透明度", @@ -180,6 +192,11 @@ export const zhCN = { "danmaku.save": "保存", "danmaku.saved": "已保存(本地会话)", "danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。", + "danmaku.blockKeywords": "屏蔽词", + "danmaku.blockTypes": "类型屏蔽", + "danmaku.blockTypeScroll": "滚动", + "danmaku.blockTypeTop": "顶部", + "danmaku.blockTypeBottom": "底部", "auth.login.title": "登录", "auth.login.heading": "登录", diff --git a/yarn.lock b/yarn.lock index 17ad971..8d60186 100644 --- a/yarn.lock +++ b/yarn.lock @@ -377,6 +377,7 @@ __metadata: version: 0.0.0-use.local resolution: "@app/web@workspace:app/web" dependencies: + "@app/danmaku": "workspace:*" "@app/design-tokens": "workspace:*" "@app/i18n": "workspace:*" "@app/seo-geo": "workspace:*"