Compare commits

...

9 Commits

Author SHA1 Message Date
Carlos Valente 7089abbb53 fix(ui): give the preview a fixed shape and top-align the buttons beside it
The stage was stretched to whatever height the panel had free via a 1fr grid
row, so its size varied with how much vertical room the editor happened to
leave rather than staying predictable. It now keeps a 16:9 box sized from
its own width - the same ratio the embedded timer's font-size estimate
already assumes - so cqw and cqh agree and the min() cap in PipTimer stays
meaningful only for the pip window, which is still freely resizable.

The button column no longer stretches or centers against that height either:
align-items: start on the group top-aligns both the stage and the buttons
when they sit side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:42:29 +00:00
Carlos Valente 99f8f4885c feat(ui): make blink/blackout exclusive and put the buttons beside the preview
Blink and blackout are two ways of hiding the same timer, and having both on
does not mean anything the audience can read differently from either alone.
Turning one on now turns the other off, enforced where the two toggles are
sent so the invariant holds regardless of which control calls it.

The screen buttons move from a row below the stage to a column on its right,
which is where they act and costs less height than a row. The stage and the
button column live in a grid so the buttons can be a fixed, content-sized
width instead of stretching to match the stage's height. A container query
against the panel's own width collapses back to a stacked layout, buttons in
a row below the stage, for a narrower fit (a resized /messagecontrol window,
for instance) where the two can't sit side by side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:36:52 +00:00
Carlos Valente c4ac9df8cf refactor(ui): overlay the status icons and let the preview fill the panel
Three adjustments to the panel layout:

- the timer status icons go back to overlaying the stage, which returns the
  row they were costing. They sit in the same chrome layer as the pop-out
  buttons, so the blackout no longer hides them
- the screen state buttons group with the preview they act on, and the gap
  before the message inputs widens to separate the two
- the stage drops its fixed aspect ratio and fills the panel width, which
  also removes the slack a tall panel used to leave around it

Without a 16:9 frame the width derived font size can overflow a short, wide
stage, so the timer is now capped by container height at the ratio the
estimate assumes. In the pip window, which has no ancestor query container,
both terms resolve against the viewport, so a 16:9 window is unchanged and a
wider one stops clipping its digits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:30:28 +00:00
Carlos Valente 0d5228f1e5 fix(ui): make the message preview match the stage it stands in for
Three things measured wrong once the preview showed a real render:

- the pip timer kept leading zeros where the timer view drops them by
  default, so the preview read 00:59:07 against the stage's 59:07 - and its
  own secondary line already dropped them
- the extracted /messagecontrol route has no flex parent for the panel to
  grow into, so the stage collapsed to its minimum instead of filling the
  window
- a tall panel caps the stage at its own width, and the slack it leaves now
  sits either side of the stage rather than all below it

Covers the screen state controls in the e2e spec: the secondary source
select puts the typed text on the stage, blackout reaches the timer view,
and clear screen resets the state while keeping the text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 13:04:23 +00:00
Carlos Valente fae9e030a3 feat(ui): add a clear screen control to message control
Recovering from "message up, blinking, secondary on" took four clicks in
three places, which is the wrong shape for something done under pressure.
One control now returns the stage to a plain timer in a single patch,
keeping whatever the operator has typed. It is disabled while nothing is
active, so an accidental click is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:40:01 +00:00
Carlos Valente 03fa62f3a1 feat(ui): fit the message control panel on a small laptop
The panel absorbs whatever height the playback control above it leaves, and
its content was a fixed stack taller than that space on a 1280x800 screen -
with the container set to hide overflow, the secondary message input was
silently clipped off the bottom.

The stage preview is now the only elastic element and the controls are the
fixed floor, which is the way round an operator needs it. The control stack
also gets shorter: blink and blackout share a row, and the secondary source
select moves next to the secondary text input.

That regrouping removes the duelling owners of `secondarySource`. The select
picks the source and the eye toggles the line on and off, so the "Show
secondary" button - which wrote the same field from a second place, and made
picking an aux read as "secondary message hidden" - is gone along with the
local mirror of the remote state it needed.

