feat(web): danmaku settings page + watch local prefs
This commit is contained in:
parent
8ecfee686c
commit
f96afbc4b5
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<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">
|
||||
OpenList 请填写其 WebDAV 地址;凭据仅保存在服务端。
|
||||
{t(locale, "mounts.openlistHint")} {t(locale, "mounts.credentialHint")}
|
||||
</p>
|
||||
<form
|
||||
className="grid gap-3 rounded-lg border border-border bg-card p-4 md:grid-cols-2"
|
||||
|
|
@ -43,36 +46,36 @@ function MountsInner() {
|
|||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="名称,如 OpenList"
|
||||
placeholder={t(locale, "mounts.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="https://example.com/dav"
|
||||
placeholder={t(locale, "mounts.baseUrlPlaceholder")}
|
||||
value={form.baseUrl}
|
||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户名(可选)"
|
||||
placeholder={t(locale, "mounts.usernamePlaceholder")}
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码 / 令牌"
|
||||
placeholder={t(locale, "mounts.passwordPlaceholder")}
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="根路径 /"
|
||||
placeholder={t(locale, "mounts.rootPathPlaceholder")}
|
||||
value={form.rootPath}
|
||||
onChange={(e) => setForm({ ...form, rootPath: e.target.value })}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
保存
|
||||
{t(locale, "mounts.save")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -86,7 +89,7 @@ function MountsInner() {
|
|||
})
|
||||
}
|
||||
>
|
||||
测试连接
|
||||
{t(locale, "mounts.test")}
|
||||
</Button>
|
||||
</div>
|
||||
{test.data && (
|
||||
|
|
@ -111,13 +114,15 @@ function MountsInner() {
|
|||
size="sm"
|
||||
onClick={() => del.mutate({ id: m.id })}
|
||||
>
|
||||
删除
|
||||
{t(locale, "mounts.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{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>
|
||||
</div>
|
||||
|
|
@ -127,11 +132,11 @@ function MountsInner() {
|
|||
const client = createTRPCReactClient();
|
||||
const qc = new QueryClient();
|
||||
|
||||
export function MountsBody() {
|
||||
export function MountsBody(props: { locale: Locale }) {
|
||||
return (
|
||||
<QueryClientProvider client={qc}>
|
||||
<trpc.Provider client={client} queryClient={qc}>
|
||||
<MountsInner />
|
||||
<MountsInner locale={props.locale} />
|
||||
</trpc.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
@ -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>
|
||||
|
|
@ -34,7 +34,7 @@ const labels: Record<string, string> = {
|
|||
}}
|
||||
>
|
||||
<AppShell locale={locale} active="mounts" labels={labels}>
|
||||
<MountsBody client:load />
|
||||
<MountsBody locale={locale} client:load />
|
||||
</AppShell>
|
||||
<Toaster client:load />
|
||||
</AppLayout>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,15 @@ export const en: Record<MessageKey, string> = {
|
|||
"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<MessageKey, string> = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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": "登录",
|
||||
|
|
|
|||
Loading…
Reference in New Issue