mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-03 21:39:08 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e28b06efed | |||
| 9efac0a60a | |||
| 0ebad09154 | |||
| f3cd541ef0 | |||
| 9ec12f4927 | |||
| 4c08ca2908 | |||
| 7ec1179ede |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,26 @@
|
|||||||
|
import { getRememberedDimensions, rememberDimensions } from '../imageDimensions';
|
||||||
|
|
||||||
|
/** stand-in for a loaded HTMLImageElement */
|
||||||
|
function makeImage(naturalWidth: number, naturalHeight: number) {
|
||||||
|
return { naturalWidth, naturalHeight } as HTMLImageElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('We remember the size of an image, so that we can reserve its space when it comes back', () => {
|
||||||
|
expect(getRememberedDimensions('http://ontime.local/unseen.png')).toBe(null);
|
||||||
|
|
||||||
|
rememberDimensions('http://ontime.local/image.png', makeImage(1920, 1080));
|
||||||
|
expect(getRememberedDimensions('http://ontime.local/image.png')).toMatchObject({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
|
// an image which failed to load has no size to offer
|
||||||
|
rememberDimensions('http://ontime.local/broken.png', makeImage(0, 0));
|
||||||
|
expect(getRememberedDimensions('http://ontime.local/broken.png')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('We keep the most recently seen images, older entries are forgotten', () => {
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
rememberDimensions(`http://ontime.local/${i}.png`, makeImage(100, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getRememberedDimensions('http://ontime.local/0.png')).toBe(null);
|
||||||
|
expect(getRememberedDimensions('http://ontime.local/599.png')).toMatchObject({ width: 100, height: 50 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Images in the cuesheet live inside a virtualised table:
|
||||||
|
* rows are unmounted when they leave the viewport and mounted again when they come back.
|
||||||
|
* A re-mounted image has no dimensions until it is available,
|
||||||
|
* which makes the row change height and the table shift under the user.
|
||||||
|
*
|
||||||
|
* We remember the size of the images we have already seen
|
||||||
|
* so that we can reserve the space they will take.
|
||||||
|
* This only holds two numbers per image: we leave the image data itself to the browser cache,
|
||||||
|
* which knows better than us when memory should be released.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ImageDimensions {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** how many sizes we remember, this is only a few bytes per entry */
|
||||||
|
const maxSize = 500;
|
||||||
|
|
||||||
|
const dimensions = new Map<string, ImageDimensions>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the size of a previously loaded image, if we have seen it before
|
||||||
|
*/
|
||||||
|
export function getRememberedDimensions(src: string): ImageDimensions | null {
|
||||||
|
return dimensions.get(src) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the size of a loaded image
|
||||||
|
*/
|
||||||
|
export function rememberDimensions(src: string, image: HTMLImageElement) {
|
||||||
|
if (image.naturalHeight === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the map iteration order is our LRU queue, re-adding the entry marks it as recently used
|
||||||
|
dimensions.delete(src);
|
||||||
|
dimensions.set(src, { width: image.naturalWidth, height: image.naturalHeight });
|
||||||
|
|
||||||
|
while (dimensions.size > maxSize) {
|
||||||
|
const oldest = dimensions.keys().next();
|
||||||
|
if (oldest.done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dimensions.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { isValidImageSource } from '../cuesheet-table/cuesheet-table-elements/EditableImage';
|
||||||
|
|
||||||
|
test('An image is referenced by link, anything else is rejected', () => {
|
||||||
|
const testCases = [
|
||||||
|
{ value: 'https://example.com/image.png', isValid: true },
|
||||||
|
{ value: 'http://example.com/image.png', isValid: true },
|
||||||
|
// a file is local to the machine running ontime, it would not resolve for the clients we serve
|
||||||
|
{ value: '/user/image.png', isValid: false },
|
||||||
|
{ value: 'file:///Users/me/image.png', isValid: false },
|
||||||
|
{ value: 'C:\\images\\image.png', isValid: false },
|
||||||
|
// values which do not describe a location we can reach
|
||||||
|
{ value: 'www.example.com/image.png', isValid: false },
|
||||||
|
{ value: 'https://', isValid: false },
|
||||||
|
{ value: 'some text', isValid: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach((t) => expect(isValidImageSource(t.value)).toBe(t.isValid));
|
||||||
|
});
|
||||||
+13
@@ -5,6 +5,19 @@
|
|||||||
&:not(:read-only):hover::placeholder {
|
&:not(:read-only):hover::placeholder {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[data-invalid] {
|
||||||
|
outline: 1px solid $red-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** feedback on a value we cannot use, either rejected or failed to load */
|
||||||
|
.message {
|
||||||
|
display: block;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
color: $red-500;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imageCell {
|
.imageCell {
|
||||||
|
|||||||
+81
-19
@@ -1,27 +1,54 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import { getRememberedDimensions, rememberDimensions } from '../../../../common/utils/imageDimensions';
|
||||||
|
|
||||||
import style from './EditableImage.module.scss';
|
import style from './EditableImage.module.scss';
|
||||||
|
|
||||||
interface EditableImageProps {
|
interface EditableImageProps {
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
|
fieldLabel: string;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
updateValue: (newValue: string) => void;
|
updateValue: (newValue: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(EditableImage);
|
export default memo(EditableImage);
|
||||||
|
|
||||||
function EditableImage({ initialValue, readOnly, updateValue }: EditableImageProps) {
|
/**
|
||||||
|
* Images are referenced by link: anything local to the machine running ontime
|
||||||
|
* would not resolve for the clients we serve the cuesheet to
|
||||||
|
*/
|
||||||
|
export function isValidImageSource(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableImage({ initialValue, fieldLabel, readOnly, updateValue }: EditableImageProps) {
|
||||||
|
const [isRejected, setIsRejected] = useState(false);
|
||||||
|
/** we keep track of the source itself, so that the state follows the value being shown */
|
||||||
|
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||||
|
const [loadedSource, setLoadedSource] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleUpdate = (newValue: string) => {
|
const handleUpdate = (newValue: string) => {
|
||||||
if (newValue === initialValue) {
|
const value = newValue.trim();
|
||||||
|
|
||||||
|
if (value === initialValue) {
|
||||||
|
setIsRejected(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newValue !== '' && !newValue.startsWith('http')) {
|
|
||||||
|
if (value !== '' && !isValidImageSource(value)) {
|
||||||
|
setIsRejected(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateValue(newValue);
|
|
||||||
|
setIsRejected(false);
|
||||||
|
updateValue(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openInNewTab = () => {
|
const openInNewTab = () => {
|
||||||
@@ -36,22 +63,42 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
|||||||
|
|
||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
return (
|
return (
|
||||||
<Input
|
<>
|
||||||
variant='ghosted'
|
<Input
|
||||||
className={style.imageInput}
|
variant='ghosted'
|
||||||
fluid
|
className={style.imageInput}
|
||||||
placeholder='Paste image URL'
|
fluid
|
||||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
placeholder='Paste image URL'
|
||||||
onKeyDown={(event) => {
|
data-invalid={isRejected || undefined}
|
||||||
if (event.key === 'Enter') {
|
onChange={() => setIsRejected(false)}
|
||||||
handleUpdate(event.currentTarget.value);
|
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||||
}
|
onKeyDown={(event) => {
|
||||||
}}
|
if (event.key === 'Enter') {
|
||||||
defaultValue={initialValue}
|
handleUpdate(event.currentTarget.value);
|
||||||
/>
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isRejected && <span className={style.message}>Images are referenced by link (https://...)</span>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cuesheet is virtualised: rows are unmounted once they leave the viewport.
|
||||||
|
* When the row comes back, we reserve the space the image took
|
||||||
|
* so that the table does not shift while the browser makes it available.
|
||||||
|
* The reservation is given in CSS so that it follows the column being resized,
|
||||||
|
* the same way the image itself does once it is shown.
|
||||||
|
*/
|
||||||
|
const knownDimensions = getRememberedDimensions(initialValue);
|
||||||
|
const isLoaded = loadedSource === initialValue;
|
||||||
|
const reservedSpace = knownDimensions
|
||||||
|
? {
|
||||||
|
aspectRatio: knownDimensions.width / knownDimensions.height,
|
||||||
|
width: `min(100%, ${knownDimensions.width}px)`,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.imageCell}>
|
<div className={style.imageCell}>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
@@ -62,7 +109,22 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Boolean(initialValue) && <img loading='lazy' src={initialValue} className={style.image} />}
|
{failedSource === initialValue ? (
|
||||||
|
<span className={style.message}>Could not load image</span>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={initialValue}
|
||||||
|
alt={fieldLabel}
|
||||||
|
className={style.image}
|
||||||
|
onLoad={(event) => {
|
||||||
|
rememberDimensions(initialValue, event.currentTarget);
|
||||||
|
setLoadedSource(initialValue);
|
||||||
|
}}
|
||||||
|
onError={() => setFailedSource(initialValue)}
|
||||||
|
/** until the image is available, we reserve the space it took the last time we saw it */
|
||||||
|
style={isLoaded ? undefined : reservedSpace}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -175,7 +175,14 @@ function LazyImage({ row, column, table }: CuesheetCellContext) {
|
|||||||
|
|
||||||
const canWrite = column.columnDef.meta?.canWrite;
|
const canWrite = column.columnDef.meta?.canWrite;
|
||||||
const initialValue = event.custom[column.id];
|
const initialValue = event.custom[column.id];
|
||||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
return (
|
||||||
|
<EditableImage
|
||||||
|
initialValue={initialValue}
|
||||||
|
fieldLabel={getColumnLabel(column)}
|
||||||
|
updateValue={update}
|
||||||
|
readOnly={!canWrite}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||||
|
|||||||
@@ -195,6 +195,24 @@
|
|||||||
font-weight: 600;
|
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 ===================*/
|
||||||
.logo {
|
.logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import TitleCard from '../../common/components/title-card/TitleCard';
|
|||||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
|
||||||
|
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||||
import { useTimerSocket } from '../../common/hooks/useSocket';
|
import { useTimerSocket } from '../../common/hooks/useSocket';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
getTotalTime,
|
getTotalTime,
|
||||||
} from './timer.utils';
|
} from './timer.utils';
|
||||||
import { TimerData, useTimerData } from './useTimerData';
|
import { TimerData, useTimerData } from './useTimerData';
|
||||||
|
import { useTimerSound } from './useTimerSound';
|
||||||
|
|
||||||
import './Timer.scss';
|
import './Timer.scss';
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
freezeOvertime,
|
freezeOvertime,
|
||||||
freezeMessage,
|
freezeMessage,
|
||||||
hidePhase,
|
hidePhase,
|
||||||
|
endSound,
|
||||||
font,
|
font,
|
||||||
keyColour,
|
keyColour,
|
||||||
timerColour,
|
timerColour,
|
||||||
@@ -75,6 +78,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const localisedMinutes = getLocalizedString('common.minutes');
|
const localisedMinutes = getLocalizedString('common.minutes');
|
||||||
|
|
||||||
|
const showSoundPrompt = useTimerSound(time.phase, endSound);
|
||||||
|
|
||||||
// gather modifiers
|
// gather modifiers
|
||||||
const viewTimerType = timerType ?? timerTypeNow;
|
const viewTimerType = timerType ?? timerTypeNow;
|
||||||
const showOverlay = getShowMessage(message.timer);
|
const showOverlay = getShowMessage(message.timer);
|
||||||
@@ -156,6 +161,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
|
|
||||||
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
|
<ViewParamsEditor target={OntimeView.Timer} viewOptions={timerOptions} />
|
||||||
|
|
||||||
|
{showSoundPrompt && <SoundPermissionPrompt />}
|
||||||
|
|
||||||
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
||||||
|
|
||||||
{!hideMessage && (
|
{!hideMessage && (
|
||||||
@@ -227,3 +234,13 @@ function TimerAutoTickingClock({ clockFormat }: { clockFormat: MaybeString }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SoundPermissionPrompt() {
|
||||||
|
const isUserActive = useFadeOutOnInactivity(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx(['sound-prompt', !isUserActive && 'sound-prompt--hidden'])} aria-live='polite'>
|
||||||
|
Interact with the page (click/tap or press any key) to enable sound
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { ViewOption } from '../../common/components/view-params-editor/viewParam
|
|||||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import { isStringBoolean, makeColourString } from '../common/viewUtils';
|
import { isStringBoolean, makeColourString } from '../common/viewUtils';
|
||||||
|
import { endSoundOptions, isEndSound, type EndSound } from './timer.sound';
|
||||||
|
|
||||||
// manually match the properties of TimerType excluding the None
|
// manually match the properties of TimerType excluding the None
|
||||||
const timerDisplayOptions: SelectOption[] = [
|
const timerDisplayOptions: SelectOption[] = [
|
||||||
@@ -76,6 +77,15 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
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;
|
freezeOvertime: boolean;
|
||||||
freezeMessage: string;
|
freezeMessage: string;
|
||||||
hidePhase: boolean;
|
hidePhase: boolean;
|
||||||
|
endSound: EndSound;
|
||||||
font?: string;
|
font?: string;
|
||||||
keyColour?: string;
|
keyColour?: string;
|
||||||
timerColour?: string;
|
timerColour?: string;
|
||||||
@@ -208,6 +219,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
|
|
||||||
// Get timerType from either source
|
// Get timerType from either source
|
||||||
const timerType = validateTimerType(getValue('timerType'), TimerType.None);
|
const timerType = validateTimerType(getValue('timerType'), TimerType.None);
|
||||||
|
const endSoundValue = getValue('endSound');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hideClock: isStringBoolean(getValue('hideClock')),
|
hideClock: isStringBoolean(getValue('hideClock')),
|
||||||
@@ -227,6 +239,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
|
|||||||
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
|
freezeOvertime: isStringBoolean(getValue('freezeOvertime')),
|
||||||
freezeMessage: getValue('freezeMessage') ?? '',
|
freezeMessage: getValue('freezeMessage') ?? '',
|
||||||
hidePhase: isStringBoolean(getValue('hidePhase')),
|
hidePhase: isStringBoolean(getValue('hidePhase')),
|
||||||
|
endSound: isEndSound(endSoundValue) ? endSoundValue : 'none',
|
||||||
|
|
||||||
font: getValue('font') ?? undefined,
|
font: getValue('font') ?? undefined,
|
||||||
keyColour: makeColourString(getValue('keyColour')),
|
keyColour: makeColourString(getValue('keyColour')),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -189,3 +189,18 @@ export function getCardData(
|
|||||||
nextSecondary,
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<HTMLAudioElement | null>(null);
|
||||||
|
const previousPhaseRef = useRef<TimerPhase | null>(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;
|
||||||
|
}
|
||||||
@@ -36,6 +36,10 @@ Remove comments that:
|
|||||||
|
|
||||||
Update adjacent comments with code. Stale comments are defects.
|
Update adjacent comments with code. Stale comments are defects.
|
||||||
|
|
||||||
|
Add a short comment immediately above every React `useEffect` describing the external synchronization or lifecycle
|
||||||
|
responsibility it owns. Explain non-obvious reasons or constraints when they matter; for files with multiple effects, a
|
||||||
|
brief responsibility label is useful even when the mechanics are straightforward.
|
||||||
|
|
||||||
## Naming and types
|
## Naming and types
|
||||||
|
|
||||||
- Prefer Ontime terms over vague `data`, `result`, `item`.
|
- Prefer Ontime terms over vague `data`, `result`, `item`.
|
||||||
|
|||||||
Reference in New Issue
Block a user