feat(web): artplayer watch page with danmaku overlay and progress
This commit is contained in:
parent
c7c6e30a56
commit
426a65e448
|
|
@ -24,6 +24,7 @@
|
||||||
"@tanstack/react-query": "^5.75.0",
|
"@tanstack/react-query": "^5.75.0",
|
||||||
"@trpc/client": "^11.19.0",
|
"@trpc/client": "^11.19.0",
|
||||||
"@trpc/react-query": "^11.19.0",
|
"@trpc/react-query": "^11.19.0",
|
||||||
|
"artplayer": "^5.2.2",
|
||||||
"astro": "^7.3.3",
|
"astro": "^7.3.3",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"lenis": "^1.3.26",
|
"lenis": "^1.3.26",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const NAV = [
|
||||||
|
|
||||||
export function AppShell(props: {
|
export function AppShell(props: {
|
||||||
locale: string;
|
locale: string;
|
||||||
active: (typeof NAV)[number]["id"];
|
active: (typeof NAV)[number]["id"] | "watch";
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
import type { Locale } from "@app/i18n";
|
||||||
|
import { t } from "@app/i18n";
|
||||||
|
import { Button } from "@app/ui";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import Artplayer from "artplayer";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render";
|
||||||
|
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||||||
|
|
||||||
|
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
||||||
|
|
||||||
|
type DanmakuPrefs = { enabled: boolean; opacity: number };
|
||||||
|
|
||||||
|
function readDanmakuPrefs(): DanmakuPrefs {
|
||||||
|
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8 };
|
||||||
|
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;
|
||||||
|
return { enabled, opacity: Math.min(1, Math.max(0, opacityRaw)) };
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
|
const { locale, mediaItemId } = props;
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const playerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const artRef = useRef<Artplayer | null>(null);
|
||||||
|
const progressStartRef = useRef(0);
|
||||||
|
const [localXml, setLocalXml] = useState("");
|
||||||
|
const [danmakuOn, setDanmakuOn] = useState(true);
|
||||||
|
const [danmakuOpacity, setDanmakuOpacity] = useState(0.8);
|
||||||
|
|
||||||
|
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 report = trpc.media.playbackReport.useMutation();
|
||||||
|
const importMeta = trpc.media.danmakuImportMeta.useMutation();
|
||||||
|
const reportRef = useRef(report);
|
||||||
|
useEffect(() => {
|
||||||
|
reportRef.current = report;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prefs = readDanmakuPrefs();
|
||||||
|
setDanmakuOn(prefs.enabled);
|
||||||
|
setDanmakuOpacity(prefs.opacity);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (progress.data) {
|
||||||
|
progressStartRef.current = progress.data.positionMs;
|
||||||
|
}
|
||||||
|
}, [progress.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mountEl = playerRef.current;
|
||||||
|
if (!mountEl || !detail.data) return;
|
||||||
|
const art = new Artplayer({
|
||||||
|
container: mountEl,
|
||||||
|
url: `/api/stream?id=${encodeURIComponent(mediaItemId)}`,
|
||||||
|
volume: 0.7,
|
||||||
|
autoplay: false,
|
||||||
|
pip: true,
|
||||||
|
setting: true,
|
||||||
|
playbackRate: true,
|
||||||
|
aspectRatio: true,
|
||||||
|
fullscreen: true,
|
||||||
|
theme: "#3b82f6",
|
||||||
|
});
|
||||||
|
artRef.current = art;
|
||||||
|
|
||||||
|
const seekToStart = () => {
|
||||||
|
const target = progressStartRef.current / 1000;
|
||||||
|
if (target > 0 && art.duration > target) {
|
||||||
|
art.currentTime = target;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
art.on("video:loadedmetadata", seekToStart);
|
||||||
|
|
||||||
|
const reportNow = () => {
|
||||||
|
reportRef.current.mutate({
|
||||||
|
mediaItemId,
|
||||||
|
positionMs: Math.floor((art.currentTime || 0) * 1000),
|
||||||
|
durationMs: Math.floor((art.duration || 0) * 1000),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
art.on("play", () => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = setInterval(reportNow, 5000);
|
||||||
|
});
|
||||||
|
art.on("pause", () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
reportNow();
|
||||||
|
});
|
||||||
|
art.on("video:ended", () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
reportNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
art.destroy(false);
|
||||||
|
artRef.current = null;
|
||||||
|
};
|
||||||
|
}, [detail.data?.id, mediaItemId]);
|
||||||
|
|
||||||
|
// 进度晚于播放器就绪时补跳一次(未开播才跳,避免打断播放)
|
||||||
|
useEffect(() => {
|
||||||
|
const art = artRef.current;
|
||||||
|
if (!art || !progress.data) return;
|
||||||
|
const target = progress.data.positionMs / 1000;
|
||||||
|
if (target > 0 && art.duration > target && !art.playing) {
|
||||||
|
art.currentTime = target;
|
||||||
|
}
|
||||||
|
}, [progress.data]);
|
||||||
|
|
||||||
|
const xmlSource = danmakuOn ? localXml || openDanmaku.data?.xml || "" : "";
|
||||||
|
const items = useMemo(() => (xmlSource ? parseDanmakuXml(xmlSource) : []), [xmlSource]);
|
||||||
|
const danmakuCount = items.length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!wrapper || items.length === 0) return;
|
||||||
|
const stop = startDanmakuLoop(
|
||||||
|
wrapper,
|
||||||
|
() => items,
|
||||||
|
() => artRef.current?.currentTime ?? 0,
|
||||||
|
{ opacity: danmakuOpacity },
|
||||||
|
);
|
||||||
|
return stop;
|
||||||
|
}, [items, danmakuOpacity]);
|
||||||
|
|
||||||
|
function onImportXml(file: File) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const text = String(reader.result ?? "");
|
||||||
|
setLocalXml(text);
|
||||||
|
importMeta.mutate({ mediaItemId, byteSize: file.size });
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = !danmakuOn
|
||||||
|
? t(locale, "watch.danmakuOff")
|
||||||
|
: localXml
|
||||||
|
? t(locale, "watch.danmakuCount", { count: danmakuCount })
|
||||||
|
: openDanmaku.data?.source === "none"
|
||||||
|
? t(locale, "watch.noDanmakuImport")
|
||||||
|
: openDanmaku.data?.source === "cache"
|
||||||
|
? t(locale, "watch.danmakuCache")
|
||||||
|
: openDanmaku.data?.source === "open-network"
|
||||||
|
? t(locale, "watch.danmakuOpenNetwork")
|
||||||
|
: t(locale, "watch.danmakuLoading");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{detail.data?.title ?? t(locale, "watch.loading")}
|
||||||
|
</h1>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={danmakuOn ? "default" : "outline"}
|
||||||
|
onClick={() => setDanmakuOn((v) => !v)}
|
||||||
|
>
|
||||||
|
{danmakuOn ? t(locale, "watch.danmakuOn") : t(locale, "watch.danmakuOff")}
|
||||||
|
</Button>
|
||||||
|
<label className="inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-3 text-sm">
|
||||||
|
{t(locale, "watch.importXml")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".xml,text/xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) onImportXml(f);
|
||||||
|
e.currentTarget.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = `/${locale}/library`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(locale, "watch.backToLibrary")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className="relative aspect-video w-full overflow-hidden rounded-lg bg-black"
|
||||||
|
>
|
||||||
|
<div ref={playerRef} className="absolute inset-0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm text-muted-foreground">
|
||||||
|
<span>{sourceLabel}</span>
|
||||||
|
<span>{t(locale, "watch.progressHint")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createTRPCReactClient();
|
||||||
|
const qc = new QueryClient();
|
||||||
|
|
||||||
|
export function WatchBody(props: { locale: Locale; mediaItemId: string }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<trpc.Provider client={client} queryClient={qc}>
|
||||||
|
<WatchInner locale={props.locale} mediaItemId={props.mediaItemId} />
|
||||||
|
</trpc.Provider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
export type DanmakuItem = { time: number; text: string; mode: number };
|
||||||
|
|
||||||
|
export function parseDanmakuXml(xml: string): DanmakuItem[] {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||||
|
const nodes = Array.from(doc.querySelectorAll("d"));
|
||||||
|
const out: DanmakuItem[] = [];
|
||||||
|
for (const n of nodes) {
|
||||||
|
const p = n.getAttribute("p") ?? "";
|
||||||
|
const [t, mode] = p.split(",");
|
||||||
|
const time = Number(t);
|
||||||
|
if (!Number.isFinite(time)) continue;
|
||||||
|
out.push({ time, text: n.textContent ?? "", mode: Number(mode) || 1 });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DanmakuLoopOptions = {
|
||||||
|
opacity?: number | undefined;
|
||||||
|
/** 每秒横向位移 px */
|
||||||
|
speed?: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startDanmakuLoop(
|
||||||
|
container: HTMLElement,
|
||||||
|
getItems: () => DanmakuItem[],
|
||||||
|
getCurrentTime: () => number,
|
||||||
|
options?: DanmakuLoopOptions,
|
||||||
|
): () => void {
|
||||||
|
const layer = document.createElement("div");
|
||||||
|
layer.className = "absolute inset-0 overflow-hidden pointer-events-none";
|
||||||
|
layer.style.position = "absolute";
|
||||||
|
layer.style.zIndex = "5";
|
||||||
|
const opacity = options?.opacity;
|
||||||
|
if (typeof opacity === "number") {
|
||||||
|
layer.style.opacity = String(Math.min(1, Math.max(0, opacity)));
|
||||||
|
}
|
||||||
|
container.style.position = container.style.position || "relative";
|
||||||
|
container.appendChild(layer);
|
||||||
|
|
||||||
|
const active: { el: HTMLDivElement; start: number }[] = [];
|
||||||
|
const speed = options?.speed ?? 160;
|
||||||
|
let raf = 0;
|
||||||
|
let cursor = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
|
||||||
|
const clearActive = () => {
|
||||||
|
for (const a of active) a.el.remove();
|
||||||
|
active.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const now = getCurrentTime();
|
||||||
|
const items = getItems();
|
||||||
|
// 进度回跳时重置游标,避免一次性涌出全部历史弹幕
|
||||||
|
if (now + 0.5 < lastTime) {
|
||||||
|
cursor = 0;
|
||||||
|
clearActive();
|
||||||
|
}
|
||||||
|
lastTime = now;
|
||||||
|
while (cursor < items.length) {
|
||||||
|
const it = items[cursor];
|
||||||
|
if (!it || it.time > now) break;
|
||||||
|
cursor += 1;
|
||||||
|
// 快进/加载导致的过期弹幕直接丢弃,防止一帧内刷屏
|
||||||
|
if (it.time < now - 2) continue;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.textContent = it.text;
|
||||||
|
el.style.cssText =
|
||||||
|
"position:absolute;left:100%;white-space:nowrap;color:#fff;font-size:22px;text-shadow:0 1px 2px #000;will-change:transform;";
|
||||||
|
const maxY = Math.max(0, container.clientHeight - 40);
|
||||||
|
el.style.top = `${Math.random() * maxY}px`;
|
||||||
|
layer.appendChild(el);
|
||||||
|
active.push({ el, start: performance.now() });
|
||||||
|
}
|
||||||
|
const t = performance.now();
|
||||||
|
for (let i = active.length - 1; i >= 0; i -= 1) {
|
||||||
|
const a = active[i];
|
||||||
|
if (!a) continue;
|
||||||
|
const elapsed = (t - a.start) / 1000;
|
||||||
|
const x = container.clientWidth - elapsed * speed;
|
||||||
|
a.el.style.transform = `translateX(${x - container.clientWidth}px)`;
|
||||||
|
if (x < -a.el.offsetWidth) {
|
||||||
|
a.el.remove();
|
||||||
|
active.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
clearActive();
|
||||||
|
layer.remove();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
import {
|
||||||
|
COOKIE_LANG,
|
||||||
|
isLocale,
|
||||||
|
localePath,
|
||||||
|
resolvePreferredLocale,
|
||||||
|
type Locale,
|
||||||
|
} from "@app/i18n";
|
||||||
|
import Watch from "~/views/watch.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, "/watch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale: Locale = normalized;
|
||||||
|
const mediaItemId = Astro.url.searchParams.get("id") ?? "";
|
||||||
|
if (!mediaItemId) {
|
||||||
|
return Astro.redirect(localePath(locale, "/library"));
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<Watch locale={locale} mediaItemId={mediaItemId} />
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
import { alternatePath, type Locale, t } from "@app/i18n";
|
||||||
|
import { Toaster } from "@app/ui";
|
||||||
|
import { AppShell } from "~/components/AppShell";
|
||||||
|
import { WatchBody } from "~/components/pages/WatchBody";
|
||||||
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
locale: Locale;
|
||||||
|
mediaItemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { locale, mediaItemId } = 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, "watch.title")}
|
||||||
|
locale={locale}
|
||||||
|
nav={[]}
|
||||||
|
hideSideRail
|
||||||
|
switchHref={alternatePath(Astro.url.pathname, other)}
|
||||||
|
switchLabel={t(other, `locale.${other}` as never)}
|
||||||
|
seo={{
|
||||||
|
title: `${t(locale, "watch.title")} · ${t(locale, "meta.brand")}`,
|
||||||
|
path: Astro.url.pathname,
|
||||||
|
robots: ["noindex", "nofollow"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppShell locale={locale} active="watch" labels={labels}>
|
||||||
|
<WatchBody locale={locale} mediaItemId={mediaItemId} client:load />
|
||||||
|
</AppShell>
|
||||||
|
<Toaster client:load />
|
||||||
|
</AppLayout>
|
||||||
|
|
@ -70,9 +70,19 @@ export const en: Record<MessageKey, string> = {
|
||||||
"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)",
|
||||||
|
|
||||||
|
"watch.title": "Watch",
|
||||||
|
"watch.loading": "Loading…",
|
||||||
"watch.danmakuOn": "Danmaku on",
|
"watch.danmakuOn": "Danmaku on",
|
||||||
|
"watch.danmakuOff": "Danmaku off",
|
||||||
"watch.importXml": "Import local XML",
|
"watch.importXml": "Import local XML",
|
||||||
"watch.noDanmaku": "No online danmaku yet",
|
"watch.noDanmaku": "No online danmaku yet",
|
||||||
|
"watch.noDanmakuImport": "No online danmaku yet; import an XML",
|
||||||
|
"watch.danmakuCache": "Danmaku (cache)",
|
||||||
|
"watch.danmakuOpenNetwork": "Danmaku (open network)",
|
||||||
|
"watch.danmakuLoading": "Loading danmaku…",
|
||||||
|
"watch.danmakuCount": "{count} danmaku",
|
||||||
|
"watch.backToLibrary": "Back to library",
|
||||||
|
"watch.progressHint": "Progress saved every ~5s",
|
||||||
|
|
||||||
"danmaku.title": "Danmaku settings",
|
"danmaku.title": "Danmaku settings",
|
||||||
"danmaku.opacity": "Opacity",
|
"danmaku.opacity": "Opacity",
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,19 @@ export const zhCN = {
|
||||||
"mounts.test": "测试连接",
|
"mounts.test": "测试连接",
|
||||||
"mounts.openlistHint": "OpenList 请填写其 WebDAV 地址(如 https://host/dav)",
|
"mounts.openlistHint": "OpenList 请填写其 WebDAV 地址(如 https://host/dav)",
|
||||||
|
|
||||||
|
"watch.title": "播放",
|
||||||
|
"watch.loading": "加载中…",
|
||||||
"watch.danmakuOn": "弹幕开",
|
"watch.danmakuOn": "弹幕开",
|
||||||
|
"watch.danmakuOff": "弹幕关",
|
||||||
"watch.importXml": "导入本地 XML",
|
"watch.importXml": "导入本地 XML",
|
||||||
"watch.noDanmaku": "暂无在线弹幕",
|
"watch.noDanmaku": "暂无在线弹幕",
|
||||||
|
"watch.noDanmakuImport": "暂无在线弹幕,可导入 XML",
|
||||||
|
"watch.danmakuCache": "弹幕(缓存)",
|
||||||
|
"watch.danmakuOpenNetwork": "弹幕(开放网络)",
|
||||||
|
"watch.danmakuLoading": "弹幕加载中…",
|
||||||
|
"watch.danmakuCount": "弹幕 {count} 条",
|
||||||
|
"watch.backToLibrary": "返回媒体库",
|
||||||
|
"watch.progressHint": "进度约每 5 秒保存",
|
||||||
|
|
||||||
"danmaku.title": "弹幕设置",
|
"danmaku.title": "弹幕设置",
|
||||||
"danmaku.opacity": "不透明度",
|
"danmaku.opacity": "不透明度",
|
||||||
|
|
|
||||||
26
yarn.lock
26
yarn.lock
|
|
@ -175,6 +175,7 @@ __metadata:
|
||||||
"@trpc/server": "npm:^11.19.0"
|
"@trpc/server": "npm:^11.19.0"
|
||||||
"@types/react": "npm:^19.1.4"
|
"@types/react": "npm:^19.1.4"
|
||||||
"@types/react-dom": "npm:^19.1.5"
|
"@types/react-dom": "npm:^19.1.5"
|
||||||
|
artplayer: "npm:^5.2.2"
|
||||||
astro: "npm:^7.3.3"
|
astro: "npm:^7.3.3"
|
||||||
gsap: "npm:^3.15.0"
|
gsap: "npm:^3.15.0"
|
||||||
lenis: "npm:^1.3.26"
|
lenis: "npm:^1.3.26"
|
||||||
|
|
@ -4518,6 +4519,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"artplayer@npm:^5.2.2":
|
||||||
|
version: 5.4.0
|
||||||
|
resolution: "artplayer@npm:5.4.0"
|
||||||
|
dependencies:
|
||||||
|
option-validator: "npm:^2.0.6"
|
||||||
|
checksum: 10c0/022e661a5757d5986e043aa112ee6a28369750559c69dc99e9b12c9d9c26605af3437e8a379aa7583abfafd4ae313c3ba7011c3d93ce0a85fccdaf548f67eb95
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"assertion-error@npm:^2.0.1":
|
"assertion-error@npm:^2.0.1":
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
resolution: "assertion-error@npm:2.0.1"
|
resolution: "assertion-error@npm:2.0.1"
|
||||||
|
|
@ -6521,6 +6531,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"kind-of@npm:^6.0.3":
|
||||||
|
version: 6.0.3
|
||||||
|
resolution: "kind-of@npm:6.0.3"
|
||||||
|
checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"kleur@npm:^4.1.5":
|
"kleur@npm:^4.1.5":
|
||||||
version: 4.1.5
|
version: 4.1.5
|
||||||
resolution: "kleur@npm:4.1.5"
|
resolution: "kleur@npm:4.1.5"
|
||||||
|
|
@ -7437,6 +7454,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"option-validator@npm:^2.0.6":
|
||||||
|
version: 2.0.6
|
||||||
|
resolution: "option-validator@npm:2.0.6"
|
||||||
|
dependencies:
|
||||||
|
kind-of: "npm:^6.0.3"
|
||||||
|
checksum: 10c0/5a78f864e0aab0d104ef456b03c7be55dafe00c83708a9ab9466cbd3bec51503eb29372039c218e06d407f04a045b3e6b30984601df06f459b22b6167135bf87
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"p-limit@npm:^4.0.0":
|
"p-limit@npm:^4.0.0":
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
resolution: "p-limit@npm:4.0.0"
|
resolution: "p-limit@npm:4.0.0"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue