feat(web,danmaku): danmaku side list and manual source picker
Right-side danmaku list with click-to-seek; rematch dialog gains hierarchical source pick (search title → episode) via DanDanPlay open API.
This commit is contained in:
parent
5f5dd968cb
commit
2bcf9f0230
|
|
@ -0,0 +1,55 @@
|
||||||
|
/**
|
||||||
|
* 顶栏语言下拉:trigger 显示当前语言,菜单项来自 `LOCALES`(后续扩展只需改 `@app/i18n`)。
|
||||||
|
* 菜单项是带 `data-switch-locale` 的 `<a>`,由 AppLayout 内联脚本写 `app-lang` cookie 后跳转。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LOCALES, type Locale, t } from "@app/i18n";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/ui";
|
||||||
|
|
||||||
|
export interface LocaleSwitchProps {
|
||||||
|
locale: Locale;
|
||||||
|
/** 各语言在当前页的 URL(AppLayout 按 pathname 预先算好) */
|
||||||
|
hrefs: Record<Locale, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LocaleSwitch({ locale, hrefs }: LocaleSwitchProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="u-linelink"
|
||||||
|
aria-label={t(locale, "locale.switch")}
|
||||||
|
>
|
||||||
|
{t(locale, `locale.${locale}`)}
|
||||||
|
<span aria-hidden="true" className="text-[9px] opacity-70">
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{LOCALES.map((code) => (
|
||||||
|
<DropdownMenuItem key={code} asChild>
|
||||||
|
<a
|
||||||
|
data-switch-locale={code}
|
||||||
|
href={hrefs[code]}
|
||||||
|
aria-current={code === locale ? "true" : undefined}
|
||||||
|
className={
|
||||||
|
code === locale
|
||||||
|
? "flex w-full items-center gap-2 font-medium text-inherit no-underline"
|
||||||
|
: "flex w-full items-center gap-2 text-inherit no-underline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{t(code, `locale.${code}`)}</span>
|
||||||
|
{code === locale && (
|
||||||
|
<span aria-hidden="true" className="ml-auto text-[11px]">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,13 @@ function danmukuPlugin(art: Artplayer): DanmukuPlugin | null {
|
||||||
return dm as DanmukuPlugin;
|
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 = {
|
type ProgressInput = {
|
||||||
mediaItemId: string;
|
mediaItemId: string;
|
||||||
positionMs: number;
|
positionMs: number;
|
||||||
|
|
@ -134,6 +141,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
const playerRef = useRef<HTMLDivElement>(null);
|
const playerRef = useRef<HTMLDivElement>(null);
|
||||||
const artRef = useRef<Artplayer | null>(null);
|
const artRef = useRef<Artplayer | null>(null);
|
||||||
const progressStartRef = useRef(0);
|
const progressStartRef = useRef(0);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
|
const prefsRef = useRef<DanmakuPrefs>(readDanmakuPrefs());
|
||||||
const [localXml, setLocalXml] = useState("");
|
const [localXml, setLocalXml] = useState("");
|
||||||
/** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */
|
/** 手选绑定后的临时展示(不写 local 覆盖,避免遮住服务端后续更新) */
|
||||||
|
|
@ -154,6 +162,9 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
setMatchOpen(false);
|
setMatchOpen(false);
|
||||||
setPlayError(false);
|
setPlayError(false);
|
||||||
setEnded(false);
|
setEnded(false);
|
||||||
|
setSourceKeyword("");
|
||||||
|
setSourceQuery("");
|
||||||
|
setSourceAnimeId(null);
|
||||||
}, [mediaItemId]);
|
}, [mediaItemId]);
|
||||||
|
|
||||||
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
|
const detail = trpc.media.libraryGet.useQuery({ id: mediaItemId });
|
||||||
|
|
@ -164,6 +175,18 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
const importXml = trpc.media.danmakuImport.useMutation();
|
const importXml = trpc.media.danmakuImport.useMutation();
|
||||||
const matchMut = trpc.media.danmakuMatch.useMutation();
|
const matchMut = trpc.media.danmakuMatch.useMutation();
|
||||||
const selectMut = trpc.media.danmakuSelectMatch.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);
|
const reportRef = useRef(report);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reportRef.current = report;
|
reportRef.current = report;
|
||||||
|
|
@ -299,6 +322,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
};
|
};
|
||||||
art.on("video:loadedmetadata", seekToStart);
|
art.on("video:loadedmetadata", seekToStart);
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
setCurrentTime(art.currentTime || 0);
|
||||||
|
};
|
||||||
|
art.on("video:timeupdate", onTimeUpdate);
|
||||||
|
|
||||||
const snapshot = (): ProgressInput | null => {
|
const snapshot = (): ProgressInput | null => {
|
||||||
if (!art.duration) return null;
|
if (!art.duration) return null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -371,7 +399,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
}, [progress.data]);
|
}, [progress.data]);
|
||||||
|
|
||||||
const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : "";
|
const xmlSource = danmakuOn ? localXml || matchXml || openDanmaku.data?.xml || "" : "";
|
||||||
const items = useMemo(() => {
|
const listComments = useMemo(() => {
|
||||||
const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
|
const raw = xmlSource ? parseDanmakuXml(xmlSource) : [];
|
||||||
raw.sort((a, b) => a.time - b.time);
|
raw.sort((a, b) => a.time - b.time);
|
||||||
const filters: FilterOptions | null = filterDraft
|
const filters: FilterOptions | null = filterDraft
|
||||||
|
|
@ -382,9 +410,11 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
blockTypes: prefs.data.blockTypes,
|
blockTypes: prefs.data.blockTypes,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const blocked = filters ? filterComments(raw, filters) : raw;
|
return filters ? filterComments(raw, filters) : raw;
|
||||||
return toDanmukuItems(thinDanmaku(blocked, danmakuDensity));
|
}, [xmlSource, prefs.data, filterDraft]);
|
||||||
}, [xmlSource, prefs.data, filterDraft, danmakuDensity]);
|
const items = useMemo(() => {
|
||||||
|
return toDanmukuItems(thinDanmaku(listComments, danmakuDensity));
|
||||||
|
}, [listComments, danmakuDensity]);
|
||||||
const danmakuCount = items.length;
|
const danmakuCount = items.length;
|
||||||
|
|
||||||
// 弹幕数据变化时交给官方插件渲染
|
// 弹幕数据变化时交给官方插件渲染
|
||||||
|
|
@ -641,42 +671,95 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
|
<div className="flex flex-col gap-3 lg:flex-row">
|
||||||
<div ref={playerRef} className="absolute inset-0" />
|
<div className="min-w-0 flex-1 space-y-3">
|
||||||
{playError && (
|
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center">
|
<div ref={playerRef} className="absolute inset-0" />
|
||||||
<p className="text-base font-medium text-white">
|
{playError && (
|
||||||
{t(locale, "watch.playFailed")}
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-3 bg-black/80 p-6 text-center">
|
||||||
</p>
|
<p className="text-base font-medium text-white">
|
||||||
<p className="max-w-md text-sm text-white/70">
|
{t(locale, "watch.playFailed")}
|
||||||
{t(locale, "watch.playFailedHint")}
|
</p>
|
||||||
</p>
|
<p className="max-w-md text-sm text-white/70">
|
||||||
<div className="flex gap-2">
|
{t(locale, "watch.playFailedHint")}
|
||||||
<Button size="sm" onClick={retryStream}>
|
</p>
|
||||||
{t(locale, "error.retry")}
|
<div className="flex gap-2">
|
||||||
</Button>
|
<Button size="sm" onClick={retryStream}>
|
||||||
<Button size="sm" variant="outline" onClick={goLibrary}>
|
{t(locale, "error.retry")}
|
||||||
{t(locale, "watch.backToLibrary")}
|
</Button>
|
||||||
</Button>
|
<Button size="sm" variant="outline" onClick={goLibrary}>
|
||||||
</div>
|
{t(locale, "watch.backToLibrary")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||||
</div>
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
|
<span>{sourceLabel}</span>
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
{isLocalSource && fetchMessage.includes("同名") && (
|
||||||
<span>{sourceLabel}</span>
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
|
||||||
{isLocalSource && fetchMessage.includes("同名") && (
|
{t(locale, "watch.sidecarLinked")}
|
||||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
|
</span>
|
||||||
{t(locale, "watch.sidecarLinked")}
|
)}
|
||||||
|
{showRematch && (
|
||||||
|
<Button size="sm" variant="outline" onClick={openRematch}>
|
||||||
|
{t(locale, "watch.rematch")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span>{t(locale, "watch.progressHint")}</span>
|
||||||
{showRematch && (
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={openRematch}>
|
</div>
|
||||||
{t(locale, "watch.rematch")}
|
|
||||||
</Button>
|
<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">
|
||||||
</span>
|
<p className="u-label">{t(locale, "watch.danmakuList")}</p>
|
||||||
<span>{t(locale, "watch.progressHint")}</span>
|
<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 &&
|
||||||
|
(i === listComments.length - 1 ||
|
||||||
|
listComments[i + 1]!.time > currentTime));
|
||||||
|
return (
|
||||||
|
<li key={`${c.time}-${c.rowId}-${i}`}>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<section className="space-y-2 rounded-lg border border-border p-3">
|
<section className="space-y-2 rounded-lg border border-border p-3">
|
||||||
|
|
@ -751,7 +834,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
|
|
||||||
{matchOpen && (
|
{matchOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
<div className="w-full max-w-lg space-y-3 rounded-lg border border-border bg-background p-4 shadow-lg">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-base font-semibold">
|
<h2 className="text-base font-semibold">
|
||||||
{t(locale, "watch.matchCandidates")}
|
{t(locale, "watch.matchCandidates")}
|
||||||
|
|
@ -765,56 +848,203 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
|
||||||
×
|
×
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{matchMut.data?.highConfidence && (
|
<div className="flex-1 space-y-4 overflow-y-auto">
|
||||||
<p className="text-xs text-primary">
|
<section className="space-y-2">
|
||||||
{t(locale, "watch.matchAutoBound")}
|
<p className="u-label">{t(locale, "watch.fileMatch")}</p>
|
||||||
</p>
|
{matchMut.data?.highConfidence && (
|
||||||
)}
|
<p className="text-xs text-primary">
|
||||||
{matchMut.isPending && (
|
{t(locale, "watch.matchAutoBound")}
|
||||||
<p className="text-sm text-muted-foreground">
|
</p>
|
||||||
{t(locale, "watch.loading")}
|
)}
|
||||||
</p>
|
{matchMut.isPending && (
|
||||||
)}
|
<p className="text-sm text-muted-foreground">
|
||||||
{matchMut.isError && (
|
{t(locale, "watch.loading")}
|
||||||
<p className="text-sm text-destructive">
|
</p>
|
||||||
{t(locale, "watch.matchFailed")}
|
)}
|
||||||
</p>
|
{matchMut.isError && (
|
||||||
)}
|
<p className="text-sm text-destructive">
|
||||||
{matchMut.isSuccess && candidates.length === 0 && (
|
{t(locale, "watch.matchFailed")}
|
||||||
<p className="text-sm text-muted-foreground">
|
</p>
|
||||||
{t(locale, "watch.matchEmpty")}
|
)}
|
||||||
</p>
|
{matchMut.isSuccess && candidates.length === 0 && (
|
||||||
)}
|
<p className="text-sm text-muted-foreground">
|
||||||
{candidates.length > 0 && (
|
{t(locale, "watch.matchEmpty")}
|
||||||
<ul className="max-h-80 space-y-2 overflow-y-auto">
|
</p>
|
||||||
{candidates.map((c) => (
|
)}
|
||||||
<li key={c.episodeId}>
|
{candidates.length > 0 && (
|
||||||
<button
|
<ul className="space-y-2">
|
||||||
type="button"
|
{candidates.map((c) => (
|
||||||
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"
|
<li key={c.episodeId}>
|
||||||
disabled={selectMut.isPending}
|
<button
|
||||||
onClick={() => onSelectCandidate(c.episodeId)}
|
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"
|
||||||
<span className="font-medium">
|
disabled={selectMut.isPending}
|
||||||
{c.animeTitle ?? `#${c.episodeId}`}
|
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>
|
</span>
|
||||||
<span className="ml-2 text-muted-foreground">
|
</div>
|
||||||
{c.episodeTitle ??
|
{sourceEpisodes.isPending && (
|
||||||
t(locale, "watch.episodeN", { n: c.episodeId })}
|
<p className="text-sm text-muted-foreground">
|
||||||
</span>
|
{t(locale, "watch.loading")}
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
</p>
|
||||||
episodeId {c.episodeId}
|
)}
|
||||||
</span>
|
{sourceEpisodes.isError && (
|
||||||
{sameNameEpisodes.has(c.episodeId) && (
|
<p className="text-sm text-destructive">
|
||||||
<span className="ml-2 rounded bg-primary/10 px-1.5 py-0.5 text-[11px] text-primary">
|
{t(locale, "watch.searchFailed")}
|
||||||
{t(locale, "watch.foundSameName")}
|
</p>
|
||||||
</span>
|
)}
|
||||||
|
{sourceEpisodes.isSuccess &&
|
||||||
|
sourceEpisodes.data.episodes.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(locale, "watch.searchEmpty")}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
{sourceEpisodes.data &&
|
||||||
</li>
|
sourceEpisodes.data.episodes.length > 0 && (
|
||||||
))}
|
<ul className="space-y-1.5">
|
||||||
</ul>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ClientRouter } from "astro:transitions";
|
||||||
import "../styles/globals.css";
|
import "../styles/globals.css";
|
||||||
import UserMenu from "~/components/UserMenu";
|
import UserMenu from "~/components/UserMenu";
|
||||||
import ThemeSwitch from "~/components/ThemeSwitch";
|
import ThemeSwitch from "~/components/ThemeSwitch";
|
||||||
|
import LocaleSwitch from "~/components/LocaleSwitch";
|
||||||
import {
|
import {
|
||||||
buildDefaultJsonLd,
|
buildDefaultJsonLd,
|
||||||
buildHeadTags,
|
buildHeadTags,
|
||||||
|
|
@ -10,7 +11,15 @@ import {
|
||||||
toJsonLdScript,
|
toJsonLdScript,
|
||||||
type PageSeoInput,
|
type PageSeoInput,
|
||||||
} from "@app/seo-geo";
|
} from "@app/seo-geo";
|
||||||
import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, localePath, t } from "@app/i18n";
|
import {
|
||||||
|
alternatePath,
|
||||||
|
COOKIE_LANG,
|
||||||
|
COOKIE_LANG_MAX_AGE,
|
||||||
|
LOCALES,
|
||||||
|
type Locale,
|
||||||
|
localePath,
|
||||||
|
t,
|
||||||
|
} from "@app/i18n";
|
||||||
import { getSiteSeo } from "~/lib/seo";
|
import { getSiteSeo } from "~/lib/seo";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
|
|
@ -19,23 +28,17 @@ export interface Props {
|
||||||
brand?: string;
|
brand?: string;
|
||||||
/** 当前页,用于侧栏高亮 */
|
/** 当前页,用于侧栏高亮 */
|
||||||
active?: "home" | "library" | "browse" | "mounts" | "danmaku";
|
active?: "home" | "library" | "browse" | "mounts" | "danmaku";
|
||||||
switchHref?: string;
|
|
||||||
switchLabel?: string;
|
|
||||||
seo?: PageSeoInput;
|
seo?: PageSeoInput;
|
||||||
/** 播放页隐藏模板页脚 */
|
/** 播放页隐藏模板页脚 */
|
||||||
hideFooter?: boolean;
|
hideFooter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { title, locale, brand, active, seo, hideFooter = false } = Astro.props;
|
||||||
title,
|
|
||||||
locale,
|
/** 语言下拉:各语言在当前页的 URL */
|
||||||
brand,
|
const localeHrefs = Object.fromEntries(
|
||||||
active,
|
LOCALES.map((code) => [code, alternatePath(Astro.url.pathname, code)]),
|
||||||
switchHref,
|
) as Record<Locale, string>;
|
||||||
switchLabel,
|
|
||||||
seo,
|
|
||||||
hideFooter = false,
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
const brandName = brand ?? t(locale, "meta.brand");
|
const brandName = brand ?? t(locale, "meta.brand");
|
||||||
|
|
||||||
|
|
@ -211,19 +214,7 @@ const nav = [
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="site-header__actions">
|
<div class="site-header__actions">
|
||||||
{
|
<LocaleSwitch client:only="react" locale={locale} hrefs={localeHrefs} />
|
||||||
switchHref && (
|
|
||||||
<a
|
|
||||||
href={switchHref}
|
|
||||||
class="u-linelink"
|
|
||||||
data-switch-locale={locale === "zh-CN" ? "en" : "zh-CN"}
|
|
||||||
hreflang={locale === "zh-CN" ? "en" : "zh-CN"}
|
|
||||||
aria-label={t(locale, "locale.switch")}
|
|
||||||
>
|
|
||||||
{switchLabel ?? (locale === "zh-CN" ? "EN" : "中文")}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<span class="lg:hidden">
|
<span class="lg:hidden">
|
||||||
<ThemeSwitch client:only="react" />
|
<ThemeSwitch client:only="react" />
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import AuthBody from "~/components/pages/AuthBody";
|
import AuthBody from "~/components/pages/AuthBody";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
||||||
|
|
@ -11,7 +11,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale, mode, next, token } = Astro.props;
|
const { locale, mode, next, token } = Astro.props;
|
||||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
|
|
||||||
const titleKey =
|
const titleKey =
|
||||||
mode === "login"
|
mode === "login"
|
||||||
|
|
@ -30,8 +29,6 @@ const pageTitle = `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}
|
||||||
<AppLayout
|
<AppLayout
|
||||||
title={pageTitle}
|
title={pageTitle}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
|
||||||
switchLabel={t(other, `locale.${other}` as never)}
|
|
||||||
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
|
seo={{ title: pageTitle, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
|
||||||
>
|
>
|
||||||
<AuthBody client:load locale={locale} mode={mode} next={next} token={token} />
|
<AuthBody client:load locale={locale} mode={mode} next={next} token={token} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import { Toaster } from "@app/ui";
|
import { Toaster } from "@app/ui";
|
||||||
import { BrowseBody } from "~/components/pages/BrowseBody";
|
import { BrowseBody } from "~/components/pages/BrowseBody";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
@ -9,7 +9,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale } = Astro.props;
|
const { locale } = Astro.props;
|
||||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|
@ -17,8 +16,6 @@ const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||||
locale={locale}
|
locale={locale}
|
||||||
active="browse"
|
active="browse"
|
||||||
brand={t(locale, "meta.product")}
|
brand={t(locale, "meta.product")}
|
||||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
|
||||||
switchLabel={t(other, `locale.${other}` as never)}
|
|
||||||
seo={{
|
seo={{
|
||||||
title: `${t(locale, "browse.title")} · ${t(locale, "meta.brand")}`,
|
title: `${t(locale, "browse.title")} · ${t(locale, "meta.brand")}`,
|
||||||
path: Astro.url.pathname,
|
path: Astro.url.pathname,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import { Toaster } from "@app/ui";
|
import { Toaster } from "@app/ui";
|
||||||
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
|
import { DanmakuSettingsBody } from "~/components/pages/DanmakuSettingsBody";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
@ -9,7 +9,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale } = Astro.props;
|
const { locale } = Astro.props;
|
||||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|
@ -17,8 +16,6 @@ const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||||
locale={locale}
|
locale={locale}
|
||||||
active="danmaku"
|
active="danmaku"
|
||||||
brand={t(locale, "meta.product")}
|
brand={t(locale, "meta.product")}
|
||||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
|
||||||
switchLabel={t(other, `locale.${other}` as never)}
|
|
||||||
seo={{
|
seo={{
|
||||||
title: `${t(locale, "danmaku.title")} · ${t(locale, "meta.brand")}`,
|
title: `${t(locale, "danmaku.title")} · ${t(locale, "meta.brand")}`,
|
||||||
path: Astro.url.pathname,
|
path: Astro.url.pathname,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import ErrorBody from "~/components/pages/ErrorBody";
|
import ErrorBody from "~/components/pages/ErrorBody";
|
||||||
import { createAppError, type AppErrorStatus } from "~/lib/errors";
|
import { createAppError, type AppErrorStatus } from "~/lib/errors";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
@ -10,7 +10,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale, status } = Astro.props;
|
const { locale, status } = Astro.props;
|
||||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
const error = createAppError(status);
|
const error = createAppError(status);
|
||||||
const titleKey = `error.${error.kind}.title` as never;
|
const titleKey = `error.${error.kind}.title` as never;
|
||||||
const pageTitle = t(locale, titleKey);
|
const pageTitle = t(locale, titleKey);
|
||||||
|
|
@ -19,8 +18,6 @@ const pageTitle = t(locale, titleKey);
|
||||||
<AppLayout
|
<AppLayout
|
||||||
title={`${pageTitle} · ${t(locale, "meta.brand")}`}
|
title={`${pageTitle} · ${t(locale, "meta.brand")}`}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
|
||||||
switchLabel={t(other, `locale.${other}` as never)}
|
|
||||||
seo={{
|
seo={{
|
||||||
title: `${pageTitle} · ${t(locale, "meta.brand")}`,
|
title: `${pageTitle} · ${t(locale, "meta.brand")}`,
|
||||||
path: Astro.url.pathname,
|
path: Astro.url.pathname,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import HomeBody from "~/components/pages/HomeBody";
|
import HomeBody from "~/components/pages/HomeBody";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
||||||
|
|
@ -8,16 +8,12 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale } = Astro.props;
|
const { locale } = Astro.props;
|
||||||
const otherLocale: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
const switchHref = alternatePath(Astro.url.pathname, otherLocale);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<AppLayout
|
<AppLayout
|
||||||
title={t(locale, "home.title")}
|
title={t(locale, "home.title")}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
active="home"
|
active="home"
|
||||||
switchHref={switchHref}
|
|
||||||
switchLabel={t(otherLocale, `locale.${otherLocale}` as never)}
|
|
||||||
>
|
>
|
||||||
<HomeBody client:load locale={locale} />
|
<HomeBody client:load locale={locale} />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import { Toaster } from "@app/ui";
|
import { Toaster } from "@app/ui";
|
||||||
import { LibraryBody } from "~/components/pages/LibraryBody";
|
import { LibraryBody } from "~/components/pages/LibraryBody";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
@ -9,7 +9,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale } = Astro.props;
|
const { locale } = Astro.props;
|
||||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|
@ -17,8 +16,6 @@ const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||||
locale={locale}
|
locale={locale}
|
||||||
active="library"
|
active="library"
|
||||||
brand={t(locale, "meta.product")}
|
brand={t(locale, "meta.product")}
|
||||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
|
||||||
switchLabel={t(other, `locale.${other}` as never)}
|
|
||||||
seo={{
|
seo={{
|
||||||
title: `${t(locale, "library.title")} · ${t(locale, "meta.brand")}`,
|
title: `${t(locale, "library.title")} · ${t(locale, "meta.brand")}`,
|
||||||
path: Astro.url.pathname,
|
path: Astro.url.pathname,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import { Toaster } from "@app/ui";
|
import { Toaster } from "@app/ui";
|
||||||
import { MountsBody } from "~/components/pages/MountsBody";
|
import { MountsBody } from "~/components/pages/MountsBody";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
@ -9,7 +9,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale } = Astro.props;
|
const { locale } = Astro.props;
|
||||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|
@ -17,8 +16,6 @@ const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||||
locale={locale}
|
locale={locale}
|
||||||
active="mounts"
|
active="mounts"
|
||||||
brand={t(locale, "meta.product")}
|
brand={t(locale, "meta.product")}
|
||||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
|
||||||
switchLabel={t(other, `locale.${other}` as never)}
|
|
||||||
seo={{
|
seo={{
|
||||||
title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`,
|
title: `${t(locale, "mounts.title")} · ${t(locale, "meta.brand")}`,
|
||||||
path: Astro.url.pathname,
|
path: Astro.url.pathname,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
import { alternatePath, type Locale, t } from "@app/i18n";
|
import { type Locale, t } from "@app/i18n";
|
||||||
import { Toaster } from "@app/ui";
|
import { Toaster } from "@app/ui";
|
||||||
import { WatchBody } from "~/components/pages/WatchBody";
|
import { WatchBody } from "~/components/pages/WatchBody";
|
||||||
import AppLayout from "~/layouts/AppLayout.astro";
|
import AppLayout from "~/layouts/AppLayout.astro";
|
||||||
|
|
@ -10,7 +10,6 @@ export interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale, mediaItemId } = Astro.props;
|
const { locale, mediaItemId } = Astro.props;
|
||||||
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<AppLayout
|
<AppLayout
|
||||||
|
|
@ -18,8 +17,6 @@ const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
|
||||||
locale={locale}
|
locale={locale}
|
||||||
brand={t(locale, "meta.product")}
|
brand={t(locale, "meta.product")}
|
||||||
hideFooter
|
hideFooter
|
||||||
switchHref={alternatePath(Astro.url.pathname, other)}
|
|
||||||
switchLabel={t(other, `locale.${other}` as never)}
|
|
||||||
seo={{
|
seo={{
|
||||||
title: `${t(locale, "watch.title")} · ${t(locale, "meta.brand")}`,
|
title: `${t(locale, "watch.title")} · ${t(locale, "meta.brand")}`,
|
||||||
path: Astro.url.pathname,
|
path: Astro.url.pathname,
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,14 @@ test.describe("locale 规范化", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("header 语言切换链接跳到另一语言", async ({ page }) => {
|
test("header 语言下拉显示当前语言并可切换", async ({ page }) => {
|
||||||
await page.goto("/zh-CN");
|
await page.goto("/zh-CN");
|
||||||
const switchLink = page.locator('[data-switch-locale="en"]');
|
const trigger = page.getByRole("button", { name: "语言" });
|
||||||
await expect(switchLink).toBeVisible();
|
await expect(trigger).toBeVisible();
|
||||||
await switchLink.click();
|
await expect(trigger).toContainText("中文");
|
||||||
|
await trigger.click();
|
||||||
|
const item = page.locator('[data-switch-locale="en"]');
|
||||||
|
await expect(item).toBeVisible();
|
||||||
|
await item.click();
|
||||||
await expect(page).toHaveURL(/\/en$/);
|
await expect(page).toHaveURL(/\/en$/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,92 @@ export async function openCommentXml(episodeId: number): Promise<string | null>
|
||||||
const body = res.json as { comments?: Array<Record<string, unknown>> };
|
const body = res.json as { comments?: Array<Record<string, unknown>> };
|
||||||
return openCommentsToXml(body.comments ?? []);
|
return openCommentsToXml(body.comments ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OpenAnimeBrief = {
|
||||||
|
animeId: number;
|
||||||
|
animeTitle: string | null;
|
||||||
|
type: string | null;
|
||||||
|
typeDescription: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
episodes: OpenEpisodeBrief[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenEpisodeBrief = {
|
||||||
|
episodeId: number;
|
||||||
|
episodeTitle: string | null;
|
||||||
|
type: string | null;
|
||||||
|
typeDescription: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawEpisode = {
|
||||||
|
episodeId?: number | string;
|
||||||
|
episodeTitle?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
typeDescription?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawAnime = {
|
||||||
|
animeId?: number | string;
|
||||||
|
animeTitle?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
typeDescription?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
episodes?: RawEpisode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function num(v: number | string | undefined): number | null {
|
||||||
|
const n = typeof v === "number" ? v : Number.parseInt(String(v ?? ""), 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEpisode(e: RawEpisode): OpenEpisodeBrief | null {
|
||||||
|
const episodeId = num(e.episodeId);
|
||||||
|
if (episodeId == null) return null;
|
||||||
|
return {
|
||||||
|
episodeId,
|
||||||
|
episodeTitle: e.episodeTitle ?? null,
|
||||||
|
type: e.type ?? null,
|
||||||
|
typeDescription: e.typeDescription ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnime(a: RawAnime): OpenAnimeBrief | null {
|
||||||
|
const animeId = num(a.animeId);
|
||||||
|
if (animeId == null) return null;
|
||||||
|
return {
|
||||||
|
animeId,
|
||||||
|
animeTitle: a.animeTitle ?? null,
|
||||||
|
type: a.type ?? null,
|
||||||
|
typeDescription: a.typeDescription ?? null,
|
||||||
|
imageUrl: a.imageUrl ?? null,
|
||||||
|
episodes: (a.episodes ?? []).map(parseEpisode).filter((x): x is OpenEpisodeBrief => x != null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenSearchOutcome =
|
||||||
|
| { ok: true; animes: OpenAnimeBrief[] }
|
||||||
|
| { ok: false; errorMessage: string; animes: [] };
|
||||||
|
|
||||||
|
/** GET /api/v2/search/episodes?keyword= — 按关键词搜作品(含集列表) */
|
||||||
|
export async function openSearchEpisodes(keyword: string): Promise<OpenSearchOutcome> {
|
||||||
|
const q = encodeURIComponent(keyword.trim());
|
||||||
|
const res = await openFetchJson(`/api/v2/search/episodes?keyword=${q}`);
|
||||||
|
if (!res.ok) return { ok: false, errorMessage: res.error, animes: [] };
|
||||||
|
const body = res.json as { animes?: RawAnime[]; errorCode?: number };
|
||||||
|
const animes = (body.animes ?? [])
|
||||||
|
.map(parseAnime)
|
||||||
|
.filter((x): x is OpenAnimeBrief => x != null);
|
||||||
|
return { ok: true, animes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpenEpisodesOutcome =
|
||||||
|
| { ok: true; anime: OpenAnimeBrief | null }
|
||||||
|
| { ok: false; errorMessage: string; anime: null };
|
||||||
|
|
||||||
|
/** GET /api/v2/episodes/{animeId} — 作品下的集列表 */
|
||||||
|
export async function openAnimeEpisodes(animeId: number): Promise<OpenEpisodesOutcome> {
|
||||||
|
const res = await openFetchJson(`/api/v2/episodes/${animeId}`);
|
||||||
|
if (!res.ok) return { ok: false, errorMessage: res.error, anime: null };
|
||||||
|
const body = res.json as RawAnime;
|
||||||
|
return { ok: true, anime: parseAnime(body) };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,19 @@ export type { OpenMatchOutcome, RankHint } from "./match.js";
|
||||||
export {
|
export {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
openApiBase,
|
openApiBase,
|
||||||
|
openAnimeEpisodes,
|
||||||
openCommentXml,
|
openCommentXml,
|
||||||
openFetchJson,
|
openFetchJson,
|
||||||
openHeaders,
|
openHeaders,
|
||||||
openMatch,
|
openMatch,
|
||||||
|
openSearchEpisodes,
|
||||||
readOpenCredentials,
|
readOpenCredentials,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
export type { OpenCredentials } from "./client.js";
|
export type {
|
||||||
|
OpenAnimeBrief,
|
||||||
|
OpenCredentials,
|
||||||
|
OpenEpisodeBrief,
|
||||||
|
OpenEpisodesOutcome,
|
||||||
|
OpenSearchOutcome,
|
||||||
|
} from "./client.js";
|
||||||
export type { DanmakuComment, MatchCandidate } from "./types.js";
|
export type { DanmakuComment, MatchCandidate } from "./types.js";
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,18 @@ export const en: Record<MessageKey, string> = {
|
||||||
"watch.blockKeywordAdd": "Add block word",
|
"watch.blockKeywordAdd": "Add block word",
|
||||||
"watch.matchFailed": "Danmaku match failed",
|
"watch.matchFailed": "Danmaku match failed",
|
||||||
"watch.matchEmpty": "No episode candidates found",
|
"watch.matchEmpty": "No episode candidates found",
|
||||||
|
"watch.danmakuList": "Danmaku list",
|
||||||
|
"watch.danmakuListEmpty": "No danmaku yet",
|
||||||
|
"watch.clickToSeek": "Click to seek",
|
||||||
|
"watch.fileMatch": "Match by file",
|
||||||
|
"watch.manualPick": "Pick danmaku source",
|
||||||
|
"watch.manualPickHint": "Steps: search title → pick episode → fetch that episode (DanDanPlay open network)",
|
||||||
|
"watch.sourceSearch": "Search",
|
||||||
|
"watch.sourceSearchPlaceholder": "Search by title keyword",
|
||||||
|
"watch.sourceBackToAnime": "Back to titles",
|
||||||
|
"watch.searchFailed": "Search failed",
|
||||||
|
"watch.searchEmpty": "No results",
|
||||||
|
"watch.episodeCount": "{count} eps",
|
||||||
"watch.blockKeywordPlaceholder": "Type a block word and press Enter",
|
"watch.blockKeywordPlaceholder": "Type a block word and press Enter",
|
||||||
|
|
||||||
"danmaku.title": "Danmaku settings",
|
"danmaku.title": "Danmaku settings",
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,18 @@ export const zhCN = {
|
||||||
"watch.blockKeywordAdd": "添加屏蔽词",
|
"watch.blockKeywordAdd": "添加屏蔽词",
|
||||||
"watch.matchFailed": "弹幕匹配失败",
|
"watch.matchFailed": "弹幕匹配失败",
|
||||||
"watch.matchEmpty": "未找到可选手集",
|
"watch.matchEmpty": "未找到可选手集",
|
||||||
|
"watch.danmakuList": "弹幕列表",
|
||||||
|
"watch.danmakuListEmpty": "暂无弹幕",
|
||||||
|
"watch.clickToSeek": "点击跳转到该时间点",
|
||||||
|
"watch.fileMatch": "按文件匹配",
|
||||||
|
"watch.manualPick": "手动指定弹幕源",
|
||||||
|
"watch.manualPickHint": "层级:搜索作品 → 选择集数 → 取该集弹幕(弹弹play 开放网络)",
|
||||||
|
"watch.sourceSearch": "搜索",
|
||||||
|
"watch.sourceSearchPlaceholder": "输入作品名关键词",
|
||||||
|
"watch.sourceBackToAnime": "返回作品列表",
|
||||||
|
"watch.searchFailed": "搜索失败",
|
||||||
|
"watch.searchEmpty": "没有找到结果",
|
||||||
|
"watch.episodeCount": "{count} 集",
|
||||||
"watch.blockKeywordPlaceholder": "输入屏蔽词后回车",
|
"watch.blockKeywordPlaceholder": "输入屏蔽词后回车",
|
||||||
|
|
||||||
"danmaku.title": "弹幕设置",
|
"danmaku.title": "弹幕设置",
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,12 @@ export const mediaRouter = t.router({
|
||||||
.mutation(({ ctx, input }) =>
|
.mutation(({ ctx, input }) =>
|
||||||
danmakuService.selectMatch(ctx.userId, input.mediaItemId, input.episodeId),
|
danmakuService.selectMatch(ctx.userId, input.mediaItemId, input.episodeId),
|
||||||
),
|
),
|
||||||
|
danmakuSearchSource: protectedProcedure
|
||||||
|
.input(danmakuSchemas.searchSource)
|
||||||
|
.query(({ input }) => danmakuService.searchSource(input.keyword)),
|
||||||
|
danmakuSourceEpisodes: protectedProcedure
|
||||||
|
.input(danmakuSchemas.sourceEpisodes)
|
||||||
|
.query(({ input }) => danmakuService.sourceEpisodes(input.animeId)),
|
||||||
danmakuImport: protectedProcedure
|
danmakuImport: protectedProcedure
|
||||||
.input(danmakuSchemas.import)
|
.input(danmakuSchemas.import)
|
||||||
.mutation(({ ctx, input }) =>
|
.mutation(({ ctx, input }) =>
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,19 @@ import {
|
||||||
hasOpenCredentials,
|
hasOpenCredentials,
|
||||||
headMd5,
|
headMd5,
|
||||||
matchFileName,
|
matchFileName,
|
||||||
|
openAnimeEpisodes,
|
||||||
openCommentXml,
|
openCommentXml,
|
||||||
openMatch,
|
openMatch,
|
||||||
|
openSearchEpisodes,
|
||||||
rankCandidates,
|
rankCandidates,
|
||||||
} from "@app/danmaku";
|
} from "@app/danmaku";
|
||||||
import type { DanmakuFetchOutput, DanmakuMatchOutput, DanmakuSettingsOutput } from "@app/types";
|
import type {
|
||||||
|
DanmakuFetchOutput,
|
||||||
|
DanmakuMatchOutput,
|
||||||
|
DanmakuSettingsOutput,
|
||||||
|
DanmakuSourceEpisodesOutput,
|
||||||
|
DanmakuSourceSearchOutput,
|
||||||
|
} from "@app/types";
|
||||||
import { decryptSecret } from "./secret.js";
|
import { decryptSecret } from "./secret.js";
|
||||||
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
|
import { createWebdav, joinWebdavPath, listDirectory, type DirEntry } from "./webdav-client.js";
|
||||||
|
|
||||||
|
|
@ -295,6 +303,61 @@ export const danmakuService = {
|
||||||
return { ok: true, count: (xml.match(/<d /g) ?? []).length };
|
return { ok: true, count: (xml.match(/<d /g) ?? []).length };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 手动指定弹幕源:搜作品 */
|
||||||
|
async searchSource(keyword: string): Promise<DanmakuSourceSearchOutput> {
|
||||||
|
if (!hasOpenCredentials()) {
|
||||||
|
return { ok: false, message: "未配置开放弹幕网络", animes: [] };
|
||||||
|
}
|
||||||
|
const outcome = await openSearchEpisodes(keyword);
|
||||||
|
if (!outcome.ok) {
|
||||||
|
return { ok: false, message: outcome.errorMessage, animes: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
animes: outcome.animes.map((a) => ({
|
||||||
|
animeId: a.animeId,
|
||||||
|
animeTitle: a.animeTitle,
|
||||||
|
typeDescription: a.typeDescription,
|
||||||
|
imageUrl: a.imageUrl,
|
||||||
|
episodeCount: a.episodes.length,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 手动指定弹幕源:列作品集数 */
|
||||||
|
async sourceEpisodes(animeId: number): Promise<DanmakuSourceEpisodesOutput> {
|
||||||
|
if (!hasOpenCredentials()) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: "未配置开放弹幕网络",
|
||||||
|
animeId,
|
||||||
|
animeTitle: null,
|
||||||
|
episodes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const outcome = await openAnimeEpisodes(animeId);
|
||||||
|
if (!outcome.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: outcome.errorMessage,
|
||||||
|
animeId,
|
||||||
|
animeTitle: null,
|
||||||
|
episodes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const anime = outcome.anime;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
animeId,
|
||||||
|
animeTitle: anime?.animeTitle ?? null,
|
||||||
|
episodes: (anime?.episodes ?? []).map((e) => ({
|
||||||
|
episodeId: e.episodeId,
|
||||||
|
episodeTitle: e.episodeTitle,
|
||||||
|
typeDescription: e.typeDescription,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async getPrefs(userId: string): Promise<DanmakuSettingsOutput> {
|
async getPrefs(userId: string): Promise<DanmakuSettingsOutput> {
|
||||||
const row = await danmakuPrefsDao.getByUser(userId);
|
const row = await danmakuPrefsDao.getByUser(userId);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,14 @@ export const danmakuSchemas = {
|
||||||
mediaItemId: id,
|
mediaItemId: id,
|
||||||
episodeId: z.number().int().positive(),
|
episodeId: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
|
/** 手动搜作品(弹弹play 开放网络) */
|
||||||
|
searchSource: z.object({
|
||||||
|
keyword: z.string().min(1, "请输入关键词").max(200),
|
||||||
|
}),
|
||||||
|
/** 手动列作品集数 */
|
||||||
|
sourceEpisodes: z.object({
|
||||||
|
animeId: z.number().int().positive(),
|
||||||
|
}),
|
||||||
/** 导入本地 XML 正文(服务端持久化);粗检含弹幕 <d 节点 */
|
/** 导入本地 XML 正文(服务端持久化);粗检含弹幕 <d 节点 */
|
||||||
import: z.object({
|
import: z.object({
|
||||||
mediaItemId: id,
|
mediaItemId: id,
|
||||||
|
|
@ -267,6 +275,36 @@ export type DanmakuMatchOutput = {
|
||||||
highConfidence: boolean;
|
highConfidence: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 手动指定弹幕源:作品摘要 */
|
||||||
|
export type DandanAnimeBrief = {
|
||||||
|
animeId: number;
|
||||||
|
animeTitle: string | null;
|
||||||
|
typeDescription: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
episodeCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 手动指定弹幕源:集摘要 */
|
||||||
|
export type DandanEpisodeBrief = {
|
||||||
|
episodeId: number;
|
||||||
|
episodeTitle: string | null;
|
||||||
|
typeDescription: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DanmakuSourceSearchOutput = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string | undefined;
|
||||||
|
animes: DandanAnimeBrief[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DanmakuSourceEpisodesOutput = {
|
||||||
|
ok: boolean;
|
||||||
|
message?: string | undefined;
|
||||||
|
animeId: number;
|
||||||
|
animeTitle: string | null;
|
||||||
|
episodes: DandanEpisodeBrief[];
|
||||||
|
};
|
||||||
|
|
||||||
export type DanmakuSettingsOutput = {
|
export type DanmakuSettingsOutput = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue