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",
|
||||
"@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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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.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",
|
||||
|
|
|
|||
|
|
@ -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": "不透明度",
|
||||
|
|
|
|||
26
yarn.lock
26
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue