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 (
);