import type { Locale } from "@app/i18n"; import { t } from "@app/i18n"; import { parseEpisodeFromFilename } from "@app/types"; import { Button, Input, toast } from "@app/ui"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Artplayer from "artplayer"; import artplayerPluginDanmuku from "artplayer-plugin-danmuku"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { filterComments, parseDanmakuXml, thinDanmaku, toDanmukuItems, type FilterOptions, } from "~/lib/danmaku-render"; import { go, queryParam } from "~/lib/nav"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { cn } from "~/lib/utils"; const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; type DanmakuPrefs = { enabled: boolean; opacity: number; density: number }; type DanmukuPlugin = { load: (danmuku: unknown) => Promise | unknown; show: () => unknown; hide: () => unknown; config: (option: { opacity?: number }) => unknown; }; function danmukuPlugin(art: Artplayer): DanmukuPlugin | null { const plugins = art.plugins as Record; const dm = plugins["artplayerPluginDanmuku"]; if (!dm || typeof dm !== "object") return null; return dm as DanmukuPlugin; } function formatTime(sec: number): string { const s = Math.max(0, Math.floor(sec)); const m = Math.floor(s / 60); const r = s % 60; return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`; } type ProgressInput = { mediaItemId: string; positionMs: number; durationMs: number; }; /** 集数:落库值优先,null 时用共享正则从文件名解析(与后端同一套) */ function epNumberOf(ep: { epNumber: number | null; rawName: string }): number | null { return ep.epNumber ?? parseEpisodeFromFilename(ep.rawName); } function readDanmakuPrefs(): DanmakuPrefs { const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 }; 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; const densityRaw = typeof rec["density"] === "number" ? rec["density"] : fallback.density; return { enabled, opacity: Math.min(1, Math.max(0, opacityRaw)), density: Math.min(1, Math.max(0.1, densityRaw)), }; } catch { return fallback; } } function writeDanmakuPrefs(prefs: DanmakuPrefs) { try { localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(prefs)); } catch { /* noop */ } } const DANMAKU_XML_PREFIX = "media-danmaku-xml:"; /** 单条弹幕 XML 本地持久化上限(localStorage 常规配额约 5MB,留余量) */ const DANMAKU_XML_MAX_BYTES = 2 * 1024 * 1024; function readLocalXml(mediaItemId: string): string { try { return localStorage.getItem(DANMAKU_XML_PREFIX + mediaItemId) ?? ""; } catch { return ""; } } function writeLocalXml(mediaItemId: string, xml: string) { try { if (new Blob([xml]).size > DANMAKU_XML_MAX_BYTES) return; localStorage.setItem(DANMAKU_XML_PREFIX + mediaItemId, xml); } catch { /* 配额不足时仅内存生效 */ } } function clearLocalXml(mediaItemId: string) { try { localStorage.removeItem(DANMAKU_XML_PREFIX + mediaItemId); } catch { /* noop */ } } /** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */ function sendProgressBeacon(input: ProgressInput) { const url = "/api/trpc/media.playbackReport?batch=1"; const body = JSON.stringify({ 0: input }); try { if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") { const ok = navigator.sendBeacon(url, new Blob([body], { type: "application/json" })); if (ok) return; } } catch { /* fall through */ } try { void fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true, credentials: "same-origin", }); } catch { /* noop */ } } function WatchInner(props: { locale: Locale; mediaItemId: string }) { const { locale, mediaItemId } = props; const playerRef = useRef(null); const artRef = useRef(null); const progressStartRef = useRef(0); const [currentTime, setCurrentTime] = useState(0); const prefsRef = useRef(readDanmakuPrefs()); const [localXml, setLocalXml] = useState(""); /** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */ const [matchXml, setMatchXml] = useState(""); const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled); const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density); const [ended, setEnded] = useState(false); const [playError, setPlayError] = useState(false); const [matchOpen, setMatchOpen] = useState(false); const [kwInput, setKwInput] = useState(""); const [filterDraft, setFilterDraft] = useState(null); // 进入/切换条目时恢复本地导入的弹幕 useEffect(() => { setLocalXml(readLocalXml(mediaItemId)); setMatchXml(""); setFilterDraft(null); setMatchOpen(false); setPlayError(false); setEnded(false); setSourceKeyword(""); setSourceQuery(""); setSourceAnimeId(null); }, [mediaItemId]); 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 prefs = trpc.media.danmakuGetSettings.useQuery(); const report = trpc.media.playbackReport.useMutation(); const importXml = trpc.media.danmakuImport.useMutation(); const clearLocal = trpc.media.danmakuClearLocal.useMutation(); const matchMut = trpc.media.danmakuMatch.useMutation(); const selectMut = trpc.media.danmakuSelectMatch.useMutation(); // 手动指定弹幕源:作品 → 集数 const [sourceKeyword, setSourceKeyword] = useState(""); const [sourceQuery, setSourceQuery] = useState(""); const [sourceAnimeId, setSourceAnimeId] = useState(null); const sourceSearch = trpc.media.danmakuSearchSource.useQuery( { keyword: sourceQuery }, { enabled: matchOpen && sourceQuery.trim().length > 0 }, ); const sourceEpisodes = trpc.media.danmakuSourceEpisodes.useQuery( { animeId: sourceAnimeId ?? 0 }, { enabled: matchOpen && sourceAnimeId != null }, ); const reportRef = useRef(report); useEffect(() => { reportRef.current = report; }); const bangumiId = detail.data?.bangumiId ?? null; const series = trpc.media.librarySeries.useQuery( { bangumiId: bangumiId ?? 0 }, { enabled: bangumiId != null && bangumiId > 0 }, ); const episodes = useMemo(() => { const list = series.data?.episodes ?? []; return [...list].sort((a, b) => (epNumberOf(a) ?? 0) - (epNumberOf(b) ?? 0)); }, [series.data]); const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId); const prevEp = epIndex > 0 ? episodes[epIndex - 1] : null; const nextEp = epIndex >= 0 && epIndex < episodes.length - 1 ? episodes[epIndex + 1] : null; const openEpisode = useCallback( (id: string) => { const g = bangumiId != null ? `&from=series&g=${bangumiId}` : ""; go(`/${locale}/watch?id=${id}${g}`); }, [locale, bangumiId], ); const goSeries = useCallback(() => { const g = queryParam("g") ?? (bangumiId != null ? String(bangumiId) : null); if (g != null) go(`/${locale}/library?g=${g}`); else go(`/${locale}/library`); }, [locale, bangumiId]); useEffect(() => { const prefs = readDanmakuPrefs(); prefsRef.current = prefs; setDanmakuOn(prefs.enabled); setDanmakuDensity(prefs.density); }, []); // 服务端 prefs 到达后覆盖显示参数;localStorage 仅作 query 失败回退 useEffect(() => { const data = prefs.data; if (!data) return; const next: DanmakuPrefs = { enabled: data.enabled, opacity: Math.min(1, Math.max(0, data.opacity)), density: Math.min(1, Math.max(0.1, data.density)), }; prefsRef.current = next; setDanmakuOn(next.enabled); setDanmakuDensity(next.density); const art = artRef.current; const dm = art ? danmukuPlugin(art) : null; if (dm) dm.config({ opacity: next.opacity }); }, [prefs.data]); useEffect(() => { if (progress.data) { progressStartRef.current = progress.data.positionMs; } }, [progress.data]); const detailId = detail.data?.id; useEffect(() => { const mountEl = playerRef.current; if (!mountEl || !detailId) return; const prefs = prefsRef.current; 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: "#FB7299", plugins: [ artplayerPluginDanmuku({ danmuku: [], opacity: prefs.opacity, fontSize: 25, speed: 5, margin: [10, "25%"], color: "#FFFFFF", mode: 0, antiOverlap: true, visible: prefs.enabled, emitter: true, }), ], }); artRef.current = art; // 常用快捷键(ArtPlayer 默认不含 f/m,补注册) art.hotkey.add("KeyF", () => { art.fullscreen = !art.fullscreen; }); art.hotkey.add("KeyM", () => { art.muted = !art.muted; }); // 播放失败 → 业务级错误卡(含重试);加载成功后清除 const onVideoError = () => { setPlayError(true); }; const onVideoReady = () => { setPlayError(false); }; art.on("video:error", onVideoError); art.on("video:loadeddata", onVideoReady); art.on("playing", onVideoReady); // 播放器内弹幕设置变更 → 回写本地默认偏好 art.on("artplayerPluginDanmuku:config", (...args: unknown[]) => { const option = (args[0] ?? {}) as { opacity?: number; visible?: boolean }; const prev = prefsRef.current; const next: DanmakuPrefs = { enabled: option.visible ?? prev.enabled, opacity: typeof option.opacity === "number" ? option.opacity : prev.opacity, density: prev.density, }; prefsRef.current = next; writeDanmakuPrefs(next); }); const seekToStart = () => { const target = progressStartRef.current / 1000; if (target > 0 && art.duration > target) { art.currentTime = target; } }; art.on("video:loadedmetadata", seekToStart); const onTimeUpdate = () => { setCurrentTime(art.currentTime || 0); }; art.on("video:timeupdate", onTimeUpdate); const snapshot = (): ProgressInput | null => { if (!art.duration) return null; return { mediaItemId, positionMs: Math.floor((art.currentTime || 0) * 1000), durationMs: Math.floor((art.duration || 0) * 1000), }; }; const reportNow = () => { const input = snapshot(); if (input) reportRef.current.mutate(input); }; let unloaded = false; const flushUnload = () => { if (unloaded) return; const input = snapshot(); if (!input) return; unloaded = true; reportRef.current.mutate(input); sendProgressBeacon(input); }; let timer: ReturnType | null = null; // video:play / video:pause 是原生事件转发:覆盖 UI 按钮与系统媒体键/程序化暂停 art.on("video:play", () => { setEnded(false); if (timer) clearInterval(timer); timer = setInterval(reportNow, 5000); }); art.on("video:pause", () => { if (timer) { clearInterval(timer); timer = null; } reportNow(); }); art.on("video:ended", () => { if (timer) { clearInterval(timer); timer = null; } reportNow(); setEnded(true); }); const onVisibility = () => { if (document.visibilityState === "hidden") flushUnload(); }; window.addEventListener("pagehide", flushUnload); document.addEventListener("visibilitychange", onVisibility); return () => { window.removeEventListener("pagehide", flushUnload); document.removeEventListener("visibilitychange", onVisibility); if (timer) clearInterval(timer); flushUnload(); art.destroy(false); artRef.current = null; }; }, [detailId, 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 || matchXml || openDanmaku.data?.xml || "" : ""; const listComments = useMemo(() => { const raw = xmlSource ? parseDanmakuXml(xmlSource) : []; raw.sort((a, b) => a.time - b.time); const filters: FilterOptions | null = filterDraft ? filterDraft : prefs.data ? { blockKeywords: prefs.data.blockKeywords, blockTypes: prefs.data.blockTypes, } : null; return filters ? filterComments(raw, filters) : raw; }, [xmlSource, prefs.data, filterDraft]); const items = useMemo(() => { return toDanmukuItems(thinDanmaku(listComments, danmakuDensity)); }, [listComments, danmakuDensity]); const danmakuCount = items.length; // 弹幕数据变化时交给官方插件渲染 useEffect(() => { const art = artRef.current; if (!art) return; const dm = danmukuPlugin(art); if (!dm) return; void dm.load(items); }, [items]); // 外部开关同步到插件显隐 useEffect(() => { const art = artRef.current; if (!art) return; const dm = danmukuPlugin(art); if (!dm) return; if (danmakuOn) dm.show(); else dm.hide(); }, [danmakuOn]); const onImportXml = useCallback( (file: File) => { const reader = new FileReader(); reader.onload = () => { const text = String(reader.result ?? ""); importXml.mutate( { mediaItemId, xml: text, fileName: file.name }, { onSuccess: () => { setLocalXml(text); writeLocalXml(mediaItemId, text); setMatchXml(""); toast.success(t(locale, "watch.importSaved")); }, onError: () => toast.error(t(locale, "watch.importFail")), }, ); }; reader.readAsText(file); }, [importXml, mediaItemId, locale], ); const openRematch = useCallback(() => { setMatchOpen(true); matchMut.mutate( { mediaItemId }, { onError: () => { setMatchOpen(false); toast.error(t(locale, "watch.matchFailed")); }, }, ); }, [matchMut, mediaItemId, locale]); const onSelectCandidate = useCallback( (episodeId: number) => { selectMut.mutate( { mediaItemId, episodeId }, { onSuccess: (data) => { // 手选成功即离开本地源:清内存与 localStorage,避免再粘滞 if (data.ok) { setLocalXml(""); clearLocalXml(mediaItemId); } if (data.ok && data.xml) { setMatchOpen(false); setMatchXml(data.xml); void openDanmaku.refetch(); toast.success(t(locale, "watch.matchManual")); return; } toast.error(data.message ?? t(locale, "watch.matchFailed")); }, onError: () => toast.error(t(locale, "watch.matchFailed")), }, ); }, [selectMut, mediaItemId, locale, openDanmaku], ); const activeFilters: FilterOptions | null = filterDraft ? filterDraft : prefs.data ? { blockKeywords: prefs.data.blockKeywords, blockTypes: prefs.data.blockTypes, } : null; const addBlockKeyword = useCallback(() => { const kw = kwInput.trim(); if (!kw) return; setFilterDraft((prev) => { const base: FilterOptions = prev ?? activeFilters ?? { blockKeywords: [], blockTypes: [] }; if (base.blockKeywords.includes(kw)) return base; return { ...base, blockKeywords: [...base.blockKeywords, kw] }; }); setKwInput(""); }, [kwInput, activeFilters]); const removeBlockKeyword = useCallback( (kw: string) => { setFilterDraft((prev) => { const base: FilterOptions = prev ?? activeFilters ?? { blockKeywords: [], blockTypes: [] }; return { ...base, blockKeywords: base.blockKeywords.filter((k) => k !== kw) }; }); }, [activeFilters], ); const toggleBlockType = useCallback( (kind: "scroll" | "top" | "bottom") => { setFilterDraft((prev) => { const base: FilterOptions = prev ?? activeFilters ?? { blockKeywords: [], blockTypes: [], }; const blockTypes = base.blockTypes.includes(kind) ? base.blockTypes.filter((k) => k !== kind) : [...base.blockTypes, kind]; return { ...base, blockTypes }; }); }, [activeFilters], ); const replay = useCallback(() => { const art = artRef.current; if (!art) return; setEnded(false); art.currentTime = 0; void art.play(); }, []); const retryStream = useCallback(() => { const art = artRef.current; if (!art) return; setPlayError(false); art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`); }, [mediaItemId]); const goLibrary = useCallback(() => { go(`/${locale}/library`); }, [locale]); if (detail.isError) { return (

{t(locale, "watch.loadError")}

{t(locale, "watch.loadErrorHint")}

); } if (!detail.data) { if (detail.isPending) { return (

{t(locale, "watch.loading")}

); } return (

{t(locale, "watch.notFound")}

{t(locale, "watch.notFoundHint")}

); } const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml"; const fetchMessage = openDanmaku.data?.message ?? ""; // 本地源也可重新匹配:手选后切回开放网络(服务端/本地 local-xml 同步清除) const showRematch = danmakuOn && (isLocalSource || (openDanmaku.data?.source === "none" && (fetchMessage.includes("手选") || fetchMessage.includes("未匹配")))); const candidates = matchMut.data?.candidates ?? []; const mediaTitle = detail.data.title; const sameNameEpisodes = new Set( candidates .filter( (c) => c.animeTitle != null && c.animeTitle.length > 0 && (mediaTitle.includes(c.animeTitle) || c.animeTitle.includes(mediaTitle)), ) .map((c) => c.episodeId), ); const sourceLabel = !danmakuOn ? localXml || isLocalSource ? t(locale, "watch.danmakuDisabledHint") : t(locale, "watch.danmakuOff") : isLocalSource ? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}` : matchXml ? t(locale, "watch.danmakuOpenNetwork") : openDanmaku.data?.reason === "need-login" ? t(locale, "watch.needDdpLogin") : openDanmaku.data?.reason === "no-app-cred" ? t(locale, "watch.noAppCred") : openDanmaku.data?.reason === "no-match" ? t(locale, "watch.noDanmakuImport") : 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}

