feat(audiobooks): add sleep timer with duration + end-of-chapter modes
SleepTimerMenu is a new shared player primitive (moon icon, popover with 5/15/30/45/60-minute presets plus End-of-chapter). Hook state in useAudiobookPlayback drives both the countdown label and the auto-pause: duration timers track wall-clock so they survive tab inactivity, and end-of-chapter watches currentChapter.end_seconds. Both modes pause the audio element and reset the setting when they fire. Wired into the right rail of MiniBar and into the utility row of NowListening. 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
7529c8e37a
commit
c71a0a88a2
@@ -20,6 +20,8 @@ function makePlayback(over: Partial<AudiobookPlayback> = {}): AudiobookPlayback
|
||||
seekTo: vi.fn(),
|
||||
skip: vi.fn(),
|
||||
setRate: vi.fn(),
|
||||
sleep: { setting: { kind: "off" as const }, remainingMs: null },
|
||||
setSleep: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { CoverExpandTile } from "./CoverExpandTile";
|
||||
import type { AudiobookPlayback } from "./useAudiobookPlayback";
|
||||
|
||||
@@ -113,6 +114,11 @@ export function MiniBar({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<SleepTimerMenu
|
||||
setting={playback.sleep.setting}
|
||||
remainingMs={playback.sleep.remainingMs}
|
||||
onChange={playback.setSleep}
|
||||
/>
|
||||
{playback.chapters.length > 0 && (
|
||||
<ChaptersMenu
|
||||
chapters={playback.chapters}
|
||||
|
||||
@@ -20,6 +20,8 @@ function makePlayback(over: Partial<AudiobookPlayback> = {}): AudiobookPlayback
|
||||
seekTo: vi.fn(),
|
||||
skip: vi.fn(),
|
||||
setRate: vi.fn(),
|
||||
sleep: { setting: { kind: "off" as const }, remainingMs: null },
|
||||
setSleep: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 type { AudiobookPlayback } from "./useAudiobookPlayback";
|
||||
|
||||
const SKIP_BACK_SECONDS = 30;
|
||||
@@ -148,6 +149,11 @@ export function NowListening({
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-6 text-sm">
|
||||
<SleepTimerMenu
|
||||
setting={playback.sleep.setting}
|
||||
remainingMs={playback.sleep.remainingMs}
|
||||
onChange={playback.setSleep}
|
||||
/>
|
||||
{playback.chapters.length > 0 && (
|
||||
<ChaptersMenu
|
||||
chapters={playback.chapters}
|
||||
|
||||
@@ -87,4 +87,31 @@ describe("useAudiobookPlayback", () => {
|
||||
);
|
||||
expect(result.current.currentChapter?.title).toBe("One");
|
||||
});
|
||||
|
||||
it("setSleep arms a duration timer that fires after the configured seconds", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
const audio = makeAudio();
|
||||
Object.defineProperty(audio, "paused", { value: false, writable: true });
|
||||
act(() => {
|
||||
(result.current.audioRef as React.MutableRefObject<HTMLAudioElement>).current = audio;
|
||||
});
|
||||
act(() => result.current.setSleep({ kind: "duration", seconds: 1 }));
|
||||
expect(result.current.sleep.remainingMs).toBeGreaterThan(0);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(audio.pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setSleep with off clears any armed timer", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAudiobookPlayback({ contentId: "c", files, initialPositionSeconds: 0 }),
|
||||
);
|
||||
act(() => result.current.setSleep({ kind: "duration", seconds: 5 }));
|
||||
expect(result.current.sleep.remainingMs).toBeGreaterThan(0);
|
||||
act(() => result.current.setSleep({ kind: "off" }));
|
||||
expect(result.current.sleep.remainingMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { buildDirectDownloadUrl } from "@/hooks/queries/downloads";
|
||||
import { useReportAudiobookProgress } from "@/hooks/audiobooks/useReportAudiobookProgress";
|
||||
import type { AudiobookFile } from "@/lib/audiobooks/types";
|
||||
import type { PlayerChapter } from "@/player/types";
|
||||
import type { SleepSetting } from "@/player/components/SleepTimerMenu";
|
||||
|
||||
const REPORT_INTERVAL_MS = 10_000;
|
||||
|
||||
@@ -28,6 +29,8 @@ export interface AudiobookPlayback {
|
||||
seekTo: (seconds: number) => void;
|
||||
skip: (delta: number) => void;
|
||||
setRate: (r: number) => void;
|
||||
sleep: { setting: SleepSetting; remainingMs: number | null };
|
||||
setSleep: (next: SleepSetting) => void;
|
||||
}
|
||||
|
||||
function safeNumber(value: number): number {
|
||||
@@ -210,6 +213,44 @@ export function useAudiobookPlayback({
|
||||
);
|
||||
}, [chapters, currentTime]);
|
||||
|
||||
const [sleepSetting, setSleepSetting] = useState<SleepSetting>({ kind: "off" });
|
||||
const [sleepTargetMs, setSleepTargetMs] = useState<number | null>(null);
|
||||
const [sleepNowMs, setSleepNowMs] = useState<number>(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepSetting.kind !== "duration") {
|
||||
setSleepTargetMs(null);
|
||||
return;
|
||||
}
|
||||
setSleepTargetMs(Date.now() + sleepSetting.seconds * 1000);
|
||||
}, [sleepSetting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepTargetMs == null) return;
|
||||
const id = window.setInterval(() => setSleepNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [sleepTargetMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepTargetMs == null) return;
|
||||
if (sleepNowMs < sleepTargetMs) return;
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) audio.pause();
|
||||
setSleepSetting({ kind: "off" });
|
||||
setSleepTargetMs(null);
|
||||
}, [sleepNowMs, sleepTargetMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sleepSetting.kind !== "end-of-chapter" || !currentChapter) return;
|
||||
if (currentTime < currentChapter.end_seconds) return;
|
||||
const audio = audioRef.current;
|
||||
if (audio && !audio.paused) audio.pause();
|
||||
setSleepSetting({ kind: "off" });
|
||||
}, [sleepSetting, currentChapter, currentTime]);
|
||||
|
||||
const setSleep = useCallback((next: SleepSetting) => setSleepSetting(next), []);
|
||||
const sleepRemainingMs = sleepTargetMs == null ? null : Math.max(0, sleepTargetMs - sleepNowMs);
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
streamUrl,
|
||||
@@ -225,5 +266,7 @@ export function useAudiobookPlayback({
|
||||
seekTo,
|
||||
skip,
|
||||
setRate,
|
||||
sleep: { setting: sleepSetting, remainingMs: sleepRemainingMs },
|
||||
setSleep,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SleepTimerMenu, type SleepSetting } from "./SleepTimerMenu";
|
||||
|
||||
describe("SleepTimerMenu", () => {
|
||||
it("shows 'Sleep' when off and the countdown when armed", () => {
|
||||
const { rerender } = render(
|
||||
<SleepTimerMenu setting={{ kind: "off" }} remainingMs={null} onChange={() => {}} />,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /sleep timer/i })).toHaveTextContent("Sleep");
|
||||
rerender(
|
||||
<SleepTimerMenu
|
||||
setting={{ kind: "duration", seconds: 300 }}
|
||||
remainingMs={272_000}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /sleep timer/i })).toHaveTextContent("Sleep 4:32");
|
||||
});
|
||||
|
||||
it("emits a duration setting when a preset is chosen", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SleepTimerMenu setting={{ kind: "off" }} remainingMs={null} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /sleep timer/i }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "15 min" }));
|
||||
expect(onChange).toHaveBeenCalledWith({ kind: "duration", seconds: 900 } as SleepSetting);
|
||||
});
|
||||
|
||||
it("emits end-of-chapter when that option is chosen", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<SleepTimerMenu setting={{ kind: "off" }} remainingMs={null} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /sleep timer/i }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /end of chapter/i }));
|
||||
expect(onChange).toHaveBeenCalledWith({ kind: "end-of-chapter" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
export type SleepSetting =
|
||||
| { kind: "off" }
|
||||
| { kind: "duration"; seconds: number }
|
||||
| { kind: "end-of-chapter" };
|
||||
|
||||
interface SleepTimerMenuProps {
|
||||
setting: SleepSetting;
|
||||
remainingMs: number | null;
|
||||
onChange: (next: SleepSetting) => void;
|
||||
}
|
||||
|
||||
const PRESETS: { label: string; seconds: number }[] = [
|
||||
{ label: "5 min", seconds: 300 },
|
||||
{ label: "15 min", seconds: 900 },
|
||||
{ label: "30 min", seconds: 1800 },
|
||||
{ label: "45 min", seconds: 2700 },
|
||||
{ label: "60 min", seconds: 3600 },
|
||||
];
|
||||
|
||||
function formatCountdown(ms: number): string {
|
||||
const total = Math.max(0, Math.ceil(ms / 1000));
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SleepTimerMenu({ setting, remainingMs, onChange }: SleepTimerMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const itemsRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const armed = setting.kind !== "off";
|
||||
|
||||
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 label = armed && remainingMs != null ? `Sleep ${formatCountdown(remainingMs)}` : "Sleep";
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative" onBlur={handleBlur}>
|
||||
<button
|
||||
type="button"
|
||||
className="player-utility-btn flex items-center gap-1.5 px-2 text-xs"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Sleep timer"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Moon className="h-3.5 w-3.5" />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 bottom-full mb-2 flex min-w-[160px] flex-col overflow-hidden rounded-lg bg-black/90 py-1.5 shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
{armed && (
|
||||
<button
|
||||
ref={(el) => {
|
||||
itemsRef.current[0] = el;
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm text-white/85 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
onChange({ kind: "off" });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Turn off
|
||||
</button>
|
||||
)}
|
||||
{PRESETS.map((p, i) => (
|
||||
<button
|
||||
key={p.seconds}
|
||||
ref={(el) => {
|
||||
itemsRef.current[(armed ? 1 : 0) + i] = el;
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm text-white/85 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
onChange({ kind: "duration", seconds: p.seconds });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
role="menuitem"
|
||||
type="button"
|
||||
className="w-full px-4 py-2 text-left text-sm text-white/85 hover:bg-white/10"
|
||||
onClick={() => {
|
||||
onChange({ kind: "end-of-chapter" });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
End of chapter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user