Brings the web audiobook player to parity with best-in-class audiobook apps (Audiobookshelf, Audible, BookPlayer) on the quality-of-life axis, per docs/superpowers/plans/audiobook-player-qol.md: - Keyboard shortcuts active on every route while the player is mounted (space/K, arrow skips, volume, M, N/P chapters, shift+./, speed, E expand, Esc collapse), inert while a video session exists. - Configurable skip intervals with asymmetric defaults (back 10s, forward 30s) via a new player settings popover. - Speed control expanded to 0.5x-3x with a 0.05 stepper, presets, and per-book memory (LRU-capped, device-local). - Chapter prev/next transport buttons (prev restarts the chapter when more than 3s in, the universal player convention). - Volume/mute via the shared VolumeControl with a new theme-token "surface" tone; persistence shared with the video player. - Smart rewind: backs up 3-30s on resume scaled by pause length, plus a flat 10s on cold resume from the detail page; explicit seeks are never second-guessed. Toggleable in player settings. - Now Listening time label cycles total / remaining / remaining at current speed. Chapter flattening is deduplicated into lib/audiobooks/chapters.ts, the SkipIcon is shared between both player views, and new pure modules (smartRewind, chapter nav, prefs) come with unit tests. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
13 KiB
Audiobook Player QoL Improvements
Commands assume the repository root is the cwd. All paths are repository-relative.
Goal
Bring the web audiobook player to feature parity with best-in-class audiobook apps (Audiobookshelf, Audible, BookPlayer, Prologue) on the quality-of-life axis. The current surface architecture (persistent mini bar + expandable Now Listening view, playback survives navigation) is already the de-facto standard and stays as-is.
Explicitly out of scope: Media Session / lock-screen integration, bookmarks (needs a server endpoint — follow-up), offline/download, queue.
Features
- Keyboard shortcuts — space/K play-pause, arrow skips, volume, chapter nav, speed step.
- Configurable skip intervals — asymmetric defaults (back 10s, forward 30s), user-tunable.
- Expanded speed control — 0.5×–3.0× with fine stepping, presets, and per-book memory.
- Chapter prev/next buttons — alongside the seconds-skip buttons.
- Volume control — reuse the shared
VolumeControl, persisted like the video player. - Smart rewind — auto-rewind on resume, scaled by how long playback was paused.
- Time remaining at current speed — real-clock remaining display in Now Listening.
No server/API changes are required; everything is web-frontend only, so no Android/Apple client coordination is needed. (Per-book speed memory is device-local in v1; syncing it server-side via profile settings is a noted follow-up.)
Existing seams (read these first)
| File | Relevance |
|---|---|
web/src/pages/audiobooks/player/useAudiobookPlayback.ts |
Playback engine. togglePlay (~line 499), skip (~534), setRate (~541), returned API (~690+). All engine work lands here. |
web/src/pages/audiobooks/player/MiniBar.tsx |
Bottom bar. Hardcoded SKIP_*_SECONDS = 30 and PLAYBACK_RATES at lines 10–12. |
web/src/pages/audiobooks/player/NowListening.tsx |
Full-screen view. Same hardcoded constants; dead "More" button (lines 50–56) becomes the settings menu trigger; remaining-time toggle at lines 33–37. |
web/src/pages/audiobooks/player/AudiobookPlayer.tsx |
Wrapper that owns mini/now-listening mode — mount point for the keyboard shortcuts hook. |
web/src/player/components/VolumeControl.tsx |
Existing slider + getPersistedVolume/persistVolume helpers (shared player-volume/player-muted storage keys). Styled for dark video overlay — needs a surface-toned variant. |
web/src/player/components/SpeedMenu.tsx |
Current preset-only menu (shared with video player). Audiobook gets a richer SpeedControl; video keeps SpeedMenu untouched. |
web/src/player/hooks/useKeyboardShortcuts.ts |
Video player's shortcut hook — pattern to mirror (input-field guard, single document listener). |
web/src/utils/storage.ts |
Typed localStorage wrapper — add new keys here, never call localStorage directly. |
web/src/player/components/SeekBar.tsx, CircleButton.tsx, SleepTimerMenu.tsx, ChaptersMenu.tsx |
Established player design language; reuse, don't fork. |
Design language
These are additions to an existing, refined system — extend it, don't restyle it:
- Surfaces: mini bar is a themed surface (
bg-background,border-t, token colors). Popovers anchored to it use the established dark-glass treatment (bg-black/90 backdrop-blur-sm rounded-lg shadow-xl, white/75 text,data-activerows) exactly asSpeedMenu/SleepTimerMenudo today. - Type: time and rate values are always
font-mono/tabular-numsso nothing shifts as digits tick. The new speed readout (1.45×) must reserve width for two decimals. - Buttons: all transport controls are
CircleButton(smsecondary flanks,md/lgprimary center). Chapter prev/next useSkipBack/SkipForwardlucide glyphs at the same stroke weight (1.6) as the rotate icons, so the cluster reads as one family. - Micro-interactions, small and purposeful:
- Skip buttons: a quick ~18° icon flick in the skip direction on press (CSS transform transition, ~150ms ease-out) — confirms the action without a toast.
- Speed stepper: value crossfades (opacity 120ms) on change; the active preset chip gets
the same
bg-white/5 text-whitetreatment as current menu rows. - Smart rewind: when it fires, the seek bar playhead animates the small jump back rather than teleporting (SeekBar already animates position; verify it holds for ≤30s deltas).
- Accessibility: every new control keeps the existing patterns —
aria-label,aria-expanded/aria-haspopupon triggers, roving focus + Escape in menus (copy fromSleepTimerMenu),role="slider"semantics fromVolumeControl/SeekBar.
Phase 1 — Engine + preferences model
1a. Preferences hook
New web/src/pages/audiobooks/player/useAudiobookPrefs.ts:
- Add storage keys:
AUDIOBOOK_SKIP_BACK,AUDIOBOOK_SKIP_FORWARD,AUDIOBOOK_SMART_REWIND(bool),AUDIOBOOK_RATES(JSON map) inweb/src/utils/storage.ts. - Exposes
{ skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind }. - Defaults: back 10s, forward 30s (the asymmetric convention — small "what did she say?" hops back, bigger hops forward), smart rewind on.
- Allowed skip values: 5, 10, 15, 30, 45, 60, 90 (two digits max keeps
SkipIcon's inset number legible). - Per-book rate map: single JSON object
{ [contentId]: rate }underAUDIOBOOK_RATES, LRU-capped at 50 entries (store{ rate, at }and evict oldest on insert). HelpersgetBookRate(contentId)/setBookRate(contentId, rate)live here too.
1b. Engine extensions (useAudiobookPlayback.ts)
- Volume/mute: initialize from
getPersistedVolume(); apply to the<audio>element in an effect; exposevolume,muted,setVolume,setMuted; persist viapersistVolume. Sharing the video player's keys is intentional — one volume preference per device. - Per-book rate: on mount, initialize
ratefromgetBookRate(contentId)(fallback 1); insetRate, alsosetBookRate. The hook needscontentIdpassed in (it already receives the files/options object — extend that input). - Chapter navigation: expose
nextChapter()andprevChapter().next: seek to start of the chapter aftercurrentChapter; no-op at the last chapter.prev: if more than 3s into the current chapter, seek to its start; otherwise seek to the previous chapter's start (the universal music/audiobook convention).
- Smart rewind: record
pausedAtRef = performance.now()whenever playback pauses (in thepauseevent handler so it also catches OS-initiated pauses). On resume, beforeaudio.play(), compute rewind from pause duration andseekTo(currentTime - rewind):- < 10s paused → 0s; < 1min → 3s; < 10min → 10s; < 1h → 20s; ≥ 1h → 30s.
- Clamp at 0; skip entirely when the smart-rewind pref is off or the resume immediately follows a user seek (seeking sets a short suppress flag so explicit jumps aren't undone).
- Also apply on cold resume: when starting with
initialPositionSeconds > 0from the detail page, rewind by the same schedule using the age of the saved progress if the API exposes an updated-at timestamp; otherwise apply a flat 10s. Keep this logic in one pure functionsmartRewindSeconds(pauseMs)so it's unit-testable.
- Skip intervals: no engine change —
skip(delta)already takes a signed delta; the UI passes the configured values.
1c. Tests
Vitest, colocated like existing *.test.tsx:
smartRewindSecondsschedule boundaries.- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
prevChapter3s threshold behavior and first/last chapter edges (chapter math is pure — test againstbuildChapterListfixtures fromweb/src/lib/audiobooks/chapters.ts).
Phase 2 — Controls UI
2a. SpeedControl (new, audiobook-specific)
New web/src/pages/audiobooks/player/SpeedControl.tsx, replacing SpeedMenu usage in both
audiobook views (video player keeps SpeedMenu):
- Trigger: same
player-utility-btnshowing the current rate (1.45×, tabular-nums). - Popover (dark-glass, bottom-anchored like today):
- A stepper row:
−/ big mono readout /+, stepping 0.05 per tap, clamped to 0.5–3.0. Press-and-hold repeats (250ms initial, 80ms repeat). - A preset chip row: 1× · 1.25× · 1.5× · 1.75× · 2× · 2.5× · 3×.
- Footnote line: "Remembered for this book" (quiet
text-white/40, 11px) — makes the per-book memory discoverable instead of magical.
- A stepper row:
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
SleepTimerMenu).
2b. Chapter prev/next buttons
- Add
SkipBack/SkipForwardCircleButton size="sm" variant="secondary"on the outside of the transport cluster in bothMiniBarandNowListening:⏮ ↺10 ▶ ↻30 ⏭. Wire toprevChapter/nextChapter; render only whenplayback.chapters.length > 0; disable at the ends (prev stays enabled mid-chapter). - In
MiniBar, hide the chapter buttons belowsm:— the 3-column grid is already tight on phones and the chapters menu remains available.
2c. Volume
- Add a
toneprop toweb/src/player/components/VolumeControl.tsx("overlay"= current white-on-dark,"surface"= theme tokens: trackbg-muted, fillbg-foreground, focus ringring-ring). Default"overlay"so the video player is untouched. - Mount in
MiniBar's right cluster (before the sleep timer) atmd:and up, and inNowListening's utility row. Wire to the new enginevolume/mutedstate.
2d. Player settings menu
New web/src/pages/audiobooks/player/PlayerSettingsMenu.tsx (dark-glass popover):
- Skip back / Skip forward: rows of small value chips (5/10/15/30/45/60/90s).
- Smart rewind: toggle row with a one-line description ("Backs up a little after a pause").
- Triggers: the currently-dead "More" (
MoreHorizontal) button inNowListening(lines 50–56) and a matching trigger inMiniBar's right cluster. MiniBar/NowListeningreaduseAudiobookPrefs()and pass the configured seconds to the skip buttons andSkipIcon(which already renders a dynamic number) — delete theSKIP_*_SECONDSconstants.
Phase 3 — Shortcuts + time display + polish
3a. Keyboard shortcuts
New web/src/pages/audiobooks/player/useAudiobookKeyboardShortcuts.ts, mounted in
AudiobookPlayer.tsx (so it's active in both mini and expanded modes, on every route):
| Key | Action |
|---|---|
| Space / K | play-pause |
| ← / → | skip by configured back/forward seconds |
| ↑ / ↓ | volume ±5% |
| M | mute toggle |
| N / P | next / previous chapter |
| Shift+. / Shift+, | speed +0.05 / −0.05 (YouTube convention) |
| E | expand / collapse Now Listening |
| Esc | collapse Now Listening (expanded mode only) |
- Mirror
useKeyboardShortcuts.tsguards: ignore when target is input/textarea/ contentEditable, single document listener, cleanup on unmount. - Additional guard: do nothing while a video session is active — the video player binds
the same keys. Check
WatchPlaybackProviderstate (or simply whether a<video>watch route is mounted) and bail; the audiobook bar is backgrounded in that case anyway. Space must also not fire when a button has focus (let the focused control handle Enter/Space). - Add
titletooltips with the shortcut hint to transport buttons (e.g.Back 10 seconds (←)) for discoverability.
3b. Time remaining at current speed
In NowListening, the right time label currently toggles total ↔ remaining. Make it cycle
three states (persist last choice in component state only):
- total (
12:04:00) - remaining (
−3:21:09) - remaining at speed (
−2:14:06 at 1.5×) —remaining / rate, hidden (skipped in the cycle) whenrate === 1.
Keep the data-testid="now-listening-right-time" hook and update its test.
3c. Polish pass
- Skip-flick and speed-crossfade micro-interactions from the design section.
- Verify mini bar layout on 360px-wide viewports with all new controls (chapter buttons and volume hidden, settings menu accessible).
cd web && pnpm run lint && pnpm run format:check; run the player test suites.
Risks / notes
useAudiobookPlayback.tsis 707 lines and growing — extract smart rewind + prefs-coupled logic into small pure modules (smartRewind.ts) rather than inflating the hook; consider splitting volume/rate concerns into auseAudioElementSettingshelper if the hook passes ~800 lines.- Smart rewind must not fight the 10s progress reporter: rewinding on resume changes
currentTime, which triggers a progress write — that's correct (the rewound position is the truth), just confirm no oscillation with the suppress-after-seek flag. - Per-book rate uses
contentId; switching narrators navigates to a differentcontentId, so each narration remembers its own speed — acceptable, arguably desirable. - Cross-device sync of prefs/per-book speed is a candidate follow-up once a profile
settings endpoint exists; the
useAudiobookPrefsAPI is the seam to swap storage behind.