Compare commits

...

3 Commits

Author SHA1 Message Date
Carlos Valente b13d5792f3 fixup! feat: implement speed controls 2025-03-02 14:45:40 +01:00
Carlos Valente d0027d4016 fixup! feat: implement speed controls 2025-03-02 14:45:32 +01:00
Carlos Valente ae1639802c feat: implement speed controls 2025-03-02 14:13:53 +01:00
21 changed files with 392 additions and 89 deletions
@@ -1,9 +1,12 @@
import { type PropsWithChildren } from 'react';
import { useNavigate } from 'react-router-dom';
import { cx } from '../../../utils/styleUtils';
import style from './AppLink.module.scss';
interface AppLinkProps {
className?: string;
search: string;
}
@@ -12,13 +15,13 @@ interface AppLinkProps {
* Handles the path to respect Ontime Clouds base URL
*/
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
const { search, children } = props;
const { className, search, children } = props;
const navigate = useNavigate();
const handleClick = () => navigate({ search });
return (
<button onClick={handleClick} className={style.link}>
<button onClick={handleClick} className={cx([style.link, className])}>
{children}
</button>
);
+20
View File
@@ -41,6 +41,17 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
visible: state.message.timer.secondarySource === 'external',
}));
export const useTimerSchedule = createSelector((state: RuntimeStore) => ({
startedAt: state.timer.startedAt,
expectedFinish: state.timer.expectedFinish,
phase: state.timer.phase,
playback: state.timer.playback,
}));
export const useTimerCurrent = createSelector((state: RuntimeStore) => ({
current: state.timer.current,
}));
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
blink: state.message.timer.blink,
blackout: state.message.timer.blackout,
@@ -114,6 +125,15 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
};
export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
speed: state.timer.speed,
}));
export const setTimerSpeed = {
getSpeed: () => socketSendJson('get-speed'),
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
};
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
selectedEventId: state.eventNow?.id ?? null,
}));
@@ -6,6 +6,7 @@ import AddTime from './add-time/AddTime';
import { AuxTimer } from './aux-timer/AuxTimer';
import PlaybackButtons from './playback-buttons/PlaybackButtons';
import PlaybackTimer from './playback-timer/PlaybackTimer';
import TimerSpeed from './timer-speed/TimerSpeed';
import style from './PlaybackControl.module.scss';
@@ -14,7 +15,7 @@ export default function PlaybackControl() {
return (
<div className={style.mainContainer}>
<PlaybackTimer playback={data.playback as Playback}>
<PlaybackTimer playback={data.playback}>
<AddTime playback={data.playback} />
</PlaybackTimer>
<PlaybackButtons
@@ -23,6 +24,7 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<TimerSpeed isPlaying={data.playback === Playback.Play} eventIndex={data.selectedEventIndex} />
<AuxTimer />
</div>
);
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -16,7 +17,9 @@ interface AddTimeProps {
playback: Playback;
}
export default function AddTime(props: AddTimeProps) {
export default memo(AddTime);
function AddTime(props: AddTimeProps) {
const { playback } = props;
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
@@ -1,9 +1,6 @@
.timeContainer {
display: grid;
grid-template-areas:
'indicators timer addtime'
'status status addtime';
grid-template-rows: 1fr auto;
grid-template-areas: 'indicators timer addtime';
grid-template-columns: 1.25rem 1fr 6.5rem;
gap: $element-inner-spacing;
}
@@ -50,29 +47,3 @@
background-color: $playback-negative;
}
}
// ---------> LABELS
.status {
grid-area: status;
height: 1.5rem;
display: flex;
gap: $section-spacing;
margin-left: 1.5rem;
}
.tag {
color: $label-gray;
font-size: calc(1rem - 2px);
margin-right: 0.25rem;
}
.time {
color: $section-white;
font-size: $text-body-size;
}
.rolltag {
color: $ontime-roll;
font-size: $text-body-size;
}
@@ -1,11 +1,8 @@
import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToString } from 'ontime-utils';
import { Playback, TimerPhase } from 'ontime-types';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import { useTimer } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { formatDuration } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
@@ -36,7 +33,16 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const rollLabel = (() => {
if (!isRolling) {
return '';
}
if (isWaiting) {
return 'Roll: Countdown to start';
}
return 'Roll mode active';
})();
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
@@ -52,55 +58,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
</Tooltip>
</div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
<div className={style.status}>
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
)}
</div>
{children}
</div>
);
}
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
playback: Playback;
}
function RunningStatus(props: RunningStatusProps) {
const { startedAt, expectedFinish, playback } = props;
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
return (
<>
<span className={style.start}>
<span className={style.tag}>Started at</span>
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</span>
</>
);
}
function StoppedStatus() {
const { data } = useReport();
const hasReport = Object.keys(data).length > 0;
if (hasReport) {
return <AppLink search='settings=feature_settings__report'>Go to report management</AppLink>;
}
return null;
}
@@ -9,6 +9,7 @@
color: $timer-color;
line-height: 0.9em;
text-align: center;
align-self: center;
letter-spacing: 0.1em;
font-weight: 600;
font-size: 3.5rem;
@@ -0,0 +1,88 @@
import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import { useClock, useTimerCurrent, useTimerSchedule } from '../../../../common/hooks/useSocket';
import useReport from '../../../../common/hooks-query/useReport';
import { cx } from '../../../../common/utils/styleUtils';
import { formatTime } from '../../../../common/utils/time';
import style from './TimerSpeed.module.scss';
interface MeetScheduleProps {
speed: number;
newSpeed: number;
}
/**
* We isolate this component since it has the potential to re-render on every second
*/
export default function TimerSchedule(props: MeetScheduleProps) {
const { speed, newSpeed } = props;
const { startedAt, expectedFinish, phase, playback } = useTimerSchedule();
const started = formatTime(startedAt);
const normalisedExpectedEnd = expectedFinish !== null ? expectedFinish % dayInMs : null;
const endTime = formatTime(normalisedExpectedEnd);
const isWaiting = phase === TimerPhase.Pending;
const hasNewSpeed = speed !== newSpeed;
if (isWaiting) {
return <div className={cx([style.entry, style.roll])}>Roll: Countdown to start</div>;
}
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
return (
<div className={style.timers}>
<div className={style.entry}>
<div className={style.timerLabel}>Started at</div>
<div>{started}</div>
</div>
<div className={style.entry}>
<div className={style.timerLabel}>Expected end</div>
{hasNewSpeed ? <SpeedFinish newSpeed={newSpeed} /> : <div>{endTime}</div>}
</div>
</div>
);
}
// TODO: extract and test
// calculate the new finish time
function useExpectedTime(remainingTimeMs: number, speedFactor: number): number {
const { clock } = useClock();
const adjustedRemainingTimeMs = remainingTimeMs / speedFactor;
const newFinishTimeMs = clock + adjustedRemainingTimeMs;
return newFinishTimeMs;
}
function StoppedStatus() {
const { data } = useReport();
const hasReport = Object.keys(data).length > 0;
if (hasReport) {
return (
<AppLink className={style.entry} search='settings=feature_settings__report'>
Go to report management
</AppLink>
);
}
return <div className={style.entry}>No running playback</div>;
}
interface SpeedFinishProps {
newSpeed: number;
}
function SpeedFinish(props: SpeedFinishProps) {
const { newSpeed } = props;
const { current } = useTimerCurrent();
const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed));
return <div className={style.highlight}>{newFinish}</div>;
}
@@ -0,0 +1,48 @@
.panelContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.inlineApart {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.inlineSiblings {
display: flex;
gap: 0.5rem;
}
.timers {
display: grid;
grid-template-columns: 1fr 1fr;
}
.entry {
display: flex;
gap: 0.25rem;
height: 1.2rem;
align-items: end;
line-height: 1em;
}
.timerLabel {
color: $label-gray;
font-size: calc(1rem - 3px);
}
.roll {
color: $ontime-roll;
}
.highlight {
color: $highlight-orange;
}
.disabled {
opacity: $opacity-disabled;
}
@@ -0,0 +1,78 @@
import { memo, useEffect, useState } from 'react';
import { Button, Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack } from '@chakra-ui/react';
import { MaybeNumber } from 'ontime-types';
import { setTimerSpeed, useTimerSpeed } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import TimerSchedule from './TimerSchedule';
import style from './TimerSpeed.module.scss';
interface TimerSpeedProps {
isPlaying: boolean;
eventIndex: MaybeNumber;
}
export default memo(TimerSpeed);
function TimerSpeed(props: TimerSpeedProps) {
const { isPlaying, eventIndex } = props;
const { speed } = useTimerSpeed();
const [newSpeed, setNewSpeed] = useState(1);
const { setSpeed } = setTimerSpeed;
// when a new timer is set, we want to reset the speed
useEffect(() => {
setNewSpeed(1);
}, [eventIndex]);
const handleApply = () => setSpeed(newSpeed);
const handleReset = () => {
setNewSpeed(1.0);
setSpeed(1.0);
};
const canReset = isPlaying && speed === newSpeed && newSpeed !== 1;
const canApply = isPlaying && speed !== newSpeed;
const willChangeSpeed = speed === 1 && !canApply;
return (
<div className={style.panelContainer}>
<TimerSchedule speed={speed} newSpeed={newSpeed} />
<div className={style.inlineApart}>
<div className={style.entry}>
<span className={cx([willChangeSpeed && style.disabled])}>{`${speed}x`}</span>
{newSpeed !== speed && <span className={style.highlight}>{`${newSpeed}x`}</span>}
</div>
<div className={style.inlineSiblings}>
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset} isDisabled={!canReset}>
Reset
</Button>
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply} isDisabled={!canApply}>
Apply
</Button>
</div>
</div>
<Slider
variant={newSpeed === 1 ? 'ontime' : 'ontime-highlight'}
defaultValue={newSpeed}
min={0.5}
max={2.0}
step={0.01}
onChange={(v) => setNewSpeed(v)}
value={newSpeed}
isDisabled={!isPlaying}
>
<SliderMark value={0.5}>0.5x</SliderMark>
<SliderMark value={1.0}>1.0x</SliderMark>
<SliderMark value={1.5}>1.5x</SliderMark>
<SliderMark value={2.0}>2.0x</SliderMark>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</div>
);
}
+1
View File
@@ -16,6 +16,7 @@ $warning-orange: $orange-500;
$info-blue: $blue-500;
$opacity-disabled: 0.4;
$active-red: $red-700;
$highlight-orange: $orange-600;
// playback colours
$playback-start: $green-600;
+1 -2
View File
@@ -17,7 +17,7 @@ export const ontimeButtonFilled = {
export const ontimeButtonOutlined = {
backgroundColor: '#2d2d2d', // $gray-1100
color: '#e2e2e2', // $blue-400
color: '#e2e2e2', // $gray-200
border: '1px solid rgba(255, 255, 255, 0.10)', // white-10
_hover: {
backgroundColor: '#404040', // $gray-1000
@@ -74,5 +74,4 @@ export const ontimeButtonGhosted = {
export const ontimeButtonSubtleWhite = {
...ontimeButtonSubtle,
color: '#f6f6f6', // $gray-50
fontWeight: 600,
};
+25
View File
@@ -0,0 +1,25 @@
export const ontimeSlider = {
thumb: {
background: '#f6f6f6', // $ui-white
width: '0.25rem',
},
filledTrack: {
background: '#779BE7', // $blue-400
},
track: {
background: '#303030', // $gray-1050
},
mark: {
color: '#b1b1b1', // $label-gray
mt: '2',
ml: '-2.5',
fontSize: 'calc(1rem - 3px)',
},
};
export const ontimeHighlightSlider = {
...ontimeSlider,
filledTrack: {
background: '#FFAB33', // $highlight-orange
},
};
+7
View File
@@ -16,6 +16,7 @@ import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal } from './ontimeModal';
import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect';
import { ontimeHighlightSlider, ontimeSlider } from './ontimeSlider';
import { ontimeSwitch } from './ontimeSwitch';
import { ontimeTab } from './ontimeTab';
import {
@@ -140,6 +141,12 @@ const theme = extendTheme({
ontime: { ...ontimeSelect },
},
},
Slider: {
variants: {
ontime: { ...ontimeSlider },
'ontime-highlight': { ...ontimeHighlightSlider },
},
},
},
});
@@ -259,6 +259,20 @@ const actionHandlers: Record<string, ActionHandler> = {
}
throw new Error('No matching method provided');
},
/* Speed */
'get-speed': () => {
const factor = runtimeService.getSpeed();
return { payload: factor };
},
'set-speed': (payload) => {
const speedToSet = numberOrError(payload);
const speedSet = runtimeService.setSpeed(speedToSet);
return { payload: speedSet };
},
'reset-speed': () => {
const factor = runtimeService.resetSpeed();
return { payload: factor };
},
/* Client */
client: (payload) => {
assert.isObject(payload);
@@ -666,6 +666,43 @@ class RuntimeService {
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
}
}
/**
* @returns {number} speed factor currently applied
*/
public getSpeed(): number {
return runtimeState.getSpeed();
}
/**
* Applies a speed factor to current timer
* @param {number} speed - speed factor
* @returns {number} applied speed factor
*/
public setSpeed(speed: number): number {
if (speed < 0.5 || speed > 2.0) {
logger.warning(LogOrigin.Server, `Speed out of bounds: ${speed}`);
return;
}
const currentState = runtimeState.getState();
if (currentState.eventNow === null) {
logger.warning(LogOrigin.Server, 'No event running to set speed to');
}
const newSpeed = runtimeState.setSpeed(speed);
logger.info(LogOrigin.Server, `Speed set to ${newSpeed}`);
return newSpeed;
}
/**
* Resets the speed of the current timer
*/
public resetSpeed(): number {
const speed = runtimeState.resetSpeed();
logger.info(LogOrigin.Server, `Speed set to ${speed}`);
return speed;
}
}
// calculate at 30fps, refresh at 1fps
+17
View File
@@ -37,6 +37,23 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
return timeEnd + addedTime + pausedTime;
}
// TODO: need to calculate the expected end taking the speed into account
/**
* // For normal timer events:
// 1. Compute how much real time (excluding paused time) has elapsed.
const realElapsed = clock - startedAt - pausedTime;
// 2. Translate real elapsed time to virtual elapsed time using the speed factor.
const virtualElapsed = realElapsed * speed;
// 3. The virtual total time is the base duration plus any added time.
const virtualTotal = duration! + addedTime;
// 4. The remaining virtual time is the total virtual time minus whats already elapsed.
const virtualRemaining = virtualTotal - virtualElapsed;
// 5. Convert the remaining virtual time back to real time.
const remainingReal = virtualRemaining / speed;
// 6. The new expected finish time is the current clock plus the adjusted remaining time.
const adjustedFinish = clock + remainingReal;
*/
// handle events that finish the day after
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
@@ -32,6 +32,7 @@ const baseState: RuntimeState = {
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
speed: 1,
},
_timer: {
forceFinish: null,
+27
View File
@@ -350,6 +350,26 @@ export function updateAll(rundown: OntimeRundown) {
loadBlock(rundown);
}
export function setSpeed(speed: number): number {
const remainingMs = runtimeState.timer.current;
const adjustedRemainingTimeMs = remainingMs / speed;
const newFinishTimeMs = runtimeState.clock + adjustedRemainingTimeMs;
runtimeState.timer.speed = speed;
runtimeState.timer.expectedFinish = newFinishTimeMs;
return runtimeState.timer.speed;
}
export function resetSpeed() {
runtimeState.timer.speed = 1.0;
return runtimeState.timer.speed;
}
export function getSpeed() {
return runtimeState.timer.speed;
}
export function start(state: RuntimeState = runtimeState): boolean {
if (state.eventNow === null) {
return false;
@@ -464,6 +484,7 @@ export type UpdateResult = {
};
export function update(): UpdateResult {
const timeSinceLastUpdate = clock.timeNow() - runtimeState.clock;
// 0. there are some things we always do
const previousClock = runtimeState.clock;
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
@@ -490,6 +511,12 @@ export function update(): UpdateResult {
}
}
const catchUpMultiplier = 1 - runtimeState.timer.speed;
if (runtimeState.timer.playback === Playback.Play) {
runtimeState.timer.addedTime += timeSinceLastUpdate * catchUpMultiplier;
}
// update timer state
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
@@ -16,6 +16,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
speed: 1.0, // change initiated by user
},
onAir: false,
message: {
@@ -30,4 +30,6 @@ export type TimerState = {
secondaryTimer: MaybeNumber;
/** only if timer has already started */
startedAt: MaybeNumber;
/** the speed of the current timer 1.0 = realtime, 2.0 = double time */
speed: number;
};