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:
RXWatcher
2026-05-25 01:38:35 +02:00
co-authored by Claude Opus 4.7
parent 7529c8e37a
commit c71a0a88a2
8 changed files with 239 additions and 0 deletions
@@ -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>
);
}