Keep offset when taking over playback with roll v2 (#1184)

* pass on offset

* account for offset in roll

* prevent roll from overtime

* add test
This commit is contained in:
Alex Christoffer Rasmussen
2024-08-26 18:09:03 +02:00
committed by GitHub
parent 1b1823e0fe
commit ee1b5b7fdd
8 changed files with 160 additions and 51 deletions
@@ -50,6 +50,7 @@ export const usePlaybackControl = () => {
playback: state.timer.playback, playback: state.timer.playback,
selectedEventIndex: state.runtime.selectedEventIndex, selectedEventIndex: state.runtime.selectedEventIndex,
numEvents: state.runtime.numEvents, numEvents: state.runtime.numEvents,
timerPhase: state.timer.phase,
}); });
return useRuntimeStore(featureSelector); return useRuntimeStore(featureSelector);
@@ -21,6 +21,7 @@ export default function PlaybackControl() {
playback={data.playback} playback={data.playback}
numEvents={data.numEvents} numEvents={data.numEvents}
selectedEventIndex={data.selectedEventIndex} selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/> />
<AuxTimer /> <AuxTimer />
</div> </div>
@@ -6,7 +6,7 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
import { IoReload } from '@react-icons/all-files/io5/IoReload'; import { IoReload } from '@react-icons/all-files/io5/IoReload';
import { IoStop } from '@react-icons/all-files/io5/IoStop'; import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime'; import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { Playback } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils'; import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
@@ -19,10 +19,11 @@ interface PlaybackButtonsProps {
playback: Playback; playback: Playback;
numEvents: number; numEvents: number;
selectedEventIndex: number | null; selectedEventIndex: number | null;
timerPhase: TimerPhase;
} }
export default function PlaybackButtons(props: PlaybackButtonsProps) { export default function PlaybackButtons(props: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex } = props; const { playback, numEvents, selectedEventIndex, timerPhase } = props;
const isRolling = playback === Playback.Roll; const isRolling = playback === Playback.Roll;
const isPlaying = playback === Playback.Play; const isPlaying = playback === Playback.Play;
@@ -37,7 +38,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) {
const disableNext = isRolling || noEvents || isLast; const disableNext = isRolling || noEvents || isLast;
const disablePrev = isRolling || noEvents || isFirst; const disablePrev = isRolling || noEvents || isFirst;
const playbackCan = validatePlayback(playback); const playbackCan = validatePlayback(playback, timerPhase);
const disableStart = !playbackCan.start; const disableStart = !playbackCan.start;
const disablePause = !playbackCan.pause; const disablePause = !playbackCan.pause;
const disableRoll = !playbackCan.roll || noEvents; const disableRoll = !playbackCan.roll || noEvents;
-9
View File
@@ -1,5 +1,3 @@
import { OntimeRundown } from 'ontime-types';
import * as runtimeState from '../stores/runtimeState.js'; import * as runtimeState from '../stores/runtimeState.js';
import type { UpdateResult } from '../stores/runtimeState.js'; import type { UpdateResult } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js'; import { timerConfig } from '../config/config.js';
@@ -100,13 +98,6 @@ export class EventTimer {
this.onUpdateCallback?.(updateResult); this.onUpdateCallback?.(updateResult);
} }
/**
* Loads roll information into timer service
*/
roll(rundown: OntimeRundown) {
return runtimeState.roll(rundown);
}
shutdown() { shutdown() {
clearInterval(this._interval); clearInterval(this._interval);
clearTimeout(this.endCallback); clearTimeout(this.endCallback);
@@ -9,6 +9,7 @@ import {
RuntimeStore, RuntimeStore,
TimerLifeCycle, TimerLifeCycle,
TimerPhase, TimerPhase,
TimerState,
} from 'ontime-types'; } from 'ontime-types';
import { millisToString, validatePlayback } from 'ontime-utils'; import { millisToString, validatePlayback } from 'ontime-utils';
@@ -86,18 +87,18 @@ class RuntimeService {
// 2. handle edge cases related to roll // 2. handle edge cases related to roll
if (newState.timer.playback === Playback.Roll) { if (newState.timer.playback === Playback.Roll) {
// check if we need to call any side effects // check if we need to call any side effects
const keepOffset = newState.runtime.offset;
if (hasSecondaryTimerFinished) { if (hasSecondaryTimerFinished) {
// if the secondary timer has finished, we need to call roll // if the secondary timer has finished, we need to call roll
// since event is already loaded // since event is already loaded
this.rollLoaded(); this.rollLoaded(keepOffset);
} else if (hasTimerFinished) { } else if (hasTimerFinished) {
// if the timer has finished, we need to load next and keep rolling // if the timer has finished, we need to load next and keep rolling
process.nextTick(() => { process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish); integrationService.dispatch(TimerLifeCycle.onFinish);
}); });
this.handleLoadNext(); this.handleLoadNext();
this.rollLoaded(); this.rollLoaded(keepOffset);
} else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) { } else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) {
// if we have skipped out of the event, we will recall roll // if we have skipped out of the event, we will recall roll
// to push the playback to the right place // to push the playback to the right place
@@ -271,16 +272,17 @@ class RuntimeService {
/** /**
* makes calls for loading and starting given event * makes calls for loading and starting given event
* @param {PlayableEvent} event * @param {PlayableEvent} event
* @param {Partial<TimerState & RestorePoint>} initialData
* @return {boolean} success - whether an event was loaded * @return {boolean} success - whether an event was loaded
*/ */
private loadEvent(event: OntimeEvent): boolean { private loadEvent(event: OntimeEvent, initialData?: Partial<TimerState & RestorePoint>): boolean {
if (!isPlayableEvent(event)) { if (!isPlayableEvent(event)) {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
return false; return false;
} }
const rundown = getRundown(); const rundown = getRundown();
const success = runtimeState.load(event, rundown); const success = runtimeState.load(event, rundown, initialData);
if (success) { if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
@@ -416,11 +418,15 @@ class RuntimeService {
* *
* we need to isolate handleLoadNext so we have control over the side effects * we need to isolate handleLoadNext so we have control over the side effects
* startSelected being a private function does not trigger emits * startSelected being a private function does not trigger emits
* and pass on runtime offset in case of roll mode
*/ */
private handleLoadNext(): boolean { private handleLoadNext(): boolean {
const state = runtimeState.getState(); const state = runtimeState.getState();
const nextEvent = findNext(state.eventNow?.id); const nextEvent = findNext(state.eventNow?.id);
if (nextEvent) { if (nextEvent) {
if (state.timer.playback === Playback.Roll) {
return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart });
}
return this.loadEvent(nextEvent); return this.loadEvent(nextEvent);
} }
@@ -445,7 +451,7 @@ class RuntimeService {
*/ */
private handleStart(): boolean { private handleStart(): boolean {
const previousState = runtimeState.getState(); const previousState = runtimeState.getState();
const canStart = validatePlayback(previousState.timer.playback).start; const canStart = validatePlayback(previousState.timer.playback, previousState.timer.phase).start;
if (!canStart) { if (!canStart) {
return false; return false;
} }
@@ -501,7 +507,7 @@ class RuntimeService {
@broadcastResult @broadcastResult
public pause() { public pause() {
const state = runtimeState.getState(); const state = runtimeState.getState();
const canPause = validatePlayback(state.timer.playback).pause; const canPause = validatePlayback(state.timer.playback, state.timer.phase).pause;
if (!canPause) { if (!canPause) {
return; return;
} }
@@ -519,7 +525,7 @@ class RuntimeService {
@broadcastResult @broadcastResult
public stop(): boolean { public stop(): boolean {
const state = runtimeState.getState(); const state = runtimeState.getState();
const canStop = validatePlayback(state.timer.playback).stop; const canStop = validatePlayback(state.timer.playback, state.timer.phase).stop;
if (!canStop) { if (!canStop) {
return false; return false;
} }
@@ -551,10 +557,10 @@ class RuntimeService {
/** /**
* Handles special case to call roll on a loaded event which we do not want to discard * Handles special case to call roll on a loaded event which we do not want to discard
*/ */
private rollLoaded() { private rollLoaded(offset?: number) {
const rundown = getRundown(); const rundown = getRundown();
try { try {
this.eventTimer.roll(rundown); runtimeState.roll(rundown, offset);
} catch (error) { } catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`); logger.error(LogOrigin.Server, `Roll: ${error}`);
} }
@@ -567,7 +573,7 @@ class RuntimeService {
public roll(skipCheck: boolean = false) { public roll(skipCheck: boolean = false) {
const previousState = runtimeState.getState(); const previousState = runtimeState.getState();
if (!skipCheck) { if (!skipCheck) {
const canRoll = validatePlayback(previousState.timer.playback).roll; const canRoll = validatePlayback(previousState.timer.playback, previousState.timer.phase).roll;
if (!canRoll) { if (!canRoll) {
return; return;
} }
@@ -575,7 +581,7 @@ class RuntimeService {
try { try {
const rundown = getRundown(); const rundown = getRundown();
const result = this.eventTimer.roll(rundown); const result = runtimeState.roll(rundown);
if (result.eventId !== previousState.eventNow?.id) { if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
process.nextTick(() => { process.nextTick(() => {
@@ -1,7 +1,7 @@
import { PlayableEvent, Playback } from 'ontime-types'; import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils'; import { deepmerge } from 'ontime-utils';
import { RuntimeState, addTime, clear, getState, load, pause, start, stop } from '../runtimeState.js'; import { RuntimeState, addTime, clear, getState, load, pause, roll, start, stop } from '../runtimeState.js';
import { initRundown } from '../../services/rundown-service/RundownService.js'; import { initRundown } from '../../services/rundown-service/RundownService.js';
const mockEvent = { const mockEvent = {
@@ -227,7 +227,103 @@ describe('mutation on runtimeState', () => {
}); });
test.todo('runtime offset on timers in overtime', () => {}); test.todo('runtime offset on timers in overtime', () => {});
});
test.todo('roll mode', () => {}); });
describe('roll mode', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime('jan 1 00:00');
clear();
});
afterEach(() => {
vi.useRealTimers();
});
describe('normal roll', () => {
const rundown = [
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
] as PlayableEvent[];
test('pending event', () => {
const { eventId, didStart } = roll(rundown);
const state = getState();
expect(eventId).toBe('1');
expect(didStart).toBe(false);
expect(state.timer.phase).toBe(TimerPhase.Pending);
expect(state.timer.secondaryTimer).toBe(1000);
});
test('roll events', () => {
vi.setSystemTime('jan 1 00:00:01');
let result = roll(rundown);
expect(result).toStrictEqual({ eventId: '1', didStart: true });
vi.setSystemTime('jan 1 00:00:02');
result = roll(rundown);
expect(result).toStrictEqual({ eventId: '2', didStart: true });
vi.setSystemTime('jan 1 00:00:03:500');
result = roll(rundown);
expect(result).toStrictEqual({ eventId: '3', didStart: true });
});
});
describe('roll takover', () => {
const rundown = [
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
] as PlayableEvent[];
test('from load', () => {
load(rundown[2], rundown);
const result = roll(rundown);
expect(result).toStrictEqual({ eventId: '3', didStart: false });
const state = getState();
expect(state.timer.phase).toBe(TimerPhase.Pending);
expect(state.timer.secondaryTimer).toBe(3000);
});
test('from play', () => {
load(rundown[0], rundown);
start();
const result = roll(rundown);
expect(result).toStrictEqual({ eventId: '1', didStart: false });
expect(getState().runtime.offset).toBe(1000);
});
});
describe('roll continue with offset', () => {
test('no gaps', () => {
const rundown = [
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
] as PlayableEvent[];
load(rundown[0], rundown);
start();
let result = roll(rundown, getState().runtime.offset);
expect(result).toStrictEqual({ eventId: '1', didStart: false });
expect(getState().runtime.offset).toBe(1000);
vi.setSystemTime('jan 1 00:00:01');
result = roll(rundown, getState().runtime.offset);
expect(result).toStrictEqual({ eventId: '2', didStart: true });
expect(getState().runtime.offset).toBe(1000);
vi.setSystemTime('jan 1 00:00:02');
result = roll(rundown, getState().runtime.offset);
expect(result).toStrictEqual({ eventId: '3', didStart: true });
expect(getState().runtime.offset).toBe(1000);
});
test.todo('with gaps', () => {
//this is a bit involved as it also depends somewhat on the RintimeService
});
}); });
}); });
+33 -20
View File
@@ -314,17 +314,15 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
// handle edge cases with roll // handle edge cases with roll
if (runtimeState.timer.playback === Playback.Roll) { if (runtimeState.timer.playback === Playback.Roll) {
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
// if waiting to roll, we update the targets and potentially start the timer // if waiting to roll, we update the targets and potentially start the timer
if (runtimeState._timer.secondaryTarget !== null) { if (runtimeState._timer.secondaryTarget !== null) {
if ( if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
runtimeState.eventNow.timeStart < runtimeState.clock &&
runtimeState.clock < runtimeState.eventNow.timeEnd
) {
// if the event is now, we queue a start // if the event is now, we queue a start
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart; runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
} else { } else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
} }
} }
} }
@@ -516,7 +514,6 @@ export function update(): UpdateResult {
if (finishedNow) { if (finishedNow) {
// reset state // reset state
runtimeState._timer.forceFinish;
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock; runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
} else { } else {
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
@@ -536,14 +533,15 @@ export function update(): UpdateResult {
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received'); throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
} }
} }
//account for offset
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer < 0 }; return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
} }
} }
export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: boolean } { export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString; didStart: boolean } {
// 1. if an event is running, we simply take over the playback // 1. if an event is running, we simply take over the playback
if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) { if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) {
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
@@ -551,7 +549,15 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart:
} }
// 2. if there is an event armed, we use it // 2. if there is an event armed, we use it
if (runtimeState.timer.playback === Playback.Armed && runtimeState.eventNow !== null) { if (runtimeState.timer.playback === Playback.Armed || runtimeState.timer.phase === TimerPhase.Pending) {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null) {
throw new Error('runtimeState.roll: invalid state received');
}
}
runtimeState.runtime.offset = offset;
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
// account for event that finishes the day after // account for event that finishes the day after
@@ -561,14 +567,16 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart:
: runtimeState.eventNow.timeEnd; : runtimeState.eventNow.timeEnd;
runtimeState.timer.expectedFinish = normalisedEndTime; runtimeState.timer.expectedFinish = normalisedEndTime;
//account for offset
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
// state catch up // state catch up
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime);
runtimeState.timer.current = runtimeState.timer.duration; runtimeState.timer.current = runtimeState.timer.duration;
runtimeState.timer.elapsed = 0; runtimeState.timer.elapsed = 0;
// check if the event is ready to start or if needs to be waited // check if the event is ready to start or if needs to be pending
const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, runtimeState.clock); const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock);
if (isNow) { if (isNow) {
runtimeState.timer.startedAt = runtimeState.clock; runtimeState.timer.startedAt = runtimeState.clock;
@@ -579,9 +587,10 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart:
if (!runtimeState.runtime.actualStart) { if (!runtimeState.runtime.actualStart) {
runtimeState.runtime.actualStart = runtimeState.clock; runtimeState.runtime.actualStart = runtimeState.clock;
} }
runtimeState.timer.secondaryTimer = null;
} else { } else {
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
} }
@@ -599,7 +608,11 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart:
clear(); clear();
runtimeState.currentBlock = prevCurrentBlock; runtimeState.currentBlock = prevCurrentBlock;
const { index, isPending } = loadRoll(timedEvents, runtimeState.clock); //account for offset but we only keep it if passed to us
runtimeState.runtime.offset = offset;
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
const { index, isPending } = loadRoll(timedEvents, offsetClock);
// load events in memory along with their data // load events in memory along with their data
loadNow(timedEvents, index); loadNow(timedEvents, index);
@@ -623,8 +636,8 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart:
// there is nothing now, but something coming up // there is nothing now, but something coming up
runtimeState.timer.phase = TimerPhase.Pending; runtimeState.timer.phase = TimerPhase.Pending;
// we need to normalise start time in case it is the day after // we need to normalise start time in case it is the day after
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
// preload timer properties // preload timer properties
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
@@ -1,13 +1,13 @@
import { Playback } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
/** /**
* Simple rules to determine whether a playback action is valid * Simple rules to determine whether a playback action is valid
*/ */
export function validatePlayback(currentPlayback: Playback) { export function validatePlayback(currentPlayback: Playback, timerPhase: TimerPhase) {
return { return {
start: currentPlayback !== Playback.Stop, start: currentPlayback !== Playback.Stop,
pause: currentPlayback === Playback.Play, pause: currentPlayback === Playback.Play,
roll: currentPlayback !== Playback.Roll, roll: currentPlayback !== Playback.Roll && timerPhase !== TimerPhase.Overtime,
stop: currentPlayback !== Playback.Stop, stop: currentPlayback !== Playback.Stop,
reload: currentPlayback !== Playback.Stop && currentPlayback !== Playback.Roll, reload: currentPlayback !== Playback.Stop && currentPlayback !== Playback.Roll,
}; };