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 { 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "登录",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue