From 798729b8140466ab02745018b297fb19bc9d2ac7 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Mon, 16 Feb 2026 05:09:07 +0100 Subject: [PATCH] SOFIE-261 | add UI for configuring t-timers (WIP) --- .../src/core/model/StudioSettings.ts | 26 + packages/webui/src/client/styles/main.scss | 1 + .../src/client/styles/tTimerSettings.scss | 255 ++++++++++ .../src/client/ui/Settings/Studio/Generic.tsx | 3 + .../ui/Settings/Studio/TTimerSettings.tsx | 449 ++++++++++++++++++ 5 files changed, 734 insertions(+) create mode 100644 packages/webui/src/client/styles/tTimerSettings.scss create mode 100644 packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx diff --git a/packages/shared-lib/src/core/model/StudioSettings.ts b/packages/shared-lib/src/core/model/StudioSettings.ts index 1a117f18388..489acb7de65 100644 --- a/packages/shared-lib/src/core/model/StudioSettings.ts +++ b/packages/shared-lib/src/core/model/StudioSettings.ts @@ -104,4 +104,30 @@ export interface IStudioSettings { * How long before their start time a rundown owned piece be added to the timeline */ rundownGlobalPiecesPrepareTime?: number + + /** Configuration for T-Timers in this studio */ + tTimerSettings?: [TTimerSettingsConfig, TTimerSettingsConfig, TTimerSettingsConfig] +} + +export type TTimerMode = 'freeRun' | 'countdown' | 'timeOfDay' + +export interface TTimerSettingsConfig { + /** User label for this timer (matches RundownTTimer.label) */ + label: string + /** Whether this timer is enabled */ + enabled: boolean + /** The mode for this timer */ + mode: TTimerMode + /** Countdown duration in milliseconds (for 'countdown' mode) */ + countdownDuration: number + /** Target time string for 'timeOfDay' mode (e.g. "14:30") */ + timeOfDayTarget: string + /** Whether the timer should stop at zero, or continue into negative values */ + stopAtZero: boolean + /** Show on the Top Bar */ + showOnTopBar: boolean + /** Show on the Director Screen */ + showOnDirectorScreen: boolean + /** Show on the Presenter Screen + Prompter */ + showOnPresenterScreen: boolean } diff --git a/packages/webui/src/client/styles/main.scss b/packages/webui/src/client/styles/main.scss index 254f12efbec..39b61032f33 100644 --- a/packages/webui/src/client/styles/main.scss +++ b/packages/webui/src/client/styles/main.scss @@ -41,6 +41,7 @@ input { @import 'rundownList'; @import 'rundownSystemStatus'; @import 'settings'; +@import 'tTimerSettings'; @import 'splitDropdown'; @import 'statusbar'; @import 'studioScreenSaver'; diff --git a/packages/webui/src/client/styles/tTimerSettings.scss b/packages/webui/src/client/styles/tTimerSettings.scss new file mode 100644 index 00000000000..80402bdfff5 --- /dev/null +++ b/packages/webui/src/client/styles/tTimerSettings.scss @@ -0,0 +1,255 @@ +@import 'colorScheme'; + +.t-timer-settings { + margin-top: 1rem; + + &__header { + display: flex; + align-items: center; + margin-bottom: 0.75rem; + + h3 { + margin: 0; + } + } +} + +.t-timer-card { + border: 1px solid #ddd; + border-radius: 6px; + background: #fafafa; + padding: 1rem 1.25rem; + margin-bottom: 0.75rem; + transition: box-shadow 0.15s ease; + + &:hover { + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + + &--disabled { + opacity: 0.5; + } + + &__top-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + border-bottom: 1px solid #eee; + padding-bottom: 0.75rem; + } + + &__toggle { + flex-shrink: 0; + } + + &__name { + display: flex; + align-items: center; + gap: 0.35rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + width: 200px; + min-width: 200px; + max-width: 200px; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + input { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + border: none; + border-bottom: 1px dashed #ccc; + background: transparent; + outline: none; + padding: 2px 4px; + width: 100%; + + &:focus { + border-bottom-color: #333; + } + } + } + + &__edit-btn { + background: none; + border: none; + cursor: pointer; + color: #999; + padding: 0; + } + + &__transport { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: 2rem; + + button { + background: none; + border: 1px solid #ccc; + border-radius: 50%; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 0.75rem; + color: #555; + transition: all 0.15s ease; + + &:hover { + background: #eee; + border-color: #999; + } + } + } + + &__body { + display: flex; + gap: 1rem; + flex-wrap: wrap; + align-items: flex-start; + } + + &__section { + display: flex; + flex-direction: column; + gap: 0.35rem; + + &--disabled { + opacity: 0.35; + pointer-events: none; + } + + &-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #888; + font-weight: 600; + margin-bottom: 0.15rem; + } + } + + &__radio-group { + display: flex; + flex-direction: column; + gap: 0.25rem; + + label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + cursor: pointer; + white-space: nowrap; + + input[type='radio'] { + margin: 0; + accent-color: #4caf50; + } + } + } + + &__checkbox-group { + display: flex; + flex-direction: column; + gap: 0.3rem; + + label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + cursor: pointer; + white-space: nowrap; + + input[type='checkbox'] { + margin: 0; + accent-color: #4caf50; + } + } + } +} + +.time-segment-group { + display: flex; + align-items: center; + gap: 0; +} + +.time-segment { + display: flex; + flex-direction: column; + align-items: center; + + &__btn { + background: none; + border: none; + width: 26px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 0.55rem; + color: #ccc; + padding: 0; + transition: color 0.1s ease; + + &:hover { + color: #888; + } + } + + &__input { + width: 26px; + text-align: center; + border: none; + background: transparent; + padding: 0; + font-size: 1.1rem; + font-weight: 600; + color: #333; + outline: none; + + &:focus { + background: rgba(0, 0, 0, 0.03); + border-radius: 3px; + } + } + + &__separator { + font-size: 1.1rem; + font-weight: 600; + color: #333; + padding: 0; + align-self: center; + } + + &__ampm { + font-size: 0.7rem; + font-weight: 600; + color: #333; + text-transform: uppercase; + background: none; + border: none; + cursor: pointer; + padding: 0 0 0 2px; + align-self: center; + vertical-align: super; + margin-top: -0.5rem; + + &:hover { + color: #666; + } + } +} diff --git a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx index 32d81caceec..f2b68ec4e8e 100644 --- a/packages/webui/src/client/ui/Settings/Studio/Generic.tsx +++ b/packages/webui/src/client/ui/Settings/Studio/Generic.tsx @@ -5,6 +5,7 @@ import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons' import { useTranslation } from 'react-i18next' import { EditAttribute } from '../../../lib/EditAttribute.js' import { StudioBaselineStatus } from './Baseline.js' +import { TTimerSettingsPanel } from './TTimerSettings.js' import { ShowStyleBaseId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { ShowStyleBases, Studios } from '../../../collections/index.js' import { useHistory } from 'react-router-dom' @@ -105,6 +106,8 @@ export function StudioGenericProperties({ studio }: IStudioGenericPropertiesProp + + ) diff --git a/packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx b/packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx new file mode 100644 index 00000000000..aa471f1b9fc --- /dev/null +++ b/packages/webui/src/client/ui/Settings/Studio/TTimerSettings.tsx @@ -0,0 +1,449 @@ +import React, { useCallback, useState } from 'react' +import moment from 'moment' +import { useTranslation } from 'react-i18next' +import { DBStudio } from '@sofie-automation/corelib/dist/dataModel/Studio' +import { Studios } from '../../../collections/index.js' +import { TTimerSettingsConfig, TTimerMode } from '@sofie-automation/shared-lib/dist/core/model/StudioSettings' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faPlay, + faRedo, + faPen, + faChevronUp, + faChevronDown, + faChevronLeft, + faChevronRight, +} from '@fortawesome/free-solid-svg-icons' +import Form from 'react-bootstrap/Form' + +// --- Type aliases --- + +/** Which part of HH:MM:SS is being edited */ +type TimeSegment = 'h' | 'm' | 's' + +/** Shorthand for the fixed-length timer settings tuple */ +type TimerTuple = [TTimerSettingsConfig, TTimerSettingsConfig, TTimerSettingsConfig] + +/** Visibility toggle fields on TTimerSettingsConfig */ +type ShowOnField = 'showOnTopBar' | 'showOnDirectorScreen' | 'showOnPresenterScreen' + +// --- Constants --- + +const DEFAULT_TIMER_CONFIG: TTimerSettingsConfig = { + label: 'Stopwatch', + enabled: false, + mode: 'freeRun', + countdownDuration: 30 * 60 * 1000, // 30 minutes + timeOfDayTarget: '', + stopAtZero: true, + showOnTopBar: true, + showOnDirectorScreen: true, + showOnPresenterScreen: false, +} + +const TIME_FORMAT = 'HH:mm:ss' + +// --- Data helpers --- + +function getTimerSettings(studio: DBStudio): TimerTuple { + const settings = studio.settingsWithOverrides?.defaults?.tTimerSettings + if (settings && Array.isArray(settings) && settings.length === 3) { + return settings as TimerTuple + } + return [ + { ...DEFAULT_TIMER_CONFIG, label: 'Stopwatch A' }, + { ...DEFAULT_TIMER_CONFIG, label: 'Stopwatch B' }, + { ...DEFAULT_TIMER_CONFIG, label: 'Stopwatch C' }, + ] +} + +function saveTimerSettings(studioId: DBStudio['_id'], timers: TimerTuple): void { + Studios.update(studioId, { + $set: { + 'settingsWithOverrides.defaults.tTimerSettings': timers, + }, + }) +} + +// --- Segmented time input --- + +interface SegmentInputProps { + readonly segment: TimeSegment + readonly value: number + readonly onIncrement: (seg: TimeSegment) => void + readonly onDecrement: (seg: TimeSegment) => void + readonly onChange: (seg: TimeSegment, raw: string) => void +} + +function SegmentInput({ segment, value, onIncrement, onDecrement, onChange }: SegmentInputProps): JSX.Element { + const [editing, setEditing] = useState(false) + const [raw, setRaw] = useState('') + + const displayValue = editing ? raw : String(value).padStart(2, '0') + + const commit = () => { + onChange(segment, raw) + setEditing(false) + } + + return ( +
+ + { + setEditing(true) + setRaw(String(value).padStart(2, '0')) + e.target.select() + }} + onChange={(e) => setRaw(e.target.value.replace(/\D/g, '').slice(0, 2))} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + commit() + ;(e.target as HTMLInputElement).blur() + } + }} + maxLength={2} + /> + +
+ ) +} + +interface TimeSegmentInputProps { + readonly value: string + readonly onChange: (newValue: string) => void + readonly showAmPm?: boolean +} + +function TimeSegmentInput({ value, onChange, showAmPm }: TimeSegmentInputProps): JSX.Element { + const parsed = moment(value || '00:00:00', TIME_FORMAT) + const h = parsed.hours() + const m = parsed.minutes() + const s = parsed.seconds() + + const clamp = (n: number, max: number) => Math.min(max, Math.max(0, n)) + + const update = (newH: number, newM: number, newS: number) => { + const ms = moment + .duration({ hours: clamp(newH, 99), minutes: clamp(newM, 59), seconds: clamp(newS, 59) }) + .asMilliseconds() + onChange(moment.utc(ms).format(TIME_FORMAT)) + } + + const handleChange = (seg: TimeSegment, raw: string) => { + const n = Number.parseInt(raw, 10) || 0 + if (seg === 'h') update(n, m, s) + else if (seg === 'm') update(h, n, s) + else update(h, m, n) + } + + const adjust = (seg: TimeSegment, delta: number) => { + if (seg === 'h') update(h + delta, m, s) + else if (seg === 'm') update(h, m + delta, s) + else update(h, m, s + delta) + } + + const toggleAmPm = () => update(h < 12 ? h + 12 : h - 12, m, s) + + const displayH = showAmPm ? h % 12 || 12 : h + const isPm = h >= 12 + + return ( +
+ adjust(seg, 1)} + onDecrement={(seg) => adjust(seg, -1)} + onChange={handleChange} + /> + : + adjust(seg, 1)} + onDecrement={(seg) => adjust(seg, -1)} + onChange={handleChange} + /> + : + adjust(seg, 1)} + onDecrement={(seg) => adjust(seg, -1)} + onChange={handleChange} + /> + {showAmPm && ( + + )} +
+ ) +} + +/** Wrapper that reads/writes milliseconds instead of a time string */ +interface DurationSegmentInputProps { + readonly valueMs: number + readonly onChange: (newMs: number) => void +} + +function DurationSegmentInput({ valueMs, onChange }: DurationSegmentInputProps): JSX.Element { + const timeStr = moment.utc(Math.max(0, valueMs || 0)).format(TIME_FORMAT) + + const handleChange = (newTimeStr: string) => { + const t = moment(newTimeStr, TIME_FORMAT) + onChange(moment.duration({ hours: t.hours(), minutes: t.minutes(), seconds: t.seconds() }).asMilliseconds()) + } + + return +} + +// --- Main panel --- + +interface TTimerSettingsPanelProps { + readonly studio: DBStudio +} + +export function TTimerSettingsPanel({ studio }: TTimerSettingsPanelProps): JSX.Element { + const { t } = useTranslation() + const timers = getTimerSettings(studio) + + const handleTimerChange = useCallback( + (index: number, updatedTimer: TTimerSettingsConfig) => { + const newTimers = [...timers] as TimerTuple + newTimers[index] = updatedTimer + saveTimerSettings(studio._id, newTimers) + }, + [studio._id, timers] + ) + + return ( +
+
+