{bangumiId != null && ( )}
{playError && (

{t(locale, "watch.playFailed")}

{t(locale, "watch.playFailedHint")}

)}
{sourceLabel} {isLocalSource && fetchMessage.includes("同名") && ( {t(locale, "watch.sidecarLinked")} )} {showRematch && ( )} {(openDanmaku.data?.reason === "need-login" || openDanmaku.data?.reason === "no-app-cred") && ( )} {isLocalSource && ( )} {t(locale, "watch.progressHint")}

{t(locale, "watch.filterTitle")}

{t(locale, "danmaku.blockKeywords")}

{(activeFilters?.blockKeywords ?? []).map((kw) => ( {kw} ))} setKwInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addBlockKeyword(); } }} />

{t(locale, "danmaku.blockTypes")}

{( [ ["scroll", "danmaku.blockTypeScroll"], ["top", "danmaku.blockTypeTop"], ["bottom", "danmaku.blockTypeBottom"], ] as const ).map(([kind, labelKey]) => { const blocked = activeFilters?.blockTypes.includes(kind) ?? false; return ( ); })}
{matchOpen && (

{t(locale, "watch.matchCandidates")}

{t(locale, "watch.fileMatch")}

{matchMut.data?.highConfidence && (

{t(locale, "watch.matchAutoBound")}

)} {matchMut.isPending && (

{t(locale, "watch.loading")}

)} {matchMut.isError && (

{t(locale, "watch.matchFailed")}

)} {matchMut.isSuccess && candidates.length === 0 && (

{t(locale, "watch.matchEmpty")}

)} {candidates.length > 0 && (
    {candidates.map((c) => (
  • ))}
)}

{t(locale, "watch.manualPick")}

{t(locale, "watch.manualPickHint")}

setSourceKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); setSourceAnimeId(null); setSourceQuery(sourceKeyword.trim()); } }} />
{sourceSearch.isPending && (

{t(locale, "watch.loading")}

)} {sourceSearch.isError && (

{t(locale, "watch.searchFailed")}

)} {sourceSearch.isSuccess && sourceQuery && sourceSearch.data.animes.length === 0 && (

{t(locale, "watch.searchEmpty")}

)} {sourceAnimeId == null && (sourceSearch.data?.animes.length ?? 0) > 0 && (
    {(sourceSearch.data?.animes ?? []).map((a) => (
  • ))}
)} {sourceAnimeId != null && (
{sourceEpisodes.data?.animeTitle ?? `#${sourceAnimeId}`}
{sourceEpisodes.isPending && (

{t(locale, "watch.loading")}

)} {sourceEpisodes.isError && (

{t(locale, "watch.searchFailed")}

)} {sourceEpisodes.isSuccess && sourceEpisodes.data.episodes.length === 0 && (

{t(locale, "watch.searchEmpty")}

)} {sourceEpisodes.data && sourceEpisodes.data.episodes.length > 0 && (
    {sourceEpisodes.data.episodes.map((ep) => (
  • ))}
)}
)}
)} {episodes.length > 0 && (

{t(locale, "watch.episodes")}

    {episodes.map((ep) => { const current = ep.id === mediaItemId; const epNo = epNumberOf(ep); return (
  • ); })}
)} {ended && nextEp && (

{t(locale, "watch.episodeN", { n: epNumberOf(nextEp) ?? episodes.indexOf(nextEp) + 1, })}

)} {ended && !nextEp && (

{t(locale, "watch.playbackFinished")}

)}
); } const client = createTRPCReactClient(); export function WatchBody(props: { locale: Locale; mediaItemId: string }) { const [qc] = useState(() => new QueryClient()); return ( ); }