From 426a65e448a76677138a25cc559c65e1aaa30e85 Mon Sep 17 00:00:00 2001 From: noelorin Date: Fri, 25 Sep 2026 05:04:44 +0800 Subject: [PATCH] feat(web): artplayer watch page with danmaku overlay and progress --- app/web/package.json | 1 + app/web/src/components/AppShell.tsx | 2 +- app/web/src/components/pages/WatchBody.tsx | 236 +++++++++++++++++++++ app/web/src/lib/danmaku-render.ts | 95 +++++++++ app/web/src/pages/[locale]/watch.astro | 29 +++ app/web/src/views/watch.astro | 41 ++++ packages/i18n/src/messages/en.ts | 10 + packages/i18n/src/messages/zh-CN.ts | 10 + yarn.lock | 26 +++ 9 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 app/web/src/components/pages/WatchBody.tsx create mode 100644 app/web/src/lib/danmaku-render.ts create mode 100644 app/web/src/pages/[locale]/watch.astro create mode 100644 app/web/src/views/watch.astro diff --git a/app/web/package.json b/app/web/package.json index 9594974..a148fe4 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -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", diff --git a/app/web/src/components/AppShell.tsx b/app/web/src/components/AppShell.tsx index eafc275..7af2238 100644 --- a/app/web/src/components/AppShell.tsx +++ b/app/web/src/components/AppShell.tsx @@ -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; children: ReactNode; }) { diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx new file mode 100644 index 0000000..1fc479d --- /dev/null +++ b/app/web/src/components/pages/WatchBody.tsx @@ -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; + 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(null); + const playerRef = useRef(null); + const artRef = useRef(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 | 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 ( +
+
+

+ {detail.data?.title ?? t(locale, "watch.loading")} +

+
+ + + +
+
+
+
+
+
+ {sourceLabel} + {t(locale, "watch.progressHint")} +
+
+ ); +} + +const client = createTRPCReactClient(); +const qc = new QueryClient(); + +export function WatchBody(props: { locale: Locale; mediaItemId: string }) { + return ( + + + + + + ); +} diff --git a/app/web/src/lib/danmaku-render.ts b/app/web/src/lib/danmaku-render.ts new file mode 100644 index 0000000..59f777b --- /dev/null +++ b/app/web/src/lib/danmaku-render.ts @@ -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(); + }; +} diff --git a/app/web/src/pages/[locale]/watch.astro b/app/web/src/pages/[locale]/watch.astro new file mode 100644 index 0000000..d0ffa11 --- /dev/null +++ b/app/web/src/pages/[locale]/watch.astro @@ -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")); +} +--- + + diff --git a/app/web/src/views/watch.astro b/app/web/src/views/watch.astro new file mode 100644 index 0000000..723a353 --- /dev/null +++ b/app/web/src/views/watch.astro @@ -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 = { + "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/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index 6b90894..884141e 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -70,9 +70,19 @@ export const en: Record = { "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", diff --git a/packages/i18n/src/messages/zh-CN.ts b/packages/i18n/src/messages/zh-CN.ts index 645e334..c26f4ee 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -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": "不透明度", diff --git a/yarn.lock b/yarn.lock index 247e93f..bf46035 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"