{t('T-Timer Settings')}

+
+ {timers.map((timer, index) => ( + handleTimerChange(index, updated)} /> + ))} +
+ ) +} + +// --- Timer card --- + +interface TTimerCardProps { + readonly timer: TTimerSettingsConfig + readonly index: number + readonly onChange: (timer: TTimerSettingsConfig) => void +} + +function TTimerCard({ timer, onChange }: TTimerCardProps): JSX.Element { + const { t } = useTranslation() + const [isEditingLabel, setIsEditingLabel] = useState(false) + + const handleToggle = useCallback(() => { + onChange({ ...timer, enabled: !timer.enabled }) + }, [timer, onChange]) + + const handleLabelChange = useCallback( + (e: React.ChangeEvent) => { + onChange({ ...timer, label: e.target.value }) + }, + [timer, onChange] + ) + + const handleModeChange = useCallback( + (mode: TTimerMode) => { + onChange({ ...timer, mode }) + }, + [timer, onChange] + ) + + const handleCountdownDurationChange = useCallback( + (countdownDuration: number) => { + onChange({ ...timer, countdownDuration }) + }, + [timer, onChange] + ) + + const handleTimeOfDayTargetChange = useCallback( + (timeOfDayTarget: string) => { + onChange({ ...timer, timeOfDayTarget }) + }, + [timer, onChange] + ) + + const handleStopAtZeroChange = useCallback( + (stopAtZero: boolean) => { + onChange({ ...timer, stopAtZero }) + }, + [timer, onChange] + ) + + const handleShowOnChange = useCallback( + (field: ShowOnField) => { + onChange({ ...timer, [field]: !timer[field] }) + }, + [timer, onChange] + ) + + const sectionClass = (activeMode: TTimerMode) => + `t-timer-card__section${timer.mode === activeMode ? '' : ' t-timer-card__section--disabled'}` + + return ( +
+
+
+ +
+
+ {isEditingLabel ? ( + setIsEditingLabel(false)} + onKeyDown={(e) => e.key === 'Enter' && setIsEditingLabel(false)} + autoFocus + /> + ) : ( + <> + {timer.label} + + + )} +
+
+ + + + +
+
+ +
+
+
+ + + +
+
+ +
+
{t('TIME OF DAY')}
+ {}} showAmPm /> +
+ +
+
{t('COUNT FROM')}
+ +
+ +
+
{t('COUNT TO')}
+ +
+ +
+
{t('WHEN TIMER REACHES 00:00')}
+
+ + +
+
+ +
+
{t('SHOW ON:')}
+
+ handleShowOnChange('showOnTopBar')} + id={`showOnTopBar-${timer.label}`} + /> + handleShowOnChange('showOnDirectorScreen')} + id={`showOnDirectorScreen-${timer.label}`} + /> + handleShowOnChange('showOnPresenterScreen')} + id={`showOnPresenterScreen-${timer.label}`} + /> +
+
+
+
+ ) +}