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