Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat (ui): implement speed controls #1206

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/client/src/common/hooks/useSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ export const setAuxTimer = {
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
};

// TODO: plugin data
export const useTimerSpeed = () => {
const featureSelector = (state: RuntimeStore) => ({
speed: state.timer.addedTime,
});

return useRuntimeStore(featureSelector);
};

export const setTimerSpeed = {
calculateSpeed: () => socketSendJson('calculate-speed'),
getSpeed: () => socketSendJson('get-speed'),
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
resetSpeed: () => socketSendJson('reset-speed'),
};

export const useCuesheet = () => {
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
Expand Down
1 change: 1 addition & 0 deletions apps/client/src/common/stores/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
speed: 1.0,
},
onAir: false,
message: {
Expand Down
2 changes: 2 additions & 0 deletions apps/client/src/features/control/playback/PlaybackControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,6 +24,7 @@ export default function PlaybackControl() {
selectedEventIndex={data.selectedEventIndex}
timerPhase={data.timerPhase}
/>
<TimerSpeed />
<AuxTimer />
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
.panelContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 2rem;
}

// TODO: unify panel label
.label {
display: block;
font-size: $inner-section-text-size;
color: $label-gray;
}

.highlight {
color: $orange-600;
}

.speedContainer {
background-color: $gray-1100;
width: 100%;
height: 4px;
border-radius: 1px;
position: relative;
overflow: hidden;
}

.speedRegular {
position: absolute;
left: 0;
height: 100%;
width: 33.33%;
background-color: $ui-white;
}

.speedOverride {
position: absolute;
background-color: $orange-700;
left: 0;
height: 100%;
width: calc(var(--override, 0) * 1%);
}

.labels {
margin-top: 0.25rem;
font-size: calc(1rem - 3px);
color: $label-gray;
position: relative;

> span {
position: absolute;
transform: translateX(-50%);

&:first-child {
transform: translateX(0);
}

&:last-child {
transform: translateX(-100%);
}
}

.override {
color: $gray-1100;
background-color: $orange-600;
padding: 0 0.25rem;
border-radius: 2px;
top: calc(-4px - 0.5rem); // speed + gap * 2
transform: translate(-50%, -100%);
// TODO: account for case where we translate all the way left
font-weight: 600;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { Button } from '@chakra-ui/react';

import { setTimerSpeed, useTimerSpeed } from '../../../../common/hooks/useSocket';

import style from './TimerSpeed.module.scss';

// TODO: extract and test
function mapRange(value: number, fromA_start: number, fromA_end: number, toB_start: number, toB_end: number): number {
return ((value - fromA_start) * (toB_end - toB_start)) / (fromA_end - fromA_start) + toB_start;
}
Comment on lines +8 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Address TODO: Extract and test mapRange function.

The mapRange function should be moved to a separate utility file and unit tested.

Would you like me to create a new utility file for this function and generate unit tests? I can also open a GitHub issue to track this task if you prefer.


export default function TimerSpeed() {
// TODO: this is the speed currently in place
const { speed: _currentSpeed } = useTimerSpeed();

// TODO: new speed comes from reply from server
const [newSpeed, _setNewSpeed] = useState(1.23);
const newSpeedIndicator = mapRange(newSpeed, 0.5, 2.0, 0, 100);
const { calculateSpeed, setSpeed, resetSpeed } = setTimerSpeed;

console.log('newSpeedIndicator', newSpeedIndicator);

const handleApply = () => {
console.log('timerSpeedControl.apply');
// TODO: add dynamic value
setSpeed(1.23);
};

const handleReset = () => {
console.log('timerSpeedControl.reset');
resetSpeed();
};

const handleMeetSchedule = () => {
console.log('timerSpeedControl.calculate');
calculateSpeed();
};

return (
<div className={style.panelContainer}>
<div className={style.label}>Timer speed</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply}>
Apply
</Button>
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset}>
Reset
</Button>
<Button size='sm' variant='ontime-subtle-white' onClick={handleMeetSchedule}>
Meet schedule
</Button>
</div>
<div>
<span>1.0x</span>
<span>{'->'}</span>
<span className={style.highlight}>{`${newSpeed}x`}</span>
</div>
<div>
<div className={style.speedContainer}>
<div className={style.speedOverride} style={{ '--override': newSpeedIndicator }} />
<div className={style.speedRegular} />
</div>
<div className={style.labels}>
<span>0.5x</span>
<span className={style.override} style={{ left: `${newSpeedIndicator}%` }}>{`${newSpeed}x`}</span>
<span style={{ left: '33.33%' }}>1.0x</span>
<span style={{ left: '66.66%' }}>1.5x</span>
<span style={{ left: '100%' }}>2.0x</span>
</div>
</div>
</div>
);
}
1 change: 0 additions & 1 deletion apps/client/src/theme/ontimeButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,4 @@ export const ontimeButtonGhosted = {
export const ontimeButtonSubtleWhite = {
...ontimeButtonSubtle,
color: '#f6f6f6', // $gray-50
fontWeight: 600,
};
18 changes: 18 additions & 0 deletions apps/server/src/api-integration/integration.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,24 @@ const actionHandlers: Record<string, ActionHandler> = {
}
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) => {
// TODO: validate payload type
const factor = runtimeService.setSpeed(payload as number);
return { payload: factor };
},
'reset-speed': () => {
const factor = runtimeService.resetSpeed();
return { payload: factor };
},
/* Client */
client: (payload) => {
assert.isObject(payload);
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/services/runtime-service/RuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,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
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/stores/runtimeState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const initialTimer: TimerState = {
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
} as const;

export type RuntimeState = {
Expand Down Expand Up @@ -360,6 +361,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() {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please dont see these as answers, but as suggestions

Right now, we are attaching the functionality of time manipulation to a target time. I believe that we should not make the feature in a way that would stop us from doing some time manipulation on its own (ie, set timer to 1.2x)

With this assumption, this is a utility function that gives me the speed that I would need to finish on time. without making mutations to state.
With this in mind

// TODO: what should this return if no timer is running?

Good question.
Considering this as a utility function, I believe the appropriate behaviour is to give me a result, or a reason not to do so. In that case, it is appropriate to throw when the operation is invalid

// 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?

I dont think we should do rounding.
Following the previous rule, I believe that we should throw if the desired finish time is in the past

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

calculateSpeed does exactly that, simply returns the speed factor necessary to finish on the timer scheduled end. The user would then need to apply that speed factor (or any other speed factor they want). As far as the clamping/rounding the function can return something like 1.234567898765432 I guess the precision is probably "free" to carry around just might only need truncated in displaying to the user.

// 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;
Expand Down Expand Up @@ -476,6 +504,7 @@ export type UpdateResult = {
};

export function update(): UpdateResult {
const timeSinceLastUpdate = clock.timeNow() - runtimeState.clock;
// 0. there are some things we always do
runtimeState.clock = clock.timeNow(); // we update the clock on every update call

Expand All @@ -500,6 +529,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);
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/definitions/runtime/TimerState.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};