237 lines
8.6 KiB
TypeScript
237 lines
8.6 KiB
TypeScript
import type { Locale } from "@app/i18n";
|
|
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 { parseDanmakuXml, startDanmakuLoop } from "~/lib/danmaku-render";
|
|
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
|
|
|
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
|
|
|
type DanmakuPrefs = { enabled: boolean; opacity: number };
|
|
|
|
function readDanmakuPrefs(): DanmakuPrefs {
|
|
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8 };
|
|
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<string, unknown>;
|
|
const enabled = typeof rec["enabled"] === "boolean" ? rec["enabled"] : fallback.enabled;
|
|
const opacityRaw = typeof rec["opacity"] === "number" ? rec["opacity"] : fallback.opacity;
|
|
return { enabled, opacity: Math.min(1, Math.max(0, opacityRaw)) };
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
|
const { locale, mediaItemId } = props;
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
const playerRef = useRef<HTMLDivElement>(null);
|
|
const artRef = useRef<Artplayer | null>(null);
|
|
const progressStartRef = useRef(0);
|
|
const [localXml, setLocalXml] = useState("");
|
|
const [danmakuOn, setDanmakuOn] = useState(true);
|
|
const [danmakuOpacity, setDanmakuOpacity] = useState(0.8);
|
|
|
|
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 report = trpc.media.playbackReport.useMutation();
|
|
const importMeta = trpc.media.danmakuImportMeta.useMutation();
|
|
const reportRef = useRef(report);
|
|
useEffect(() => {
|
|
reportRef.current = report;
|
|
});
|
|
|
|
useEffect(() => {
|
|
const prefs = readDanmakuPrefs();
|
|
setDanmakuOn(prefs.enabled);
|
|
setDanmakuOpacity(prefs.opacity);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (progress.data) {
|
|
progressStartRef.current = progress.data.positionMs;
|
|
}
|
|
}, [progress.data]);
|
|
|
|
useEffect(() => {
|
|
const mountEl = playerRef.current;
|
|
if (!mountEl || !detail.data) return;
|
|
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: "#3b82f6",
|
|
});
|
|
artRef.current = art;
|
|
|
|
const seekToStart = () => {
|
|
const target = progressStartRef.current / 1000;
|
|
if (target > 0 && art.duration > target) {
|
|
art.currentTime = target;
|
|
}
|
|
};
|
|
art.on("video:loadedmetadata", seekToStart);
|
|
|
|
const reportNow = () => {
|
|
reportRef.current.mutate({
|
|
mediaItemId,
|
|
positionMs: Math.floor((art.currentTime || 0) * 1000),
|
|
durationMs: Math.floor((art.duration || 0) * 1000),
|
|
});
|
|
};
|
|
|
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
art.on("play", () => {
|
|
if (timer) clearInterval(timer);
|
|
timer = setInterval(reportNow, 5000);
|
|
});
|
|
art.on("pause", () => {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
reportNow();
|
|
});
|
|
art.on("video:ended", () => {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
reportNow();
|
|
});
|
|
|
|
return () => {
|
|
if (timer) clearInterval(timer);
|
|
art.destroy(false);
|
|
artRef.current = null;
|
|
};
|
|
}, [detail.data?.id, 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 || openDanmaku.data?.xml || "" : "";
|
|
const items = useMemo(() => (xmlSource ? parseDanmakuXml(xmlSource) : []), [xmlSource]);
|
|
const danmakuCount = items.length;
|
|
|
|
useEffect(() => {
|
|
const wrapper = wrapperRef.current;
|
|
if (!wrapper || items.length === 0) return;
|
|
const stop = startDanmakuLoop(
|
|
wrapper,
|
|
() => items,
|
|
() => artRef.current?.currentTime ?? 0,
|
|
{ opacity: danmakuOpacity },
|
|
);
|
|
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 sourceLabel = !danmakuOn
|
|
? t(locale, "watch.danmakuOff")
|
|
: localXml
|
|
? t(locale, "watch.danmakuCount", { count: danmakuCount })
|
|
: 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 (
|
|
<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>
|
|
<div className="ml-auto flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant={danmakuOn ? "default" : "outline"}
|
|
onClick={() => setDanmakuOn((v) => !v)}
|
|
>
|
|
{danmakuOn ? t(locale, "watch.danmakuOn") : t(locale, "watch.danmakuOff")}
|
|
</Button>
|
|
<label className="inline-flex h-9 cursor-pointer items-center rounded-md border border-border px-3 text-sm">
|
|
{t(locale, "watch.importXml")}
|
|
<input
|
|
type="file"
|
|
accept=".xml,text/xml"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const f = e.target.files?.[0];
|
|
if (f) onImportXml(f);
|
|
e.currentTarget.value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
window.location.href = `/${locale}/library`;
|
|
}}
|
|
>
|
|
{t(locale, "watch.backToLibrary")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
ref={wrapperRef}
|
|
className="relative aspect-video w-full overflow-hidden rounded-lg bg-black"
|
|
>
|
|
<div ref={playerRef} className="absolute inset-0" />
|
|
</div>
|
|
<div className="flex justify-between text-sm text-muted-foreground">
|
|
<span>{sourceLabel}</span>
|
|
<span>{t(locale, "watch.progressHint")}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const client = createTRPCReactClient();
|
|
const qc = new QueryClient();
|
|
|
|
export function WatchBody(props: { locale: Locale; mediaItemId: string }) {
|
|
return (
|
|
<QueryClientProvider client={qc}>
|
|
<trpc.Provider client={client} queryClient={qc}>
|
|
<WatchInner locale={props.locale} mediaItemId={props.mediaItemId} />
|
|
</trpc.Provider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|