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