feat(web): danmaku settings server prefs with block words
This commit is contained in:
parent
4b2f16d779
commit
9ef9dd01a5
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "登录",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue