Files
silo-server/web/src/pages/LibraryPage.tsx
T
162e0cc449 feat(audiobooks): redesign audiobook library around resume and series progression (#116)
* 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>
2026-06-09 19:28:53 -04:00

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