1194 lines
55 KiB
TypeScript
1194 lines
55 KiB
TypeScript
import type { Locale } from "@app/i18n";
|
||
import { t } from "@app/i18n";
|
||
import { parseEpisodeFromFilename } from "@app/types";
|
||
import { Button, Input, toast } from "@app/ui";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import Artplayer from "artplayer";
|
||
import artplayerPluginDanmuku from "artplayer-plugin-danmuku";
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import {
|
||
filterComments,
|
||
parseDanmakuXml,
|
||
thinDanmaku,
|
||
toDanmukuItems,
|
||
type FilterOptions,
|
||
} from "~/lib/danmaku-render";
|
||
import { go, queryParam } from "~/lib/nav";
|
||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||
import { cn } from "~/lib/utils";
|
||
|
||
const DANMAKU_SETTINGS_KEY = "media-danmaku-settings";
|
||
|
||
type DanmakuPrefs = { enabled: boolean; opacity: number; density: number };
|
||
|
||
type DanmukuPlugin = {
|
||
load: (danmuku: unknown) => Promise<unknown> | unknown;
|
||
show: () => unknown;
|
||
hide: () => unknown;
|
||
config: (option: { opacity?: number }) => unknown;
|
||
};
|
||
|
||
function danmukuPlugin(art: Artplayer): DanmukuPlugin | null {
|
||
const plugins = art.plugins as Record<string, unknown>;
|
||
const dm = plugins["artplayerPluginDanmuku"];
|
||
if (!dm || typeof dm !== "object") return null;
|
||
return dm as DanmukuPlugin;
|
||
}
|
||
|
||
function formatTime(sec: number): string {
|
||
const s = Math.max(0, Math.floor(sec));
|
||
const m = Math.floor(s / 60);
|
||
const r = s % 60;
|
||
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`;
|
||
}
|
||
|
||
type ProgressInput = {
|
||
mediaItemId: string;
|
||
positionMs: number;
|
||
durationMs: number;
|
||
};
|
||
|
||
/** 集数:落库值优先,null 时用共享正则从文件名解析(与后端同一套) */
|
||
function epNumberOf(ep: { epNumber: number | null; rawName: string }): number | null {
|
||
return ep.epNumber ?? parseEpisodeFromFilename(ep.rawName);
|
||
}
|
||
|
||
function readDanmakuPrefs(): DanmakuPrefs {
|
||
const fallback: DanmakuPrefs = { enabled: true, opacity: 0.8, density: 1 };
|
||
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;
|
||
const densityRaw = typeof rec["density"] === "number" ? rec["density"] : fallback.density;
|
||
return {
|
||
enabled,
|
||
opacity: Math.min(1, Math.max(0, opacityRaw)),
|
||
density: Math.min(1, Math.max(0.1, densityRaw)),
|
||
};
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function writeDanmakuPrefs(prefs: DanmakuPrefs) {
|
||
try {
|
||
localStorage.setItem(DANMAKU_SETTINGS_KEY, JSON.stringify(prefs));
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
}
|
||
|
||
const DANMAKU_XML_PREFIX = "media-danmaku-xml:";
|
||
/** 单条弹幕 XML 本地持久化上限(localStorage 常规配额约 5MB,留余量) */
|
||
const DANMAKU_XML_MAX_BYTES = 2 * 1024 * 1024;
|
||
|
||
function readLocalXml(mediaItemId: string): string {
|
||
try {
|
||
return localStorage.getItem(DANMAKU_XML_PREFIX + mediaItemId) ?? "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function writeLocalXml(mediaItemId: string, xml: string) {
|
||
try {
|
||
if (new Blob([xml]).size > DANMAKU_XML_MAX_BYTES) return;
|
||
localStorage.setItem(DANMAKU_XML_PREFIX + mediaItemId, xml);
|
||
} catch {
|
||
/* 配额不足时仅内存生效 */
|
||
}
|
||
}
|
||
|
||
function clearLocalXml(mediaItemId: string) {
|
||
try {
|
||
localStorage.removeItem(DANMAKU_XML_PREFIX + mediaItemId);
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
}
|
||
|
||
/** 页面卸载时 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 playerRef = useRef<HTMLDivElement>(null);
|
||
const artRef = useRef<Artplayer | null>(null);
|
||
const progressStartRef = useRef(0);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
|
||
const [localXml, setLocalXml] = useState("");
|
||
/** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */
|
||
const [matchXml, setMatchXml] = useState("");
|
||
const [danmakuOn, setDanmakuOn] = useState(prefsRef.current.enabled);
|
||
const [danmakuDensity, setDanmakuDensity] = useState(prefsRef.current.density);
|
||
const [ended, setEnded] = useState(false);
|
||
const [playError, setPlayError] = useState(false);
|
||
const [matchOpen, setMatchOpen] = useState(false);
|
||
const [kwInput, setKwInput] = useState("");
|
||
const [filterDraft, setFilterDraft] = useState<FilterOptions | null>(null);
|
||
|
||
// 进入/切换条目时恢复本地导入的弹幕
|
||
useEffect(() => {
|
||
setLocalXml(readLocalXml(mediaItemId));
|
||
setMatchXml("");
|
||
setFilterDraft(null);
|
||
setMatchOpen(false);
|
||
setPlayError(false);
|
||
setEnded(false);
|
||
setSourceKeyword("");
|
||
setSourceQuery("");
|
||
setSourceAnimeId(null);
|
||
}, [mediaItemId]);
|
||
|
||
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 prefs = trpc.media.danmakuGetSettings.useQuery();
|
||
const report = trpc.media.playbackReport.useMutation();
|
||
const importXml = trpc.media.danmakuImport.useMutation();
|
||
const clearLocal = trpc.media.danmakuClearLocal.useMutation();
|
||
const matchMut = trpc.media.danmakuMatch.useMutation();
|
||
const selectMut = trpc.media.danmakuSelectMatch.useMutation();
|
||
// 手动指定弹幕源:作品 → 集数
|
||
const [sourceKeyword, setSourceKeyword] = useState("");
|
||
const [sourceQuery, setSourceQuery] = useState("");
|
||
const [sourceAnimeId, setSourceAnimeId] = useState<number | null>(null);
|
||
const sourceSearch = trpc.media.danmakuSearchSource.useQuery(
|
||
{ keyword: sourceQuery },
|
||
{ enabled: matchOpen && sourceQuery.trim().length > 0 },
|
||
);
|
||
const sourceEpisodes = trpc.media.danmakuSourceEpisodes.useQuery(
|
||
{ animeId: sourceAnimeId ?? 0 },
|
||
{ enabled: matchOpen && sourceAnimeId != null },
|
||
);
|
||
const reportRef = useRef(report);
|
||
useEffect(() => {
|
||
reportRef.current = report;
|
||
});
|
||
|
||
const bangumiId = detail.data?.bangumiId ?? null;
|
||
const series = trpc.media.librarySeries.useQuery(
|
||
{ bangumiId: bangumiId ?? 0 },
|
||
{ enabled: bangumiId != null && bangumiId > 0 },
|
||
);
|
||
const episodes = useMemo(() => {
|
||
const list = series.data?.episodes ?? [];
|
||
return [...list].sort((a, b) => (epNumberOf(a) ?? 0) - (epNumberOf(b) ?? 0));
|
||
}, [series.data]);
|
||
const epIndex = episodes.findIndex((ep) => ep.id === mediaItemId);
|
||
const prevEp = epIndex > 0 ? episodes[epIndex - 1] : null;
|
||
const nextEp = epIndex >= 0 && epIndex < episodes.length - 1 ? episodes[epIndex + 1] : null;
|
||
|
||
const openEpisode = useCallback(
|
||
(id: string) => {
|
||
const g = bangumiId != null ? `&from=series&g=${bangumiId}` : "";
|
||
go(`/${locale}/watch?id=${id}${g}`);
|
||
},
|
||
[locale, bangumiId],
|
||
);
|
||
|
||
const goSeries = useCallback(() => {
|
||
const g = queryParam("g") ?? (bangumiId != null ? String(bangumiId) : null);
|
||
if (g != null) go(`/${locale}/library?g=${g}`);
|
||
else go(`/${locale}/library`);
|
||
}, [locale, bangumiId]);
|
||
|
||
useEffect(() => {
|
||
const prefs = readDanmakuPrefs();
|
||
prefsRef.current = prefs;
|
||
setDanmakuOn(prefs.enabled);
|
||
setDanmakuDensity(prefs.density);
|
||
}, []);
|
||
|
||
// 服务端 prefs 到达后覆盖显示参数;localStorage 仅作 query 失败回退
|
||
useEffect(() => {
|
||
const data = prefs.data;
|
||
if (!data) return;
|
||
const next: DanmakuPrefs = {
|
||
enabled: data.enabled,
|
||
opacity: Math.min(1, Math.max(0, data.opacity)),
|
||
density: Math.min(1, Math.max(0.1, data.density)),
|
||
};
|
||
prefsRef.current = next;
|
||
setDanmakuOn(next.enabled);
|
||
setDanmakuDensity(next.density);
|
||
const art = artRef.current;
|
||
const dm = art ? danmukuPlugin(art) : null;
|
||
if (dm) dm.config({ opacity: next.opacity });
|
||
}, [prefs.data]);
|
||
|
||
useEffect(() => {
|
||
if (progress.data) {
|
||
progressStartRef.current = progress.data.positionMs;
|
||
}
|
||
}, [progress.data]);
|
||
|
||
const detailId = detail.data?.id;
|
||
useEffect(() => {
|
||
const mountEl = playerRef.current;
|
||
if (!mountEl || !detailId) return;
|
||
const prefs = prefsRef.current;
|
||
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: "#FB7299",
|
||
plugins: [
|
||
artplayerPluginDanmuku({
|
||
danmuku: [],
|
||
opacity: prefs.opacity,
|
||
fontSize: 25,
|
||
speed: 5,
|
||
margin: [10, "25%"],
|
||
color: "#FFFFFF",
|
||
mode: 0,
|
||
antiOverlap: true,
|
||
visible: prefs.enabled,
|
||
emitter: true,
|
||
}),
|
||
],
|
||
});
|
||
artRef.current = art;
|
||
|
||
// 常用快捷键(ArtPlayer 默认不含 f/m,补注册)
|
||
art.hotkey.add("KeyF", () => {
|
||
art.fullscreen = !art.fullscreen;
|
||
});
|
||
art.hotkey.add("KeyM", () => {
|
||
art.muted = !art.muted;
|
||
});
|
||
|
||
// 播放失败 → 业务级错误卡(含重试);加载成功后清除
|
||
const onVideoError = () => {
|
||
setPlayError(true);
|
||
};
|
||
const onVideoReady = () => {
|
||
setPlayError(false);
|
||
};
|
||
art.on("video:error", onVideoError);
|
||
art.on("video:loadeddata", onVideoReady);
|
||
art.on("playing", onVideoReady);
|
||
|
||
// 播放器内弹幕设置变更 → 回写本地默认偏好
|
||
art.on("artplayerPluginDanmuku:config", (...args: unknown[]) => {
|
||
const option = (args[0] ?? {}) as { opacity?: number; visible?: boolean };
|
||
const prev = prefsRef.current;
|
||
const next: DanmakuPrefs = {
|
||
enabled: option.visible ?? prev.enabled,
|
||
opacity: typeof option.opacity === "number" ? option.opacity : prev.opacity,
|
||
density: prev.density,
|
||
};
|
||
prefsRef.current = next;
|
||
writeDanmakuPrefs(next);
|
||
});
|
||
|
||
const seekToStart = () => {
|
||
const target = progressStartRef.current / 1000;
|
||
if (target > 0 && art.duration > target) {
|
||
art.currentTime = target;
|
||
}
|
||
};
|
||
art.on("video:loadedmetadata", seekToStart);
|
||
|
||
const onTimeUpdate = () => {
|
||
setCurrentTime(art.currentTime || 0);
|
||
};
|
||
art.on("video:timeupdate", onTimeUpdate);
|
||
|
||
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);
|
||
};
|
||
let unloaded = false;
|
||
const flushUnload = () => {
|
||
if (unloaded) return;
|
||
const input = snapshot();
|
||
if (!input) return;
|
||
unloaded = true;
|
||
reportRef.current.mutate(input);
|
||
sendProgressBeacon(input);
|
||
};
|
||
|
||
let timer: ReturnType<typeof setInterval> | null = null;
|
||
// video:play / video:pause 是原生事件转发:覆盖 UI 按钮与系统媒体键/程序化暂停
|
||
art.on("video:play", () => {
|
||
setEnded(false);
|
||
if (timer) clearInterval(timer);
|
||
timer = setInterval(reportNow, 5000);
|
||
});
|
||
art.on("video:pause", () => {
|
||
if (timer) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
reportNow();
|
||
});
|
||
art.on("video:ended", () => {
|
||
if (timer) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
reportNow();
|
||
setEnded(true);
|
||
});
|
||
|
||
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;
|
||
};
|
||
}, [detailId, 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 || matchXml || openDanmaku.data?.xml || "" : "";
|
||
const listComments = useMemo(() => {
|
||
const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
|
||
raw.sort((a, b) => a.time - b.time);
|
||
const filters: FilterOptions | null = filterDraft
|
||
? filterDraft
|
||
: prefs.data
|
||
? {
|
||
blockKeywords: prefs.data.blockKeywords,
|
||
blockTypes: prefs.data.blockTypes,
|
||
}
|
||
: null;
|
||
return filters ? filterComments(raw, filters) : raw;
|
||
}, [xmlSource, prefs.data, filterDraft]);
|
||
const items = useMemo(() => {
|
||
return toDanmukuItems(thinDanmaku(listComments, danmakuDensity));
|
||
}, [listComments, danmakuDensity]);
|
||
const danmakuCount = items.length;
|
||
|
||
// 弹幕数据变化时交给官方插件渲染
|
||
useEffect(() => {
|
||
const art = artRef.current;
|
||
if (!art) return;
|
||
const dm = danmukuPlugin(art);
|
||
if (!dm) return;
|
||
void dm.load(items);
|
||
}, [items]);
|
||
|
||
// 外部开关同步到插件显隐
|
||
useEffect(() => {
|
||
const art = artRef.current;
|
||
if (!art) return;
|
||
const dm = danmukuPlugin(art);
|
||
if (!dm) return;
|
||
if (danmakuOn) dm.show();
|
||
else dm.hide();
|
||
}, [danmakuOn]);
|
||
|
||
const onImportXml = useCallback(
|
||
(file: File) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const text = String(reader.result ?? "");
|
||
importXml.mutate(
|
||
{ mediaItemId, xml: text, fileName: file.name },
|
||
{
|
||
onSuccess: () => {
|
||
setLocalXml(text);
|
||
writeLocalXml(mediaItemId, text);
|
||
setMatchXml("");
|
||
toast.success(t(locale, "watch.importSaved"));
|
||
},
|
||
onError: () => toast.error(t(locale, "watch.importFail")),
|
||
},
|
||
);
|
||
};
|
||
reader.readAsText(file);
|
||
},
|
||
[importXml, mediaItemId, locale],
|
||
);
|
||
|
||
const openRematch = useCallback(() => {
|
||
setMatchOpen(true);
|
||
matchMut.mutate(
|
||
{ mediaItemId },
|
||
{
|
||
onError: () => {
|
||
setMatchOpen(false);
|
||
toast.error(t(locale, "watch.matchFailed"));
|
||
},
|
||
},
|
||
);
|
||
}, [matchMut, mediaItemId, locale]);
|
||
|
||
const onSelectCandidate = useCallback(
|
||
(episodeId: number) => {
|
||
selectMut.mutate(
|
||
{ mediaItemId, episodeId },
|
||
{
|
||
onSuccess: (data) => {
|
||
// 手选成功即离开本地源:清内存与 localStorage,避免再粘滞
|
||
if (data.ok) {
|
||
setLocalXml("");
|
||
clearLocalXml(mediaItemId);
|
||
}
|
||
if (data.ok && data.xml) {
|
||
setMatchOpen(false);
|
||
setMatchXml(data.xml);
|
||
void openDanmaku.refetch();
|
||
toast.success(t(locale, "watch.matchManual"));
|
||
return;
|
||
}
|
||
toast.error(data.message ?? t(locale, "watch.matchFailed"));
|
||
},
|
||
onError: () => toast.error(t(locale, "watch.matchFailed")),
|
||
},
|
||
);
|
||
},
|
||
[selectMut, mediaItemId, locale, openDanmaku],
|
||
);
|
||
|
||
const activeFilters: FilterOptions | null = filterDraft
|
||
? filterDraft
|
||
: prefs.data
|
||
? {
|
||
blockKeywords: prefs.data.blockKeywords,
|
||
blockTypes: prefs.data.blockTypes,
|
||
}
|
||
: null;
|
||
|
||
const addBlockKeyword = useCallback(() => {
|
||
const kw = kwInput.trim();
|
||
if (!kw) return;
|
||
setFilterDraft((prev) => {
|
||
const base: FilterOptions = prev ??
|
||
activeFilters ?? { blockKeywords: [], blockTypes: [] };
|
||
if (base.blockKeywords.includes(kw)) return base;
|
||
return { ...base, blockKeywords: [...base.blockKeywords, kw] };
|
||
});
|
||
setKwInput("");
|
||
}, [kwInput, activeFilters]);
|
||
|
||
const removeBlockKeyword = useCallback(
|
||
(kw: string) => {
|
||
setFilterDraft((prev) => {
|
||
const base: FilterOptions = prev ??
|
||
activeFilters ?? { blockKeywords: [], blockTypes: [] };
|
||
return { ...base, blockKeywords: base.blockKeywords.filter((k) => k !== kw) };
|
||
});
|
||
},
|
||
[activeFilters],
|
||
);
|
||
|
||
const toggleBlockType = useCallback(
|
||
(kind: "scroll" | "top" | "bottom") => {
|
||
setFilterDraft((prev) => {
|
||
const base: FilterOptions = prev ??
|
||
activeFilters ?? {
|
||
blockKeywords: [],
|
||
blockTypes: [],
|
||
};
|
||
const blockTypes = base.blockTypes.includes(kind)
|
||
? base.blockTypes.filter((k) => k !== kind)
|
||
: [...base.blockTypes, kind];
|
||
return { ...base, blockTypes };
|
||
});
|
||
},
|
||
[activeFilters],
|
||
);
|
||
|
||
const replay = useCallback(() => {
|
||
const art = artRef.current;
|
||
if (!art) return;
|
||
setEnded(false);
|
||
art.currentTime = 0;
|
||
void art.play();
|
||
}, []);
|
||
|
||
const retryStream = useCallback(() => {
|
||
const art = artRef.current;
|
||
if (!art) return;
|
||
setPlayError(false);
|
||
art.switchUrl(`/api/stream?id=${encodeURIComponent(mediaItemId)}`);
|
||
}, [mediaItemId]);
|
||
|
||
const goLibrary = useCallback(() => {
|
||
go(`/${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="u-empty">
|
||
<p className="u-empty__title">{t(locale, "watch.notFound")}</p>
|
||
<p>{t(locale, "watch.notFoundHint")}</p>
|
||
<div className="u-empty__actions">
|
||
<Button size="sm" onClick={goLibrary}>
|
||
{t(locale, "watch.backToLibrary")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml";
|
||
const fetchMessage = openDanmaku.data?.message ?? "";
|
||
// 本地源也可重新匹配:手选后切回开放网络(服务端/本地 local-xml 同步清除)
|
||
const showRematch =
|
||
danmakuOn &&
|
||
(isLocalSource ||
|
||
(openDanmaku.data?.source === "none" &&
|
||
(fetchMessage.includes("手选") || fetchMessage.includes("未匹配"))));
|
||
const candidates = matchMut.data?.candidates ?? [];
|
||
const mediaTitle = detail.data.title;
|
||
const sameNameEpisodes = new Set(
|
||
candidates
|
||
.filter(
|
||
(c) =>
|
||
c.animeTitle != null &&
|
||
c.animeTitle.length > 0 &&
|
||
(mediaTitle.includes(c.animeTitle) || c.animeTitle.includes(mediaTitle)),
|
||
)
|
||
.map((c) => c.episodeId),
|
||
);
|
||
|
||
const sourceLabel = !danmakuOn
|
||
? localXml || isLocalSource
|
||
? t(locale, "watch.danmakuDisabledHint")
|
||
: t(locale, "watch.danmakuOff")
|
||
: isLocalSource
|
||
? `${t(locale, "watch.danmakuLocal")} · ${t(locale, "watch.danmakuCount", { count: danmakuCount })}`
|
||
: matchXml
|
||
? t(locale, "watch.danmakuOpenNetwork")
|
||
: openDanmaku.data?.reason === "need-login"
|
||
? t(locale, "watch.needDdpLogin")
|
||
: openDanmaku.data?.reason === "no-app-cred"
|
||
? t(locale, "watch.noAppCred")
|
||
: openDanmaku.data?.reason === "no-match"
|
||
? t(locale, "watch.noDanmakuImport")
|
||
: 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}</h1>
|
||
{bangumiId != null && (
|
||
<Button size="sm" variant="ghost" onClick={goSeries}>
|
||
← {t(locale, "watch.backToSeries")}
|
||
</Button>
|
||
)}
|
||
<div className="ml-auto flex flex-wrap gap-2">
|
||
<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={goLibrary}>
|
||
{t(locale, "watch.backToLibrary")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col gap-3 lg:flex-row">
|
||
<div className="min-w-0 flex-1 space-y-3">
|
||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
|
||
<div ref={playerRef} className="absolute inset-0" />
|
||
{playError && (
|
||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center">
|
||
<p className="text-base font-medium text-white">
|
||
{t(locale, "watch.playFailed")}
|
||
</p>
|
||
<p className="max-w-md text-sm text-white/70">
|
||
{t(locale, "watch.playFailedHint")}
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={retryStream}>
|
||
{t(locale, "error.retry")}
|
||
</Button>
|
||
<Button size="sm" variant="outline" onClick={goLibrary}>
|
||
{t(locale, "watch.backToLibrary")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<span>{sourceLabel}</span>
|
||
{isLocalSource && fetchMessage.includes("同名") && (
|
||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
|
||
{t(locale, "watch.sidecarLinked")}
|
||
</span>
|
||
)}
|
||
{showRematch && (
|
||
<Button size="sm" variant="outline" onClick={openRematch}>
|
||
{t(locale, "watch.rematch")}
|
||
</Button>
|
||
)}
|
||
{(openDanmaku.data?.reason === "need-login" ||
|
||
openDanmaku.data?.reason === "no-app-cred") && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => go(`/${locale}/settings`)}
|
||
>
|
||
{t(locale, "watch.gotoSettings")}
|
||
</Button>
|
||
)}
|
||
{isLocalSource && (
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
disabled={clearLocal.isPending}
|
||
onClick={() =>
|
||
clearLocal.mutate(
|
||
{ mediaItemId },
|
||
{
|
||
onSuccess: () => {
|
||
setLocalXml("");
|
||
clearLocalXml(mediaItemId);
|
||
void openDanmaku.refetch();
|
||
toast.success(
|
||
t(locale, "watch.clearLocalOk"),
|
||
);
|
||
},
|
||
},
|
||
)
|
||
}
|
||
>
|
||
{t(locale, "watch.clearLocalDanmaku")}
|
||
</Button>
|
||
)}
|
||
</span>
|
||
<span>{t(locale, "watch.progressHint")}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<aside className="flex w-full shrink-0 flex-col rounded-lg border border-border lg:w-72">
|
||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||
<p className="u-label">{t(locale, "watch.danmakuList")}</p>
|
||
<span className="text-xs text-muted-foreground">
|
||
{t(locale, "watch.danmakuCount", { count: listComments.length })}
|
||
</span>
|
||
</div>
|
||
<div className="max-h-[28rem] min-h-[12rem] flex-1 overflow-y-auto">
|
||
{listComments.length === 0 ? (
|
||
<p className="p-3 text-sm text-muted-foreground">
|
||
{t(locale, "watch.danmakuListEmpty")}
|
||
</p>
|
||
) : (
|
||
<ul>
|
||
{listComments.map((c, i) => {
|
||
const active =
|
||
Math.abs(c.time - currentTime) < 0.5 ||
|
||
(c.time <= currentTime &&
|
||
(listComments[i + 1]?.time ??
|
||
Number.POSITIVE_INFINITY) > currentTime);
|
||
return (
|
||
<li key={`${c.time}-${c.rowId}`}>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"flex w-full items-start gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-primary/5",
|
||
active && "bg-primary/10",
|
||
)}
|
||
onClick={() => {
|
||
const art = artRef.current;
|
||
if (art) art.currentTime = c.time;
|
||
}}
|
||
title={t(locale, "watch.clickToSeek")}
|
||
>
|
||
<span className="tabular-nums text-muted-foreground">
|
||
{formatTime(c.time)}
|
||
</span>
|
||
<span className="min-w-0 flex-1 break-words">
|
||
{c.text}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<section className="space-y-2 rounded-lg border border-border p-3">
|
||
<p className="u-label">{t(locale, "watch.filterTitle")}</p>
|
||
<div className="space-y-2">
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "danmaku.blockKeywords")}
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{(activeFilters?.blockKeywords ?? []).map((kw) => (
|
||
<span
|
||
key={kw}
|
||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-xs"
|
||
>
|
||
{kw}
|
||
<button
|
||
type="button"
|
||
className="text-muted-foreground hover:text-foreground"
|
||
onClick={() => removeBlockKeyword(kw)}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
<Input
|
||
className="h-8 w-40 text-sm"
|
||
value={kwInput}
|
||
placeholder={t(locale, "watch.blockKeywordPlaceholder")}
|
||
onChange={(e) => setKwInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addBlockKeyword();
|
||
}
|
||
}}
|
||
/>
|
||
<Button size="sm" variant="outline" onClick={addBlockKeyword}>
|
||
{t(locale, "watch.blockKeywordAdd")}
|
||
</Button>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "danmaku.blockTypes")}
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(
|
||
[
|
||
["scroll", "danmaku.blockTypeScroll"],
|
||
["top", "danmaku.blockTypeTop"],
|
||
["bottom", "danmaku.blockTypeBottom"],
|
||
] as const
|
||
).map(([kind, labelKey]) => {
|
||
const blocked = activeFilters?.blockTypes.includes(kind) ?? false;
|
||
return (
|
||
<button
|
||
key={kind}
|
||
type="button"
|
||
className={cn(
|
||
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||
blocked
|
||
? "border-primary bg-primary/10 font-medium text-primary"
|
||
: "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground",
|
||
)}
|
||
onClick={() => toggleBlockType(kind)}
|
||
>
|
||
{t(locale, labelKey)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{matchOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col space-y-3 overflow-hidden rounded-lg border border-border bg-background p-4 shadow-lg">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="text-base font-semibold">
|
||
{t(locale, "watch.matchCandidates")}
|
||
</h2>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="ml-auto"
|
||
onClick={() => setMatchOpen(false)}
|
||
>
|
||
×
|
||
</Button>
|
||
</div>
|
||
<div className="flex-1 space-y-4 overflow-y-auto">
|
||
<section className="space-y-2">
|
||
<p className="u-label">{t(locale, "watch.fileMatch")}</p>
|
||
{matchMut.data?.highConfidence && (
|
||
<p className="text-xs text-primary">
|
||
{t(locale, "watch.matchAutoBound")}
|
||
</p>
|
||
)}
|
||
{matchMut.isPending && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "watch.loading")}
|
||
</p>
|
||
)}
|
||
{matchMut.isError && (
|
||
<p className="text-sm text-destructive">
|
||
{t(locale, "watch.matchFailed")}
|
||
</p>
|
||
)}
|
||
{matchMut.isSuccess && candidates.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "watch.matchEmpty")}
|
||
</p>
|
||
)}
|
||
{candidates.length > 0 && (
|
||
<ul className="space-y-2">
|
||
{candidates.map((c) => (
|
||
<li key={c.episodeId}>
|
||
<button
|
||
type="button"
|
||
className="w-full rounded-md border border-border px-3 py-2 text-left text-sm transition-colors hover:border-primary/60 hover:bg-primary/5"
|
||
disabled={selectMut.isPending}
|
||
onClick={() => onSelectCandidate(c.episodeId)}
|
||
>
|
||
<span className="font-medium">
|
||
{c.animeTitle ?? `#${c.episodeId}`}
|
||
</span>
|
||
<span className="ml-2 text-muted-foreground">
|
||
{c.episodeTitle ??
|
||
t(locale, "watch.episodeN", {
|
||
n: c.episodeId,
|
||
})}
|
||
</span>
|
||
<span className="ml-2 text-xs text-muted-foreground">
|
||
episodeId {c.episodeId}
|
||
</span>
|
||
{sameNameEpisodes.has(c.episodeId) && (
|
||
<span className="ml-2 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
|
||
{t(locale, "watch.foundSameName")}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className="space-y-2 border-t border-border pt-3">
|
||
<p className="u-label">{t(locale, "watch.manualPick")}</p>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(locale, "watch.manualPickHint")}
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
className="h-9 flex-1"
|
||
value={sourceKeyword}
|
||
placeholder={t(locale, "watch.sourceSearchPlaceholder")}
|
||
onChange={(e) => setSourceKeyword(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
setSourceAnimeId(null);
|
||
setSourceQuery(sourceKeyword.trim());
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
disabled={sourceKeyword.trim().length === 0}
|
||
onClick={() => {
|
||
setSourceAnimeId(null);
|
||
setSourceQuery(sourceKeyword.trim());
|
||
}}
|
||
>
|
||
{t(locale, "watch.sourceSearch")}
|
||
</Button>
|
||
</div>
|
||
|
||
{sourceSearch.isPending && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "watch.loading")}
|
||
</p>
|
||
)}
|
||
{sourceSearch.isError && (
|
||
<p className="text-sm text-destructive">
|
||
{t(locale, "watch.searchFailed")}
|
||
</p>
|
||
)}
|
||
{sourceSearch.isSuccess &&
|
||
sourceQuery &&
|
||
sourceSearch.data.animes.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "watch.searchEmpty")}
|
||
</p>
|
||
)}
|
||
{sourceAnimeId == null &&
|
||
(sourceSearch.data?.animes.length ?? 0) > 0 && (
|
||
<ul className="space-y-1.5">
|
||
{(sourceSearch.data?.animes ?? []).map((a) => (
|
||
<li key={a.animeId}>
|
||
<button
|
||
type="button"
|
||
className="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:border-primary/60 hover:bg-primary/5"
|
||
onClick={() => setSourceAnimeId(a.animeId)}
|
||
>
|
||
<span className="font-medium">
|
||
{a.animeTitle ?? `#${a.animeId}`}
|
||
</span>
|
||
<span className="ml-2 text-xs text-muted-foreground">
|
||
{a.typeDescription ?? ""}
|
||
{a.episodeCount > 0 &&
|
||
` · ${t(
|
||
locale,
|
||
"watch.episodeCount",
|
||
{
|
||
count: a.episodeCount,
|
||
},
|
||
)}`}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{sourceAnimeId != null && (
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => setSourceAnimeId(null)}
|
||
>
|
||
← {t(locale, "watch.sourceBackToAnime")}
|
||
</Button>
|
||
<span className="text-sm text-muted-foreground">
|
||
{sourceEpisodes.data?.animeTitle ??
|
||
`#${sourceAnimeId}`}
|
||
</span>
|
||
</div>
|
||
{sourceEpisodes.isPending && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "watch.loading")}
|
||
</p>
|
||
)}
|
||
{sourceEpisodes.isError && (
|
||
<p className="text-sm text-destructive">
|
||
{t(locale, "watch.searchFailed")}
|
||
</p>
|
||
)}
|
||
{sourceEpisodes.isSuccess &&
|
||
sourceEpisodes.data.episodes.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "watch.searchEmpty")}
|
||
</p>
|
||
)}
|
||
{sourceEpisodes.data &&
|
||
sourceEpisodes.data.episodes.length > 0 && (
|
||
<ul className="space-y-1.5">
|
||
{sourceEpisodes.data.episodes.map((ep) => (
|
||
<li key={ep.episodeId}>
|
||
<button
|
||
type="button"
|
||
className="w-full rounded-md border border-border px-3 py-2 text-left text-sm hover:border-primary/60 hover:bg-primary/5"
|
||
disabled={selectMut.isPending}
|
||
onClick={() =>
|
||
onSelectCandidate(ep.episodeId)
|
||
}
|
||
>
|
||
<span className="font-medium">
|
||
{ep.episodeTitle ??
|
||
t(
|
||
locale,
|
||
"watch.episodeN",
|
||
{
|
||
n: ep.episodeId,
|
||
},
|
||
)}
|
||
</span>
|
||
<span className="ml-2 text-xs text-muted-foreground">
|
||
{ep.typeDescription ?? ""}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{episodes.length > 0 && (
|
||
<section className="space-y-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<p className="u-label">{t(locale, "watch.episodes")}</p>
|
||
<span className="u-dotline flex-1" aria-hidden="true" />
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!prevEp}
|
||
onClick={() => prevEp && openEpisode(prevEp.id)}
|
||
>
|
||
← {t(locale, "watch.prevEpisode")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!nextEp}
|
||
onClick={() => nextEp && openEpisode(nextEp.id)}
|
||
>
|
||
{t(locale, "watch.nextEpisode")} →
|
||
</Button>
|
||
</div>
|
||
<ul className="flex flex-wrap gap-2">
|
||
{episodes.map((ep) => {
|
||
const current = ep.id === mediaItemId;
|
||
const epNo = epNumberOf(ep);
|
||
return (
|
||
<li key={ep.id}>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||
current
|
||
? "border-primary bg-primary/10 font-medium text-primary"
|
||
: "border-border text-muted-foreground hover:border-primary/60 hover:text-foreground",
|
||
)}
|
||
onClick={() => !current && openEpisode(ep.id)}
|
||
>
|
||
{epNo != null
|
||
? t(locale, "watch.episodeN", { n: epNo })
|
||
: ep.rawName}
|
||
{current && (
|
||
<span className="ml-2 text-[11px]">
|
||
{t(locale, "watch.currentEpisode")}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
|
||
{ended && nextEp && (
|
||
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/5 p-4">
|
||
<p className="text-sm">
|
||
{t(locale, "watch.episodeN", {
|
||
n: epNumberOf(nextEp) ?? episodes.indexOf(nextEp) + 1,
|
||
})}
|
||
</p>
|
||
<Button size="sm" className="ml-auto" onClick={() => openEpisode(nextEp.id)}>
|
||
{t(locale, "watch.playNextCta")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{ended && !nextEp && (
|
||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 p-4">
|
||
<p className="text-sm">{t(locale, "watch.playbackFinished")}</p>
|
||
<div className="ml-auto flex gap-2">
|
||
<Button size="sm" variant="outline" onClick={replay}>
|
||
{t(locale, "watch.replay")}
|
||
</Button>
|
||
<Button size="sm" onClick={bangumiId != null ? goSeries : goLibrary}>
|
||
{bangumiId != null
|
||
? t(locale, "watch.backToSeries")
|
||
: t(locale, "watch.backToLibrary")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const client = createTRPCReactClient();
|
||
export function WatchBody(props: { locale: Locale; mediaItemId: string }) {
|
||
const [qc] = useState(() => new QueryClient());
|
||
return (
|
||
<QueryClientProvider client={qc}>
|
||
<trpc.Provider client={client} queryClient={qc}>
|
||
<WatchInner locale={props.locale} mediaItemId={props.mediaItemId} />
|
||
</trpc.Provider>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|