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