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 { 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,14 +199,58 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
return stop;
|
return stop;
|
||||||
}, [items, danmakuOpacity]);
|
}, [items, danmakuOpacity]);
|
||||||
|
|
||||||
function onImportXml(file: File) {
|
const onImportXml = useCallback(
|
||||||
const reader = new FileReader();
|
(file: File) => {
|
||||||
reader.onload = () => {
|
const reader = new FileReader();
|
||||||
const text = String(reader.result ?? "");
|
reader.onload = () => {
|
||||||
setLocalXml(text);
|
const text = String(reader.result ?? "");
|
||||||
importMeta.mutate({ mediaItemId, byteSize: file.size });
|
setLocalXml(text);
|
||||||
};
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": "不透明度",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue