docs(audiobooks): UI redesign spec — player two-mode + detail page
Bring the audiobook detail page and player to parity with the video player, translated for audio: shared glass-disc primitives, a polished mini bar with cover-tile expand affordance, and a Now Listening full-overlay mode. Restructure the detail page so progress, current chapter, narrator, and cross-book context are foregrounded. Defers bookmarks, car mode, and cross-book rails to v1.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
454be6da74
commit
024c51f616
@@ -0,0 +1,439 @@
|
||||
# Audiobook UI Redesign — Design Spec
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Branch context:** `feat/audiobooks` in `silo-server`
|
||||
**Status:** Approved — ready for implementation plan
|
||||
**Related spec:** [`2026-05-24-audiobooks-absorption-design.md`](./2026-05-24-audiobooks-absorption-design.md)
|
||||
|
||||
## Goal
|
||||
|
||||
Bring the audiobook detail page and audiobook player to visual and
|
||||
interaction parity with Silo's existing video player, translated for
|
||||
audio. Today the audiobook surfaces work but feel generic, hide useful
|
||||
information, and re-implement primitives the video player already has
|
||||
in a polished form.
|
||||
|
||||
User-visible outcome:
|
||||
|
||||
- The audiobook player has the same two-mode shape as the video player
|
||||
(compact HUD + immersive full-screen mode), so listening can be
|
||||
background or foreground depending on intent.
|
||||
- The detail page foregrounds the information audiobook listeners care
|
||||
about — current chapter, narrator, listened progress — instead of
|
||||
burying it.
|
||||
- The audiobook-only affordances that don't exist today (sleep timer,
|
||||
speed menu, bookmarks, narrator emphasis) have a home.
|
||||
|
||||
## Hard constraints
|
||||
|
||||
- **Reuse video-player primitives.** `SeekBar`, `ChaptersMenu`, and the
|
||||
glass-disc button visual treatment all live in `web/src/player/` and
|
||||
are already wired up correctly. The audiobook player must consume
|
||||
them, not re-implement them.
|
||||
- **One audio element.** Switching between mini and Now Listening must
|
||||
not remount the audio element or restart the stream. Both modes are
|
||||
chrome layered over the same playback state.
|
||||
- **No new top-level routes.** The Now Listening view is an overlay on
|
||||
whatever route the user is on, not a `/audiobooks/listen` page. This
|
||||
mirrors how fullscreen video works — fullscreen is a mode, not a
|
||||
destination.
|
||||
- **Player state must be liftable.** The split between
|
||||
`useAudiobookPlayback` (state hook) and `MiniBar` / `NowListening`
|
||||
(chrome) must allow a future v1.1 change to lift the hook into an
|
||||
app-level provider so the mini bar can survive page navigation.
|
||||
v1 itself ships with the player tied to the detail route, same as
|
||||
today.
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- New mini-bar layout (cover tile, chapter title, glass-disc transport,
|
||||
utility rail with sleep / chapters / speed / expand / close).
|
||||
- New Now Listening full-overlay mode (large cover, chapter heading,
|
||||
tall seek bar, sleep / chapters / speed / bookmark / car-mode row,
|
||||
remaining-time toggle, overflow menu).
|
||||
- Restructured audiobook detail page (progress + chapter-aware Resume
|
||||
action, chapters expanded by default with currently-playing
|
||||
highlight, narrator card, "Also by author", "In this series").
|
||||
- Extraction of `CircleButton` and a new `SpeedMenu` into
|
||||
`web/src/player/components/` as shared primitives between video and
|
||||
audiobook players.
|
||||
- Split of today's monolithic `AudiobookPlayer.tsx` into a state hook
|
||||
(`useAudiobookPlayback`) plus two chrome components (`MiniBar`,
|
||||
`NowListening`) under `web/src/pages/audiobooks/player/`.
|
||||
- Sleep timer (client-side only; pauses + short fade-out when the
|
||||
timer fires).
|
||||
|
||||
### Out (deferred to v1.1)
|
||||
|
||||
- **Bookmarks.** UI affordances (mini-bar button, Now Listening
|
||||
utility-row button, "Bookmarks (n)" detail-page action) ship
|
||||
**hidden** in v1. Full feature needs `audiobook_bookmarks` table +
|
||||
CRUD endpoints, which is its own sub-spec.
|
||||
- **"Also by narrator" / cross-book "In this series" rails.** Render
|
||||
only when the backend already exposes the data; otherwise hide.
|
||||
Server-side joins to power them are a follow-up.
|
||||
- **Car mode.** Hidden in v1; v1.1 will add a huge-buttons layout
|
||||
variant reached from the utility row.
|
||||
- **Audiobook library page redesign.** Out of scope; current grid
|
||||
stays.
|
||||
- **Persistent player across page navigation as a lifted React
|
||||
context.** The current detail page owns the player state; lifting it
|
||||
to an app-level provider so the bar survives navigating away from
|
||||
the detail page is a follow-up. v1 ships with the bar tied to the
|
||||
detail route, same as today, but visually and structurally ready
|
||||
for that lift.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three connected surfaces
|
||||
|
||||
```
|
||||
detail page ──opens──► mini bar ──expand──► Now Listening
|
||||
▲ │
|
||||
└─────── collapse ──────┘
|
||||
```
|
||||
|
||||
- **Detail page** (`/audiobooks/book/:id`) is the landing surface.
|
||||
Stays mounted while the player is open.
|
||||
- **Mini bar** is a fixed bottom strip (current behavior) that appears
|
||||
once the user clicks Play/Resume.
|
||||
- **Now Listening** is a full-viewport overlay (z-index above the mini
|
||||
bar, below modals) reached by clicking the cover-art tile or the
|
||||
expand chevron in the mini bar. Dismissed via a collapse chevron.
|
||||
|
||||
The mini ↔ Now Listening transition uses a CSS view transition keyed
|
||||
on the cover art element so the small tile appears to grow into the
|
||||
big cover. The same primitive already used by `ViewTransitionLink`.
|
||||
|
||||
### Shared player primitives
|
||||
|
||||
`web/src/player/components/` already houses the visual building blocks
|
||||
used by the video player. Two of them get extracted so both players
|
||||
consume the same source of truth:
|
||||
|
||||
| File | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `SeekBar.tsx` | reused as-is | already shared |
|
||||
| `ChaptersMenu.tsx` | reused as-is | already shared |
|
||||
| `CircleButton.tsx` | **new** — extracted from `PlayerControls.tsx` | the glass-disc primary/secondary button used in both transport clusters |
|
||||
| `SpeedMenu.tsx` | **new** | popover speed menu matching the styling of `ChaptersMenu`; replaces the raw `<select>` today; video player can adopt later |
|
||||
| `SleepTimerMenu.tsx` | **new** | popover with off / 5 / 15 / 30 / 45 / 60 / end-of-chapter; audiobook-only but lives here for symmetry |
|
||||
|
||||
Extracting `CircleButton` removes a divergence rather than adding a
|
||||
layer — it's the kind of focused improvement that earns its keep
|
||||
because today the video player and the audiobook player each render
|
||||
their own visually-similar-but-not-identical disc buttons.
|
||||
|
||||
### Audiobook player component tree
|
||||
|
||||
```
|
||||
web/src/pages/audiobooks/
|
||||
AudiobookDetail.tsx (restructured per "Detail page" below)
|
||||
AudiobookLibrary.tsx (unchanged)
|
||||
player/ (new folder)
|
||||
AudiobookPlayer.tsx (top level: owns audio element and state)
|
||||
MiniBar.tsx (compact chrome)
|
||||
NowListening.tsx (overlay chrome)
|
||||
CoverExpandTile.tsx (left-edge tile inside MiniBar — also the view-transition anchor)
|
||||
useAudiobookPlayback.ts (state hook: audio events, progress reporting, sleep timer)
|
||||
```
|
||||
|
||||
`AudiobookPlayer` becomes a thin shell:
|
||||
|
||||
```tsx
|
||||
function AudiobookPlayer(props) {
|
||||
const playback = useAudiobookPlayback(props);
|
||||
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
|
||||
return (
|
||||
<>
|
||||
<audio ref={playback.audioRef} src={playback.streamUrl} preload="metadata" hidden />
|
||||
{mode === "mini"
|
||||
? <MiniBar playback={playback} onExpand={() => setMode("now-listening")} onClose={props.onClose} />
|
||||
: <NowListening playback={playback} onCollapse={() => setMode("mini")} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The audio element lives in the parent so a mode swap never touches it.
|
||||
|
||||
### useAudiobookPlayback responsibilities
|
||||
|
||||
Lifts today's monolithic `AudiobookPlayer.tsx` into a single hook
|
||||
returning a stable shape:
|
||||
|
||||
```ts
|
||||
{
|
||||
audioRef, // ref<HTMLAudioElement>
|
||||
streamUrl, // string
|
||||
playing, currentTime, duration, buffered, rate,
|
||||
chapters, // PlayerChapter[] (flattened across files)
|
||||
currentChapter, // PlayerChapter | null
|
||||
sleep, // { remainingMs: number | null, end: SleepEndCondition | null }
|
||||
togglePlay, seekTo, skip,
|
||||
setRate,
|
||||
setSleep, // arms the timer
|
||||
// bookmarks (v1.1 — initially returns []/no-op stubs)
|
||||
bookmarks, addBookmark, removeBookmark,
|
||||
}
|
||||
```
|
||||
|
||||
The hook owns: audio event wiring, periodic progress reporting (the
|
||||
existing 10s `useReportAudiobookProgress` cadence), sleep-timer state
|
||||
+ fade-out, computing `currentChapter` from `currentTime`, and the
|
||||
unmount-time pause + final report. The chrome components stay pure
|
||||
presentational.
|
||||
|
||||
## Mini bar
|
||||
|
||||
Layout (one seek-bar row above a controls row):
|
||||
|
||||
```
|
||||
[ full-width SeekBar with chapter ticks ]
|
||||
[ cover tile | title + chapter + time | ◀30 ▶❚❚ 30▶ | ⌛ ☰ 1× ⌃ ✕ ]
|
||||
↑ left col middle center right rail
|
||||
36×54px truncating shared CircleButton cluster
|
||||
sleep, chapters, speed,
|
||||
expand chevron, close
|
||||
(bookmark hidden in v1)
|
||||
```
|
||||
|
||||
Differences from today's implementation:
|
||||
|
||||
- **CoverExpandTile** added at the left edge. 36×54 (portrait
|
||||
aspect-2:3). On hover: subtle scale + a small "expand" chevron
|
||||
overlay. Clicking it triggers the view transition to Now Listening.
|
||||
- **Left text column** shows title (line 1) and current chapter title
|
||||
(line 2). Time row stays where it is. Today the chapter title isn't
|
||||
shown anywhere while playing — this is the single most useful
|
||||
"where am I" cue and it's missing.
|
||||
- **Center cluster** swaps the bespoke `CircleButton` defined inline
|
||||
in the current `AudiobookPlayer` for the extracted shared one. Same
|
||||
three controls (back 30 / play-pause / forward 30).
|
||||
- **Right rail** replaces the raw `<select>` for speed with
|
||||
`SpeedMenu` (popover, same styling as `ChaptersMenu`). Adds
|
||||
`SleepTimerMenu`, an expand-up chevron (`ChevronUp`), and the
|
||||
existing close `X`. The bookmark button is **hidden in v1** (mini
|
||||
bar has no bookmark control until v1.1 lands).
|
||||
|
||||
Mini bar height is unchanged from today.
|
||||
|
||||
## Now Listening overlay
|
||||
|
||||
Full-viewport overlay, dark surface (`bg-background`). Z-index above
|
||||
the mini bar, below modals.
|
||||
|
||||
Layout on desktop (width ≥ md):
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ⌄ (collapse, top-left) ⋯ More (top-right) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ │ PROJECT HAIL MARY │
|
||||
│ │ [ COVER ] │ Andy Weir │
|
||||
│ │ ~360 × 540 │ Narrated by Ray Porter │
|
||||
│ │ │ │
|
||||
│ │ │ ── CHAPTER 7 ── │
|
||||
│ │ │ The Astrophage │
|
||||
│ └──────────────────────┘ │
|
||||
│ │
|
||||
│ [ tall SeekBar with chapter ticks ] │
|
||||
│ 12:43 -4:08:35 │
|
||||
│ │
|
||||
│ ◀30 ▶❚❚ (large) 30▶ │
|
||||
│ │
|
||||
│ ⌛ Sleep ☰ Chapters (24) 1× Speed │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Layout on mobile (portrait): same elements stacked top-to-bottom —
|
||||
header row, cover (centered, smaller), metadata block, seek bar,
|
||||
transport, utility row. No horizontal split.
|
||||
|
||||
Key behaviors:
|
||||
|
||||
- **Tap right time** toggles between `-remaining` and the wall-clock
|
||||
end time. The toggle preference is in-memory only (resets per
|
||||
session) — no persistence concern.
|
||||
- **Sleep timer** opens `SleepTimerMenu`. When armed, the trigger
|
||||
shows the countdown (`Sleep 04:32`). When the timer hits zero the
|
||||
player fades volume to 0 over 5s and pauses.
|
||||
- **Car mode** and **bookmark** buttons are **hidden in v1** — the
|
||||
utility row ships with sleep / chapters / speed only. Car mode +
|
||||
bookmark slots are documented here so v1.1 has a clear home for
|
||||
them.
|
||||
- **Overflow ⋯** menu items for v1: "Go to detail page", "Report a
|
||||
sync problem" (opens a mailto/issue link). Future additions live
|
||||
here.
|
||||
|
||||
The same `<audio>` element backs both modes, so all controls in Now
|
||||
Listening are bound to the same `useAudiobookPlayback` state.
|
||||
|
||||
## Detail page restructure
|
||||
|
||||
The page keeps its current top-level shape (hero band + content
|
||||
below) and adds/rearranges these blocks:
|
||||
|
||||
### Hero band
|
||||
|
||||
Today: `DetailHero` with a stack of `Play` / `Play from Start`
|
||||
actions and a small progress bar that appears below the buttons only
|
||||
when progress exists.
|
||||
|
||||
New version (still using `DetailHero` but with a richer `actions`
|
||||
slot):
|
||||
|
||||
```
|
||||
[ progress bar — full width of actions column, always shown if any progress ]
|
||||
[ "3h 12m listened · 19%" caption ]
|
||||
|
||||
▶ Resume Ch 7 · The Astrophage ↻ Start Over 🔖 Bookmarks (3)
|
||||
```
|
||||
|
||||
- Progress bar moves up *above* the buttons so the listener sees
|
||||
position context first.
|
||||
- Resume button label includes the chapter the listener will land
|
||||
in. Pulled from the same `buildChapterList` already in the file.
|
||||
- "Bookmarks (n)" button visible only when n > 0; opens a side sheet
|
||||
listing them. v1 ships with an empty stub (always 0, button never
|
||||
shown) — full feature in v1.1.
|
||||
|
||||
### Chapters section
|
||||
|
||||
Today: collapsed-by-default disclosure showing a chapter list.
|
||||
|
||||
New version:
|
||||
|
||||
- Expanded by default. (Collapsed in this position made sense when
|
||||
the page was sparse; once we're foregrounding chapter awareness
|
||||
everywhere else, hiding the list here is contradictory.)
|
||||
- Currently-playing chapter row gets a left-edge accent and a
|
||||
"▶ playing" badge on the right.
|
||||
- Lightweight sort menu in the section header: "By position" (default),
|
||||
"Longest first" — no UI for "By title" since chapter titles rarely
|
||||
sort meaningfully. Sort preference is in-memory only for v1.
|
||||
- Clicking a chapter row still calls `openPlayer(absoluteStart)` as
|
||||
today; if the player is open, it seeks rather than reopens.
|
||||
|
||||
### Narrator card
|
||||
|
||||
New section between Chapters and the cross-rails. Renders only when
|
||||
`data.narrator` is non-empty.
|
||||
|
||||
```
|
||||
── NARRATOR ──
|
||||
[ avatar 64×64 ] Ray Porter
|
||||
47 audiobooks in your library →
|
||||
```
|
||||
|
||||
Avatar source: if the absorbed audiobook data model exposes a
|
||||
narrator photo, use it; otherwise an initials avatar in a circle.
|
||||
The `→` link goes to a narrator detail page if one exists, or a
|
||||
search-results page filtered by narrator name as a fallback.
|
||||
|
||||
### Cross-book rails
|
||||
|
||||
New sections, each rendered only when its data is present:
|
||||
|
||||
- "Also by {author}" — horizontal scroller of cover tiles, same
|
||||
pattern as `MediaRow` used elsewhere in Silo.
|
||||
- "In this series" — same pattern, with "Book n of m" subtitle and
|
||||
the current book non-clickable / highlighted.
|
||||
|
||||
Both rails ship behind feature-detection: if the API response for the
|
||||
book includes the related arrays, render them; if not, hide the
|
||||
section without showing a placeholder. This avoids blocking v1 on
|
||||
backend rail support.
|
||||
|
||||
### What's *not* in the new detail page
|
||||
|
||||
- Reviews / ratings — out of scope, not in the data model.
|
||||
- Tabs — page is short enough that one scroll surface beats tabs.
|
||||
- Social / cohost listening — no signal it's wanted.
|
||||
|
||||
## Data & API impact
|
||||
|
||||
Most of the work is pure frontend, but three small backend-adjacent
|
||||
items to call out:
|
||||
|
||||
| Item | Impact |
|
||||
|---|---|
|
||||
| Sleep timer | Frontend-only. No API change. |
|
||||
| Speed menu | Frontend-only — already a client-side audio setting. |
|
||||
| Bookmarks | **Deferred to v1.1.** Needs `audiobook_bookmarks` table (`content_id`, `profile_id`, `position_seconds`, `note`, `created_at`) plus CRUD endpoints. v1 ships with the button hidden and `useAudiobookPlayback` exposing no-op bookmark stubs. |
|
||||
| Narrator card with "n audiobooks in library" count | Needs narrator-aware query. If the detail endpoint already returns it, render; otherwise hide the count line and show only the name. |
|
||||
| "Also by author" / "In this series" rails | Render only when arrays present. v1 doesn't require backend changes — it just adapts to whatever the response contains. |
|
||||
|
||||
## Routing & view-transition wiring
|
||||
|
||||
- `/audiobooks/book/:contentId` continues to be the only audiobook
|
||||
route added by this work. Mini bar and Now Listening are layered
|
||||
inside this route's component tree.
|
||||
- View transition name: `audiobook-cover-{contentId}`. Set on the
|
||||
cover tile inside `CoverExpandTile` and on the large cover element
|
||||
inside `NowListening`. Same `contentId` on both = the browser
|
||||
animates the rect change automatically.
|
||||
- The detail page's hero cover and the mini bar's cover tile share
|
||||
the same view-transition name so the *initial* open (clicking
|
||||
Play/Resume on the hero) also animates the cover into the mini bar
|
||||
position.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All transport controls have `aria-label`s (already true today; keep
|
||||
parity).
|
||||
- The expand chevron and the cover tile are both labeled "Expand
|
||||
player" / "Open Now Listening" — two ways in, both discoverable to
|
||||
screen readers.
|
||||
- `SleepTimerMenu`, `SpeedMenu`, `ChaptersMenu` use the existing
|
||||
`Popover` primitive with proper roving focus + Escape-to-close
|
||||
(inherits from `ChaptersMenu` behavior).
|
||||
- Now Listening overlay traps focus while open and restores focus to
|
||||
the mini bar's expand chevron on close.
|
||||
- Color contrast for the chapter-title text in the mini bar must
|
||||
meet WCAG AA against the bar's background (use `text-foreground`
|
||||
with a `text-muted-foreground` chapter title is the existing
|
||||
pattern and passes).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** `useAudiobookPlayback` — chapter computation from current
|
||||
time across multi-file audiobooks, sleep-timer arm/disarm/fire,
|
||||
progress reporting cadence and pause/seek/end triggers.
|
||||
- **Component:** `MiniBar` and `NowListening` render expected
|
||||
elements given a mocked playback object; expand/collapse calls the
|
||||
right callbacks; speed menu / sleep menu open and emit the right
|
||||
values.
|
||||
- **Integration / Playwright** (if the repo has Playwright wired for
|
||||
these flows): start playback from detail page → mini bar appears
|
||||
with chapter title → click cover tile → Now Listening overlay
|
||||
appears → seek bar works in both modes → collapse returns to mini
|
||||
bar with playback uninterrupted (key assertion: `audio.currentTime`
|
||||
monotonically advances across the mode swap).
|
||||
|
||||
## Out of scope (recap)
|
||||
|
||||
- Bookmarks backend (v1.1 sub-spec)
|
||||
- Cross-book rails backend joins
|
||||
- Car mode full layout
|
||||
- Audiobook library grid redesign
|
||||
- Lifting the player to an app-level provider so it survives
|
||||
navigation away from the detail page
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Multi-file audiobooks.** Today's player only plays `files[0]` —
|
||||
multi-file audiobooks display all chapters but can't actually cross
|
||||
file boundaries. This redesign doesn't fix that; it inherits the
|
||||
limitation. Worth flagging in the implementation plan whether to
|
||||
fold a fix into v1 or leave it for a separate ticket.
|
||||
- **View transition browser support.** CSS view transitions are
|
||||
supported in modern Chromium and Safari Tech Preview; Firefox is
|
||||
not there yet. The mini ↔ Now Listening swap must work without
|
||||
the transition (graceful fallback to a plain unmount/mount).
|
||||
- **Sleep-timer fade.** A 5s gain ramp on `HTMLAudioElement.volume`
|
||||
works but is not as smooth as a Web Audio API gain node. Starting
|
||||
simple; revisit if it feels janky.
|
||||
Reference in New Issue
Block a user