Compare commits

..

1 Commits

Author SHA1 Message Date
Carlos Valente d3d9fe0157 refactor(progress bar): reset when event changes 2026-08-01 12:52:45 +02:00
13 changed files with 100 additions and 150 deletions
@@ -9,6 +9,7 @@ import './MultiPartProgressBar.scss';
interface MultiPartProgressBar {
now: MaybeNumber;
complete: MaybeNumber;
eventId?: string | null;
normalColor: string;
warning?: MaybeNumber;
warningColor: string;
@@ -24,6 +25,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
const {
now,
complete,
eventId,
normalColor,
warning,
warningColor,
@@ -35,7 +37,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '',
} = props;
const percentRemaining = 100 - useAnimatedProgress(now, complete);
const percentRemaining = 100 - useAnimatedProgress(now, complete, eventId);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
@@ -7,12 +7,12 @@ import './ProgressBar.scss';
interface ProgressBarProps {
current: MaybeNumber;
duration: MaybeNumber;
eventId?: string | null;
className?: string;
}
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const progress = useAnimatedProgress(current, duration);
export default function ProgressBar({ current, duration, eventId, className }: ProgressBarProps) {
const progress = useAnimatedProgress(current, duration, eventId);
return (
<div className={`progress-bar__bg ${className}`}>
@@ -1,26 +1,34 @@
import { MaybeNumber, Playback } from 'ontime-types';
import { EntryId, MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket';
import { useIsOnline, usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber, eventId?: EntryId | null): number {
const playback = usePlayback();
const isOnline = useIsOnline();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() });
const baseline = useRef({ current, duration, eventId, playback, at: performance.now() });
const [, setTick] = useState(0);
const now = performance.now();
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
const hasAuthoritativeUpdate =
baseline.current.current !== current || // handle timer updates
baseline.current.duration !== duration || // handle duration changes
baseline.current.eventId !== eventId || // handle event changing
baseline.current.playback !== playback; // handle playback changes
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
if (hasAuthoritativeUpdate) {
// Reset during render so an event change is reflected in this very paint.
baseline.current = { current, duration, eventId, playback, at: now };
}
// There is only something to animate while a connected timer is counting down towards 0.
const shouldAnimate = isOnline && isRunning && current !== null && current > 0 && duration !== null;
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
@@ -34,8 +42,8 @@ export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber)
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
// Derive from the anchor plus elapsed time at render; freeze while disconnected or not running.
const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
const value = isOnline && isRunning && anchored !== null ? anchored - (now - baseline.current.at) : anchored;
return getProgress(value, duration);
}
@@ -163,6 +163,7 @@ export const useNextFlag = createSelector((state: RuntimeStore) => ({
export const useProgressData = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
duration: state.timer.duration,
eventId: state.eventNow?.id ?? null,
timeWarning: state.eventNow?.timeWarning ?? null,
timeDanger: state.eventNow?.timeDanger ?? null,
}));
@@ -10,12 +10,13 @@ interface StatusBarProgressProps {
}
export default function StatusBarProgress({ viewSettings }: StatusBarProgressProps) {
const { current, duration, timeWarning, timeDanger } = useProgressData();
const { current, duration, eventId, timeWarning, timeDanger } = useProgressData();
return (
<MultiPartProgressBar
now={current}
complete={duration}
eventId={eventId}
normalColor={viewSettings.normalColor}
warning={timeWarning}
warningColor={viewSettings.warningColor}
@@ -1,12 +1,13 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useTimer } from '../../../../common/hooks/useSocket';
import { useSelectedEventId, useTimer } from '../../../../common/hooks/useSocket';
import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() {
const timer = useTimer();
const eventId = useSelectedEventId();
const progress = useAnimatedProgress(timer.current, timer.duration);
const progress = useAnimatedProgress(timer.current, timer.duration, eventId);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
}
@@ -112,7 +112,14 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
<BackstageClock timeformat={timeformat} />
</div>
{showProgress && <ProgressBar className='progress-container' current={time.current} duration={time.duration} />}
{showProgress && (
<ProgressBar
className='progress-container'
current={time.current}
duration={time.duration}
eventId={selectedEventId}
/>
)}
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
@@ -6,12 +6,13 @@ import styles from './CuesheetProgress.module.scss';
export default function CuesheetProgress() {
const { data } = useViewSettings();
const { current, duration, timeWarning, timeDanger } = useProgressData();
const { current, duration, eventId, timeWarning, timeDanger } = useProgressData();
return (
<MultiPartProgressBar
now={current}
complete={duration}
eventId={eventId}
normalColor={data.normalColor}
warning={timeWarning}
warningColor={data.warningColor}
@@ -96,6 +96,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
eventId={eventNow?.id}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}
@@ -1,7 +1,5 @@
@use '@/theme/viewerDefs' as *;
$content-width: min(100%, 1100px);
.project {
margin: 0;
box-sizing: border-box; /* reset */
@@ -18,100 +16,56 @@ $content-width: min(100%, 1100px);
display: flex;
flex-direction: column;
/* =================== HEADER ===================*/
.project-header {
width: $content-width;
margin-inline: auto;
display: flex;
align-items: center;
gap: clamp(12px, 2vw, 24px);
padding-bottom: $view-element-gap;
border-bottom: 1px solid $white-10;
}
.logo {
max-width: min(200px, 30vw);
}
.title {
font-size: $header-font-size;
font-weight: 600;
line-height: 1.1em;
}
.description {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
}
/* =================== CONTENT ===================*/
.info {
flex: 1;
width: $content-width;
max-height: 100%;
margin-inline: auto;
overflow-y: auto;
width: min(calc(100vw - 4rem), 960px);
display: flex;
flex-direction: column;
align-items: start;
gap: $view-element-gap;
padding-block: $view-element-gap;
padding-bottom: 10vh;
}
.info__card {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
border-radius: $element-border-radius;
padding: $view-block-padding $view-inline-padding;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.info__media {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: $view-element-gap;
}
.info__media .info__value {
flex: 1;
min-width: 0;
}
.info__label {
font-size: $timer-label-size;
font-weight: 600;
letter-spacing: 0.05em;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.info__value {
white-space: break-spaces;
line-height: 1.35;
overflow-wrap: anywhere;
}
.info__custom {
display: flex;
gap: 1rem;
}
.info__image-container {
flex: 0 0 min(128px, 25%);
display: flex;
justify-content: center;
align-items: center;
width: 192px;
height: 192px;
}
.info__image {
display: block;
width: 100%;
height: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.link.info__value {
display: inline-flex;
gap: 0.35em;
display: flex;
gap: $view-element-gap;
align-items: center;
color: $action-text-color;
@@ -124,13 +78,12 @@ $content-width: min(100%, 1100px);
/* =================== MOBILE ===================*/
@media screen and (max-width: 768px) {
.project {
.project-header {
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.logo img {
height: min(50px, 10vh);
}
.info__image-container {
width: 96px;
height: 96px;
}
}
}
@@ -1,5 +1,4 @@
import { OntimeView } from 'ontime-types';
import { type ReactNode, useState } from 'react';
import { IoOpenOutline } from 'react-icons/io5';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -42,50 +41,56 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return (
<>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
</>
);
}
const hasHeader = Boolean(projectData.logo || projectData.title || projectData.description);
return (
<div className={`project ${isMirrored ? 'mirror' : ''}`} data-testid='project-view'>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
{hasHeader && (
<div className='project-header'>
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='project-header__text'>
{projectData.title && <div className='title'>{projectData.title}</div>}
{projectData.description && <div className='description'>{projectData.description}</div>}
</div>
</div>
)}
{projectData.logo && <ViewLogo name={projectData.logo} className='logo' />}
<div className='info'>
{projectData.info && <InfoCard label={getLocalizedString('project.info')}>{projectData.info}</InfoCard>}
{projectData.title && (
<div>
<div className='info__label'>{getLocalizedString('project.title')}</div>
<div className='info__value'>{projectData.title}</div>
</div>
)}
{projectData.description && (
<div>
<div className='info__label'>{getLocalizedString('project.description')}</div>
<div className='info__value'>{projectData.description}</div>
</div>
)}
{projectData.info && (
<div>
<div className='info__label'>{getLocalizedString('project.info')}</div>
<div className='info__value'>{projectData.info}</div>
</div>
)}
{projectData.url && (
<div className='info__card'>
<div>
<div className='info__label'>{getLocalizedString('project.url')}</div>
<a href={projectData.url} target='_blank' rel='noreferrer' className='info__value link'>
{projectData.url}
<IoOpenOutline style={{ fontSize: '1em' }} />
{projectData.url} <IoOpenOutline style={{ fontSize: '1em' }} />
</a>
</div>
)}
{projectData.custom.map((info, idx) => {
const hasImage = Boolean(info.url);
const hasUrl = Boolean(info.url);
return (
// oxlint-disable-next-line react/no-array-index-key - we only have the index to go of here
<div key={`${info.title}-${idx}`} className='info__card'>
{info.title && <div className='info__label'>{info.title}</div>}
{hasImage ? (
<div className='info__media'>
<InfoImage src={info.url} />
{info.value && <div className='info__value'>{info.value}</div>}
<div key={`${info.title}-${idx}`} className='info__custom'>
{hasUrl && (
<div className='info__image-container'>
<img className='info__image' src={info.url} loading='lazy' />
</div>
) : (
info.value && <div className='info__value'>{info.value}</div>
)}
<div>
<div className='info__label'>{info.title}</div>
<div className='info__value'>{info.value}</div>
</div>
</div>
);
})}
@@ -93,35 +98,3 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
</div>
);
}
interface InfoCardProps {
label: string;
children: ReactNode;
}
function InfoCard({ label, children }: InfoCardProps) {
return (
<div className='info__card'>
<div className='info__label'>{label}</div>
<div className='info__value'>{children}</div>
</div>
);
}
/**
* Renders an user provided image, collapsing itself if the image fails to load
* We remove the container along with the image to avoid leaving an empty gap in the card
*/
function InfoImage({ src }: { src: string }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return null;
}
return (
<div className='info__image-container'>
<img className='info__image' src={src} loading='lazy' alt='' onError={() => setHasError(true)} />
</div>
);
}
@@ -2,7 +2,7 @@ import { Day } from 'ontime-types';
import { CSSProperties, RefObject } from 'react';
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { useExpectedStartData, useSelectedEventId, useTimer } from '../../common/hooks/useSocket';
import { alpha, cx } from '../../common/utils/styleUtils';
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
@@ -169,7 +169,8 @@ function TimelineEntryStatus({
/** Generates a block level progress bar */
function ActiveBlock() {
const { current, duration } = useTimer();
const progress = useAnimatedProgress(current, duration);
const eventId = useSelectedEventId();
const progress = useAnimatedProgress(current, duration, eventId);
return (
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
);
+1
View File
@@ -195,6 +195,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
now={time.current}
complete={totalTime}
eventId={eventNow?.id}
normalColor={viewSettings.normalColor}
warning={eventNow?.timeWarning}
warningColor={viewSettings.warningColor}