feat(web): danmaku settings page + watch local prefs

This commit is contained in:
noelorin 2026-09-25 05:29:28 +08:00
parent 8ecfee686c
commit f96afbc4b5
8 changed files with 238 additions and 25 deletions

View File

@ -0,0 +1,109 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuSettings = { enabled: boolean; opacity: number; density: number };
const FALLBACK: DanmakuSettings = { enabled: true, opacity: 0.8, density: 1 };
function readSettings(): DanmakuSettings {
try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return FALLBACK;
const parsed: unknown = JSON.parse(raw);
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;
return {
enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(3, Math.max(0.1, densityRaw)),
};
} catch {
return FALLBACK;
}
}
function Inner(props: { locale: Locale }) {
const { locale } = props;
const save = trpc.media.danmakuSaveSettings.useMutation();
const [enabled, setEnabled] = useState(FALLBACK.enabled);
const [opacity, setOpacity] = useState(FALLBACK.opacity);
const [density, setDensity] = useState(FALLBACK.density);
useEffect(() => {
const prefs = readSettings();
setEnabled(prefs.enabled);
setOpacity(prefs.opacity);
setDensity(prefs.density);
}, []);
const onSave = () => {
const next = { enabled, opacity, density };
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(next));
save.mutate(next);
};
return (
<div className="max-w-md space-y-4">
<h1 className="text-xl font-semibold">{t(locale, "danmaku.title")}</h1>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{t(locale, "danmaku.enabled")}
</label>
<label className="block text-sm">
{t(locale, "danmaku.opacityValue", { value: (opacity * 100).toFixed(0) })}
<input
type="range"
min={0}
max={1}
step={0.05}
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full"
/>
</label>
<label className="block text-sm">
{t(locale, "danmaku.densityValue", { value: density })}
<input
type="range"
min={0.1}
max={3}
step={0.1}
value={density}
onChange={(e) => setDensity(Number(e.target.value))}
className="w-full"
/>
</label>
<Button onClick={onSave}>{t(locale, "danmaku.save")}</Button>
{save.isSuccess && (
<p className="text-sm text-green-600">{t(locale, "danmaku.saved")}</p>
)}
<p className="text-xs text-muted-foreground">{t(locale, "danmaku.localImportHint")}</p>
</div>
);
}
const client = createTRPCReactClient();
const qc = new QueryClient();
export function DanmakuSettingsBody(props: { locale: Locale }) {
return (
<QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}>
<Inner locale={props.locale} />
</trpc.Provider>
</QueryClientProvider>
);
}

View File

