feat(audiobooks): make audiobook libraries first-class catalog items (#73)
* docs(audiobooks): design spec for plugin absorption Plan to absorb silo-plugin-audiobooks into silo-server as a first-party feature. Audiobooks land in silo's existing SPA; ABS clients connect directly. Hard constraints: reuse existing tables (media_items, media_files, user_watch_progress, user_playback_sessions, people, item_people, library_collections); only two new tables (abs_sessions, podcast_feeds) and at most one column add (media_libraries.kind); silo's main :8080 listener handles ABS Socket.io natively. Out of scope: audiobook requests flow, smart collections, share links, external recommender, custom metadata providers, separate audiobook SPA. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): implementation plan sub-plan 1 (discovery + schema) First of six sub-plans for the absorption. Six tasks: a discovery audit that resolves the spec's Risk questions, four idempotent SQL migrations (abs_sessions, podcast_feeds, media_libraries.kind, audiobooks.enabled feature flag), and an empty-but-compiling internal/audiobooks package scaffolded into cmd/silo. Lands as a strict no-op for users (feature flag defaults to false). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): discovery findings for absorption sub-plan 1 Locks schema/code decisions for migrations 139-142 and downstream sub-plans. Resolves open Risk questions from the absorption design spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): migration 139 add abs_sessions table Parallel of jellycompat_sessions for Audiobookshelf-compatible clients. Lets ABS mobile/desktop apps maintain a device-bound session that silo's audiobooks/abs handlers will validate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(audiobooks): match codebase conventions in migration 139 Lowercases type keywords in the abs_sessions CREATE TABLE body to match neighboring migrations, fixes the client_version column alignment, and replaces the misleading "parallel to jellycompat_sessions" header comment with a more accurate description of the table's role. Cosmetic only — the running schema is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): migration 140 add podcast_feeds table Side table on media_items for RSS-subscribed podcasts. Holds feed URL, ETag/Last-Modified for conditional fetches, last-refresh timestamp, and the per-feed refresh interval consumed by the upcoming podcastfeed.Refresher scheduled task. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(audiobooks): uppercase PRIMARY KEY in migration 140 Aligns with the codebase convention (type keywords lowercase, constraint keywords uppercase) established in migration 139's post-style-fix form. Cosmetic only — running schema is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(audiobooks): migration 141 no-op for media_folders.type Sub-plan 1 originally reserved migration 141 to add a 'kind' column to media_libraries discriminating audiobook/podcast libraries. Discovery audit (sub-plan 1 Task 1) found that the actual table is media_folders and it already has a type text NOT NULL column with no CHECK constraint or enum, so 'audiobooks' and 'podcasts' can be added as future values without DDL. Landing this migration as a documented no-op preserves the version numbering audit trail and pins the decision in git history. The matching down migration is also a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): migration 142 add audiobooks.enabled flag Server-settings row that gates the absorbed audiobooks feature. Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent sub-plans branch on this flag and operators flip it to 'true' at cutover. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): scaffold internal/audiobooks package Empty-but-compiling Service that reads the audiobooks.enabled feature flag from server_settings. Wired into cmd/silo so the package is referenced from the binary; no routes mounted, no scheduled tasks registered, no DB writes. Subsequent sub-plans hang scanner branches, ABS handlers, Socket.io, podcast refresher, and SPA pages off this Service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * style(audiobooks): cosmetic cleanups in scaffolded package Two pre-emptive cleanups flagged by code review before sub-plan 2 copies the patterns: 1. Sort the internal/audiobooks import after internal/adminjob in cmd/silo/main.go (alphabetical). 2. Drop the redundant "audiobooks: " prefix from the Enabled() error wrap; matches how every other top-level service package (watchstate, scanqueue, metadata, etc.) formats errors. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): implementation plan sub-plan 2 (scanner) Second of six sub-plans. 10 tasks: PersonKind constants for Author and Narrator, audio-extension recognizer, library-type helpers, a walkLogicalTree refactor (movieLibrary bool -> typed walkMode), chapter extraction via ffprobe, single-file and multi-file audiobook parsers, scanner write path producing media_items.type='audiobook', and a filesystem podcast parser (RSS deferred to sub-plan 5). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): add Author and Narrator PersonKind constants Discovery audit confirmed item_people.kind is unconstrained smallint with values 1-6 in use. Reserve 7 = Author, 8 = Narrator for audiobook people-links written by the upcoming scanner branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): add audio-extension recognizer for scanner Mirrors the existing videoExtensions/SupportsVideoFile pair. Used by upcoming audiobook and podcast scanner branches to filter directory walks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): library-type recognizers for scanner dispatch isAudiobookLibraryType and isPodcastLibraryType match singular and plural forms case-insensitively, mirroring isMovieLibraryType. Used by upcoming scanner walk branches (Task 4) that filter audio files into audiobook and podcast libraries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(scanner): replace movieLibrary bool with typed walkMode Lets walkLogicalTree dispatch on multiple library shapes (video, movie, audiobook, podcast) without proliferating boolean flags. Behavior for existing video and movie libraries is unchanged; audiobook and podcast modes will be consumed by the upcoming audiobook.go and podcast.go parsers in later tasks of this sub-plan. walkModeFor() derives the mode from a media_folders.type string; unknown types default to walkModeVideo to preserve prior behavior for any caller still passing a raw type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): expose ffprobe format tags on ProbeData The audiobook scanner needs format-level tags (title, artist, album, date) for media_items metadata; ffprobe already parses them in ffprobeFormat.Tags but ProbeData previously discarded them. Add FormatTags map[string]string to ProbeData, populate it in convertProbeData via a new normalizeFormatTags helper that lowercases keys and trims values. Adds a fixture audiobook .m4b with embedded chapters (Intro/Outro) and format tags, and a test that verifies ProbeFile() returns both correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): parser for single-file audiobook folders parseAudiobookFolder reads tags + chapters via the existing ProbeFile (now that Task 5 exposes FormatTags on ProbeData) and produces a parsedAudiobook struct. Title falls back from "title" tag to "album"; author from "artist" -> "album_artist" -> "composer"; series from "album" -> "series" -> "mvnm" (Movement Name, used by some MP4 tools). Year parsed from "date" or "year" tags, tolerating ISO dates and parenthesized forms. Single-file case only; multi-file folders (one audio file per chapter) return a placeholder error and arrive in Task 7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): multi-file audiobook folder support Folders containing N audio files (one per chapter/part) get one parsedAudiobookFile per file; each file's chapter list is synthesized as a single chapter with title = filename stem. Title/author/series/ year come from the first file's tags. Also drops the duplicate pickFirstNonEmpty helper added in Task 6 in favor of the existing firstNonEmpty already in probe.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): scanner write path produces audiobook media_items ScanAudiobookFolder walks an audiobooks-typed media folder and treats each immediate subdirectory as one audiobook. For each parsed audiobook it upserts: - one media_items row with type='audiobook' - one media_files row per audio file (with chapters JSONB) - author/narrator links in item_people (kind=7, kind=8) Adds itemRepo and personRepo to the Scanner struct, wired from fileRepo.Pool() in NewScanner — no constructor signature change needed. ScanFolder dispatches to this path when folder.Type='audiobooks', bypassing the per-file movie/TV pipeline because audiobooks are folder-scoped entities. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): filesystem podcast scanner ScanPodcastFolder walks a podcasts-typed media folder, treating each subdirectory as a podcast show and each audio file inside as an episode. Writes media_items.type='podcast' + episodes rows + media_files rows. RSS-subscribed feeds (podcast_feeds table) arrive in sub-plan 5; this task covers filesystem-only ingestion. ScanFolder dispatches to this path when folder.Type='podcasts'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(audiobooks): implementation plan sub-plan 5 (podcasts) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): expose audiobooks/podcasts library types in admin UI Adds 'Audiobooks' and 'Podcasts' options to the library-type dropdown in the admin libraries page so operators can flag a folder as an audiobook or podcast library. Extends contentLevelsForType() so the admin UI's downstream filtering treats those types correctly (audiobook -> ['audiobook'], podcasts -> ['podcast', 'podcast_episode']). Backend scanner branches for these types were already wired in sub-plan 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 139_abs_sessions to 147 for origin/main merge origin/main adds 139_media_requests at the same number our local audiobook branch had used for abs_sessions. Renumber ours to 147 to free up 139 for the upstream migration. The schema_versions row is updated in lockstep on the running database so the migrator sees the abs_sessions migration as already applied at its new version. Migrations 140-146 (podcast feeds, media_folders kind noop, audiobook feature flag, abs playback sessions, podcast episode guid, audiobook series, audiobook title cleanup) stay where they are — they don't collide with anything on origin/main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 140_podcast_feeds to 157 for origin/main merge origin/main added 140_user_permissions at the same version this branch had used for podcast_feeds. Renumber ours to 157 (next free above the collections-unify migration at 156) so 140 is free for the upstream migration. schema_versions on the running database is updated in lockstep so the migrator sees podcast_feeds as already applied at its new version. Same pattern asd59c1cb(renumber 139_abs_sessions to 147 for the prior main merge). Pending migrations after this rename: 132 (downloaded subtitles admin index, main), 140 (user_permissions, main), and 156 (unify_user_collections, this branch). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 141_media_folders_kind_noop to 159 for origin/main merge Same shape aseb8f67d(the 140→157 renumber from the previous main merge). origin/main added 141_episode_title_sort_index at the same version this branch had used for media_folders_kind_noop. Renumber ours to 159 (next free above the audiobook_series truncate at 158) so 141 is open for the upstream migration. schema_versions on the running database is updated in lockstep so the migrator sees media_folders_kind_noop as already applied at its new version. Pending migrations on silo-prod after this rename: 141 (episode_title_sort_index, main) and any other newer ones from main that the branch hasn't picked up yet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(migrations): renumber 142_audiobooks_feature_flag to 160 for origin/main merge Companion to 3c6f062's 141 renumber — origin/main also added 142_episode_catalog_entries (alongside 141_episode_title_sort_index) at a version this branch had used for the audiobooks feature flag. Renumber ours to 160 so 142 is open for the upstream migration; schema_versions on silo-prod is updated in lockstep so the migrator sees audiobooks_feature_flag as already applied at its new version. This was the only remaining collision (verified by checking for duplicate version prefixes across migrations/). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audiobooks): address foundation review comments * fix(audiobooks): tighten scanner identity handling * fix(audiobooks): propagate scanner cancellation * chore(audiobooks): adopt goose migration layout * docs(audiobooks): implementation plan sub-plan 3 (API + frontend MVP) Third of six sub-plans. 9 tasks: three REST endpoints (list/detail/ progress), TanStack Query hooks + types, three React pages (Library/Detail/Player), and navigation integration. Scoped to MVP — author/series indices, smart collections, share links, and other nice-to-haves from the spec are deferred. Streaming reuses silo's existing /api/v1/stream/{session_id}; no new transcode code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): list endpoint at GET /api/v1/audiobooks Paginated list of media_items with type='audiobook' scoped to the caller's accessible libraries via the existing access filter. Mirrors silo's existing list-style handlers for movies and series. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id} Returns the media_items row, its media_files (with chapters JSONB), author/narrator extracted from item_people (kinds 7/8), and the caller's per-profile listening progress from user_watch_progress. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress UPSERTs user_watch_progress for the caller's (user_id, profile_id, content_id). Body carries position_seconds; clients are expected to post every 5-10s during playback plus on pause/seek (matching silo's existing video progress cadence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): frontend types and TanStack Query hooks TypeScript types match the JSON shapes from the new /api/v1/audiobooks endpoints (list, detail, progress). Three hooks: useAudiobookLibrary (list), useAudiobook (detail), and useReportAudiobookProgress (mutation that invalidates the detail query on success so progress updates reflect immediately). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): library grid page at /audiobooks Renders a paginated grid of audiobook cards using the useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}. Cards show poster, title, and year; falls back to a "No cover" placeholder when the audiobook has no poster_url. Empty state hints to operators that they need to set a library's type to 'audiobooks'. Routes themselves are wired in Task 8 (navigation integration). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): detail page with chapter list Renders cover, title, author, narrator, year, and overview alongside a chapter list. Clicking a chapter opens an inline sticky AudiobookPlayer at that chapter's start. A "Resume" button restarts playback at the saved progress position if present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): HTML5 audio player with chapter navigation Single-file audiobook playback for MVP. Multi-file queuing arrives in a follow-up. Streams via the existing /api/v1/direct-download GET endpoint. Position is reported to /api/v1/audiobooks/{id}/progress every 10s while playing plus on pause/seek/end. Skip-30s, playback rate select, chapter list panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(audiobooks): wire navigation and routes Adds an Audiobooks entry to the sidebar and registers the two new routes (/audiobooks for the library grid, /audiobooks/book/:id for detail). The player renders inline inside the detail page; no dedicated player route is required for MVP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audiobooks): address native API review comments * feat(audiobooks): add ABS compatibility and polish * fix(audiobooks): stabilize ABS playback progress reporting * fix(audiobooks): clean up ABS branch review fixes * chore(audiobooks): adopt goose layout for ABS migrations * fix(audiobooks): align player seek bar props * feat(audiobooks): make libraries first-class catalog items * feat(admin): add server restart endpoint * fix(audiobooks): address review comment findings --------- Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
RXWatcher
Claude Opus 4.7
parent
c469550fa0
commit
eb6024573e
+59
-6
@@ -528,6 +528,46 @@ export interface CrewMember {
|
||||
photo_thumbhash?: string;
|
||||
}
|
||||
|
||||
export interface AudiobookPerson {
|
||||
person_id?: string;
|
||||
name: string;
|
||||
photo_url?: string;
|
||||
photo_thumbhash?: string;
|
||||
}
|
||||
|
||||
export interface AudiobookRelatedItem {
|
||||
content_id: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
poster_url?: string;
|
||||
series_index?: number;
|
||||
}
|
||||
|
||||
export interface AudiobookSeriesGroup {
|
||||
name?: string;
|
||||
entries: AudiobookRelatedItem[];
|
||||
}
|
||||
|
||||
export interface AudiobookNarration {
|
||||
content_id: string;
|
||||
title: string;
|
||||
year?: number;
|
||||
narrators: string[];
|
||||
}
|
||||
|
||||
export interface AudiobookDetailExtension {
|
||||
authors: AudiobookPerson[];
|
||||
narrators: AudiobookPerson[];
|
||||
publisher?: string;
|
||||
total_duration_seconds: number;
|
||||
series?: AudiobookSeriesGroup;
|
||||
other_narrations: AudiobookNarration[];
|
||||
related: {
|
||||
also_by_author: AudiobookRelatedItem[];
|
||||
similar: AudiobookRelatedItem[];
|
||||
};
|
||||
}
|
||||
|
||||
// Seasons / Watched State
|
||||
export interface LeafItemUserData {
|
||||
played: boolean;
|
||||
@@ -608,7 +648,7 @@ export interface BrowseItemSortMetrics {
|
||||
|
||||
export interface BrowseItem {
|
||||
content_id: string;
|
||||
type: "movie" | "series" | "season" | "episode";
|
||||
type: "movie" | "series" | "season" | "episode" | "audiobook";
|
||||
title: string;
|
||||
series_title?: string;
|
||||
season_number?: number | null;
|
||||
@@ -675,6 +715,11 @@ export interface CatalogFiltersResponse extends ItemFiltersResponse {
|
||||
audio_languages?: string[];
|
||||
subtitle_languages?: string[];
|
||||
original_languages?: string[];
|
||||
// Audiobook-native facets — populated when the scope contains audiobook
|
||||
// items, empty otherwise. The UI gates these on libraryType=audiobook[s].
|
||||
authors?: string[];
|
||||
narrators?: string[];
|
||||
series?: string[];
|
||||
}
|
||||
|
||||
// Item Detail
|
||||
@@ -696,6 +741,7 @@ export interface FileVersion {
|
||||
presentation_kind?: string;
|
||||
presentation_group_key?: string;
|
||||
presentation_part_index?: number;
|
||||
presentation_part_total?: number;
|
||||
multi_episode_start?: number;
|
||||
multi_episode_end?: number;
|
||||
effective_audio_track_index?: number;
|
||||
@@ -871,7 +917,7 @@ export type SetMarkersRequest = Partial<Record<MarkerKind, MarkerSegmentInput |
|
||||
|
||||
export interface ItemDetail {
|
||||
content_id: string;
|
||||
type: "movie" | "series" | "season" | "episode";
|
||||
type: "movie" | "series" | "season" | "episode" | "audiobook" | "podcast";
|
||||
status?: "pending" | "matched" | "unmatched" | "ambiguous";
|
||||
|
||||
// Metadata (served inline from Postgres).
|
||||
@@ -944,6 +990,7 @@ export interface ItemDetail {
|
||||
effective_version_hdr?: boolean;
|
||||
effective_version_codec_video?: string;
|
||||
effective_version_edition_key?: string;
|
||||
audiobook?: AudiobookDetailExtension;
|
||||
}
|
||||
|
||||
export interface WatchDetail {
|
||||
@@ -1114,13 +1161,16 @@ export interface QuerySort {
|
||||
| "progress"
|
||||
| "date_viewed"
|
||||
| "plays"
|
||||
| "author"
|
||||
| "narrator"
|
||||
| "series"
|
||||
| "relevance";
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface QueryDefinition {
|
||||
library_ids: number[];
|
||||
media_scope?: "movie" | "series" | "episode";
|
||||
media_scope?: "movie" | "series" | "episode" | "audiobook";
|
||||
match: "all" | "any";
|
||||
groups: QueryGroup[];
|
||||
sort: QuerySort;
|
||||
@@ -2886,7 +2936,7 @@ export interface SectionItemUpcomingEvent {
|
||||
|
||||
export interface SectionItem {
|
||||
content_id: string;
|
||||
type: "movie" | "series" | "season" | "episode";
|
||||
type: "movie" | "series" | "season" | "episode" | "audiobook";
|
||||
title: string;
|
||||
series_id?: string;
|
||||
series_title?: string;
|
||||
@@ -3045,7 +3095,8 @@ export function normalizeQueryDefinition(value?: QueryDefinitionInput | null): Q
|
||||
media_scope:
|
||||
value?.media_scope === "movie" ||
|
||||
value?.media_scope === "series" ||
|
||||
value?.media_scope === "episode"
|
||||
value?.media_scope === "episode" ||
|
||||
value?.media_scope === "audiobook"
|
||||
? value.media_scope
|
||||
: undefined,
|
||||
match: value?.match === "any" ? "any" : "all",
|
||||
@@ -3105,7 +3156,9 @@ export function queryDefinitionFromSectionConfig(
|
||||
? "series"
|
||||
: config.media_scope === "episode" || config.filter_type === "episode"
|
||||
? "episode"
|
||||
: undefined;
|
||||
: config.media_scope === "audiobook" || config.filter_type === "audiobook"
|
||||
? "audiobook"
|
||||
: undefined;
|
||||
|
||||
const legacySortField = typeof config.sort === "string" ? config.sort : undefined;
|
||||
const legacySortOrder = typeof config.order === "string" ? config.order : undefined;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FolderPlus, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useCollections, useAddItemToCollection } from "@/hooks/queries/collections";
|
||||
import { useUserLibraries } from "@/hooks/queries/libraries";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import { libraryCollectionKeys } from "@/hooks/queries/keys";
|
||||
import { useOptionalAuth } from "@/hooks/useAuth";
|
||||
import type { LibraryCollection } from "@/api/types";
|
||||
|
||||
interface AddToCollectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mediaItemId: string;
|
||||
/** Optional label shown so the user knows what they're adding. */
|
||||
itemTitle?: string;
|
||||
}
|
||||
|
||||
interface CollectionPick {
|
||||
id: string;
|
||||
title: string;
|
||||
source: "user" | "library";
|
||||
group: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add-to-collection picker. Lists the caller's manual user collections
|
||||
* plus — for admins — every manual library collection grouped by library.
|
||||
* Synced/smart collections are excluded since they overwrite manual edits
|
||||
* on the next sync. Selecting a row commits the add via the existing
|
||||
* PUT collections/{id}/items/{itemId} endpoint (or the /admin variant).
|
||||
*/
|
||||
export default function AddToCollectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mediaItemId,
|
||||
itemTitle,
|
||||
}: AddToCollectionDialogProps) {
|
||||
const auth = useOptionalAuth();
|
||||
const isAdmin = auth?.user?.role === "admin";
|
||||
const { data: userCollections, isLoading: userLoading } = useCollections();
|
||||
const { data: libraries } = useUserLibraries();
|
||||
const addItem = useAddItemToCollection();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// Admins fetch each library's collections so we can offer adding to admin
|
||||
// manual collections too. Non-admins skip these queries entirely.
|
||||
const libraryQueries = useQueries({
|
||||
queries: (isAdmin ? (libraries ?? []) : []).map((lib) => ({
|
||||
queryKey: libraryCollectionKeys.list(lib.id),
|
||||
queryFn: () =>
|
||||
api<{ collections: LibraryCollection[] }>(`/library/${lib.id}/collections`).then(
|
||||
(data) => data.collections ?? [],
|
||||
),
|
||||
enabled: Number.isFinite(lib.id) && lib.id > 0,
|
||||
})),
|
||||
});
|
||||
const libraryLoading = isAdmin && libraryQueries.some((q) => q.isLoading);
|
||||
|
||||
const picks = useMemo<CollectionPick[]>(() => {
|
||||
const out: CollectionPick[] = [];
|
||||
for (const c of userCollections ?? []) {
|
||||
if (c.collection_type === "manual") {
|
||||
out.push({ id: c.id, title: c.name, source: "user", group: "My Collections" });
|
||||
}
|
||||
}
|
||||
if (isAdmin && libraries) {
|
||||
for (let i = 0; i < libraries.length; i++) {
|
||||
const lib = libraries[i]!;
|
||||
const res = libraryQueries[i];
|
||||
if (res?.data) {
|
||||
for (const c of res.data) {
|
||||
if (c.collection_type === "manual") {
|
||||
out.push({ id: c.id, title: c.title, source: "library", group: lib.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [userCollections, libraries, libraryQueries, isAdmin]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const m = new Map<string, CollectionPick[]>();
|
||||
for (const p of picks) {
|
||||
if (!m.has(p.group)) m.set(p.group, []);
|
||||
m.get(p.group)!.push(p);
|
||||
}
|
||||
return Array.from(m.entries());
|
||||
}, [picks]);
|
||||
|
||||
const isLoading = userLoading || libraryLoading;
|
||||
|
||||
function handleConfirm() {
|
||||
if (!selectedId) return;
|
||||
const pick = picks.find((p) => p.id === selectedId);
|
||||
if (!pick) return;
|
||||
addItem.mutate(
|
||||
{ collectionId: pick.id, mediaItemId, source: pick.source },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelectedId(null);
|
||||
onOpenChange(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add to Collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
{itemTitle
|
||||
? `Pick a manual collection to add "${itemTitle}" to.`
|
||||
: "Pick a manual collection."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading collections…
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-muted-foreground px-4 py-10 text-center text-sm">
|
||||
You don't have any manual collections yet. Create one in{" "}
|
||||
<span className="text-foreground">Collections</span> first.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-border divide-y">
|
||||
{groups.map(([group, list]) => (
|
||||
<li key={group}>
|
||||
<div className="bg-muted/50 text-muted-foreground px-3 py-1.5 text-[11px] font-semibold tracking-[0.12em] uppercase">
|
||||
{group}
|
||||
</div>
|
||||
{list.map((p) => {
|
||||
const isSelected = p.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(p.id)}
|
||||
data-selected={isSelected ? "true" : undefined}
|
||||
className={`hover:bg-muted/50 flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-muted/60" : ""
|
||||
}`}
|
||||
>
|
||||
<FolderPlus className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{p.title}</span>
|
||||
{p.source === "library" && (
|
||||
<span className="text-muted-foreground text-[10px] tracking-[0.1em] uppercase">
|
||||
Library
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={addItem.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedId || addItem.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{addItem.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
PinOff,
|
||||
LayoutGrid,
|
||||
Puzzle,
|
||||
BookHeadphones,
|
||||
Send,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
@@ -63,6 +64,9 @@ function getLibraryIcon(type: string) {
|
||||
return <Film className="h-[18px] w-[18px]" />;
|
||||
case "series":
|
||||
return <Tv className="h-[18px] w-[18px]" />;
|
||||
case "audiobook":
|
||||
case "audiobooks":
|
||||
return <BookHeadphones className="h-[18px] w-[18px]" />;
|
||||
default:
|
||||
return <Library className="h-[18px] w-[18px]" />;
|
||||
}
|
||||
|
||||
@@ -143,10 +143,19 @@ export default function ContinueWatchingCard(props: ContinueWatchingCardProps) {
|
||||
const containerWidth = isPoster
|
||||
? "w-[140px] shrink-0 sm:w-[160px] lg:w-[185px]"
|
||||
: "w-[260px] shrink-0 sm:w-[315px]";
|
||||
const imageAspect = isPoster ? "aspect-[2/3]" : "aspect-video";
|
||||
// Audiobook covers are square (Audible-style); use 1:1 for the poster
|
||||
// variant so they don't get top/bottom-cropped into a 2:3 frame.
|
||||
const imageAspect = isPoster
|
||||
? card.type === "audiobook"
|
||||
? "aspect-square"
|
||||
: "aspect-[2/3]"
|
||||
: "aspect-video";
|
||||
const isSectionEpisode = "sectionItem" in props && props.sectionItem?.type === "episode";
|
||||
// Section episode payloads use poster_url for vertical season/series artwork
|
||||
// and backdrop_url for the episode still used by wide cards.
|
||||
// Episodes store the horizontal still in poster_url (see
|
||||
// episode_catalog_source.go); wide-variant movies/series/seasons need the
|
||||
// backdrop for the 16:9 card. Poster variant always wants the vertical
|
||||
// poster, except section-episode payloads which use poster_url for the
|
||||
// vertical season/series artwork and backdrop_url for the episode still.
|
||||
const imagePrimary = isPoster
|
||||
? card.posterUrl
|
||||
: isSectionEpisode
|
||||
|
||||
@@ -154,7 +154,9 @@ export default function ItemCard({
|
||||
className="block overflow-hidden rounded-xl"
|
||||
>
|
||||
<div
|
||||
className="media-card-image relative aspect-[2/3]"
|
||||
className={`media-card-image relative ${
|
||||
item.type === "audiobook" ? "aspect-square" : "aspect-[2/3]"
|
||||
}`}
|
||||
style={
|
||||
thumbhashUrl
|
||||
? {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useOverlayPrefs } from "@/hooks/useOverlayPrefs";
|
||||
interface SharedItemGridProps {
|
||||
loading?: boolean;
|
||||
sortField?: string;
|
||||
libraryId?: number;
|
||||
selectionMode?: boolean;
|
||||
selectedIds?: ReadonlySet<string>;
|
||||
onToggleSelect?: (item: BrowseItem) => void;
|
||||
@@ -42,7 +43,14 @@ const GRID_CLASSES =
|
||||
"grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8 gap-3";
|
||||
|
||||
export default function ItemGrid(props: ItemGridProps) {
|
||||
const { loading, sortField, selectionMode = false, selectedIds, onToggleSelect } = props;
|
||||
const {
|
||||
loading,
|
||||
sortField,
|
||||
libraryId,
|
||||
selectionMode = false,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
} = props;
|
||||
const { prefs: overlayPrefs } = useOverlayPrefs();
|
||||
const totalItems = hasStaticItems(props) ? props.items.length : props.totalItems;
|
||||
const pages = hasStaticItems(props)
|
||||
@@ -147,6 +155,7 @@ export default function ItemGrid(props: ItemGridProps) {
|
||||
<div key={item.content_id} role="listitem">
|
||||
<ItemCard
|
||||
item={item}
|
||||
libraryId={libraryId}
|
||||
sortField={sortField}
|
||||
overlayPrefs={overlayPrefs}
|
||||
selectionMode={selectionMode}
|
||||
|
||||
@@ -104,13 +104,18 @@ function sortJobs(jobs: AdminJob[]) {
|
||||
const leftTime = Date.parse(left.requested_at);
|
||||
const rightTime = Date.parse(right.requested_at);
|
||||
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) {
|
||||
return right.id.localeCompare(left.id);
|
||||
const leftID = left.id ?? "";
|
||||
const rightID = right.id ?? "";
|
||||
return rightID.localeCompare(leftID);
|
||||
}
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
}
|
||||
|
||||
function upsertJob(existing: AdminJob[] | undefined, nextJob: AdminJob, limit = 50) {
|
||||
if (!nextJob || !nextJob.id) {
|
||||
return existing ?? [];
|
||||
}
|
||||
const jobs = existing ? [...existing] : [];
|
||||
const index = jobs.findIndex((job) => job.id === nextJob.id);
|
||||
if (index >= 0) {
|
||||
|
||||
@@ -36,7 +36,9 @@ export default function SectionItemCard({ item, libraryId }: SectionItemCardProp
|
||||
<div className="relative">
|
||||
<ViewTransitionLink to={itemHref} className="block overflow-hidden rounded-xl">
|
||||
<div
|
||||
className="media-card-image relative aspect-[2/3]"
|
||||
className={`media-card-image relative ${
|
||||
item.type === "audiobook" ? "aspect-square" : "aspect-[2/3]"
|
||||
}`}
|
||||
style={
|
||||
thumbhashUrl
|
||||
? {
|
||||
|
||||
@@ -67,6 +67,10 @@ function contentLevelsForType(libraryType: string): string[] {
|
||||
return ["movie"];
|
||||
case "mixed":
|
||||
return ["movie", "series", "season", "episode"];
|
||||
case "audiobooks":
|
||||
return ["audiobook"];
|
||||
case "podcasts":
|
||||
return ["podcast", "podcast_episode"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -131,7 +135,10 @@ function buildLevelChainsFromServer(
|
||||
}
|
||||
|
||||
function contentLevelLabel(level: string): string {
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
return level
|
||||
.split("_")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function buildProviderChainBody(activeLevelChains: Record<string, LevelChainItem[]>) {
|
||||
@@ -466,6 +473,8 @@ export function LibraryForm({
|
||||
<SelectItem value="movies">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="mixed">Mixed</SelectItem>
|
||||
<SelectItem value="audiobooks">Audiobooks</SelectItem>
|
||||
<SelectItem value="podcasts">Podcasts</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function CatalogFilterBar({
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { GuidedFormState } from "@/components/collections/CollectionGuidedR
|
||||
import CollectionGuidedRulesEditor from "@/components/collections/CollectionGuidedRulesEditor";
|
||||
import CollectionRulesEditor from "@/components/collections/CollectionRulesEditor";
|
||||
import type { QuerySortRelevanceScope } from "@/lib/querySortOptions";
|
||||
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PortalContainerContext } from "@/components/ui/portal-container-context";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -33,6 +34,9 @@ const EMPTY_SECONDARY_FILTERS: Partial<GuidedFormState> = {
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -62,6 +66,10 @@ interface CatalogFilterSheetProps {
|
||||
onQueryDefinitionChange: (qd: QueryDefinition) => void;
|
||||
filters?: CatalogFiltersResponse;
|
||||
filtersLoading?: boolean;
|
||||
libraryType?: string;
|
||||
// Forwarded into the editor so audiobook-native facet sections can
|
||||
// typeahead-search /api/v1/catalog/filters/search at the same scope.
|
||||
catalogState?: CatalogSearchState;
|
||||
}
|
||||
|
||||
export default function CatalogFilterSheet({
|
||||
@@ -80,6 +88,8 @@ export default function CatalogFilterSheet({
|
||||
onQueryDefinitionChange,
|
||||
filters,
|
||||
filtersLoading = false,
|
||||
libraryType,
|
||||
catalogState,
|
||||
}: CatalogFilterSheetProps) {
|
||||
// Portal container inside the Sheet's DOM so that react-remove-scroll
|
||||
// (activated by the Dialog/Sheet) does not block scroll events inside
|
||||
@@ -152,6 +162,8 @@ export default function CatalogFilterSheet({
|
||||
showSortControls={false}
|
||||
filters={filters}
|
||||
filtersLoading={filtersLoading}
|
||||
libraryType={libraryType}
|
||||
catalogState={catalogState}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,9 @@ vi.mock("@/components/collections/CollectionGuidedRulesEditor", () => ({
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -133,6 +136,9 @@ describe("CatalogFiltersPanel", () => {
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CatalogFiltersPanelProps {
|
||||
sortRelevanceScope?: QuerySortRelevanceScope;
|
||||
resultCountLabel?: string;
|
||||
resultCountLoading?: boolean;
|
||||
libraryType?: string;
|
||||
}
|
||||
|
||||
export default function CatalogFiltersPanel({
|
||||
@@ -39,6 +40,7 @@ export default function CatalogFiltersPanel({
|
||||
sortRelevanceScope,
|
||||
resultCountLabel,
|
||||
resultCountLoading = false,
|
||||
libraryType,
|
||||
}: CatalogFiltersPanelProps) {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"guided" | "advanced">("guided");
|
||||
@@ -107,6 +109,7 @@ export default function CatalogFiltersPanel({
|
||||
onQueryDefinitionChange={(nextQd) =>
|
||||
onStateChange({ ...state, query_definition: nextQd })
|
||||
}
|
||||
libraryType={libraryType}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -129,6 +132,7 @@ export function CatalogFilterSheetContainer({
|
||||
onEditorModeChange,
|
||||
queryDefinition,
|
||||
onQueryDefinitionChange,
|
||||
libraryType,
|
||||
}: {
|
||||
state: CatalogSearchState;
|
||||
open: boolean;
|
||||
@@ -145,6 +149,7 @@ export function CatalogFilterSheetContainer({
|
||||
onEditorModeChange: (mode: "guided" | "advanced") => void;
|
||||
queryDefinition: QueryDefinition;
|
||||
onQueryDefinitionChange: (nextQd: QueryDefinition) => void;
|
||||
libraryType?: string;
|
||||
}) {
|
||||
const filtersQuery = useCatalogFilters(state, {
|
||||
enabled: editorMode === "guided",
|
||||
@@ -169,6 +174,8 @@ export function CatalogFilterSheetContainer({
|
||||
onQueryDefinitionChange={onQueryDefinitionChange}
|
||||
filters={filtersQuery.data}
|
||||
filtersLoading={filtersQuery.isLoading}
|
||||
libraryType={libraryType}
|
||||
catalogState={state}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,30 @@ export function getActiveFilterBadges(state: GuidedFormState): ActiveFilterBadge
|
||||
});
|
||||
}
|
||||
|
||||
if (state.author) {
|
||||
badges.push({
|
||||
key: "author",
|
||||
label: `Author: ${state.author}`,
|
||||
clearPatch: { author: "" },
|
||||
});
|
||||
}
|
||||
|
||||
if (state.narrator) {
|
||||
badges.push({
|
||||
key: "narrator",
|
||||
label: `Narrator: ${state.narrator}`,
|
||||
clearPatch: { narrator: "" },
|
||||
});
|
||||
}
|
||||
|
||||
if (state.series) {
|
||||
badges.push({
|
||||
key: "series",
|
||||
label: `Series: ${state.series}`,
|
||||
clearPatch: { series: "" },
|
||||
});
|
||||
}
|
||||
|
||||
// Studio
|
||||
if (state.studio) {
|
||||
badges.push({
|
||||
|
||||
@@ -46,6 +46,9 @@ function emptyState(): GuidedFormState {
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -150,6 +153,9 @@ describe("guidedStateToQueryDefinition", () => {
|
||||
director: "Denis Villeneuve",
|
||||
writer: "Tony Gilroy",
|
||||
producer: "Emma Thomas",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
originalLanguages: ["Japanese"],
|
||||
network: "HBO",
|
||||
@@ -259,6 +265,36 @@ describe("guidedStateToQueryDefinition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips audiobook-native fields (author/narrator/series)", () => {
|
||||
const original: QueryDefinition = {
|
||||
library_ids: [9],
|
||||
media_scope: "audiobook",
|
||||
match: "all",
|
||||
groups: [
|
||||
{
|
||||
match: "all",
|
||||
rules: [
|
||||
{ field: "author", op: "is", value: "Brandon Sanderson" },
|
||||
{ field: "narrator", op: "is", value: "Michael Kramer" },
|
||||
{ field: "series", op: "is", value: "Mistborn" },
|
||||
],
|
||||
},
|
||||
],
|
||||
sort: { field: "title", order: "asc" },
|
||||
};
|
||||
|
||||
const state = queryDefinitionToGuidedState(original);
|
||||
expect(state.author).toBe("Brandon Sanderson");
|
||||
expect(state.narrator).toBe("Michael Kramer");
|
||||
expect(state.series).toBe("Mistborn");
|
||||
|
||||
const rebuilt = guidedStateToQueryDefinition(state, original);
|
||||
const rules = rebuilt.groups[0]!.rules;
|
||||
expect(rules).toContainEqual({ field: "author", op: "is", value: "Brandon Sanderson" });
|
||||
expect(rules).toContainEqual({ field: "narrator", op: "is", value: "Michael Kramer" });
|
||||
expect(rules).toContainEqual({ field: "series", op: "is", value: "Mistborn" });
|
||||
});
|
||||
|
||||
it("round-trips last_air_date sort through parse and serialize", () => {
|
||||
const original: QueryDefinition = {
|
||||
library_ids: [1],
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/api/types";
|
||||
import LibraryMultiSelect from "@/components/LibraryMultiSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FacetSearchSelect } from "@/components/ui/facet-search-select";
|
||||
import { PersonSearchSelect } from "@/components/ui/person-search-select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useCatalogMetadataFilters } from "@/hooks/queries/catalog";
|
||||
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
|
||||
import {
|
||||
getDefaultQuerySortOrder,
|
||||
normalizeQuerySortForScope,
|
||||
@@ -38,7 +40,7 @@ const DECADE_OPTIONS = Array.from({ length: 15 }, (_, index) => 2030 - index * 1
|
||||
|
||||
/** Flat form state that maps 1-to-1 with friendly form fields. */
|
||||
export interface GuidedFormState {
|
||||
mediaScope: "all" | "movie" | "series" | "episode";
|
||||
mediaScope: "all" | "movie" | "series" | "episode" | "audiobook";
|
||||
libraryIds: number[];
|
||||
genres: string[];
|
||||
decade: string;
|
||||
@@ -51,6 +53,9 @@ export interface GuidedFormState {
|
||||
director: string;
|
||||
writer: string;
|
||||
producer: string;
|
||||
author: string;
|
||||
narrator: string;
|
||||
series: string;
|
||||
studio: string;
|
||||
network: string;
|
||||
country: string;
|
||||
@@ -103,6 +108,9 @@ export function queryDefinitionToGuidedState(
|
||||
director: "",
|
||||
writer: "",
|
||||
producer: "",
|
||||
author: "",
|
||||
narrator: "",
|
||||
series: "",
|
||||
studio: "",
|
||||
network: "",
|
||||
country: "",
|
||||
@@ -177,6 +185,15 @@ export function queryDefinitionToGuidedState(
|
||||
case "producer":
|
||||
if (rule.op === "is") state.producer = String(rule.value);
|
||||
break;
|
||||
case "author":
|
||||
if (rule.op === "is") state.author = String(rule.value);
|
||||
break;
|
||||
case "narrator":
|
||||
if (rule.op === "is") state.narrator = String(rule.value);
|
||||
break;
|
||||
case "series":
|
||||
if (rule.op === "is") state.series = String(rule.value);
|
||||
break;
|
||||
case "studio":
|
||||
if (rule.op === "is") state.studio = String(rule.value);
|
||||
break;
|
||||
@@ -278,6 +295,15 @@ export function guidedStateToQueryDefinition(
|
||||
if (state.producer) {
|
||||
rules.push({ field: "producer", op: "is", value: state.producer });
|
||||
}
|
||||
if (state.author) {
|
||||
rules.push({ field: "author", op: "is", value: state.author });
|
||||
}
|
||||
if (state.narrator) {
|
||||
rules.push({ field: "narrator", op: "is", value: state.narrator });
|
||||
}
|
||||
if (state.series) {
|
||||
rules.push({ field: "series", op: "is", value: state.series });
|
||||
}
|
||||
if (state.studio) {
|
||||
rules.push({ field: "studio", op: "is", value: state.studio });
|
||||
}
|
||||
@@ -358,6 +384,16 @@ interface CollectionGuidedRulesEditorProps {
|
||||
showSortControls?: boolean;
|
||||
filters?: CatalogFiltersResponse;
|
||||
filtersLoading?: boolean;
|
||||
// When the editor is scoped to a single library (e.g. on the library
|
||||
// browse page) pass its type so video-only filter sections (Director,
|
||||
// Studio, Network, Content Rating, Video Quality, etc.) can be hidden
|
||||
// for audiobook libraries.
|
||||
libraryType?: string;
|
||||
// When set, the audiobook-native facet sections (Author / Narrator /
|
||||
// Series) switch to typeahead-backed FacetSearchSelect, querying
|
||||
// /api/v1/catalog/filters/search scoped to this state. Without it
|
||||
// they fall back to the bulk filters payload (top 1000 alphabetical).
|
||||
catalogState?: CatalogSearchState;
|
||||
}
|
||||
|
||||
export default function CollectionGuidedRulesEditor({
|
||||
@@ -373,11 +409,17 @@ export default function CollectionGuidedRulesEditor({
|
||||
showSortControls = true,
|
||||
filters: providedFilters,
|
||||
filtersLoading: providedFiltersLoading,
|
||||
libraryType,
|
||||
catalogState,
|
||||
}: CollectionGuidedRulesEditorProps) {
|
||||
const state = useMemo(() => queryDefinitionToGuidedState(value), [value]);
|
||||
const metadataFiltersQuery = useCatalogMetadataFilters();
|
||||
const filters = providedFilters ?? metadataFiltersQuery.data;
|
||||
const filtersLoading = providedFiltersLoading ?? metadataFiltersQuery.isLoading;
|
||||
// Audiobook-only libraries should hide video-only filter sections.
|
||||
// Backend stores type as the plural "audiobooks"; the singular form
|
||||
// shows up in some API surfaces — accept either.
|
||||
const isAudiobookLibrary = libraryType === "audiobook" || libraryType === "audiobooks";
|
||||
const sortOptions = getCollectionSortOptions(allowPersonalizedSorts, sortRelevanceScope);
|
||||
const selectedSort = normalizeQuerySortForScope(
|
||||
{ field: state.sortField, order: state.sortOrder },
|
||||
@@ -518,32 +560,34 @@ export default function CollectionGuidedRulesEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Minimum IMDb Rating</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={state.minRating}
|
||||
onChange={(e) => update({ minRating: e.target.value })}
|
||||
placeholder="e.g. 7.0"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{isAudiobookLibrary ? null : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Minimum IMDb Rating</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={state.minRating}
|
||||
onChange={(e) => update({ minRating: e.target.value })}
|
||||
placeholder="e.g. 7.0"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content Rating</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.content_ratings ?? []}
|
||||
value={state.contentRating}
|
||||
onChange={(contentRating) => update({ contentRating })}
|
||||
placeholder="Select rating..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content Rating</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.content_ratings ?? []}
|
||||
value={state.contentRating}
|
||||
onChange={(contentRating) => update({ contentRating })}
|
||||
placeholder="Select rating..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Original Language</Label>
|
||||
@@ -558,68 +602,147 @@ export default function CollectionGuidedRulesEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Actor</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.actor}
|
||||
onChange={(actor) => update({ actor })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Director</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.director}
|
||||
onChange={(director) => update({ director })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isAudiobookLibrary ? null : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Actor</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.actor}
|
||||
onChange={(actor) => update({ actor })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Director</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.director}
|
||||
onChange={(director) => update({ director })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Writer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.writer}
|
||||
onChange={(writer) => update({ writer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Producer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.producer}
|
||||
onChange={(producer) => update({ producer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Writer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.writer}
|
||||
onChange={(writer) => update({ writer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Producer</Label>
|
||||
<PersonSearchSelect
|
||||
value={state.producer}
|
||||
onChange={(producer) => update({ producer })}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Studio</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.studios ?? []}
|
||||
value={state.studio}
|
||||
onChange={(studio) => update({ studio })}
|
||||
placeholder="Select studio..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Network</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.networks ?? []}
|
||||
value={state.network}
|
||||
onChange={(network) => update({ network })}
|
||||
placeholder="Select network..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Studio</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.studios ?? []}
|
||||
value={state.studio}
|
||||
onChange={(studio) => update({ studio })}
|
||||
placeholder="Select studio..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Network</Label>
|
||||
<SearchableSelect
|
||||
options={filters?.networks ?? []}
|
||||
value={state.network}
|
||||
onChange={(network) => update({ network })}
|
||||
placeholder="Select network..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAudiobookLibrary ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Author</Label>
|
||||
{catalogState ? (
|
||||
<FacetSearchSelect
|
||||
facet="author"
|
||||
state={catalogState}
|
||||
value={state.author}
|
||||
onChange={(author) => update({ author })}
|
||||
placeholder="Search authors..."
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={filters?.authors ?? []}
|
||||
value={state.author}
|
||||
onChange={(author) => update({ author })}
|
||||
placeholder="Select author..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Narrator</Label>
|
||||
{catalogState ? (
|
||||
<FacetSearchSelect
|
||||
facet="narrator"
|
||||
state={catalogState}
|
||||
value={state.narrator}
|
||||
onChange={(narrator) => update({ narrator })}
|
||||
placeholder="Search narrators..."
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={filters?.narrators ?? []}
|
||||
value={state.narrator}
|
||||
onChange={(narrator) => update({ narrator })}
|
||||
placeholder="Select narrator..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Series</Label>
|
||||
{catalogState ? (
|
||||
<FacetSearchSelect
|
||||
facet="series"
|
||||
state={catalogState}
|
||||
value={state.series}
|
||||
onChange={(series) => update({ series })}
|
||||
placeholder="Search series..."
|
||||
disabled={readOnly}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={filters?.series ?? []}
|
||||
value={state.series}
|
||||
onChange={(series) => update({ series })}
|
||||
placeholder="Select series..."
|
||||
disabled={readOnly}
|
||||
isLoading={filtersLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -681,34 +804,36 @@ export default function CollectionGuidedRulesEditor({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Video Quality</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: "fourK", label: "4K" },
|
||||
{ key: "hdr", label: "HDR" },
|
||||
{ key: "dolbyVision", label: "DOVI" },
|
||||
].map((option) => {
|
||||
const selected = state[option.key as keyof GuidedFormState] === true;
|
||||
return (
|
||||
<Button
|
||||
key={option.key}
|
||||
type="button"
|
||||
variant={selected ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
update({
|
||||
[option.key]: !selected,
|
||||
} as Partial<GuidedFormState>)
|
||||
}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{isAudiobookLibrary ? null : (
|
||||
<div className="space-y-2">
|
||||
<Label>Video Quality</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: "fourK", label: "4K" },
|
||||
{ key: "hdr", label: "HDR" },
|
||||
{ key: "dolbyVision", label: "DOVI" },
|
||||
].map((option) => {
|
||||
const selected = state[option.key as keyof GuidedFormState] === true;
|
||||
return (
|
||||
<Button
|
||||
key={option.key}
|
||||
type="button"
|
||||
variant={selected ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
update({
|
||||
[option.key]: !selected,
|
||||
} as Partial<GuidedFormState>)
|
||||
}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -68,7 +68,9 @@ export default function CollectionRulesEditor({
|
||||
onChange({
|
||||
...value,
|
||||
media_scope:
|
||||
next === "all" ? undefined : (next as "movie" | "series" | "episode"),
|
||||
next === "all"
|
||||
? undefined
|
||||
: (next as "movie" | "series" | "episode" | "audiobook"),
|
||||
sort: nextSort,
|
||||
});
|
||||
}}
|
||||
@@ -82,6 +84,7 @@ export default function CollectionRulesEditor({
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { GripVertical, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { GripVertical, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import {
|
||||
@@ -9,29 +10,149 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import type { CollectionItem } from "@/api/types";
|
||||
import type { BrowseItem, CollectionItem } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useAddItemToCollection,
|
||||
useCollectionItems,
|
||||
useRemoveCollectionItem,
|
||||
useReorderCollectionItems,
|
||||
} from "@/hooks/queries/collections";
|
||||
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
|
||||
import { catalogKeys } from "@/hooks/queries/keys";
|
||||
import { useSortableList } from "@/hooks/useSortableList";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
|
||||
interface ManualCollectionItemsEditorProps {
|
||||
collectionId: string;
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Whether this collection lives under the admin /admin/collections/* route
|
||||
* (library collection) or the user /collections/* route (personal). The
|
||||
* add-item mutation needs to know to hit the right endpoint.
|
||||
*/
|
||||
source?: "user" | "library";
|
||||
}
|
||||
|
||||
const SEARCH_LIMIT = 12;
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
function AddItemPanel({
|
||||
collectionId,
|
||||
source,
|
||||
existingIds,
|
||||
}: {
|
||||
collectionId: string;
|
||||
source: "user" | "library";
|
||||
existingIds: Set<string>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const debounced = useDebounce(query.trim(), DEBOUNCE_MS);
|
||||
const addItem = useAddItemToCollection();
|
||||
|
||||
const searchState = useMemo(
|
||||
() => createCatalogSearchState("query", { q: debounced || undefined }),
|
||||
[debounced],
|
||||
);
|
||||
|
||||
const results = useQuery({
|
||||
queryKey: [
|
||||
"manualCollectionPicker",
|
||||
collectionId,
|
||||
catalogKeys.list({
|
||||
source: searchState.source,
|
||||
q: searchState.q,
|
||||
limit: SEARCH_LIMIT,
|
||||
offset: 0,
|
||||
}),
|
||||
],
|
||||
queryFn: ({ signal }) => fetchCatalogPage(searchState, SEARCH_LIMIT, 0, { signal }),
|
||||
enabled: debounced.length > 0,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const items: BrowseItem[] = results.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search the catalog to add titles…"
|
||||
className="pl-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{debounced.length === 0 ? null : results.isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-3 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Searching…
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-3 text-sm">
|
||||
No matches.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="border-border/60 divide-border/60 max-h-72 divide-y overflow-y-auto rounded-md border">
|
||||
{items.map((item) => {
|
||||
const already = existingIds.has(item.content_id);
|
||||
return (
|
||||
<li key={item.content_id} className="flex items-center gap-3 px-3 py-2">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={item.poster_url}
|
||||
alt=""
|
||||
className="bg-muted h-10 w-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted h-10 w-10 shrink-0 rounded" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.title}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{item.year ? `${item.year} · ` : ""}
|
||||
{item.type}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={already ? "ghost" : "outline"}
|
||||
disabled={already || addItem.isPending}
|
||||
onClick={() =>
|
||||
addItem.mutate({
|
||||
collectionId,
|
||||
mediaItemId: item.content_id,
|
||||
source,
|
||||
})
|
||||
}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{already ? "Added" : "Add"}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManualCollectionItemsEditor({
|
||||
collectionId,
|
||||
readOnly = false,
|
||||
source = "user",
|
||||
}: ManualCollectionItemsEditorProps) {
|
||||
const { data, isLoading } = useCollectionItems(collectionId);
|
||||
const items = useMemo(() => data ?? [], [data]);
|
||||
const reorderMutation = useReorderCollectionItems(collectionId);
|
||||
const removeMutation = useRemoveCollectionItem(collectionId);
|
||||
const existingIds = useMemo(() => new Set(items.map((i) => i.media_item_id)), [items]);
|
||||
|
||||
const { sensors, collisionDetection, handleDragEnd } = useSortableList(
|
||||
items,
|
||||
@@ -49,37 +170,40 @@ export function ManualCollectionItemsEditor({
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-5 text-sm">
|
||||
No items yet. Items added to this collection will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.media_item_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item.media_item_id}
|
||||
item={item}
|
||||
index={index}
|
||||
readOnly={readOnly}
|
||||
onRemove={() => removeMutation.mutate(item.media_item_id)}
|
||||
/>
|
||||
))}
|
||||
<div className="space-y-4">
|
||||
{!readOnly && (
|
||||
<AddItemPanel collectionId={collectionId} source={source} existingIds={existingIds} />
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-5 text-sm">
|
||||
No items yet. Search above to add titles.
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragEnd={readOnly ? undefined : handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={items.map((item) => item.media_item_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<SortableItemRow
|
||||
key={item.media_item_id}
|
||||
item={item}
|
||||
index={index}
|
||||
readOnly={readOnly}
|
||||
onRemove={() => removeMutation.mutate(item.media_item_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +61,9 @@ export function SectionSummaryBadges({
|
||||
<Badge variant="secondary">{recipeLabel(catalog, section.sectionType)}</Badge>
|
||||
{queryDefinition.media_scope === "movie" ? <Badge variant="outline">Movies</Badge> : null}
|
||||
{queryDefinition.media_scope === "series" ? <Badge variant="outline">Series</Badge> : null}
|
||||
{queryDefinition.media_scope === "episode" ? (
|
||||
<Badge variant="outline">Episodes</Badge>
|
||||
{queryDefinition.media_scope === "episode" ? <Badge variant="outline">Episodes</Badge> : null}
|
||||
{queryDefinition.media_scope === "audiobook" ? (
|
||||
<Badge variant="outline">Audiobooks</Badge>
|
||||
) : null}
|
||||
{libraries
|
||||
? queryDefinition.library_ids.map((libraryId) => {
|
||||
|
||||
@@ -477,7 +477,9 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
|
||||
setQueryDefinition({
|
||||
...queryDefinition,
|
||||
media_scope:
|
||||
value === "all" ? undefined : (value as "movie" | "series" | "episode"),
|
||||
value === "all"
|
||||
? undefined
|
||||
: (value as "movie" | "series" | "episode" | "audiobook"),
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -489,6 +491,7 @@ export default function SectionEditorDrawer(props: SectionEditorDrawerProps) {
|
||||
<SelectItem value="movie">Movies</SelectItem>
|
||||
<SelectItem value="series">Series</SelectItem>
|
||||
<SelectItem value="episode">Episodes</SelectItem>
|
||||
<SelectItem value="audiobook">Audiobooks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
import { fetchCatalogFacetSearch, type CatalogFacetName } from "@/hooks/queries/catalog";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import type { CatalogSearchState } from "@/pages/catalogSearchParams";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PortalContainerContext } from "./portal-container-context";
|
||||
|
||||
interface FacetSearchSelectProps {
|
||||
/** The catalog facet to typeahead-search (e.g. "author", "series"). */
|
||||
facet: CatalogFacetName;
|
||||
/** The catalog scope — usually the same state passed to CatalogFiltersPanel. */
|
||||
state: CatalogSearchState;
|
||||
/** Currently selected value (or empty for "any"). */
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Max matches to fetch per query. Defaults to 20. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FacetSearchSelect is the typeahead counterpart to SearchableSelect for
|
||||
* high-cardinality catalog facets (Author, Narrator, Series). It debounces
|
||||
* the user's input then queries /api/v1/catalog/filters/search with the
|
||||
* given facet + prefix. The dropdown lists matches in alphabetical order
|
||||
* with the currently selected value pinned first so users can clear it
|
||||
* without re-searching.
|
||||
*
|
||||
* The component intentionally does not render an "All N items" indicator —
|
||||
* the typeahead pattern relies on the user narrowing rather than scrolling.
|
||||
* When more matches are available than were returned, a small "Keep typing
|
||||
* to narrow further" hint shows below the list.
|
||||
*/
|
||||
export function FacetSearchSelect({
|
||||
facet,
|
||||
state,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Search...",
|
||||
disabled = false,
|
||||
limit = 20,
|
||||
}: FacetSearchSelectProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [focusedIndex, setFocusedIndex] = React.useState(-1);
|
||||
const listboxId = React.useId();
|
||||
const portalContainer = React.useContext(PortalContainerContext);
|
||||
const debouncedQuery = useDebounce(search.trim(), 200);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["catalogFacetSearch", facet, debouncedQuery, limit, state] as const,
|
||||
queryFn: ({ signal }) =>
|
||||
fetchCatalogFacetSearch(state, facet, debouncedQuery, limit, { signal }),
|
||||
enabled: open && debouncedQuery.length > 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const matches = query.data?.matches ?? [];
|
||||
const hasMore = query.data?.has_more ?? false;
|
||||
|
||||
const options = React.useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
if (value) {
|
||||
names.add(value);
|
||||
}
|
||||
for (const name of matches) {
|
||||
if (name) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return Array.from(names);
|
||||
}, [matches, value]);
|
||||
|
||||
const allItems = React.useMemo(() => ["", ...options], [options]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setFocusedIndex(-1);
|
||||
setSearch("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function handleSearchKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setFocusedIndex((prev) => (prev < allItems.length - 1 ? prev + 1 : prev));
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (focusedIndex >= 0 && focusedIndex < allItems.length) {
|
||||
const nextValue = allItems[focusedIndex];
|
||||
if (nextValue !== undefined) {
|
||||
onChange(nextValue);
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverPrimitive.Trigger asChild disabled={disabled}>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
className={cn(
|
||||
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-9 w-full items-center justify-between rounded-md border px-3 py-2 text-sm shadow-xs focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverPrimitive.Trigger>
|
||||
|
||||
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
|
||||
<PopoverPrimitive.Content
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="bg-popover text-popover-foreground z-50 w-[var(--radix-popover-trigger-width)] rounded-md border shadow-md"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="p-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setFocusedIndex(-1);
|
||||
}}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder="Type to search..."
|
||||
aria-label="Search"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
focusedIndex >= 0 ? `${listboxId}-opt-${focusedIndex}` : undefined
|
||||
}
|
||||
className="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-2 text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="max-h-60 overflow-y-auto overscroll-contain p-1"
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">Loading...</p>
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{debouncedQuery ? "No matches" : "Start typing to search"}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
id={`${listboxId}-opt-0`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!value}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm select-none",
|
||||
!value && "font-medium",
|
||||
focusedIndex === 0 && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4 shrink-0", value ? "opacity-0" : "opacity-100")}
|
||||
/>
|
||||
<span className="text-muted-foreground italic">Any</span>
|
||||
</button>
|
||||
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
id={`${listboxId}-opt-${index + 1}`}
|
||||
key={option}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === option}
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm select-none",
|
||||
value === option && "font-medium",
|
||||
focusedIndex === index + 1 && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(option);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4 shrink-0",
|
||||
value === option ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
{hasMore ? (
|
||||
<p className="text-muted-foreground px-2 py-1.5 text-xs italic">
|
||||
Keep typing to narrow further...
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,36 @@ export async function fetchCatalogFilters(
|
||||
);
|
||||
}
|
||||
|
||||
export type CatalogFacetName =
|
||||
| "genre"
|
||||
| "studio"
|
||||
| "network"
|
||||
| "country"
|
||||
| "original_language"
|
||||
| "content_rating"
|
||||
| "author"
|
||||
| "narrator"
|
||||
| "series";
|
||||
|
||||
export interface CatalogFacetSearchResponse {
|
||||
matches: string[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export async function fetchCatalogFacetSearch(
|
||||
state: CatalogSearchState,
|
||||
facet: CatalogFacetName,
|
||||
prefix: string,
|
||||
limit: number,
|
||||
options?: RequestInit,
|
||||
): Promise<CatalogFacetSearchResponse> {
|
||||
const params = buildCatalogApiSearchParams(state);
|
||||
params.set("facet", facet);
|
||||
params.set("q", prefix);
|
||||
params.set("limit", String(limit));
|
||||
return api<CatalogFacetSearchResponse>(`/catalog/filters/search?${params.toString()}`, options);
|
||||
}
|
||||
|
||||
export function createCatalogSearchState(
|
||||
source: CatalogSource,
|
||||
patch: Partial<CatalogSearchState> = {},
|
||||
|
||||
@@ -118,6 +118,50 @@ export function useDeleteCollection() {
|
||||
});
|
||||
}
|
||||
|
||||
// useAddItemToCollection adds a single media item to either a personal user
|
||||
// collection (PUT /collections/{id}/items/{itemId}) or an admin library
|
||||
// collection (PUT /admin/collections/{id}/items/{itemId}). Source determines
|
||||
// the route; manual collections are the only ones that meaningfully accept
|
||||
// hand-curated items — synced collections will overwrite on the next sync.
|
||||
export function useAddItemToCollection() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
collectionId,
|
||||
mediaItemId,
|
||||
source,
|
||||
position,
|
||||
}: {
|
||||
collectionId: string;
|
||||
mediaItemId: string;
|
||||
source: "user" | "library";
|
||||
position?: number;
|
||||
}) => {
|
||||
const path =
|
||||
source === "user"
|
||||
? `/collections/${collectionId}/items/${mediaItemId}`
|
||||
: `/admin/collections/${collectionId}/items/${mediaItemId}`;
|
||||
return api<void>(path, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ position: position ?? 0 }),
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success("Added to collection");
|
||||
if (vars.source === "user") {
|
||||
return invalidateUserCollectionQueries(queryClient, vars.collectionId);
|
||||
}
|
||||
// Library collection: invalidate its items query.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["libraryCollections", "items", vars.collectionId],
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to add to collection");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveCollectionItem(collectionId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery, useQueries } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@/api/client";
|
||||
import type { ProgressListResponse, ProgressEntry, ItemDetail } from "@/api/types";
|
||||
import { catalogKeys, progressKeys } from "./keys";
|
||||
@@ -65,3 +65,39 @@ export function useContinueWatching(
|
||||
isLoading: enabled && progressLoading,
|
||||
};
|
||||
}
|
||||
|
||||
interface ReportMediaProgressVars {
|
||||
contentId: string;
|
||||
positionSeconds: number;
|
||||
durationSeconds: number;
|
||||
forceOverwrite?: boolean;
|
||||
}
|
||||
|
||||
export function useReportMediaProgress() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
contentId,
|
||||
positionSeconds,
|
||||
durationSeconds,
|
||||
forceOverwrite = true,
|
||||
}: ReportMediaProgressVars) =>
|
||||
api("/sync/progress", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
media_item_id: contentId,
|
||||
position: positionSeconds,
|
||||
duration: durationSeconds,
|
||||
force_overwrite: forceOverwrite,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: progressKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
AudiobookNarration as ApiAudiobookNarration,
|
||||
AudiobookRelatedItem as ApiAudiobookRelatedItem,
|
||||
VersionChapter,
|
||||
} from "@/api/types";
|
||||
|
||||
export type AudiobookChapter = VersionChapter;
|
||||
|
||||
export interface AudiobookFile {
|
||||
id: number;
|
||||
path?: string;
|
||||
duration_seconds: number;
|
||||
chapters?: AudiobookChapter[];
|
||||
}
|
||||
|
||||
export type AudiobookRelatedItem = ApiAudiobookRelatedItem;
|
||||
export type AudiobookNarration = ApiAudiobookNarration;
|
||||
@@ -1,5 +1,5 @@
|
||||
export type QuerySortOrder = "asc" | "desc";
|
||||
export type QuerySortRelevanceScope = "movie" | "series" | "episode" | "all";
|
||||
export type QuerySortRelevanceScope = "movie" | "series" | "episode" | "audiobook" | "all";
|
||||
export type QuerySortField =
|
||||
| "title"
|
||||
| "added_at"
|
||||
@@ -16,7 +16,10 @@ export type QuerySortField =
|
||||
| "bitrate"
|
||||
| "progress"
|
||||
| "date_viewed"
|
||||
| "plays";
|
||||
| "plays"
|
||||
| "author"
|
||||
| "narrator"
|
||||
| "series";
|
||||
|
||||
type ApplicableMediaScope = Exclude<QuerySortRelevanceScope, "all">;
|
||||
|
||||
@@ -47,7 +50,14 @@ interface QuerySortLike {
|
||||
order?: string | null;
|
||||
}
|
||||
|
||||
const ALL_MEDIA_SCOPES: ApplicableMediaScope[] = ["movie", "series", "episode"];
|
||||
// ALL_VIDEO_SCOPES is the universe of video-side scopes — used as the
|
||||
// baseline for the "all" relevance scope (mixed libraries / episode
|
||||
// browse mode) so that sorts limited to series+episode are still
|
||||
// filtered out as not universally applicable. Audiobook is explicit
|
||||
// opt-in: a sort field must list "audiobook" in its applicableMediaScopes
|
||||
// to be eligible for an audiobook-only library.
|
||||
const ALL_VIDEO_SCOPES: ApplicableMediaScope[] = ["movie", "series", "episode"];
|
||||
const ALL_MEDIA_SCOPES: ApplicableMediaScope[] = [...ALL_VIDEO_SCOPES, "audiobook"];
|
||||
|
||||
export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
{
|
||||
@@ -91,7 +101,7 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
label: "Content Rating",
|
||||
defaultOrder: "asc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: ALL_VIDEO_SCOPES,
|
||||
},
|
||||
{
|
||||
value: "runtime",
|
||||
@@ -105,35 +115,35 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
label: "IMDb Rating",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: ALL_VIDEO_SCOPES,
|
||||
},
|
||||
{
|
||||
value: "rating_tmdb",
|
||||
label: "TMDB Rating",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: ALL_VIDEO_SCOPES,
|
||||
},
|
||||
{
|
||||
value: "rating_rt_critic",
|
||||
label: "RT Critic Rating",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: ALL_VIDEO_SCOPES,
|
||||
},
|
||||
{
|
||||
value: "rating_rt_audience",
|
||||
label: "RT Audience Rating",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: ALL_VIDEO_SCOPES,
|
||||
},
|
||||
{
|
||||
value: "resolution",
|
||||
label: "Resolution",
|
||||
defaultOrder: "desc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
applicableMediaScopes: ALL_VIDEO_SCOPES,
|
||||
},
|
||||
{
|
||||
value: "bitrate",
|
||||
@@ -163,6 +173,31 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [
|
||||
personalized: true,
|
||||
applicableMediaScopes: ALL_MEDIA_SCOPES,
|
||||
},
|
||||
// Audiobook-native sorts. Author / Narrator use alphabetically-first
|
||||
// person name per item; Series uses audiobook_series.series_name with
|
||||
// series_index breaking ties so books within a series come back in
|
||||
// narrative order.
|
||||
{
|
||||
value: "author",
|
||||
label: "Author",
|
||||
defaultOrder: "asc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ["audiobook"],
|
||||
},
|
||||
{
|
||||
value: "narrator",
|
||||
label: "Narrator",
|
||||
defaultOrder: "asc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ["audiobook"],
|
||||
},
|
||||
{
|
||||
value: "series",
|
||||
label: "Series",
|
||||
defaultOrder: "asc",
|
||||
personalized: false,
|
||||
applicableMediaScopes: ["audiobook"],
|
||||
},
|
||||
];
|
||||
|
||||
const QUERY_SORT_OPTION_MAP = new Map(QUERY_SORT_OPTIONS.map((option) => [option.value, option]));
|
||||
@@ -183,8 +218,12 @@ function optionMatchesRelevanceScope(
|
||||
if (!relevanceScope) {
|
||||
return true;
|
||||
}
|
||||
// "all" — applies to mixed libraries and the episode browse mode.
|
||||
// Match the historical baseline (universal across all video scopes)
|
||||
// so series-only sorts still get normalized away. Audiobook scope is
|
||||
// intentionally not part of this baseline; see ALL_VIDEO_SCOPES.
|
||||
if (relevanceScope === "all") {
|
||||
return ALL_MEDIA_SCOPES.every((scope) => option.applicableMediaScopes.includes(scope));
|
||||
return ALL_VIDEO_SCOPES.every((scope) => option.applicableMediaScopes.includes(scope));
|
||||
}
|
||||
return option.applicableMediaScopes.includes(relevanceScope);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import type { ItemDetail } from "@/api/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
playerMounts: [] as Array<{ initialPositionSeconds: number; mountId: number }>,
|
||||
}));
|
||||
|
||||
let playerMountCounter = 0;
|
||||
vi.mock("@/pages/audiobooks/player/AudiobookPlayer", () => ({
|
||||
default: function PlayerStub({ initialPositionSeconds }: { initialPositionSeconds: number }) {
|
||||
useEffect(() => {
|
||||
playerMountCounter += 1;
|
||||
mocks.playerMounts.push({ initialPositionSeconds, mountId: playerMountCounter });
|
||||
}, []);
|
||||
return <div data-testid="player-stub">player @ {initialPositionSeconds}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/AddToCollectionDialog", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
vi.mock("@/pages/audiobooks/components/ChaptersSection", () => ({ ChaptersSection: () => null }));
|
||||
vi.mock("@/pages/audiobooks/components/NarratorCard", () => ({ NarratorCard: () => null }));
|
||||
vi.mock("@/pages/audiobooks/components/NarratorPicker", () => ({ NarratorPicker: () => null }));
|
||||
vi.mock("@/pages/audiobooks/components/RelatedRail", () => ({ RelatedRail: () => null }));
|
||||
vi.mock("@/pages/ItemDetail/DetailHero", () => ({
|
||||
default: ({ actions }: { actions?: ReactNode }) => <div>{actions}</div>,
|
||||
}));
|
||||
vi.mock("@/pages/ItemDetail/components/MetadataBadges", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import AudiobookContent from "./AudiobookContent";
|
||||
|
||||
function bookWithProgress(seconds: number): ItemDetail & { type: "audiobook" } {
|
||||
return {
|
||||
content_id: "book-1",
|
||||
type: "audiobook",
|
||||
title: "Test Book",
|
||||
year: 2024,
|
||||
overview: "",
|
||||
runtime: 0,
|
||||
content_rating: "",
|
||||
genres: [],
|
||||
rating_imdb: null,
|
||||
rating_tmdb: null,
|
||||
rating_rt_critic: null,
|
||||
rating_rt_audience: null,
|
||||
imdb_id: "",
|
||||
tmdb_id: "",
|
||||
tvdb_id: "",
|
||||
cast: [],
|
||||
crew: [],
|
||||
studios: [],
|
||||
networks: [],
|
||||
countries: [],
|
||||
release_date: null,
|
||||
first_air_date: null,
|
||||
last_air_date: null,
|
||||
season_count: null,
|
||||
poster_url: "",
|
||||
poster_thumbhash: "",
|
||||
backdrop_url: "",
|
||||
backdrop_thumbhash: "",
|
||||
logo_url: "",
|
||||
versions: [
|
||||
{
|
||||
file_id: 1,
|
||||
file_path: "p",
|
||||
resolution: "",
|
||||
codec_video: "",
|
||||
codec_audio: "aac",
|
||||
hdr: false,
|
||||
container: "m4b",
|
||||
file_size: 1,
|
||||
duration: 36_000,
|
||||
bitrate: 128_000,
|
||||
chapters: [],
|
||||
},
|
||||
],
|
||||
subtitles: [],
|
||||
intro: null,
|
||||
credits: null,
|
||||
user_data: {
|
||||
played: false,
|
||||
is_in_progress: true,
|
||||
position_seconds: seconds,
|
||||
duration_seconds: 36_000,
|
||||
},
|
||||
audiobook: {
|
||||
authors: [{ name: "Author" }],
|
||||
narrators: [{ name: "Narrator" }],
|
||||
total_duration_seconds: 36_000,
|
||||
other_narrations: [],
|
||||
related: { also_by_author: [], similar: [] },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("AudiobookContent Play-from-Start remount", () => {
|
||||
beforeEach(() => {
|
||||
mocks.playerMounts.length = 0;
|
||||
playerMountCounter = 0;
|
||||
});
|
||||
|
||||
it("forces a player remount on every openPlayer call, even when startSeconds is unchanged", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AudiobookContent item={bookWithProgress(5000)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(mocks.playerMounts).toHaveLength(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /play from start/i }));
|
||||
expect(mocks.playerMounts).toHaveLength(1);
|
||||
expect(mocks.playerMounts[0]?.initialPositionSeconds).toBe(0);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /play from start/i }));
|
||||
expect(mocks.playerMounts).toHaveLength(2);
|
||||
expect(mocks.playerMounts[1]?.initialPositionSeconds).toBe(0);
|
||||
expect(mocks.playerMounts[1]?.mountId).not.toBe(mocks.playerMounts[0]?.mountId);
|
||||
});
|
||||
|
||||
it("remounts when switching from Resume to Play-from-Start", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AudiobookContent item={bookWithProgress(5000)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /^resume/i }));
|
||||
expect(mocks.playerMounts).toHaveLength(1);
|
||||
expect(mocks.playerMounts[0]?.initialPositionSeconds).toBe(5000);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /play from start/i }));
|
||||
expect(mocks.playerMounts).toHaveLength(2);
|
||||
expect(mocks.playerMounts[1]?.initialPositionSeconds).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ItemDetail, LeafItemUserData } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderPlus, Play } from "lucide-react";
|
||||
import AddToCollectionDialog from "@/components/AddToCollectionDialog";
|
||||
import AudiobookPlayer from "@/pages/audiobooks/player/AudiobookPlayer";
|
||||
import { ChaptersSection } from "@/pages/audiobooks/components/ChaptersSection";
|
||||
import { NarratorCard } from "@/pages/audiobooks/components/NarratorCard";
|
||||
import { NarratorPicker } from "@/pages/audiobooks/components/NarratorPicker";
|
||||
import { RelatedRail } from "@/pages/audiobooks/components/RelatedRail";
|
||||
import DetailHero from "@/pages/ItemDetail/DetailHero";
|
||||
import MetadataBadges from "@/pages/ItemDetail/components/MetadataBadges";
|
||||
import type { AudiobookChapter, AudiobookFile } from "@/lib/audiobooks/types";
|
||||
|
||||
function formatSeconds(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "";
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
if (h > 0) {
|
||||
return `${h}h ${String(m).padStart(2, "0")}m`;
|
||||
}
|
||||
if (m > 0) {
|
||||
return `${m}m ${String(s).padStart(2, "0")}s`;
|
||||
}
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function totalDuration(files: AudiobookFile[]): number {
|
||||
return files.reduce((acc, file) => acc + (file.duration_seconds ?? 0), 0);
|
||||
}
|
||||
|
||||
function findChapterAt(
|
||||
chapters: ReturnType<typeof buildChapterList>,
|
||||
seconds: number,
|
||||
): { label: string; index: number } | null {
|
||||
for (let i = chapters.length - 1; i >= 0; i--) {
|
||||
const chapter = chapters[i];
|
||||
if (chapter && seconds >= chapter.absoluteStart) {
|
||||
return { label: chapter.label, index: i + 1 };
|
||||
}
|
||||
}
|
||||
return chapters[0] ? { label: chapters[0].label, index: 1 } : null;
|
||||
}
|
||||
|
||||
function buildChapterList(files: AudiobookFile[]): Array<{
|
||||
chapter: AudiobookChapter;
|
||||
absoluteStart: number;
|
||||
fileId: number;
|
||||
label: string;
|
||||
}> {
|
||||
const result: Array<{
|
||||
chapter: AudiobookChapter;
|
||||
absoluteStart: number;
|
||||
fileId: number;
|
||||
label: string;
|
||||
}> = [];
|
||||
let offset = 0;
|
||||
for (const file of files) {
|
||||
for (const chapter of file.chapters ?? []) {
|
||||
result.push({
|
||||
chapter,
|
||||
absoluteStart: offset + chapter.start_seconds,
|
||||
fileId: file.id,
|
||||
label: chapter.title || `Chapter ${chapter.index + 1}`,
|
||||
});
|
||||
}
|
||||
offset += file.duration_seconds ?? 0;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function leafUserData(userData: ItemDetail["user_data"]): LeafItemUserData | undefined {
|
||||
return userData && "position_seconds" in userData ? userData : undefined;
|
||||
}
|
||||
|
||||
function namesFromPeople(people: Array<{ name?: string }> | undefined): string {
|
||||
return (people ?? [])
|
||||
.map((person) => person.name?.trim())
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function namesFromCrew(item: ItemDetail, job: string): string {
|
||||
return (item.crew ?? [])
|
||||
.filter((credit) => credit.job.toLowerCase() === job)
|
||||
.map((credit) => credit.name?.trim())
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function genreHref(genre: string, libraryId?: number): string {
|
||||
const params = new URLSearchParams();
|
||||
if (libraryId) {
|
||||
params.set("tab", "library");
|
||||
params.set("genre", genre);
|
||||
return `/library/${libraryId}?${params.toString()}`;
|
||||
}
|
||||
params.set("source", "query");
|
||||
params.set("type", "audiobook");
|
||||
params.set("genre", genre);
|
||||
return `/catalog?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function AudiobookContent({
|
||||
item,
|
||||
libraryId,
|
||||
}: {
|
||||
item: ItemDetail & { type: "audiobook" };
|
||||
libraryId?: number;
|
||||
}) {
|
||||
const [playerOpen, setPlayerOpen] = useState(false);
|
||||
const [startSeconds, setStartSeconds] = useState(0);
|
||||
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
|
||||
const [playToken, setPlayToken] = useState(0);
|
||||
|
||||
const files = useMemo<AudiobookFile[]>(
|
||||
() =>
|
||||
(item.versions ?? []).map((version) => ({
|
||||
id: version.file_id,
|
||||
path: version.file_path ?? version.file_name ?? "",
|
||||
duration_seconds: version.duration ?? 0,
|
||||
chapters: version.chapters ?? [],
|
||||
})),
|
||||
[item.versions],
|
||||
);
|
||||
|
||||
const author =
|
||||
namesFromPeople(item.audiobook?.authors) || namesFromCrew(item, "author") || undefined;
|
||||
const narrator =
|
||||
namesFromPeople(item.audiobook?.narrators) || namesFromCrew(item, "narrator") || undefined;
|
||||
const progress = leafUserData(item.user_data);
|
||||
const resumeSeconds = progress?.position_seconds ?? 0;
|
||||
const durationTotal =
|
||||
item.audiobook?.total_duration_seconds || progress?.duration_seconds || totalDuration(files);
|
||||
const hasProgress = Boolean(
|
||||
progress &&
|
||||
resumeSeconds > 0 &&
|
||||
durationTotal > 0 &&
|
||||
(progress.is_in_progress ?? !progress.played),
|
||||
);
|
||||
const chapters = useMemo(() => buildChapterList(files), [files]);
|
||||
|
||||
function openPlayer(atSeconds: number) {
|
||||
setStartSeconds(atSeconds);
|
||||
setPlayToken((token) => token + 1);
|
||||
setPlayerOpen(true);
|
||||
}
|
||||
|
||||
function handlePlayResume() {
|
||||
openPlayer(hasProgress ? resumeSeconds : 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AddToCollectionDialog
|
||||
open={addToCollectionOpen}
|
||||
onOpenChange={setAddToCollectionOpen}
|
||||
mediaItemId={item.content_id}
|
||||
itemTitle={item.title}
|
||||
/>
|
||||
|
||||
{playerOpen && (
|
||||
<AudiobookPlayer
|
||||
key={`${item.content_id}-${startSeconds}-${playToken}`}
|
||||
contentId={item.content_id}
|
||||
title={item.title}
|
||||
author={author}
|
||||
narrator={narrator}
|
||||
posterUrl={item.poster_url}
|
||||
files={files}
|
||||
initialPositionSeconds={startSeconds}
|
||||
onClose={() => setPlayerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DetailHero
|
||||
title={item.title}
|
||||
context="Audiobook"
|
||||
studioLabel={item.audiobook?.publisher || item.studios?.[0] || undefined}
|
||||
posterUrl={item.poster_url}
|
||||
posterThumbhash={item.poster_thumbhash}
|
||||
posterOrientation="square"
|
||||
subtitle={
|
||||
(author || narrator) && (
|
||||
<div className="text-muted-foreground flex flex-col gap-0.5 text-sm">
|
||||
{author && (
|
||||
<span>
|
||||
<span className="font-medium">By</span> {author}
|
||||
</span>
|
||||
)}
|
||||
{narrator && (
|
||||
<span>
|
||||
<span className="font-medium">Narrated by</span>{" "}
|
||||
{(item.audiobook?.other_narrations ?? []).length > 0 ? (
|
||||
<NarratorPicker
|
||||
currentNarrator={narrator}
|
||||
currentContentId={item.content_id}
|
||||
others={item.audiobook?.other_narrations ?? []}
|
||||
/>
|
||||
) : (
|
||||
narrator
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
metadata={
|
||||
<MetadataBadges
|
||||
year={item.year > 0 ? String(item.year) : undefined}
|
||||
duration={durationTotal > 0 ? formatSeconds(durationTotal) : undefined}
|
||||
/>
|
||||
}
|
||||
overview={item.overview}
|
||||
genres={item.genres}
|
||||
genreHref={(genre) => genreHref(genre, libraryId)}
|
||||
actions={
|
||||
files.length > 0 && (
|
||||
<div className="flex max-w-md flex-col gap-3">
|
||||
{hasProgress && durationTotal > 0 && (
|
||||
<div>
|
||||
<div className="bg-muted h-1.5 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, (resumeSeconds / durationTotal) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{formatSeconds(resumeSeconds)} listened ·{" "}
|
||||
{Math.round((resumeSeconds / durationTotal) * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={handlePlayResume} size="lg" className="gap-2">
|
||||
<Play className="h-4 w-4 fill-current" />
|
||||
{hasProgress
|
||||
? (() => {
|
||||
const chapter = findChapterAt(chapters, resumeSeconds);
|
||||
return chapter ? `Resume · ${chapter.label}` : "Resume";
|
||||
})()
|
||||
: "Play"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setAddToCollectionOpen(true)}
|
||||
className="gap-2"
|
||||
title="Add to a manual collection"
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Add to Collection
|
||||
</Button>
|
||||
{hasProgress && (
|
||||
<Button variant="outline" size="lg" onClick={() => openPlayer(0)}>
|
||||
Play from Start
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="page-shell space-y-12 py-10 sm:space-y-14"
|
||||
style={playerOpen ? { paddingBottom: "8rem" } : undefined}
|
||||
>
|
||||
{narrator && <NarratorCard narrator={narrator} />}
|
||||
|
||||
{item.audiobook?.series && item.audiobook.series.entries.length > 0 && (
|
||||
<RelatedRail
|
||||
heading={
|
||||
item.audiobook.series.name ? `In ${item.audiobook.series.name}` : "In this series"
|
||||
}
|
||||
items={item.audiobook.series.entries.map((entry) => ({
|
||||
content_id: entry.content_id,
|
||||
title: entry.title,
|
||||
poster_url: entry.poster_url,
|
||||
subtitle:
|
||||
typeof entry.series_index === "number" ? `Book ${entry.series_index}` : undefined,
|
||||
highlight: entry.content_id === item.content_id,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(item.audiobook?.related.also_by_author ?? []).length > 0 && (
|
||||
<RelatedRail
|
||||
heading={`Also by ${author ?? "this author"}`}
|
||||
items={(item.audiobook?.related.also_by_author ?? []).map((entry) => ({
|
||||
content_id: entry.content_id,
|
||||
title: entry.title,
|
||||
poster_url: entry.poster_url,
|
||||
subtitle: entry.year ? String(entry.year) : undefined,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(item.audiobook?.related.similar ?? []).length > 0 && (
|
||||
<RelatedRail
|
||||
heading="You might also like"
|
||||
items={(item.audiobook?.related.similar ?? []).map((entry) => ({
|
||||
content_id: entry.content_id,
|
||||
title: entry.title,
|
||||
poster_url: entry.poster_url,
|
||||
subtitle: entry.year ? String(entry.year) : undefined,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChaptersSection
|
||||
files={files}
|
||||
currentPositionSeconds={playerOpen ? startSeconds : resumeSeconds || null}
|
||||
onSelect={(seconds) => openPlayer(seconds)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,17 @@ interface DetailHeroProps {
|
||||
backdropThumbhash?: string;
|
||||
posterUrl?: string;
|
||||
posterThumbhash?: string;
|
||||
posterOrientation?: "portrait" | "landscape";
|
||||
posterOrientation?: "portrait" | "landscape" | "square";
|
||||
hidePoster?: boolean;
|
||||
logoUrl?: string;
|
||||
metadata?: ReactNode;
|
||||
genres?: string[];
|
||||
/**
|
||||
* Optional URL builder that turns each genre into a router path. When
|
||||
* provided, genre badges render as <a> links to the filtered library
|
||||
* view. When omitted, badges render as plain text (default).
|
||||
*/
|
||||
genreHref?: (genre: string) => string;
|
||||
overview?: string;
|
||||
actions?: ReactNode;
|
||||
aside?: ReactNode;
|
||||
@@ -38,6 +44,7 @@ export default function DetailHero({
|
||||
logoUrl,
|
||||
metadata,
|
||||
genres,
|
||||
genreHref,
|
||||
overview,
|
||||
actions,
|
||||
aside,
|
||||
@@ -54,16 +61,32 @@ export default function DetailHero({
|
||||
const posterPlaceholder = posterThumbhash ? decodeThumbhash(posterThumbhash) : "";
|
||||
const isCompact = variant === "compact";
|
||||
|
||||
const posterSizeClass =
|
||||
posterOrientation === "portrait"
|
||||
? isCompact
|
||||
? "w-[140px] sm:w-[160px]"
|
||||
: "w-[170px] sm:w-[220px]"
|
||||
: isCompact
|
||||
? "w-[200px] sm:w-[260px]"
|
||||
: "w-[240px] sm:w-[320px]";
|
||||
const posterSizeClass = (() => {
|
||||
switch (posterOrientation) {
|
||||
case "portrait":
|
||||
return isCompact ? "w-[140px] sm:w-[160px]" : "w-[170px] sm:w-[220px]";
|
||||
case "square":
|
||||
// Bigger than portrait — square posters are visually smaller per
|
||||
// pixel than 2:3 ones at the same width, so bump dimensions to
|
||||
// keep them feeling like a comparable hero focal element.
|
||||
return isCompact ? "w-[180px] sm:w-[200px]" : "w-[200px] sm:w-[260px]";
|
||||
case "landscape":
|
||||
default:
|
||||
return isCompact ? "w-[200px] sm:w-[260px]" : "w-[240px] sm:w-[320px]";
|
||||
}
|
||||
})();
|
||||
|
||||
const posterAspect = posterOrientation === "portrait" ? "aspect-[2/3]" : "aspect-video";
|
||||
const posterAspect = (() => {
|
||||
switch (posterOrientation) {
|
||||
case "portrait":
|
||||
return "aspect-[2/3]";
|
||||
case "square":
|
||||
return "aspect-square";
|
||||
case "landscape":
|
||||
default:
|
||||
return "aspect-video";
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<section className="item-detail-hero border-border/10 relative isolate overflow-hidden border-b">
|
||||
@@ -220,11 +243,21 @@ export default function DetailHero({
|
||||
genres &&
|
||||
genres.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{genres.map((genre) => (
|
||||
<span key={genre} className="metadata-badge">
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
{genres.map((genre) =>
|
||||
genreHref ? (
|
||||
<a
|
||||
key={genre}
|
||||
href={genreHref(genre)}
|
||||
className="metadata-badge hover:bg-foreground/10 transition-colors"
|
||||
>
|
||||
{genre}
|
||||
</a>
|
||||
) : (
|
||||
<span key={genre} className="metadata-badge">
|
||||
{genre}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Check,
|
||||
Captions,
|
||||
Download,
|
||||
FolderPlus,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Play,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
RotateCcw,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
import AddToCollectionDialog from "@/components/AddToCollectionDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -154,6 +156,7 @@ export default function ActionBar({
|
||||
const playbackController = useWatchPlaybackController();
|
||||
const [playChoiceOpen, setPlayChoiceOpen] = useState(false);
|
||||
const [refreshDialogOpen, setRefreshDialogOpen] = useState(false);
|
||||
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
|
||||
const [markerEditorOpen, setMarkerEditorOpen] = useState(false);
|
||||
const showMarkerEditor = canEditMarkers && !!contentId;
|
||||
const hasMultipleVersions = (playbackVariants?.length ?? 0) > 1 || (versions?.length ?? 0) > 1;
|
||||
@@ -371,6 +374,12 @@ export default function ActionBar({
|
||||
{inWatchlist ? "Remove from Watchlist" : "Add to Watchlist"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{contentId && (
|
||||
<DropdownMenuItem onSelect={() => setAddToCollectionOpen(true)}>
|
||||
<FolderPlus className="size-4" />
|
||||
Add to Collection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDownload && (
|
||||
<DropdownMenuItem onSelect={onDownload}>
|
||||
<Download className="size-4" />
|
||||
@@ -474,6 +483,13 @@ export default function ActionBar({
|
||||
onConfirm={handleRefreshConfirm}
|
||||
isPending={isRefreshing}
|
||||
/>
|
||||
{contentId && (
|
||||
<AddToCollectionDialog
|
||||
open={addToCollectionOpen}
|
||||
onOpenChange={setAddToCollectionOpen}
|
||||
mediaItemId={contentId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Stream info controls (second row) ──────────────── */}
|
||||
|
||||
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useParams: () => ({ id: "movie-123" }),
|
||||
useSearchParams: () => [new URLSearchParams()],
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/catalogRead", () => ({
|
||||
@@ -53,6 +54,6 @@ describe("ItemDetail", () => {
|
||||
const markup = renderToStaticMarkup(<ItemDetail />);
|
||||
|
||||
expect(markup).toContain("Catalog Detail");
|
||||
expect(mocks.useCatalogItemDetail).toHaveBeenCalledWith("movie-123");
|
||||
expect(mocks.useCatalogItemDetail).toHaveBeenCalledWith("movie-123", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { Navigate, useParams, useSearchParams } from "react-router";
|
||||
import { useCatalogItemDetail } from "@/hooks/queries/catalogRead";
|
||||
import type { ItemDetail } from "@/api/types";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
@@ -9,6 +9,7 @@ import MovieContent from "@/pages/ItemDetail/MovieContent";
|
||||
import SeriesContent from "@/pages/ItemDetail/SeriesContent";
|
||||
import SeasonContent from "@/pages/ItemDetail/SeasonContent";
|
||||
import EpisodeContent from "@/pages/ItemDetail/EpisodeContent";
|
||||
import AudiobookContent from "@/pages/ItemDetail/AudiobookContent";
|
||||
import {
|
||||
CastSkeleton,
|
||||
CrewSkeleton,
|
||||
@@ -98,6 +99,12 @@ export default function ItemDetail() {
|
||||
return <SeasonContent item={item as ItemDetail & { type: "season" }} />;
|
||||
case "episode":
|
||||
return <EpisodeContent item={item as ItemDetail & { type: "episode" }} />;
|
||||
case "audiobook":
|
||||
return (
|
||||
<AudiobookContent item={item as ItemDetail & { type: "audiobook" }} libraryId={libraryId} />
|
||||
);
|
||||
case "podcast":
|
||||
return <Navigate to={`/podcasts/show/${item.content_id}`} replace />;
|
||||
default:
|
||||
return (
|
||||
<div className="page-shell text-muted-foreground py-8">
|
||||
|
||||
@@ -182,4 +182,28 @@ describe("LibraryBrowse", () => {
|
||||
expect(state.type_override).toBe("episode");
|
||||
expect(state.query_definition.sort).toEqual({ field: "title", order: "asc" });
|
||||
});
|
||||
|
||||
it("uses audiobook media scope for audiobook libraries", () => {
|
||||
renderToStaticMarkup(
|
||||
<LibraryBrowse
|
||||
libraryId={10}
|
||||
libraryType="audiobooks"
|
||||
browseType="series"
|
||||
queryDefinition={{
|
||||
library_ids: [],
|
||||
match: "all",
|
||||
groups: [],
|
||||
sort: { field: "title", order: "asc" },
|
||||
}}
|
||||
onBrowseTypeChange={() => {}}
|
||||
onQueryDefinitionChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const [state] = mocks.useCatalogWindow.mock.calls[
|
||||
mocks.useCatalogWindow.mock.calls.length - 1
|
||||
] as [CatalogSearchState, Record<string, unknown>];
|
||||
expect(state.library_id).toBe(10);
|
||||
expect(state.query_definition.media_scope).toBe("audiobook");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,12 +32,26 @@ function getLibrarySortRelevanceScope(
|
||||
if (libraryType === "movie" || libraryType === "series") {
|
||||
return libraryType;
|
||||
}
|
||||
if (mediaScope === "movie" || mediaScope === "series" || mediaScope === "episode") {
|
||||
// The DB stores audiobook library type as the plural "audiobooks";
|
||||
// the sort scope is the singular "audiobook" (matches QueryDefinition.media_scope).
|
||||
if (libraryType === "audiobook" || libraryType === "audiobooks") {
|
||||
return "audiobook";
|
||||
}
|
||||
if (
|
||||
mediaScope === "movie" ||
|
||||
mediaScope === "series" ||
|
||||
mediaScope === "episode" ||
|
||||
mediaScope === "audiobook"
|
||||
) {
|
||||
return mediaScope;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function isAudiobookLibraryType(libraryType: string): boolean {
|
||||
return libraryType === "audiobook" || libraryType === "audiobooks";
|
||||
}
|
||||
|
||||
export default function LibraryBrowse({
|
||||
libraryId,
|
||||
libraryType,
|
||||
@@ -57,9 +71,11 @@ export default function LibraryBrowse({
|
||||
media_scope:
|
||||
libraryType === "mixed"
|
||||
? queryDefinition.media_scope
|
||||
: libraryType === "movie"
|
||||
? libraryType
|
||||
: undefined,
|
||||
: isAudiobookLibraryType(libraryType)
|
||||
? "audiobook"
|
||||
: libraryType === "movie"
|
||||
? libraryType
|
||||
: undefined,
|
||||
sort: normalizeQuerySortForScope(queryDefinition.sort, {
|
||||
includePersonalized: true,
|
||||
relevanceScope: sortRelevanceScope,
|
||||
@@ -141,11 +157,13 @@ export default function LibraryBrowse({
|
||||
allowPersonalizedFilters
|
||||
allowPersonalizedSorts
|
||||
sortRelevanceScope={sortRelevanceScope}
|
||||
libraryType={libraryType}
|
||||
/>
|
||||
<ItemGrid
|
||||
totalItems={totalItems}
|
||||
pages={pages}
|
||||
pageSize={limit}
|
||||
libraryId={libraryId}
|
||||
loading={isLoading}
|
||||
onVisibleRangeChange={handleVisibleRangeChange}
|
||||
sortField={scopedQueryDefinition.sort.field}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Gauge,
|
||||
Download,
|
||||
Puzzle,
|
||||
MonitorPlay,
|
||||
Network,
|
||||
Database,
|
||||
HardDrive,
|
||||
ScrollText,
|
||||
@@ -22,7 +22,7 @@ import ScannerSettings from "./ScannerSettings";
|
||||
import IntroSettings from "./IntroSettings";
|
||||
import RateLimitSettings from "./RateLimitSettings";
|
||||
import IntegrationsSettings from "./IntegrationsSettings";
|
||||
import JellyfinSettings from "./JellyfinSettings";
|
||||
import CompatibilityProxiesSettings from "./CompatibilityProxiesSettings";
|
||||
import DatabaseSettings from "./DatabaseSettings";
|
||||
import StorageSettings from "./StorageSettings";
|
||||
import DownloadSettings from "./DownloadSettings";
|
||||
@@ -46,7 +46,12 @@ const SETTINGS_NAV: SettingsNav[] = [
|
||||
{ id: "rate-limiting", label: "Rate Limiting", icon: Gauge, component: RateLimitSettings },
|
||||
{ id: "downloads", label: "Downloads", icon: Download, component: DownloadSettings },
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle, component: IntegrationsSettings },
|
||||
{ id: "jellyfin", label: "Jellyfin Compat", icon: MonitorPlay, component: JellyfinSettings },
|
||||
{
|
||||
id: "compatibility-proxies",
|
||||
label: "Compatibility Proxies",
|
||||
icon: Network,
|
||||
component: CompatibilityProxiesSettings,
|
||||
},
|
||||
{ id: "database", label: "Database", icon: Database, component: DatabaseSettings },
|
||||
{ id: "storage", label: "Storage", icon: HardDrive, component: StorageSettings },
|
||||
{
|
||||
@@ -60,7 +65,8 @@ const SETTINGS_NAV: SettingsNav[] = [
|
||||
|
||||
export default function AdminSettingsLayout() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeId = searchParams.get("tab") || "general";
|
||||
const rawActiveId = searchParams.get("tab") || "general";
|
||||
const activeId = rawActiveId === "jellyfin" ? "compatibility-proxies" : rawActiveId;
|
||||
|
||||
function setActiveId(id: string) {
|
||||
setSearchParams({ tab: id }, { replace: true });
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import CompatibilityProxiesSettings from "./CompatibilityProxiesSettings";
|
||||
|
||||
const useSettingsFormMock = vi.fn();
|
||||
|
||||
vi.mock("@/hooks/useSettingsForm", () => ({
|
||||
useSettingsForm: (...args: unknown[]) => useSettingsFormMock(...args),
|
||||
}));
|
||||
|
||||
describe("CompatibilityProxiesSettings", () => {
|
||||
it("shows Jellyfin and Audiobookshelf proxy settings", () => {
|
||||
useSettingsFormMock.mockReturnValue({
|
||||
isLoading: false,
|
||||
getValue: (key: string) => {
|
||||
if (key === "audiobookshelf_compat.enabled") return "true";
|
||||
if (key === "jellyfin_compat.public_url") return "https://jellyfin.example.test";
|
||||
return "";
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
dirtyCount: 0,
|
||||
save: vi.fn(),
|
||||
discard: vi.fn(),
|
||||
isSaving: false,
|
||||
restartRequired: false,
|
||||
sensitiveConfigured: [],
|
||||
sensitiveManagedByEnv: [],
|
||||
buildConnectionCheckRequest: vi.fn(),
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(<CompatibilityProxiesSettings />);
|
||||
|
||||
expect(useSettingsFormMock).toHaveBeenCalledWith({
|
||||
keys: expect.arrayContaining([
|
||||
"jellyfin_compat.public_url",
|
||||
"jellyfin_compat.server_name",
|
||||
"audiobookshelf_compat.enabled",
|
||||
]),
|
||||
});
|
||||
expect(markup).toContain("Compatibility Proxies");
|
||||
expect(markup).toContain("Jellyfin");
|
||||
expect(markup).toContain("Audiobookshelf");
|
||||
expect(markup).toContain("Enable Audiobookshelf Proxy");
|
||||
expect(markup).not.toContain("Listen Address");
|
||||
expect(markup).not.toContain("https://abs.example.test");
|
||||
});
|
||||
});
|
||||
+35
-26
@@ -5,7 +5,7 @@ import { SaveBar } from "./SaveBar";
|
||||
import { FieldGroup } from "./FieldGroup";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const KEYS = [
|
||||
const JELLYFIN_KEYS = [
|
||||
"jellyfin_compat.public_url",
|
||||
"jellyfin_compat.server_name",
|
||||
"jellyfin_compat.server_id",
|
||||
@@ -14,22 +14,26 @@ const KEYS = [
|
||||
"jellyfin_compat.playback_session_ttl",
|
||||
];
|
||||
|
||||
export default function JellyfinSettings() {
|
||||
const AUDIOBOOKSHELF_KEYS = ["audiobookshelf_compat.enabled"];
|
||||
|
||||
const KEYS = [...JELLYFIN_KEYS, ...AUDIOBOOKSHELF_KEYS];
|
||||
|
||||
export default function CompatibilityProxiesSettings() {
|
||||
const form = useSettingsForm({ keys: useMemo(() => KEYS, []) });
|
||||
|
||||
if (form.isLoading)
|
||||
return (
|
||||
<div className="space-y-6" role="status" aria-label="Loading settings">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-8 w-56" />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<span className="sr-only">Loading settings</span>
|
||||
</div>
|
||||
@@ -38,14 +42,14 @@ export default function JellyfinSettings() {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-6 space-y-2">
|
||||
<h2 className="text-xl font-semibold tracking-tight">Jellyfin Compat</h2>
|
||||
<h2 className="text-xl font-semibold tracking-tight">Compatibility Proxies</h2>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Tune the compatibility layer exposed to Jellyfin-compatible clients.
|
||||
Configure protocol-compatible listener surfaces for external client apps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<FieldGroup label="Server Identity">
|
||||
<div className="flex-1 space-y-6">
|
||||
<FieldGroup label="Jellyfin">
|
||||
<SettingField
|
||||
label="Public URL"
|
||||
value={form.getValue("jellyfin_compat.public_url")}
|
||||
@@ -66,26 +70,31 @@ export default function JellyfinSettings() {
|
||||
value={form.getValue("jellyfin_compat.emulated_server_version")}
|
||||
onChange={(v) => form.setValue("jellyfin_compat.emulated_server_version", v)}
|
||||
/>
|
||||
<SettingField
|
||||
label="Session TTL"
|
||||
type="duration"
|
||||
hint="e.g. 24h"
|
||||
value={form.getValue("jellyfin_compat.session_ttl")}
|
||||
onChange={(v) => form.setValue("jellyfin_compat.session_ttl", v)}
|
||||
/>
|
||||
<SettingField
|
||||
label="Playback Session TTL"
|
||||
type="duration"
|
||||
hint="e.g. 6h"
|
||||
value={form.getValue("jellyfin_compat.playback_session_ttl")}
|
||||
onChange={(v) => form.setValue("jellyfin_compat.playback_session_ttl", v)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6">
|
||||
<FieldGroup label="Session Lifetimes">
|
||||
<SettingField
|
||||
label="Session TTL"
|
||||
type="duration"
|
||||
hint="e.g. 24h"
|
||||
value={form.getValue("jellyfin_compat.session_ttl")}
|
||||
onChange={(v) => form.setValue("jellyfin_compat.session_ttl", v)}
|
||||
/>
|
||||
<SettingField
|
||||
label="Playback Session TTL"
|
||||
type="duration"
|
||||
hint="e.g. 6h"
|
||||
value={form.getValue("jellyfin_compat.playback_session_ttl")}
|
||||
onChange={(v) => form.setValue("jellyfin_compat.playback_session_ttl", v)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<FieldGroup label="Audiobookshelf">
|
||||
<SettingField
|
||||
label="Enable Audiobookshelf Proxy"
|
||||
type="toggle"
|
||||
hint="Starts the ABS-compatible API listener for external Audiobookshelf clients."
|
||||
value={form.getValue("audiobookshelf_compat.enabled")}
|
||||
onChange={(v) => form.setValue("audiobookshelf_compat.enabled", v)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
|
||||
<SaveBar
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChaptersSection } from "./ChaptersSection";
|
||||
import type { AudiobookFile } from "@/lib/audiobooks/types";
|
||||
|
||||
const files: AudiobookFile[] = [
|
||||
{
|
||||
id: 1,
|
||||
path: "a",
|
||||
duration_seconds: 600,
|
||||
chapters: [
|
||||
{ index: 0, title: "Prologue", source: "embedded", start_seconds: 0, end_seconds: 200 },
|
||||
{ index: 1, title: "Memory", source: "embedded", start_seconds: 200, end_seconds: 600 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function expandChapters(): Promise<void> {
|
||||
await userEvent.click(screen.getByRole("button", { name: /^chapters/i }));
|
||||
}
|
||||
|
||||
describe("ChaptersSection", () => {
|
||||
it("renders chapter section collapsed by default", () => {
|
||||
render(<ChaptersSection files={files} currentPositionSeconds={null} onSelect={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /Prologue/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Memory/ })).not.toBeInTheDocument();
|
||||
// Header toggle is visible.
|
||||
expect(screen.getByRole("button", { name: /^chapters/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands when the header is clicked", async () => {
|
||||
render(<ChaptersSection files={files} currentPositionSeconds={null} onSelect={vi.fn()} />);
|
||||
await expandChapters();
|
||||
expect(screen.getByRole("button", { name: /Prologue/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Memory/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights the currently-playing chapter once expanded", async () => {
|
||||
render(<ChaptersSection files={files} currentPositionSeconds={250} onSelect={vi.fn()} />);
|
||||
await expandChapters();
|
||||
const row = screen.getByRole("button", { name: /Memory/ });
|
||||
expect(row).toHaveAttribute("data-current", "true");
|
||||
});
|
||||
|
||||
it("sort menu switches between position and longest-first orders", async () => {
|
||||
render(<ChaptersSection files={files} currentPositionSeconds={null} onSelect={vi.fn()} />);
|
||||
await expandChapters();
|
||||
const rowsBefore = screen.getAllByRole("button", { name: /Prologue|Memory/ });
|
||||
expect(within(rowsBefore[0]!).getByText("Prologue")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /sort/i }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /longest first/i }));
|
||||
|
||||
const rowsAfter = screen.getAllByRole("button", { name: /Prologue|Memory/ });
|
||||
expect(within(rowsAfter[0]!).getByText("Memory")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelect with absolute start seconds when a chapter is clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ChaptersSection files={files} currentPositionSeconds={null} onSelect={onSelect} />);
|
||||
await expandChapters();
|
||||
await userEvent.click(screen.getByRole("button", { name: /Memory/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowUpDown, ChevronDown, Play } from "lucide-react";
|
||||
import type { AudiobookChapter, AudiobookFile } from "@/lib/audiobooks/types";
|
||||
|
||||
interface ChaptersSectionProps {
|
||||
files: AudiobookFile[];
|
||||
currentPositionSeconds: number | null;
|
||||
onSelect: (absoluteSeconds: number) => void;
|
||||
}
|
||||
|
||||
type SortMode = "position" | "longest-first";
|
||||
|
||||
interface Row {
|
||||
chapter: AudiobookChapter;
|
||||
absoluteStart: number;
|
||||
durationSeconds: number;
|
||||
label: string;
|
||||
positionIndex: number; // 1-based
|
||||
}
|
||||
|
||||
function buildRows(files: AudiobookFile[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let offset = 0;
|
||||
let positionIndex = 1;
|
||||
for (const file of files) {
|
||||
if (file.chapters) {
|
||||
for (const ch of file.chapters) {
|
||||
rows.push({
|
||||
chapter: ch,
|
||||
absoluteStart: offset + ch.start_seconds,
|
||||
durationSeconds: Math.max(0, (ch.end_seconds ?? ch.start_seconds) - ch.start_seconds),
|
||||
label: ch.title || `Chapter ${ch.index + 1}`,
|
||||
positionIndex: positionIndex++,
|
||||
});
|
||||
}
|
||||
}
|
||||
offset += file.duration_seconds ?? 0;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function formatChapterDuration(s: number): string {
|
||||
if (s <= 0) return "";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}m ${String(sec).padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function formatChapterStart(totalSeconds: number): string {
|
||||
const h = Math.floor(totalSeconds / 3600);
|
||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||
const s = Math.floor(totalSeconds % 60);
|
||||
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function ChaptersSection({ files, currentPositionSeconds, onSelect }: ChaptersSectionProps) {
|
||||
const rows = useMemo(() => buildRows(files), [files]);
|
||||
const [sort, setSort] = useState<SortMode>("position");
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (sort === "longest-first") {
|
||||
return [...rows].sort((a, b) => b.durationSeconds - a.durationSeconds);
|
||||
}
|
||||
return rows;
|
||||
}, [rows, sort]);
|
||||
|
||||
const currentIndex = useMemo(() => {
|
||||
if (currentPositionSeconds == null) return -1;
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i];
|
||||
if (row && currentPositionSeconds >= row.absoluteStart) return i;
|
||||
}
|
||||
return -1;
|
||||
}, [rows, currentPositionSeconds]);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
className="hover:text-primary flex items-center gap-2 text-xl font-semibold tracking-tight transition-colors"
|
||||
>
|
||||
<ChevronDown className={`h-5 w-5 transition-transform ${expanded ? "" : "-rotate-90"}`} />
|
||||
Chapters
|
||||
<span className="text-muted-foreground text-sm font-normal">({rows.length})</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sort chapters"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sortOpen}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
|
||||
onClick={() => setSortOpen((v) => !v)}
|
||||
>
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
Sort
|
||||
</button>
|
||||
{sortOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="bg-popover absolute right-0 mt-2 w-44 overflow-hidden rounded-md border shadow-lg"
|
||||
>
|
||||
{(["position", "longest-first"] as SortMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
data-active={mode === sort ? "true" : undefined}
|
||||
className="hover:bg-muted/60 w-full px-3 py-2 text-left text-sm"
|
||||
onClick={() => {
|
||||
setSort(mode);
|
||||
setSortOpen(false);
|
||||
}}
|
||||
>
|
||||
{mode === "position" ? "By position" : "Longest first"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<ol className="divide-border divide-y rounded-xl border">
|
||||
{sorted.map((row) => {
|
||||
const isCurrent = rows[currentIndex]?.chapter === row.chapter;
|
||||
return (
|
||||
<li key={`${row.chapter.index}-${row.absoluteStart}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(row.absoluteStart)}
|
||||
data-current={isCurrent ? "true" : undefined}
|
||||
className={`hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||||
isCurrent ? "bg-muted/50 border-primary border-l-2" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-muted-foreground w-8 shrink-0 text-right text-xs tabular-nums">
|
||||
{row.positionIndex}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{row.label}</span>
|
||||
<span className="text-muted-foreground hidden shrink-0 font-mono text-xs sm:inline">
|
||||
{formatChapterStart(row.absoluteStart)}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 font-mono text-xs">
|
||||
{formatChapterDuration(row.durationSeconds)}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-primary flex shrink-0 items-center gap-1 text-xs">
|
||||
<Play className="h-3 w-3 fill-current" /> playing
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
interface NarratorCardProps {
|
||||
narrator: string;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]?.toUpperCase() ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function NarratorCard({ narrator }: NarratorCardProps) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-xl font-semibold tracking-tight">Narrator</h2>
|
||||
<div className="bg-muted/30 flex items-center gap-4 rounded-xl border p-4">
|
||||
<div className="bg-muted text-muted-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded-full text-lg font-medium">
|
||||
{initials(narrator) || "?"}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-medium">{narrator}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { AudiobookNarration } from "@/lib/audiobooks/types";
|
||||
|
||||
interface NarratorPickerProps {
|
||||
currentNarrator: string;
|
||||
currentContentId: string;
|
||||
others: AudiobookNarration[];
|
||||
}
|
||||
|
||||
export function NarratorPicker({ currentNarrator, currentContentId, others }: NarratorPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
const options: AudiobookNarration[] = [
|
||||
{ content_id: currentContentId, title: "", narrators: [currentNarrator] },
|
||||
...others,
|
||||
];
|
||||
|
||||
return (
|
||||
<span ref={wrapperRef} className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 rounded border border-transparent px-1 py-0.5 transition-colors hover:border-current/30"
|
||||
>
|
||||
{currentNarrator}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
className="bg-popover absolute top-full left-0 z-50 mt-1 min-w-[220px] overflow-hidden rounded-md border shadow-lg"
|
||||
>
|
||||
<div className="text-muted-foreground px-3 py-2 text-[11px] tracking-[0.12em] uppercase">
|
||||
{options.length} narrations
|
||||
</div>
|
||||
{options.map((it) => {
|
||||
const isCurrent = it.content_id === currentContentId;
|
||||
return (
|
||||
<button
|
||||
key={it.content_id}
|
||||
role="option"
|
||||
aria-selected={isCurrent}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
if (!isCurrent) navigate(`/item/${it.content_id}`);
|
||||
}}
|
||||
className={`hover:bg-muted/60 flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm ${
|
||||
isCurrent ? "bg-muted/40 font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{it.narrators.length > 0 ? it.narrators.join(", ") : "Unknown narrator"}
|
||||
</span>
|
||||
{it.year ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{it.year}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
|
||||
interface RelatedRailItem {
|
||||
content_id: string;
|
||||
title: string;
|
||||
poster_url?: string;
|
||||
subtitle?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
interface RelatedRailProps {
|
||||
heading: string;
|
||||
subtitle?: string;
|
||||
items: RelatedRailItem[];
|
||||
}
|
||||
|
||||
export function RelatedRail({ heading, subtitle, items }: RelatedRailProps) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{heading}</h2>
|
||||
{subtitle && <p className="text-muted-foreground mt-1 text-xs">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="-mx-2 flex gap-3 overflow-x-auto px-2 pb-2">
|
||||
{items.map((item) => (
|
||||
<ViewTransitionLink
|
||||
key={item.content_id}
|
||||
to={`/item/${item.content_id}`}
|
||||
className={`block w-[140px] shrink-0 sm:w-[160px] lg:w-[185px] ${
|
||||
item.highlight ? "ring-primary rounded-lg ring-2 ring-offset-2" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={item.poster_url}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 truncate text-sm font-medium">{item.title}</div>
|
||||
{item.subtitle && (
|
||||
<div className="text-muted-foreground truncate text-xs">{item.subtitle}</div>
|
||||
)}
|
||||
</ViewTransitionLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import type { AudiobookFile } from "@/lib/audiobooks/types";
|
||||
import { useAudiobookPlayback } from "./useAudiobookPlayback";
|
||||
import { MiniBar } from "./MiniBar";
|
||||
import { NowListening } from "./NowListening";
|
||||
|
||||
export interface AudiobookPlayerProps {
|
||||
contentId: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
narrator?: string;
|
||||
posterUrl?: string;
|
||||
files: AudiobookFile[];
|
||||
initialPositionSeconds?: number;
|
||||
autoPlay?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function AudiobookPlayer({
|
||||
contentId,
|
||||
title,
|
||||
author,
|
||||
narrator,
|
||||
posterUrl,
|
||||
files,
|
||||
initialPositionSeconds = 0,
|
||||
autoPlay = true,
|
||||
onClose,
|
||||
}: AudiobookPlayerProps) {
|
||||
const playback = useAudiobookPlayback({
|
||||
contentId,
|
||||
files,
|
||||
initialPositionSeconds,
|
||||
autoPlay,
|
||||
});
|
||||
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
|
||||
|
||||
return (
|
||||
<>
|
||||
{playback.hasFile && (
|
||||
<audio
|
||||
ref={playback.audioRef}
|
||||
src={playback.streamUrl}
|
||||
preload="metadata"
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
{mode === "mini" ? (
|
||||
<MiniBar
|
||||
contentId={contentId}
|
||||
title={title}
|
||||
posterUrl={posterUrl}
|
||||
playback={playback}
|
||||
onClose={onClose}
|
||||
onExpand={() => setMode("now-listening")}
|
||||
/>
|
||||
) : (
|
||||
<NowListening
|
||||
contentId={contentId}
|
||||
title={title}
|
||||
author={author}
|
||||
narrator={narrator}
|
||||
posterUrl={posterUrl ?? ""}
|
||||
playback={playback}
|
||||
onCollapse={() => setMode("mini")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Maximize2 } from "lucide-react";
|
||||
|
||||
interface CoverExpandTileProps {
|
||||
contentId: string;
|
||||
posterUrl?: string;
|
||||
title?: string;
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
export function CoverExpandTile({ contentId, posterUrl, title, onExpand }: CoverExpandTileProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
aria-label="Open Now Listening"
|
||||
className="group/tile bg-muted relative h-[54px] w-[36px] shrink-0 overflow-hidden rounded-md transition-transform hover:scale-[1.04] focus-visible:scale-[1.04] focus-visible:outline-none"
|
||||
style={{ viewTransitionName: `audiobook-cover-${contentId}` }}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<img src={posterUrl} alt={title ?? ""} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover/tile:opacity-100 group-focus-visible/tile:opacity-100">
|
||||
<Maximize2 className="h-3.5 w-3.5 text-white" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MiniBar } from "./MiniBar";
|
||||
import type { AudiobookPlayback } from "./useAudiobookPlayback";
|
||||
|
||||
function makePlayback(over: Partial<AudiobookPlayback> = {}): AudiobookPlayback {
|
||||
return {
|
||||
audioRef: { current: null },
|
||||
streamUrl: "",
|
||||
hasFile: true,
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
duration: 600,
|
||||
buffered: null,
|
||||
rate: 1,
|
||||
chapters: [],
|
||||
currentChapter: null,
|
||||
togglePlay: vi.fn(),
|
||||
seekTo: vi.fn(),
|
||||
skip: vi.fn(),
|
||||
setRate: vi.fn(),
|
||||
sleep: { setting: { kind: "off" as const }, remainingMs: null },
|
||||
setSleep: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MiniBar", () => {
|
||||
it("renders the title", () => {
|
||||
render(<MiniBar contentId="book-1" title="Project Hail Mary" playback={makePlayback()} />);
|
||||
expect(screen.getByText("Project Hail Mary")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls togglePlay when the center button is clicked", async () => {
|
||||
const togglePlay = vi.fn();
|
||||
render(<MiniBar contentId="book-1" title="X" playback={makePlayback({ togglePlay })} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /^(Play|Pause)$/ }));
|
||||
expect(togglePlay).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when the close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MiniBar contentId="book-1" title="X" playback={makePlayback()} onClose={onClose} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /close player/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the current chapter title under the book title", () => {
|
||||
render(
|
||||
<MiniBar
|
||||
contentId="book-1"
|
||||
title="X"
|
||||
playback={makePlayback({
|
||||
currentChapter: {
|
||||
index: 6,
|
||||
title: "The Astrophage",
|
||||
start_seconds: 0,
|
||||
end_seconds: 100,
|
||||
source: "embedded",
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("The Astrophage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the chapter row when no current chapter is known", () => {
|
||||
render(
|
||||
<MiniBar contentId="book-1" title="X" playback={makePlayback({ currentChapter: null })} />,
|
||||
);
|
||||
expect(screen.queryByTestId("minibar-chapter-title")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onExpand when the cover tile is clicked", async () => {
|
||||
const onExpand = vi.fn();
|
||||
render(
|
||||
<MiniBar
|
||||
contentId="book-1"
|
||||
title="X"
|
||||
posterUrl="/p.jpg"
|
||||
playback={makePlayback()}
|
||||
onExpand={onExpand}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /open now listening/i }));
|
||||
expect(onExpand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { X, Pause, Play, RotateCcw, RotateCw } from "lucide-react";
|
||||
import { SeekBar, formatTime } from "@/player/components/SeekBar";
|
||||
import { ChaptersMenu } from "@/player/components/ChaptersMenu";
|
||||
import { CircleButton } from "@/player/components/CircleButton";
|
||||
import { SpeedMenu } from "@/player/components/SpeedMenu";
|
||||
import { SleepTimerMenu } from "@/player/components/SleepTimerMenu";
|
||||
import { CoverExpandTile } from "./CoverExpandTile";
|
||||
import type { AudiobookPlayback } from "./useAudiobookPlayback";
|
||||
|
||||
const SKIP_BACK_SECONDS = 30;
|
||||
const SKIP_FORWARD_SECONDS = 30;
|
||||
const PLAYBACK_RATES = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
interface MiniBarProps {
|
||||
contentId: string;
|
||||
title?: string;
|
||||
posterUrl?: string;
|
||||
playback: AudiobookPlayback;
|
||||
onClose?: () => void;
|
||||
onExpand?: () => void;
|
||||
}
|
||||
|
||||
export function MiniBar({
|
||||
contentId,
|
||||
title,
|
||||
posterUrl,
|
||||
playback,
|
||||
onClose,
|
||||
onExpand,
|
||||
}: MiniBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="bg-background fixed right-0 bottom-0 z-40 border-t px-3 pt-2 pb-2 shadow-lg sm:px-6"
|
||||
style={{ left: "var(--app-sidebar-offset, 0px)" }}
|
||||
>
|
||||
<SeekBar
|
||||
currentTime={playback.currentTime}
|
||||
duration={playback.duration}
|
||||
buffered={playback.buffered}
|
||||
chapters={playback.chapters}
|
||||
onSeek={playback.seekTo}
|
||||
/>
|
||||
|
||||
<div className="mt-1 grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 sm:gap-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<CoverExpandTile
|
||||
contentId={contentId}
|
||||
posterUrl={posterUrl}
|
||||
title={title}
|
||||
onExpand={onExpand}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{title ? (
|
||||
<div
|
||||
className="truncate text-[14px] leading-tight font-semibold tracking-tight sm:text-[15px]"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
{playback.currentChapter ? (
|
||||
<div
|
||||
data-testid="minibar-chapter-title"
|
||||
className="text-muted-foreground truncate text-[11px] leading-tight"
|
||||
title={playback.currentChapter.title}
|
||||
>
|
||||
{playback.currentChapter.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-[10px] leading-tight uppercase">
|
||||
<span className="font-mono text-[11px] tracking-[0.12em] normal-case tabular-nums">
|
||||
{formatTime(playback.currentTime)}
|
||||
<span className="mx-1 opacity-50">/</span>
|
||||
{formatTime(playback.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 sm:gap-3">
|
||||
<CircleButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
ariaLabel={`Back ${SKIP_BACK_SECONDS} seconds`}
|
||||
onClick={() => playback.skip(-SKIP_BACK_SECONDS)}
|
||||
disabled={!playback.hasFile}
|
||||
>
|
||||
<SkipIcon direction="back" seconds={SKIP_BACK_SECONDS} />
|
||||
</CircleButton>
|
||||
|
||||
<CircleButton
|
||||
size="md"
|
||||
variant="primary"
|
||||
ariaLabel={playback.playing ? "Pause" : "Play"}
|
||||
onClick={playback.togglePlay}
|
||||
disabled={!playback.hasFile}
|
||||
data-paused={!playback.playing}
|
||||
>
|
||||
{playback.playing ? (
|
||||
<Pause className="h-5 w-5" strokeWidth={0} fill="currentColor" />
|
||||
) : (
|
||||
<Play className="ml-[2px] h-5 w-5" strokeWidth={0} fill="currentColor" />
|
||||
)}
|
||||
</CircleButton>
|
||||
|
||||
<CircleButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
ariaLabel={`Forward ${SKIP_FORWARD_SECONDS} seconds`}
|
||||
onClick={() => playback.skip(SKIP_FORWARD_SECONDS)}
|
||||
disabled={!playback.hasFile}
|
||||
>
|
||||
<SkipIcon direction="forward" seconds={SKIP_FORWARD_SECONDS} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<SleepTimerMenu
|
||||
setting={playback.sleep.setting}
|
||||
remainingMs={playback.sleep.remainingMs}
|
||||
onChange={playback.setSleep}
|
||||
/>
|
||||
{playback.chapters.length > 0 && (
|
||||
<ChaptersMenu
|
||||
chapters={playback.chapters}
|
||||
currentTime={playback.currentTime}
|
||||
onSeek={playback.seekTo}
|
||||
/>
|
||||
)}
|
||||
<SpeedMenu rates={PLAYBACK_RATES} value={playback.rate} onChange={playback.setRate} />
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close player"
|
||||
className="text-muted-foreground hover:text-foreground rounded p-1.5"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipIcon({ direction, seconds }: { direction: "back" | "forward"; seconds: number }) {
|
||||
const Arrow = direction === "back" ? RotateCcw : RotateCw;
|
||||
return (
|
||||
<span className="relative flex h-6 w-6 items-center justify-center">
|
||||
<Arrow className="h-6 w-6" strokeWidth={1.6} />
|
||||
<span className="absolute inset-0 flex items-center justify-center pb-[1px] text-[8px] font-semibold tracking-tight tabular-nums">
|
||||
{seconds}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NowListening } from "./NowListening";
|
||||
import type { AudiobookPlayback } from "./useAudiobookPlayback";
|
||||
|
||||
function makePlayback(over: Partial<AudiobookPlayback> = {}): AudiobookPlayback {
|
||||
return {
|
||||
audioRef: { current: null },
|
||||
streamUrl: "",
|
||||
hasFile: true,
|
||||
playing: false,
|
||||
currentTime: 0,
|
||||
duration: 600,
|
||||
buffered: null,
|
||||
rate: 1,
|
||||
chapters: [],
|
||||
currentChapter: null,
|
||||
togglePlay: vi.fn(),
|
||||
seekTo: vi.fn(),
|
||||
skip: vi.fn(),
|
||||
setRate: vi.fn(),
|
||||
sleep: { setting: { kind: "off" as const }, remainingMs: null },
|
||||
setSleep: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("NowListening", () => {
|
||||
it("renders title, author, narrator, and the current chapter heading", () => {
|
||||
render(
|
||||
<NowListening
|
||||
contentId="book-1"
|
||||
title="Project Hail Mary"
|
||||
author="Andy Weir"
|
||||
narrator="Ray Porter"
|
||||
posterUrl="/p.jpg"
|
||||
playback={makePlayback({
|
||||
currentChapter: {
|
||||
index: 6,
|
||||
title: "The Astrophage",
|
||||
start_seconds: 0,
|
||||
end_seconds: 100,
|
||||
source: "embedded",
|
||||
},
|
||||
})}
|
||||
onCollapse={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Project Hail Mary" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Andy Weir")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ray Porter/)).toBeInTheDocument();
|
||||
expect(screen.getByText("The Astrophage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCollapse when the collapse button is clicked", async () => {
|
||||
const onCollapse = vi.fn();
|
||||
render(
|
||||
<NowListening
|
||||
contentId="book-1"
|
||||
title="X"
|
||||
posterUrl=""
|
||||
playback={makePlayback()}
|
||||
onCollapse={onCollapse}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: /back to player/i }));
|
||||
expect(onCollapse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toggles between remaining and total time when the right time is clicked", async () => {
|
||||
render(
|
||||
<NowListening
|
||||
contentId="book-1"
|
||||
title="X"
|
||||
posterUrl=""
|
||||
playback={makePlayback({ currentTime: 100, duration: 600 })}
|
||||
onCollapse={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("now-listening-right-time")).toHaveTextContent("10:00");
|
||||
await userEvent.click(screen.getByTestId("now-listening-right-time"));
|
||||
expect(screen.getByTestId("now-listening-right-time")).toHaveTextContent("-8:20");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, MoreHorizontal, Pause, Play, RotateCcw, RotateCw } from "lucide-react";
|
||||
import { SeekBar, formatTime } from "@/player/components/SeekBar";
|
||||
import { ChaptersMenu } from "@/player/components/ChaptersMenu";
|
||||
import { CircleButton } from "@/player/components/CircleButton";
|
||||
import { SpeedMenu } from "@/player/components/SpeedMenu";
|
||||
import { SleepTimerMenu } from "@/player/components/SleepTimerMenu";
|
||||
import type { AudiobookPlayback } from "./useAudiobookPlayback";
|
||||
|
||||
const SKIP_BACK_SECONDS = 30;
|
||||
const SKIP_FORWARD_SECONDS = 30;
|
||||
const PLAYBACK_RATES = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
interface NowListeningProps {
|
||||
contentId: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
narrator?: string;
|
||||
posterUrl: string;
|
||||
playback: AudiobookPlayback;
|
||||
onCollapse: () => void;
|
||||
}
|
||||
|
||||
export function NowListening({
|
||||
contentId,
|
||||
title,
|
||||
author,
|
||||
narrator,
|
||||
posterUrl,
|
||||
playback,
|
||||
onCollapse,
|
||||
}: NowListeningProps) {
|
||||
const [showRemaining, setShowRemaining] = useState(false);
|
||||
|
||||
const rightTimeLabel = showRemaining
|
||||
? `-${formatTime(Math.max(0, playback.duration - playback.currentTime))}`
|
||||
: formatTime(playback.duration);
|
||||
|
||||
return (
|
||||
<div className="bg-background fixed inset-0 z-50 flex flex-col overflow-y-auto">
|
||||
<div className="bg-background/95 sticky top-0 z-10 flex items-center justify-between px-6 py-4 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCollapse}
|
||||
className="text-muted-foreground hover:bg-muted hover:text-foreground -ml-2 inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
<span>Back to player</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More"
|
||||
className="text-muted-foreground hover:text-foreground rounded p-1.5"
|
||||
>
|
||||
<MoreHorizontal className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-1 items-center gap-8 px-6 pt-2 pb-10 sm:gap-10 md:grid-cols-[auto_1fr] md:px-16">
|
||||
<div className="mx-auto w-full max-w-[min(70vw,320px)] md:mx-0 md:max-w-[360px]">
|
||||
<div
|
||||
className="bg-muted aspect-square w-full overflow-hidden rounded-2xl shadow-2xl"
|
||||
style={{ viewTransitionName: `audiobook-cover-${contentId}` }}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<img src={posterUrl} alt={title} className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-xl flex-col gap-6 md:gap-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
|
||||
{author && <p className="text-muted-foreground text-base">{author}</p>}
|
||||
{narrator && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Narrated by <span className="text-foreground">{narrator}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{playback.currentChapter && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
|
||||
Chapter {playback.currentChapter.index + 1}
|
||||
</p>
|
||||
<p className="text-foreground text-lg font-medium">{playback.currentChapter.title}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<SeekBar
|
||||
currentTime={playback.currentTime}
|
||||
duration={playback.duration}
|
||||
buffered={playback.buffered}
|
||||
chapters={playback.chapters}
|
||||
onSeek={playback.seekTo}
|
||||
/>
|
||||
<div className="text-muted-foreground flex items-center justify-between text-xs tabular-nums">
|
||||
<span>{formatTime(playback.currentTime)}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="now-listening-right-time"
|
||||
onClick={() => setShowRemaining((v) => !v)}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{rightTimeLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<CircleButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
ariaLabel={`Back ${SKIP_BACK_SECONDS} seconds`}
|
||||
onClick={() => playback.skip(-SKIP_BACK_SECONDS)}
|
||||
disabled={!playback.hasFile}
|
||||
>
|
||||
<SkipIcon direction="back" seconds={SKIP_BACK_SECONDS} />
|
||||
</CircleButton>
|
||||
|
||||
<CircleButton
|
||||
size="lg"
|
||||
variant="primary"
|
||||
ariaLabel={playback.playing ? "Pause" : "Play"}
|
||||
onClick={playback.togglePlay}
|
||||
disabled={!playback.hasFile}
|
||||
data-paused={!playback.playing}
|
||||
>
|
||||
{playback.playing ? (
|
||||
<Pause className="h-8 w-8" strokeWidth={0} fill="currentColor" />
|
||||
) : (
|
||||
<Play className="ml-[2px] h-8 w-8" strokeWidth={0} fill="currentColor" />
|
||||
)}
|
||||
</CircleButton>
|
||||
|
||||
<CircleButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
ariaLabel={`Forward ${SKIP_FORWARD_SECONDS} seconds`}
|
||||
onClick={() => playback.skip(SKIP_FORWARD_SECONDS)}
|
||||
disabled={!playback.hasFile}
|
||||
>
|
||||
<SkipIcon direction="forward" seconds={SKIP_FORWARD_SECONDS} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-6 text-sm">
|
||||
<SleepTimerMenu
|
||||
setting={playback.sleep.setting}
|
||||
remainingMs={playback.sleep.remainingMs}
|
||||
onChange={playback.setSleep}
|
||||
/>
|
||||
{playback.chapters.length > 0 && (
|
||||
<ChaptersMenu
|
||||
chapters={playback.chapters}
|
||||
currentTime={playback.currentTime}
|
||||
onSeek={playback.seekTo}
|
||||
/>
|
||||
)}
|
||||
<SpeedMenu rates={PLAYBACK_RATES} value={playback.rate} onChange={playback.setRate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkipIcon({ direction, seconds }: { direction: "back" | "forward"; seconds: number }) {
|
||||
const Arrow = direction === "back" ? RotateCcw : RotateCw;
|
||||
return (
|
||||
<span className="relative flex h-7 w-7 items-center justify-center">
|
||||
<Arrow className="h-7 w-7" strokeWidth={1.6} />
|
||||
<span className="absolute inset-0 flex items-center justify-center pb-[1px] text-[8.5px] font-semibold tracking-tight tabular-nums">
|
||||
{seconds}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useAudiobookPlayback } from "./useAudiobookPlayback";
|
||||
import type { AudiobookFile } from "@/lib/audiobooks/types";
|
||||
|
||||
vi.mock("@/hooks/queries/progress", () => ({
|
||||
useReportMediaProgress: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/hooks/queries/downloads", () => ({
|
||||
buildDirectDownloadUrl: (id: number) => `/stream/${id}`,
|
||||
}));
|
||||
|
||||
const files: AudiobookFile[] = [
|
||||
{
|
||||
id: 1,
|
||||
path: "a.m4b",
|
||||
duration_seconds: 600,
|
||||
chapters: [
|
||||
{ index: 0, title: "One", source: "embedded", start_seconds: 0, end_seconds: 300 },
|
||||
{ index: 1, title: "Two", source: "embedded", start_seconds: 300, end_seconds: 600 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const multiFile: AudiobookFile[] = [
|
||||
{
|
||||
id: 1,
|
||||
path: "a.mp3",
|
||||
duration_seconds: 300,
|
||||
chapters: [{ index: 0, title: "One", source: "embedded", start_seconds: 0, end_seconds: 300 }],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
path: "b.mp3",
|
||||
duration_seconds: 300,
|
||||
chapters: [{ index: 0, title: "Two", source: "embedded", start_seconds: 0, end_seconds: 300 }],
|
||||
},
|
||||
];
|
||||
|
||||
function makeAudio() {
|
||||
const audio = document.createElement("audio");
|
||||
Object.defineProperty(audio, "duration", { value: 600, writable: true });
|
||||
Object.defineProperty(audio, "paused", { value: true, writable: true });
|
||||
audio.play = vi.fn().mockResolvedValue(undefined);
|
||||
audio.pause = vi.fn();
|
||||
return audio;
|
||||
}
|
||||
|
||||
describe("useAudiobookPlayback", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("returns a flattened chapter list across files", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
expect(result.current.chapters).toHaveLength(2);
|
||||
expect(result.current.chapters[0]!.start_seconds).toBe(0);
|
||||
expect(result.current.chapters[1]!.start_seconds).toBe(300);
|
||||
});
|
||||
|
||||
it("computes streamUrl from the first file id", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
expect(result.current.streamUrl).toBe("/stream/1");
|
||||
});
|
||||
|
||||
it("computes streamUrl from the part containing the initial absolute position", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files: multiFile, initialPositionSeconds: 450 }),
|
||||
);
|
||||
expect(result.current.streamUrl).toBe("/stream/2");
|
||||
expect(result.current.currentTime).toBe(450);
|
||||
expect(result.current.duration).toBe(600);
|
||||
});
|
||||
|
||||
it("togglePlay invokes audio.play when paused, audio.pause otherwise", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
const audio = makeAudio();
|
||||
act(() => {
|
||||
(result.current.audioRef as React.MutableRefObject<HTMLAudioElement>).current = audio;
|
||||
});
|
||||
act(() => result.current.togglePlay());
|
||||
expect(audio.play).toHaveBeenCalled();
|
||||
Object.defineProperty(audio, "paused", { value: false, writable: true });
|
||||
act(() => result.current.togglePlay());
|
||||
expect(audio.pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seekTo clamps to [0, duration]", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
const audio = makeAudio();
|
||||
act(() => {
|
||||
(result.current.audioRef as React.MutableRefObject<HTMLAudioElement>).current = audio;
|
||||
});
|
||||
act(() => result.current.seekTo(1_000_000));
|
||||
expect(audio.currentTime).toBe(599); // 600 - 1 (clamp to duration - 1 per existing behavior)
|
||||
act(() => result.current.seekTo(-50));
|
||||
expect(audio.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("currentChapter starts at the first chapter when currentTime is 0", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
expect(result.current.currentChapter?.title).toBe("One");
|
||||
});
|
||||
|
||||
it("setSleep arms a duration timer that fires after the configured seconds", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
const audio = makeAudio();
|
||||
Object.defineProperty(audio, "paused", { value: false, writable: true });
|
||||
act(() => {
|
||||
(result.current.audioRef as React.MutableRefObject<HTMLAudioElement>).current = audio;
|
||||
});
|
||||
act(() => result.current.setSleep({ kind: "duration", seconds: 1 }));
|
||||
expect(result.current.sleep.remainingMs).toBeGreaterThan(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(audio.pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setSleep with off clears any armed timer", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
act(() => result.current.setSleep({ kind: "duration", seconds: 5 }));
|
||||
expect(result.current.sleep.remainingMs).toBeGreaterThan(0);
|
||||
act(() => result.current.setSleep({ kind: "off" }));
|
||||
expect(result.current.sleep.remainingMs).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback, type RefObject } from "react";
|
||||
import { buildDirectDownloadUrl } from "@/hooks/queries/downloads";
|
||||
import { useReportMediaProgress } from "@/hooks/queries/progress";
|
||||
import type { AudiobookFile } from "@/lib/audiobooks/types";
|
||||
import type { PlayerChapter } from "@/player/types";
|
||||
import type { SleepSetting } from "@/player/components/SleepTimerMenu";
|
||||
|
||||
const REPORT_INTERVAL_MS = 10_000;
|
||||
|
||||
export interface UseAudiobookPlaybackOptions {
|
||||
contentId: string;
|
||||
files: AudiobookFile[];
|
||||
initialPositionSeconds: number;
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
|
||||
export interface AudiobookPlayback {
|
||||
audioRef: RefObject<HTMLAudioElement | null>;
|
||||
streamUrl: string;
|
||||
hasFile: boolean;
|
||||
playing: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
buffered: TimeRanges | null;
|
||||
rate: number;
|
||||
chapters: PlayerChapter[];
|
||||
currentChapter: PlayerChapter | null;
|
||||
togglePlay: () => void;
|
||||
seekTo: (seconds: number) => void;
|
||||
skip: (delta: number) => void;
|
||||
setRate: (r: number) => void;
|
||||
sleep: { setting: SleepSetting; remainingMs: number | null };
|
||||
setSleep: (next: SleepSetting) => void;
|
||||
}
|
||||
|
||||
interface AudiobookPart {
|
||||
file: AudiobookFile;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function safeNumber(value: number): number {
|
||||
return Number.isFinite(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function buildParts(files: AudiobookFile[]): AudiobookPart[] {
|
||||
const parts: AudiobookPart[] = [];
|
||||
let offset = 0;
|
||||
for (const file of files) {
|
||||
const duration = safeNumber(file.duration_seconds ?? 0);
|
||||
parts.push({ file, start: offset, end: offset + duration });
|
||||
offset += duration;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function totalDuration(parts: AudiobookPart[]): number {
|
||||
return parts.reduce((max, part) => Math.max(max, part.end), 0);
|
||||
}
|
||||
|
||||
function clampedBookTime(seconds: number, duration: number): number {
|
||||
const value = safeNumber(seconds);
|
||||
if (duration <= 0) {
|
||||
return value;
|
||||
}
|
||||
return Math.max(0, Math.min(value, Math.max(0, duration - 1)));
|
||||
}
|
||||
|
||||
function findPartIndex(parts: AudiobookPart[], seconds: number): number {
|
||||
if (parts.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
const time = safeNumber(seconds);
|
||||
const index = parts.findIndex(
|
||||
(part) => time >= part.start && time < Math.max(part.end, part.start + 1),
|
||||
);
|
||||
if (index >= 0) {
|
||||
return index;
|
||||
}
|
||||
return time >= parts[parts.length - 1]!.end ? parts.length - 1 : 0;
|
||||
}
|
||||
|
||||
function localTimeForPart(part: AudiobookPart | undefined, absoluteSeconds: number): number {
|
||||
if (!part) {
|
||||
return 0;
|
||||
}
|
||||
const duration = safeNumber(part.file.duration_seconds ?? 0);
|
||||
const local = safeNumber(absoluteSeconds) - part.start;
|
||||
if (duration <= 0) {
|
||||
return Math.max(0, local);
|
||||
}
|
||||
return Math.max(0, Math.min(local, Math.max(0, duration - 1)));
|
||||
}
|
||||
|
||||
function absoluteBufferedRanges(
|
||||
ranges: TimeRanges,
|
||||
part: AudiobookPart | undefined,
|
||||
bookDuration: number,
|
||||
): TimeRanges {
|
||||
if (!part) {
|
||||
return { length: 0, start: () => 0, end: () => 0 } as TimeRanges;
|
||||
}
|
||||
const out: Array<{ start: number; end: number }> = [];
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const start = Math.min(bookDuration, part.start + safeNumber(ranges.start(i)));
|
||||
const end = Math.min(bookDuration, part.start + safeNumber(ranges.end(i)));
|
||||
if (end > start) {
|
||||
out.push({ start, end });
|
||||
}
|
||||
}
|
||||
return {
|
||||
length: out.length,
|
||||
start(index: number) {
|
||||
const range = out[index];
|
||||
if (!range) throw new Error("TimeRanges index out of bounds");
|
||||
return range.start;
|
||||
},
|
||||
end(index: number) {
|
||||
const range = out[index];
|
||||
if (!range) throw new Error("TimeRanges index out of bounds");
|
||||
return range.end;
|
||||
},
|
||||
} as TimeRanges;
|
||||
}
|
||||
|
||||
function buildPlayerChapters(files: AudiobookFile[]): PlayerChapter[] {
|
||||
const out: PlayerChapter[] = [];
|
||||
let offset = 0;
|
||||
let nextIndex = 0;
|
||||
for (const file of files) {
|
||||
for (const chapter of file.chapters ?? []) {
|
||||
const start = offset + chapter.start_seconds;
|
||||
const end = offset + (chapter.end_seconds || chapter.start_seconds);
|
||||
out.push({
|
||||
index: nextIndex++,
|
||||
title: chapter.title || `Chapter ${chapter.index + 1}`,
|
||||
start_seconds: start,
|
||||
end_seconds: end > start ? end : start + 1,
|
||||
source: chapter.source || "embedded",
|
||||
});
|
||||
}
|
||||
offset += file.duration_seconds ?? 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function useAudiobookPlayback({
|
||||
contentId,
|
||||
files,
|
||||
initialPositionSeconds,
|
||||
autoPlay = true,
|
||||
}: UseAudiobookPlaybackOptions): AudiobookPlayback {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const parts = useMemo(() => buildParts(files), [files]);
|
||||
const duration = useMemo(() => totalDuration(parts), [parts]);
|
||||
const chapters = useMemo(() => buildPlayerChapters(files), [files]);
|
||||
const [activeFileIndex, setActiveFileIndex] = useState(() =>
|
||||
findPartIndex(parts, initialPositionSeconds),
|
||||
);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
clampedBookTime(initialPositionSeconds, duration),
|
||||
);
|
||||
const [buffered, setBuffered] = useState<TimeRanges | null>(null);
|
||||
const [rate, setRateState] = useState(1);
|
||||
|
||||
const { mutate: reportProgress } = useReportMediaProgress();
|
||||
const activePart = activeFileIndex >= 0 ? parts[activeFileIndex] : undefined;
|
||||
const fileId = activePart?.file.id;
|
||||
const streamUrl = fileId ? buildDirectDownloadUrl(fileId) : "";
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
const reportRef = useRef<(pos: number) => void>(() => {});
|
||||
const pendingLocalSeekRef = useRef<number | null>(null);
|
||||
const playAfterSourceSwitchRef = useRef(false);
|
||||
const autoPlayPendingRef = useRef(autoPlay);
|
||||
|
||||
const setAbsoluteTime = useCallback(
|
||||
(seconds: number) => {
|
||||
const next = clampedBookTime(seconds, duration);
|
||||
currentTimeRef.current = next;
|
||||
setCurrentTime(next);
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reportRef.current = (posSeconds: number) => {
|
||||
reportProgress({
|
||||
contentId,
|
||||
positionSeconds: Math.floor(safeNumber(posSeconds)),
|
||||
durationSeconds: Math.floor(safeNumber(duration)),
|
||||
});
|
||||
};
|
||||
}, [contentId, duration, reportProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = clampedBookTime(initialPositionSeconds, duration);
|
||||
const index = findPartIndex(parts, target);
|
||||
pendingLocalSeekRef.current = localTimeForPart(parts[index], target);
|
||||
autoPlayPendingRef.current = autoPlay;
|
||||
setBuffered(null);
|
||||
setActiveFileIndex(index);
|
||||
currentTimeRef.current = target;
|
||||
setCurrentTime(target);
|
||||
}, [autoPlay, contentId, duration, initialPositionSeconds, parts]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !fileId || !activePart) return;
|
||||
|
||||
const absoluteFromAudio = () => activePart.start + safeNumber(audio.currentTime);
|
||||
|
||||
const onTimeUpdate = () => setAbsoluteTime(absoluteFromAudio());
|
||||
const onProgress = () =>
|
||||
setBuffered(absoluteBufferedRanges(audio.buffered, activePart, duration));
|
||||
const onDurationChange = () =>
|
||||
setBuffered(absoluteBufferedRanges(audio.buffered, activePart, duration));
|
||||
const onLoadedMetadata = () => {
|
||||
audio.playbackRate = rate;
|
||||
const pending = pendingLocalSeekRef.current;
|
||||
const local = pending ?? localTimeForPart(activePart, currentTimeRef.current);
|
||||
if (local > 0) {
|
||||
const max = Number.isFinite(audio.duration) ? Math.max(0, audio.duration - 1) : local;
|
||||
audio.currentTime = Math.min(local, max);
|
||||
}
|
||||
pendingLocalSeekRef.current = null;
|
||||
const shouldPlay = autoPlayPendingRef.current || playAfterSourceSwitchRef.current;
|
||||
autoPlayPendingRef.current = false;
|
||||
playAfterSourceSwitchRef.current = false;
|
||||
if (shouldPlay) {
|
||||
audio.play().catch((err) => {
|
||||
console.warn("audiobook autoplay blocked", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
const onPlay = () => setPlaying(true);
|
||||
const onPause = () => {
|
||||
setPlaying(false);
|
||||
reportRef.current(absoluteFromAudio());
|
||||
};
|
||||
const onSeeked = () => {
|
||||
const absolute = absoluteFromAudio();
|
||||
setAbsoluteTime(absolute);
|
||||
reportRef.current(absolute);
|
||||
};
|
||||
const onEnded = () => {
|
||||
const nextIndex = activeFileIndex + 1;
|
||||
if (nextIndex < parts.length) {
|
||||
const nextPart = parts[nextIndex];
|
||||
if (nextPart) {
|
||||
pendingLocalSeekRef.current = 0;
|
||||
playAfterSourceSwitchRef.current = true;
|
||||
setBuffered(null);
|
||||
setAbsoluteTime(nextPart.start);
|
||||
setActiveFileIndex(nextIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
currentTimeRef.current = duration;
|
||||
setCurrentTime(duration);
|
||||
setPlaying(false);
|
||||
reportRef.current(duration);
|
||||
};
|
||||
const onError = () => {
|
||||
const err = audio.error;
|
||||
console.error("audiobook audio error", {
|
||||
code: err?.code,
|
||||
message: err?.message,
|
||||
networkState: audio.networkState,
|
||||
readyState: audio.readyState,
|
||||
src: audio.currentSrc,
|
||||
});
|
||||
};
|
||||
|
||||
audio.addEventListener("timeupdate", onTimeUpdate);
|
||||
audio.addEventListener("progress", onProgress);
|
||||
audio.addEventListener("durationchange", onDurationChange);
|
||||
audio.addEventListener("loadedmetadata", onLoadedMetadata);
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
audio.addEventListener("seeked", onSeeked);
|
||||
audio.addEventListener("ended", onEnded);
|
||||
audio.addEventListener("error", onError);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", onTimeUpdate);
|
||||
audio.removeEventListener("progress", onProgress);
|
||||
audio.removeEventListener("durationchange", onDurationChange);
|
||||
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
audio.removeEventListener("seeked", onSeeked);
|
||||
audio.removeEventListener("ended", onEnded);
|
||||
audio.removeEventListener("error", onError);
|
||||
};
|
||||
}, [activeFileIndex, activePart, duration, fileId, parts, rate, setAbsoluteTime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const id = window.setInterval(() => {
|
||||
reportRef.current(currentTimeRef.current);
|
||||
}, REPORT_INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [playing]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
return () => {
|
||||
if (audio && !audio.paused) {
|
||||
audio.pause();
|
||||
reportRef.current(currentTimeRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (audio.paused) {
|
||||
audio.play().catch((err) => console.error("audiobook play failed", err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const seekTo = useCallback(
|
||||
(seconds: number) => {
|
||||
const target = clampedBookTime(seconds, duration);
|
||||
const nextIndex = findPartIndex(parts, target);
|
||||
const nextPart = parts[nextIndex];
|
||||
const audio = audioRef.current;
|
||||
const shouldContinuePlaying = audio ? !audio.paused : playing;
|
||||
const local = localTimeForPart(nextPart, target);
|
||||
|
||||
if (nextIndex !== activeFileIndex) {
|
||||
pendingLocalSeekRef.current = local;
|
||||
playAfterSourceSwitchRef.current = shouldContinuePlaying;
|
||||
setBuffered(null);
|
||||
setActiveFileIndex(nextIndex);
|
||||
} else if (audio) {
|
||||
audio.currentTime = local;
|
||||
}
|
||||
|
||||
currentTimeRef.current = target;
|
||||
setCurrentTime(target);
|
||||
reportRef.current(target);
|
||||
},
|
||||
[activeFileIndex, duration, parts, playing],
|
||||
);
|
||||
|
||||
const skip = useCallback(
|
||||
(delta: number) => {
|
||||
seekTo(currentTimeRef.current + delta);
|
||||
},
|
||||
[seekTo],
|
||||
);
|
||||
|
||||
const setRate = useCallback((nextRate: number) => {
|
||||
setRateState(nextRate);
|
||||
if (audioRef.current) audioRef.current.playbackRate = nextRate;
|
||||
}, []);
|
||||
|
||||
const currentChapter = useMemo(() => {
|
||||
if (chapters.length === 0) return null;
|
||||
for (let i = chapters.length - 1; i >= 0; i--) {
|
||||
const chapter = chapters[i];
|
||||
if (chapter && currentTime >= chapter.start_seconds) {
|
||||
return chapter;
|
||||
}
|
||||
}
|
||||
return chapters[0] ?? null;
|
||||
}, [chapters, currentTime]);
|
||||
|
||||
const [sleepSetting, setSleepSetting] = useState<SleepSetting>({ kind: "off" });
|
||||
const [sleepTargetMs, setSleepTargetMs] = useState<number | null>(null);
|
||||
const [sleepChapterEndSeconds, setSleepChapterEndSeconds] = useState<number | null>(null);
|
||||
const [sleepNowMs, setSleepNowMs] = useState<number>(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepSetting.kind !== "duration") {
|
||||
setSleepTargetMs(null);
|
||||
return;
|
||||
}
|
||||
setSleepChapterEndSeconds(null);
|
||||
setSleepTargetMs(Date.now() + sleepSetting.seconds * 1000);
|
||||
}, [sleepSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepSetting.kind !== "end-of-chapter") {
|
||||
setSleepChapterEndSeconds(null);
|
||||
return;
|
||||
}
|
||||
setSleepTargetMs(null);
|
||||
setSleepChapterEndSeconds((current) => {
|
||||
if (current != null && current > currentTime) return current;
|
||||
return currentChapter?.end_seconds ?? null;
|
||||
});
|
||||
}, [currentChapter, currentTime, sleepSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepTargetMs == null) return;
|
||||
const id = window.setInterval(() => setSleepNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [sleepTargetMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepTargetMs == null) return;
|
||||
if (sleepNowMs < sleepTargetMs) return;
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) audio.pause();
|
||||
setSleepSetting({ kind: "off" });
|
||||
setSleepTargetMs(null);
|
||||
}, [sleepNowMs, sleepTargetMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepSetting.kind !== "end-of-chapter" || sleepChapterEndSeconds == null) return;
|
||||
if (currentTime < sleepChapterEndSeconds) return;
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) audio.pause();
|
||||
setSleepSetting({ kind: "off" });
|
||||
setSleepChapterEndSeconds(null);
|
||||
}, [sleepSetting, sleepChapterEndSeconds, currentTime]);
|
||||
|
||||
const setSleep = useCallback((next: SleepSetting) => setSleepSetting(next), []);
|
||||
const sleepRemainingMs = sleepTargetMs == null ? null : Math.max(0, sleepTargetMs - sleepNowMs);
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
streamUrl,
|
||||
hasFile: Boolean(activePart),
|
||||
playing,
|
||||
currentTime,
|
||||
duration,
|
||||
buffered,
|
||||
rate,
|
||||
chapters,
|
||||
currentChapter,
|
||||
togglePlay,
|
||||
seekTo,
|
||||
skip,
|
||||
setRate,
|
||||
sleep: { setting: sleepSetting, remainingMs: sleepRemainingMs },
|
||||
setSleep,
|
||||
};
|
||||
}
|
||||
@@ -134,7 +134,10 @@ export function parseCatalogSearchParams(searchParams: URLSearchParams): Catalog
|
||||
|
||||
baseState.query_definition = normalizeQueryDefinition({
|
||||
library_ids: baseState.library_id ? [baseState.library_id] : [],
|
||||
media_scope: type === "movie" || type === "series" ? type : undefined,
|
||||
media_scope:
|
||||
type === "movie" || type === "series" || type === "episode" || type === "audiobook"
|
||||
? type
|
||||
: undefined,
|
||||
match: searchParams.get("match") === "any" ? "any" : "all",
|
||||
groups: [...implicitGroups, ...groups],
|
||||
sort: sort
|
||||
|
||||
@@ -134,6 +134,14 @@ describe("parseLibraryPageState", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves audiobook scope for mixed library filters", () => {
|
||||
const state = parseLibraryPageState(params("tab=library&type=audiobook&sort=author"), "mixed");
|
||||
|
||||
expect(state.activeTab).toBe("library");
|
||||
expect(state.queryDefinition.media_scope).toBe("audiobook");
|
||||
expect(state.queryDefinition.sort).toEqual({ field: "author", order: "asc" });
|
||||
});
|
||||
|
||||
it("accepts grouped query params and canonical sorts", () => {
|
||||
const state = parseLibraryPageState(
|
||||
params(
|
||||
@@ -177,6 +185,24 @@ describe("parseLibraryPageState", () => {
|
||||
expect(state.queryDefinition.sort).toEqual({ field: "title", order: "asc" });
|
||||
});
|
||||
|
||||
it("normalizes video-only sorts away on audiobook libraries", () => {
|
||||
const state = parseLibraryPageState(
|
||||
params("tab=library&sort=rating_imdb&order=desc"),
|
||||
"audiobooks",
|
||||
);
|
||||
expect(state.queryDefinition.media_scope).toBe("audiobook");
|
||||
expect(state.queryDefinition.sort).toEqual({ field: "title", order: "asc" });
|
||||
});
|
||||
|
||||
it("keeps audiobook-applicable sorts on audiobook libraries", () => {
|
||||
const state = parseLibraryPageState(
|
||||
params("tab=library&sort=runtime&order=desc"),
|
||||
"audiobooks",
|
||||
);
|
||||
expect(state.queryDefinition.media_scope).toBe("audiobook");
|
||||
expect(state.queryDefinition.sort).toEqual({ field: "runtime", order: "desc" });
|
||||
});
|
||||
|
||||
it("normalizes legacy sort aliases to canonical values", () => {
|
||||
expect(
|
||||
parseLibraryPageState(params("tab=library&sort=sort_title"), "mixed").queryDefinition.sort
|
||||
@@ -308,6 +334,31 @@ describe("updateLibraryPageSearchParams", () => {
|
||||
type: "episode",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not write a redundant type param for audiobook libraries", () => {
|
||||
const next = updateLibraryPageSearchParams(
|
||||
params("foo=bar"),
|
||||
{
|
||||
activeTab: "library",
|
||||
browseType: "series",
|
||||
queryDefinition: {
|
||||
library_ids: [],
|
||||
media_scope: "audiobook",
|
||||
match: "all",
|
||||
groups: [],
|
||||
sort: { field: "author", order: "asc" },
|
||||
},
|
||||
},
|
||||
"audiobooks",
|
||||
);
|
||||
|
||||
expect(asObject(next)).toEqual({
|
||||
foo: "bar",
|
||||
tab: "library",
|
||||
sort: "author",
|
||||
order: "asc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("library page saved state helpers", () => {
|
||||
|
||||
@@ -66,12 +66,26 @@ function getLibrarySortRelevanceScope(
|
||||
if (libraryType === "movie" || libraryType === "series") {
|
||||
return libraryType;
|
||||
}
|
||||
if (mediaScope === "movie" || mediaScope === "series" || mediaScope === "episode") {
|
||||
// The DB stores audiobook library type as the plural "audiobooks";
|
||||
// the sort scope is the singular "audiobook" (matches QueryDefinition.media_scope).
|
||||
if (libraryType === "audiobook" || libraryType === "audiobooks") {
|
||||
return "audiobook";
|
||||
}
|
||||
if (
|
||||
mediaScope === "movie" ||
|
||||
mediaScope === "series" ||
|
||||
mediaScope === "episode" ||
|
||||
mediaScope === "audiobook"
|
||||
) {
|
||||
return mediaScope;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function isAudiobookLibraryType(libraryType: string): boolean {
|
||||
return libraryType === "audiobook" || libraryType === "audiobooks";
|
||||
}
|
||||
|
||||
function readString(value: string | null): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
@@ -258,9 +272,14 @@ export function parseLibraryPageState(
|
||||
const mediaScopeParam = readString(searchParams.get("type"));
|
||||
const mediaScope =
|
||||
libraryType === "mixed" &&
|
||||
(mediaScopeParam === "movie" || mediaScopeParam === "series" || mediaScopeParam === "episode")
|
||||
(mediaScopeParam === "movie" ||
|
||||
mediaScopeParam === "series" ||
|
||||
mediaScopeParam === "episode" ||
|
||||
mediaScopeParam === "audiobook")
|
||||
? mediaScopeParam
|
||||
: undefined;
|
||||
: isAudiobookLibraryType(libraryType)
|
||||
? "audiobook"
|
||||
: undefined;
|
||||
const sortRelevanceScope =
|
||||
libraryType === "series" && browseType === "episode"
|
||||
? "all"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { CircleButton } from "./CircleButton";
|
||||
|
||||
describe("CircleButton", () => {
|
||||
it("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<CircleButton size="sm" variant="secondary" ariaLabel="Test" onClick={onClick}>
|
||||
x
|
||||
</CircleButton>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Test" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies the primary skin class for variant=primary", () => {
|
||||
render(
|
||||
<CircleButton size="md" variant="primary" ariaLabel="Play">
|
||||
▶
|
||||
</CircleButton>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Play" });
|
||||
expect(btn.className).toContain("player-disc-primary");
|
||||
});
|
||||
|
||||
it("sets data-paused when prop is true", () => {
|
||||
render(
|
||||
<CircleButton size="md" variant="primary" ariaLabel="Play" data-paused>
|
||||
▶
|
||||
</CircleButton>,
|
||||
);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("data-paused", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
export type CircleButtonProps = {
|
||||
size: "sm" | "md" | "lg";
|
||||
variant: "primary" | "secondary";
|
||||
ariaLabel: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
"data-paused"?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Glass-disc button used in both the video and audiobook player transports.
|
||||
* - `primary` = glossy white disc (play/pause).
|
||||
* - `secondary` = subtle glass disc (skip, prev/next).
|
||||
* Sizes: `sm` 40–44px in-bar secondaries, `md` 52–56px in-bar play,
|
||||
* `lg` 80px for floating variants (e.g. Now Listening play button).
|
||||
*/
|
||||
export function CircleButton({
|
||||
size,
|
||||
variant,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
"data-paused": dataPaused,
|
||||
}: CircleButtonProps) {
|
||||
const base =
|
||||
"flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/75 disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
const sizing =
|
||||
size === "lg"
|
||||
? "h-20 w-20"
|
||||
: size === "md"
|
||||
? "h-12 w-12 sm:h-14 sm:w-14"
|
||||
: "h-10 w-10 sm:h-11 sm:w-11";
|
||||
const skin = variant === "primary" ? "player-disc-primary" : "player-disc-secondary";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`${base} ${sizing} ${skin}`}
|
||||
data-paused={dataPaused ? "true" : undefined}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SkipForward,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
import { CircleButton } from "./CircleButton";
|
||||
import { SeekBar, formatTime } from "./SeekBar";
|
||||
import { VolumeControl } from "./VolumeControl";
|
||||
import { QualityMenu } from "./QualityMenu";
|
||||
@@ -402,52 +403,6 @@ export function PlayerControls({
|
||||
Internal building blocks
|
||||
───────────────────────────────────────────────────────────────────── */
|
||||
|
||||
type CircleButtonProps = {
|
||||
size: "sm" | "md" | "lg";
|
||||
variant: "primary" | "secondary";
|
||||
ariaLabel: string;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
"data-paused"?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pill-circle button used in the centered playback cluster.
|
||||
* - `primary` = glossy white disc (play/pause).
|
||||
* - `secondary` = subtle glass disc (skip, prev/next episode).
|
||||
* Sizes: `sm` 40–44px for in-bar secondaries, `md` 52–56px for in-bar
|
||||
* play button, `lg` 80px reserved for a future floating variant.
|
||||
*/
|
||||
function CircleButton({
|
||||
size,
|
||||
variant,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
children,
|
||||
"data-paused": dataPaused,
|
||||
}: CircleButtonProps) {
|
||||
const base =
|
||||
"flex items-center justify-center rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/75";
|
||||
const sizing =
|
||||
size === "lg"
|
||||
? "h-20 w-20"
|
||||
: size === "md"
|
||||
? "h-12 w-12 sm:h-14 sm:w-14"
|
||||
: "h-10 w-10 sm:h-11 sm:w-11";
|
||||
const skin = variant === "primary" ? "player-disc-primary" : "player-disc-secondary";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClick}
|
||||
className={`${base} ${sizing} ${skin}`}
|
||||
data-paused={dataPaused ? "true" : undefined}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Invisible placeholder that reserves the exact footprint of a CircleButton
|
||||
* so the playback cluster stays symmetric when a neighboring episode isn't
|
||||
* available (first/last episode in a series). */
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SleepTimerMenu, type SleepSetting } from "./SleepTimerMenu";
|
||||
|
||||
describe("SleepTimerMenu", () => {
|
||||
it("shows 'Sleep' when off and the countdown when armed", () => {
|
||||
const { rerender } = render(
|
||||
<SleepTimerMenu setting={{ kind: "off" }} remainingMs={null} onChange={() => {}} />,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /sleep timer/i })).toHaveTextContent("Sleep");
|
||||
rerender(
|
||||
<SleepTimerMenu
|
||||
setting={{ kind: "duration", seconds: 300 }}
|
||||
remainingMs={272_000}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /sleep timer/i })).toHaveTextContent("Sleep 4:32");
|
||||
});
|
||||
|
||||
it("emits a duration setting when a preset is chosen", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SleepTimerMenu setting={{ kind: "off" }} remainingMs={null} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /sleep timer/i }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "15 min" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ kind: "duration", seconds: 900 } as SleepSetting);
|
||||
});
|
||||
|
||||
it("emits end-of-chapter when that option is chosen", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SleepTimerMenu setting={{ kind: "off" }} remainingMs={null} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /sleep timer/i }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /end of chapter/i }));
|
||||
expect(onChange).toHaveBeenCalledWith({ kind: "end-of-chapter" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
export type SleepSetting =
|
||||
| { kind: "off" }
|
||||
| { kind: "duration"; seconds: number }
|
||||
| { kind: "end-of-chapter" };
|
||||
|
||||
interface SleepTimerMenuProps {
|
||||
setting: SleepSetting;
|
||||
remainingMs: number | null;
|
||||
onChange: (next: SleepSetting) => void;
|
||||
}
|
||||
|
||||
const PRESETS: { label: string; seconds: number }[] = [
|
||||
{ label: "5 min", seconds: 300 },
|
||||
{ label: "15 min", seconds: 900 },
|
||||
{ label: "30 min", seconds: 1800 },
|
||||
{ label: "45 min", seconds: 2700 },
|
||||
{ label: "60 min", seconds: 3600 },
|
||||
];
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const total = Math.max(0, Math.ceil(ms / 1000));
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SleepTimerMenu({ setting, remainingMs, onChange }: SleepTimerMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const itemsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const armed = setting.kind !== "off";
|
||||
|
||||
const handleBlur = useCallback((e: React.FocusEvent) => {
|
||||
if (!menuRef.current?.contains(e.relatedTarget as Node)) setOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
const label = armed && remainingMs != null ? `Sleep ${formatCountdown(remainingMs)}` : "Sleep";
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative" onBlur={handleBlur}>
|
||||
<button
|
||||
type="button"
|
||||
className="player-utility-btn flex items-center gap-1.5 px-2 text-xs"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Sleep timer"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 bottom-full mb-2 flex min-w-[160px] flex-col overflow-hidden rounded-lg bg-black/90 py-1.5 shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
{armed && (
|
||||
<button
|
||||
ref={(el) => {
|
||||
itemsRef.current[0] = el;
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm text-white/85 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
onChange({ kind: "off" });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Turn off
|
||||
</button>
|
||||
)}
|
||||
{PRESETS.map((p, i) => (
|
||||
<button
|
||||
key={p.seconds}
|
||||
ref={(el) => {
|
||||
itemsRef.current[(armed ? 1 : 0) + i] = el;
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm text-white/85 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
onChange({ kind: "duration", seconds: p.seconds });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
role="menuitem"
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm text-white/85 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
onChange({ kind: "end-of-chapter" });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
End of chapter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SpeedMenu } from "./SpeedMenu";
|
||||
|
||||
const RATES = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
describe("SpeedMenu", () => {
|
||||
it("shows the current rate label on the trigger", () => {
|
||||
render(<SpeedMenu rates={RATES} value={1.5} onChange={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /playback speed/i })).toHaveTextContent("1.5×");
|
||||
});
|
||||
|
||||
it("opens, lists all rates, and emits the chosen value", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SpeedMenu rates={RATES} value={1} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /playback speed/i }));
|
||||
expect(screen.getAllByRole("menuitem")).toHaveLength(RATES.length);
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "1.25×" }));
|
||||
expect(onChange).toHaveBeenCalledWith(1.25);
|
||||
});
|
||||
|
||||
it("marks the current rate as active", async () => {
|
||||
render(<SpeedMenu rates={RATES} value={1.5} onChange={() => {}} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /playback speed/i }));
|
||||
const active = screen.getByRole("menuitem", { name: "1.5×" });
|
||||
expect(active).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SpeedMenuProps {
|
||||
rates: readonly number[];
|
||||
value: number;
|
||||
onChange: (rate: number) => void;
|
||||
}
|
||||
|
||||
export function SpeedMenu({ rates, value, onChange }: SpeedMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const handleBlur = useCallback((e: React.FocusEvent) => {
|
||||
if (!menuRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open]);
|
||||
|
||||
const handleMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
const items = menuItemsRef.current.filter(Boolean) as HTMLButtonElement[];
|
||||
if (items.length === 0) return;
|
||||
const i = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
let next: number | null = null;
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
next = i < items.length - 1 ? i + 1 : 0;
|
||||
break;
|
||||
case "ArrowUp":
|
||||
next = i > 0 ? i - 1 : items.length - 1;
|
||||
break;
|
||||
case "Home":
|
||||
next = 0;
|
||||
break;
|
||||
case "End":
|
||||
next = items.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
items[next]?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative" onBlur={handleBlur}>
|
||||
<button
|
||||
type="button"
|
||||
className="player-utility-btn px-2 text-xs tabular-nums"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Playback speed"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
{value}×
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 bottom-full mb-2 flex min-w-[100px] flex-col overflow-hidden rounded-lg bg-black/90 py-1.5 shadow-xl backdrop-blur-sm"
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
{rates.map((r, idx) => (
|
||||
<button
|
||||
key={r}
|
||||
ref={(el) => {
|
||||
menuItemsRef.current[idx] = el;
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
data-active={r === value ? "true" : undefined}
|
||||
className={`w-full px-4 py-2 text-right text-sm tabular-nums transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
|
||||
r === value ? "bg-white/5 text-white" : "text-white/75"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onChange(r);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{r}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user