1258 lines
55 KiB
TypeScript
1258 lines
55 KiB
TypeScript
import type { Locale } from "@app/i18n";
|
||
import { t } from "@app/i18n";
|
||
import { parseEpisodeFromFilename, type LibraryGroup } from "@app/types";
|
||
import { Badge, Button, Input, toast } from "@app/ui";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { cn } from "~/lib/utils";
|
||
import { go, queryParam, setQueryParams } from "~/lib/nav";
|
||
import { createTRPCReactClient, trpc } from "~/lib/trpc";
|
||
import { formatTrpcError } from "~/lib/trpc-error";
|
||
|
||
type ViewMode = "wall" | "list";
|
||
|
||
const PAGE_SIZE = 24;
|
||
|
||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||
const [debounced, setDebounced] = useState(value);
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => setDebounced(value), delayMs);
|
||
return () => clearTimeout(timer);
|
||
}, [value, delayMs]);
|
||
return debounced;
|
||
}
|
||
|
||
function PosterBox(props: { posterUrl: string | null; locale: Locale; className?: string }) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"flex aspect-[3/4] items-center justify-center bg-muted text-xs text-muted-foreground",
|
||
props.className,
|
||
)}
|
||
>
|
||
{props.posterUrl ? (
|
||
<img
|
||
src={props.posterUrl}
|
||
alt=""
|
||
loading="lazy"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
t(props.locale, "library.noPoster")
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MountTabs(props: {
|
||
mounts: { id: string; name: string }[];
|
||
value: string;
|
||
onChange: (mountId: string) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-wrap gap-1 rounded-md border border-border p-0.5" role="tablist">
|
||
{props.mounts.map((m) => (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={m.id === props.value}
|
||
className={cn(
|
||
"rounded px-3 py-1.5 text-sm transition-colors",
|
||
m.id === props.value
|
||
? "bg-primary font-medium text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted",
|
||
)}
|
||
onClick={() => props.onChange(m.id)}
|
||
>
|
||
{m.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 作品详情:剧集按需从 librarySeries 拉取(限当前挂载) */
|
||
function GroupDetail(props: {
|
||
bangumiId: number;
|
||
mountId: string;
|
||
locale: Locale;
|
||
onBack: () => void;
|
||
onPlay: (mediaItemId: string, bangumiId: number) => void;
|
||
onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined;
|
||
onManage?: ((group: ManageTarget) => void) | undefined;
|
||
}) {
|
||
const { locale } = props;
|
||
const seriesQuery = trpc.media.librarySeries.useQuery(
|
||
{ bangumiId: props.bangumiId, mountId: props.mountId || undefined },
|
||
{ enabled: Boolean(props.mountId) },
|
||
);
|
||
const group = seriesQuery.data;
|
||
return (
|
||
<div className="space-y-4">
|
||
<Button variant="outline" size="sm" onClick={props.onBack}>
|
||
← {t(locale, "library.backToList")}
|
||
</Button>
|
||
{seriesQuery.isPending && (
|
||
<p className="text-sm text-muted-foreground">{t(locale, "browse.loading")}</p>
|
||
)}
|
||
{seriesQuery.isError && (
|
||
<p className="text-sm text-red-600">{t(locale, "browse.loadError")}</p>
|
||
)}
|
||
{group && (
|
||
<div className="flex flex-col gap-4 sm:flex-row">
|
||
<div className="w-full max-w-40 overflow-hidden rounded-lg border border-border sm:w-40">
|
||
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
||
</div>
|
||
<div className="min-w-0 flex-1 space-y-2">
|
||
<div className="flex items-start gap-2">
|
||
<h2
|
||
className="min-w-0 flex-1 truncate text-lg font-semibold"
|
||
title={group.title}
|
||
>
|
||
{group.title}
|
||
</h2>
|
||
{props.onManage && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
props.onManage?.({
|
||
bangumiId: props.bangumiId,
|
||
title: group.title,
|
||
posterUrl: group.posterUrl,
|
||
})
|
||
}
|
||
>
|
||
{t(locale, "library.manageGroup")}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t(locale, "library.episodeCount", { count: group.episodes.length })}
|
||
</p>
|
||
<div className="space-y-1">
|
||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||
{t(locale, "library.episodes")}
|
||
</div>
|
||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||
{group.episodes.map((ep) => {
|
||
// 落库集数优先,null 时用共享正则从文件名兜底解析
|
||
const epNo =
|
||
ep.epNumber ?? parseEpisodeFromFilename(ep.rawName);
|
||
return (
|
||
<li
|
||
key={ep.id}
|
||
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||
>
|
||
<span className="w-10 shrink-0 font-mono text-xs text-muted-foreground">
|
||
{epNo != null ? `EP${epNo}` : "—"}
|
||
</span>
|
||
<span
|
||
className="min-w-0 flex-1 truncate"
|
||
title={ep.rawName}
|
||
>
|
||
{ep.rawName}
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
onClick={() => props.onPlay(ep.id, props.bangumiId)}
|
||
>
|
||
{t(locale, "library.play")}
|
||
</Button>
|
||
{props.onMatch && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
props.onMatch?.(ep.id, ep.rawName)
|
||
}
|
||
>
|
||
{t(locale, "library.match")}
|
||
</Button>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ContinueRow(props: { locale: Locale }) {
|
||
const { locale } = props;
|
||
const utils = trpc.useUtils();
|
||
const list = trpc.media.playbackContinue.useQuery({ limit: 6 });
|
||
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
|
||
const [confirmClear, setConfirmClear] = useState(false);
|
||
const remove = trpc.media.playbackDelete.useMutation({
|
||
onSuccess: () => {
|
||
setConfirmRemoveId(null);
|
||
toast.success(t(locale, "library.removedToast"));
|
||
void utils.media.playbackContinue.invalidate();
|
||
},
|
||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.removeError"))),
|
||
});
|
||
const clear = trpc.media.playbackClear.useMutation({
|
||
onSuccess: () => {
|
||
setConfirmClear(false);
|
||
toast.success(t(locale, "library.clearedToast"));
|
||
void utils.media.playbackContinue.invalidate();
|
||
},
|
||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.clearError"))),
|
||
});
|
||
const items = list.data ?? [];
|
||
if (list.isPending) return null;
|
||
if (items.length === 0) return null;
|
||
return (
|
||
<section className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<p className="u-label">{t(locale, "library.continueTitle")}</p>
|
||
<span className="u-dotline flex-1" aria-hidden="true" />
|
||
<button
|
||
type="button"
|
||
className="shrink-0 text-xs text-muted-foreground transition-colors hover:text-destructive"
|
||
disabled={clear.isPending}
|
||
onClick={() => {
|
||
setConfirmRemoveId(null);
|
||
if (confirmClear) clear.mutate({});
|
||
else setConfirmClear(true);
|
||
}}
|
||
>
|
||
{confirmClear
|
||
? t(locale, "library.confirmClearContinue")
|
||
: t(locale, "library.clearContinue")}
|
||
</button>
|
||
</div>
|
||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||
{items.map((item) => {
|
||
const pct =
|
||
item.durationMs > 0
|
||
? Math.min(100, Math.round((item.positionMs / item.durationMs) * 100))
|
||
: 0;
|
||
return (
|
||
<div
|
||
key={item.mediaItemId}
|
||
className="relative w-40 shrink-0 overflow-hidden rounded-lg border border-border bg-card transition-colors hover:border-primary/60"
|
||
>
|
||
<button
|
||
type="button"
|
||
className="block w-full text-left"
|
||
onClick={() => {
|
||
setConfirmRemoveId(null);
|
||
setConfirmClear(false);
|
||
go(`/${locale}/watch?id=${item.mediaItemId}`);
|
||
}}
|
||
>
|
||
<PosterBox posterUrl={item.posterUrl} locale={locale} />
|
||
<div className="p-2">
|
||
<div
|
||
className="truncate text-sm font-medium"
|
||
title={item.title}
|
||
>
|
||
{item.title}
|
||
</div>
|
||
<div className="mt-1 h-1 w-full overflow-hidden rounded bg-muted">
|
||
<div
|
||
className="h-full bg-primary"
|
||
style={{ width: `${pct}%` }}
|
||
/>
|
||
</div>
|
||
<div className="mt-1 font-mono text-[11px] text-muted-foreground">
|
||
{t(locale, "library.continue")} · {pct}%
|
||
</div>
|
||
</div>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="absolute top-1.5 right-1.5 z-10 rounded-md border border-border bg-background/90 px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-destructive"
|
||
disabled={remove.isPending}
|
||
onClick={() => {
|
||
setConfirmClear(false);
|
||
if (confirmRemoveId === item.mediaItemId) {
|
||
remove.mutate({ mediaItemId: item.mediaItemId });
|
||
} else {
|
||
setConfirmRemoveId(item.mediaItemId);
|
||
}
|
||
}}
|
||
>
|
||
{confirmRemoveId === item.mediaItemId
|
||
? t(locale, "library.confirmRemoveContinue")
|
||
: t(locale, "library.removeContinue")}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 「加载更多」分页累积:按 offset 缓存已取页。
|
||
* reqKey/refreshKey 变更时丢弃旧累积,避免 mutation 后旧页与新页混排。
|
||
*/
|
||
type PagedAcc<T> = {
|
||
key: string;
|
||
total: number;
|
||
pages: Record<number, T[]>;
|
||
};
|
||
|
||
function usePagedAccumulator<T>(params: {
|
||
reqKey: string;
|
||
refreshKey: number;
|
||
data: { total: number; items: T[] } | undefined;
|
||
offset: number;
|
||
}): { items: T[]; total: number; hasMore: boolean } {
|
||
const { reqKey, refreshKey, data, offset } = params;
|
||
const [acc, setAcc] = useState<PagedAcc<T> | null>(null);
|
||
const fullKey = `${reqKey}|r${refreshKey}`;
|
||
const [prevFullKey, setPrevFullKey] = useState(fullKey);
|
||
// 渲染期重置:条件或 refreshKey 变化后丢弃旧页
|
||
if (prevFullKey !== fullKey) {
|
||
setPrevFullKey(fullKey);
|
||
setAcc(null);
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!data) return;
|
||
setAcc((prev) => {
|
||
if (!prev || prev.key !== fullKey || offset === 0) {
|
||
return { key: fullKey, total: data.total, pages: { [offset]: data.items } };
|
||
}
|
||
return {
|
||
key: prev.key,
|
||
total: data.total,
|
||
pages: { ...prev.pages, [offset]: data.items },
|
||
};
|
||
});
|
||
}, [data, fullKey, offset]);
|
||
|
||
const items: T[] = [];
|
||
let total = 0;
|
||
if (acc && acc.key === fullKey) {
|
||
total = acc.total;
|
||
for (const pageOffset of Object.keys(acc.pages)
|
||
.map(Number)
|
||
.sort((a, b) => a - b)) {
|
||
const page = acc.pages[pageOffset];
|
||
if (page) items.push(...page);
|
||
}
|
||
}
|
||
return { items, total, hasMore: items.length < total };
|
||
}
|
||
|
||
type ManageTarget = { bangumiId: number; title: string; posterUrl: string | null };
|
||
|
||
/** 海报墙组管理弹窗:编辑标题/封面、重新匹配、取消匹配、删除记录 */
|
||
function GroupManageModal(props: {
|
||
locale: Locale;
|
||
mountId: string;
|
||
group: ManageTarget;
|
||
onClose: () => void;
|
||
onChanged: () => void;
|
||
onRemoved: (bangumiId: number) => void;
|
||
}) {
|
||
const { locale, mountId, group } = props;
|
||
const [tab, setTab] = useState<"edit" | "rematch">("edit");
|
||
const [title, setTitle] = useState(group.title);
|
||
const [poster, setPoster] = useState(group.posterUrl ?? "");
|
||
const [searchQ, setSearchQ] = useState("");
|
||
const [confirmUnbind, setConfirmUnbind] = useState(false);
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
|
||
const search = trpc.media.scrapeSearch.useQuery(
|
||
{ q: searchQ },
|
||
{ enabled: tab === "rematch" && searchQ.length > 1 },
|
||
);
|
||
const save = trpc.media.libraryUpdateGroup.useMutation({
|
||
onSuccess: () => {
|
||
toast.success(t(locale, "library.saved"));
|
||
props.onChanged();
|
||
},
|
||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))),
|
||
});
|
||
const unbind = trpc.media.scrapeUnbindGroup.useMutation({
|
||
onSuccess: () => {
|
||
toast.success(t(locale, "library.unbound"));
|
||
props.onChanged();
|
||
props.onRemoved(group.bangumiId);
|
||
props.onClose();
|
||
},
|
||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))),
|
||
});
|
||
const remove = trpc.media.libraryDeleteGroup.useMutation({
|
||
onSuccess: () => {
|
||
toast.success(t(locale, "library.groupDeleted"));
|
||
props.onChanged();
|
||
props.onRemoved(group.bangumiId);
|
||
props.onClose();
|
||
},
|
||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))),
|
||
});
|
||
const rebind = trpc.media.scrapeBindGroup.useMutation({
|
||
onSuccess: () => {
|
||
toast.success(t(locale, "library.matched"));
|
||
props.onChanged();
|
||
setTab("edit");
|
||
setSearchQ("");
|
||
},
|
||
onError: (err) => toast.error(formatTrpcError(err, t(locale, "library.groupOpError"))),
|
||
});
|
||
|
||
const busy = save.isPending || unbind.isPending || remove.isPending || rebind.isPending;
|
||
const canSave = title.trim().length > 0;
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||
<div className="w-full max-w-lg space-y-3 rounded-lg border border-border bg-background p-4">
|
||
<div className="flex items-center gap-2">
|
||
<h2 className="shrink-0 font-medium">
|
||
{t(locale, "library.groupManageTitle")}
|
||
</h2>
|
||
<span className="truncate text-xs text-muted-foreground" title={title}>
|
||
{title}
|
||
</span>
|
||
</div>
|
||
<div className="flex rounded-md border border-border p-0.5">
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"flex-1 rounded px-3 py-1.5 text-sm transition-colors",
|
||
tab === "edit"
|
||
? "bg-primary font-medium text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted",
|
||
)}
|
||
onClick={() => setTab("edit")}
|
||
>
|
||
{t(locale, "library.editInfo")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"flex-1 rounded px-3 py-1.5 text-sm transition-colors",
|
||
tab === "rematch"
|
||
? "bg-primary font-medium text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted",
|
||
)}
|
||
onClick={() => setTab("rematch")}
|
||
>
|
||
{t(locale, "library.rematch")}
|
||
</button>
|
||
</div>
|
||
|
||
{tab === "edit" ? (
|
||
<div className="space-y-3">
|
||
<div className="space-y-1 text-sm">
|
||
<label htmlFor="group-manage-title" className="text-muted-foreground">
|
||
{t(locale, "library.editTitleLabel")}
|
||
</label>
|
||
<Input
|
||
id="group-manage-title"
|
||
value={title}
|
||
disabled={busy}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1 text-sm">
|
||
<label htmlFor="group-manage-poster" className="text-muted-foreground">
|
||
{t(locale, "library.posterLabel")}
|
||
</label>
|
||
<Input
|
||
id="group-manage-poster"
|
||
value={poster}
|
||
disabled={busy}
|
||
placeholder={t(locale, "library.posterPlaceholder")}
|
||
onChange={(e) => setPoster(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
disabled={!canSave || busy}
|
||
onClick={() =>
|
||
save.mutate({
|
||
mountId,
|
||
bangumiId: group.bangumiId,
|
||
title: title.trim(),
|
||
posterUrl: poster.trim() === "" ? null : poster.trim(),
|
||
})
|
||
}
|
||
>
|
||
{t(locale, "library.save")}
|
||
</Button>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(locale, "library.groupOpHint")}
|
||
</p>
|
||
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setConfirmDelete(false);
|
||
if (confirmUnbind) {
|
||
unbind.mutate({
|
||
mountId,
|
||
bangumiId: group.bangumiId,
|
||
});
|
||
} else {
|
||
setConfirmUnbind(true);
|
||
}
|
||
}}
|
||
>
|
||
{confirmUnbind
|
||
? t(locale, "library.confirmUnbind")
|
||
: t(locale, "library.unbind")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setConfirmUnbind(false);
|
||
if (confirmDelete) {
|
||
remove.mutate({
|
||
mountId,
|
||
bangumiId: group.bangumiId,
|
||
});
|
||
} else {
|
||
setConfirmDelete(true);
|
||
}
|
||
}}
|
||
>
|
||
{confirmDelete
|
||
? t(locale, "library.confirmDeleteGroup")
|
||
: t(locale, "library.deleteGroup")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-3">
|
||
<Input
|
||
placeholder={t(locale, "library.searchWork")}
|
||
value={searchQ}
|
||
onChange={(e) => setSearchQ(e.target.value)}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(locale, "library.candidates")}
|
||
</p>
|
||
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
|
||
{(search.data ?? []).map((hit) => (
|
||
<li
|
||
key={hit.id}
|
||
className="flex items-center justify-between gap-2 p-2 text-sm"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate">{hit.nameCn || hit.name}</div>
|
||
{hit.nameCn && hit.name && (
|
||
<div className="truncate text-xs text-muted-foreground">
|
||
{hit.name}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
disabled={rebind.isPending}
|
||
onClick={() =>
|
||
rebind.mutate({
|
||
mountId,
|
||
bangumiId: group.bangumiId,
|
||
newBangumiId: hit.id,
|
||
})
|
||
}
|
||
>
|
||
{t(locale, "library.bind")}
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2 border-t border-border pt-3">
|
||
<Button variant="outline" size="sm" disabled={busy} onClick={props.onClose}>
|
||
{t(locale, "library.cancel")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WallView(props: {
|
||
locale: Locale;
|
||
mountId: string;
|
||
q: string;
|
||
refreshKey: number;
|
||
selectedBangumiId: number | null;
|
||
onSelect: (bangumiId: number | null) => void;
|
||
onPlay: (mediaItemId: string, bangumiId: number) => void;
|
||
onMatch?: ((mediaItemId: string, rawName?: string) => void) | undefined;
|
||
onManage?: ((group: ManageTarget) => void) | undefined;
|
||
onClearSearch?: (() => void) | undefined;
|
||
hideEmpty?: boolean;
|
||
mountsEmpty?: boolean;
|
||
}) {
|
||
const { locale, mountId, q } = props;
|
||
const [offset, setOffset] = useState(0);
|
||
const reqKey = `${mountId}|${q}`;
|
||
const [prevReqKey, setPrevReqKey] = useState(reqKey);
|
||
if (prevReqKey !== reqKey) {
|
||
setPrevReqKey(reqKey);
|
||
setOffset(0);
|
||
}
|
||
const [prevRefresh, setPrevRefresh] = useState(props.refreshKey);
|
||
if (prevRefresh !== props.refreshKey) {
|
||
setPrevRefresh(props.refreshKey);
|
||
setOffset(0);
|
||
}
|
||
const groupsQuery = trpc.media.libraryGroups.useQuery(
|
||
{ mountId, q: q || undefined, limit: PAGE_SIZE, offset },
|
||
{ enabled: Boolean(mountId) },
|
||
);
|
||
const paged = usePagedAccumulator<LibraryGroup>({
|
||
reqKey,
|
||
refreshKey: props.refreshKey,
|
||
data: groupsQuery.data
|
||
? { total: groupsQuery.data.total, items: groupsQuery.data.groups }
|
||
: undefined,
|
||
offset,
|
||
});
|
||
const groups = paged.items;
|
||
const hasMore = paged.hasMore;
|
||
|
||
if (props.selectedBangumiId != null) {
|
||
return (
|
||
<GroupDetail
|
||
bangumiId={props.selectedBangumiId}
|
||
mountId={mountId}
|
||
locale={locale}
|
||
onBack={() => props.onSelect(null)}
|
||
onPlay={props.onPlay}
|
||
onMatch={props.onMatch}
|
||
onManage={props.onManage}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const showEmpty =
|
||
!props.hideEmpty && (props.mountsEmpty || (groups.length === 0 && !groupsQuery.isPending));
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
||
{groups.map((group) => (
|
||
<div key={group.bangumiId} className="relative">
|
||
<button
|
||
type="button"
|
||
className="w-full overflow-hidden rounded-lg border border-border bg-card text-left transition-colors hover:border-primary/60"
|
||
onClick={() => props.onSelect(group.bangumiId)}
|
||
>
|
||
<PosterBox posterUrl={group.posterUrl} locale={locale} />
|
||
<div className="p-2">
|
||
<div className="truncate text-sm font-medium" title={group.title}>
|
||
{group.title}
|
||
</div>
|
||
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
|
||
{t(locale, "library.episodeCount", {
|
||
count: group.episodeCount,
|
||
})}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
{props.onManage && (
|
||
<button
|
||
type="button"
|
||
className="absolute top-1.5 right-1.5 z-10 rounded-md border border-border bg-background/90 px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||
onClick={() =>
|
||
props.onManage?.({
|
||
bangumiId: group.bangumiId,
|
||
title: group.title,
|
||
posterUrl: group.posterUrl,
|
||
})
|
||
}
|
||
>
|
||
{t(locale, "library.manageGroup")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
{showEmpty && (
|
||
<div className="u-empty col-span-full">
|
||
<p className="u-empty__title">{t(locale, "library.title")}</p>
|
||
<p>
|
||
{!props.mountsEmpty && q
|
||
? t(locale, "library.noResults", { q })
|
||
: t(locale, "library.groupsEmpty")}
|
||
</p>
|
||
<div className="u-empty__actions">
|
||
{!props.mountsEmpty && q && props.onClearSearch ? (
|
||
<Button size="sm" variant="outline" onClick={props.onClearSearch}>
|
||
{t(locale, "library.clearSearch")}
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
|
||
{t(locale, "library.ctaBrowse")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => go(`/${locale}/mounts`)}
|
||
>
|
||
{t(locale, "library.ctaMounts")}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{hasMore && (
|
||
<div className="flex justify-center">
|
||
<Button
|
||
variant="outline"
|
||
disabled={groupsQuery.isPending}
|
||
onClick={() => setOffset(groups.length)}
|
||
>
|
||
{t(locale, "library.loadMore")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ListView(props: {
|
||
locale: Locale;
|
||
mountId: string;
|
||
q: string;
|
||
filter: "" | "unmatched" | "ok";
|
||
refreshKey: number;
|
||
onFilterChange: (f: "" | "unmatched" | "ok") => void;
|
||
onMatch: (mediaItemId: string, rawName?: string) => void;
|
||
onPlay: (mediaItemId: string, bangumiId?: number | null) => void;
|
||
onRescrape?: ((mediaItemId: string) => void) | undefined;
|
||
onClearSearch?: (() => void) | undefined;
|
||
hideEmpty?: boolean;
|
||
mountsEmpty?: boolean;
|
||
}) {
|
||
const { locale, mountId, q, filter } = props;
|
||
const [offset, setOffset] = useState(0);
|
||
const pageLimit = PAGE_SIZE;
|
||
const reqKey = `${mountId}|${q}|${filter}`;
|
||
const [prevReqKey, setPrevReqKey] = useState(reqKey);
|
||
if (prevReqKey !== reqKey) {
|
||
setPrevReqKey(reqKey);
|
||
setOffset(0);
|
||
}
|
||
const [prevRefresh, setPrevRefresh] = useState(props.refreshKey);
|
||
if (prevRefresh !== props.refreshKey) {
|
||
setPrevRefresh(props.refreshKey);
|
||
setOffset(0);
|
||
}
|
||
const list = trpc.media.libraryList.useQuery({
|
||
limit: pageLimit,
|
||
offset,
|
||
mountId: mountId || undefined,
|
||
scrapeStatus: filter || undefined,
|
||
q: q || undefined,
|
||
});
|
||
// 与海报墙同一套累积逻辑:加载更多追加而不是顶掉上一页
|
||
type MediaListItem = {
|
||
id: string;
|
||
title: string;
|
||
rawName: string;
|
||
posterUrl: string | null;
|
||
bangumiId: number | null;
|
||
scrapeStatus: string;
|
||
};
|
||
const paged = usePagedAccumulator<MediaListItem>({
|
||
reqKey,
|
||
refreshKey: props.refreshKey,
|
||
data: list.data
|
||
? {
|
||
total: list.data.total,
|
||
items: list.data.rows.map((r) => ({
|
||
id: r.id,
|
||
title: r.title,
|
||
rawName: r.rawName,
|
||
posterUrl: r.posterUrl,
|
||
bangumiId: r.bangumiId,
|
||
scrapeStatus: r.scrapeStatus,
|
||
})),
|
||
}
|
||
: undefined,
|
||
offset,
|
||
});
|
||
const rows = paged.items;
|
||
const hasMore = paged.hasMore;
|
||
|
||
const statusBadge = (status: string) => {
|
||
if (status === "ok")
|
||
return { variant: "secondary" as const, label: t(locale, "library.filterOk") };
|
||
if (status === "unmatched")
|
||
return { variant: "default" as const, label: t(locale, "library.unmatched") };
|
||
if (status === "failed")
|
||
return { variant: "destructive" as const, label: t(locale, "library.statusFailed") };
|
||
return { variant: "outline" as const, label: t(locale, "library.statusPending") };
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-end">
|
||
<select
|
||
className="h-9 rounded-md border border-border bg-background px-2 text-sm"
|
||
value={filter}
|
||
onChange={(e) => {
|
||
props.onFilterChange(e.target.value as typeof filter);
|
||
setOffset(0);
|
||
}}
|
||
>
|
||
<option value="">{t(locale, "library.filterAll")}</option>
|
||
<option value="ok">{t(locale, "library.filterOk")}</option>
|
||
<option value="unmatched">{t(locale, "library.unmatched")}</option>
|
||
</select>
|
||
</div>
|
||
|
||
{rows.length > 0 && (
|
||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
||
<ul className="divide-y divide-border">
|
||
{rows.map((item) => {
|
||
const badge = statusBadge(item.scrapeStatus);
|
||
return (
|
||
<li
|
||
key={item.id}
|
||
className="flex flex-wrap items-center gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
|
||
>
|
||
<PosterBox
|
||
posterUrl={item.posterUrl}
|
||
locale={locale}
|
||
className="h-14 w-[42px] shrink-0 overflow-hidden rounded-md border border-border"
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<p
|
||
className="truncate text-sm font-medium"
|
||
title={item.title}
|
||
>
|
||
{item.title}
|
||
</p>
|
||
<Badge
|
||
variant={badge.variant}
|
||
className="shrink-0 font-normal"
|
||
>
|
||
{badge.label}
|
||
</Badge>
|
||
</div>
|
||
<p
|
||
className="truncate font-mono text-xs text-muted-foreground"
|
||
title={item.rawName}
|
||
>
|
||
{item.rawName}
|
||
</p>
|
||
</div>
|
||
<div className="ml-auto flex shrink-0 gap-1">
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
onClick={() => props.onPlay(item.id, item.bangumiId)}
|
||
>
|
||
{t(locale, "library.play")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => props.onMatch(item.id, item.rawName)}
|
||
>
|
||
{t(locale, "library.match")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => props.onRescrape?.(item.id)}
|
||
>
|
||
{t(locale, "library.reScrape")}
|
||
</Button>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{!props.hideEmpty && (props.mountsEmpty || (rows.length === 0 && !list.isPending)) && (
|
||
<div className="u-empty">
|
||
<p className="u-empty__title">{t(locale, "library.title")}</p>
|
||
<p>
|
||
{!props.mountsEmpty && q
|
||
? t(locale, "library.noResults", { q })
|
||
: !props.mountsEmpty && filter
|
||
? t(locale, "library.filterEmpty")
|
||
: t(locale, "library.empty")}
|
||
</p>
|
||
<div className="u-empty__actions">
|
||
{!props.mountsEmpty && q && props.onClearSearch ? (
|
||
<Button size="sm" variant="outline" onClick={props.onClearSearch}>
|
||
{t(locale, "library.clearSearch")}
|
||
</Button>
|
||
) : !props.mountsEmpty && filter ? (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => props.onFilterChange("")}
|
||
>
|
||
{t(locale, "library.filterAll")}
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button size="sm" onClick={() => go(`/${locale}/browse`)}>
|
||
{t(locale, "library.ctaBrowse")}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => go(`/${locale}/mounts`)}
|
||
>
|
||
{t(locale, "library.ctaMounts")}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{hasMore && (
|
||
<div className="flex justify-center">
|
||
<Button
|
||
variant="outline"
|
||
disabled={list.isPending}
|
||
onClick={() => setOffset((o) => o + pageLimit)}
|
||
>
|
||
{t(locale, "library.loadMore")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LibraryInner(props: { locale: Locale }) {
|
||
const { locale } = props;
|
||
const [view, setView] = useState<ViewMode>(() => {
|
||
try {
|
||
return localStorage.getItem("media-library-view") === "list" ? "list" : "wall";
|
||
} catch {
|
||
return "wall";
|
||
}
|
||
});
|
||
const changeView = (v: ViewMode) => {
|
||
setView(v);
|
||
try {
|
||
localStorage.setItem("media-library-view", v);
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
};
|
||
const [filter, setFilter] = useState<"" | "unmatched" | "ok">("");
|
||
const [q, setQ] = useState("");
|
||
const debouncedQ = useDebouncedValue(q, 300);
|
||
const [matchId, setMatchId] = useState<string | null>(null);
|
||
const [manageGroup, setManageGroup] = useState<ManageTarget | null>(null);
|
||
const [searchQ, setSearchQ] = useState("");
|
||
const [epInput, setEpInput] = useState("");
|
||
const [mountId, setMountId] = useState(() => queryParam("mount") ?? "");
|
||
const [selectedBangumiId, setSelectedBangumiId] = useState<number | null>(() => {
|
||
const raw = queryParam("g");
|
||
const parsed = raw ? Number(raw) : Number.NaN;
|
||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||
});
|
||
const search = trpc.media.scrapeSearch.useQuery(
|
||
{ q: searchQ },
|
||
{ enabled: searchQ.length > 1 && matchId !== null },
|
||
);
|
||
const utils = trpc.useUtils();
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
const bumpRefresh = () => setRefreshKey((k) => k + 1);
|
||
/** 组级操作后的统一刷新:分页游标 + 全部相关查询 */
|
||
const refreshAll = () => {
|
||
bumpRefresh();
|
||
void utils.media.libraryList.invalidate();
|
||
void utils.media.libraryGroups.invalidate();
|
||
void utils.media.librarySeries.invalidate();
|
||
void utils.media.playbackContinue.invalidate();
|
||
};
|
||
const bind = trpc.media.scrapeBind.useMutation({
|
||
onSuccess: () => {
|
||
setMatchId(null);
|
||
toast.success(t(locale, "library.matched"));
|
||
bumpRefresh();
|
||
void utils.media.libraryList.invalidate();
|
||
void utils.media.libraryGroups.invalidate();
|
||
void utils.media.librarySeries.invalidate();
|
||
void utils.media.playbackContinue.invalidate();
|
||
},
|
||
});
|
||
const rescrape = trpc.media.scrapeRescrape.useMutation({
|
||
onSuccess: () => {
|
||
toast.success(t(locale, "library.reScrape"));
|
||
bumpRefresh();
|
||
void utils.media.libraryList.invalidate();
|
||
void utils.media.libraryGroups.invalidate();
|
||
void utils.media.librarySeries.invalidate();
|
||
},
|
||
});
|
||
|
||
const bindWithEp = (bangumiId: number) => {
|
||
if (!matchId) return;
|
||
const epNum = epInput.trim() === "" ? undefined : Number(epInput);
|
||
bind.mutate({
|
||
mediaItemId: matchId,
|
||
bangumiId,
|
||
...(epNum != null && Number.isFinite(epNum) ? { epNumber: epNum } : {}),
|
||
});
|
||
};
|
||
|
||
/** 打开匹配弹窗:集数用共享正则从文件名预填,用户可改 */
|
||
const openMatch = (mediaItemId: string, rawName?: string) => {
|
||
setMatchId(mediaItemId);
|
||
const parsed = rawName != null ? parseEpisodeFromFilename(rawName) : null;
|
||
setEpInput(parsed != null ? String(parsed) : "");
|
||
};
|
||
|
||
const mounts = trpc.media.mountList.useQuery();
|
||
const probe = trpc.media.libraryList.useQuery({ limit: 1, offset: 0 });
|
||
const autoScan = trpc.media.libraryScan.useMutation();
|
||
const autoScanStarted = useRef(false);
|
||
const [autoScanning, setAutoScanning] = useState(false);
|
||
const mountList = mounts.data?.mounts ?? [];
|
||
const mountsEmpty = !mounts.isPending && mountList.length === 0;
|
||
|
||
useEffect(() => {
|
||
setQueryParams({ mount: mountId || null });
|
||
}, [mountId]);
|
||
|
||
// 挂载列表返回后:URL 里的 mount 有效则保留;否则自动选中第一个
|
||
useEffect(() => {
|
||
const list = mounts.data?.mounts ?? [];
|
||
if (list.length === 0) return;
|
||
if (mountId && list.some((m) => m.id === mountId)) return;
|
||
const first = list[0];
|
||
if (first) setMountId(first.id);
|
||
}, [mounts.data, mountId]);
|
||
|
||
// 空库且已有挂载时,自动把已挂载目录扫进媒体库(仅触发一次;顺序扫描避免并发打满 WebDAV)
|
||
useEffect(() => {
|
||
if (autoScanStarted.current) return;
|
||
if (mounts.isPending || probe.isPending) return;
|
||
const list = mounts.data?.mounts ?? [];
|
||
if (list.length === 0) return;
|
||
if ((probe.data?.total ?? 0) > 0) return;
|
||
autoScanStarted.current = true;
|
||
setAutoScanning(true);
|
||
void (async () => {
|
||
for (const m of list) {
|
||
try {
|
||
await autoScan.mutateAsync({ mountId: m.id });
|
||
} catch {
|
||
// 单个挂载失败不阻断其余挂载
|
||
}
|
||
}
|
||
setAutoScanning(false);
|
||
setRefreshKey((k) => k + 1);
|
||
void utils.media.libraryList.invalidate();
|
||
void utils.media.libraryGroups.invalidate();
|
||
})();
|
||
}, [mounts.data, mounts.isPending, probe.data, probe.isPending, autoScan, utils]);
|
||
|
||
useEffect(() => {
|
||
setQueryParams({ g: selectedBangumiId != null ? String(selectedBangumiId) : null });
|
||
}, [selectedBangumiId]);
|
||
|
||
const play = (id: string, bangumiId?: number | null) => {
|
||
const g = bangumiId ?? selectedBangumiId;
|
||
const back = g != null ? `&from=series&g=${g}` : "";
|
||
go(`/${locale}/watch?id=${id}${back}`);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<ContinueRow locale={locale} />
|
||
{autoScanning && (
|
||
<p className="text-sm text-muted-foreground">{t(locale, "library.autoScanning")}</p>
|
||
)}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h1 className="text-xl font-semibold">{t(locale, "library.title")}</h1>
|
||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||
<div className="flex rounded-md border border-border p-0.5">
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"rounded px-3 py-1.5 text-sm transition-colors",
|
||
view === "wall"
|
||
? "bg-primary font-medium text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted",
|
||
)}
|
||
onClick={() => {
|
||
// 海报墙只展示已刮削分组,切过去时列表对齐同一集合
|
||
changeView("wall");
|
||
}}
|
||
>
|
||
{t(locale, "library.viewWall")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
"rounded px-3 py-1.5 text-sm transition-colors",
|
||
view === "list"
|
||
? "bg-primary font-medium text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-muted",
|
||
)}
|
||
onClick={() => {
|
||
changeView("list");
|
||
if (filter === "") setFilter("ok");
|
||
}}
|
||
>
|
||
{t(locale, "library.viewList")}
|
||
</button>
|
||
</div>
|
||
<Input
|
||
className="w-40"
|
||
placeholder={t(locale, "library.searchPlaceholder")}
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{mountList.length > 0 && (
|
||
<MountTabs
|
||
mounts={mountList}
|
||
value={mountId}
|
||
onChange={(id) => {
|
||
setMountId(id);
|
||
setSelectedBangumiId(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{view === "wall" ? (
|
||
<WallView
|
||
locale={locale}
|
||
mountId={mountId}
|
||
q={debouncedQ}
|
||
refreshKey={refreshKey}
|
||
selectedBangumiId={selectedBangumiId}
|
||
onSelect={setSelectedBangumiId}
|
||
onPlay={play}
|
||
onMatch={openMatch}
|
||
onManage={setManageGroup}
|
||
onClearSearch={() => setQ("")}
|
||
hideEmpty={autoScanning}
|
||
mountsEmpty={mountsEmpty}
|
||
/>
|
||
) : (
|
||
<ListView
|
||
locale={locale}
|
||
mountId={mountId}
|
||
q={debouncedQ}
|
||
filter={filter}
|
||
refreshKey={refreshKey}
|
||
onFilterChange={setFilter}
|
||
onMatch={openMatch}
|
||
onPlay={play}
|
||
onRescrape={(id) => rescrape.mutate({ mediaItemId: id })}
|
||
onClearSearch={() => setQ("")}
|
||
hideEmpty={autoScanning}
|
||
mountsEmpty={mountsEmpty}
|
||
/>
|
||
)}
|
||
|
||
{manageGroup && (
|
||
<GroupManageModal
|
||
locale={locale}
|
||
mountId={mountId}
|
||
group={manageGroup}
|
||
onClose={() => setManageGroup(null)}
|
||
onChanged={refreshAll}
|
||
onRemoved={(bangumiId) => {
|
||
if (selectedBangumiId === bangumiId) setSelectedBangumiId(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{matchId && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||
<div className="w-full max-w-lg rounded-lg border border-border bg-background p-4 space-y-3">
|
||
<h2 className="font-medium">{t(locale, "library.manualTitle")}</h2>
|
||
<Input
|
||
placeholder={t(locale, "library.searchWork")}
|
||
value={searchQ}
|
||
onChange={(e) => setSearchQ(e.target.value)}
|
||
/>
|
||
<div className="flex items-center gap-2 text-sm">
|
||
<span className="text-muted-foreground">
|
||
{t(locale, "library.epLabel")}
|
||
</span>
|
||
<Input
|
||
className="w-28"
|
||
inputMode="numeric"
|
||
placeholder={t(locale, "library.epPlaceholder")}
|
||
value={epInput}
|
||
onChange={(e) => setEpInput(e.target.value)}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{t(locale, "library.candidates")}
|
||
</p>
|
||
<ul className="max-h-64 divide-y divide-border overflow-auto rounded border border-border">
|
||
{(search.data ?? []).map((hit) => (
|
||
<li
|
||
key={hit.id}
|
||
className="flex items-center justify-between gap-2 p-2 text-sm"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="truncate">{hit.nameCn || hit.name}</div>
|
||
{hit.nameCn && hit.name && (
|
||
<div className="truncate text-xs text-muted-foreground">
|
||
{hit.name}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Button size="sm" onClick={() => bindWithEp(hit.id)}>
|
||
{t(locale, "library.bind")}
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={() => setMatchId(null)}>
|
||
{t(locale, "library.cancel")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const client = createTRPCReactClient();
|
||
|
||
export function LibraryBody(props: { locale: Locale }) {
|
||
const [qc] = useState(() => new QueryClient());
|
||
return (
|
||
<QueryClientProvider client={qc}>
|
||
<trpc.Provider client={client} queryClient={qc}>
|
||
<LibraryInner locale={props.locale} />
|
||
</trpc.Provider>
|
||
</QueryClientProvider>
|
||
);
|
||
}
|