feat(web): watch rematch candidates, import persist, danmaku filters
This commit is contained in:
parent
714f51a1a6
commit
c236f50ec6
|
|
@ -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:*",
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "");
|
||||||
|
importXml.mutate(
|
||||||
|
{ mediaItemId, xml: text, fileName: file.name },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
setLocalXml(text);
|
setLocalXml(text);
|
||||||
writeLocalXml(mediaItemId, text);
|
writeLocalXml(mediaItemId, text);
|
||||||
importMeta.mutate(
|
setMatchXml("");
|
||||||
{ mediaItemId, byteSize: file.size },
|
toast.success(t(locale, "watch.importSaved"));
|
||||||
{
|
},
|
||||||
onSuccess: () => toast.success(t(locale, "watch.importOk")),
|
|
||||||
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,12 +538,34 @@ 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 })}`
|
||||||
|
: matchXml
|
||||||
|
? t(locale, "watch.danmakuOpenNetwork")
|
||||||
: openDanmaku.data?.source === "none"
|
: openDanmaku.data?.source === "none"
|
||||||
? t(locale, "watch.noDanmakuImport")
|
? t(locale, "watch.noDanmakuImport")
|
||||||
: openDanmaku.data?.source === "cache"
|
: openDanmaku.data?.source === "cache"
|
||||||
|
|
@ -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 className="flex flex-wrap items-center gap-2">
|
||||||
<span>{sourceLabel}</span>
|
<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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "登录",
|
||||||
|
|
|
||||||
|
|
@ -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:*"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue