* feat(audiobooks): redesign audiobook library around resume and series progression Audiobook libraries previously reused the video-shaped library page: a backdrop carousel hero (audiobooks have square covers and no backdrops), movie-style default sections, and a browse grid whose primary audiobook axes (author, narrator, series) were buried as filters. Backend: - New next_in_series section type: surfaces the next unstarted book, by series_index, in series the profile has finished a book of, ordered by most recent finish. Registered as a library-staple recipe. - New GET /api/v1/catalog/audiobook-groups endpoint: grouped browse by author/narrator/series with book count, total duration, per-profile progress counts, and poster URLs for cover stacks. - Audiobook library defaults: continue-listening is featured (renders as the Now Listening hero) with next-in-series directly after it. A data migration upgrades existing audiobook libraries, skipping layouts where an admin already featured a section. Frontend: - NowListeningHero replaces HeroBanner for audiobook libraries: resume deck with chapter position, hours left, ambient color from the cover, and one-click resume; remaining in-progress books render as the Continue Listening row. - Library tab gains Books/Series/Authors/Narrators browse axes persisted via the type param; selecting a group drops into the Books grid with the matching filter applied. - "Recommended" tab is labeled "Home" for audiobook libraries; audiobook continue cards use square covers and hr/min time-left formatting. - Shared audiobook chapter/file/duration helpers extracted to web/src/lib/audiobooks (deduplicated from AudiobookContent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(audiobooks): address review feedback on library redesign - Push library scoping into the next-in-series candidate SQL so finished series whose next book lives in another library can't consume the candidate limit and starve a library-scoped section (Codex P2). - Paginate the audiobook groups fetch until the server-reported total is reached (500/page, 20-page bound) so client-side filtering sees the complete author/narrator/series list (Codex P2, CodeRabbit). - Make the redesign migration rollback-safe: rows the Up touches carry config markers (featured_by_migration / seeded_by_migration) and the Down reverts only marked rows, leaving admin-set featured state and hand-created next_in_series sections alone (Codex P2, CodeRabbit). - Gate NowListeningHero's detail-derived files/credits on the detail matching the deck item, so Resume can't start the new book with the previous book's files while keepPreviousData shows stale detail (Codex P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
290 lines
9.5 KiB
TypeScript
290 lines
9.5 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Link, useParams, useSearchParams } from "react-router";
|
|
import type { QueryDefinition } from "@/api/types";
|
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import LibraryHeader from "@/components/LibraryHeader";
|
|
import { useUserLibraries } from "@/hooks/queries/libraries";
|
|
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
|
import LibraryRecommended from "./LibraryRecommended";
|
|
import LibraryBrowse from "./LibraryBrowse";
|
|
import LibraryCollections from "./LibraryCollections";
|
|
import { useLibraryPageStatePreference } from "@/hooks/queries/libraryPageState";
|
|
import {
|
|
applySavedLibraryPageSearchParams,
|
|
hasLibraryPageSearchParams,
|
|
parseLibraryPageState,
|
|
serializeLibraryPageSearchParams,
|
|
updateLibraryPageSearchParams,
|
|
type LibraryBrowseType,
|
|
} from "./libraryPageSearchParams";
|
|
|
|
export default function LibraryPage() {
|
|
const { libraryId } = useParams<{ libraryId: string }>();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { data: libraries, isLoading } = useUserLibraries();
|
|
const {
|
|
isLoading: libraryPageStateLoading,
|
|
preference: libraryPageStatePreference,
|
|
rememberEnabled: rememberLibraryPageState,
|
|
saveLibrarySearch,
|
|
} = useLibraryPageStatePreference();
|
|
const savedStateHydratedLibraryIdRef = useRef<number | null>(null);
|
|
const applyingSavedSearchParamsRef = useRef<string | null>(null);
|
|
const applyingSavedSearchParamsLibraryIdRef = useRef<number | null>(null);
|
|
|
|
const id = Number(libraryId);
|
|
const library = libraries?.find((l) => l.id === id);
|
|
const libraryType = library?.type ?? "";
|
|
const savedLibrarySearch =
|
|
Number.isFinite(id) && id > 0
|
|
? libraryPageStatePreference.libraries[String(id)]?.search
|
|
: undefined;
|
|
const shouldApplySavedLibrarySearch =
|
|
Boolean(libraryType) &&
|
|
Number.isFinite(id) &&
|
|
id > 0 &&
|
|
!libraryPageStateLoading &&
|
|
rememberLibraryPageState &&
|
|
savedStateHydratedLibraryIdRef.current !== id &&
|
|
savedLibrarySearch != null &&
|
|
!hasLibraryPageSearchParams(searchParams) &&
|
|
savedLibrarySearch !== serializeLibraryPageSearchParams(searchParams);
|
|
const shouldWaitForSavedLibrarySearch =
|
|
Boolean(libraryType) &&
|
|
Number.isFinite(id) &&
|
|
id > 0 &&
|
|
libraryPageStateLoading &&
|
|
savedStateHydratedLibraryIdRef.current !== id &&
|
|
!hasLibraryPageSearchParams(searchParams);
|
|
const { activeTab, browseType, queryDefinition } = parseLibraryPageState(
|
|
searchParams,
|
|
libraryType,
|
|
);
|
|
|
|
// Tracks whether LibraryRecommended is currently rendering a hero banner.
|
|
// We can't derive this from layout metadata alone — a featured section can
|
|
// still resolve to error/empty, in which case no hero is actually on screen
|
|
// and overlay-mode header styling would be unreadable (especially on light
|
|
// themes, where overlay assumes a dark backdrop behind it).
|
|
const [hasRenderedHero, setHasRenderedHero] = useState(false);
|
|
const handleHeroStateChange = useCallback((rendered: boolean) => {
|
|
setHasRenderedHero(rendered);
|
|
}, []);
|
|
|
|
useDocumentTitle(library?.name ?? "Library");
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!libraryType ||
|
|
!Number.isFinite(id) ||
|
|
id <= 0 ||
|
|
libraryPageStateLoading ||
|
|
!rememberLibraryPageState ||
|
|
savedStateHydratedLibraryIdRef.current === id ||
|
|
!shouldApplySavedLibrarySearch
|
|
) {
|
|
return;
|
|
}
|
|
|
|
savedStateHydratedLibraryIdRef.current = id;
|
|
const nextSearchParams = applySavedLibraryPageSearchParams(searchParams, savedLibrarySearch);
|
|
if (nextSearchParams.toString() !== searchParams.toString()) {
|
|
applyingSavedSearchParamsRef.current = serializeLibraryPageSearchParams(nextSearchParams);
|
|
applyingSavedSearchParamsLibraryIdRef.current = id;
|
|
setSearchParams(nextSearchParams, { replace: true });
|
|
}
|
|
}, [
|
|
id,
|
|
libraryPageStateLoading,
|
|
libraryType,
|
|
rememberLibraryPageState,
|
|
savedLibrarySearch,
|
|
searchParams,
|
|
setSearchParams,
|
|
shouldApplySavedLibrarySearch,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!libraryType || shouldApplySavedLibrarySearch) {
|
|
return;
|
|
}
|
|
|
|
if (applyingSavedSearchParamsLibraryIdRef.current !== id) {
|
|
applyingSavedSearchParamsRef.current = null;
|
|
applyingSavedSearchParamsLibraryIdRef.current = id;
|
|
}
|
|
|
|
const normalizedSearchParams = updateLibraryPageSearchParams(
|
|
searchParams,
|
|
{ activeTab, browseType, queryDefinition },
|
|
libraryType,
|
|
);
|
|
|
|
if (normalizedSearchParams.toString() !== searchParams.toString()) {
|
|
setSearchParams(normalizedSearchParams, { replace: true });
|
|
}
|
|
}, [
|
|
activeTab,
|
|
browseType,
|
|
queryDefinition,
|
|
libraryType,
|
|
searchParams,
|
|
setSearchParams,
|
|
shouldApplySavedLibrarySearch,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!libraryType ||
|
|
!Number.isFinite(id) ||
|
|
id <= 0 ||
|
|
libraryPageStateLoading ||
|
|
!rememberLibraryPageState ||
|
|
shouldApplySavedLibrarySearch
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const normalizedSearchParams = updateLibraryPageSearchParams(
|
|
searchParams,
|
|
{ activeTab, browseType, queryDefinition },
|
|
libraryType,
|
|
);
|
|
if (normalizedSearchParams.toString() !== searchParams.toString()) {
|
|
return;
|
|
}
|
|
|
|
const canonicalSearch = serializeLibraryPageSearchParams(normalizedSearchParams);
|
|
if (applyingSavedSearchParamsRef.current != null) {
|
|
if (applyingSavedSearchParamsRef.current === canonicalSearch) {
|
|
applyingSavedSearchParamsRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
if (savedLibrarySearch !== canonicalSearch) {
|
|
saveLibrarySearch(id, canonicalSearch);
|
|
}
|
|
}, [
|
|
activeTab,
|
|
browseType,
|
|
id,
|
|
libraryPageStateLoading,
|
|
libraryType,
|
|
queryDefinition,
|
|
rememberLibraryPageState,
|
|
saveLibrarySearch,
|
|
savedLibrarySearch,
|
|
searchParams,
|
|
shouldApplySavedLibrarySearch,
|
|
]);
|
|
|
|
const handleTabChange = (value: string) => {
|
|
const nextSearchParams = updateLibraryPageSearchParams(
|
|
searchParams,
|
|
{
|
|
activeTab:
|
|
value === "library" ? "library" : value === "collections" ? "collections" : "recommended",
|
|
browseType,
|
|
queryDefinition,
|
|
},
|
|
libraryType,
|
|
);
|
|
setSearchParams(nextSearchParams);
|
|
};
|
|
|
|
const handleQueryDefinitionChange = (nextQueryDefinition: QueryDefinition) => {
|
|
const nextSearchParams = updateLibraryPageSearchParams(
|
|
searchParams,
|
|
{
|
|
activeTab: "library",
|
|
browseType,
|
|
queryDefinition: nextQueryDefinition,
|
|
},
|
|
libraryType,
|
|
);
|
|
setSearchParams(nextSearchParams);
|
|
};
|
|
|
|
const handleBrowseTypeChange = (
|
|
nextBrowseType: LibraryBrowseType,
|
|
nextQueryDefinition?: QueryDefinition,
|
|
) => {
|
|
const nextSearchParams = updateLibraryPageSearchParams(
|
|
searchParams,
|
|
{
|
|
activeTab: "library",
|
|
browseType: nextBrowseType,
|
|
queryDefinition: nextQueryDefinition ?? queryDefinition,
|
|
},
|
|
libraryType,
|
|
);
|
|
setSearchParams(nextSearchParams);
|
|
};
|
|
|
|
if (isLoading || shouldWaitForSavedLibrarySearch || shouldApplySavedLibrarySearch) {
|
|
return (
|
|
<div className="h-full px-4 py-4 sm:px-6 sm:py-6 lg:px-10 xl:px-12">
|
|
<Skeleton className="mb-6 h-10 w-48" />
|
|
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
|
{Array.from({ length: 21 }).map((_, i) => (
|
|
<div key={i}>
|
|
<Skeleton className="aspect-[2/3] w-full rounded-lg" />
|
|
<Skeleton className="mt-2 h-4 w-3/4 rounded" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!library) {
|
|
return (
|
|
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
|
<p>This library is hidden or unavailable for your account.</p>
|
|
<Link to="/settings/libraries" className="text-primary text-sm font-medium hover:underline">
|
|
Manage library visibility in Settings
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isRecommended = activeTab === "recommended";
|
|
// Only use transparent-overlay styling when a hero is actually on screen.
|
|
// Without it, overlay styling (white text, no backdrop) would be illegible
|
|
// against the normal page background — especially on light themes.
|
|
const useOverlay = isRecommended && hasRenderedHero;
|
|
|
|
return (
|
|
<div className="relative">
|
|
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
|
<LibraryHeader libraryName={library.name} libraryType={libraryType} overlay={useOverlay} />
|
|
<TabsContent value="recommended" className="mt-0">
|
|
<LibraryRecommended
|
|
libraryId={id}
|
|
libraryType={libraryType}
|
|
onHeroStateChange={handleHeroStateChange}
|
|
/>
|
|
</TabsContent>
|
|
<TabsContent value="library" className="mt-0">
|
|
<div className="px-4 py-4 sm:px-6 sm:py-6 lg:px-10 xl:px-12">
|
|
<LibraryBrowse
|
|
libraryId={id}
|
|
libraryType={libraryType || "mixed"}
|
|
browseType={browseType}
|
|
queryDefinition={queryDefinition}
|
|
onBrowseTypeChange={handleBrowseTypeChange}
|
|
onQueryDefinitionChange={handleQueryDefinitionChange}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
<TabsContent value="collections" className="mt-0">
|
|
<div className="px-4 py-4 sm:px-6 sm:py-6 lg:px-10 xl:px-12">
|
|
<LibraryCollections libraryId={id} />
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|