feat(ui): render a live timer in the message control preview

The preview showed the word "Timer" where the timer goes, so it could not
tell an operator what the audience will actually see. It now embeds the same
component the picture-in-picture window uses.

Making PipTimer embeddable also fixes the pop-out window, which rendered a
running timer while the stage was blacked out and did not blink with the
stage. The timer font size, the message padding and the overtime outline move
from viewport to container units: inside the preview they resolve against the
stage frame, and in the pip window - which has no ancestor query container -
they resolve against its viewport exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
This commit is contained in:
Carlos Valente
2026-08-26 12:32:27 +00:00
parent 2ef966fc14
commit 60cdf45c36
5 changed files with 55 additions and 84 deletions
@@ -39,11 +39,6 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
phase: state.timer.phase,
secondarySource: state.message.timer.secondarySource,
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false,
}));
@@ -1,34 +1,33 @@
.preview {
background-color: $ui-black;
display: grid;
place-content: center;
text-align: center;
position: relative;
display: grid;
place-items: center;
min-height: 0;
overflow: hidden;
}
.mainContent {
font-size: 1rem;
font-weight: 600;
/* the frame is a query container so that the embedded timer scales to the preview, not to the viewport */
.stageFrame {
position: relative;
container-type: inline-size;
contain: paint;
overflow: hidden;
aspect-ratio: 16 / 9;
width: 100%;
color: var(--override-colour, $ui-white);
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='none'] {
opacity: $opacity-disabled;
}
max-height: 100%;
border-radius: $component-border-radius-md;
}
.secondaryContent {
border-top: 1px solid $white-7;
}
/* keep the pop-out affordances reachable above the blackout and message overlays */
.stageCorners {
position: absolute;
inset: 0;
z-index: calc($zindex-floating + 2);
pointer-events: none;
.blackout {
display: none;
> * {
pointer-events: auto;
}
}
.eventStatus {
@@ -1,4 +1,4 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
@@ -7,62 +7,22 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import { PipTimer } from '../../../views/editor/pip-timer/PipTimer';
import style from './TimerPreview.module.scss';
const secondarySourceLabels: Record<string, string> = {
aux1: 'Aux 1',
aux2: 'Aux 2',
aux3: 'Aux 3',
secondary: 'Secondary message',
};
export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { countToEnd, timerType } = useMessagePreview();
const { data } = useViewSettings();
const main = (() => {
if (showTimerMessage) return 'Message';
if (timerType === TimerType.None) return timerPlaceholder;
if (phase === TimerPhase.Pending) return 'Standby to start';
if (phase === TimerPhase.Overtime) return 'Timer Overtime';
if (timerType === TimerType.Clock) return 'Clock';
if (countToEnd) return 'Count to End';
return 'Timer';
})();
const secondary = (() => {
// message is a fullscreen overlay or secondary is not active
if (showTimerMessage || !secondarySource) return null;
// we need to check aux first since it takes priority
return secondarySourceLabels[secondarySource];
})();
const overrideColour = (() => {
// override fallback colours from starter project
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ff7300';
return data.normalColor ?? '#FFFC';
})();
const showColourOverride = main == 'Timer';
const contentClasses = cx([blink && 'blink', blackout && style.blackout]);
return (
<div className={style.preview}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={contentClasses}>
<div
className={style.mainContent}
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
<div className={style.stageFrame}>
<PipTimer viewSettings={data} />
<div className={style.stageCorners}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
@@ -6,8 +6,9 @@
padding: 0;
box-sizing: border-box; /* reset */
overflow: hidden;
width: 100%; /* restrict the page width to viewport */
height: 100vh;
width: 100%;
height: 100%; /* fill the pip window or the editor preview frame */
position: relative; /* anchor for the blackout and message overlays */
transition: opacity 0.5s ease-in-out;
font-family: $viewer-font-family;
@@ -19,8 +20,8 @@
flex-direction: column;
&--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
outline: clamp(4px, 1cqw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1cqw, 16px) * -1);
transition: $viewer-transition-time;
}
@@ -107,10 +108,24 @@
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
.blackout {
position: absolute;
inset: 0;
padding: 2vw;
z-index: 0;
background-color: #000;
opacity: 0;
transition: opacity $viewer-transition-time;
&--active {
z-index: calc($zindex-floating + 1);
opacity: 1;
}
}
.message-overlay {
position: absolute;
inset: 0;
padding: 2cqw;
background: $viewer-background-color;
opacity: 0;
transition: opacity $viewer-transition-time;
@@ -70,16 +70,18 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
return (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text}
</FitText>
</div>
<div className='timer-container'>
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
<div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }}
style={{ fontSize: `${timerFontSize}cqw` }}
data-phase={time.phase}
>
{display}