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 { 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { go } from "~/lib/nav"; import { go } from "~/lib/nav";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; 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 { function readSettings(): DanmakuSettings {
try { try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY); const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return FALLBACK; if (!raw) return { ...FALLBACK };
const parsed: unknown = JSON.parse(raw); 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 rec = parsed as Record<string, unknown>;
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled; const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : FALLBACK.enabled;
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity; const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : FALLBACK.opacity;
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : FALLBACK.density; 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 { return {
enabled, enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)), opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(1, Math.max(0.1, densityRaw)), density: Math.min(1, Math.max(0.1, densityRaw)),
blockKeywords: blockKeywords.slice(0, 100),
blockTypes: parseBlockTypes(rec["blockTypes"]),
}; };
} catch { } 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 }) { function Inner(props: { locale: Locale }) {
const { locale } = props; const { locale } = props;
const prefsQuery = trpc.media.danmakuGetSettings.useQuery();
const save = trpc.media.danmakuSaveSettings.useMutation(); const save = trpc.media.danmakuSaveSettings.useMutation();
const [enabled, setEnabled] = useState(FALLBACK.enabled); const filledFromServer = useRef(false);
const [opacity, setOpacity] = useState(FALLBACK.opacity);
const [density, setDensity] = useState(FALLBACK.density); 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(() => { useEffect(() => {
const prefs = readSettings(); const data = prefsQuery.data;
setEnabled(prefs.enabled); if (!data || filledFromServer.current) return;
setOpacity(prefs.opacity); filledFromServer.current = true;
setDensity(prefs.density); 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 onSave = () => {
const next = { enabled, opacity, density }; const next: DanmakuSettings = { enabled, opacity, density, blockKeywords, blockTypes };
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next)); writeSettings(next);
save.mutate(next, { save.mutate(next, {
onSuccess: () => { onSuccess: () => {
toast.success(t(locale, "danmaku.saved")); toast.success(t(locale, "danmaku.saved"));
}, },
onError: () => { onError: () => {
toast.error(t(locale, "danmaku.save")); toast.error(t(locale, "danmaku.saveFailed"));
}, },
}); });
}; };
const openNetworkMissing = prefsQuery.data?.openNetworkConfigured === false;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1> <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"> <Card className="max-w-md space-y-5 p-6">
<label className="flex items-center gap-2.5 text-sm font-medium"> <label className="flex items-center gap-2.5 text-sm font-medium">
<input <input
@ -105,14 +185,67 @@ function Inner(props: { locale: Locale }) {
className="w-full" className="w-full"
/> />
</div> </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"> <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")} {t(locale, "danmaku.save")}
</Button> </Button>
{save.isSuccess && (
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
)}
</div> </div>
<p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.serverPrefsHint")}
</p>
<p className="text-xs leading-relaxed text-muted-foreground"> <p className="text-xs leading-relaxed text-muted-foreground">
{t(locale, "danmaku.localImportHint")} {t(locale, "danmaku.localImportHint")}
</p> </p>

View File

@ -195,14 +195,19 @@ export const en: Record<MessageKey, string> = {
"danmaku.opacityValue": "Opacity {value}%", "danmaku.opacityValue": "Opacity {value}%",
"danmaku.densityValue": "Density {value}%", "danmaku.densityValue": "Density {value}%",
"danmaku.save": "Save", "danmaku.save": "Save",
"danmaku.saved": "Saved (local session)", "danmaku.saved": "Saved",
"danmaku.saveFailed": "Save failed",
"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 server defaults, display options, and block rules.",
"danmaku.blockKeywords": "Blocked words", "danmaku.blockKeywords": "Blocked words",
"danmaku.blockTypes": "Block types", "danmaku.blockTypes": "Block types",
"danmaku.blockTypeScroll": "Scroll", "danmaku.blockTypeScroll": "Scroll",
"danmaku.blockTypeTop": "Top", "danmaku.blockTypeTop": "Top",
"danmaku.blockTypeBottom": "Bottom", "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.title": "Sign in",
"auth.login.heading": "Sign in", "auth.login.heading": "Sign in",

View File

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