app-template/app/web/src/components/pages/WatchBody.tsx

1194 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}