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
This commit is contained in:
Carlos Valente
2026-08-26 12:39:02 +00:00
parent f208f148b7
commit 03fa62f3a1
11 changed files with 131 additions and 156 deletions
+8 -4
View File
@@ -22,10 +22,14 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
nextEventId: state.eventNext?.id ?? null,
}));
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
export const useScreenControl = createSelector((state: RuntimeStore) => ({
blackout: state.message.timer.blackout,
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) => ({
@@ -33,9 +37,9 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
visible: state.message.timer.visible,
}));
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
export const useSecondaryMessageInput = createSelector((state: RuntimeStore) => ({
text: state.message.secondary,
visible: state.message.timer.secondarySource === 'secondary',
source: state.message.timer.secondarySource,
}));
export const useTimerStatus = createSelector((state: RuntimeStore) => ({
@@ -2,14 +2,12 @@
display: grid;
grid-template-columns: 1fr auto;
gap: $element-spacing;
margin-top: $element-inner-spacing;
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
&.active {
color: $action-text-color;
&.withSource {
grid-template-columns: auto 1fr auto;
}
}
.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 { cx } from '../../../common/utils/styleUtils';
@@ -9,12 +10,15 @@ interface InputRowProps {
label: string;
placeholder: string;
text: string;
/** whether this text is currently on the audience screen */
visible: boolean;
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>) {
const { label, placeholder, text, visible, changeHandler, children } = props;
const { label, placeholder, text, visible, changeHandler, sourcePicker, children } = props;
const [value, setValue] = useState(text);
const inputRef = useRef<HTMLInputElement>(null);
@@ -43,10 +47,11 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
return (
<div>
<label className={cx([style.label, visible && style.active])} htmlFor={label}>
<Editor.Label className={cx([style.label, visible && style.active])} htmlFor={label}>
{label}
</label>
<div className={style.inputItems}>
</Editor.Label>
<div className={cx([style.inputItems, sourcePicker && style.withSource])}>
{sourcePicker}
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
{children}
</div>
@@ -1,18 +1,18 @@
import { SecondarySource } from 'ontime-types';
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import {
setMessage,
useExternalMessageInput as useSecondaryMessageInput,
useTimerMessageInput,
} from '../../../common/hooks/useSocket';
import Select from '../../../common/components/select/Select';
import { setMessage, useSecondaryMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
import InputRow from './InputRow';
import TimerControlsPreview from './TimerViewControl';
import ScreenControl from './ScreenControl';
import TimerPreview from './TimerPreview';
export default function MessageControl() {
return (
<>
<TimerControlsPreview />
<TimerPreview />
<ScreenControl />
<TimerMessageInput />
<SecondaryInput />
</>
@@ -24,7 +24,7 @@ function TimerMessageInput() {
return (
<InputRow
label='Timer Message'
label='Timer message'
placeholder='Message shown fullscreen in stage timer'
text={text}
visible={visible}
@@ -32,6 +32,7 @@ function TimerMessageInput() {
>
<IconButton
aria-label='Toggle timer message visibility'
aria-pressed={visible}
onClick={() => setMessage.timerVisible(!visible)}
variant={visible ? 'primary' : 'subtle'}
>
@@ -41,31 +42,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() {
const { text, visible } = useSecondaryMessageInput();
const toggleSecondary = () => {
if (visible) {
setMessage.timerSecondarySource(null);
} else {
setMessage.timerSecondarySource('secondary');
}
};
const { text, source } = useSecondaryMessageInput();
const isShowingSecondaryLine = source !== null;
const selectedSource = source ?? 'aux1';
return (
<InputRow
label='Secondary Message'
label='Secondary'
placeholder='Message shown as secondary text in stage timer'
text={text}
visible={visible}
visible={source === 'secondary'}
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
aria-label='Toggle secondary message visibility'
onClick={toggleSecondary}
variant={visible ? 'primary' : 'subtle'}
aria-label='Toggle secondary visibility'
aria-pressed={isShowingSecondaryLine}
onClick={() => setMessage.timerSecondarySource(isShowingSecondaryLine ? null : selectedSource)}
variant={isShowingSecondaryLine ? 'primary' : 'subtle'}
data-testid='toggle secondary'
>
{visible ? <IoEye /> : <IoEyeOffOutline />}
{isShowingSecondaryLine ? <IoEye /> : <IoEyeOffOutline />}
</IconButton>
</InputRow>
);
@@ -1,11 +1,19 @@
.growPanel {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.contentLayout {
flex: 1;
min-height: 0;
container-type: inline-size;
display: flex;
flex-direction: column;
gap: $section-spacing;
gap: $element-spacing;
color: $ui-white;
padding-top: $panel-top-padding;
}
@@ -0,0 +1,6 @@
.screenControl {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: $element-spacing;
}
@@ -0,0 +1,31 @@
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 } = 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>
</div>
);
}
@@ -1,9 +1,19 @@
.preview {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: $element-inner-spacing;
}
/* the stage is the only elastic element in the panel, the controls below it keep their size */
.stage {
flex: 1 1 auto;
min-height: 3rem;
/* keep the height driven frame from growing wider than the panel */
max-height: calc(100cqw * 9 / 16);
display: grid;
place-items: center;
min-height: 0;
overflow: hidden;
}
/* the frame is a query container so that the embedded timer scales to the preview, not to the viewport */
@@ -13,8 +23,7 @@
contain: paint;
overflow: hidden;
aspect-ratio: 16 / 9;
width: 100%;
max-height: 100%;
height: 100%;
border-radius: $component-border-radius-md;
}
@@ -3,6 +3,7 @@ import useViewSettings from '../../../common/hooks-query/useViewSettings';
import { handleLinks } from '../../../common/utils/linkUtils';
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';
@@ -11,12 +12,15 @@ export default function TimerPreview() {
return (
<div className={style.preview}>
<div className={style.stageFrame}>
<PipTimer viewSettings={data} />
<div className={style.stageCorners}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
<div className={style.stage}>
<div className={style.stageFrame}>
<PipTimer viewSettings={data} />
<div className={style.stageCorners}>
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
</div>
</div>
</div>
<TimerStatus />
</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,95 +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 TimerStatus from './TimerStatus';
import style from './TimerViewControl.module.scss';
export default function TimerControlsPreview() {
const { blackout, blink } = useTimerViewControl();
return (
<div className={style.previewContainer}>
<TimerPreview />
<div className={style.options}>
<TimerStatus />
<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>
</>
);
}