From f96afbc4b594af94e4acfca531cfb455f7c1d209 Mon Sep 17 00:00:00 2001 From: noelorin Date: Fri, 25 Sep 2026 05:29:28 +0800 Subject: [PATCH] feat(web): danmaku settings page + watch local prefs --- .../components/pages/DanmakuSettingsBody.tsx | 109 ++++++++++++++++++ app/web/src/components/pages/MountsBody.tsx | 33 +++--- app/web/src/components/pages/WatchBody.tsx | 31 +++-- app/web/src/pages/[locale]/danmaku.astro | 19 +++ app/web/src/views/danmaku.astro | 40 +++++++ app/web/src/views/mounts.astro | 2 +- packages/i18n/src/messages/en.ts | 15 +++ packages/i18n/src/messages/zh-CN.ts | 14 +++ 8 files changed, 238 insertions(+), 25 deletions(-) create mode 100644 app/web/src/components/pages/DanmakuSettingsBody.tsx create mode 100644 app/web/src/pages/[locale]/danmaku.astro create mode 100644 app/web/src/views/danmaku.astro diff --git a/app/web/src/components/pages/DanmakuSettingsBody.tsx b/app/web/src/components/pages/DanmakuSettingsBody.tsx new file mode 100644 index 0000000..aafb513 --- /dev/null +++ b/app/web/src/components/pages/DanmakuSettingsBody.tsx @@ -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; + 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 ( +
+

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

+ + + + + {save.isSuccess && ( +

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

+ )} +

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

+
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function DanmakuSettingsBody(props: { locale: Locale }) { + return ( + + + + + + ); +} diff --git a/app/web/src/components/pages/MountsBody.tsx b/app/web/src/components/pages/MountsBody.tsx index a0911d5..e440779 100644 --- a/app/web/src/components/pages/MountsBody.tsx +++ b/app/web/src/components/pages/MountsBody.tsx @@ -1,9 +1,12 @@ +import type { Locale } from "@app/i18n"; +import { t } from "@app/i18n"; import { Button, Input } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; -function MountsInner() { +function MountsInner(props: { locale: Locale }) { + const { locale } = props; const utils = trpc.useUtils(); const list = trpc.media.mountList.useQuery(); const create = trpc.media.mountCreate.useMutation({ @@ -23,9 +26,9 @@ function MountsInner() { return (
-

挂载管理

+

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

- OpenList 请填写其 WebDAV 地址;凭据仅保存在服务端。 + {t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}

setForm({ ...form, name: e.target.value })} required /> setForm({ ...form, baseUrl: e.target.value })} required /> setForm({ ...form, username: e.target.value })} /> setForm({ ...form, password: e.target.value })} /> setForm({ ...form, rootPath: e.target.value })} />
{test.data && ( @@ -111,13 +114,15 @@ function MountsInner() { size="sm" onClick={() => del.mutate({ id: m.id })} > - 删除 + {t(locale, "mounts.delete")}
))} {list.data?.mounts.length === 0 && ( -
  • 尚未添加挂载
  • +
  • + {t(locale, "mounts.empty")} +
  • )} @@ -127,11 +132,11 @@ function MountsInner() { const client = createTRPCReactClient(); const qc = new QueryClient(); -export function MountsBody() { +export function MountsBody(props: { locale: Locale }) { return ( - + ); diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 0b966cd..fd9ab43 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -9,7 +9,7 @@ import { createTRPCReactClient, trpc } from "~/lib/trpc"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; -type DanmakuPrefs = { enabled: boolean; opacity: number }; +type DanmakuPrefs = { enabled: boolean; opacity: number; density: number }; type ProgressInput = { mediaItemId: string; @@ -18,7 +18,7 @@ type ProgressInput = { }; function readDanmakuPrefs(): DanmakuPrefs { - const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8 }; + const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 }; try { const raw = localStorage.getItem(DANMAKU_SETTINGS_KEY); if (!raw) return fallback; @@ -27,7 +27,12 @@ function readDanmakuPrefs(): DanmakuPrefs { 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; - 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 { return fallback; } @@ -67,13 +72,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const [localXml, setLocalXml] = useState(""); const [danmakuOn, setDanmakuOn] = useState(true); const [danmakuOpacity, setDanmakuOpacity] = useState(0.8); + const [danmakuDensity, setDanmakuDensity] = useState(1); const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId }); const progress = trpc.media.playbackGet.useQuery({ mediaItemId }); - const openDanmaku = trpc.media.danmakuFetch.useQuery( - { mediaItemId }, - { enabled: danmakuOn }, - ); + const openDanmaku = trpc.media.danmakuFetch.useQuery({ mediaItemId }, { enabled: danmakuOn }); const report = trpc.media.playbackReport.useMutation(); const importMeta = trpc.media.danmakuImportMeta.useMutation(); const reportRef = useRef(report); @@ -85,6 +88,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const prefs = readDanmakuPrefs(); setDanmakuOn(prefs.enabled); setDanmakuOpacity(prefs.opacity); + setDanmakuDensity(prefs.density); }, []); useEffect(() => { @@ -93,9 +97,10 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { } }, [progress.data]); + const detailId = detail.data?.id; useEffect(() => { const mountEl = playerRef.current; - if (!mountEl || !detail.data) return; + if (!mountEl || !detailId) return; const art = new Artplayer({ container: mountEl, url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`, @@ -171,7 +176,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { art.destroy(false); artRef.current = null; }; - }, [detail.data?.id, mediaItemId]); + }, [detailId, mediaItemId]); // 进度晚于播放器就绪时补跳一次(未开播才跳,避免打断播放) useEffect(() => { @@ -184,7 +189,13 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { }, [progress.data]); 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; useEffect(() => { diff --git a/app/web/src/pages/[locale]/danmaku.astro b/app/web/src/pages/[locale]/danmaku.astro new file mode 100644 index 0000000..72a8513 --- /dev/null +++ b/app/web/src/pages/[locale]/danmaku.astro @@ -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; +--- + + diff --git a/app/web/src/views/danmaku.astro b/app/web/src/views/danmaku.astro new file mode 100644 index 0000000..245b4b9 --- /dev/null +++ b/app/web/src/views/danmaku.astro @@ -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 = { + "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"), +}; +--- + + + + + + + diff --git a/app/web/src/views/mounts.astro b/app/web/src/views/mounts.astro index c4b3ab7..f26de78 100644 --- a/app/web/src/views/mounts.astro +++ b/app/web/src/views/mounts.astro @@ -34,7 +34,7 @@ const labels: Record = { }} > - + diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index 7e4b3c1..db21124 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -69,6 +69,15 @@ export const en: Record = { "mounts.add": "Add WebDAV", "mounts.test": "Test connection", "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.loading": "Loading…", @@ -92,6 +101,12 @@ export const en: Record = { "danmaku.opacity": "Opacity", "danmaku.density": "Density", "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.heading": "Sign in", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 95fd764..e88a77c 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -70,6 +70,15 @@ export const zhCN = { "mounts.add": "添加 WebDAV", "mounts.test": "测试连接", "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.loading": "加载中…", @@ -93,6 +102,11 @@ export const zhCN = { "danmaku.opacity": "不透明度", "danmaku.density": "密度", "danmaku.enabled": "启用弹幕", + "danmaku.opacityValue": "不透明度 {value}%", + "danmaku.densityValue": "密度 {value}x", + "danmaku.save": "保存", + "danmaku.saved": "已保存(本地会话)", + "danmaku.localImportHint": "本地 XML 导入在播放页操作;此处保存默认开关与显示参数。", "auth.login.title": "登录", "auth.login.heading": "登录",