From 2df3d376ada71de25483b0c2234de7623f18a42b Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Thu, 25 Jan 2024 16:51:20 +0100 Subject: [PATCH] feat: many timers (#706) * wip: create simple timer class * refactor: service to manage extra timers * wip:ui for controlling the extra timer --------- Co-authored-by: arc-alex --- apps/client/src/common/hooks/useSocket.ts | 25 +++- apps/client/src/common/stores/runtime.ts | 8 +- .../control/playback/PlaybackControl.tsx | 2 + .../extra-timer/ExtraTimer.module.scss | 5 + .../playback/extra-timer/ExtraTimer.tsx | 74 ++++++++++ .../control/playback/tap-button/TapButton.tsx | 1 - .../src/features/editors/Editor.module.scss | 2 +- apps/server/src/app.ts | 8 +- .../DataProvider.test.ts | 0 .../src/classes/simple-timer/SimpleTimer.ts | 78 +++++++++++ .../__tests__/SimpleTimer.test.ts | 128 ++++++++++++++++++ .../src/controllers/integrationController.ts | 37 ++++- .../extra-timer-service/ExtraTimerService.ts | 77 +++++++++++ apps/server/tsconfig.json | 3 +- .../definitions/runtime/ExtraTimer.type.ts | 17 +++ .../definitions/runtime/RuntimeStore.type.ts | 4 + packages/types/src/index.ts | 3 + 17 files changed, 465 insertions(+), 7 deletions(-) create mode 100644 apps/client/src/features/control/playback/extra-timer/ExtraTimer.module.scss create mode 100644 apps/client/src/features/control/playback/extra-timer/ExtraTimer.tsx rename apps/server/src/classes/data-provider/{__test__ => __tests__}/DataProvider.test.ts (100%) create mode 100644 apps/server/src/classes/simple-timer/SimpleTimer.ts create mode 100644 apps/server/src/classes/simple-timer/__tests__/SimpleTimer.test.ts create mode 100644 apps/server/src/services/extra-timer-service/ExtraTimerService.ts create mode 100644 packages/types/src/definitions/runtime/ExtraTimer.type.ts diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index f6d68ddb8..ef0155cdb 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -1,4 +1,4 @@ -import { RuntimeStore } from 'ontime-types'; +import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types'; import { useRuntimeStore } from '../stores/runtime'; import { socketSendJson } from '../utils/socket'; @@ -92,6 +92,29 @@ export const useInfoPanel = () => { return useRuntimeStore(featureSelector); }; +export const useExtraTimerTime = () => { + const featureSelector = (state: RuntimeStore) => state.timer1.current; + + return useRuntimeStore(featureSelector); +}; + +export const useExtraTimerControl = () => { + const featureSelector = (state: RuntimeStore) => ({ + playback: state.timer1.playback, + direction: state.timer1.direction, + }); + + return useRuntimeStore(featureSelector); +}; + +export const setExtraTimer = { + start: () => socketSendJson('extratimer', SimplePlayback.Start), + pause: () => socketSendJson('extratimer', SimplePlayback.Pause), + stop: () => socketSendJson('extratimer', SimplePlayback.Stop), + setDirection: (direction: SimpleDirection) => socketSendJson('extratimer', { direction }), + setTime: (time: number) => socketSendJson('extratimer', { settime: time }), +}; + export const useCuesheet = () => { const featureSelector = (state: RuntimeStore) => ({ playback: state.timer.playback, diff --git a/apps/client/src/common/stores/runtime.ts b/apps/client/src/common/stores/runtime.ts index 41294bf94..e450bc0f8 100644 --- a/apps/client/src/common/stores/runtime.ts +++ b/apps/client/src/common/stores/runtime.ts @@ -1,5 +1,5 @@ import isEqual from 'react-fast-compare'; -import { Playback, RuntimeStore } from 'ontime-types'; +import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types'; import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional'; export const runtimeStorePlaceholder: RuntimeStore = { @@ -44,6 +44,12 @@ export const runtimeStorePlaceholder: RuntimeStore = { eventNext: null, publicEventNow: null, publicEventNext: null, + timer1: { + current: 0, + direction: SimpleDirection.CountUp, + duration: 0, + playback: SimplePlayback.Stop, + }, }; const deepCompare = (a: T, b: T) => isEqual(a, b); diff --git a/apps/client/src/features/control/playback/PlaybackControl.tsx b/apps/client/src/features/control/playback/PlaybackControl.tsx index 0ef89f607..ce4127a5f 100644 --- a/apps/client/src/features/control/playback/PlaybackControl.tsx +++ b/apps/client/src/features/control/playback/PlaybackControl.tsx @@ -2,6 +2,7 @@ import { Playback } from 'ontime-types'; import { usePlaybackControl } from '../../../common/hooks/useSocket'; +import { ExtraTimer } from './extra-timer/ExtraTimer'; import PlaybackButtons from './playback-buttons/PlaybackButtons'; import PlaybackTimer from './playback-timer/PlaybackTimer'; @@ -18,6 +19,7 @@ export default function PlaybackControl() { numEvents={data.numEvents} selectedEventIndex={data.selectedEventIndex} /> + ); } diff --git a/apps/client/src/features/control/playback/extra-timer/ExtraTimer.module.scss b/apps/client/src/features/control/playback/extra-timer/ExtraTimer.module.scss new file mode 100644 index 000000000..26b827438 --- /dev/null +++ b/apps/client/src/features/control/playback/extra-timer/ExtraTimer.module.scss @@ -0,0 +1,5 @@ +.extraRow { + display: flex; + gap: 0.5rem; + margin-top: 2rem; +} diff --git a/apps/client/src/features/control/playback/extra-timer/ExtraTimer.tsx b/apps/client/src/features/control/playback/extra-timer/ExtraTimer.tsx new file mode 100644 index 000000000..9f608f9cd --- /dev/null +++ b/apps/client/src/features/control/playback/extra-timer/ExtraTimer.tsx @@ -0,0 +1,74 @@ +import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown'; +import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; +import { IoPause } from '@react-icons/all-files/io5/IoPause'; +import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; +import { IoStop } from '@react-icons/all-files/io5/IoStop'; +import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types'; + +import TimeInput from '../../../../common/components/input/time-input/TimeInput'; +import { setExtraTimer, useExtraTimerControl, useExtraTimerTime } from '../../../../common/hooks/useSocket'; +import { forgivingStringToMillis } from '../../../../common/utils/dateConfig'; +import TapButton from '../tap-button/TapButton'; + +import style from './ExtraTimer.module.scss'; + +export function ExtraTimer() { + const { playback, direction } = useExtraTimerControl(); + + const { start, pause, stop, setDirection } = setExtraTimer; + + const toggleDirection = () => { + const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown; + setDirection(newDirection); + }; + + const userCan = { + start: playback !== SimplePlayback.Start, + pause: playback === SimplePlayback.Start, + stop: playback !== SimplePlayback.Stop, + }; + + return ( +
+ + + {direction === SimpleDirection.CountDown && } + {direction === SimpleDirection.CountUp && } + + + + + + + + + + + +
+ ); +} + +function ExtraTimeInput() { + const time = useExtraTimerTime(); + const { setTime } = setExtraTimer; + + const handleTimeUpdate = (_field: string, value: string) => { + const newTime = forgivingStringToMillis(value); + setTime(newTime); + }; + + return ( + submitHandler={handleTimeUpdate} name='extraTimer' time={time} placeholder='Timer 1' /> + ); +} diff --git a/apps/client/src/features/control/playback/tap-button/TapButton.tsx b/apps/client/src/features/control/playback/tap-button/TapButton.tsx index 3417d43df..54d6a1aac 100644 --- a/apps/client/src/features/control/playback/tap-button/TapButton.tsx +++ b/apps/client/src/features/control/playback/tap-button/TapButton.tsx @@ -8,7 +8,6 @@ import style from './TapButton.module.scss'; interface TapButtonProps { disabled?: boolean; aspect?: 'normal' | 'square' | 'fill'; - square?: boolean; free?: boolean; onClick: () => void; theme?: Playback | 'neutral'; diff --git a/apps/client/src/features/editors/Editor.module.scss b/apps/client/src/features/editors/Editor.module.scss index 6fa45812f..4733c1c6e 100644 --- a/apps/client/src/features/editors/Editor.module.scss +++ b/apps/client/src/features/editors/Editor.module.scss @@ -1,7 +1,7 @@ @use './EditorMixin' as editor; $menu-width: 2.75rem; -$playback-width: 26rem; +$playback-width: 30rem; .corner { @include editor.corner; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3a0b42316..b4237a2bb 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,4 +1,4 @@ -import { HttpSettings, LogOrigin, OSCSettings, Playback } from 'ontime-types'; +import { HttpSettings, LogOrigin, OSCSettings, Playback, SimpleDirection, SimplePlayback } from 'ontime-types'; import 'dotenv/config'; import express from 'express'; @@ -168,6 +168,12 @@ export const startServer = async () => { publicEventNow: state.publicEventNow, eventNext: state.eventNext, publicEventNext: state.publicEventNext, + timer1: { + duration: null, + current: null, + playback: SimplePlayback.Stop, + direction: SimpleDirection.CountDown, + }, }); // load restore point if it exists diff --git a/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts similarity index 100% rename from apps/server/src/classes/data-provider/__test__/DataProvider.test.ts rename to apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts diff --git a/apps/server/src/classes/simple-timer/SimpleTimer.ts b/apps/server/src/classes/simple-timer/SimpleTimer.ts new file mode 100644 index 000000000..d6a5b4181 --- /dev/null +++ b/apps/server/src/classes/simple-timer/SimpleTimer.ts @@ -0,0 +1,78 @@ +import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types'; + +export class SimpleTimer { + state: SimpleTimerState = { + duration: 0, + current: 0, + playback: SimplePlayback.Stop, + direction: SimpleDirection.CountDown, + }; + private startedAt: number | null = null; + private pausedAt: number | null = null; + + constructor() {} + + public reset() { + this.state = { + duration: 0, + current: 0, + playback: SimplePlayback.Stop, + direction: SimpleDirection.CountDown, + }; + } + + /** + * Sets the duration of the timer + * @param time - time in milliseconds + */ + public setTime(time: number): SimpleTimerState { + this.state.duration = time; + this.state.current = time; + return this.state; + } + + public setDirection(direction: SimpleDirection): SimpleTimerState { + this.state.playback = SimplePlayback.Stop; + this.state.current = this.state.duration; + this.state.direction = direction; + return this.state; + } + + public start(timeNow: number): SimpleTimerState { + if (this.state.playback === SimplePlayback.Pause) { + const elapsedSincePause = this.pausedAt - this.startedAt; + this.startedAt = timeNow - elapsedSincePause; + } else if (this.state.playback === SimplePlayback.Stop) { + this.startedAt = timeNow; + } + this.state.playback = SimplePlayback.Start; + return this.update(timeNow); + } + + public pause(timeNow: number): SimpleTimerState { + if (this.state.playback !== SimplePlayback.Start) return this.state; + this.state.playback = SimplePlayback.Pause; + this.pausedAt = timeNow; + return this.state; + } + + public stop(): SimpleTimerState { + this.state.playback = SimplePlayback.Stop; + this.state.current = this.state.duration; + this.startedAt = null; + return this.state; + } + + public update(timeNow: number): SimpleTimerState { + if (this.state.playback === SimplePlayback.Start) { + const elapsed = timeNow - this.startedAt; + if (this.state.direction === SimpleDirection.CountDown) { + this.state.current = this.state.duration - elapsed; + } else if (this.state.direction === SimpleDirection.CountUp) { + this.state.current = this.state.duration + elapsed; + } + } + + return this.state; + } +} diff --git a/apps/server/src/classes/simple-timer/__tests__/SimpleTimer.test.ts b/apps/server/src/classes/simple-timer/__tests__/SimpleTimer.test.ts new file mode 100644 index 000000000..9575a0213 --- /dev/null +++ b/apps/server/src/classes/simple-timer/__tests__/SimpleTimer.test.ts @@ -0,0 +1,128 @@ +import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types'; + +import { SimpleTimer } from '../SimpleTimer.js'; + +describe('SimpleTimer count-down', () => { + let timer: SimpleTimer; + + describe('normal timer flow', () => { + const initialTime = 1000; + timer = new SimpleTimer(); + + test('setting the timer duration', () => { + const newState = timer.setTime(initialTime); + const expected: SimpleTimerState = { + duration: initialTime, + current: initialTime, + direction: SimpleDirection.CountDown, + playback: SimplePlayback.Stop, + }; + expect(newState).toStrictEqual(expected); + }); + + test('setting the timer to play', () => { + const newState = timer.start(0); + const expected: SimpleTimerState = { + duration: initialTime, + current: initialTime, + direction: SimpleDirection.CountDown, + playback: SimplePlayback.Start, + }; + expect(newState).toStrictEqual(expected); + }); + + test('updating the timer', () => { + let newState = timer.update(100); + const expected: SimpleTimerState = { + duration: initialTime, + current: initialTime - 100, + direction: SimpleDirection.CountDown, + playback: SimplePlayback.Start, + }; + expect(newState).toStrictEqual(expected); + + newState = timer.update(500); + expected.current = initialTime - 500; + expect(newState).toStrictEqual(expected); + + newState = timer.update(1500); + expected.current = initialTime - 1500; + expect(newState).toStrictEqual(expected); + }); + + test('pausing the time doesnt affect the current', () => { + const pausedTime = 200; + let newState = timer.pause(1500); + const expected: SimpleTimerState = { + duration: initialTime, + current: initialTime - 1500, + direction: SimpleDirection.CountDown, + playback: SimplePlayback.Pause, + }; + expect(newState).toStrictEqual(expected); + + newState = timer.update(1600); + expect(newState).toStrictEqual(expected); + + newState = timer.update(1700); + expect(newState).toStrictEqual(expected); + + newState = timer.start(1700); + expected.playback = SimplePlayback.Start; + expect(newState).toStrictEqual(expected); + + newState = timer.update(1800); + (expected.current = initialTime - 1800 + pausedTime), expect(newState).toStrictEqual(expected); + }); + + test('stopping the timer clears the running data', () => { + const newState = timer.stop(); + const expected: SimpleTimerState = { + duration: initialTime, + current: initialTime, + direction: SimpleDirection.CountDown, + playback: SimplePlayback.Stop, + }; + expect(newState).toStrictEqual(expected); + }); + + test('count-up mode', () => { + const initialState = timer.setDirection(SimpleDirection.CountUp); + expect(initialState.current).toBe(initialTime); + + let newState = timer.start(0); + const expected: SimpleTimerState = { + duration: initialTime, + current: initialTime, + direction: SimpleDirection.CountUp, + playback: SimplePlayback.Start, + }; + + newState = timer.update(100); + expected.current = initialTime + 100; + expect(newState).toStrictEqual(expected); + + expect(newState).toStrictEqual(expected); + + newState = timer.update(500); + expected.current = initialTime + 500; + expect(newState).toStrictEqual(expected); + + newState = timer.update(1500); + expected.current = initialTime + 1500; + expect(newState).toStrictEqual(expected); + }); + + test('changing direction stops the timer', () => { + const newState = timer.setDirection(SimpleDirection.CountDown); + const expected: SimpleTimerState = { + duration: initialTime, + current: initialTime, + direction: SimpleDirection.CountDown, + playback: SimplePlayback.Stop, + }; + + expect(newState).toStrictEqual(expected); + }); + }); +}); diff --git a/apps/server/src/controllers/integrationController.ts b/apps/server/src/controllers/integrationController.ts index 2a9a1236d..f46d288dd 100644 --- a/apps/server/src/controllers/integrationController.ts +++ b/apps/server/src/controllers/integrationController.ts @@ -1,4 +1,4 @@ -import { DeepPartial, MessageState } from 'ontime-types'; +import { DeepPartial, MessageState, SimpleDirection, SimplePlayback } from 'ontime-types'; // skipcq: JS-C1003 - we like the API import * as assert from '../utils/assert.js'; @@ -8,6 +8,7 @@ import { messageService } from '../services/message-service/MessageService.js'; import { runtimeService } from '../services/runtime-service/RuntimeService.js'; import { eventStore } from '../stores/EventStore.js'; import { parse, updateEvent } from './integrationController.config.js'; +import { extraTimerService } from '../services/extra-timer-service/ExtraTimerService.js'; import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js'; export type ChangeOptions = { @@ -152,6 +153,40 @@ const actionHandlers: Record = { runtimeService.addTime(time * 1000); return { payload: 'success' }; }, + /* Extra timers */ + extratimer: (payload) => { + if (payload && typeof payload === 'string') { + if (payload === SimplePlayback.Start) { + const reply = extraTimerService.start(); + return { payload: reply }; + } + if (payload === SimplePlayback.Pause) { + const reply = extraTimerService.pause(); + return { payload: reply }; + } + if (payload === SimplePlayback.Stop) { + const reply = extraTimerService.stop(); + return { payload: reply }; + } + } + + if (payload && typeof payload === 'object') { + if ('settime' in payload) { + const time = numberOrError(payload.settime); + const reply = extraTimerService.setTime(time); + return { payload: reply }; + } + if ('direction' in payload) { + if (payload.direction === SimpleDirection.CountUp || payload.direction === SimpleDirection.CountDown) { + const reply = extraTimerService.setDirection(payload.direction); + return { payload: reply }; + } else { + throw new Error('Invalid direction payload'); + } + } + } + throw new Error('Invalid extratimer payload'); + }, }; /** * Returns a value of type number, converting if necessary diff --git a/apps/server/src/services/extra-timer-service/ExtraTimerService.ts b/apps/server/src/services/extra-timer-service/ExtraTimerService.ts new file mode 100644 index 000000000..e61af19c2 --- /dev/null +++ b/apps/server/src/services/extra-timer-service/ExtraTimerService.ts @@ -0,0 +1,77 @@ +import { SimpleDirection, SimpleTimerState } from 'ontime-types'; + +import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js'; +import { eventStore } from '../../stores/EventStore.js'; + +export type EmitFn = (state: SimpleTimerState) => void; +export type GetTimeFn = () => number; + +export class ExtraTimerService { + private timer: SimpleTimer; + private interval: NodeJS.Timer; + private emit: EmitFn; + private getTime: GetTimeFn; + + constructor(emit: EmitFn, getTime: GetTimeFn) { + this.timer = new SimpleTimer(); + this.emit = emit; + this.getTime = getTime; + } + + private startInterval() { + this.interval = setInterval(this.update.bind(this), 500); + } + + private stopInterval() { + clearInterval(this.interval); + } + + @broadcastReturn + setDirection(direction: SimpleDirection) { + return this.timer.setDirection(direction); + } + + @broadcastReturn + start() { + this.startInterval(); + return this.timer.start(this.getTime()); + } + + @broadcastReturn + pause() { + return this.timer.pause(this.getTime()); + } + + @broadcastReturn + stop() { + this.stopInterval(); + return this.timer.stop(); + } + + @broadcastReturn + setTime(duration: number) { + return this.timer.setTime(duration); + } + + @broadcastReturn + private update() { + return this.timer.update(this.getTime()); + } +} + +function broadcastReturn(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = function (...args: any[]) { + const result = originalMethod.apply(this, args); + this.emit(result); + return result; + }; + + return descriptor; +} + +const emit = (state: SimpleTimerState) => eventStore.set('timer1', state); +const timeNow = () => Date.now(); + +export const extraTimerService = new ExtraTimerService(emit, timeNow); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 98e3d91e9..ddc5f578f 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -8,7 +8,8 @@ "esModuleInterop": true, "skipLibCheck": true, "types": ["vitest/globals"], - "outDir": "dist" + "outDir": "dist", + "experimentalDecorators": true }, "include": [ "src/**/*" diff --git a/packages/types/src/definitions/runtime/ExtraTimer.type.ts b/packages/types/src/definitions/runtime/ExtraTimer.type.ts new file mode 100644 index 000000000..e6bd9e1ff --- /dev/null +++ b/packages/types/src/definitions/runtime/ExtraTimer.type.ts @@ -0,0 +1,17 @@ +export enum SimplePlayback { + Start = 'start', + Pause = 'pause', + Stop = 'stop', +} + +export enum SimpleDirection { + CountUp = 'count-up', + CountDown = 'count-down', +} + +export type SimpleTimerState = { + duration: number; + current: number; + playback: SimplePlayback; + direction: SimpleDirection; +}; diff --git a/packages/types/src/definitions/runtime/RuntimeStore.type.ts b/packages/types/src/definitions/runtime/RuntimeStore.type.ts index 9e6c21ec4..5a750f28a 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.type.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.type.ts @@ -2,6 +2,7 @@ import { MessageState } from './MessageControl.type.js'; import { TimerState } from './TimerState.type.js'; import { Runtime } from './Runtime.type.js'; import { OntimeEvent } from '../core/OntimeEvent.type.js'; +import { SimpleTimerState } from './ExtraTimer.type.js'; export type RuntimeStore = { // timer data @@ -18,4 +19,7 @@ export type RuntimeStore = { publicEventNow: OntimeEvent | null; eventNext: OntimeEvent | null; publicEventNext: OntimeEvent | null; + + // extra timers + timer1: SimpleTimerState; }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6f0bcfded..17ed539ef 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -59,6 +59,9 @@ export type { Runtime } from './definitions/runtime/Runtime.type.js'; export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js'; export type { TimerState } from './definitions/runtime/TimerState.type.js'; +// ---> Extra Timer +export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/ExtraTimer.type.js'; + // CLIENT // TYPE UTILITIES