From 03fa62f3a100b2c25432b240e391a1a5f803d44f Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Wed, 26 Aug 2026 12:39:02 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_011CHxSQ6nWHuyar8feieRra --- apps/client/src/common/hooks/useSocket.ts | 12 ++- .../control/message/InputRow.module.scss | 14 ++- .../src/features/control/message/InputRow.tsx | 15 ++- .../control/message/MessageControl.tsx | 62 +++++++----- .../message/MessageControlExport.module.scss | 10 +- .../control/message/ScreenControl.module.scss | 6 ++ .../control/message/ScreenControl.tsx | 31 ++++++ .../control/message/TimerPreview.module.scss | 19 +++- .../features/control/message/TimerPreview.tsx | 12 ++- .../message/TimerViewControl.module.scss | 11 --- .../control/message/TimerViewControl.tsx | 95 ------------------- 11 files changed, 131 insertions(+), 156 deletions(-) create mode 100644 apps/client/src/features/control/message/ScreenControl.module.scss create mode 100644 apps/client/src/features/control/message/ScreenControl.tsx delete mode 100644 apps/client/src/features/control/message/TimerViewControl.module.scss delete mode 100644 apps/client/src/features/control/message/TimerViewControl.tsx diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 49e16de1c..da94bb454 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -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) => ({ diff --git a/apps/client/src/features/control/message/InputRow.module.scss b/apps/client/src/features/control/message/InputRow.module.scss index 877549f99..7730006a1 100644 --- a/apps/client/src/features/control/message/InputRow.module.scss +++ b/apps/client/src/features/control/message/InputRow.module.scss @@ -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; +} diff --git a/apps/client/src/features/control/message/InputRow.tsx b/apps/client/src/features/control/message/InputRow.tsx index c5559a5b9..59c5e8c37 100644 --- a/apps/client/src/features/control/message/InputRow.tsx +++ b/apps/client/src/features/control/message/InputRow.tsx @@ -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) { - 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(null); @@ -43,10 +47,11 @@ export default function InputRow(props: PropsWithChildren) { return (
- -
+ +
+ {sourcePicker} {children}
diff --git a/apps/client/src/features/control/message/MessageControl.tsx b/apps/client/src/features/control/message/MessageControl.tsx index 54270651f..ecbab07a3 100644 --- a/apps/client/src/features/control/message/MessageControl.tsx +++ b/apps/client/src/features/control/message/MessageControl.tsx @@ -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 ( <> - + + @@ -24,7 +24,7 @@ function TimerMessageInput() { return ( 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 ( setMessage.secondaryMessage(newValue)} + sourcePicker={ + { - if (value === null) return; - // we can only update the remote if it is enabled - if (secondarySource !== null) { - setMessage.timerSecondarySource(value); - } - setValue(value); - }} - /> - - - ); -}