diff --git a/apps/timo-web/app/[locale]/(main)/_containers/MainShellContainer.tsx b/apps/timo-web/app/[locale]/(main)/_containers/MainShellContainer.tsx index e184bae4..d28168cb 100644 --- a/apps/timo-web/app/[locale]/(main)/_containers/MainShellContainer.tsx +++ b/apps/timo-web/app/[locale]/(main)/_containers/MainShellContainer.tsx @@ -1,12 +1,14 @@ "use client"; import { cn } from "@repo/timo-design-system/utils"; +import { useEffect } from "react"; import { NavigationSidebar } from "@/components/layout/sidebar/navigation/NavigationSidebar"; import { NavigationSidebarProvider, useNavigationSidebar, } from "@/components/layout/sidebar/navigation/NavigationSidebarContext"; +import { unlockTimerSound } from "@/utils/timer/timer-sound"; interface MainShellContainerProps { children: React.ReactNode; @@ -33,6 +35,22 @@ const MainContent = ({ children }: { children: React.ReactNode }) => { }; export const MainShellContainer = ({ children }: MainShellContainerProps) => { + useEffect(() => { + const unlock = (event: Event) => { + if (!event.isTrusted) return; + void unlockTimerSound().then((isUnlocked) => { + if (isUnlocked) removeListeners(); + }); + }; + const removeListeners = () => { + window.removeEventListener("click", unlock, true); + window.removeEventListener("keydown", unlock, true); + }; + window.addEventListener("click", unlock, true); + window.addEventListener("keydown", unlock, true); + return removeListeners; + }, []); + return ( diff --git a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts index f9c6a679..0ec5cacd 100644 --- a/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts +++ b/apps/timo-web/app/[locale]/(main)/focus/_hooks/use-focus-session.ts @@ -24,6 +24,7 @@ import { useTimerActions } from "@/hooks/timer/use-timer-actions"; import { useTimerOvertime } from "@/hooks/timer/use-timer-overtime"; import { useTimerQueryInvalidation } from "@/hooks/timer/use-timer-query-invalidation"; import { getTimerProgress } from "@/utils/timer/get-timer-progress"; +import { playTimerSound } from "@/utils/timer/timer-sound"; export interface UseFocusSessionOptions { onMutationError: () => void; @@ -138,6 +139,7 @@ export const useFocusSession = ({ useEffect(() => { if (isTimeUp && !wasTimeUpRef.current) { timerSessionControlsRef.current?.openEndModal(); + void playTimerSound(); if (timer && isRunning) { changeStatus({ timerId: timer.timerId, data: { action: "PAUSE" } }); } diff --git a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx index 2cdeb265..39958b97 100644 --- a/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx +++ b/apps/timo-web/components/layout/sidebar/time/TimerPanel.tsx @@ -22,6 +22,7 @@ import { formatDateKey } from "@/utils/date/date"; import { convertDurationToTimeText } from "@/utils/duration/convert-duration-to-time-text"; import { formatDurationLabel } from "@/utils/duration/format-duration-label"; import { getTimerProgress } from "@/utils/timer/get-timer-progress"; +import { playTimerSound } from "@/utils/timer/timer-sound"; export const TimerPanel = () => { const [feedbackText, setFeedbackText] = useState(); @@ -87,6 +88,7 @@ export const TimerPanel = () => { useEffect(() => { if (isTimeUp && !wasTimeUpRef.current) { timerSessionControlsRef.current?.openEndModal(); + void playTimerSound(); if (activeTimer && isRunning) { changeStatus({ timerId: activeTimer.timerId, diff --git a/apps/timo-web/components/timer/TimerSessionControls.tsx b/apps/timo-web/components/timer/TimerSessionControls.tsx index a926b854..ccaab769 100644 --- a/apps/timo-web/components/timer/TimerSessionControls.tsx +++ b/apps/timo-web/components/timer/TimerSessionControls.tsx @@ -11,6 +11,7 @@ import { type ExtendTimePreset, } from "@/components/timer/TimerExtendModalPanel"; import { TimerStopModalPanel } from "@/components/timer/TimerStopModalPanel"; +import { playTimerSound } from "@/utils/timer/timer-sound"; type TimerModalStep = "end" | "stop" | "extend" | "complete"; @@ -124,7 +125,10 @@ export const TimerSessionControls = forwardRef< {step === "stop" && ( setStep("complete")} + onSwitch={() => { + void playTimerSound(); + setStep("complete"); + }} /> )} {step === "extend" && ( diff --git a/apps/timo-web/public/sounds/timer-completed.mp3 b/apps/timo-web/public/sounds/timer-completed.mp3 new file mode 100644 index 00000000..c6eb5401 Binary files /dev/null and b/apps/timo-web/public/sounds/timer-completed.mp3 differ diff --git a/apps/timo-web/utils/timer/timer-sound.ts b/apps/timo-web/utils/timer/timer-sound.ts new file mode 100644 index 00000000..c28b983b --- /dev/null +++ b/apps/timo-web/utils/timer/timer-sound.ts @@ -0,0 +1,39 @@ +let audioContext: AudioContext | undefined; +let soundBuffer: Promise | undefined; + +export const unlockTimerSound = async () => { + try { + audioContext ??= new AudioContext(); + if (audioContext.state !== "running") await audioContext.resume(); + return audioContext.state === "running"; + } catch { + return false; + } +}; + +export const playTimerSound = async () => { + try { + audioContext ??= new AudioContext(); + const context = audioContext; + const resume = context.resume(); + soundBuffer ??= fetch("/sounds/timer-completed.mp3") + .then((response) => { + if (!response.ok) throw new Error("Failed to load timer sound"); + return response.arrayBuffer(); + }) + .then((data) => context.decodeAudioData(data)) + .catch((error: unknown) => { + soundBuffer = undefined; + throw error; + }); + const [, buffer] = await Promise.all([resume, soundBuffer]); + + const source = context.createBufferSource(); + source.buffer = buffer; + source.connect(context.destination); + source.onended = () => source.disconnect(); + source.start(); + } catch { + return; + } +};