fix(web): watch error states and unload progress flush

This commit is contained in:
noelorin 2026-09-25 05:15:56 +08:00
parent 426a65e448
commit 8ecfee686c
3 changed files with 117 additions and 22 deletions

View File

@ -3,7 +3,7 @@ import { t } from "@app/i18n";
import { Button } from "@app/ui"; import { Button } from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Artplayer from "artplayer"; 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 { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render";
import { createTRPCReactClient, trpc } from "~/lib/trpc"; import { createTRPCReactClient, trpc } from "~/lib/trpc";
@ -11,6 +11,12 @@ const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
type DanmakuPrefs = { enabled: boolean; opacity: number }; type DanmakuPrefs = { enabled: boolean; opacity: number };
type ProgressInput = {
mediaItemId: string;
positionMs: number;
durationMs: number;
};
function readDanmakuPrefs(): DanmakuPrefs { function readDanmakuPrefs(): DanmakuPrefs {
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8 }; const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8 };
try { 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 }) { function WatchInner(props: { locale: Locale; mediaItemId: string }) {
const { locale, mediaItemId } = props; const { locale, mediaItemId } = props;
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
@ -87,12 +118,23 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}; };
art.on("video:loadedmetadata", seekToStart); art.on("video:loadedmetadata", seekToStart);
const reportNow = () => { const snapshot = (): ProgressInput | null => {
reportRef.current.mutate({ if (!art.duration) return null;
return {
mediaItemId, mediaItemId,
positionMs: Math.floor((art.currentTime || 0) * 1000), positionMs: Math.floor((art.currentTime || 0) * 1000),
durationMs: Math.floor((art.duration || 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<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
@ -115,8 +157,17 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
reportNow(); reportNow();
}); });
const onVisibility = () => {
if (document.visibilityState === "hidden") flushUnload();
};
window.addEventListener("pagehide", flushUnload);
document.addEventListener("visibilitychange", onVisibility);
return () => { return () => {
window.removeEventListener("pagehide", flushUnload);
document.removeEventListener("visibilitychange", onVisibility);
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
flushUnload();
art.destroy(false); art.destroy(false);
artRef.current = null; artRef.current = null;
}; };
@ -148,7 +199,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
return stop; return stop;
}, [items, danmakuOpacity]); }, [items, danmakuOpacity]);
function onImportXml(file: File) { const onImportXml = useCallback(
(file: File) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const text = String(reader.result ?? ""); const text = String(reader.result ?? "");
@ -156,6 +208,49 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
importMeta.mutate({ mediaItemId, byteSize: file.size }); importMeta.mutate({ mediaItemId, byteSize: file.size });
}; };
reader.readAsText(file); reader.readAsText(file);
},
[importMeta, mediaItemId],
);
const goLibrary = useCallback(() => {
window.location.href = `/${locale}/library`;
}, [locale]);
if (detail.isError) {
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold">{t(locale, "watch.loadError")}</h1>
<p className="text-sm text-muted-foreground">{t(locale, "watch.loadErrorHint")}</p>
<div className="flex gap-2">
<Button size="sm" onClick={() => detail.refetch()}>
{t(locale, "error.retry")}
</Button>
<Button size="sm" variant="ghost" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
</div>
</div>
);
}
if (!detail.data) {
if (detail.isPending) {
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold">{t(locale, "watch.loading")}</h1>
<div className="aspect-video w-full animate-pulse rounded-lg bg-muted" />
</div>
);
}
return (
<div className="space-y-3">
<h1 className="text-xl font-semibold">{t(locale, "watch.notFound")}</h1>
<p className="text-sm text-muted-foreground">{t(locale, "watch.notFoundHint")}</p>
<Button size="sm" variant="ghost" onClick={goLibrary}>
{t(locale, "watch.backToLibrary")}
</Button>
</div>
);
} }
const sourceLabel = !danmakuOn const sourceLabel = !danmakuOn
@ -173,9 +268,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold"> <h1 className="text-xl font-semibold">{detail.data.title}</h1>
{detail.data?.title ?? t(locale, "watch.loading")}
</h1>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<Button <Button
size="sm" size="sm"
@ -197,13 +290,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
}} }}
/> />
</label> </label>
<Button <Button size="sm" variant="ghost" onClick={goLibrary}>
size="sm"
variant="ghost"
onClick={() => {
window.location.href = `/${locale}/library`;
}}
>
{t(locale, "watch.backToLibrary")} {t(locale, "watch.backToLibrary")}
</Button> </Button>
</div> </div>

View File

@ -83,6 +83,10 @@ export const en: Record<MessageKey, string> = {
"watch.danmakuCount": "{count} danmaku", "watch.danmakuCount": "{count} danmaku",
"watch.backToLibrary": "Back to library", "watch.backToLibrary": "Back to library",
"watch.progressHint": "Progress saved every ~5s", "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.title": "Danmaku settings",
"danmaku.opacity": "Opacity", "danmaku.opacity": "Opacity",

View File

@ -84,6 +84,10 @@ export const zhCN = {
"watch.danmakuCount": "弹幕 {count} 条", "watch.danmakuCount": "弹幕 {count} 条",
"watch.backToLibrary": "返回媒体库", "watch.backToLibrary": "返回媒体库",
"watch.progressHint": "进度约每 5 秒保存", "watch.progressHint": "进度约每 5 秒保存",
"watch.loadError": "加载失败",
"watch.loadErrorHint": "无法加载该媒体,请检查链接或稍后重试。",
"watch.notFound": "媒体不存在",
"watch.notFoundHint": "该媒体可能已被删除,或你没有访问权限。",
"danmaku.title": "弹幕设置", "danmaku.title": "弹幕设置",
"danmaku.opacity": "不透明度", "danmaku.opacity": "不透明度",