diff --git a/web/src/pages/audiobooks/player/MiniBar.test.tsx b/web/src/pages/audiobooks/player/MiniBar.test.tsx index 5f5b643c..0502afd3 100644 --- a/web/src/pages/audiobooks/player/MiniBar.test.tsx +++ b/web/src/pages/audiobooks/player/MiniBar.test.tsx @@ -20,6 +20,8 @@ function makePlayback(over: Partial = {}): AudiobookPlayback seekTo: vi.fn(), skip: vi.fn(), setRate: vi.fn(), + sleep: { setting: { kind: "off" as const }, remainingMs: null }, + setSleep: vi.fn(), ...over, }; } diff --git a/web/src/pages/audiobooks/player/MiniBar.tsx b/web/src/pages/audiobooks/player/MiniBar.tsx index 69681bf5..8e57364b 100644 --- a/web/src/pages/audiobooks/player/MiniBar.tsx +++ b/web/src/pages/audiobooks/player/MiniBar.tsx @@ -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({
+ {playback.chapters.length > 0 && ( = {}): AudiobookPlayback seekTo: vi.fn(), skip: vi.fn(), setRate: vi.fn(), + sleep: { setting: { kind: "off" as const }, remainingMs: null }, + setSleep: vi.fn(), ...over, }; } diff --git a/web/src/pages/audiobooks/player/NowListening.tsx b/web/src/pages/audiobooks/player/NowListening.tsx index 3fe66429..f4020fc9 100644 --- a/web/src/pages/audiobooks/player/NowListening.tsx +++ b/web/src/pages/audiobooks/player/NowListening.tsx @@ -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({
+ {playback.chapters.length > 0 && ( { ); 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).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(); + }); }); diff --git a/web/src/pages/audiobooks/player/useAudiobookPlayback.ts b/web/src/pages/audiobooks/player/useAudiobookPlayback.ts index 4503e3ba..5ce065c6 100644 --- a/web/src/pages/audiobooks/player/useAudiobookPlayback.ts +++ b/web/src/pages/audiobooks/player/useAudiobookPlayback.ts @@ -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({ kind: "off" }); + const [sleepTargetMs, setSleepTargetMs] = useState(null); + const [sleepNowMs, setSleepNowMs] = useState(() => 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, }; } diff --git a/web/src/player/components/SleepTimerMenu.test.tsx b/web/src/player/components/SleepTimerMenu.test.tsx new file mode 100644 index 00000000..7ffdaef1 --- /dev/null +++ b/web/src/player/components/SleepTimerMenu.test.tsx @@ -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( + {}} />, + ); + expect(screen.getByRole("button", { name: /sleep timer/i })).toHaveTextContent("Sleep"); + rerender( + {}} + />, + ); + 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(); + 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(); + 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" }); + }); +}); diff --git a/web/src/player/components/SleepTimerMenu.tsx b/web/src/player/components/SleepTimerMenu.tsx new file mode 100644 index 00000000..132a20ea --- /dev/null +++ b/web/src/player/components/SleepTimerMenu.tsx @@ -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(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 ( +
+ + + {open && ( +
+ {armed && ( + + )} + {PRESETS.map((p, i) => ( + + ))} + +
+ )} +
+ ); +}