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 ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@app/danmaku": "workspace:*",
|
||||
"@app/design-tokens": "workspace:*",
|
||||
"@app/i18n": "workspace:*",
|
||||
"@app/seo-geo": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -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<DanmakuPrefs>(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<FilterOptions | null>(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 ?? "");
|
||||
importXml.mutate(
|
||||
{ mediaItemId, xml: text, fileName: file.name },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setLocalXml(text);
|
||||
writeLocalXml(mediaItemId, text);
|
||||
importMeta.mutate(
|
||||
{ mediaItemId, byteSize: file.size },
|
||||
{
|
||||
onSuccess: () => toast.success(t(locale, "watch.importOk")),
|
||||
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,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
|
||||
? localXml
|
||||
? localXml || isLocalSource
|
||||
? t(locale, "watch.danmakuDisabledHint")
|
||||
: t(locale, "watch.danmakuOff")
|
||||
: localXml
|
||||
? t(locale, "watch.danmakuCount", { count: danmakuCount })
|
||||
: 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"
|
||||
|
|
@ -489,10 +624,157 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
)}
|
||||
</div>
|
||||
<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>
|
||||
{showRematch && (
|
||||
<Button size="sm" variant="outline" onClick={openRematch}>
|
||||
{t(locale, "watch.rematch")}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
<span>{t(locale, "watch.progressHint")}</span>
|
||||
</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 && (
|
||||
<section className="space-y-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 = {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,18 @@ export const en: Record<MessageKey, string> = {
|
|||
"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<MessageKey, string> = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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": "登录",
|
||||
|
|
|
|||
Loading…
Reference in New Issue