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:
co-authored by
Claude Opus 4.7
parent
63e00a498b
commit
025fdba565
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user