mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-01 13:38:03 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3d9fe0157 |
@@ -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 (0–100) 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}
|
||||
|
||||
+3
-2
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user