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 }; +}