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 as d59c1cb (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 as eb8f67d (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:
Quick
2026-06-07 15:57:05 -04:00
committed by GitHub
co-authored by RXWatcher Claude Opus 4.7
parent c469550fa0
commit eb6024573e
253 changed files with 50703 additions and 446 deletions
+59 -6
View File
@@ -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&apos;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>
);
}
+4
View File
@@ -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]" />;
}
+12 -3
View File
@@ -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
+3 -1
View File
@@ -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
? {
+10 -1
View File
@@ -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) {
+3 -1
View File
@@ -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>
);
}
+30
View File
@@ -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> = {},
+44
View File
@@ -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({
+37 -1
View File
@@ -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 });
},
});
}
+17
View File
@@ -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;
+49 -10
View File
@@ -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>
);
}
+48 -15
View File
@@ -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) ──────────────── */}
+2 -1
View File
@@ -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);
});
});
+8 -1
View File
@@ -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">
+24
View File
@@ -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");
});
});
+22 -4
View File
@@ -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");
});
});
@@ -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();
});
});
+157
View File
@@ -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,
};
}
+4 -1
View File
@@ -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", () => {
+22 -3
View File
@@ -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>
);
}
+1 -46
View File
@@ -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");
});
});
+95
View File
@@ -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>
);
}