Also: blackout now uses the destructive button variant, the toggles expose
`aria-pressed`, and the input rows use the shared editor label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:39:02 +00:00
Carlos Valente f208f148b7 refactor(ui): move the timer status icons off the stage preview
The icon rail was absolutely positioned over the same box as the preview
content, so at narrow widths it collided with the render - and now that the
preview shows a real timer, it also sat under the blackout overlay. It moves
into the options column as a compact row, where it reads as editor chrome
rather than part of the stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:33:32 +00:00
Carlos Valente 60cdf45c36 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
2026-08-26 12:32:27 +00:00
Carlos Valente 2ef966fc14 fix(ui): restore blink and live-label feedback in message control
The blink modifier in the timer preview referenced a CSS module class that
does not exist, so `style.blink` resolved to undefined and the preview never
blinked. `.blink` is a global animation class, matching how the timer view
applies it.

The "message is live" label highlight used `??` where `&&` was meant: since
`visible` is always a boolean, the active style was unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra
2026-08-26 12:29:16 +00:00
18 changed files with 322 additions and 300 deletions
+17 -12
View File
@@ -22,10 +22,14 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
nextEventId: state.eventNext?.id ?? null, nextEventId: state.eventNext?.id ?? null,
})); }));
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({ export const useScreenControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout, blackout: state.message.timer.blackout,
blink: state.message.timer.blink, blink: state.message.timer.blink,
secondarySource: state.message.timer.secondarySource, isScreenModified:
state.message.timer.visible ||
state.message.timer.blink ||
state.message.timer.blackout ||
state.message.timer.secondarySource !== null,
})); }));
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
@@ -33,17 +37,12 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
visible: state.message.timer.visible, visible: state.message.timer.visible,
})); }));
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({ export const useSecondaryMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.secondary, text: state.message.secondary,
visible: state.message.timer.secondarySource === 'secondary', source: state.message.timer.secondarySource,
})); }));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({ export const useTimerStatus = 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, timerType: state.eventNow?.timerType ?? null,
countToEnd: state.eventNow?.countToEnd ?? false, countToEnd: state.eventNow?.countToEnd ?? false,
})); }));
@@ -52,10 +51,16 @@ export const setMessage = {
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }), timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }), secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }), // blink and blackout are mutually exclusive stage states, so turning one on turns the other off
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }), timerBlink: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blink: true, blackout: false } } : { timer: { blink: false } }),
timerBlackout: (payload: boolean) =>
sendSocket('message', payload ? { timer: { blackout: true, blink: false } } : { timer: { blackout: false } }),
timerSecondarySource: (payload: TimerMessage['secondarySource']) => timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
sendSocket('message', { timer: { secondarySource: payload } }), sendSocket('message', { timer: { secondarySource: payload } }),
/** returns the stage to a plain timer, keeping whatever the operator has typed */
clearScreen: () =>
sendSocket('message', { timer: { visible: false, blink: false, blackout: false, secondarySource: null } }),
}; };
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
@@ -2,14 +2,12 @@
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: $element-spacing; gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label { &.withSource {
font-size: $inner-section-text-size; grid-template-columns: auto 1fr auto;
color: $label-gray;
&.active {
color: $action-text-color;
} }
} }
.label.active {
color: $action-text-color;
}
@@ -1,5 +1,6 @@
import { PropsWithChildren, useEffect, useRef, useState } from 'react'; import { PropsWithChildren, ReactNode, useEffect, useRef, useState } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
@@ -9,12 +10,15 @@ interface InputRowProps {
label: string; label: string;
placeholder: string; placeholder: string;
text: string; text: string;
/** whether this text is currently on the audience screen */
visible: boolean; visible: boolean;
changeHandler: (newValue: string) => void; changeHandler: (newValue: string) => void;
/** control which picks where the text is shown, rendered before the input */
sourcePicker?: ReactNode;
} }
export default function InputRow(props: PropsWithChildren<InputRowProps>) { export default function InputRow(props: PropsWithChildren<InputRowProps>) {
const { label, placeholder, text, visible, changeHandler, children } = props; const { label, placeholder, text, visible, changeHandler, sourcePicker, children } = props;
const [value, setValue] = useState(text); const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -43,10 +47,11 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
return ( return (
<div> <div>
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}> <Editor.Label className={cx([style.label, visible && style.active])} htmlFor={label}>
{label} {label}
</label> </Editor.Label>
<div className={style.inputItems}> <div className={cx([style.inputItems, sourcePicker && style.withSource])}>
{sourcePicker}
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} /> <Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
{children} {children}
</div> </div>
@@ -0,0 +1,17 @@
/* the screen state buttons belong with the preview they act on, not with the message inputs.
buttons sit in a column to the right of the stage by default; once the container narrows
too far for that (e.g. an extracted window resized narrow), they drop into a row below it.
the group is sized by its content, not stretched - the preview keeps a fixed shape rather
than growing to whatever height the panel happens to have free */
.screenGroup {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: $element-spacing;
}
@container (max-width: 22rem) {
.screenGroup {
grid-template-columns: 1fr;
}
}
@@ -1,18 +1,22 @@
import { SecondarySource } from 'ontime-types';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5'; import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import { import Select from '../../../common/components/select/Select';
setMessage, import { setMessage, useSecondaryMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput,
} from '../../../common/hooks/useSocket';
import InputRow from './InputRow'; import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl'; import ScreenControl from './ScreenControl';
import TimerPreview from './TimerPreview';
import style from './MessageControl.module.scss';
export default function MessageControl() { export default function MessageControl() {
return ( return (
<> <>
<TimerControlsPreview /> <div className={style.screenGroup}>
<TimerPreview />
<ScreenControl />
</div>
<TimerMessageInput /> <TimerMessageInput />
<SecondaryInput /> <SecondaryInput />
</> </>
@@ -24,7 +28,7 @@ function TimerMessageInput() {
return ( return (
<InputRow <InputRow
label='Timer Message' label='Timer message'
placeholder='Message shown fullscreen in stage timer' placeholder='Message shown fullscreen in stage timer'
text={text} text={text}
visible={visible} visible={visible}
@@ -32,6 +36,7 @@ function TimerMessageInput() {
> >
<IconButton <IconButton
aria-label='Toggle timer message visibility' aria-label='Toggle timer message visibility'
aria-pressed={visible}
onClick={() => setMessage.timerVisible(!visible)} onClick={() => setMessage.timerVisible(!visible)}
variant={visible ? 'primary' : 'subtle'} variant={visible ? 'primary' : 'subtle'}
> >
@@ -41,31 +46,46 @@ function TimerMessageInput() {
); );
} }
/**
* The secondary line of the stage timer shows one of the aux timers or the secondary message.
* The select owns which one, the eye owns whether the line is shown at all.
*/
function SecondaryInput() { function SecondaryInput() {
const { text, visible } = useSecondaryMessageInput(); const { text, source } = useSecondaryMessageInput();
const isShowingSecondaryLine = source !== null;
const toggleSecondary = () => { const selectedSource = source ?? 'aux1';
if (visible) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource('secondary');
}
};
return ( return (
<InputRow <InputRow
label='Secondary Message' label='Secondary'
placeholder='Message shown as secondary text in stage timer' placeholder='Message shown as secondary text in stage timer'
text={text} text={text}
visible={visible} visible={source === 'secondary'}
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)} changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
sourcePicker={
<Select
value={selectedSource}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
setMessage.timerSecondarySource(value);
}}
/>
}
> >
<IconButton <IconButton
aria-label='Toggle secondary message visibility' aria-label='Toggle secondary visibility'
onClick={toggleSecondary} aria-pressed={isShowingSecondaryLine}
variant={visible ? 'primary' : 'subtle'} onClick={() => setMessage.timerSecondarySource(isShowingSecondaryLine ? null : selectedSource)}
variant={isShowingSecondaryLine ? 'primary' : 'subtle'}
data-testid='toggle secondary'
> >
{visible ? <IoEye /> : <IoEyeOffOutline />} {isShowingSecondaryLine ? <IoEye /> : <IoEyeOffOutline />}
</IconButton> </IconButton>
</InputRow> </InputRow>
); );
@@ -1,8 +1,21 @@
.growPanel { .growPanel {
flex: 1; flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* the extracted route has no flex parent to grow into */
.extractedPanel {
height: 100%;
} }
.contentLayout { .contentLayout {
flex: 1;
min-height: 0;
container-type: inline-size;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $section-spacing; gap: $section-spacing;
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../../common/components/error-boundary/ErrorBounda
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx } from '../../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../../externals'; import { getIsNavigationLocked } from '../../../externals';
import MessageControl from './MessageControl'; import MessageControl from './MessageControl';
@@ -16,7 +17,10 @@ function MessageControlExport() {
return ( return (
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<Editor.Panel className={style.growPanel} data-testid='panel-messages-control'> <Editor.Panel
className={cx([style.growPanel, isExtracted && style.extractedPanel])}
data-testid='panel-messages-control'
>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />} {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
@@ -0,0 +1,14 @@
.screenControl {
display: grid;
grid-auto-flow: row;
gap: $element-spacing;
width: 8rem;
}
@container (max-width: 22rem) {
.screenControl {
grid-auto-flow: column;
grid-auto-columns: 1fr;
width: 100%;
}
}
@@ -0,0 +1,40 @@
import Button from '../../../common/components/buttons/Button';
import { setMessage, useScreenControl } from '../../../common/hooks/useSocket';
import style from './ScreenControl.module.scss';
export default function ScreenControl() {
const { blackout, blink, isScreenModified } = useScreenControl();
return (
<div className={style.screenControl}>
<Button
variant={blink ? 'primary' : 'subtle'}
aria-pressed={blink}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'destructive' : 'subtle'}
aria-pressed={blackout}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout
</Button>
<Button
variant='subtle'
fluid
disabled={!isScreenModified}
onClick={() => setMessage.clearScreen()}
data-testid='clear screen'
>
Clear screen
</Button>
</div>
);
}
@@ -1,49 +1,26 @@
.preview { /* fixed shape, not stretched to whatever height the panel has free - it fills the
background-color: $ui-black; available width and keeps a 16:9 proportion, which is also what the font size
display: grid; estimate in the embedded timer assumes */
place-content: center; .stage {
text-align: center;
position: relative;
}
.mainContent {
font-size: 1rem;
font-weight: 600;
width: 100%; width: 100%;
color: var(--override-colour, $ui-white); aspect-ratio: 16 / 9;
min-width: 0;
&[data-phase='pending'] { position: relative;
color: $ontime-roll; /* a query container so that the embedded timer scales to the preview, not to the viewport */
} container-type: size;
&[data-phase='overtime'] { contain: paint;
color: $playback-negative; overflow: hidden;
} border-radius: $component-border-radius-md;
&[data-phase='none'] {
opacity: $opacity-disabled;
}
} }
.secondaryContent { /* editor chrome sits above the blackout and message overlays, so it stays reachable and readable */
border-top: 1px solid $white-7; .stageChrome {
}
.blackout {
display: none;
}
.eventStatus {
position: absolute; position: absolute;
left: 0; inset: 0;
margin: 0.5rem 0.25rem; z-index: calc($zindex-floating + 2);
display: flex; pointer-events: none;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon { > * {
color: $gray-1000; pointer-events: auto;
&[data-active='true'] {
color: $active-indicator;
} }
} }
@@ -1,110 +1,21 @@
import { TimerPhase, TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils'; import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import useViewSettings from '../../../common/hooks-query/useViewSettings'; import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { useMessagePreview } from '../../../common/hooks/useSocket';
import { handleLinks } from '../../../common/utils/linkUtils'; import { handleLinks } from '../../../common/utils/linkUtils';
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
import PipRoot from '../../../views/editor/pip-timer/PipRoot'; import PipRoot from '../../../views/editor/pip-timer/PipRoot';
import { PipTimer } from '../../../views/editor/pip-timer/PipTimer';
import TimerStatus from './TimerStatus';
import style from './TimerPreview.module.scss'; 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() { export default function TimerPreview() {
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
const { data } = useViewSettings(); 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 && style.blink, blackout && style.blackout]);
return ( return (
<div className={style.preview}> <div className={style.stage}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} /> <PipTimer viewSettings={data} />
<div className={contentClasses}> <div className={style.stageChrome}>
<div <TimerStatus />
className={style.mainContent} <CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
data-phase={showColourOverride && phase}
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
>
{main}
</div>
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
</div>
<div className={style.eventStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,17 @@
.timerStatus {
position: absolute;
top: 0;
left: 0;
margin: 0.5rem 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.statusIcon {
color: $gray-1000;
&[data-active='true'] {
color: $active-indicator;
}
}
@@ -0,0 +1,58 @@
import { TimerType } from 'ontime-types';
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useTimerStatus } from '../../../common/hooks/useSocket';
import style from './TimerStatus.module.scss';
/** Read only summary of how the loaded event drives the stage timer */
export default function TimerStatus() {
const { countToEnd, timerType } = useTimerStatus();
return (
<div className={style.timerStatus}>
<Tooltip
text='Time type: Count down'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountDown}
>
<IoArrowDown />
</Tooltip>
<Tooltip
text='Time type: Count up'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.CountUp}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text='Time type: Clock'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.Clock}
>
<IoTime />
</Tooltip>
<Tooltip
text='Time type: None'
render={<span />}
className={style.statusIcon}
data-active={timerType === TimerType.None}
>
<IoBan />
</Tooltip>
<Tooltip
text={countToEnd ? 'Count to end' : 'Count duration'}
render={<span />}
className={style.statusIcon}
data-active={countToEnd}
>
<LuArrowDownToLine />
</Tooltip>
</div>
);
}
@@ -1,11 +0,0 @@
.previewContainer {
display: grid;
gap: $element-spacing;
grid-template-columns: 3fr 2fr;
}
.options {
display: flex;
flex-direction: column;
gap: $element-spacing;
}
@@ -1,92 +0,0 @@
import { SecondarySource } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../common/components/buttons/Button';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Select from '../../../common/components/select/Select';
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
import TimerPreview from './TimerPreview';
import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl();
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<SecondarySourceControl />
<Editor.Separator orientation='horizontal' />
<Button
variant={blink ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlink(!blink)}
data-testid='toggle timer blink'
>
Blink
</Button>
<Button
variant={blackout ? 'primary' : 'subtle'}
fluid
onClick={() => setMessage.timerBlackout(!blackout)}
data-testid='toggle timer blackout'
>
Blackout screen
</Button>
</div>
</div>
);
}
function SecondarySourceControl() {
const { secondarySource } = useTimerViewControl();
const [value, setValue] = useState<SecondarySource>('aux1');
// sync secondary source with external changes
useEffect(() => {
if (secondarySource !== null) {
setValue(secondarySource);
}
}, [secondarySource]);
const toggleSecondary = () => {
if (secondarySource === value) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource(value);
}
};
return (
<>
<Select
value={value}
options={[
{ value: 'aux1', label: 'Aux 1' },
{ value: 'aux2', label: 'Aux 2' },
{ value: 'aux3', label: 'Aux 3' },
{ value: 'secondary', label: 'Secondary message' },
]}
onValueChange={(value: SecondarySource | null) => {
if (value === null) return;
// we can only update the remote if it is enabled
if (secondarySource !== null) {
setMessage.timerSecondarySource(value);
}
setValue(value);
}}
/>
<Button
variant={secondarySource !== null ? 'primary' : 'subtle'}
fluid
onClick={toggleSecondary}
data-testid='toggle secondary'
>
Show secondary
</Button>
</>
);
}
@@ -6,8 +6,9 @@
padding: 0; padding: 0;
box-sizing: border-box; /* reset */ box-sizing: border-box; /* reset */
overflow: hidden; overflow: hidden;
width: 100%; /* restrict the page width to viewport */ width: 100%;
height: 100vh; 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; transition: opacity 0.5s ease-in-out;
font-family: $viewer-font-family; font-family: $viewer-font-family;
@@ -19,8 +20,8 @@
flex-direction: column; flex-direction: column;
&--finished { &--finished {
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color; outline: clamp(4px, 1cqw, 16px) solid $timer-finished-color;
outline-offset: calc(clamp(4px, 1vw, 16px) * -1); outline-offset: calc(clamp(4px, 1cqw, 16px) * -1);
transition: $viewer-transition-time; transition: $viewer-transition-time;
} }
@@ -107,10 +108,24 @@
/* =================== OVERLAY ===================*/ /* =================== OVERLAY ===================*/
.message-overlay { .blackout {
position: fixed; position: absolute;
inset: 0; 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; background: $viewer-background-color;
opacity: 0; opacity: 0;
transition: opacity $viewer-transition-time; transition: opacity $viewer-transition-time;
@@ -41,9 +41,10 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather timer data // gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime); const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow); const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
// match the defaults of the timer view, which is what the preview is standing in for
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', { const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false, removeSeconds: false,
removeLeadingZero: false, removeLeadingZero: true,
}); });
const currentAux = (() => { const currentAux = (() => {
@@ -63,23 +64,27 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather presentation styles // gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger); const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
// the estimate is tuned for a 16:9 screen, so cap it by height for containers wider than that
const timerFontSize = getEstimatedFontSize(display, secondaryContent); const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const timerFontRule = `min(${timerFontSize}cqw, ${((timerFontSize * 16) / 9).toFixed(2)}cqh)`;
const userStyles = { const userStyles = {
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }), ...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
}; };
return ( return (
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}> <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'])}> <div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}> <FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
{message.timer.text} {message.timer.text}
</FitText> </FitText>
</div> </div>
<div className='timer-container'> <div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
<div <div
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])} className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
style={{ fontSize: `${timerFontSize}vw` }} style={{ fontSize: timerFontRule }}
data-phase={time.phase} data-phase={time.phase}
> >
{display} {display}
@@ -19,3 +19,29 @@ test('message control sends messages to screens', async ({ context }) => {
await expect(featurePage.getByText('TIME NOW')).toBeVisible(); await expect(featurePage.getByText('TIME NOW')).toBeVisible();
}); });
test('message control drives the stage screen state', async ({ context }) => {
const editorPage = await context.newPage();
const featurePage = await context.newPage();
await editorPage.goto('/messagecontrol');
await featurePage.goto('/timer');
await featurePage.waitForLoadState('load', { timeout: 5000 });
// the secondary line defaults to an aux timer, the select is what puts our text on screen
await editorPage.getByPlaceholder('Message shown as secondary text in stage timer').fill('testing secondary');
await editorPage.getByRole('combobox').click();
await editorPage.getByRole('option', { name: 'Message' }).click();
await expect(featurePage.getByText('testing secondary')).toBeVisible();
await editorPage.getByTestId('toggle timer blackout').click();
await expect(featurePage.locator('.blackout')).toHaveClass(/blackout--active/);
// clearing returns the screen to normal, but keeps what the operator typed
await editorPage.getByTestId('clear screen').click();
await expect(featurePage.locator('.blackout')).not.toHaveClass(/blackout--active/);
await expect(featurePage.getByText('testing secondary')).toHaveCount(0);
await expect(editorPage.getByPlaceholder('Message shown as secondary text in stage timer')).toHaveValue(
'testing secondary',
);
});