feat(audiobooks): player QoL — shortcuts, smart rewind, per-book speed, volume, chapter nav (#125)

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>
This commit is contained in:
Quick
2026-06-10 09:08:18 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 35a0db7d29
commit 54b20f57d7
24 changed files with 1752 additions and 172 deletions
@@ -0,0 +1,224 @@
# 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
1. **Keyboard shortcuts** — space/K play-pause, arrow skips, volume, chapter nav, speed step.
2. **Configurable skip intervals** — asymmetric defaults (back 10s, forward 30s), user-tunable.
3. **Expanded speed control** — 0.5×–3.0× with fine stepping, presets, and per-book memory.
4. **Chapter prev/next buttons** — alongside the seconds-skip buttons.
5. **Volume control** — reuse the shared `VolumeControl`, persisted like the video player.
6. **Smart rewind** — auto-rewind on resume, scaled by how long playback was paused.
7. **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-active` rows)
exactly as `SpeedMenu`/`SleepTimerMenu` do today.
- **Type**: time and rate values are always `font-mono`/`tabular-nums` so nothing shifts as
digits tick. The new speed readout (`1.45×`) must reserve width for two decimals.
- **Buttons**: all transport controls are `CircleButton` (`sm` secondary flanks, `md`/`lg`
primary center). Chapter prev/next use `SkipBack`/`SkipForward` lucide 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-white` treatment 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-haspopup` on triggers, roving focus + Escape in menus (copy from
`SleepTimerMenu`), `role="slider"` semantics from `VolumeControl`/`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) in `web/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 }` under `AUDIOBOOK_RATES`,
LRU-capped at 50 entries (store `{ rate, at }` and evict oldest on insert).
Helpers `getBookRate(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; expose `volume`, `muted`, `setVolume`, `setMuted`; persist via `persistVolume`.
Sharing the video player's keys is intentional — one volume preference per device.
- **Per-book rate**: on mount, initialize `rate` from `getBookRate(contentId)` (fallback 1);
in `setRate`, also `setBookRate`. The hook needs `contentId` passed in (it already
receives the files/options object — extend that input).
- **Chapter navigation**: expose `nextChapter()` and `prevChapter()`.
- `next`: seek to start of the chapter after `currentChapter`; 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
the `pause` event handler so it also catches OS-initiated pauses). On resume, before
`audio.play()`, compute rewind from pause duration and `seekTo(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 > 0` from 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 function `smartRewindSeconds(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`:
- `smartRewindSeconds` schedule boundaries.
- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
- `prevChapter` 3s threshold behavior and first/last chapter edges
(chapter math is pure — test against `buildChapterList` fixtures from
`web/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-btn` showing 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.
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
`SleepTimerMenu`).
### 2b. Chapter prev/next buttons
- Add `SkipBack`/`SkipForward` `CircleButton size="sm" variant="secondary"` on the outside
of the transport cluster in both `MiniBar` and `NowListening`:
`⏮ ↺10 ▶ ↻30 ⏭`. Wire to `prevChapter`/`nextChapter`; render only when
`playback.chapters.length > 0`; disable at the ends (prev stays enabled mid-chapter).
- In `MiniBar`, hide the chapter buttons below `sm:` — the 3-column grid is already tight
on phones and the chapters menu remains available.
### 2c. Volume
- Add a `tone` prop to `web/src/player/components/VolumeControl.tsx`
(`"overlay"` = current white-on-dark, `"surface"` = theme tokens: track `bg-muted`,
fill `bg-foreground`, focus ring `ring-ring`). Default `"overlay"` so the video player is
untouched.
- Mount in `MiniBar`'s right cluster (before the sleep timer) at `md:` and up, and in
`NowListening`'s utility row. Wire to the new engine `volume`/`muted` state.
### 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 in `NowListening`
(lines 50–56) and a matching trigger in `MiniBar`'s right cluster.
- `MiniBar`/`NowListening` read `useAudiobookPrefs()` and pass the configured seconds to
the skip buttons and `SkipIcon` (which already renders a dynamic number) — delete the
`SKIP_*_SECONDS` constants.
## 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.ts` guards: 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 `WatchPlaybackProvider` state (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 `title` tooltips 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):
1. total (`12:04:00`)
2. remaining (`−3:21:09`)
3. remaining at speed (`−2:14:06 at 1.5×`) — `remaining / rate`, hidden (skipped in the
cycle) when `rate === 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.ts` is 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 a `useAudioElementSettings` helper 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 different `contentId`,
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 `useAudiobookPrefs` API is the seam to swap storage behind.
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { buildPlayerChapters, nextChapterStart, prevChapterStart } from "./chapters";
function file(id: number, durationSeconds: number, chapterStarts: number[]): AudiobookFile {
return {
id,
duration_seconds: durationSeconds,
chapters: chapterStarts.map((start, index) => ({
index,
title: `File ${id} Chapter ${index + 1}`,
start_seconds: start,
end_seconds: chapterStarts[index + 1] ?? durationSeconds,
source: "embedded",
})),
} as AudiobookFile;
}
const files = [file(1, 600, [0, 300]), file(2, 400, [0, 200])];
const chapters = buildPlayerChapters(files);
describe("buildPlayerChapters", () => {
it("flattens chapters across files with absolute offsets", () => {
expect(chapters.map((c) => c.start_seconds)).toEqual([0, 300, 600, 800]);
expect(chapters.map((c) => c.index)).toEqual([0, 1, 2, 3]);
expect(chapters[2]?.end_seconds).toBe(800);
});
});
describe("nextChapterStart", () => {
it("returns the following chapter's absolute start", () => {
expect(nextChapterStart(chapters, chapters[0]!)).toBe(300);
expect(nextChapterStart(chapters, chapters[1]!)).toBe(600);
});
it("returns null at the last chapter and without a current chapter", () => {
expect(nextChapterStart(chapters, chapters[3]!)).toBeNull();
expect(nextChapterStart(chapters, null)).toBeNull();
});
});
describe("prevChapterStart", () => {
it("restarts the current chapter when more than the threshold in", () => {
expect(prevChapterStart(chapters, chapters[1]!, 310)).toBe(300);
});
it("jumps to the previous chapter near the chapter start", () => {
expect(prevChapterStart(chapters, chapters[1]!, 302)).toBe(0);
expect(prevChapterStart(chapters, chapters[2]!, 601)).toBe(300);
});
it("stays at the start of the first chapter", () => {
expect(prevChapterStart(chapters, chapters[0]!, 2)).toBe(0);
});
it("returns null without a current chapter", () => {
expect(prevChapterStart(chapters, null, 100)).toBeNull();
});
});
+53
View File
@@ -1,4 +1,5 @@
import type { AudiobookChapter, AudiobookFile } from "@/lib/audiobooks/types";
import type { PlayerChapter } from "@/player/types";
export interface ChapterListEntry {
chapter: AudiobookChapter;
@@ -46,3 +47,55 @@ export function findChapterAt(
export function totalAudiobookDuration(files: AudiobookFile[]): number {
return files.reduce((acc, file) => acc + (file.duration_seconds ?? 0), 0);
}
/**
* Flattens per-file chapters into the player's chapter shape with absolute
* start/end offsets and a contiguous 0-based index across the whole book.
*/
export 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;
}
/** Absolute start of the chapter after `current`, or null at the last chapter. */
export function nextChapterStart(
chapters: PlayerChapter[],
current: PlayerChapter | null,
): number | null {
if (!current) return null;
return chapters[current.index + 1]?.start_seconds ?? null;
}
/**
* Target for a "previous chapter" press, following the universal player
* convention: more than `restartThresholdSeconds` into the current chapter
* restarts it; otherwise jump to the previous chapter's start.
*/
export function prevChapterStart(
chapters: PlayerChapter[],
current: PlayerChapter | null,
currentTime: number,
restartThresholdSeconds = 3,
): number | null {
if (!current) return null;
if (currentTime - current.start_seconds > restartThresholdSeconds) {
return current.start_seconds;
}
return (chapters[current.index - 1] ?? current).start_seconds;
}
@@ -157,8 +157,9 @@ describe("AudiobookContent playback actions", () => {
await userEvent.click(screen.getByRole("button", { name: /^resume/i }));
expect(mocks.startPlayback).toHaveBeenCalledTimes(1);
// Smart rewind backs a cold resume up 10s so the listener regains context.
expect(mocks.startPlayback.mock.calls[0]?.[0]).toMatchObject({
initialPositionSeconds: 5000,
initialPositionSeconds: 4990,
});
await userEvent.click(screen.getByRole("button", { name: /listen from start/i }));
+12 -2
View File
@@ -9,11 +9,14 @@ 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 PageBack from "@/components/PageBack";
import MetadataBadges from "@/pages/ItemDetail/components/MetadataBadges";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { buildChapterList, findChapterAt, totalAudiobookDuration } from "@/lib/audiobooks/chapters";
import { audiobookFilesFromVersions } from "@/lib/audiobooks/files";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
import { coldResumePosition } from "@/pages/audiobooks/player/smartRewind";
import { getAudiobookSmartRewind } from "@/pages/audiobooks/player/useAudiobookPrefs";
function formatSeconds(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return "";
@@ -120,7 +123,7 @@ export default function AudiobookContent({
audiobookPlayback?.toggleActivePlayback();
return;
}
openPlayer(hasProgress ? resumeSeconds : 0);
openPlayer(hasProgress ? coldResumePosition(resumeSeconds, getAudiobookSmartRewind()) : 0);
}
function primaryPlaybackLabel() {
@@ -142,7 +145,13 @@ export default function AudiobookContent({
return;
}
handledPlayParamRef.current = true;
openPlayer(searchParams.get("restart") === "1" ? 0 : hasProgress ? resumeSeconds : 0);
openPlayer(
searchParams.get("restart") === "1"
? 0
: hasProgress
? coldResumePosition(resumeSeconds, getAudiobookSmartRewind())
: 0,
);
}, [files.length, hasProgress, openPlayer, resumeSeconds, searchParams]);
return (
@@ -156,6 +165,7 @@ export default function AudiobookContent({
<DetailHero
title={item.title}
topNav={<PageBack />}
context="Audiobook"
studioLabel={item.audiobook?.publisher || item.studios?.[0] || undefined}
posterUrl={item.poster_url}
@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from "react";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { useAudiobookKeyboardShortcuts } from "./useAudiobookKeyboardShortcuts";
import { useAudiobookPlayback } from "./useAudiobookPlayback";
import { useAudiobookPrefs } from "./useAudiobookPrefs";
import { MiniBar } from "./MiniBar";
import { NowListening } from "./NowListening";
@@ -43,14 +45,25 @@ export default function AudiobookPlayer({
onPlaybackStateChange,
onControlsChange,
}: AudiobookPlayerProps) {
const prefs = useAudiobookPrefs();
const playback = useAudiobookPlayback({
contentId,
files,
initialPositionSeconds,
autoPlay,
smartRewindEnabled: prefs.smartRewind,
onStopRequested: onClose,
});
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
useAudiobookKeyboardShortcuts({
playback,
prefs,
expanded: mode === "now-listening",
onToggleExpanded: () => setMode((m) => (m === "mini" ? "now-listening" : "mini")),
onCollapse: () => setMode("mini"),
});
const lastPlaybackStateEmitRef = useRef<{
emittedAt: number;
duration: number;
@@ -145,6 +158,7 @@ export default function AudiobookPlayer({
title={title}
posterUrl={posterUrl}
playback={playback}
prefs={prefs}
onClose={onClose}
onExpand={() => setMode("now-listening")}
/>
@@ -156,6 +170,7 @@ export default function AudiobookPlayer({
narrator={narrator}
posterUrl={posterUrl ?? ""}
playback={playback}
prefs={prefs}
onCollapse={() => setMode("mini")}
/>
)}
@@ -2,46 +2,46 @@ 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,
};
}
import { makeChapters, makePlayback, makePrefs } from "./playerTestUtils";
describe("MiniBar", () => {
it("renders the title", () => {
render(<MiniBar contentId="book-1" title="Project Hail Mary" playback={makePlayback()} />);
render(
<MiniBar
contentId="book-1"
title="Project Hail Mary"
playback={makePlayback()}
prefs={makePrefs()}
/>,
);
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 })} />);
render(
<MiniBar
contentId="book-1"
title="X"
playback={makePlayback({ togglePlay })}
prefs={makePrefs()}
/>,
);
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} />);
render(
<MiniBar
contentId="book-1"
title="X"
playback={makePlayback()}
prefs={makePrefs()}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /close player/i }));
expect(onClose).toHaveBeenCalled();
});
@@ -60,6 +60,7 @@ describe("MiniBar", () => {
source: "embedded",
},
})}
prefs={makePrefs()}
/>,
);
expect(screen.getByText("The Astrophage")).toBeInTheDocument();
@@ -67,7 +68,12 @@ describe("MiniBar", () => {
it("omits the chapter row when no current chapter is known", () => {
render(
<MiniBar contentId="book-1" title="X" playback={makePlayback({ currentChapter: null })} />,
<MiniBar
contentId="book-1"
title="X"
playback={makePlayback({ currentChapter: null })}
prefs={makePrefs()}
/>,
);
expect(screen.queryByTestId("minibar-chapter-title")).not.toBeInTheDocument();
});
@@ -80,10 +86,64 @@ describe("MiniBar", () => {
title="X"
posterUrl="/p.jpg"
playback={makePlayback()}
prefs={makePrefs()}
onExpand={onExpand}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /open now listening/i }));
expect(onExpand).toHaveBeenCalled();
});
it("skips by the configured intervals", async () => {
const skip = vi.fn();
render(
<MiniBar
contentId="book-1"
title="X"
playback={makePlayback({ skip })}
prefs={makePrefs({ skipBack: 15, skipForward: 60 })}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Back 15 seconds" }));
expect(skip).toHaveBeenCalledWith(-15);
await userEvent.click(screen.getByRole("button", { name: "Forward 60 seconds" }));
expect(skip).toHaveBeenCalledWith(60);
});
it("shows chapter prev/next buttons only when chapters exist and wires them up", async () => {
const prevChapter = vi.fn();
const nextChapter = vi.fn();
const chapters = makeChapters([0, 300], 600);
const { rerender } = render(
<MiniBar
contentId="book-1"
title="X"
playback={makePlayback({ chapters, currentChapter: chapters[0], prevChapter, nextChapter })}
prefs={makePrefs()}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Previous chapter" }));
expect(prevChapter).toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Next chapter" }));
expect(nextChapter).toHaveBeenCalled();
rerender(
<MiniBar contentId="book-1" title="X" playback={makePlayback()} prefs={makePrefs()} />,
);
expect(screen.queryByRole("button", { name: "Previous chapter" })).not.toBeInTheDocument();
});
it("disables next chapter at the last chapter", () => {
const chapters = makeChapters([0, 300], 600);
render(
<MiniBar
contentId="book-1"
title="X"
playback={makePlayback({ chapters, currentChapter: chapters[1] })}
prefs={makePrefs()}
/>,
);
expect(screen.getByRole("button", { name: "Next chapter" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Previous chapter" })).toBeEnabled();
});
});
+65 -26
View File
@@ -1,21 +1,22 @@
import { X, Pause, Play, RotateCcw, RotateCw } from "lucide-react";
import { X, Pause, Play, SkipBack, SkipForward } 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 { VolumeControl } from "@/player/components/VolumeControl";
import { CoverExpandTile } from "./CoverExpandTile";
import { PlayerSettingsMenu } from "./PlayerSettingsMenu";
import { SkipIcon } from "./SkipIcon";
import { SpeedControl } from "./SpeedControl";
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;
import type { AudiobookPrefs } from "./useAudiobookPrefs";
interface MiniBarProps {
contentId: string;
title?: string;
posterUrl?: string;
playback: AudiobookPlayback;
prefs: AudiobookPrefs;
onClose?: () => void;
onExpand?: () => void;
}
@@ -25,9 +26,14 @@ export function MiniBar({
title,
posterUrl,
playback,
prefs,
onClose,
onExpand,
}: MiniBarProps) {
const hasChapters = playback.chapters.length > 0;
const hasNextChapter =
playback.currentChapter != null && playback.currentChapter.index + 1 < playback.chapters.length;
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"
@@ -78,20 +84,38 @@ export function MiniBar({
</div>
<div className="flex items-center justify-center gap-2 sm:gap-3">
{hasChapters && (
<span className="hidden sm:contents">
<CircleButton
size="sm"
variant="secondary"
ariaLabel="Previous chapter"
title="Previous chapter (P)"
onClick={playback.prevChapter}
disabled={!playback.hasFile}
>
<SkipBack className="h-4 w-4" strokeWidth={0} fill="currentColor" />
</CircleButton>
</span>
)}
<CircleButton
size="sm"
variant="secondary"
ariaLabel={`Back ${SKIP_BACK_SECONDS} seconds`}
onClick={() => playback.skip(-SKIP_BACK_SECONDS)}
ariaLabel={`Back ${prefs.skipBack} seconds`}
title={`Back ${prefs.skipBack} seconds (←)`}
className="group"
onClick={() => playback.skip(-prefs.skipBack)}
disabled={!playback.hasFile}
>
<SkipIcon direction="back" seconds={SKIP_BACK_SECONDS} />
<SkipIcon direction="back" seconds={prefs.skipBack} />
</CircleButton>
<CircleButton
size="md"
variant="primary"
ariaLabel={playback.playing ? "Pause" : "Play"}
title={playback.playing ? "Pause (space)" : "Play (space)"}
onClick={playback.togglePlay}
disabled={!playback.hasFile}
data-paused={!playback.playing}
@@ -106,28 +130,55 @@ export function MiniBar({
<CircleButton
size="sm"
variant="secondary"
ariaLabel={`Forward ${SKIP_FORWARD_SECONDS} seconds`}
onClick={() => playback.skip(SKIP_FORWARD_SECONDS)}
ariaLabel={`Forward ${prefs.skipForward} seconds`}
title={`Forward ${prefs.skipForward} seconds (→)`}
className="group"
onClick={() => playback.skip(prefs.skipForward)}
disabled={!playback.hasFile}
>
<SkipIcon direction="forward" seconds={SKIP_FORWARD_SECONDS} />
<SkipIcon direction="forward" seconds={prefs.skipForward} />
</CircleButton>
{hasChapters && (
<span className="hidden sm:contents">
<CircleButton
size="sm"
variant="secondary"
ariaLabel="Next chapter"
title="Next chapter (N)"
onClick={playback.nextChapter}
disabled={!playback.hasFile || !hasNextChapter}
>
<SkipForward className="h-4 w-4" strokeWidth={0} fill="currentColor" />
</CircleButton>
</span>
)}
</div>
<div className="flex items-center justify-end gap-2">
<div className="hidden md:block">
<VolumeControl
tone="surface"
volume={playback.volume}
muted={playback.muted}
onVolumeChange={playback.setVolume}
onMutedChange={playback.setMuted}
/>
</div>
<SleepTimerMenu
setting={playback.sleep.setting}
remainingMs={playback.sleep.remainingMs}
onChange={playback.setSleep}
/>
{playback.chapters.length > 0 && (
{hasChapters && (
<ChaptersMenu
chapters={playback.chapters}
currentTime={playback.currentTime}
onSeek={playback.seekTo}
/>
)}
<SpeedMenu rates={PLAYBACK_RATES} value={playback.rate} onChange={playback.setRate} />
<SpeedControl value={playback.rate} onChange={playback.setRate} />
<PlayerSettingsMenu prefs={prefs} />
{onClose && (
<button
type="button"
@@ -143,15 +194,3 @@ export function MiniBar({
</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>
);
}
@@ -2,29 +2,7 @@ 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,
};
}
import { makePlayback, makePrefs } from "./playerTestUtils";
describe("NowListening", () => {
it("renders title, author, narrator, and the current chapter heading", () => {
@@ -44,6 +22,7 @@ describe("NowListening", () => {
source: "embedded",
},
})}
prefs={makePrefs()}
onCollapse={vi.fn()}
/>,
);
@@ -61,6 +40,7 @@ describe("NowListening", () => {
title="X"
posterUrl=""
playback={makePlayback()}
prefs={makePrefs()}
onCollapse={onCollapse}
/>,
);
@@ -68,18 +48,42 @@ describe("NowListening", () => {
expect(onCollapse).toHaveBeenCalled();
});
it("toggles between remaining and total time when the right time is clicked", async () => {
it("cycles total → remaining → total at 1x speed", async () => {
render(
<NowListening
contentId="book-1"
title="X"
posterUrl=""
playback={makePlayback({ currentTime: 100, duration: 600 })}
prefs={makePrefs()}
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");
const label = () => screen.getByTestId("now-listening-right-time");
expect(label()).toHaveTextContent("10:00");
await userEvent.click(label());
expect(label()).toHaveTextContent("-8:20");
await userEvent.click(label());
expect(label()).toHaveTextContent("10:00");
});
it("offers remaining time at the current speed when rate is not 1x", async () => {
render(
<NowListening
contentId="book-1"
title="X"
posterUrl=""
playback={makePlayback({ currentTime: 100, duration: 600, rate: 2 })}
prefs={makePrefs()}
onCollapse={vi.fn()}
/>,
);
const label = () => screen.getByTestId("now-listening-right-time");
await userEvent.click(label()); // remaining
expect(label()).toHaveTextContent("-8:20");
await userEvent.click(label()); // remaining at speed: 500s / 2 = 250s
expect(label()).toHaveTextContent("-4:10 at 2×");
await userEvent.click(label());
expect(label()).toHaveTextContent("10:00");
});
});
@@ -1,15 +1,17 @@
import { useState } from "react";
import { ChevronDown, MoreHorizontal, Pause, Play, RotateCcw, RotateCw } from "lucide-react";
import { ChevronDown, Pause, Play, SkipBack, SkipForward } 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 { VolumeControl } from "@/player/components/VolumeControl";
import { PlayerSettingsMenu } from "./PlayerSettingsMenu";
import { SkipIcon } from "./SkipIcon";
import { SpeedControl, formatRate } from "./SpeedControl";
import type { AudiobookPlayback } from "./useAudiobookPlayback";
import type { AudiobookPrefs } from "./useAudiobookPrefs";
const SKIP_BACK_SECONDS = 30;
const SKIP_FORWARD_SECONDS = 30;
const PLAYBACK_RATES = [0.75, 1, 1.25, 1.5, 2] as const;
type TimeMode = "total" | "remaining" | "remaining-at-speed";
interface NowListeningProps {
contentId: string;
@@ -18,6 +20,7 @@ interface NowListeningProps {
narrator?: string;
posterUrl: string;
playback: AudiobookPlayback;
prefs: AudiobookPrefs;
onCollapse: () => void;
}
@@ -28,13 +31,35 @@ export function NowListening({
narrator,
posterUrl,
playback,
prefs,
onCollapse,
}: NowListeningProps) {
const [showRemaining, setShowRemaining] = useState(false);
const [timeMode, setTimeMode] = useState<TimeMode>("total");
const rightTimeLabel = showRemaining
? `-${formatTime(Math.max(0, playback.duration - playback.currentTime))}`
: formatTime(playback.duration);
const remaining = Math.max(0, playback.duration - playback.currentTime);
// "At current speed" only exists as a distinct reading when rate ≠ 1.
const effectiveMode =
timeMode === "remaining-at-speed" && playback.rate === 1 ? "remaining" : timeMode;
const rightTimeLabel =
effectiveMode === "total"
? formatTime(playback.duration)
: effectiveMode === "remaining"
? `-${formatTime(remaining)}`
: `-${formatTime(remaining / playback.rate)} at ${formatRate(playback.rate)}`;
const cycleTimeMode = () => {
setTimeMode((mode) => {
if (mode === "total") return "remaining";
if (mode === "remaining") {
return playback.rate !== 1 ? "remaining-at-speed" : "total";
}
return "total";
});
};
const hasChapters = playback.chapters.length > 0;
const hasNextChapter =
playback.currentChapter != null && playback.currentChapter.index + 1 < playback.chapters.length;
return (
<div className="bg-background fixed inset-0 z-50 flex flex-col overflow-y-auto">
@@ -47,13 +72,7 @@ export function NowListening({
<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>
<PlayerSettingsMenu prefs={prefs} />
</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">
@@ -101,7 +120,8 @@ export function NowListening({
<button
type="button"
data-testid="now-listening-right-time"
onClick={() => setShowRemaining((v) => !v)}
onClick={cycleTimeMode}
title="Toggle total / remaining / remaining at speed"
className="hover:text-foreground transition-colors"
>
{rightTimeLabel}
@@ -109,21 +129,37 @@ export function NowListening({
</div>
</div>
<div className="flex items-center justify-center gap-4">
<div className="flex items-center justify-center gap-3 sm:gap-4">
{hasChapters && (
<CircleButton
size="sm"
variant="secondary"
ariaLabel="Previous chapter"
title="Previous chapter (P)"
onClick={playback.prevChapter}
disabled={!playback.hasFile}
>
<SkipBack className="h-4 w-4" strokeWidth={0} fill="currentColor" />
</CircleButton>
)}
<CircleButton
size="sm"
variant="secondary"
ariaLabel={`Back ${SKIP_BACK_SECONDS} seconds`}
onClick={() => playback.skip(-SKIP_BACK_SECONDS)}
ariaLabel={`Back ${prefs.skipBack} seconds`}
title={`Back ${prefs.skipBack} seconds (←)`}
className="group"
onClick={() => playback.skip(-prefs.skipBack)}
disabled={!playback.hasFile}
>
<SkipIcon direction="back" seconds={SKIP_BACK_SECONDS} />
<SkipIcon direction="back" seconds={prefs.skipBack} size="lg" />
</CircleButton>
<CircleButton
size="lg"
variant="primary"
ariaLabel={playback.playing ? "Pause" : "Play"}
title={playback.playing ? "Pause (space)" : "Play (space)"}
onClick={playback.togglePlay}
disabled={!playback.hasFile}
data-paused={!playback.playing}
@@ -138,43 +174,53 @@ export function NowListening({
<CircleButton
size="sm"
variant="secondary"
ariaLabel={`Forward ${SKIP_FORWARD_SECONDS} seconds`}
onClick={() => playback.skip(SKIP_FORWARD_SECONDS)}
ariaLabel={`Forward ${prefs.skipForward} seconds`}
title={`Forward ${prefs.skipForward} seconds (→)`}
className="group"
onClick={() => playback.skip(prefs.skipForward)}
disabled={!playback.hasFile}
>
<SkipIcon direction="forward" seconds={SKIP_FORWARD_SECONDS} />
<SkipIcon direction="forward" seconds={prefs.skipForward} size="lg" />
</CircleButton>
{hasChapters && (
<CircleButton
size="sm"
variant="secondary"
ariaLabel="Next chapter"
title="Next chapter (N)"
onClick={playback.nextChapter}
disabled={!playback.hasFile || !hasNextChapter}
>
<SkipForward className="h-4 w-4" strokeWidth={0} fill="currentColor" />
</CircleButton>
)}
</div>
<div className="text-muted-foreground flex items-center justify-center gap-6 text-sm">
<div className="text-muted-foreground flex flex-wrap items-center justify-center gap-4 text-sm sm:gap-6">
<VolumeControl
tone="surface"
volume={playback.volume}
muted={playback.muted}
onVolumeChange={playback.setVolume}
onMutedChange={playback.setMuted}
/>
<SleepTimerMenu
setting={playback.sleep.setting}
remainingMs={playback.sleep.remainingMs}
onChange={playback.setSleep}
/>
{playback.chapters.length > 0 && (
{hasChapters && (
<ChaptersMenu
chapters={playback.chapters}
currentTime={playback.currentTime}
onSeek={playback.seekTo}
/>
)}
<SpeedMenu rates={PLAYBACK_RATES} value={playback.rate} onChange={playback.setRate} />
<SpeedControl 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,115 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { SlidersHorizontal } from "lucide-react";
import { SKIP_INTERVAL_CHOICES, type AudiobookPrefs } from "./useAudiobookPrefs";
interface PlayerSettingsMenuProps {
prefs: AudiobookPrefs;
}
/** Skip-interval and smart-rewind preferences, anchored to the player bar. */
export function PlayerSettingsMenu({ prefs }: PlayerSettingsMenuProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleBlur = useCallback((e: React.FocusEvent) => {
if (!containerRef.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]);
return (
<div ref={containerRef} className="relative" onBlur={handleBlur}>
<button
type="button"
className="player-utility-btn"
onClick={() => setOpen((v) => !v)}
aria-label="Player settings"
aria-expanded={open}
aria-haspopup="dialog"
title="Player settings"
>
<SlidersHorizontal className="h-[18px] w-[18px]" />
</button>
{open && (
<div
role="dialog"
aria-label="Player settings"
className="absolute right-0 bottom-full mb-2 flex w-[260px] flex-col gap-4 rounded-lg bg-black/90 px-4 py-3.5 shadow-xl backdrop-blur-sm"
>
<SkipIntervalRow label="Skip back" value={prefs.skipBack} onChange={prefs.setSkipBack} />
<SkipIntervalRow
label="Skip forward"
value={prefs.skipForward}
onChange={prefs.setSkipForward}
/>
<button
type="button"
role="switch"
aria-checked={prefs.smartRewind}
className="group flex items-start justify-between gap-3 text-left focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
onClick={() => prefs.setSmartRewind(!prefs.smartRewind)}
>
<span className="flex flex-col gap-0.5">
<span className="text-sm text-white/85">Smart rewind</span>
<span className="text-[11px] leading-snug text-white/40">
Backs up a little after a pause
</span>
</span>
<span
aria-hidden
className={`mt-0.5 inline-flex h-[18px] w-8 shrink-0 items-center rounded-full px-[2px] transition-colors ${
prefs.smartRewind ? "bg-white/80" : "bg-white/20"
}`}
>
<span
className={`h-[14px] w-[14px] rounded-full bg-black/80 transition-transform ${
prefs.smartRewind ? "translate-x-[14px]" : "translate-x-0"
}`}
/>
</span>
</button>
</div>
)}
</div>
);
}
function SkipIntervalRow({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (seconds: number) => void;
}) {
return (
<div className="flex flex-col gap-1.5">
<span className="text-[11px] tracking-[0.14em] text-white/40 uppercase">{label}</span>
<div className="flex flex-wrap gap-1">
{SKIP_INTERVAL_CHOICES.map((seconds) => (
<button
key={seconds}
type="button"
data-active={seconds === value ? "true" : undefined}
className={`rounded-full px-2 py-0.5 text-xs tabular-nums transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
seconds === value ? "bg-white/15 text-white" : "text-white/60"
}`}
onClick={() => onChange(seconds)}
>
{seconds}s
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { RotateCcw, RotateCw } from "lucide-react";
/**
* Rotate-arrow glyph with the skip interval inset, shared by the mini bar and
* Now Listening transports. Pair with `className="group"` on the surrounding
* CircleButton so the arrow flicks in the skip direction while pressed.
*/
export function SkipIcon({
direction,
seconds,
size = "md",
}: {
direction: "back" | "forward";
seconds: number;
size?: "md" | "lg";
}) {
const Arrow = direction === "back" ? RotateCcw : RotateCw;
const box = size === "lg" ? "h-7 w-7" : "h-6 w-6";
const label = size === "lg" ? "text-[8.5px]" : "text-[8px]";
const flick =
direction === "back" ? "group-active:-rotate-[18deg]" : "group-active:rotate-[18deg]";
return (
<span className={`relative flex items-center justify-center ${box}`}>
<Arrow
className={`${box} transition-transform duration-150 ease-out ${flick}`}
strokeWidth={1.6}
/>
<span
className={`absolute inset-0 flex items-center justify-center pb-[1px] font-semibold tracking-tight tabular-nums ${label}`}
>
{seconds}
</span>
</span>
);
}
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Minus, Plus } from "lucide-react";
import {
AUDIOBOOK_RATE_MAX,
AUDIOBOOK_RATE_MIN,
AUDIOBOOK_RATE_PRESETS,
AUDIOBOOK_RATE_STEP,
clampAudiobookRate,
} from "./useAudiobookPrefs";
const HOLD_DELAY_MS = 250;
const HOLD_REPEAT_MS = 80;
export function formatRate(rate: number): string {
return `${rate}×`;
}
interface SpeedControlProps {
value: number;
onChange: (rate: number) => void;
}
/**
* Audiobook playback speed popover: fine ±0.05 stepper (press-and-hold to
* repeat) plus quick presets. The chosen rate is remembered per book by the
* playback engine.
*/
export function SpeedControl({ value, onChange }: SpeedControlProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const valueRef = useRef(value);
const holdTimerRef = useRef<number | null>(null);
const holdIntervalRef = useRef<number | null>(null);
valueRef.current = value;
const handleBlur = useCallback((e: React.FocusEvent) => {
if (!containerRef.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 step = useCallback(
(direction: 1 | -1) => {
onChange(clampAudiobookRate(valueRef.current + direction * AUDIOBOOK_RATE_STEP));
},
[onChange],
);
const stopHold = useCallback(() => {
if (holdTimerRef.current != null) window.clearTimeout(holdTimerRef.current);
if (holdIntervalRef.current != null) window.clearInterval(holdIntervalRef.current);
holdTimerRef.current = null;
holdIntervalRef.current = null;
}, []);
const startHold = useCallback(
(direction: 1 | -1) => {
step(direction);
stopHold();
holdTimerRef.current = window.setTimeout(() => {
holdIntervalRef.current = window.setInterval(() => step(direction), HOLD_REPEAT_MS);
}, HOLD_DELAY_MS);
},
[step, stopHold],
);
useEffect(() => stopHold, [stopHold]);
const handlePopoverKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
e.preventDefault();
step(1);
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
e.preventDefault();
step(-1);
}
},
[step],
);
return (
<div ref={containerRef} className="relative" onBlur={handleBlur}>
<button
type="button"
className="player-utility-btn min-w-[3.25rem] px-2 text-xs tabular-nums"
onClick={() => setOpen((v) => !v)}
aria-label="Playback speed"
aria-expanded={open}
aria-haspopup="dialog"
title="Playback speed (shift+. / shift+,)"
>
{formatRate(value)}
</button>
{open && (
<div
role="dialog"
aria-label="Playback speed"
className="absolute right-0 bottom-full mb-2 flex w-[230px] flex-col gap-3 rounded-lg bg-black/90 px-4 py-3 shadow-xl backdrop-blur-sm"
onKeyDown={handlePopoverKeyDown}
>
<div className="flex items-center justify-between">
<StepperButton
direction={-1}
disabled={value <= AUDIOBOOK_RATE_MIN}
onHoldStart={startHold}
onHoldStop={stopHold}
/>
<span className="min-w-[4.5rem] text-center font-mono text-lg text-white tabular-nums">
{formatRate(value)}
</span>
<StepperButton
direction={1}
disabled={value >= AUDIOBOOK_RATE_MAX}
onHoldStart={startHold}
onHoldStop={stopHold}
/>
</div>
<div className="flex flex-wrap justify-center gap-1.5">
{AUDIOBOOK_RATE_PRESETS.map((preset) => (
<button
key={preset}
type="button"
data-active={preset === value ? "true" : undefined}
className={`rounded-full px-2.5 py-1 text-xs tabular-nums transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
preset === value ? "bg-white/15 text-white" : "text-white/60"
}`}
onClick={() => onChange(preset)}
>
{formatRate(preset)}
</button>
))}
</div>
<p className="text-center text-[11px] text-white/40">Remembered for this book</p>
</div>
)}
</div>
);
}
function StepperButton({
direction,
disabled,
onHoldStart,
onHoldStop,
}: {
direction: 1 | -1;
disabled: boolean;
onHoldStart: (direction: 1 | -1) => void;
onHoldStop: () => void;
}) {
const Icon = direction === 1 ? Plus : Minus;
return (
<button
type="button"
aria-label={direction === 1 ? "Faster" : "Slower"}
disabled={disabled}
className="flex h-8 w-8 items-center justify-center rounded-full text-white/75 transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40"
onPointerDown={(e) => {
// Keep focus behavior; only pointer presses start the hold-repeat.
if (e.button === 0 || e.pointerType !== "mouse") onHoldStart(direction);
}}
onPointerUp={onHoldStop}
onPointerLeave={onHoldStop}
onPointerCancel={onHoldStop}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onHoldStart(direction);
}
}}
onKeyUp={onHoldStop}
>
<Icon className="h-4 w-4" />
</button>
);
}
@@ -0,0 +1,58 @@
import { vi } from "vitest";
import type { PlayerChapter } from "@/player/types";
import type { AudiobookPlayback } from "./useAudiobookPlayback";
import {
DEFAULT_SKIP_BACK_SECONDS,
DEFAULT_SKIP_FORWARD_SECONDS,
type AudiobookPrefs,
} from "./useAudiobookPrefs";
export 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,
volume: 1,
muted: false,
togglePlay: vi.fn(),
seekTo: vi.fn(),
skip: vi.fn(),
setRate: vi.fn(),
setVolume: vi.fn(),
setMuted: vi.fn(),
nextChapter: vi.fn(),
prevChapter: vi.fn(),
sleep: { setting: { kind: "off" as const }, remainingMs: null },
setSleep: vi.fn(),
...over,
};
}
export function makePrefs(over: Partial<AudiobookPrefs> = {}): AudiobookPrefs {
return {
skipBack: DEFAULT_SKIP_BACK_SECONDS,
skipForward: DEFAULT_SKIP_FORWARD_SECONDS,
smartRewind: true,
setSkipBack: vi.fn(),
setSkipForward: vi.fn(),
setSmartRewind: vi.fn(),
...over,
};
}
export function makeChapters(starts: number[], total: number): PlayerChapter[] {
return starts.map((start, index) => ({
index,
title: `Chapter ${index + 1}`,
start_seconds: start,
end_seconds: starts[index + 1] ?? total,
source: "embedded",
}));
}
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { coldResumePosition, smartRewindSeconds } from "./smartRewind";
describe("smartRewindSeconds", () => {
it("does not rewind for brief pauses", () => {
expect(smartRewindSeconds(0)).toBe(0);
expect(smartRewindSeconds(9_999)).toBe(0);
});
it("scales the rewind with the pause duration", () => {
expect(smartRewindSeconds(10_000)).toBe(3);
expect(smartRewindSeconds(59_999)).toBe(3);
expect(smartRewindSeconds(60_000)).toBe(10);
expect(smartRewindSeconds(599_999)).toBe(10);
expect(smartRewindSeconds(600_000)).toBe(20);
expect(smartRewindSeconds(3_599_999)).toBe(20);
expect(smartRewindSeconds(3_600_000)).toBe(30);
expect(smartRewindSeconds(86_400_000)).toBe(30);
});
it("treats invalid input as no rewind", () => {
expect(smartRewindSeconds(NaN)).toBe(0);
expect(smartRewindSeconds(-5)).toBe(0);
});
});
describe("coldResumePosition", () => {
it("rewinds a flat amount when enabled", () => {
expect(coldResumePosition(120, true)).toBe(110);
});
it("never goes negative", () => {
expect(coldResumePosition(4, true)).toBe(0);
});
it("leaves fresh starts and disabled prefs untouched", () => {
expect(coldResumePosition(0, true)).toBe(0);
expect(coldResumePosition(120, false)).toBe(120);
});
});
@@ -0,0 +1,23 @@
/**
* Smart rewind: after a pause, back up a little so the listener regains
* context before new audio starts. The longer the pause, the bigger the jump.
*/
export function smartRewindSeconds(pauseMs: number): number {
if (!Number.isFinite(pauseMs) || pauseMs < 10_000) return 0;
if (pauseMs < 60_000) return 3;
if (pauseMs < 600_000) return 10;
if (pauseMs < 3_600_000) return 20;
return 30;
}
/**
* Rewind applied when resuming a book "cold" (from the detail page) where the
* age of the saved progress is unknown. Flat, small, and skipped for fresh
* starts so "Listen from Start" and chapter jumps are never moved.
*/
export const COLD_RESUME_REWIND_SECONDS = 10;
export function coldResumePosition(resumeSeconds: number, smartRewindEnabled: boolean): number {
if (!smartRewindEnabled || resumeSeconds <= 0) return Math.max(0, resumeSeconds);
return Math.max(0, resumeSeconds - COLD_RESUME_REWIND_SECONDS);
}
@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, renderHook } from "@testing-library/react";
import { useAudiobookKeyboardShortcuts } from "./useAudiobookKeyboardShortcuts";
import { makePlayback, makePrefs } from "./playerTestUtils";
import type { AudiobookPlayback } from "./useAudiobookPlayback";
function setup(playbackOver: Partial<AudiobookPlayback> = {}, expanded = false) {
const playback = makePlayback(playbackOver);
const onToggleExpanded = vi.fn();
const onCollapse = vi.fn();
renderHook(() =>
useAudiobookKeyboardShortcuts({
playback,
prefs: makePrefs({ skipBack: 15, skipForward: 45 }),
expanded,
onToggleExpanded,
onCollapse,
}),
);
return { playback, onToggleExpanded, onCollapse };
}
describe("useAudiobookKeyboardShortcuts", () => {
it("toggles playback on space and K", () => {
const { playback } = setup();
fireEvent.keyDown(document.body, { key: " " });
fireEvent.keyDown(document.body, { key: "k" });
expect(playback.togglePlay).toHaveBeenCalledTimes(2);
});
it("skips by the configured intervals on arrow keys", () => {
const { playback } = setup();
fireEvent.keyDown(document.body, { key: "ArrowLeft" });
expect(playback.skip).toHaveBeenCalledWith(-15);
fireEvent.keyDown(document.body, { key: "ArrowRight" });
expect(playback.skip).toHaveBeenCalledWith(45);
});
it("adjusts volume on up/down and unmutes when raising while muted", () => {
const { playback } = setup({ volume: 0.5, muted: true });
fireEvent.keyDown(document.body, { key: "ArrowUp" });
expect(playback.setVolume).toHaveBeenCalledWith(0.55);
expect(playback.setMuted).toHaveBeenCalledWith(false);
fireEvent.keyDown(document.body, { key: "ArrowDown" });
expect(playback.setVolume).toHaveBeenCalledWith(0.45);
});
it("toggles mute on M and navigates chapters on N/P", () => {
const { playback } = setup();
fireEvent.keyDown(document.body, { key: "m" });
expect(playback.setMuted).toHaveBeenCalledWith(true);
fireEvent.keyDown(document.body, { key: "n" });
expect(playback.nextChapter).toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: "p" });
expect(playback.prevChapter).toHaveBeenCalled();
});
it("steps the playback rate on shift+. and shift+,", () => {
const { playback } = setup({ rate: 1.5 });
fireEvent.keyDown(document.body, { key: ">", shiftKey: true });
expect(playback.setRate).toHaveBeenCalledWith(1.55);
fireEvent.keyDown(document.body, { key: "<", shiftKey: true });
expect(playback.setRate).toHaveBeenCalledWith(1.45);
});
it("expands/collapses on E and collapses on Escape when expanded", () => {
const { onToggleExpanded, onCollapse } = setup({}, true);
fireEvent.keyDown(document.body, { key: "e" });
expect(onToggleExpanded).toHaveBeenCalled();
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onCollapse).toHaveBeenCalled();
});
it("ignores Escape when collapsed", () => {
const { onCollapse } = setup({}, false);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(onCollapse).not.toHaveBeenCalled();
});
it("does nothing while typing in an input", () => {
const { playback } = setup();
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
fireEvent.keyDown(input, { key: " " });
expect(playback.togglePlay).not.toHaveBeenCalled();
input.remove();
});
it("leaves space alone when a button has focus", () => {
const { playback } = setup();
const button = document.createElement("button");
document.body.appendChild(button);
button.focus();
fireEvent.keyDown(button, { key: " " });
expect(playback.togglePlay).not.toHaveBeenCalled();
button.remove();
});
it("ignores keys with system modifiers held", () => {
const { playback } = setup();
fireEvent.keyDown(document.body, { key: "k", metaKey: true });
fireEvent.keyDown(document.body, { key: "ArrowLeft", ctrlKey: true });
expect(playback.togglePlay).not.toHaveBeenCalled();
expect(playback.skip).not.toHaveBeenCalled();
});
it("stays inert without a loaded file", () => {
const { playback } = setup({ hasFile: false });
fireEvent.keyDown(document.body, { key: " " });
expect(playback.togglePlay).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,156 @@
import { useContext, useEffect } from "react";
import { WatchPlaybackControllerContext } from "@/playback/watchPlaybackContext";
import type { AudiobookPlayback } from "./useAudiobookPlayback";
import { AUDIOBOOK_RATE_STEP, type AudiobookPrefs } from "./useAudiobookPrefs";
interface AudiobookKeyboardShortcutsOptions {
playback: AudiobookPlayback;
prefs: AudiobookPrefs;
/** Whether the Now Listening full-screen view is open. */
expanded: boolean;
onToggleExpanded: () => void;
onCollapse: () => void;
}
/**
* Global keyboard shortcuts for the audiobook player, active on every route
* while the player is mounted:
*
* Space/K play-pause · ←/→ skip · ↑/↓ volume · M mute · N/P chapter ·
* Shift+. / Shift+, speed · E expand/collapse · Esc collapse
*
* Stays inert while a video watch session exists — the video player owns the
* same keys and the audiobook bar is backgrounded in that case.
*/
export function useAudiobookKeyboardShortcuts({
playback,
prefs,
expanded,
onToggleExpanded,
onCollapse,
}: AudiobookKeyboardShortcutsOptions) {
// Read the watch context leniently: outside the provider (tests) it is
// simply null rather than an error.
const watch = useContext(WatchPlaybackControllerContext);
const videoSessionActive = watch?.state.request != null;
const { skipBack, skipForward } = prefs;
const {
hasFile,
rate,
volume,
muted,
togglePlay,
skip,
setRate,
setVolume,
setMuted,
nextChapter,
prevChapter,
} = playback;
useEffect(() => {
if (videoSessionActive || !hasFile) return;
function handleKeyDown(e: KeyboardEvent) {
// A focused control (seek bar, volume slider, open menu) that already
// handled the key wins; so does anything the user is typing into.
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
) {
return;
}
// Space activates a focused button/link; never steal it.
if (e.key === " " && target.closest("button, a, [role='button'], [role='slider']")) {
return;
}
switch (e.key) {
case " ":
case "k":
case "K":
e.preventDefault();
togglePlay();
break;
case "ArrowLeft":
e.preventDefault();
skip(-skipBack);
break;
case "ArrowRight":
e.preventDefault();
skip(skipForward);
break;
case "ArrowUp":
e.preventDefault();
setVolume(Math.min(1, volume + 0.05));
if (muted) setMuted(false);
break;
case "ArrowDown":
e.preventDefault();
setVolume(Math.max(0, volume - 0.05));
break;
case "m":
case "M":
e.preventDefault();
setMuted(!muted);
break;
case "n":
case "N":
e.preventDefault();
nextChapter();
break;
case "p":
case "P":
e.preventDefault();
prevChapter();
break;
case ">":
e.preventDefault();
setRate(rate + AUDIOBOOK_RATE_STEP);
break;
case "<":
e.preventDefault();
setRate(rate - AUDIOBOOK_RATE_STEP);
break;
case "e":
case "E":
e.preventDefault();
onToggleExpanded();
break;
case "Escape":
// Open popovers close themselves on Escape; only collapse the
// full-screen view when nothing else is consuming the key.
if (expanded && !document.querySelector("[role='menu'], [role='dialog']")) {
onCollapse();
}
break;
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [
expanded,
hasFile,
muted,
nextChapter,
onCollapse,
onToggleExpanded,
prevChapter,
rate,
setMuted,
setRate,
setVolume,
skip,
skipBack,
skipForward,
togglePlay,
videoSessionActive,
volume,
]);
}
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef, useState, useCallback, type RefObject } from "react";
import { toast } from "sonner";
import { useReportMediaProgress } from "@/hooks/queries/progress";
import { buildPlayerChapters, nextChapterStart, prevChapterStart } from "@/lib/audiobooks/chapters";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { getPersistedVolume, persistVolume } from "@/player/components/VolumeControl";
import { usePlayerConfig } from "@/player/context/PlayerConfigContext";
import { playerFetch } from "@/player/player-fetch";
import type { PlaybackRealtimeCommandEnvelope } from "@/player/realtime-protocol";
@@ -9,6 +11,8 @@ import { buildPlayerStreamUrl } from "@/player/stream-url";
import type { PlaybackSessionResponse, PlayerChapter } from "@/player/types";
import { usePlaybackRealtime } from "@/player/hooks/usePlaybackRealtime";
import type { SleepSetting } from "@/player/components/SleepTimerMenu";
import { smartRewindSeconds } from "./smartRewind";
import { clampAudiobookRate, getBookRate, setBookRate } from "./useAudiobookPrefs";
const REPORT_INTERVAL_MS = 10_000;
@@ -17,6 +21,7 @@ export interface UseAudiobookPlaybackOptions {
files: AudiobookFile[];
initialPositionSeconds: number;
autoPlay?: boolean;
smartRewindEnabled?: boolean;
onStopRequested?: () => void;
}
@@ -31,10 +36,16 @@ export interface AudiobookPlayback {
rate: number;
chapters: PlayerChapter[];
currentChapter: PlayerChapter | null;
volume: number;
muted: boolean;
togglePlay: () => void;
seekTo: (seconds: number) => void;
skip: (delta: number) => void;
setRate: (r: number) => void;
setVolume: (volume: number) => void;
setMuted: (muted: boolean) => void;
nextChapter: () => void;
prevChapter: () => void;
sleep: { setting: SleepSetting; remainingMs: number | null };
setSleep: (next: SleepSetting) => void;
}
@@ -134,27 +145,6 @@ function absoluteBufferedRanges(
} 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;
}
function readNumericPayload(
payload: Record<string, unknown> | undefined,
...keys: string[]
@@ -184,6 +174,7 @@ export function useAudiobookPlayback({
files,
initialPositionSeconds,
autoPlay = true,
smartRewindEnabled = true,
onStopRequested,
}: UseAudiobookPlaybackOptions): AudiobookPlayback {
const config = usePlayerConfig();
@@ -199,7 +190,9 @@ export function useAudiobookPlayback({
clampedBookTime(initialPositionSeconds, duration),
);
const [buffered, setBuffered] = useState<TimeRanges | null>(null);
const [rate, setRateState] = useState(1);
const [rate, setRateState] = useState(() => getBookRate(contentId) ?? 1);
const [volume, setVolumeState] = useState(() => getPersistedVolume().volume);
const [muted, setMutedState] = useState(() => getPersistedVolume().muted);
const [sessionState, setSessionState] = useState<AudiobookSessionState>({
sessionId: null,
streamUrl: "",
@@ -220,6 +213,9 @@ export function useAudiobookPlayback({
);
const playAfterSourceSwitchRef = useRef(false);
const autoPlayPendingRef = useRef(autoPlay);
// Wall-clock time playback last paused, for smart rewind on resume. Cleared
// by explicit seeks so a hand-picked position is never second-guessed.
const pausedAtRef = useRef<number | null>(null);
const setAbsoluteTime = useCallback(
(seconds: number) => {
@@ -418,6 +414,7 @@ export function useAudiobookPlayback({
reportSessionRef.current(absoluteFromAudio(), false);
};
const onPause = () => {
pausedAtRef.current = performance.now();
setPlaying(false);
reportRef.current(absoluteFromAudio());
};
@@ -486,6 +483,15 @@ export function useAudiobookPlayback({
return () => window.clearInterval(id);
}, [playing]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = volume;
audio.muted = muted;
// sessionState.streamUrl keeps this in the deps so a freshly mounted
// element picks the persisted values up before playback starts.
}, [muted, sessionState.streamUrl, volume]);
useEffect(() => {
const audio = audioRef.current;
return () => {
@@ -496,18 +502,9 @@ export function useAudiobookPlayback({
};
}, []);
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) => {
pausedAtRef.current = null;
const target = clampedBookTime(seconds, duration);
const nextIndex = findPartIndex(parts, target);
const nextPart = parts[nextIndex];
@@ -531,6 +528,38 @@ export function useAudiobookPlayback({
[activeFileIndex, duration, parts, playing],
);
const resumePlayback = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
const pausedAtMs = pausedAtRef.current;
pausedAtRef.current = null;
if (smartRewindEnabled && pausedAtMs != null) {
const rewind = smartRewindSeconds(performance.now() - pausedAtMs);
if (rewind > 0) {
const target = clampedBookTime(currentTimeRef.current - rewind, duration);
const targetIndex = findPartIndex(parts, target);
seekTo(target);
if (targetIndex !== activeFileIndex) {
// The rewind crossed a file boundary; playback resumes once the new
// source is ready instead of racing the source switch.
playAfterSourceSwitchRef.current = true;
return;
}
}
}
audio.play().catch((err) => console.error("audiobook play failed", err));
}, [activeFileIndex, duration, parts, seekTo, smartRewindEnabled]);
const togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
resumePlayback();
} else {
audio.pause();
}
}, [resumePlayback]);
const skip = useCallback(
(delta: number) => {
seekTo(currentTimeRef.current + delta);
@@ -538,10 +567,32 @@ export function useAudiobookPlayback({
[seekTo],
);
const setRate = useCallback((nextRate: number) => {
setRateState(nextRate);
if (audioRef.current) audioRef.current.playbackRate = nextRate;
}, []);
const setRate = useCallback(
(nextRate: number) => {
const clamped = clampAudiobookRate(nextRate);
setRateState(clamped);
setBookRate(contentId, clamped);
if (audioRef.current) audioRef.current.playbackRate = clamped;
},
[contentId],
);
const setVolume = useCallback(
(next: number) => {
const clamped = Math.min(1, Math.max(0, Number.isFinite(next) ? next : 1));
setVolumeState(clamped);
persistVolume(clamped, muted);
},
[muted],
);
const setMuted = useCallback(
(next: boolean) => {
setMutedState(next);
persistVolume(volume, next);
},
[volume],
);
const executeRealtimeCommand = useCallback(
async (command: PlaybackRealtimeCommandEnvelope) => {
@@ -553,12 +604,12 @@ export function useAudiobookPlayback({
return;
case "unpause":
if (!audio) return;
await audio.play();
resumePlayback();
return;
case "play_pause":
if (!audio) return;
if (audio.paused) {
await audio.play();
resumePlayback();
} else {
audio.pause();
}
@@ -577,13 +628,13 @@ export function useAudiobookPlayback({
return;
}
case "set_volume": {
const volume = readNumericPayload(command.payload, "volume", "level");
if (volume === null || !audio) {
const nextVolume = readNumericPayload(command.payload, "volume", "level");
if (nextVolume === null || !audio) {
throw new Error("missing_volume");
}
audio.volume = Math.min(1, Math.max(0, volume));
if (audio.volume > 0 && audio.muted) {
audio.muted = false;
setVolume(nextVolume);
if (nextVolume > 0) {
setMuted(false);
}
return;
}
@@ -614,7 +665,7 @@ export function useAudiobookPlayback({
throw new Error("unsupported");
}
},
[onStopRequested, seekTo],
[onStopRequested, resumePlayback, seekTo, setMuted, setVolume],
);
usePlaybackRealtime({
@@ -633,6 +684,16 @@ export function useAudiobookPlayback({
return chapters[0] ?? null;
}, [chapters, currentTime]);
const nextChapter = useCallback(() => {
const target = nextChapterStart(chapters, currentChapter);
if (target != null) seekTo(target);
}, [chapters, currentChapter, seekTo]);
const prevChapter = useCallback(() => {
const target = prevChapterStart(chapters, currentChapter, currentTimeRef.current);
if (target != null) seekTo(target);
}, [chapters, currentChapter, seekTo]);
const [sleepSetting, setSleepSetting] = useState<SleepSetting>({ kind: "off" });
const [sleepTargetMs, setSleepTargetMs] = useState<number | null>(null);
const [sleepChapterEndSeconds, setSleepChapterEndSeconds] = useState<number | null>(null);
@@ -697,10 +758,16 @@ export function useAudiobookPlayback({
rate,
chapters,
currentChapter,
volume,
muted,
togglePlay,
seekTo,
skip,
setRate,
setVolume,
setMuted,
nextChapter,
prevChapter,
sleep: { setting: sleepSetting, remainingMs: sleepRemainingMs },
setSleep,
};
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { storage } from "@/utils/storage";
import {
clampAudiobookRate,
getAudiobookSkipBack,
getAudiobookSkipForward,
getAudiobookSmartRewind,
getBookRate,
setBookRate,
} from "./useAudiobookPrefs";
beforeEach(() => {
// The vitest jsdom environment ships a non-functional localStorage; install
// an in-memory one per test, matching the convention in api/client.test.ts.
const state = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
value: {
get length() {
return state.size;
},
getItem: (key: string) => state.get(key) ?? null,
key: (index: number) => Array.from(state.keys())[index] ?? null,
setItem: (key: string, value: string) => {
state.set(key, value);
},
removeItem: (key: string) => {
state.delete(key);
},
clear: () => {
state.clear();
},
} satisfies Storage,
configurable: true,
});
});
describe("skip interval prefs", () => {
it("returns asymmetric defaults", () => {
expect(getAudiobookSkipBack()).toBe(10);
expect(getAudiobookSkipForward()).toBe(30);
});
it("reads persisted values", () => {
storage.set(storage.KEYS.AUDIOBOOK_SKIP_BACK, "15");
storage.set(storage.KEYS.AUDIOBOOK_SKIP_FORWARD, "60");
expect(getAudiobookSkipBack()).toBe(15);
expect(getAudiobookSkipForward()).toBe(60);
});
it("falls back to defaults for values outside the allowed set", () => {
storage.set(storage.KEYS.AUDIOBOOK_SKIP_BACK, "7");
storage.set(storage.KEYS.AUDIOBOOK_SKIP_FORWARD, "garbage");
expect(getAudiobookSkipBack()).toBe(10);
expect(getAudiobookSkipForward()).toBe(30);
});
});
describe("smart rewind pref", () => {
it("defaults to enabled", () => {
expect(getAudiobookSmartRewind()).toBe(true);
});
it("only an explicit false disables it", () => {
storage.set(storage.KEYS.AUDIOBOOK_SMART_REWIND, "false");
expect(getAudiobookSmartRewind()).toBe(false);
storage.set(storage.KEYS.AUDIOBOOK_SMART_REWIND, "true");
expect(getAudiobookSmartRewind()).toBe(true);
});
});
describe("clampAudiobookRate", () => {
it("clamps to the supported range", () => {
expect(clampAudiobookRate(0.1)).toBe(0.5);
expect(clampAudiobookRate(9)).toBe(3);
});
it("snaps to the 0.05 grid without float drift", () => {
expect(clampAudiobookRate(1.2500000000003)).toBe(1.25);
expect(clampAudiobookRate(1.23)).toBe(1.25);
});
it("treats invalid input as 1x", () => {
expect(clampAudiobookRate(NaN)).toBe(1);
});
});
describe("per-book rate memory", () => {
it("round-trips a rate per book", () => {
expect(getBookRate("book-a")).toBeNull();
setBookRate("book-a", 1.5);
setBookRate("book-b", 2);
expect(getBookRate("book-a")).toBe(1.5);
expect(getBookRate("book-b")).toBe(2);
});
it("evicts the oldest entries beyond the cap", () => {
const now = vi.spyOn(Date, "now");
for (let i = 0; i < 55; i++) {
// Distinct timestamps make eviction order deterministic.
now.mockReturnValue(1_000_000 + i);
setBookRate(`book-${i}`, 1.25);
}
now.mockRestore();
expect(getBookRate("book-0")).toBeNull();
expect(getBookRate("book-4")).toBeNull();
expect(getBookRate("book-5")).toBe(1.25);
expect(getBookRate("book-54")).toBe(1.25);
});
it("tolerates malformed stored JSON", () => {
storage.set(storage.KEYS.AUDIOBOOK_RATES, "{not json");
expect(getBookRate("book-a")).toBeNull();
setBookRate("book-a", 1.75);
expect(getBookRate("book-a")).toBe(1.75);
});
});
@@ -0,0 +1,129 @@
import { useCallback, useState } from "react";
import { storage } from "@/utils/storage";
export const SKIP_INTERVAL_CHOICES = [5, 10, 15, 30, 45, 60, 90] as const;
export const DEFAULT_SKIP_BACK_SECONDS = 10;
export const DEFAULT_SKIP_FORWARD_SECONDS = 30;
export const AUDIOBOOK_RATE_MIN = 0.5;
export const AUDIOBOOK_RATE_MAX = 3;
export const AUDIOBOOK_RATE_STEP = 0.05;
export const AUDIOBOOK_RATE_PRESETS = [1, 1.25, 1.5, 1.75, 2, 2.5, 3] as const;
export function clampAudiobookRate(rate: number): number {
if (!Number.isFinite(rate)) return 1;
const clamped = Math.min(AUDIOBOOK_RATE_MAX, Math.max(AUDIOBOOK_RATE_MIN, rate));
// Snap to the step grid so repeated +/- stepping never accumulates float drift.
return Number((Math.round(clamped / AUDIOBOOK_RATE_STEP) * AUDIOBOOK_RATE_STEP).toFixed(2));
}
type SkipKey = typeof storage.KEYS.AUDIOBOOK_SKIP_BACK | typeof storage.KEYS.AUDIOBOOK_SKIP_FORWARD;
function readSkipSeconds(key: SkipKey, fallback: number): number {
const raw = storage.get(key);
const value = raw == null ? NaN : Number(raw);
return (SKIP_INTERVAL_CHOICES as readonly number[]).includes(value) ? value : fallback;
}
export function getAudiobookSkipBack(): number {
return readSkipSeconds(storage.KEYS.AUDIOBOOK_SKIP_BACK, DEFAULT_SKIP_BACK_SECONDS);
}
export function getAudiobookSkipForward(): number {
return readSkipSeconds(storage.KEYS.AUDIOBOOK_SKIP_FORWARD, DEFAULT_SKIP_FORWARD_SECONDS);
}
/** Smart rewind defaults to on; only an explicit "false" disables it. */
export function getAudiobookSmartRewind(): boolean {
return storage.get(storage.KEYS.AUDIOBOOK_SMART_REWIND) !== "false";
}
export interface AudiobookPrefs {
skipBack: number;
skipForward: number;
smartRewind: boolean;
setSkipBack: (seconds: number) => void;
setSkipForward: (seconds: number) => void;
setSmartRewind: (enabled: boolean) => void;
}
/**
* Device-local audiobook player preferences. Instantiate once per player
* (in AudiobookPlayer) and pass down — multiple instances do not observe each
* other's changes within a render lifetime.
*/
export function useAudiobookPrefs(): AudiobookPrefs {
const [skipBack, setSkipBackState] = useState(getAudiobookSkipBack);
const [skipForward, setSkipForwardState] = useState(getAudiobookSkipForward);
const [smartRewind, setSmartRewindState] = useState(getAudiobookSmartRewind);
const setSkipBack = useCallback((seconds: number) => {
setSkipBackState(seconds);
storage.set(storage.KEYS.AUDIOBOOK_SKIP_BACK, String(seconds));
}, []);
const setSkipForward = useCallback((seconds: number) => {
setSkipForwardState(seconds);
storage.set(storage.KEYS.AUDIOBOOK_SKIP_FORWARD, String(seconds));
}, []);
const setSmartRewind = useCallback((enabled: boolean) => {
setSmartRewindState(enabled);
storage.set(storage.KEYS.AUDIOBOOK_SMART_REWIND, String(enabled));
}, []);
return { skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind };
}
// --- Per-book playback rate memory -----------------------------------------
const MAX_REMEMBERED_BOOK_RATES = 50;
interface StoredBookRate {
rate: number;
at: number;
}
function readBookRates(): Record<string, StoredBookRate> {
const raw = storage.get(storage.KEYS.AUDIOBOOK_RATES);
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
const out: Record<string, StoredBookRate> = {};
for (const [key, value] of Object.entries(parsed)) {
const entry = value as Partial<StoredBookRate> | null;
if (
entry &&
typeof entry.rate === "number" &&
Number.isFinite(entry.rate) &&
typeof entry.at === "number"
) {
out[key] = { rate: entry.rate, at: entry.at };
}
}
return out;
} catch {
return {};
}
}
/** Last playback rate used for this book, or null if never adjusted. */
export function getBookRate(contentId: string): number | null {
const entry = readBookRates()[contentId];
return entry ? clampAudiobookRate(entry.rate) : null;
}
/** Remember the playback rate for this book (LRU-capped). */
export function setBookRate(contentId: string, rate: number): void {
const rates = readBookRates();
rates[contentId] = { rate: clampAudiobookRate(rate), at: Date.now() };
const entries = Object.entries(rates);
if (entries.length > MAX_REMEMBERED_BOOK_RATES) {
entries
.sort((a, b) => a[1].at - b[1].at)
.slice(0, entries.length - MAX_REMEMBERED_BOOK_RATES)
.forEach(([key]) => delete rates[key]);
}
storage.set(storage.KEYS.AUDIOBOOK_RATES, JSON.stringify(rates));
}
+6 -1
View File
@@ -5,6 +5,8 @@ export type CircleButtonProps = {
onClick?: () => void;
disabled?: boolean;
children: React.ReactNode;
className?: string;
title?: string;
"data-paused"?: boolean;
};
@@ -22,6 +24,8 @@ export function CircleButton({
onClick,
disabled,
children,
className,
title,
"data-paused": dataPaused,
}: CircleButtonProps) {
const base =
@@ -37,9 +41,10 @@ export function CircleButton({
<button
type="button"
aria-label={ariaLabel}
title={title}
onClick={onClick}
disabled={disabled}
className={`${base} ${sizing} ${skin}`}
className={`${base} ${sizing} ${skin}${className ? ` ${className}` : ""}`}
data-paused={dataPaused ? "true" : undefined}
>
{children}
+26 -5
View File
@@ -23,6 +23,12 @@ interface VolumeControlProps {
muted: boolean;
onVolumeChange: (volume: number) => void;
onMutedChange: (muted: boolean) => void;
/**
* "overlay" (default) is the white-on-dark video HUD styling; "surface"
* uses theme tokens for placement on regular app surfaces like the
* audiobook mini bar.
*/
tone?: "overlay" | "surface";
}
export function VolumeControl({
@@ -30,6 +36,7 @@ export function VolumeControl({
muted,
onVolumeChange,
onMutedChange,
tone = "overlay",
}: VolumeControlProps) {
const sliderRef = useRef<HTMLDivElement>(null);
@@ -133,6 +140,17 @@ export function VolumeControl({
[volume, muted, onVolumeChange, onMutedChange],
);
const surface = tone === "surface";
const buttonClass = surface
? "text-muted-foreground hover:text-foreground hover:bg-muted inline-flex h-9 w-9 items-center justify-center rounded-full transition-colors"
: "player-utility-btn";
const sliderFocusClass = surface ? "focus-visible:ring-ring" : "focus-visible:ring-white/70";
const trackClass = surface ? "bg-muted" : "bg-white/15";
const fillClass = surface
? "bg-foreground"
: "bg-white shadow-[0_0_8px_-1px_rgb(255_255_255/0.35)]";
const thumbClass = surface ? "bg-foreground ring-border" : "bg-white ring-black/10";
return (
// Scroll-wheel still adjusts volume from anywhere in the group, but the
// mute button and slider are now fully independent items — nothing shifts
@@ -140,9 +158,10 @@ export function VolumeControl({
<div className="group/vol flex items-center gap-2" onWheel={handleWheel}>
<button
type="button"
className="player-utility-btn"
className={buttonClass}
onClick={() => onMutedChange(!muted)}
aria-label={muted ? "Unmute" : "Mute"}
title={muted ? "Unmute (M)" : "Mute (M)"}
data-active={muted || volume === 0 ? "false" : undefined}
>
{muted || volume === 0 ? (
@@ -164,18 +183,20 @@ export function VolumeControl({
aria-valuemax={100}
aria-valuenow={Math.round(displayVolume * 100)}
aria-valuetext={`Volume ${Math.round(displayVolume * 100)}%`}
className="group/vol-slider relative flex h-6 w-24 cursor-pointer touch-none items-center rounded-full focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none"
className={`group/vol-slider relative flex h-6 w-24 cursor-pointer touch-none items-center rounded-full focus-visible:ring-2 focus-visible:outline-none ${sliderFocusClass}`}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onKeyDown={handleKeyDown}
>
<div className="relative h-[3px] w-full rounded-full bg-white/15 transition-[height] duration-150 ease-out group-hover/vol-slider:h-[5px]">
<div
className={`relative h-[3px] w-full rounded-full transition-[height] duration-150 ease-out group-hover/vol-slider:h-[5px] ${trackClass}`}
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-white shadow-[0_0_8px_-1px_rgb(255_255_255/0.35)]"
className={`absolute inset-y-0 left-0 rounded-full ${fillClass}`}
style={{ width: `${displayVolume * 100}%` }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-[0_2px_8px_rgb(0_0_0/0.5)] ring-1 ring-black/10 transition-opacity duration-150 group-hover/vol-slider:opacity-100"
className={`absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-0 shadow-[0_2px_8px_rgb(0_0_0/0.5)] ring-1 transition-opacity duration-150 group-hover/vol-slider:opacity-100 ${thumbClass}`}
style={{ left: `${displayVolume * 100}%` }}
/>
</div>
+4
View File
@@ -7,6 +7,10 @@ const STORAGE_KEYS = {
DEVICE_ID: "silo-device-id",
VOLUME: "player-volume",
MUTED: "player-muted",
AUDIOBOOK_SKIP_BACK: "audiobook-skip-back",
AUDIOBOOK_SKIP_FORWARD: "audiobook-skip-forward",
AUDIOBOOK_SMART_REWIND: "audiobook-smart-rewind",
AUDIOBOOK_RATES: "audiobook-rates",
THEME: "silo-theme",
UI_TEXT_SCALE: "silo-ui-text-scale",
UI_TEXT_WEIGHT: "silo-ui-text-weight",