@ -1,9 +1,12 @@
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { Button, Input } from "@app/ui"; import { Button, Input } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
function MountsInner() { function MountsInner(props: { locale: Locale }) {
const { locale } = props;
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const list = trpc.media.mountList.useQuery(); const list = trpc.media.mountList.useQuery();
const create = trpc.media.mountCreate.useMutation({ const create = trpc.media.mountCreate.useMutation({
@ -23,9 +26,9 @@ function MountsInner() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-xl font-semibold">挂载管理</h1> <h1 className="text-xl font-semibold">{t(locale, "mounts.title")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
OpenList 请填写其 WebDAV 地址;凭据仅保存在服务端。 {t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}
</p> </p>
<form <form
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2" className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
@ -43,36 +46,36 @@ function MountsInner() {
}} }}
> >
<Input <Input
placeholder="名称,如 OpenList" placeholder={t(locale, "mounts.namePlaceholder")}
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
required required
/> />
<Input <Input
placeholder="https://example.com/dav" placeholder={t(locale, "mounts.baseUrlPlaceholder")}
value={form.baseUrl} value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })} onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
required required
/> />
<Input <Input
placeholder="用户名(可选)" placeholder={t(locale, "mounts.usernamePlaceholder")}
value={form.username} value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })} onChange={(e) => setForm({ ...form, username: e.target.value })}
/> />
<Input <Input
type="password" type="password"
placeholder="密码 / 令牌" placeholder={t(locale, "mounts.passwordPlaceholder")}
value={form.password} value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })} onChange={(e) => setForm({ ...form, password: e.target.value })}
/> />
<Input <Input
placeholder="根路径 /" placeholder={t(locale, "mounts.rootPathPlaceholder")}
value={form.rootPath} value={form.rootPath}
onChange={(e) => setForm({ ...form, rootPath: e.target.value })} onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" disabled={create.isPending}> <Button type="submit" disabled={create.isPending}>
保存 {t(locale, "mounts.save")}
</Button> </Button>
<Button <Button
type="button" type="button"
@ -86,7 +89,7 @@ function MountsInner() {
}) })
} }
> >
测试连接 {t(locale, "mounts.test")}
</Button> </Button>
</div> </div>
{test.data && ( {test.data && (
@ -111,13 +114,15 @@ function MountsInner() {
size="sm" size="sm"
onClick={() => del.mutate({ id: m.id })} onClick={() => del.mutate({ id: m.id })}
> >
删除 {t(locale, "mounts.delete")}
</Button> </Button>
</div> </div>
</li> </li>
))} ))}
{list.data?.mounts.length === 0 && ( {list.data?.mounts.length === 0 && (
<li className="p-4 text-sm text-muted-foreground">尚未添加挂载</li> <li className="p-4 text-sm text-muted-foreground">
{t(locale, "mounts.empty")}
</li>
)} )}
</ul> </ul>
</div> </div>
@ -127,11 +132,11 @@ function MountsInner() {
const client = createTRPCReactClient(); const client = createTRPCReactClient();
const qc = new QueryClient(); const qc = new QueryClient();
export function MountsBody() { export function MountsBody(props: { locale: Locale }) {
return ( return (
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<trpc.Provider client={client} queryClient={qc}> <trpc.Provider client={client} queryClient={qc}>
<MountsInner /> <MountsInner locale={props.locale} />
</trpc.Provider> </trpc.Provider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@ -9,7 +9,7 @@ import { createTRPCReactClient, trpc } from "~/lib/trpc";
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuPrefs = { enabled: boolean; opacity: number }; type DanmakuPrefs = { enabled: boolean; opacity: number; density: number };
type ProgressInput = { type ProgressInput = {
mediaItemId: string; mediaItemId: string;
@ -18,7 +18,7 @@ type ProgressInput = {
}; };
function readDanmakuPrefs(): DanmakuPrefs { function readDanmakuPrefs(): DanmakuPrefs {
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8 }; const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 };
try { try {
const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY); const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY);
if (!raw) return fallback; if (!raw) return fallback;
@ -27,7 +27,12 @@ function readDanmakuPrefs(): DanmakuPrefs {
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;
return { enabled, opacity: Math.min(1, Math.max(0, opacityRaw)) }; const densityRaw = typeof rec["density"] === "number" ? rec["density"] : fallback.density;
return {
enabled,
opacity: Math.min(1, Math.max(0, opacityRaw)),
density: Math.min(3, Math.max(0.1, densityRaw)),
};
} catch { } catch {
return fallback; return fallback;
} }
@ -67,13 +72,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const [localXml, setLocalXml] = useState(""); const [localXml, setLocalXml] = useState("");
const [danmakuOn, setDanmakuOn] = useState(true); const [danmakuOn, setDanmakuOn] = useState(true);
const [danmakuOpacity, setDanmakuOpacity] = useState(0.8); const [danmakuOpacity, setDanmakuOpacity] = useState(0.8);
const [danmakuDensity, setDanmakuDensity] = useState(1);
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId }); const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
const progress = trpc.media.playbackGet.useQuery({ mediaItemId }); const progress = trpc.media.playbackGet.useQuery({ mediaItemId });
const openDanmaku = trpc.media.danmakuFetch.useQuery( const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn });
{ mediaItemId },
{ enabled: danmakuOn },
);
const report = trpc.media.playbackReport.useMutation(); const report = trpc.media.playbackReport.useMutation();
const importMeta = trpc.media.danmakuImportMeta.useMutation(); const importMeta = trpc.media.danmakuImportMeta.useMutation();
const reportRef = useRef(report); const reportRef = useRef(report);
@ -85,6 +88,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const prefs = readDanmakuPrefs(); const prefs = readDanmakuPrefs();
setDanmakuOn(prefs.enabled); setDanmakuOn(prefs.enabled);
setDanmakuOpacity(prefs.opacity); setDanmakuOpacity(prefs.opacity);
setDanmakuDensity(prefs.density);
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -93,9 +97,10 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
} }
}, [progress.data]); }, [progress.data]);
const detailId = detail.data?.id;
useEffect(() => { useEffect(() => {
const mountEl = playerRef.current; const mountEl = playerRef.current;
if (!mountEl || !detail.data) return; if (!mountEl || !detailId) return;
const art = new Artplayer({ const art = new Artplayer({
container: mountEl, container: mountEl,
url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`, url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`,
@ -171,7 +176,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
art.destroy(false); art.destroy(false);
artRef.current = null; artRef.current = null;
}; };
}, [detail.data?.id, mediaItemId]); }, [detailId, mediaItemId]);
// 进度晚于播放器就绪时补跳一次(未开播才跳,避免打断播放) // 进度晚于播放器就绪时补跳一次(未开播才跳,避免打断播放)
useEffect(() => { useEffect(() => {
@ -184,7 +189,13 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}, [progress.data]); }, [progress.data]);
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : ""; const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : "";
const items = useMemo(() => (xmlSource ? parseDanmakuXml(xmlSource) : []), [xmlSource]); const items = useMemo(() => {
const all = xmlSource ? parseDanmakuXml(xmlSource) : [];
// density < 1 时按比例抽稀,避免同屏弹幕过密
if (danmakuDensity >= 1) return all;
const keepEvery = Math.max(1, Math.round(1 / danmakuDensity));
return all.filter((_, i) => i % keepEvery === 0);
}, [xmlSource, danmakuDensity]);
const danmakuCount = items.length; const danmakuCount = items.length;
useEffect(() => { useEffect(() => {

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Danmaku from "~/views/danmaku.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/danmaku"));
}
const locale: Locale = normalized;
---
<Danmaku locale={locale} />

View File

@ -0,0 +1,40 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import { AppShell } from "~/components/AppShell";
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const labels: Record<string, string> = {
"meta.brand": t(locale, "meta.brand"),
"nav.library": t(locale, "nav.library"),
"nav.browse": t(locale, "nav.browse"),
"nav.mounts": t(locale, "nav.mounts"),
"nav.danmaku": t(locale, "nav.danmaku"),
};
---
<AppLayout
title={t(locale, "danmaku.title")}
locale={locale}
nav={[]}
hideSideRail
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "danmaku.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<AppShell locale={locale} active="danmaku" labels={labels}>
<DanmakuSettingsBody locale={locale} client:load />
</AppShell>
<Toaster client:load />
</AppLayout>

View File

@ -34,7 +34,7 @@ const labels: Record<string, string> = {
}} }}
> >
<AppShell locale={locale} active="mounts" labels={labels}> <AppShell locale={locale} active="mounts" labels={labels}>
<MountsBody client:load /> <MountsBody locale={locale} client:load />
</AppShell> </AppShell>
<Toaster client:load /> <Toaster client:load />
</AppLayout> </AppLayout>

