diff --git a/app/web/src/components/pages/DanmakuSettingsBody.tsx b/app/web/src/components/pages/DanmakuSettingsBody.tsx index 5ac2e36..d729f1b 100644 --- a/app/web/src/components/pages/DanmakuSettingsBody.tsx +++ b/app/web/src/components/pages/DanmakuSettingsBody.tsx @@ -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; + +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; 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(initial.blockKeywords); + const [blockTypes, setBlockTypes] = useState(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 (

{t(locale, "danmaku.title")}

+ {openNetworkMissing && ( +
+ {t(locale, "danmaku.openNetworkNotConfigured")} +
+ )}
+
+

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

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

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

+
+ {BLOCK_TYPE_LABELS.map(([kind, labelKey]) => ( + + ))} +
+
- - {save.isSuccess && ( -

{t(locale, "danmaku.saved")}

- )}
+

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

{t(locale, "danmaku.localImportHint")}

diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index a077316..8ce6677 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -195,14 +195,19 @@ export const en: Record = { "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", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 2879b74..b2a7a00 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -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": "登录",