app-template/app/web/src/components/pages/WatchBody.tsx

237 lines
8.6 KiB
TypeScript

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