From ee1b5b7fdd2c6c5183e28871d323298e844e9eb9 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Mon, 26 Aug 2024 18:09:03 +0200 Subject: [PATCH] Keep offset when taking over playback with roll v2 (#1184) * pass on offset * account for offset in roll * prevent roll from overtime * add test --- apps/client/src/common/hooks/useSocket.ts | 1 + .../control/playback/PlaybackControl.tsx | 1 + .../playback-buttons/PlaybackButtons.tsx | 7 +- apps/server/src/services/EventTimer.ts | 9 -- .../runtime-service/RuntimeService.ts | 30 +++-- .../src/stores/__tests__/runtimeState.test.ts | 104 +++++++++++++++++- apps/server/src/stores/runtimeState.ts | 53 +++++---- .../src/validate-action/validatePlayback.ts | 6 +- 8 files changed, 160 insertions(+), 51 deletions(-) diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index aa4c9b86f..eab5f0f52 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -50,6 +50,7 @@ export const usePlaybackControl = () => { playback: state.timer.playback, selectedEventIndex: state.runtime.selectedEventIndex, numEvents: state.runtime.numEvents, + timerPhase: state.timer.phase, }); return useRuntimeStore(featureSelector); diff --git a/apps/client/src/features/control/playback/PlaybackControl.tsx b/apps/client/src/features/control/playback/PlaybackControl.tsx index c55acf922..4c7210b07 100644 --- a/apps/client/src/features/control/playback/PlaybackControl.tsx +++ b/apps/client/src/features/control/playback/PlaybackControl.tsx @@ -21,6 +21,7 @@ export default function PlaybackControl() { playback={data.playback} numEvents={data.numEvents} selectedEventIndex={data.selectedEventIndex} + timerPhase={data.timerPhase} /> diff --git a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx index 2f05debaf..609026396 100644 --- a/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx +++ b/apps/client/src/features/control/playback/playback-buttons/PlaybackButtons.tsx @@ -6,7 +6,7 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward' import { IoReload } from '@react-icons/all-files/io5/IoReload'; import { IoStop } from '@react-icons/all-files/io5/IoStop'; 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 { setPlayback } from '../../../../common/hooks/useSocket'; @@ -19,10 +19,11 @@ interface PlaybackButtonsProps { playback: Playback; numEvents: number; selectedEventIndex: number | null; + timerPhase: TimerPhase; } export default function PlaybackButtons(props: PlaybackButtonsProps) { - const { playback, numEvents, selectedEventIndex } = props; + const { playback, numEvents, selectedEventIndex, timerPhase } = props; const isRolling = playback === Playback.Roll; const isPlaying = playback === Playback.Play; @@ -37,7 +38,7 @@ export default function PlaybackButtons(props: PlaybackButtonsProps) { const disableNext = isRolling || noEvents || isLast; const disablePrev = isRolling || noEvents || isFirst; - const playbackCan = validatePlayback(playback); + const playbackCan = validatePlayback(playback, timerPhase); const disableStart = !playbackCan.start; const disablePause = !playbackCan.pause; const disableRoll = !playbackCan.roll || noEvents; diff --git a/apps/server/src/services/EventTimer.ts b/apps/server/src/services/EventTimer.ts index 3617ae369..e40aa67a6 100644 --- a/apps/server/src/services/EventTimer.ts +++ b/apps/server/src/services/EventTimer.ts @@ -1,5 +1,3 @@ -import { OntimeRundown } from 'ontime-types'; - import * as runtimeState from '../stores/runtimeState.js'; import type { UpdateResult } from '../stores/runtimeState.js'; import { timerConfig } from '../config/config.js'; @@ -100,13 +98,6 @@ export class EventTimer { this.onUpdateCallback?.(updateResult); } - /** - * Loads roll information into timer service - */ - roll(rundown: OntimeRundown) { - return runtimeState.roll(rundown); - } - shutdown() { clearInterval(this._interval); clearTimeout(this.endCallback); diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index ebec7c298..950b62b65 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -9,6 +9,7 @@ import { RuntimeStore, TimerLifeCycle, TimerPhase, + TimerState, } from 'ontime-types'; import { millisToString, validatePlayback } from 'ontime-utils'; @@ -86,18 +87,18 @@ class RuntimeService { // 2. handle edge cases related to roll if (newState.timer.playback === Playback.Roll) { // check if we need to call any side effects - + const keepOffset = newState.runtime.offset; if (hasSecondaryTimerFinished) { // if the secondary timer has finished, we need to call roll // since event is already loaded - this.rollLoaded(); + this.rollLoaded(keepOffset); } else if (hasTimerFinished) { // if the timer has finished, we need to load next and keep rolling process.nextTick(() => { integrationService.dispatch(TimerLifeCycle.onFinish); }); this.handleLoadNext(); - this.rollLoaded(); + this.rollLoaded(keepOffset); } else if (skippedOutOfEvent(newState, this.lastIntegrationClockUpdate, timerConfig.skipLimit)) { // if we have skipped out of the event, we will recall roll // to push the playback to the right place @@ -271,16 +272,17 @@ class RuntimeService { /** * makes calls for loading and starting given event * @param {PlayableEvent} event + * @param {Partial} initialData * @return {boolean} success - whether an event was loaded */ - private loadEvent(event: OntimeEvent): boolean { + private loadEvent(event: OntimeEvent, initialData?: Partial): boolean { if (!isPlayableEvent(event)) { logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`); return false; } const rundown = getRundown(); - const success = runtimeState.load(event, rundown); + const success = runtimeState.load(event, rundown, initialData); if (success) { 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 * startSelected being a private function does not trigger emits + * and pass on runtime offset in case of roll mode */ private handleLoadNext(): boolean { const state = runtimeState.getState(); const nextEvent = findNext(state.eventNow?.id); if (nextEvent) { + if (state.timer.playback === Playback.Roll) { + return this.loadEvent(nextEvent, { firstStart: state.runtime.actualStart }); + } return this.loadEvent(nextEvent); } @@ -445,7 +451,7 @@ class RuntimeService { */ private handleStart(): boolean { const previousState = runtimeState.getState(); - const canStart = validatePlayback(previousState.timer.playback).start; + const canStart = validatePlayback(previousState.timer.playback, previousState.timer.phase).start; if (!canStart) { return false; } @@ -501,7 +507,7 @@ class RuntimeService { @broadcastResult public pause() { const state = runtimeState.getState(); - const canPause = validatePlayback(state.timer.playback).pause; + const canPause = validatePlayback(state.timer.playback, state.timer.phase).pause; if (!canPause) { return; } @@ -519,7 +525,7 @@ class RuntimeService { @broadcastResult public stop(): boolean { const state = runtimeState.getState(); - const canStop = validatePlayback(state.timer.playback).stop; + const canStop = validatePlayback(state.timer.playback, state.timer.phase).stop; if (!canStop) { 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 */ - private rollLoaded() { + private rollLoaded(offset?: number) { const rundown = getRundown(); try { - this.eventTimer.roll(rundown); + runtimeState.roll(rundown, offset); } catch (error) { logger.error(LogOrigin.Server, `Roll: ${error}`); } @@ -567,7 +573,7 @@ class RuntimeService { public roll(skipCheck: boolean = false) { const previousState = runtimeState.getState(); if (!skipCheck) { - const canRoll = validatePlayback(previousState.timer.playback).roll; + const canRoll = validatePlayback(previousState.timer.playback, previousState.timer.phase).roll; if (!canRoll) { return; } @@ -575,7 +581,7 @@ class RuntimeService { try { const rundown = getRundown(); - const result = this.eventTimer.roll(rundown); + const result = runtimeState.roll(rundown); if (result.eventId !== previousState.eventNow?.id) { logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); process.nextTick(() => { diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 4a2ff6c36..372c0f07f 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -1,7 +1,7 @@ -import { PlayableEvent, Playback } from 'ontime-types'; +import { PlayableEvent, Playback, TimerPhase } from 'ontime-types'; 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'; const mockEvent = { @@ -227,7 +227,103 @@ describe('mutation on runtimeState', () => { }); 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 + }); }); }); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index dce23f606..9ee612b7c 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -314,17 +314,15 @@ export function updateLoaded(event?: PlayableEvent): string | undefined { // handle edge cases with 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 (runtimeState._timer.secondaryTarget !== null) { - if ( - runtimeState.eventNow.timeStart < runtimeState.clock && - runtimeState.clock < runtimeState.eventNow.timeEnd - ) { + if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) { // if the event is now, we queue a start runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart; - runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock; } 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) { // reset state - runtimeState._timer.forceFinish; runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock; } else { runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState); @@ -536,14 +533,15 @@ export function update(): UpdateResult { 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.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; - return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer < 0 }; + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock; + 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 if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) { 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 - 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; // account for event that finishes the day after @@ -561,14 +567,16 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: : runtimeState.eventNow.timeEnd; runtimeState.timer.expectedFinish = normalisedEndTime; + //account for offset + const offsetClock = runtimeState.clock + runtimeState.runtime.offset; + // state catch up runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, normalisedEndTime); runtimeState.timer.current = runtimeState.timer.duration; runtimeState.timer.elapsed = 0; - // check if the event is ready to start or if needs to be waited - const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, runtimeState.clock); - + // check if the event is ready to start or if needs to be pending + const isNow = checkIsNow(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd, offsetClock); if (isNow) { runtimeState.timer.startedAt = runtimeState.clock; @@ -579,9 +587,10 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: if (!runtimeState.runtime.actualStart) { runtimeState.runtime.actualStart = runtimeState.clock; } + runtimeState.timer.secondaryTimer = null; } else { - runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); - runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; + runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock); + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock; runtimeState.timer.phase = TimerPhase.Pending; } @@ -599,7 +608,11 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: clear(); 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 loadNow(timedEvents, index); @@ -623,8 +636,8 @@ export function roll(rundown: OntimeRundown): { eventId: MaybeString; didStart: // there is nothing now, but something coming up runtimeState.timer.phase = TimerPhase.Pending; // we need to normalise start time in case it is the day after - runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, runtimeState.clock); - runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - runtimeState.clock; + runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock); + runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock; // preload timer properties runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd); diff --git a/packages/utils/src/validate-action/validatePlayback.ts b/packages/utils/src/validate-action/validatePlayback.ts index a97c160bb..035945c05 100644 --- a/packages/utils/src/validate-action/validatePlayback.ts +++ b/packages/utils/src/validate-action/validatePlayback.ts @@ -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 */ -export function validatePlayback(currentPlayback: Playback) { +export function validatePlayback(currentPlayback: Playback, timerPhase: TimerPhase) { return { start: currentPlayback !== Playback.Stop, pause: currentPlayback === Playback.Play, - roll: currentPlayback !== Playback.Roll, + roll: currentPlayback !== Playback.Roll && timerPhase !== TimerPhase.Overtime, stop: currentPlayback !== Playback.Stop, reload: currentPlayback !== Playback.Stop && currentPlayback !== Playback.Roll, };