View File

@ -69,6 +69,15 @@ export const en: Record<MessageKey, string> = {
"mounts.add": "Add WebDAV", "mounts.add": "Add WebDAV",
"mounts.test": "Test connection", "mounts.test": "Test connection",
"mounts.openlistHint": "For OpenList, use its WebDAV URL (e.g. https://host/dav)", "mounts.openlistHint": "For OpenList, use its WebDAV URL (e.g. https://host/dav)",
"mounts.credentialHint": "Credentials are stored on the server only.",
"mounts.namePlaceholder": "Name, e.g. OpenList",
"mounts.baseUrlPlaceholder": "https://example.com/dav",
"mounts.usernamePlaceholder": "Username (optional)",
"mounts.passwordPlaceholder": "Password / token",
"mounts.rootPathPlaceholder": "Root path /",
"mounts.save": "Save",
"mounts.delete": "Delete",
"mounts.empty": "No mounts yet",
"watch.title": "Watch", "watch.title": "Watch",
"watch.loading": "Loading…", "watch.loading": "Loading…",
@ -92,6 +101,12 @@ export const en: Record<MessageKey, string> = {
"danmaku.opacity": "Opacity", "danmaku.opacity": "Opacity",
"danmaku.density": "Density", "danmaku.density": "Density",
"danmaku.enabled": "Enable danmaku", "danmaku.enabled": "Enable danmaku",
"danmaku.opacityValue": "Opacity {value}%",
"danmaku.densityValue": "Density {value}x",
"danmaku.save": "Save",
"danmaku.saved": "Saved (local session)",
"danmaku.localImportHint":
"Import local XML on the watch page; this saves the default toggle and display options.",
"auth.login.title": "Sign in", "auth.login.title": "Sign in",
"auth.login.heading": "Sign in", "auth.login.heading": "Sign in",

View File

@ -70,6 +70,15 @@ export const zhCN = {
"mounts.add": "添加 WebDAV", "mounts.add": "添加 WebDAV",
"mounts.test": "测试连接", "mounts.test": "测试连接",
"mounts.openlistHint": "OpenList 请填写其 WebDAV 地址(如 https://host/dav)", "mounts.openlistHint": "OpenList 请填写其 WebDAV 地址(如 https://host/dav)",
"mounts.credentialHint": "凭据仅保存在服务端。",
"mounts.namePlaceholder": "名称,如 OpenList",
"mounts.baseUrlPlaceholder": "https://example.com/dav",
"mounts.usernamePlaceholder": "用户名(可选)",
"mounts.passwordPlaceholder": "密码 / 令牌",
"mounts.rootPathPlaceholder": "根路径 /",
"mounts.save": "保存",
"mounts.delete": "删除",
"mounts.empty": "尚未添加挂载",
"watch.title": "播放", "watch.title": "播放",
"watch.loading": "加载中…", "watch.loading": "加载中…",
@ -93,6 +102,11 @@ export const zhCN = {
"danmaku.opacity": "不透明度", "danmaku.opacity": "不透明度",
"danmaku.density": "密度", "danmaku.density": "密度",
"danmaku.enabled": "启用弹幕", "danmaku.enabled": "启用弹幕",
"danmaku.opacityValue": "不透明度 {value}%",
"danmaku.densityValue": "密度 {value}x",
"danmaku.save": "保存",
"danmaku.saved": "已保存(本地会话)",
"danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。",
"auth.login.title": "登录", "auth.login.title": "登录",
"auth.login.heading": "登录", "auth.login.heading": "登录",