diff --git a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss index cd6ffab64..ecd4c0eac 100644 --- a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.scss @@ -38,8 +38,6 @@ $progress-bar-br: 3px; .multiprogress-bar__indicator-bar { background-color: var(--background-color-override, $ui-black); opacity: 0.8; - transition: 1s linear; - transition-property: width; .multiprogress-bar--ignore-css-override & { background-color: $ui-black; diff --git a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx index c3b9c943b..feb8bb644 100644 --- a/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx +++ b/apps/client/src/common/components/multi-part-progress-bar/MultiPartProgressBar.tsx @@ -1,5 +1,6 @@ import { MaybeNumber } from 'ontime-types'; +import { useAnimatedProgress } from '../../hooks/useAnimatedProgress'; import { getProgress } from '../../utils/getProgress'; import { cx } from '../../utils/styleUtils'; @@ -34,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) { className = '', } = props; - const percentRemaining = 100 - getProgress(now, complete); + const percentRemaining = 100 - useAnimatedProgress(now, complete); const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0; const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0; const isOvertime = now !== null && now < 0; diff --git a/apps/client/src/common/components/progress-bar/ProgressBar.scss b/apps/client/src/common/components/progress-bar/ProgressBar.scss index 3fe09d6ab..ab974568e 100644 --- a/apps/client/src/common/components/progress-bar/ProgressBar.scss +++ b/apps/client/src/common/components/progress-bar/ProgressBar.scss @@ -14,6 +14,4 @@ $progress-bar-br: 3px; .progress-bar__indicator { height: $progress-bar-size; background-color: var(--timer-progress-override, $accent-color); - transition: 1s linear; - transition-property: width; } diff --git a/apps/client/src/common/components/progress-bar/ProgressBar.tsx b/apps/client/src/common/components/progress-bar/ProgressBar.tsx index 720704260..f8def035e 100644 --- a/apps/client/src/common/components/progress-bar/ProgressBar.tsx +++ b/apps/client/src/common/components/progress-bar/ProgressBar.tsx @@ -1,6 +1,6 @@ import { MaybeNumber } from 'ontime-types'; -import { getProgress } from '../../utils/getProgress'; +import { useAnimatedProgress } from '../../hooks/useAnimatedProgress'; import './ProgressBar.scss'; @@ -12,7 +12,7 @@ interface ProgressBarProps { export default function ProgressBar(props: ProgressBarProps) { const { current, duration, className } = props; - const progress = getProgress(current, duration); + const progress = useAnimatedProgress(current, duration); return (
diff --git a/apps/client/src/common/hooks/useAnimatedProgress.ts b/apps/client/src/common/hooks/useAnimatedProgress.ts new file mode 100644 index 000000000..71f628dbd --- /dev/null +++ b/apps/client/src/common/hooks/useAnimatedProgress.ts @@ -0,0 +1,41 @@ +import { MaybeNumber, Playback } from 'ontime-types'; +import { useEffect, useRef, useState } from 'react'; + +import { getProgress } from '../utils/getProgress'; +import { usePlayback } from './useSocket'; + +/** + * Returns the live completion percentage (0–100) of a countdown, interpolated locally. + */ +export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number { + const playback = usePlayback(); + const isRunning = playback === Playback.Play || playback === Playback.Roll; + + const baseline = useRef({ current, at: performance.now() }); + const [, setTick] = useState(0); + + // there is only something to animate while a running timer is counting down towards 0 + const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null; + + // re-anchor to the authoritative value whenever the server pushes a new timer update + useEffect(() => { + baseline.current = { current, at: performance.now() }; + }, [current, duration, playback]); + + // while counting down, re-render every animation frame so the derived progress stays smooth + useEffect(() => { + if (!shouldAnimate) { + return; + } + let frame = requestAnimationFrame(function tick() { + setTick((value) => value + 1); + frame = requestAnimationFrame(tick); + }); + return () => cancelAnimationFrame(frame); + }, [shouldAnimate]); + + // derive from the anchor plus elapsed time at render; frozen to the anchor when not running + const anchored = baseline.current.current; + const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored; + return getProgress(value, duration); +} diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.module.scss b/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.module.scss index 0996e06b0..57e3bde28 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.module.scss +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.module.scss @@ -3,7 +3,5 @@ width: 0; border-radius: 1px 0 0 1px; - transition: 1s linear; - transition-property: width; background-color: $gray-200; } diff --git a/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.tsx b/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.tsx index 68c356d81..bcef5e3e1 100644 --- a/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.tsx +++ b/apps/client/src/features/rundown/rundown-event/composite/RundownEventProgressBar.tsx @@ -1,12 +1,12 @@ +import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress'; import { useTimer } from '../../../../common/hooks/useSocket'; -import { getProgress } from '../../../../common/utils/getProgress'; import style from './RundownEventProgressBar.module.scss'; export default function RundownEventProgressBar() { const timer = useTimer(); - const progress = getProgress(timer.current, timer.duration); + const progress = useAnimatedProgress(timer.current, timer.duration); return
; } diff --git a/apps/client/src/views/timeline/TimelineEntry.tsx b/apps/client/src/views/timeline/TimelineEntry.tsx index 723c2d0ba..0c43ff761 100644 --- a/apps/client/src/views/timeline/TimelineEntry.tsx +++ b/apps/client/src/views/timeline/TimelineEntry.tsx @@ -1,8 +1,8 @@ import { Day } from 'ontime-types'; import { CSSProperties, RefObject } from 'react'; +import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress'; import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket'; -import { getProgress } from '../../common/utils/getProgress'; import { alpha, cx } from '../../common/utils/styleUtils'; import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time'; import { useTranslation } from '../../translation/TranslationProvider'; @@ -169,7 +169,7 @@ function TimelineEntryStatus({ /** Generates a block level progress bar */ function ActiveBlock() { const { current, duration } = useTimer(); - const progress = getProgress(current, duration); + const progress = useAnimatedProgress(current, duration); return (
);