From a6a4d5d89ed82c0e0088f9bedb7e1f355e2487a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 12:34:31 +0000 Subject: [PATCH] feat(ui): play a sound when the timer ends Adds an opt-in "Play sound on timer end" option to the timer view, which sounds the bundled buzzer when the timer transitions into overtime. Browsers reject audio playback until the document has been interacted with, and that permission is lost on every page load. Since a timer screen is typically left unattended this would fail silently, so the audio element is primed on the first interaction with the page and the view shows a hint while it is still blocked. The hint is tied to mouse movement, which does not itself grant playback permission, so it stays off screen unless somebody is at the machine to act on it. Off by default, and inert when off: no audio element is created and no listeners are registered. The change is contained to the timer view. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Np5ZL4TnZSutqA1Lqzm5Gq --- apps/client/src/views/timer/Timer.scss | 19 +++++ apps/client/src/views/timer/Timer.tsx | 23 ++++++ .../views/timer/__tests__/timer.utils.test.ts | 34 ++++++++ apps/client/src/views/timer/timer.options.ts | 10 +++ apps/client/src/views/timer/timer.utils.ts | 15 ++++ apps/client/src/views/timer/useTimerSound.ts | 77 +++++++++++++++++++ 6 files changed, 178 insertions(+) create mode 100644 apps/client/src/views/timer/__tests__/timer.utils.test.ts create mode 100644 apps/client/src/views/timer/useTimerSound.ts diff --git a/apps/client/src/views/timer/Timer.scss b/apps/client/src/views/timer/Timer.scss index 7e94b7c66..20d0ce531 100644 --- a/apps/client/src/views/timer/Timer.scss +++ b/apps/client/src/views/timer/Timer.scss @@ -195,6 +195,25 @@ font-weight: 600; } + /* =================== SOUND PROMPT ===================*/ + .sound-prompt { + position: absolute; + bottom: $view-block-padding; + left: $view-inline-padding; + padding: 0.5em 0.75em; + border-radius: $element-border-radius; + background-color: $viewer-card-bg-color; + color: $viewer-secondary-color; + font-size: $timer-label-size; + text-transform: uppercase; + pointer-events: none; + transition: opacity $viewer-transition-time; + + &--hidden { + opacity: 0; + } + } + /* =================== LOGO ===================*/ .logo { position: absolute; diff --git a/apps/client/src/views/timer/Timer.tsx b/apps/client/src/views/timer/Timer.tsx index a3e10c91f..0ec50f704 100644 --- a/apps/client/src/views/timer/Timer.tsx +++ b/apps/client/src/views/timer/Timer.tsx @@ -8,6 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard'; import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock'; +import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity'; import { useTimerSocket } from '../../common/hooks/useSocket'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { cx } from '../../common/utils/styleUtils'; @@ -30,6 +31,7 @@ import { getTotalTime, } from './timer.utils'; import { TimerData, useTimerData } from './useTimerData'; +import { useTimerSound } from './useTimerSound'; import './Timer.scss'; @@ -66,6 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, freezeOvertime, freezeMessage, hidePhase, + endSound, font, keyColour, timerColour, @@ -75,6 +78,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, const { getLocalizedString } = useTranslation(); const localisedMinutes = getLocalizedString('common.minutes'); + const { showPrompt } = useTimerSound(time.phase, endSound); + // gather modifiers const viewTimerType = timerType ?? timerTypeNow; const showOverlay = getShowMessage(message.timer); @@ -156,6 +161,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings, + {showPrompt && } +
{!hideMessage && ( @@ -227,3 +234,19 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
); } + +/** + * Nudges the user to interact with the screen so that the browser allows audio playback + * Any interaction arms the sound, so this is a hint rather than a control + * It is tied to mouse movement since that does not itself grant playback permission, + * which keeps the hint off screen unless somebody is at the machine to act on it + */ +function EnableSoundPrompt() { + const isUserActive = useFadeOutOnInactivity(true); + + return ( +
+ Tap the screen to enable sound +
+ ); +} diff --git a/apps/client/src/views/timer/__tests__/timer.utils.test.ts b/apps/client/src/views/timer/__tests__/timer.utils.test.ts new file mode 100644 index 000000000..78f210751 --- /dev/null +++ b/apps/client/src/views/timer/__tests__/timer.utils.test.ts @@ -0,0 +1,34 @@ +import { TimerPhase } from 'ontime-types'; + +import { shouldPlayEndSound } from '../timer.utils'; + +describe('shouldPlayEndSound()', () => { + test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])( + 'sounds when a running timer goes into overtime from %s', + (previousPhase) => { + expect(shouldPlayEndSound(previousPhase, TimerPhase.Overtime)).toBe(true); + }, + ); + + it('stays silent on the first phase we see, a client could be joining mid-overtime', () => { + expect(shouldPlayEndSound(null, TimerPhase.Overtime)).toBe(false); + }); + + it('stays silent when the phase was reset, a reload during overtime starts from none', () => { + expect(shouldPlayEndSound(TimerPhase.None, TimerPhase.Overtime)).toBe(false); + }); + + it('stays silent for a roll timer waiting to start', () => { + expect(shouldPlayEndSound(TimerPhase.Pending, TimerPhase.Overtime)).toBe(false); + }); + + it('sounds once, not on every update while in overtime', () => { + expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.Overtime)).toBe(false); + }); + + it('stays silent on phases which are not the end of the timer', () => { + expect(shouldPlayEndSound(TimerPhase.Default, TimerPhase.Warning)).toBe(false); + expect(shouldPlayEndSound(TimerPhase.Warning, TimerPhase.Danger)).toBe(false); + expect(shouldPlayEndSound(TimerPhase.Overtime, TimerPhase.None)).toBe(false); + }); +}); diff --git a/apps/client/src/views/timer/timer.options.ts b/apps/client/src/views/timer/timer.options.ts index ffcd84391..698acccc5 100644 --- a/apps/client/src/views/timer/timer.options.ts +++ b/apps/client/src/views/timer/timer.options.ts @@ -76,6 +76,14 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields): type: 'boolean', defaultValue: false, }, + { + id: 'endSound', + title: 'Play sound on timer end', + description: + 'Plays a sound in this screen when the timer reaches zero. The screen must be interacted with once before it can play', + type: 'boolean', + defaultValue: false, + }, ], }, { @@ -193,6 +201,7 @@ type TimerOptions = { freezeOvertime: boolean; freezeMessage: string; hidePhase: boolean; + endSound: boolean; font?: string; keyColour?: string; timerColour?: string; @@ -227,6 +236,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL freezeOvertime: isStringBoolean(getValue('freezeOvertime')), freezeMessage: getValue('freezeMessage') ?? '', hidePhase: isStringBoolean(getValue('hidePhase')), + endSound: isStringBoolean(getValue('endSound')), font: getValue('font') ?? undefined, keyColour: makeColourString(getValue('keyColour')), diff --git a/apps/client/src/views/timer/timer.utils.ts b/apps/client/src/views/timer/timer.utils.ts index 1add32d88..6537c524a 100644 --- a/apps/client/src/views/timer/timer.utils.ts +++ b/apps/client/src/views/timer/timer.utils.ts @@ -189,3 +189,18 @@ export function getCardData( nextSecondary, }; } + +/** + * Whether the end of timer sound should play for a given phase transition + * We only sound the transition into overtime from a phase that was already counting, + * which keeps a client that connects or reloads mid-overtime silent + */ +export function shouldPlayEndSound(previousPhase: TimerPhase | null, phase: TimerPhase): boolean { + if (phase !== TimerPhase.Overtime) { + return false; + } + + return ( + previousPhase === TimerPhase.Default || previousPhase === TimerPhase.Warning || previousPhase === TimerPhase.Danger + ); +} diff --git a/apps/client/src/views/timer/useTimerSound.ts b/apps/client/src/views/timer/useTimerSound.ts new file mode 100644 index 000000000..2fcb1b925 --- /dev/null +++ b/apps/client/src/views/timer/useTimerSound.ts @@ -0,0 +1,77 @@ +import { TimerPhase } from 'ontime-types'; +import { useEffect, useRef, useState } from 'react'; + +import buzzer from '../../assets/sounds/buzzer.mp3'; +import { shouldPlayEndSound } from './timer.utils'; + +/** + * Plays a sound when the timer reaches its end + * + * Browsers reject playback until the document has been interacted with, and that permission + * is lost on every page load. Since a timer screen is typically left unattended, we prime the + * audio element on the first interaction and let the view prompt for one if it never comes. + * Safari grants the permission per element, so priming has to call play() on this element from + * inside the event handler, it is not enough to know that an interaction happened. + */ +export function useTimerSound(phase: TimerPhase, enabled: boolean): { showPrompt: boolean } { + const audioRef = useRef(null); + const previousPhaseRef = useRef(null); + const [isArmed, setIsArmed] = useState(false); + + useEffect(() => { + if (!enabled) { + return; + } + + audioRef.current = new Audio(buzzer); + + return () => { + audioRef.current?.pause(); + audioRef.current = null; + setIsArmed(false); + }; + }, [enabled]); + + useEffect(() => { + if (!enabled || isArmed) { + return; + } + + const controller = new AbortController(); + const prime = () => { + audioRef.current + ?.play() + .then(() => { + if (!audioRef.current) return; + audioRef.current.pause(); + audioRef.current.currentTime = 0; + setIsArmed(true); + }) + .catch(() => { + // playback is still blocked, a later interaction will try again + }); + }; + + document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal }); + document.addEventListener('keydown', prime, { capture: true, signal: controller.signal }); + + return () => { + controller.abort(); + }; + }, [enabled, isArmed]); + + useEffect(() => { + const previousPhase = previousPhaseRef.current; + previousPhaseRef.current = phase; + + if (!enabled || !shouldPlayEndSound(previousPhase, phase)) { + return; + } + + audioRef.current?.play().catch(() => { + // the screen has not been interacted with, the view shows a prompt for it + }); + }, [enabled, phase]); + + return { showPrompt: enabled && !isArmed }; +}