diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index 1fc479d..0b966cd 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -3,7 +3,7 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render"; import { createTRPCReactClient, trpc } from "~/lib/trpc"; @@ -11,6 +11,12 @@ const DANMAKU_SETTINGS_KEY = "media-danmaku-settings"; type DanmakuPrefs = { enabled: boolean; opacity: number }; +type ProgressInput = { + mediaItemId: string; + positionMs: number; + durationMs: number; +}; + function readDanmakuPrefs(): DanmakuPrefs { const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8 }; try { @@ -27,6 +33,31 @@ function readDanmakuPrefs(): DanmakuPrefs { } } +/** 页面卸载时 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 wrapperRef = useRef(null); @@ -87,12 +118,23 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { }; art.on("video:loadedmetadata", seekToStart); - const reportNow = () => { - reportRef.current.mutate({ + 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); + }; + const flushUnload = () => { + const input = snapshot(); + if (!input) return; + reportRef.current.mutate(input); + sendProgressBeacon(input); }; let timer: ReturnType | null = null; @@ -115,8 +157,17 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { reportNow(); }); + 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; }; @@ -148,14 +199,58 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { 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 onImportXml = useCallback( + (file: File) => { + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result ?? ""); + setLocalXml(text); + importMeta.mutate({ mediaItemId, byteSize: file.size }); + }; + reader.readAsText(file); + }, + [importMeta, mediaItemId], + ); + + const goLibrary = useCallback(() => { + window.location.href = `/${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 sourceLabel = !danmakuOn @@ -173,9 +268,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { return (
-

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

+

{detail.data.title}

diff --git a/packages/i18n/src/messages/en.ts b/packages/i18n/src/messages/en.ts index 884141e..7e4b3c1 100644 --- a/packages/i18n/src/messages/en.ts +++ b/packages/i18n/src/messages/en.ts @@ -83,6 +83,10 @@ export const en: Record = { "watch.danmakuCount": "{count} danmaku", "watch.backToLibrary": "Back to library", "watch.progressHint": "Progress saved every ~5s", + "watch.loadError": "Failed to load", + "watch.loadErrorHint": "Could not load this media. Check the link or try again later.", + "watch.notFound": "Media not found", + "watch.notFoundHint": "This media may have been removed, or you do not have access.", "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 c26f4ee..95fd764 100644 --- a/packages/i18n/src/messages/zh-CN.ts +++ b/packages/i18n/src/messages/zh-CN.ts @@ -84,6 +84,10 @@ export const zhCN = { "watch.danmakuCount": "弹幕 {count} 条", "watch.backToLibrary": "返回媒体库", "watch.progressHint": "进度约每 5 秒保存", + "watch.loadError": "加载失败", + "watch.loadErrorHint": "无法加载该媒体,请检查链接或稍后重试。", + "watch.notFound": "媒体不存在", + "watch.notFoundHint": "该媒体可能已被删除,或你没有访问权限。", "danmaku.title": "弹幕设置", "danmaku.opacity": "不透明度",