diff --git a/apps/client/src/assets/sounds/arpeggio.wav b/apps/client/src/assets/sounds/arpeggio.wav new file mode 100644 index 000000000..6e3a7823e Binary files /dev/null and b/apps/client/src/assets/sounds/arpeggio.wav differ diff --git a/apps/client/src/assets/sounds/bell.wav b/apps/client/src/assets/sounds/bell.wav new file mode 100644 index 000000000..a51b55f8e Binary files /dev/null and b/apps/client/src/assets/sounds/bell.wav differ diff --git a/apps/client/src/assets/sounds/chime.wav b/apps/client/src/assets/sounds/chime.wav new file mode 100644 index 000000000..4d70aa708 Binary files /dev/null and b/apps/client/src/assets/sounds/chime.wav differ diff --git a/apps/client/src/views/timer/Timer.scss b/apps/client/src/views/timer/Timer.scss index 7e94b7c66..26bb448d8 100644 --- a/apps/client/src/views/timer/Timer.scss +++ b/apps/client/src/views/timer/Timer.scss @@ -195,6 +195,24 @@ font-weight: 600; } + .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..9ca0dbf93 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 showSoundPrompt = 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, + {showSoundPrompt && } +
{!hideMessage && ( @@ -227,3 +234,13 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
); } + +function SoundPermissionPrompt() { + const isUserActive = useFadeOutOnInactivity(true); + + return ( +
+ Interact with the page (click/tap or press any key) 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..9a452aa1f 100644 --- a/apps/client/src/views/timer/timer.options.ts +++ b/apps/client/src/views/timer/timer.options.ts @@ -16,6 +16,7 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils'; import { PresetContext } from '../../common/context/PresetContext'; import { isStringBoolean, makeColourString } from '../common/viewUtils'; +import { endSoundOptions, isEndSound, type EndSound } from './timer.sound'; // manually match the properties of TimerType excluding the None const timerDisplayOptions: SelectOption[] = [ @@ -76,6 +77,15 @@ 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: 'option', + values: endSoundOptions, + defaultValue: 'none', + }, ], }, { @@ -193,6 +203,7 @@ type TimerOptions = { freezeOvertime: boolean; freezeMessage: string; hidePhase: boolean; + endSound: EndSound; font?: string; keyColour?: string; timerColour?: string; @@ -208,6 +219,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL // Get timerType from either source const timerType = validateTimerType(getValue('timerType'), TimerType.None); + const endSoundValue = getValue('endSound'); return { hideClock: isStringBoolean(getValue('hideClock')), @@ -227,6 +239,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL freezeOvertime: isStringBoolean(getValue('freezeOvertime')), freezeMessage: getValue('freezeMessage') ?? '', hidePhase: isStringBoolean(getValue('hidePhase')), + endSound: isEndSound(endSoundValue) ? endSoundValue : 'none', font: getValue('font') ?? undefined, keyColour: makeColourString(getValue('keyColour')), diff --git a/apps/client/src/views/timer/timer.sound.ts b/apps/client/src/views/timer/timer.sound.ts new file mode 100644 index 000000000..f6dcc1a9e --- /dev/null +++ b/apps/client/src/views/timer/timer.sound.ts @@ -0,0 +1,20 @@ +import arpeggio from '../../assets/sounds/arpeggio.wav'; +import bell from '../../assets/sounds/bell.wav'; +import chime from '../../assets/sounds/chime.wav'; +import type { SelectOption } from '../../common/components/select/Select'; + +// synthesised tones bundled with the app, no external source or licence to track +export const endSoundSources = { chime, bell, arpeggio } as const; + +export type EndSound = 'none' | keyof typeof endSoundSources; + +export const endSoundOptions: SelectOption[] = [ + { value: 'none', label: 'None' }, + { value: 'chime', label: 'Chime' }, + { value: 'bell', label: 'Bell' }, + { value: 'arpeggio', label: 'Arpeggio' }, +]; + +export function isEndSound(value: string | null): value is EndSound { + return value === 'none' || value === 'chime' || value === 'bell' || value === 'arpeggio'; +} 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..03769f1f3 --- /dev/null +++ b/apps/client/src/views/timer/useTimerSound.ts @@ -0,0 +1,99 @@ +import { TimerPhase } from 'ontime-types'; +import { useEffect, useRef, useState } from 'react'; + +import { endSoundSources, type EndSound } from './timer.sound'; +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, sound: EndSound): boolean { + const audioRef = useRef(null); + const previousPhaseRef = useRef(null); + const [isArmed, setIsArmed] = useState(false); + + const enabled = sound !== 'none'; + + // Create and clean up the audio element; changing sounds requires re-arming it in Safari. + useEffect(() => { + setIsArmed(false); + + if (sound === 'none') { + return; + } + + const audio = new Audio(endSoundSources[sound]); + audioRef.current = audio; + + return () => { + audio.pause(); + audioRef.current = null; + }; + }, [sound]); + + // Listen for user interaction until muted playback succeeds and arms the selected audio element. + useEffect(() => { + if (!enabled || isArmed) { + return; + } + + const controller = new AbortController(); + const prime = () => { + const audio = audioRef.current; + if (!audio) { + return; + } + + const wasMuted = audio.muted; + audio.muted = true; + audio + .play() + .then(() => { + if (audioRef.current !== audio) { + return; + } + audio.pause(); + audio.currentTime = 0; + setIsArmed(true); + }) + .catch(() => { + // playback is still blocked, a later interaction will try again + }) + .finally(() => { + audio.muted = wasMuted; + }); + }; + + document.addEventListener('pointerdown', prime, { capture: true, signal: controller.signal }); + document.addEventListener('keydown', prime, { capture: true, signal: controller.signal }); + + return () => { + controller.abort(); + }; + }, [enabled, isArmed]); + + // Track phase transitions and play only when a running timer enters overtime. + useEffect(() => { + const previousPhase = previousPhaseRef.current; + previousPhaseRef.current = phase; + + if (!enabled || !shouldPlayEndSound(previousPhase, phase)) { + return; + } + const audio = audioRef.current; + if (!audio) return; + + audio.currentTime = 0; + audio.play().catch(() => { + // the screen has not been interacted with, the view shows a prompt for it + }); + }, [enabled, phase]); + + return enabled && !isArmed; +}