feat(web): danmaku settings server prefs with block words

This commit is contained in:
noelorin 2026-09-26 22:47:06 +08:00
parent 4b2f16d779
commit 9ef9dd01a5
3 changed files with 168 additions and 26 deletions

View File

@ -1,67 +1,147 @@
import type { Locale } from "@app/i18n";
import type { Locale, MessageKey } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Card, toast } from "@app/ui";
import { Button, Card, Input, toast } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuSettings = { enabled: boolean; opacity: number; density: number };
type BlockType = "scroll" | "top" | "bottom";
const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 };
type DanmakuSettings = {
enabled: boolean;
opacity: number;
density: number;
blockKeywords: string[];
blockTypes: BlockType[];
};
const FALLBACK: DanmakuSettings = {
enabled: true,
opacity: 0.8,
density: 1,
blockKeywords: [],
blockTypes: [],
};
const BLOCK_TYPE_LABELS = [
["scroll", "danmaku.blockTypeScroll"],
["top", "danmaku.blockTypeTop"],
["bottom", "danmaku.blockTypeBottom"],
] as const satisfies ReadonlyArray<readonly [BlockType, MessageKey]>;
function parseBlockTypes(raw: unknown): BlockType[] {
if (!Array.isArray(raw)) return [];
const allowed: BlockType[] = ["scroll", "top", "bottom"];
return raw.filter((v): v is BlockType => allowed.includes(v as BlockType));
}
function readSettings(): DanmakuSettings {
try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return FALLBACK;
if (!raw) return { ...FALLBACK };
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return FALLBACK;
if (typeof parsed !== "object" || parsed === null) return { ...FALLBACK };
const rec = parsed as Record<string, unknown>;
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density;
const kwRaw = rec["blockKeywords"];
const blockKeywords = Array.isArray(kwRaw)
? kwRaw.filter(
(v): v is string => typeof v === "string" && v.length > 0 && v.length <= 50,
)
: [];
return {
enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(1, Math.max(0.1, densityRaw)),
blockKeywords: blockKeywords.slice(0, 100),
blockTypes: parseBlockTypes(rec["blockTypes"]),
};
} catch {
return FALLBACK;
return { ...FALLBACK };
}
}
function writeSettings(next: DanmakuSettings) {
try {
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
} catch {
// localStorage 不可用时静默;服务端已持久化
}
}
function Inner(props: { locale: Locale }) {
const { locale } = props;
const prefsQuery = trpc.media.danmakuGetSettings.useQuery();
const save = trpc.media.danmakuSaveSettings.useMutation();
const [enabled, setEnabled] = useState(FALLBACK.enabled);
const [opacity, setOpacity] = useState(FALLBACK.opacity);
const [density, setDensity] = useState(FALLBACK.density);
const filledFromServer = useRef(false);
const initial = readSettings();
const [enabled, setEnabled] = useState(initial.enabled);
const [opacity, setOpacity] = useState(initial.opacity);
const [density, setDensity] = useState(initial.density);
const [blockKeywords, setBlockKeywords] = useState<string[]>(initial.blockKeywords);
const [blockTypes, setBlockTypes] = useState<BlockType[]>(initial.blockTypes);
const [kwInput, setKwInput] = useState("");
useEffect(() => {
const prefs = readSettings();
setEnabled(prefs.enabled);
setOpacity(prefs.opacity);
setDensity(prefs.density);
const data = prefsQuery.data;
if (!data || filledFromServer.current) return;
filledFromServer.current = true;
setEnabled(data.enabled);
setOpacity(data.opacity);
setDensity(data.density);
setBlockKeywords(data.blockKeywords);
setBlockTypes(data.blockTypes);
}, [prefsQuery.data]);
const addBlockKeyword = useCallback(() => {
const kw = kwInput.trim();
if (!kw || kw.length > 50) return;
setBlockKeywords((prev) => {
if (prev.includes(kw) || prev.length >= 100) return prev;
return [...prev, kw];
});
setKwInput("");
}, [kwInput]);
const removeBlockKeyword = useCallback((kw: string) => {
setBlockKeywords((prev) => prev.filter((k) => k !== kw));
}, []);
const toggleBlockType = useCallback((kind: BlockType) => {
setBlockTypes((prev) =>
prev.includes(kind) ? prev.filter((k) => k !== kind) : [...prev, kind],
);
}, []);
const onSave = () => {
const next = { enabled, opacity, density };
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
const next: DanmakuSettings = { enabled, opacity, density, blockKeywords, blockTypes };
writeSettings(next);
save.mutate(next, {
onSuccess: () => {
toast.success(t(locale, "danmaku.saved"));
},
onError: () => {
toast.error(t(locale, "danmaku.save"));
toast.error(t(locale, "danmaku.saveFailed"));
},
});
};
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
{openNetworkMissing && (
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200">
{t(locale, "danmaku.openNetworkNotConfigured")}
</div>
)}
<Card className="max-w-md space-y-5 p-6">
<label className="flex items-center gap-2.5 text-sm font-medium">
<input
@ -105,14 +185,67 @@ function Inner(props: { locale: Locale }) {
className="w-full"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t(locale, "danmaku.blockKeywords")}</p>
<div className="flex flex-wrap items-center gap-2">
{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 type="button" size="sm" variant="outline" onClick={addBlockKeyword}>
{t(locale, "watch.blockKeywordAdd")}
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t(locale, "danmaku.blockTypes")}</p>
<div className="flex flex-wrap gap-3">
{BLOCK_TYPE_LABELS.map(([kind, labelKey]) => (
<label
key={kind}
className="flex items-center gap-2 text-sm font-medium"
>
<input
type="checkbox"
checked={blockTypes.includes(kind)}
onChange={() => toggleBlockType(kind)}
/>
{t(locale, labelKey)}
</label>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button type="button" onClick={onSave}>
<Button type="button" onClick={onSave} disabled={save.isPending}>
{t(locale, "danmaku.save")}
</Button>
{save.isSuccess && (
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
)}
</div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.serverPrefsHint")}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.localImportHint")}
</p>

View File

@ -195,14 +195,19 @@ export const en: Record<MessageKey, string> = {
"danmaku.opacityValue": "Opacity {value}%",
"danmaku.densityValue": "Density {value}%",
"danmaku.save": "Save",
"danmaku.saved": "Saved (local session)",
"danmaku.saved": "Saved",
"danmaku.saveFailed": "Save failed",
"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 server defaults, display options, and block rules.",
"danmaku.blockKeywords": "Blocked words",
"danmaku.blockTypes": "Block types",
"danmaku.blockTypeScroll": "Scroll",
"danmaku.blockTypeTop": "Top",
"danmaku.blockTypeBottom": "Bottom",
"danmaku.openNetworkNotConfigured":
"Open danmaku network credentials are not configured; online danmaku may be empty.",
"danmaku.serverPrefsHint":
"Preferences are stored on this account; watch-page filters and display follow this config.",
"auth.login.title": "Sign in",
"auth.login.heading": "Sign in",

View File

@ -190,13 +190,17 @@ export const zhCN = {
"danmaku.opacityValue": "不透明度 {value}%",
"danmaku.densityValue": "密度 {value}%",
"danmaku.save": "保存",
"danmaku.saved": "已保存(本地会话)",
"danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。",
"danmaku.saved": "已保存",
"danmaku.saveFailed": "保存失败",
"danmaku.localImportHint":
"本地 XML 导入在播放页操作;此处保存服务端开关、显示参数与屏蔽规则。",
"danmaku.blockKeywords": "屏蔽词",
"danmaku.blockTypes": "类型屏蔽",
"danmaku.blockTypeScroll": "滚动",
"danmaku.blockTypeTop": "顶部",
"danmaku.blockTypeBottom": "底部",
"danmaku.openNetworkNotConfigured": "未配置开放弹幕网络服务端凭据,在线弹幕可能为空。",
"danmaku.serverPrefsHint": "偏好保存在当前账号下,播放页过滤与显示沿用此配置。",
"auth.login.title": "登录",
"auth.login.heading": "登录",