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:
committed by
GitHub
co-authored by
Claude Fable 5
parent
35a0db7d29
commit
54b20f57d7
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user