fix(web): watch error states and unload progress flush
This commit is contained in:
parent
426a65e448
commit
8ecfee686c
|
|
@ -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<HTMLDivElement>(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<typeof setInterval> | 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 (
|
||||
<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
|
||||
|
|
@ -173,9 +268,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
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>
|
||||
<h1 className="text-xl font-semibold">{detail.data.title}</h1>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -197,13 +290,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|||
}}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
window.location.href = `/${locale}/library`;
|
||||
}}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={goLibrary}>
|
||||
{t(locale, "watch.backToLibrary")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export const en: Record<MessageKey, string> = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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": "不透明度",
|
||||
|
|
|
|||
Loading…
Reference in New Issue