feat(player): SpeedMenu primitive replaces audiobook select

Introduces a shared SpeedMenu component (button trigger + popover with
arrow-key navigation and escape-to-close) used by the audiobook player.
Replaces the previous native <select> with a menu that matches the
in-design dark surface treatment and the planned shared player chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-25 01:21:40 +02:00
co-authored by Claude Opus 4.7
parent 63e00a498b
commit 025fdba565
3 changed files with 133 additions and 19 deletions
+9 -19
View File
@@ -5,6 +5,7 @@ import { useReportAudiobookProgress } from "@/hooks/audiobooks/useReportAudioboo
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { SeekBar, formatTime } from "@/player/components/SeekBar";
import { ChaptersMenu } from "@/player/components/ChaptersMenu";
import { SpeedMenu } from "@/player/components/SpeedMenu";
import type { PlayerChapter } from "@/player/types";
export interface AudiobookPlayerProps {
@@ -200,12 +201,6 @@ export default function AudiobookPlayer({
seekTo(audio.currentTime + delta);
}
function handleRateChange(e: React.ChangeEvent<HTMLSelectElement>) {
const r = Number(e.target.value);
setRate(r);
if (audioRef.current) audioRef.current.playbackRate = r;
}
const canPlay = Boolean(file);
return (
@@ -239,7 +234,7 @@ export default function AudiobookPlayer({
{title}
</div>
) : null}
<div className="text-muted-foreground flex items-center gap-2 text-[10px] uppercase leading-tight">
<div className="text-muted-foreground flex items-center gap-2 text-[10px] leading-tight uppercase">
<span className="font-mono text-[11px] tracking-[0.12em] normal-case tabular-nums">
{formatTime(currentTime)}
<span className="mx-1 opacity-50">/</span>
@@ -285,19 +280,14 @@ export default function AudiobookPlayer({
{chapters.length > 0 && (
<ChaptersMenu chapters={chapters} currentTime={currentTime} onSeek={seekTo} />
)}
<select
<SpeedMenu
rates={PLAYBACK_RATES}
value={rate}
onChange={handleRateChange}
aria-label="Playback speed"
className="bg-background text-muted-foreground hover:text-foreground focus:ring-ring shrink-0 rounded border-0 px-1.5 py-1 text-xs outline-none focus:ring-1"
disabled={!canPlay}
>
{PLAYBACK_RATES.map((r) => (
<option key={r} value={r}>
{r}×
</option>
))}
</select>
onChange={(r) => {
setRate(r);
if (audioRef.current) audioRef.current.playbackRate = r;
}}
/>
{onClose && (
<button
type="button"
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SpeedMenu } from "./SpeedMenu";
const RATES = [0.75, 1, 1.25, 1.5, 2] as const;
describe("SpeedMenu", () => {
it("shows the current rate label on the trigger", () => {
render(<SpeedMenu rates={RATES} value={1.5} onChange={() => {}} />);
expect(screen.getByRole("button", { name: /playback speed/i })).toHaveTextContent("1.5×");
});
it("opens, lists all rates, and emits the chosen value", async () => {
const onChange = vi.fn();
render(<SpeedMenu rates={RATES} value={1} onChange={onChange} />);
await userEvent.click(screen.getByRole("button", { name: /playback speed/i }));
expect(screen.getAllByRole("menuitem")).toHaveLength(RATES.length);
await userEvent.click(screen.getByRole("menuitem", { name: "1.25×" }));
expect(onChange).toHaveBeenCalledWith(1.25);
});
it("marks the current rate as active", async () => {
render(<SpeedMenu rates={RATES} value={1.5} onChange={() => {}} />);
await userEvent.click(screen.getByRole("button", { name: /playback speed/i }));
const active = screen.getByRole("menuitem", { name: "1.5×" });
expect(active).toHaveAttribute("data-active", "true");
});
});
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface SpeedMenuProps {
rates: readonly number[];
value: number;
onChange: (rate: number) => void;
}
export function SpeedMenu({ rates, value, onChange }: SpeedMenuProps) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const menuItemsRef = useRef<(HTMLButtonElement | null)[]>([]);
const handleBlur = useCallback((e: React.FocusEvent) => {
if (!menuRef.current?.contains(e.relatedTarget as Node)) {
setOpen(false);
}
}, []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const handleMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
const items = menuItemsRef.current.filter(Boolean) as HTMLButtonElement[];
if (items.length === 0) return;
const i = items.indexOf(document.activeElement as HTMLButtonElement);
let next: number | null = null;
switch (e.key) {
case "ArrowDown":
next = i < items.length - 1 ? i + 1 : 0;
break;
case "ArrowUp":
next = i > 0 ? i - 1 : items.length - 1;
break;
case "Home":
next = 0;
break;
case "End":
next = items.length - 1;
break;
default:
return;
}
e.preventDefault();
items[next]?.focus();
}, []);
return (
<div ref={menuRef} className="relative" onBlur={handleBlur}>
<button
type="button"
className="player-utility-btn px-2 text-xs tabular-nums"
onClick={() => setOpen((v) => !v)}
aria-label="Playback speed"
aria-expanded={open}
aria-haspopup="menu"
>
{value}×
</button>
{open && (
<div
role="menu"
className="absolute right-0 bottom-full mb-2 flex min-w-[100px] flex-col overflow-hidden rounded-lg bg-black/90 py-1.5 shadow-xl backdrop-blur-sm"
onKeyDown={handleMenuKeyDown}
>
{rates.map((r, idx) => (
<button
key={r}
ref={(el) => {
menuItemsRef.current[idx] = el;
}}
role="menuitem"
type="button"
data-active={r === value ? "true" : undefined}
className={`w-full px-4 py-2 text-right text-sm tabular-nums transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:outline-none ${
r === value ? "bg-white/5 text-white" : "text-white/75"
}`}
onClick={() => {
onChange(r);
setOpen(false);
}}
>
{r}×
</button>
))}
</div>
)}
</div>
);
}