diff --git a/apps/client/src/common/components/link/app-link/AppLink.tsx b/apps/client/src/common/components/link/app-link/AppLink.tsx index 7135aff27..cc7a32ae7 100644 --- a/apps/client/src/common/components/link/app-link/AppLink.tsx +++ b/apps/client/src/common/components/link/app-link/AppLink.tsx @@ -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) { - const { search, children } = props; + const { className, search, children } = props; const navigate = useNavigate(); const handleClick = () => navigate({ search }); return ( - ); diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 0000a7650..1f8b91fca 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -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,16 @@ export const setAuxTimer = { setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), }; +export const useTimerSpeed = createSelector((state: RuntimeStore) => ({ + speed: state.timer.speed, +})); + +export const setTimerSpeed = { + calculateSpeed: () => socketSendJson('calculate-speed'), + getSpeed: () => socketSendJson('get-speed'), + setSpeed: (speed: number) => socketSendJson('set-speed', speed), +}; + export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ selectedEventId: state.eventNow?.id ?? null, })); diff --git a/apps/client/src/features/control/playback/PlaybackControl.tsx b/apps/client/src/features/control/playback/PlaybackControl.tsx index 4c7210b07..10408bbbe 100644 --- a/apps/client/src/features/control/playback/PlaybackControl.tsx +++ b/apps/client/src/features/control/playback/PlaybackControl.tsx @@ -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 (
- + +
); diff --git a/apps/client/src/features/control/playback/add-time/AddTime.tsx b/apps/client/src/features/control/playback/add-time/AddTime.tsx index 4bb9f1046..c842a4adc 100644 --- a/apps/client/src/features/control/playback/add-time/AddTime.tsx +++ b/apps/client/src/features/control/playback/add-time/AddTime.tsx @@ -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 diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss index 1fd3fdbdb..b31ac2777 100644 --- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss +++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.module.scss @@ -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; -} diff --git a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx index eb1a541a6..8b3c54e42 100644 --- a/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx +++ b/apps/client/src/features/control/playback/playback-timer/PlaybackTimer.tsx @@ -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 { + 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 -
- {isWaiting ? ( - Roll: Countdown to start - ) : ( - - )} -
{children} ); } - -interface RunningStatusProps { - startedAt: MaybeNumber; - expectedFinish: MaybeNumber; - playback: Playback; -} -function RunningStatus(props: RunningStatusProps) { - const { startedAt, expectedFinish, playback } = props; - - if (playback === Playback.Stop) { - return ; - } - - const started = millisToString(startedAt); - const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null; - const finish = millisToString(finishedMs); - - return ( - <> - - Started at - {started} - - - Expect end - {finish} - - - ); -} - -function StoppedStatus() { - const { data } = useReport(); - const hasReport = Object.keys(data).length > 0; - - if (hasReport) { - return Go to report management; - } - - return null; -} diff --git a/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss b/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss index c93ddff23..2e5c3091f 100644 --- a/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss +++ b/apps/client/src/features/control/playback/timer-display/TimerDisplay.module.scss @@ -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; diff --git a/apps/client/src/features/control/playback/timer-speed/TimerSchedule.tsx b/apps/client/src/features/control/playback/timer-speed/TimerSchedule.tsx new file mode 100644 index 000000000..55f50ff5a --- /dev/null +++ b/apps/client/src/features/control/playback/timer-speed/TimerSchedule.tsx @@ -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
Roll: Countdown to start
; + } + + if (playback === Playback.Stop) { + return ; + } + + return ( +
+
+
Started at
+
{started}
+
+
+
Expected end
+ {hasNewSpeed ? :
{endTime}
} +
+
+ ); +} + +// 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 ( + + Go to report management + + ); + } + + return
No running playback
; +} + +interface SpeedFinishProps { + newSpeed: number; +} + +function SpeedFinish(props: SpeedFinishProps) { + const { newSpeed } = props; + const { current } = useTimerCurrent(); + + const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed)); + + return
{newFinish}
; +} diff --git a/apps/client/src/features/control/playback/timer-speed/TimerSpeed.module.scss b/apps/client/src/features/control/playback/timer-speed/TimerSpeed.module.scss new file mode 100644 index 000000000..14fa0b4ef --- /dev/null +++ b/apps/client/src/features/control/playback/timer-speed/TimerSpeed.module.scss @@ -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; +} \ No newline at end of file diff --git a/apps/client/src/features/control/playback/timer-speed/TimerSpeed.tsx b/apps/client/src/features/control/playback/timer-speed/TimerSpeed.tsx new file mode 100644 index 000000000..465da7344 --- /dev/null +++ b/apps/client/src/features/control/playback/timer-speed/TimerSpeed.tsx @@ -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 ( +
+ +
+
+ {`${speed}x`} + {newSpeed !== speed && {` ⇢ ${newSpeed}x`}} +
+
+ + +
+
+ setNewSpeed(v)} + value={newSpeed} + isDisabled={!isPlaying} + > + 0.5x + 1.0x + 1.5x + 2.0x + + + + + +
+ ); +} diff --git a/apps/client/src/theme/_ontimeStyles.scss b/apps/client/src/theme/_ontimeStyles.scss index 6254e8534..269d3e917 100644 --- a/apps/client/src/theme/_ontimeStyles.scss +++ b/apps/client/src/theme/_ontimeStyles.scss @@ -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; diff --git a/apps/client/src/theme/ontimeButton.ts b/apps/client/src/theme/ontimeButton.ts index 826e752de..81fe74913 100644 --- a/apps/client/src/theme/ontimeButton.ts +++ b/apps/client/src/theme/ontimeButton.ts @@ -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, }; diff --git a/apps/client/src/theme/ontimeSlider.tsx b/apps/client/src/theme/ontimeSlider.tsx new file mode 100644 index 000000000..e283fccb1 --- /dev/null +++ b/apps/client/src/theme/ontimeSlider.tsx @@ -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 + }, +}; diff --git a/apps/client/src/theme/theme.ts b/apps/client/src/theme/theme.ts index 3003fb001..0a0f1d1fd 100644 --- a/apps/client/src/theme/theme.ts +++ b/apps/client/src/theme/theme.ts @@ -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 }, + }, + }, }, }); diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index ded76835e..e44e4e5b5 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -259,6 +259,24 @@ const actionHandlers: Record = { } throw new Error('No matching method provided'); }, + /* Speed */ + 'calculate-speed': () => { + const factor = runtimeService.calculateSpeed(); + return { payload: factor }; + }, + '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); diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 1dc486992..8002ef273 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -666,6 +666,42 @@ class RuntimeService { logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`); } } + + /** + * Utility calculates the speed factor necessary to finish on time + * @returns {number} speed factor needed to meet schedule + */ + public calculateSpeed(): number { + return runtimeState.calculateSpeed(); + } + + /** + * @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 { + // TODO: validate state + // TODO: validate value + runtimeState.setSpeed(speed); + return runtimeState.getSpeed(); + } + + /** + * Resets the speed of the current timer + * @returns {number} applied speed factor + */ + public resetSpeed(): number { + runtimeState.resetSpeed(); + return runtimeState.getSpeed(); + } } // calculate at 30fps, refresh at 1fps diff --git a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts index 7c4d96839..bc4064f1a 100644 --- a/apps/server/src/stores/__mocks__/runtimeState.mocks.ts +++ b/apps/server/src/stores/__mocks__/runtimeState.mocks.ts @@ -32,6 +32,7 @@ const baseState: RuntimeState = { playback: Playback.Stop, secondaryTimer: null, startedAt: null, + speed: 1, }, _timer: { forceFinish: null, diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 3c01871e1..d0ea80a22 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -350,6 +350,33 @@ export function updateAll(rundown: OntimeRundown) { loadBlock(rundown); } +export function setSpeed(speed: number) { + runtimeState.timer.speed = speed; +} + +export function resetSpeed() { + runtimeState.timer.speed = 1.0; +} + +export function getSpeed() { + return runtimeState.timer.speed; +} + +export function calculateSpeed() { + // TODO: what should this return if no timer is running? + if (runtimeState.eventNow !== null) { + const timeToDesiredFinish = runtimeState.eventNow.timeEnd - runtimeState.clock; + const timeToActualFinish = runtimeState.timer.expectedFinish - runtimeState.clock; + const factor = 1 / (timeToDesiredFinish / timeToActualFinish); + + // TODO: this can produce negative speeds (i.e. the desired finish time is in the past) + // TODO: should there be some clamping or rounding on this number? + return factor; + } + + return 1.0; +} + export function start(state: RuntimeState = runtimeState): boolean { if (state.eventNow === null) { return false; @@ -464,6 +491,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 +518,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); diff --git a/packages/types/src/definitions/runtime/RuntimeStore.ts b/packages/types/src/definitions/runtime/RuntimeStore.ts index e9674ace4..45907b938 100644 --- a/packages/types/src/definitions/runtime/RuntimeStore.ts +++ b/packages/types/src/definitions/runtime/RuntimeStore.ts @@ -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: { diff --git a/packages/types/src/definitions/runtime/TimerState.type.ts b/packages/types/src/definitions/runtime/TimerState.type.ts index 302f4e62e..6c3376450 100644 --- a/packages/types/src/definitions/runtime/TimerState.type.ts +++ b/packages/types/src/definitions/runtime/TimerState.type.ts @@ -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; };