From 15ad2fdc365d71e67ac9be67ffff61f2246bc0b6 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:44:17 +0100 Subject: [PATCH 1/4] SOFIE-261 | add UI for t-timers (WIP) --- .../webui/src/client/styles/rundownView.scss | 80 +++++++++-- .../RundownHeader/RundownHeaderTimers.tsx | 124 ++++++++++++++++++ .../RundownHeader/TimingDisplay.tsx | 2 + 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index b647eabfa6..9e036f1ddf 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -209,8 +209,13 @@ body.no-overflow { bottom: 0; right: 0; - background: - linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), + background: linear-gradient( + -45deg, + $color-status-fatal 33%, + transparent 33%, + transparent 66%, + $color-status-fatal 66% + ), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%), linear-gradient(-45deg, $color-status-fatal 33%, transparent 33%, transparent 66%, $color-status-fatal 66%); @@ -266,7 +271,15 @@ body.no-overflow { .timing__header__left { text-align: left; + display: flex; + } + + .timing__header__center { + display: flex; + justify-content: center; + align-items: center; } + .timing__header__right { display: grid; grid-template-columns: auto auto; @@ -1100,8 +1113,7 @@ svg.icon { } .segment-timeline__part { .segment-timeline__part__invalid-cover { - background-image: - repeating-linear-gradient( + background-image: repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 4px, @@ -1383,8 +1395,7 @@ svg.icon { left: 2px; right: 2px; z-index: 3; - background: - repeating-linear-gradient( + background: repeating-linear-gradient( 45deg, var(--invalid-reason-color-opaque) 0, var(--invalid-reason-color-opaque) 5px, @@ -1566,8 +1577,7 @@ svg.icon { right: 1px; z-index: 10; pointer-events: all; - background-image: - repeating-linear-gradient( + background-image: repeating-linear-gradient( 45deg, var(--invalid-reason-color-transparent) 0%, var(--invalid-reason-color-transparent) 5px, @@ -3573,3 +3583,57 @@ svg.icon { } @import 'rundownOverview'; + +.rundown-header .timing__header_t-timers { + display: flex; + flex-direction: column; + justify-content: center; + margin-right: 1em; + padding-left: 1em; + text-align: right; + align-self: center; + width: fit-content; + + .timing__header_t-timers__timer { + display: flex; + gap: 0.5em; + justify-content: space-between; + align-items: baseline; + + .timing__header_t-timers__timer__label { + font-size: 0.7em; + color: #b8b8b8; + text-transform: uppercase; + } + + .timing__header_t-timers__timer__value { + font-family: + 'Roboto', + Helvetica Neue, + Arial, + sans-serif; + font-variant-numeric: tabular-nums; + font-weight: 500; + color: $general-clock; + font-size: 1.1em; + } + + .timing__header_t-timers__timer__sign { + margin-right: 0.2em; + font-weight: 700; + color: #fff; + } + + .timing__header_t-timers__timer__part { + color: white; + &.timing__header_t-timers__timer__part--dimmed { + color: #888; // Dimmed color for "00" + font-weight: 400; + } + } + .timing__header_t-timers__timer__separator { + margin: 0 0.05em; + color: #888; + } + } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx new file mode 100644 index 0000000000..4aafecf659 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' +import { useTiming } from '../RundownTiming/withTiming' +import { RundownUtils } from '../../../lib/rundown' +import classNames from 'classnames' + +interface IProps { + tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] +} + +export const RundownHeaderTimers: React.FC = ({ tTimers }) => { + useTiming() + + // TODO: Remove this mock data once verified + const mockTimers = React.useMemo<[RundownTTimer, RundownTTimer, RundownTTimer]>(() => { + const now = Date.now() + return [ + { + index: 1, + label: 'Timer 1', + mode: { type: 'freeRun', startTime: now - 60000 }, + }, + { + index: 2, + label: 'Timer 2', + mode: { type: 'countdown', startTime: now, duration: 300000, pauseTime: null, stopAtZero: false }, + }, + { + index: 3, + label: 'Timer 3', + mode: { type: 'countdown', startTime: now - 10000, duration: 5000, pauseTime: null, stopAtZero: true }, + }, + ] + }, []) + + tTimers = mockTimers + + const hasActiveTimers = tTimers.some((t) => t.mode) + + if (!hasActiveTimers) return null + + return ( +
+ {tTimers.map((timer) => ( + + ))} +
+ ) +} + +interface ISingleTimerProps { + timer: RundownTTimer +} + +function SingleTimer({ timer }: ISingleTimerProps) { + if (!timer.mode) return null + + const now = Date.now() + + const isRunning = timer.mode!.type === 'countdown' && timer.mode!.pauseTime === null + + const { diff, isNegative, isFreeRun } = calculateDiff(timer, now) + const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) + const parts = timeStr.split(':') + + const timerSign = isFreeRun ? '+' : isNegative ? '-' : '+' + + return ( +
+ {timer.label} +
+ {timerSign} + {parts.map((p, i) => ( + + + {p} + + {i < parts.length - 1 && :} + + ))} +
+
+ ) +} + +function calculateDiff( + timer: RundownTTimer, + now: number +): { + diff: number + isNegative: boolean + isFreeRun: boolean +} { + if (timer.mode!.type === 'freeRun') { + const startTime = timer.mode!.startTime + const diff = now - startTime + return { diff, isNegative: false, isFreeRun: true } + } else if (timer.mode!.type === 'countdown') { + const endTime = timer.mode!.startTime + timer.mode!.duration + let diff = endTime - now + + if (timer.mode!.stopAtZero && diff < 0) { + diff = 0 + } + + return { diff, isNegative: diff >= 0, isFreeRun: false } + } + return { diff: 0, isNegative: false, isFreeRun: false } +} diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx index 53c9134642..8e930daaa4 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx @@ -12,6 +12,7 @@ import { PlaylistStartTiming } from '../RundownTiming/PlaylistStartTiming' import { RundownName } from '../RundownTiming/RundownName' import { TimeOfDay } from '../RundownTiming/TimeOfDay' import { useTiming } from '../RundownTiming/withTiming' +import { RundownHeaderTimers } from './RundownHeaderTimers' interface ITimingDisplayProps { rundownPlaylist: DBRundownPlaylist @@ -50,6 +51,7 @@ export function TimingDisplay({
+
From 95dc71f916209c4f6a3a8288146ee1d43bda0b03 Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:13:59 +0100 Subject: [PATCH 2/4] SOFIE-261 | change alignment of t-timers in rundown screen --- .../webui/src/client/styles/rundownView.scss | 27 ++++++++++++------- .../RundownHeader/RundownHeaderTimers.tsx | 24 ----------------- .../RundownHeader/TimingDisplay.tsx | 2 +- 3 files changed, 19 insertions(+), 34 deletions(-) diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index 9e036f1ddf..ef27214343 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -275,6 +275,7 @@ body.no-overflow { } .timing__header__center { + position: relative; display: flex; justify-content: center; align-items: center; @@ -3585,25 +3586,29 @@ svg.icon { @import 'rundownOverview'; .rundown-header .timing__header_t-timers { + position: absolute; + right: 100%; + top: 50%; + transform: translateY(-38%); display: flex; flex-direction: column; justify-content: center; + align-items: flex-end; margin-right: 1em; - padding-left: 1em; - text-align: right; - align-self: center; - width: fit-content; .timing__header_t-timers__timer { display: flex; gap: 0.5em; justify-content: space-between; align-items: baseline; + white-space: nowrap; + line-height: 1.3; .timing__header_t-timers__timer__label { font-size: 0.7em; color: #b8b8b8; text-transform: uppercase; + white-space: nowrap; } .timing__header_t-timers__timer__value { @@ -3614,20 +3619,24 @@ svg.icon { sans-serif; font-variant-numeric: tabular-nums; font-weight: 500; - color: $general-clock; + color: #fff; font-size: 1.1em; } .timing__header_t-timers__timer__sign { - margin-right: 0.2em; - font-weight: 700; + display: inline-block; + width: 0.6em; + text-align: center; + font-weight: 500; + font-size: 0.9em; color: #fff; + margin-right: 0.3em; } .timing__header_t-timers__timer__part { - color: white; + color: #fff; &.timing__header_t-timers__timer__part--dimmed { - color: #888; // Dimmed color for "00" + color: #888; font-weight: 400; } } diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 4aafecf659..925d4ddff9 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -11,30 +11,6 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - // TODO: Remove this mock data once verified - const mockTimers = React.useMemo<[RundownTTimer, RundownTTimer, RundownTTimer]>(() => { - const now = Date.now() - return [ - { - index: 1, - label: 'Timer 1', - mode: { type: 'freeRun', startTime: now - 60000 }, - }, - { - index: 2, - label: 'Timer 2', - mode: { type: 'countdown', startTime: now, duration: 300000, pauseTime: null, stopAtZero: false }, - }, - { - index: 3, - label: 'Timer 3', - mode: { type: 'countdown', startTime: now - 10000, duration: 5000, pauseTime: null, stopAtZero: true }, - }, - ] - }, []) - - tTimers = mockTimers - const hasActiveTimers = tTimers.some((t) => t.mode) if (!hasActiveTimers) return null diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx index 8e930daaa4..0ade467075 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/TimingDisplay.tsx @@ -51,9 +51,9 @@ export function TimingDisplay({
-
+
From 079b70ba28bf443efa0e164eb855aa7c50c4ea9c Mon Sep 17 00:00:00 2001 From: "Robert (Jamie) Munro" Date: Fri, 30 Jan 2026 17:28:22 +0000 Subject: [PATCH 3/4] Refactor UI for new timer style --- .../RundownHeader/RundownHeaderTimers.tsx | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 925d4ddff9..185cef40f5 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -33,13 +33,13 @@ function SingleTimer({ timer }: ISingleTimerProps) { const now = Date.now() - const isRunning = timer.mode!.type === 'countdown' && timer.mode!.pauseTime === null + const isRunning = timer.state !== null && !timer.state.paused - const { diff, isNegative, isFreeRun } = calculateDiff(timer, now) + const diff = calculateDiff(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) const parts = timeStr.split(':') - const timerSign = isFreeRun ? '+' : isNegative ? '-' : '+' + const timerSign = diff >= 0 ? '+' : '-' return (
{timer.label} @@ -74,27 +74,23 @@ function SingleTimer({ timer }: ISingleTimerProps) { ) } -function calculateDiff( - timer: RundownTTimer, - now: number -): { - diff: number - isNegative: boolean - isFreeRun: boolean -} { - if (timer.mode!.type === 'freeRun') { - const startTime = timer.mode!.startTime - const diff = now - startTime - return { diff, isNegative: false, isFreeRun: true } - } else if (timer.mode!.type === 'countdown') { - const endTime = timer.mode!.startTime + timer.mode!.duration - let diff = endTime - now - - if (timer.mode!.stopAtZero && diff < 0) { - diff = 0 - } - - return { diff, isNegative: diff >= 0, isFreeRun: false } +function calculateDiff(timer: RundownTTimer, now: number): number { + if (!timer.state) { + return 0 } - return { diff: 0, isNegative: false, isFreeRun: false } + + // Get current time: either frozen duration or calculated from zeroTime + const currentTime = timer.state.paused ? timer.state.duration : timer.state.zeroTime - now + + // Free run counts up, so negate to get positive elapsed time + if (timer.mode?.type === 'freeRun') { + return -currentTime + } + + // Apply stopAtZero if configured + if (timer.mode?.stopAtZero && currentTime < 0) { + return 0 + } + + return currentTime } From e9fc4c4fc0ce1dc7132b5c535ff23f9a4c74c2ac Mon Sep 17 00:00:00 2001 From: Maciek Antek Papiewski <41971218+anteeek@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:26:28 +0100 Subject: [PATCH 4/4] Apply code review suggestions --- .../RundownHeader/RundownHeaderTimers.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx index 185cef40f5..d5de3a5942 100644 --- a/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx +++ b/packages/webui/src/client/ui/RundownView/RundownHeader/RundownHeaderTimers.tsx @@ -3,6 +3,7 @@ import { RundownTTimer } from '@sofie-automation/corelib/dist/dataModel/RundownP import { useTiming } from '../RundownTiming/withTiming' import { RundownUtils } from '../../../lib/rundown' import classNames from 'classnames' +import { getCurrentTime } from '../../../lib/systemTime' interface IProps { tTimers: [RundownTTimer, RundownTTimer, RundownTTimer] @@ -11,13 +12,13 @@ interface IProps { export const RundownHeaderTimers: React.FC = ({ tTimers }) => { useTiming() - const hasActiveTimers = tTimers.some((t) => t.mode) + const activeTimers = tTimers.filter((t) => t.mode) - if (!hasActiveTimers) return null + if (activeTimers.length == 0) return null return (
- {tTimers.map((timer) => ( + {activeTimers.map((timer) => ( ))}
@@ -29,11 +30,9 @@ interface ISingleTimerProps { } function SingleTimer({ timer }: ISingleTimerProps) { - if (!timer.mode) return null + const now = getCurrentTime() - const now = Date.now() - - const isRunning = timer.state !== null && !timer.state.paused + const isRunning = !!timer.state && !timer.state.paused const diff = calculateDiff(timer, now) const timeStr = RundownUtils.formatDiffToTimecode(Math.abs(diff), false, true, true, false, true) @@ -41,6 +40,8 @@ function SingleTimer({ timer }: ISingleTimerProps) { const timerSign = diff >= 0 ? '+' : '-' + const isCountingDown = timer.mode?.type === 'countdown' && diff < 0 && isRunning + return (
{timer.label} @@ -61,7 +62,7 @@ function SingleTimer({ timer }: ISingleTimerProps) { {p}