feat(web): artplayer watch page with danmaku overlay and progress

This commit is contained in:
noelorin 2026-09-25 05:04:44 +08:00
parent c7c6e30a56
commit 426a65e448
9 changed files with 449 additions and 1 deletions

View File

@ -24,6 +24,7 @@
"@tanstack/react-query": "^5.75.0",
"@trpc/client": "^11.19.0",
"@trpc/react-query": "^11.19.0",
"artplayer": "^5.2.2",
"astro": "^7.3.3",
"gsap": "^3.15.0",
"lenis": "^1.3.26",

View File

@ -10,7 +10,7 @@ const NAV = [
export function AppShell(props: {
locale: string;
active: (typeof NAV)[number]["id"];
active: (typeof NAV)[number]["id"] | "watch";
labels: Record<string, string>;
children: ReactNode;
}) {

View File

@ -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>
);
}

View File

@ -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();
};
}

View File

@ -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} />

View File

@ -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>

View File

@ -70,9 +70,19 @@ export const en: Record<MessageKey, string> = {
"mounts.test": "Test connection",
"mounts.openlistHint": "For OpenList, use its WebDAV URL (e.g. https://host/dav)",
"watch.title": "Watch",
"watch.loading": "Loading…",
"watch.danmakuOn": "Danmaku on",
"watch.danmakuOff": "Danmaku off",
"watch.importXml": "Import local XML",
"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.opacity": "Opacity",

View File

@ -71,9 +71,19 @@ export const zhCN = {
"mounts.test": "测试连接",
"mounts.openlistHint": "OpenList 请填写其 WebDAV 地址(如 https://host/dav)",
"watch.title": "播放",
"watch.loading": "加载中…",
"watch.danmakuOn": "弹幕开",
"watch.danmakuOff": "弹幕关",
"watch.importXml": "导入本地 XML",
"watch.noDanmaku": "暂无在线弹幕",
"watch.noDanmakuImport": "暂无在线弹幕,可导入 XML",
"watch.danmakuCache": "弹幕(缓存)",
"watch.danmakuOpenNetwork": "弹幕(开放网络)",
"watch.danmakuLoading": "弹幕加载中…",
"watch.danmakuCount": "弹幕 {count} 条",
"watch.backToLibrary": "返回媒体库",
"watch.progressHint": "进度约每 5 秒保存",
"danmaku.title": "弹幕设置",
"danmaku.opacity": "不透明度",

View File

@ -175,6 +175,7 @@ __metadata:
"@trpc/server": "npm:^11.19.0"
"@types/react": "npm:^19.1.4"
"@types/react-dom": "npm:^19.1.5"
artplayer: "npm:^5.2.2"
astro: "npm:^7.3.3"
gsap: "npm:^3.15.0"
lenis: "npm:^1.3.26"
@ -4518,6 +4519,15 @@ __metadata:
languageName: node
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":
version: 2.0.1
resolution: "assertion-error@npm:2.0.1"
@ -6521,6 +6531,13 @@ __metadata:
languageName: node
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":
version: 4.1.5
resolution: "kleur@npm:4.1.5"
@ -7437,6 +7454,15 @@ __metadata:
languageName: node
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":
version: 4.0.0
resolution: "p-limit@npm:4.0.0"