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:
noelorin 2026-09-27 11:19:31 +08:00
parent 5f5dd968cb
commit 2bcf9f0230
19 changed files with 635 additions and 152 deletions

View File

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

View File

@ -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,6 +671,8 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
</Button> </Button>
</div> </div>
</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 className="relative aspect-video w-full overflow-hidden rounded-lg bg-black">
<div ref={playerRef} className="absolute inset-0" /> <div ref={playerRef} className="absolute inset-0" />
{playError && ( {playError && (
@ -678,6 +710,57 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
</span> </span>
<span>{t(locale, "watch.progressHint")}</span> <span>{t(locale, "watch.progressHint")}</span>
</div> </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 &&
(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>
<section className="space-y-2 rounded-lg border border-border p-3"> <section className="space-y-2 rounded-lg border border-border p-3">
<p className="u-label">{t(locale, "watch.filterTitle")}</p> <p className="u-label">{t(locale, "watch.filterTitle")}</p>
@ -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,6 +848,9 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
× ×
</Button> </Button>
</div> </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 && ( {matchMut.data?.highConfidence && (
<p className="text-xs text-primary"> <p className="text-xs text-primary">
{t(locale, "watch.matchAutoBound")} {t(locale, "watch.matchAutoBound")}
@ -786,7 +872,7 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
</p> </p>
)} )}
{candidates.length > 0 && ( {candidates.length > 0 && (
<ul className="max-h-80 space-y-2 overflow-y-auto"> <ul className="space-y-2">
{candidates.map((c) => ( {candidates.map((c) => (
<li key={c.episodeId}> <li key={c.episodeId}>
<button <button
@ -800,7 +886,9 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
</span> </span>
<span className="ml-2 text-muted-foreground"> <span className="ml-2 text-muted-foreground">
{c.episodeTitle ?? {c.episodeTitle ??
t(locale, "watch.episodeN", { n: c.episodeId })} t(locale, "watch.episodeN", {
n: c.episodeId,
})}
</span> </span>
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-xs text-muted-foreground">
episodeId {c.episodeId} episodeId {c.episodeId}
@ -815,6 +903,148 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) {
))} ))}
</ul> </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>
</div> </div>
)} )}

View File

@ -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>

View File

@ -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} />

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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>

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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$/);
}); });

View File

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

View File

@ -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";

View File

@ -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",

View File

@ -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": "弹幕设置",

View File

@ -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 }) =>

View File

@ -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 {

View File

@ -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;