mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 832457620e | |||
| 9feec76858 | |||
| 05e3c8a9f8 | |||
| 08c580f773 | |||
| c33cbbea7d | |||
| 7c642f3832 | |||
| 729c68e9e2 | |||
| 932bb8102c | |||
| ee39bc296f | |||
| 8cda9eac0a | |||
| 140a3310cb | |||
| 0a6542df76 | |||
| b2eb140ee2 | |||
| e28f692d3d | |||
| dc73e7e637 | |||
| b81e3a4ba5 | |||
| 6fc7b15d23 | |||
| 99dd4b4d5c | |||
| b60b1beeec | |||
| eab916f410 | |||
| 069a003787 | |||
| 9b08080b7a | |||
| 8e7ab54d39 | |||
| 4a16378786 | |||
| e5b30770ce | |||
| 8465b04c89 | |||
| 718a6ad2b7 | |||
| 6ca4bfc363 | |||
| 4859026989 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.13.1",
|
||||
"version": "3.14.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.13.1",
|
||||
"version": "3.14.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"@mantine/hooks": "^7.13.3",
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^8.43.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@tanstack/react-query-devtools": "^5.62.7",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
|
||||
@@ -158,7 +158,14 @@ export default function AppRouter() {
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
<Route path='/cuesheet' element={<PCuesheet />} />
|
||||
<Route path='/op' element={<POperator />} />
|
||||
<Route
|
||||
path='/op'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<POperator />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes - Elements *!/*/}
|
||||
<Route
|
||||
|
||||
@@ -26,17 +26,22 @@ class ErrorBoundary extends React.Component {
|
||||
error,
|
||||
errorInfo: info,
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
|
||||
const appState = runtimeStore.getState();
|
||||
if (appState.ping < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setExtras({
|
||||
error,
|
||||
store: runtimeStore.getState(),
|
||||
store: appState,
|
||||
hasSocket: { hasConnected, shouldReconnect, reconnectAttempts },
|
||||
});
|
||||
const eventId = Sentry.captureException(error);
|
||||
this.setState({ eventId, info });
|
||||
});
|
||||
this.reportContent = `${error} ${info.componentStack}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function useReactiveTextInput(
|
||||
submitOnCtrlEnter?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
): UseReactiveTextInputReturn {
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
@@ -58,7 +59,14 @@ export default function useReactiveTextInput(
|
||||
setText(cleanVal);
|
||||
}
|
||||
}
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
|
||||
setTimeout(() => {
|
||||
if (options?.allowKeyboardNavigation) {
|
||||
ref.current?.parentElement?.focus(); // Focus on parent element to continue keyboard navigation
|
||||
} else {
|
||||
ref.current?.blur();
|
||||
}
|
||||
}); // Immediate timeout to ensure text is set before blurring
|
||||
},
|
||||
[initialText, options, ref, submitCallback],
|
||||
);
|
||||
@@ -70,12 +78,25 @@ export default function useReactiveTextInput(
|
||||
const handleEscape = useCallback(() => {
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
// force the text to be the initial value
|
||||
if (ref.current) {
|
||||
ref.current.value = initialText;
|
||||
}
|
||||
options?.onCancelUpdate?.();
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before bluring
|
||||
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
|
||||
}, [initialText, options, ref]);
|
||||
|
||||
const keyHandler = useMemo(() => {
|
||||
const hotKeys: HotkeyItem[] = [['Escape', handleEscape, { preventDefault: true }]];
|
||||
const hotKeys: HotkeyItem[] = [
|
||||
[
|
||||
'Escape',
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
handleEscape();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
],
|
||||
];
|
||||
|
||||
if (options?.submitOnEnter) {
|
||||
hotKeys.push(['Enter', () => handleSubmit(text)]);
|
||||
@@ -84,7 +105,22 @@ export default function useReactiveTextInput(
|
||||
if (options?.submitOnCtrlEnter) {
|
||||
hotKeys.push(['mod + Enter', () => handleSubmit(text)]);
|
||||
}
|
||||
return getHotkeyHandler(hotKeys);
|
||||
|
||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||
|
||||
return (event: KeyboardEvent<HTMLElement>) => {
|
||||
// allow moving in input field with arrow keys
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
hotKeyHandler(event);
|
||||
};
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -15,13 +12,13 @@ interface AppLinkProps {
|
||||
* Handles the path to respect Ontime Clouds base URL
|
||||
*/
|
||||
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||
const { className, search, children } = props;
|
||||
const { search, children } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => navigate({ search });
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={cx([style.link, className])}>
|
||||
<button onClick={handleClick} className={style.link}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -126,9 +126,9 @@ function MultiOption(props: EditFormMultiOptionProps) {
|
||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
const { value, label, colour } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value}>
|
||||
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { ParamField } from './types';
|
||||
import type { MultiselectOptions, ParamField } from './types';
|
||||
|
||||
export const makeOptionsFromCustomFields = (
|
||||
customFields: CustomFields,
|
||||
@@ -18,6 +18,17 @@ export const makeOptionsFromCustomFields = (
|
||||
return options;
|
||||
};
|
||||
|
||||
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
|
||||
const options: MultiselectOptions = {};
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
options[key] = { value: key, label: value.label, colour: value.colour };
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export const getTimeOption = (timeFormat: string): ParamField => {
|
||||
const placeholder = `${timeFormat} (default)`;
|
||||
return {
|
||||
|
||||
@@ -12,9 +12,11 @@ type OptionsField = {
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type MultiselectOption = { value: string; label: string; colour: string };
|
||||
export type MultiselectOptions = Record<string, MultiselectOption>;
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: Record<string, { value: string; label: string; colour: string }>;
|
||||
values: MultiselectOptions;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||
|
||||
import { useRuntimeStore } from '../stores/runtime';
|
||||
import { socketSendJson } from '../utils/socket';
|
||||
@@ -41,17 +41,6 @@ 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,
|
||||
@@ -125,15 +114,6 @@ export const setAuxTimer = {
|
||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||
};
|
||||
|
||||
export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
|
||||
speed: state.timer.speed,
|
||||
}));
|
||||
|
||||
export const setTimerSpeed = {
|
||||
getSpeed: () => socketSendJson('get-speed'),
|
||||
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
|
||||
};
|
||||
|
||||
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
@@ -180,7 +160,7 @@ export const useRuntimePlaybackOverview = createSelector((state: RuntimeStore) =
|
||||
|
||||
numEvents: state.runtime.numEvents,
|
||||
selectedEventIndex: state.runtime.selectedEventIndex,
|
||||
offset: state.runtime.offset,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
|
||||
currentBlock: state.currentBlock,
|
||||
}));
|
||||
@@ -190,6 +170,15 @@ export const useTimelineStatus = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offset,
|
||||
}));
|
||||
|
||||
export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offset,
|
||||
}));
|
||||
@@ -203,6 +192,12 @@ export const useIsOnline = createSelector((state: RuntimeStore) => ({
|
||||
isOnline: state.ping > 0,
|
||||
}));
|
||||
|
||||
export const useOffsetMode = createSelector((state: RuntimeStore) => ({
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
}));
|
||||
|
||||
export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload);
|
||||
|
||||
export const usePlayback = () => {
|
||||
const featureSelector = (state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { formatTime, nowInMillis } from '../time';
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { calculateTimeUntilStart, formatTime, nowInMillis } from '../time';
|
||||
|
||||
describe('nowInMillis()', () => {
|
||||
it('should return the current time in milliseconds', () => {
|
||||
@@ -38,3 +41,267 @@ describe('formatTime()', () => {
|
||||
expect(time).toStrictEqual('-01:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTimeUntilStart()', () => {
|
||||
describe('Absolute offset mode', () => {
|
||||
test('ontime', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: 0,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 90,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(30);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(30);
|
||||
});
|
||||
|
||||
test('running ahead', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 80,
|
||||
offset: 10,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // <-- when running ahead the unlinked timer stays put
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(10);
|
||||
});
|
||||
|
||||
test('running behind with enough gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 20,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(50); // <-- when gap is enough to compensate for the running behind
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
|
||||
test('running behind with too little gaps', () => {
|
||||
const test = {
|
||||
timeStart: 100,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 50,
|
||||
offset: -20,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
actualStart: 0,
|
||||
plannedStart: 0,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(60); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(70); // This should not be possible
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relative offset mode', () => {
|
||||
test('basic function', () => {
|
||||
const test = {
|
||||
timeStart: 0,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0, // relative
|
||||
// offset: -100, // absolute
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
const timeStartEvent2 = 10;
|
||||
const timeStartEvent3 = 20;
|
||||
|
||||
//event 1 is the currently running event
|
||||
|
||||
//event 2
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(10);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(10);
|
||||
|
||||
//event 3
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||
test.clock = 105;
|
||||
|
||||
//event 2
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: true })).toBe(5);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent2, isLinkedToLoaded: false })).toBe(5);
|
||||
|
||||
//event 3
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: timeStartEvent3, isLinkedToLoaded: false })).toBe(15);
|
||||
});
|
||||
|
||||
test('gaps', () => {
|
||||
const test = {
|
||||
timeStart: 20,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 10,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0, // relative
|
||||
// offset: -100, // absolute
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
// When clock advances by 5ms, time until start should decrease by 5ms
|
||||
test.clock = 105;
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(15);
|
||||
});
|
||||
|
||||
test('added/remove time', () => {
|
||||
const test = {
|
||||
timeStart: 20,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0, // relative
|
||||
// offset: -100, // absolute
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(20);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20);
|
||||
|
||||
test.offset = 5; // remove 5 with addtime - we are ahead of time
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(15);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(20); // unlocked evets will stay on schedule
|
||||
|
||||
test.offset = -5; // add 5 with addtime - we are behind
|
||||
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: true })).toBe(25);
|
||||
expect(calculateTimeUntilStart({ ...test, isLinkedToLoaded: false })).toBe(25);
|
||||
});
|
||||
|
||||
test('next day', () => {
|
||||
const test = {
|
||||
// timeStart: 10,
|
||||
// dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
// totalGap: 0,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0, // relative
|
||||
// offset: -100, // absolute
|
||||
offsetMode: OffsetMode.Relative,
|
||||
};
|
||||
|
||||
// this event will start the current day
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 10,
|
||||
dayOffset: 0,
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(10);
|
||||
|
||||
// this event will start the next day
|
||||
// in absolute mode this would start in dayInMs - 100 since the gap would compensate
|
||||
// but in relative mode with and actual start that is 100 offset it starts in dayInMs
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 0,
|
||||
dayOffset: 1,
|
||||
totalGap: dayInMs - 20,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(dayInMs);
|
||||
|
||||
// advancing 100ms
|
||||
test.clock = 200;
|
||||
|
||||
expect(
|
||||
calculateTimeUntilStart({
|
||||
...test,
|
||||
timeStart: 0,
|
||||
dayOffset: 1,
|
||||
totalGap: dayInMs - 20,
|
||||
isLinkedToLoaded: false,
|
||||
}),
|
||||
).toBe(dayInMs - 100);
|
||||
});
|
||||
});
|
||||
|
||||
test('overlap with negative total gap', () => {
|
||||
const test = {
|
||||
// timeStart: 20,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
currentDay: 0,
|
||||
// totalGap: 10,
|
||||
clock: 100,
|
||||
actualStart: 100,
|
||||
plannedStart: 0,
|
||||
offset: 0, // relative
|
||||
// offset: -100, // absolute
|
||||
offsetMode: OffsetMode.Relative,
|
||||
isLinkedToLoaded: false,
|
||||
};
|
||||
|
||||
// the overlap will be pushed out to the expected available time
|
||||
expect(calculateTimeUntilStart({ ...test, timeStart: 5, totalGap: -5 })).toBe(10);
|
||||
|
||||
test.clock = 105;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Allows lazy evaluation and caching of a functions result
|
||||
*/
|
||||
export function lazyEvaluate<T>(fn: () => T): () => T {
|
||||
let result: T | undefined;
|
||||
return function () {
|
||||
if (result === undefined) {
|
||||
result = fn();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MaybeNumber, Settings, TimeFormat } from 'ontime-types';
|
||||
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
import { MaybeNumber, OffsetMode, OntimeEvent, Settings, TimeFormat } from 'ontime-types';
|
||||
import { dayInMs, formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { FORMAT_12, FORMAT_24 } from '../../viewerConfig';
|
||||
import { APP_SETTINGS } from '../api/constants';
|
||||
import { useTimeUntilData } from '../hooks/useSocket';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
/**
|
||||
@@ -125,3 +126,89 @@ export function formatDuration(duration: number, hideSeconds = true): string {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param totalGap accumulated gap from the current event
|
||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||
* @returns
|
||||
*/
|
||||
export function useTimeUntilStart(
|
||||
// typed like this to make it very clear what the data is
|
||||
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
},
|
||||
): number {
|
||||
const { offset, clock, currentDay, offsetMode, actualStart, plannedStart } = useTimeUntilData();
|
||||
return calculateTimeUntilStart({ ...data, currentDay, clock, offset, offsetMode, actualStart, plannedStart });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param currentDay the day offset of the urrently running event
|
||||
* @param totalGap accumulated gap from the current event
|
||||
* @param isLinkedToLoaded is this event part of a chain linking back to the current loaded event
|
||||
* @param clock
|
||||
* @param offset
|
||||
* @returns
|
||||
*/
|
||||
export function calculateTimeUntilStart(
|
||||
data: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'> & {
|
||||
currentDay: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
clock: number;
|
||||
offset: number;
|
||||
offsetMode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
},
|
||||
): number {
|
||||
const {
|
||||
timeStart,
|
||||
dayOffset,
|
||||
currentDay,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
clock,
|
||||
offset,
|
||||
delay,
|
||||
offsetMode,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
} = data;
|
||||
|
||||
//How many days from the currently running event to this one
|
||||
const relativeDayOffset = dayOffset - currentDay;
|
||||
|
||||
const delayedStart = Math.max(0, timeStart + delay);
|
||||
|
||||
//The normalised start time of this event relative to the currently running event
|
||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||
|
||||
let relativeStartOffset = 0;
|
||||
|
||||
if (offsetMode === OffsetMode.Relative) {
|
||||
relativeStartOffset = (actualStart ?? 0) - (plannedStart ?? 0);
|
||||
}
|
||||
|
||||
const scheduledTimeUntil = normalisedTimeStart - clock + relativeStartOffset;
|
||||
|
||||
const offsetTimeUntil = scheduledTimeUntil - offset;
|
||||
|
||||
if (isLinkedToLoaded) {
|
||||
//if we are directly linked back to the loaded event we just follow the offset
|
||||
return offsetTimeUntil;
|
||||
}
|
||||
|
||||
const gapsCanCompensateForOffset = totalGap + offset >= 0;
|
||||
if (gapsCanCompensateForOffset) {
|
||||
// if we are ahead of schedule or the gap can compensate for the amount we are behind then expect to start at the scheduled time
|
||||
return scheduledTimeUntil;
|
||||
}
|
||||
|
||||
// otherwise consume as much of the offset as possible with the gap
|
||||
const offsetTimeUntilBufferedByGaps = offsetTimeUntil - totalGap;
|
||||
return offsetTimeUntilBufferedByGaps;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
|
||||
export const appVersion = version;
|
||||
export const isProduction = import.meta.env.MODE === 'production';
|
||||
export const isDev = !isProduction;
|
||||
export const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||
export const currentHostName = window.location.hostname;
|
||||
export const isLocalhost = currentHostName === 'localhost' || currentHostName === '127.0.0.1';
|
||||
export const isDockerImage = Boolean(import.meta.env.VITE_IS_DOCKER);
|
||||
export const isOntimeCloud = window.location.hostname.includes('cloud.getontime.no');
|
||||
export const isOntimeCloud = currentHostName.includes('cloud.getontime.no');
|
||||
|
||||
// resolve entrypoint URLs
|
||||
|
||||
@@ -75,4 +76,4 @@ function resolveBaseURI(): string {
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import OntimeActionForm from './OntimeActionForm';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
|
||||
@@ -3,3 +3,12 @@
|
||||
background: $ui-white;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.column {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: $label-gray;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,12 @@ import { Button, Select, Switch } from '@chakra-ui/react';
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
|
||||
import { serverURL } from '../../../../externals';
|
||||
import { currentHostName, isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
@@ -39,6 +38,12 @@ export default function GenerateLinkForm() {
|
||||
formState: { errors },
|
||||
} = useForm<GenerateLinkFormOptions>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
baseUrl: currentHostName,
|
||||
path: '',
|
||||
lock: false,
|
||||
authenticate: false,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -72,8 +77,11 @@ export default function GenerateLinkForm() {
|
||||
</Info>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Host IP' description='Which IP address will be used' />
|
||||
<Select variant='ontime' size='sm' {...register('baseUrl')}>
|
||||
<Panel.Field
|
||||
title='Host IP'
|
||||
description={`Which IP address will be used${isOntimeCloud ? ' (not applicable in Ontime Cloud)' : ''}`}
|
||||
/>
|
||||
<Select variant='ontime' isDisabled={isOntimeCloud} size='sm' {...register('baseUrl')}>
|
||||
{infoData.networkInterfaces.map((nif) => {
|
||||
return (
|
||||
<option key={nif.name} value={nif.address}>
|
||||
@@ -128,9 +136,9 @@ export default function GenerateLinkForm() {
|
||||
>
|
||||
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
|
||||
</Button>
|
||||
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div className={style.column}>
|
||||
<QRCode size={172} value={url} className={style.qrCode} />
|
||||
<ExternalLink href={url}>{url}</ExternalLink>
|
||||
<div>{url}</div>
|
||||
</div>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { usePing } from '../../../../common/hooks/useSocket';
|
||||
import { socketSendJson } from '../../../../common/utils/socket';
|
||||
import { isDockerImage } from '../../../../externals';
|
||||
import { isDockerImage, isOntimeCloud } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
|
||||
@@ -30,8 +30,12 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
{!isOntimeCloud && (
|
||||
<>
|
||||
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
|
||||
<InfoNif />
|
||||
</>
|
||||
)}
|
||||
<GenerateLinkForm />
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
|
||||
@@ -15,7 +14,7 @@ export default function PlaybackControl() {
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
<PlaybackTimer playback={data.playback}>
|
||||
<PlaybackTimer playback={data.playback as Playback}>
|
||||
<AddTime playback={data.playback} />
|
||||
</PlaybackTimer>
|
||||
<PlaybackButtons
|
||||
@@ -24,7 +23,6 @@ export default function PlaybackControl() {
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
timerPhase={data.timerPhase}
|
||||
/>
|
||||
<TimerSpeed isPlaying={data.playback === Playback.Play} eventIndex={data.selectedEventIndex} />
|
||||
<AuxTimer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { memo } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
@@ -17,9 +16,7 @@ interface AddTimeProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
export default memo(AddTime);
|
||||
|
||||
function AddTime(props: AddTimeProps) {
|
||||
export default function AddTime(props: AddTimeProps) {
|
||||
const { playback } = props;
|
||||
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
.timeContainer {
|
||||
display: grid;
|
||||
grid-template-areas: 'indicators timer addtime';
|
||||
grid-template-areas:
|
||||
'indicators timer addtime'
|
||||
'status status addtime';
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-columns: 1.25rem 1fr 6.5rem;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
@@ -47,3 +50,29 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
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';
|
||||
|
||||
@@ -33,16 +36,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = (() => {
|
||||
if (!isRolling) {
|
||||
return '';
|
||||
}
|
||||
if (isWaiting) {
|
||||
return 'Roll: Countdown to start';
|
||||
}
|
||||
|
||||
return 'Roll mode active';
|
||||
})();
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
|
||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||
|
||||
@@ -58,7 +52,55 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
||||
<div className={style.status}>
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
) : (
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunningStatusProps {
|
||||
startedAt: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
playback: Playback;
|
||||
}
|
||||
function RunningStatus(props: RunningStatusProps) {
|
||||
const { startedAt, expectedFinish, playback } = props;
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(finishedMs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={style.start}>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StoppedStatus() {
|
||||
const { data } = useReport();
|
||||
const hasReport = Object.keys(data).length > 0;
|
||||
|
||||
if (hasReport) {
|
||||
return <AppLink search='settings=feature_settings__report'>Go to report management</AppLink>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
color: $timer-color;
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
font-size: 3.5rem;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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 <div className={cx([style.entry, style.roll])}>Roll: Countdown to start</div>;
|
||||
}
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.timers}>
|
||||
<div className={style.entry}>
|
||||
<div className={style.timerLabel}>Started at</div>
|
||||
<div>{started}</div>
|
||||
</div>
|
||||
<div className={style.entry}>
|
||||
<div className={style.timerLabel}>Expected end</div>
|
||||
{hasNewSpeed ? <SpeedFinish newSpeed={newSpeed} /> : <div>{endTime}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<AppLink className={style.entry} search='settings=feature_settings__report'>
|
||||
Go to report management
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={style.entry}>No running playback</div>;
|
||||
}
|
||||
|
||||
interface SpeedFinishProps {
|
||||
newSpeed: number;
|
||||
}
|
||||
|
||||
function SpeedFinish(props: SpeedFinishProps) {
|
||||
const { newSpeed } = props;
|
||||
const { current } = useTimerCurrent();
|
||||
|
||||
const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed));
|
||||
|
||||
return <div className={style.highlight}>{newFinish}</div>;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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 (
|
||||
<div className={style.panelContainer}>
|
||||
<TimerSchedule speed={speed} newSpeed={newSpeed} />
|
||||
<div className={style.inlineApart}>
|
||||
<div className={style.entry}>
|
||||
<span className={cx([willChangeSpeed && style.disabled])}>{`${speed}x`}</span>
|
||||
{newSpeed !== speed && <span className={style.highlight}>{` ⇢ ${newSpeed}x`}</span>}
|
||||
</div>
|
||||
<div className={style.inlineSiblings}>
|
||||
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset} isDisabled={!canReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply} isDisabled={!canApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
variant={newSpeed === 1 ? 'ontime' : 'ontime-highlight'}
|
||||
defaultValue={newSpeed}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.01}
|
||||
onChange={(v) => setNewSpeed(v)}
|
||||
value={newSpeed}
|
||||
isDisabled={!isPlaying}
|
||||
>
|
||||
<SliderMark value={0.5}>0.5x</SliderMark>
|
||||
<SliderMark value={1.0}>1.0x</SliderMark>
|
||||
<SliderMark value={1.5}>1.5x</SliderMark>
|
||||
<SliderMark value={2.0}>2.0x</SliderMark>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export default function Operator() {
|
||||
|
||||
// subscriptions is a MultiSelect and may have multiple values
|
||||
const subscriptions = searchParams.getAll('subscribe').filter((value) => Object.hasOwn(customFields, value));
|
||||
const canEdit = shouldEdit && subscriptions;
|
||||
const canEdit = shouldEdit && subscriptions.length;
|
||||
|
||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||
const secondary = searchParams.get('secondary');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CustomFields } from 'ontime-types';
|
||||
|
||||
import {
|
||||
getTimeOption,
|
||||
makeCustomFieldSelectOptions,
|
||||
makeOptionsFromCustomFields,
|
||||
OptionTitle,
|
||||
} from '../../common/components/view-params-editor/constants';
|
||||
@@ -9,13 +10,7 @@ import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
const customFieldSelect = Object.entries(customFields).reduce<
|
||||
Record<string, { value: string; label: string; colour: string }>
|
||||
>((acc, [key, field]) => {
|
||||
acc[key] = { value: key, label: field.label, colour: field.colour };
|
||||
return acc;
|
||||
}, {});
|
||||
const customFieldSelect = makeCustomFieldSelectOptions(customFields);
|
||||
|
||||
return [
|
||||
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
|
||||
@@ -46,6 +41,13 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
title: 'Edit custom field',
|
||||
description: 'Allows editing an highlighted custom field by long pressing',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -59,13 +61,6 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
title: 'Edit custom field',
|
||||
description: 'Allows editing an events selected custom field by long pressing.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -170,7 +170,7 @@ function RuntimeOverview() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} />
|
||||
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
|
||||
<TimeColumn label='Time now' value={formatedTime(clock)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,16 @@ interface TimeLayoutProps {
|
||||
muted?: boolean;
|
||||
daySpan?: number;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function TimeColumn({ label, value, muted, className }: TimeLayoutProps) {
|
||||
export function TimeColumn({ label, value, muted, className, testId }: TimeLayoutProps) {
|
||||
return (
|
||||
<div className={style.column}>
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={cx([style.clock, muted && style.muted, className])}>{value}</span>
|
||||
<span className={cx([style.clock, muted && style.muted, className])} data-testid={testId}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
MaybeString,
|
||||
OntimeEvent,
|
||||
PlayableEvent,
|
||||
Playback,
|
||||
RundownCached,
|
||||
@@ -272,7 +271,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
let isNextDay = false;
|
||||
let totalGap = 0;
|
||||
const isEditMode = appMode === AppMode.Edit;
|
||||
let currentDay = 0;
|
||||
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
|
||||
return (
|
||||
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
|
||||
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
|
||||
@@ -299,7 +298,10 @@ export default function Rundown({ data }: RundownProps) {
|
||||
|
||||
if (isPlayableEvent(entry)) {
|
||||
isNextDay = checkIsNextDay(entry, lastEvent);
|
||||
totalGap += !isPast ? entry.gap : 0;
|
||||
if (!isPast) {
|
||||
totalGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null;
|
||||
}
|
||||
if (isNewLatest(entry, lastEvent)) {
|
||||
// populate previous entry
|
||||
thisEvent = entry;
|
||||
@@ -313,7 +315,6 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const hasCursor = entry.id === cursor;
|
||||
if (isLoaded) {
|
||||
isPast = false;
|
||||
currentDay = (entry as OntimeEvent).dayOffset;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -336,7 +337,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
isRolling={featureData.playback === Playback.Roll}
|
||||
isNextDay={isNextDay}
|
||||
totalGap={totalGap}
|
||||
currentDay={currentDay}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ interface RundownEntryProps {
|
||||
playback?: Playback; // we only care about this if this event is playing
|
||||
isRolling: boolean; // we need to know even if not related to this event
|
||||
totalGap: number;
|
||||
currentDay: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
export default function RundownEntry(props: RundownEntryProps) {
|
||||
@@ -56,7 +56,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
eventIndex,
|
||||
isNextDay,
|
||||
totalGap,
|
||||
currentDay,
|
||||
isLinkedToLoaded,
|
||||
} = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||
@@ -178,8 +178,9 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
isRolling={isRolling}
|
||||
gap={data.gap}
|
||||
isNextDay={isNextDay}
|
||||
dayOffset={data.dayOffset - currentDay}
|
||||
dayOffset={data.dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,8 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
|
||||
fontWeight='600'
|
||||
letterSpacing='0.25px'
|
||||
paddingLeft='0'
|
||||
size='sm'
|
||||
fontSize='1rem'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ $skip-opacity: 0.2;
|
||||
'binder ... ... ...';
|
||||
|
||||
grid-template-columns: $block-binder-width 3rem 1fr auto;
|
||||
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
|
||||
grid-template-rows: 0.125rem 2rem 2rem 2rem 0.125rem;
|
||||
align-items: center;
|
||||
padding-right: $block-clearance;
|
||||
gap: 2px;
|
||||
row-gap: 0.25rem;
|
||||
|
||||
transition-property: background-color;
|
||||
transition-duration: $transition-time-feedback;
|
||||
@@ -120,7 +121,7 @@ $skip-opacity: 0.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.eventTimers {
|
||||
|
||||
@@ -51,6 +51,7 @@ interface EventBlockProps {
|
||||
isNextDay: boolean;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
actionHandler: (
|
||||
action: EventItemActions,
|
||||
payload?:
|
||||
@@ -91,6 +92,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
isNextDay,
|
||||
dayOffset,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
actionHandler,
|
||||
} = props;
|
||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||
@@ -310,6 +312,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
dayOffset={dayOffset}
|
||||
isPast={isPast}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
|
||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||
@@ -47,6 +46,7 @@ interface EventBlockInnerProps {
|
||||
dayOffset: number;
|
||||
isPast: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
function EventBlockInner(props: EventBlockInnerProps) {
|
||||
@@ -72,6 +72,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
dayOffset,
|
||||
isPast,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
} = props;
|
||||
|
||||
const [renderInner, setRenderInner] = useState(false);
|
||||
@@ -120,11 +121,13 @@ function EventBlockInner(props: EventBlockInnerProps) {
|
||||
<EventBlockChip
|
||||
className={style.chipSection}
|
||||
id={eventId}
|
||||
trueTimeStart={timeStart + dayOffset * dayInMs}
|
||||
timeStart={timeStart}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isPast={isPast}
|
||||
isLoaded={loaded}
|
||||
totalGap={totalGap}
|
||||
isLinkedAndNext={isNext && linkStart !== null}
|
||||
duration={duration}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,27 +3,29 @@ import { Tooltip } from '@chakra-ui/react';
|
||||
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
|
||||
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { usePlayback, useTimelineStatus } from '../../../../common/hooks/useSocket';
|
||||
import { usePlayback } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time';
|
||||
import { tooltipDelayFast } from '../../../../ontimeConfig';
|
||||
|
||||
import style from './EventBlockChip.module.scss';
|
||||
|
||||
interface EventBlockChipProps {
|
||||
id: string;
|
||||
trueTimeStart: number;
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
isPast: boolean;
|
||||
isLoaded: boolean;
|
||||
className: string;
|
||||
totalGap: number;
|
||||
isLinkedAndNext: boolean;
|
||||
duration: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
export default function EventBlockChip(props: EventBlockChipProps) {
|
||||
const { trueTimeStart, isPast, isLoaded, className, totalGap, isLinkedAndNext, id, duration } = props;
|
||||
const { timeStart, delay, dayOffset, isPast, isLoaded, className, totalGap, id, duration, isLinkedToLoaded } = props;
|
||||
const { playback } = usePlayback();
|
||||
|
||||
if (isLoaded) {
|
||||
@@ -41,7 +43,13 @@ export default function EventBlockChip(props: EventBlockChipProps) {
|
||||
return (
|
||||
<Tooltip label='Expected time until start' openDelay={tooltipDelayFast}>
|
||||
<div className={className}>
|
||||
<EventUntil trueTimeStart={trueTimeStart} totalGap={totalGap} isLinkedAndNext={isLinkedAndNext} />
|
||||
<EventUntil
|
||||
timeStart={timeStart}
|
||||
delay={delay}
|
||||
dayOffset={dayOffset}
|
||||
totalGap={totalGap}
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -51,18 +59,17 @@ export default function EventBlockChip(props: EventBlockChipProps) {
|
||||
}
|
||||
|
||||
interface EventUntilProps {
|
||||
trueTimeStart: number;
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedAndNext: boolean;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
function EventUntil(props: EventUntilProps) {
|
||||
const { trueTimeStart, totalGap, isLinkedAndNext } = props;
|
||||
const { clock, offset } = useTimelineStatus();
|
||||
const { timeStart, delay, dayOffset, totalGap, isLinkedToLoaded } = props;
|
||||
|
||||
const consumedOffset = isLinkedAndNext ? offset : Math.min(offset + totalGap, 0);
|
||||
const offsetTimestart = trueTimeStart - consumedOffset;
|
||||
const timeUntil = offsetTimestart - clock;
|
||||
const timeUntil = useTimeUntilStart({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded });
|
||||
const isDue = timeUntil < MILLIS_PER_SECOND;
|
||||
|
||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import EventEditor from './EventEditor';
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.eventEditor} data-testid='editor-container'>
|
||||
<div className={cx([style.eventEditor, style.inModal])} data-testid='editor-container'>
|
||||
<EventEditor event={event} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
.eventEditor {
|
||||
max-height: 80vh;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
|
||||
&.inModal {
|
||||
max-height: 80vh;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
||||
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
|
||||
|
||||
import RundownMenu from './RundownMenu';
|
||||
@@ -12,6 +14,8 @@ export default function RundownHeader() {
|
||||
const setRunMode = () => setAppMode(AppMode.Run);
|
||||
const setEditMode = () => setAppMode(AppMode.Edit);
|
||||
|
||||
const { offsetMode } = useOffsetMode();
|
||||
|
||||
return (
|
||||
<div className={style.header}>
|
||||
<ButtonGroup isAttached>
|
||||
@@ -22,6 +26,22 @@ export default function RundownHeader() {
|
||||
Edit
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup isAttached>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={offsetMode === OffsetMode.Absolute ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setOffsetMode(OffsetMode.Absolute)}
|
||||
>
|
||||
Absolute
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={offsetMode === OffsetMode.Relative ? 'ontime-filled' : 'ontime-subtle'}
|
||||
onClick={() => setOffsetMode(OffsetMode.Relative)}
|
||||
>
|
||||
Relative
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<RundownMenu />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ $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;
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ontimeButtonFilled = {
|
||||
|
||||
export const ontimeButtonOutlined = {
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
color: '#e2e2e2', // $gray-200
|
||||
color: '#e2e2e2', // $blue-400
|
||||
border: '1px solid rgba(255, 255, 255, 0.10)', // white-10
|
||||
_hover: {
|
||||
backgroundColor: '#404040', // $gray-1000
|
||||
@@ -74,4 +74,5 @@ export const ontimeButtonGhosted = {
|
||||
export const ontimeButtonSubtleWhite = {
|
||||
...ontimeButtonSubtle,
|
||||
color: '#f6f6f6', // $gray-50
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
},
|
||||
};
|
||||
@@ -16,7 +16,6 @@ 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 {
|
||||
@@ -141,12 +140,6 @@ const theme = extendTheme({
|
||||
ontime: { ...ontimeSelect },
|
||||
},
|
||||
},
|
||||
Slider: {
|
||||
variants: {
|
||||
ontime: { ...ontimeSlider },
|
||||
'ontime-highlight': { ...ontimeHighlightSlider },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -81,10 +81,18 @@ export default function Backstage(props: BackstageProps) {
|
||||
const clock = formatTime(time.clock);
|
||||
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
||||
const startedAt = isPendingStart ? formatTime(time.secondaryTimer) : formatTime(time.startedAt);
|
||||
const scheduledStart =
|
||||
hasEvents && showNow ? '' : formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
const scheduledEnd =
|
||||
hasEvents && showNow ? '' : formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
|
||||
const scheduledStart = (() => {
|
||||
if (showNow) return undefined;
|
||||
if (!hasEvents) return undefined;
|
||||
return formatTime(runtime.plannedStart, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
})();
|
||||
|
||||
const scheduledEnd = (() => {
|
||||
if (showNow) return undefined;
|
||||
if (!hasEvents) return undefined;
|
||||
return formatTime(runtime.plannedEnd, { format12: 'hh:mm a', format24: 'HH:mm' });
|
||||
})();
|
||||
|
||||
let displayTimer = millisToString(time.current, { fallback: timerPlaceholderMin });
|
||||
displayTimer = removeLeadingZero(displayTimer);
|
||||
@@ -93,6 +101,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
const qrSize = Math.max(window.innerWidth / 15, 72);
|
||||
const showProgress = getShowProgressBar(time.playback);
|
||||
const showSchedule = hasEvents && screenHeight > 420; // in vertical screens we may not have space
|
||||
const showPending = scheduledStart && scheduledEnd;
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
@@ -143,7 +152,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showNow && hasEvents && (
|
||||
{showPending && (
|
||||
<div className='event'>
|
||||
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
|
||||
<div className='timer-group'>
|
||||
|
||||
@@ -23,8 +23,8 @@ import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
// TODO: can we use the normalised rundown for the table?
|
||||
const { data: flatRundown } = useFlatRundown();
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
|
||||
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
|
||||
@@ -50,7 +50,7 @@ export default function CuesheetPage() {
|
||||
[onEventEditorClose, onEventEditorOpen],
|
||||
);
|
||||
|
||||
if (!customFields || !flatRundown) {
|
||||
if (!customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending') {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
position: relative;
|
||||
@include ellipsis-overflow;
|
||||
}
|
||||
|
||||
th:focus,
|
||||
td:focus {
|
||||
outline: 2px solid $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.tableHeader,
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
import {
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
MaybeString,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
OntimeRundownEntry,
|
||||
TimeField,
|
||||
} from 'ontime-types';
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import useFollowComponent from '../../../common/hooks/useFollowComponent';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useCuesheetOptions } from '../cuesheet.options';
|
||||
|
||||
import BlockRow from './cuesheet-table-elements/BlockRow';
|
||||
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
|
||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import useColumnManager from './useColumnManager';
|
||||
@@ -38,8 +25,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
const { data, columns, showModal } = props;
|
||||
|
||||
const { updateEvent, updateTimer } = useEventAction();
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { followSelected, hideDelays, hidePast, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
||||
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
|
||||
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
|
||||
useColumnManager(columns);
|
||||
|
||||
@@ -47,6 +33,8 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
const tableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
|
||||
|
||||
const { listeners } = useTableNav();
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@@ -63,6 +51,7 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||
// check if value is the same
|
||||
const event = data[rowIndex];
|
||||
|
||||
if (!event || !isOntimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
@@ -104,10 +93,6 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
const rowModel = table.getRowModel();
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
|
||||
let eventIndex = 0;
|
||||
// for the first event, it will be past if there is something selected
|
||||
let isPast = Boolean(selectedEventId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CuesheetTableSettings
|
||||
@@ -117,74 +102,9 @@ export default function CuesheetTable(props: CuesheetTableProps) {
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet} id='cuesheet'>
|
||||
<table className={style.cuesheet} id='cuesheet' {...listeners}>
|
||||
<CuesheetHeader headerGroups={headerGroups} />
|
||||
<tbody>
|
||||
{rowModel.rows.map((row, index) => {
|
||||
const key = row.original.id;
|
||||
const isSelected = selectedEventId === key;
|
||||
const entry = row.original;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
return <BlockRow key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
||||
}
|
||||
if (isOntimeDelay(entry)) {
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
const delayVal = entry.duration;
|
||||
if (hideDelays || delayVal === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DelayRow key={key} duration={delayVal} />;
|
||||
}
|
||||
if (isOntimeEvent(entry)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedEventId;
|
||||
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (isSelected) {
|
||||
rowBgColour = '#D20300'; // $red-700
|
||||
} else if (entry.colour) {
|
||||
try {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = Color(getAccessibleColour(entry.colour).backgroundColor);
|
||||
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
|
||||
} catch (_error) {
|
||||
/* we do not handle errors here */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EventRow
|
||||
key={`${row.id}-${entry.revision}`}
|
||||
rowId={row.id}
|
||||
eventId={entry.id}
|
||||
eventIndex={eventIndex}
|
||||
rowIndex={index}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
skip={entry.skip}
|
||||
colour={entry.colour}
|
||||
rowBgColour={rowBgColour}
|
||||
table={table}
|
||||
columnSizing={columnSizing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// currently there is no scenario where entryType is not handled above, either way...
|
||||
return null;
|
||||
})}
|
||||
</tbody>
|
||||
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} columnSizing={columnSizing} />
|
||||
</table>
|
||||
</div>
|
||||
<CuesheetTableMenu showModal={showModal} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useRef } from 'react';
|
||||
|
||||
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
|
||||
|
||||
@@ -7,19 +7,37 @@ import style from '../CuesheetTable.module.scss';
|
||||
interface BlockRowProps {
|
||||
hidePast: boolean;
|
||||
title: string;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
function BlockRow(props: BlockRowProps) {
|
||||
const { hidePast, title } = props;
|
||||
const { hidePast, title, columnCount } = props;
|
||||
const { currentBlockId } = useCurrentBlockId();
|
||||
const firstCellRef = useRef<null | HTMLTableCellElement>(null);
|
||||
|
||||
if (hidePast && !currentBlockId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paddingRows = new Array(columnCount - 1).fill(null);
|
||||
|
||||
return (
|
||||
<tr className={style.blockRow}>
|
||||
<td>{title}</td>
|
||||
<td tabIndex={-1} role='cell' ref={firstCellRef}>
|
||||
{title}
|
||||
</td>
|
||||
{paddingRows.map((_value, index) => {
|
||||
return (
|
||||
<td
|
||||
key={index}
|
||||
tabIndex={-1}
|
||||
role='cell'
|
||||
onFocus={() => {
|
||||
firstCellRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { RowModel, Table } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
|
||||
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { useCuesheetOptions } from '../../../cuesheet/cuesheet.options';
|
||||
|
||||
import BlockRow from './BlockRow';
|
||||
import DelayRow from './DelayRow';
|
||||
import EventRow from './EventRow';
|
||||
|
||||
interface CuesheetBodyProps {
|
||||
rowModel: RowModel<OntimeRundownEntry>;
|
||||
selectedRef: MutableRefObject<HTMLTableRowElement | null>;
|
||||
table: Table<OntimeRundownEntry>;
|
||||
columnSizing: Record<string, number>;
|
||||
}
|
||||
|
||||
export default function CuesheetBody(props: CuesheetBodyProps) {
|
||||
const { rowModel, selectedRef, table, columnSizing } = props;
|
||||
|
||||
const { selectedEventId } = useSelectedEventId();
|
||||
const { hideDelays, hidePast } = useCuesheetOptions();
|
||||
|
||||
const getColumnCount = lazyEvaluate(() => table.getVisibleFlatColumns().length);
|
||||
|
||||
let eventIndex = 0;
|
||||
// for the first event, it will be past if there is something selected
|
||||
let isPast = Boolean(selectedEventId);
|
||||
return (
|
||||
<tbody>
|
||||
{rowModel.rows.map((row, index) => {
|
||||
const key = row.original.id;
|
||||
const isSelected = selectedEventId === key;
|
||||
const entry = row.original;
|
||||
if (isSelected) {
|
||||
isPast = false;
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
const columnCount = getColumnCount();
|
||||
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
|
||||
}
|
||||
if (isOntimeDelay(entry)) {
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
const delayVal = entry.duration;
|
||||
if (hideDelays || delayVal === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <DelayRow key={key} duration={delayVal} />;
|
||||
}
|
||||
if (isOntimeEvent(entry)) {
|
||||
eventIndex++;
|
||||
const isSelected = key === selectedEventId;
|
||||
|
||||
if (isPast && hidePast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (isSelected) {
|
||||
rowBgColour = '#D20300'; // $red-700
|
||||
} else if (entry.colour) {
|
||||
try {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = Color(getAccessibleColour(entry.colour).backgroundColor);
|
||||
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
|
||||
} catch (_error) {
|
||||
/* we do not handle errors here */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EventRow
|
||||
key={row.id}
|
||||
rowId={row.id}
|
||||
event={entry}
|
||||
eventIndex={eventIndex}
|
||||
rowIndex={index}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
rowBgColour={rowBgColour}
|
||||
table={table}
|
||||
columnSizing={columnSizing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// currently there is no scenario where entryType is not handled above, either way...
|
||||
return null;
|
||||
})}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
+6
-2
@@ -24,8 +24,12 @@ export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
|
||||
return (
|
||||
<tr key={headerGroup.id}>
|
||||
{showActionMenu && <th className={style.actionColumn} />}
|
||||
{!hideIndexColumn && <th className={style.indexColumn}>#</th>}
|
||||
{showActionMenu && <th className={style.actionColumn} tabIndex={-1} />}
|
||||
{!hideIndexColumn && (
|
||||
<th className={style.indexColumn} tabIndex={-1}>
|
||||
#
|
||||
</th>
|
||||
)}
|
||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const width = header.getSize();
|
||||
|
||||
@@ -14,7 +14,9 @@ function DelayRow(props: DelayRowProps) {
|
||||
|
||||
return (
|
||||
<tr className={style.delayRow}>
|
||||
<td>{delayTime}</td>
|
||||
<td tabIndex={0} role='cell'>
|
||||
{delayTime}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
+29
-12
@@ -2,7 +2,7 @@ import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react
|
||||
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
|
||||
import { flexRender, Table } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
import { OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
@@ -13,7 +13,7 @@ import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface EventRowProps {
|
||||
rowId: string;
|
||||
eventId: string;
|
||||
event: OntimeEvent;
|
||||
eventIndex: number;
|
||||
rowIndex: number;
|
||||
isPast?: boolean;
|
||||
@@ -26,10 +26,22 @@ interface EventRowProps {
|
||||
columnSizing: Record<string, number>;
|
||||
}
|
||||
|
||||
export default memo(EventRow);
|
||||
export default memo(EventRow, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.rowId === nextProps.rowId &&
|
||||
prevProps.event.revision === nextProps.event.revision &&
|
||||
prevProps.eventIndex === nextProps.eventIndex &&
|
||||
prevProps.rowIndex === nextProps.rowIndex &&
|
||||
prevProps.isPast === nextProps.isPast &&
|
||||
prevProps.selectedRef === nextProps.selectedRef &&
|
||||
prevProps.rowBgColour === nextProps.rowBgColour &&
|
||||
prevProps.table === nextProps.table &&
|
||||
prevProps.columnSizing === nextProps.columnSizing
|
||||
);
|
||||
});
|
||||
|
||||
function EventRow(props: EventRowProps) {
|
||||
const { rowId, eventId, eventIndex, rowIndex, isPast, selectedRef, skip, colour, rowBgColour, table } = props;
|
||||
const { rowId, event, eventIndex, rowIndex, isPast, selectedRef, rowBgColour, table } = props;
|
||||
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -63,23 +75,23 @@ function EventRow(props: EventRowProps) {
|
||||
};
|
||||
}, [ownRef, selectedRef]);
|
||||
|
||||
const { color, backgroundColor } = getAccessibleColour(colour);
|
||||
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
||||
const mutedText = Color(color).fade(0.4).hexa();
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cx([style.eventRow, skip ?? style.skip])}
|
||||
className={cx([style.eventRow, event.skip ?? style.skip])}
|
||||
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
|
||||
ref={selectedRef ?? ownRef}
|
||||
>
|
||||
{showActionMenu && (
|
||||
<td className={style.actionColumn}>
|
||||
<td className={style.actionColumn} tabIndex={-1} role='cell'>
|
||||
<IconButton
|
||||
aria-label='Options'
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const yPos = 8 + rect.y + rect.height / 2;
|
||||
openMenu({ x: rect.x, y: yPos }, eventId, rowIndex);
|
||||
openMenu({ x: rect.x, y: yPos }, event.id, rowIndex);
|
||||
}}
|
||||
>
|
||||
<IoEllipsisHorizontal />
|
||||
@@ -87,7 +99,7 @@ function EventRow(props: EventRowProps) {
|
||||
</td>
|
||||
)}
|
||||
{!hideIndexColumn && (
|
||||
<td className={style.indexColumn} style={{ backgroundColor, color: mutedText }}>
|
||||
<td className={style.indexColumn} style={{ backgroundColor, color: mutedText }} tabIndex={-1} role='cell'>
|
||||
{eventIndex}
|
||||
</td>
|
||||
)}
|
||||
@@ -97,7 +109,12 @@ function EventRow(props: EventRowProps) {
|
||||
?.getVisibleCells()
|
||||
.map((cell) => {
|
||||
return (
|
||||
<td key={cell.id} style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}>
|
||||
<td
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}
|
||||
tabIndex={-1}
|
||||
role='cell'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ function MultiLineCell(props: MultiLineCellProps) {
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
submitOnCtrlEnter: true,
|
||||
allowKeyboardNavigation: true,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+4
@@ -17,6 +17,7 @@ const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
|
||||
|
||||
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
|
||||
allowSubmitSameValue,
|
||||
allowKeyboardNavigation: true,
|
||||
submitOnEnter: true, // single line should submit on enter
|
||||
submitOnCtrlEnter: true,
|
||||
onCancelUpdate: handleCancelUpdate,
|
||||
@@ -31,6 +32,9 @@ const SingleLineCell = forwardRef((props: SingleLineCellProps, inputRef) => {
|
||||
select() {
|
||||
ref.current?.select();
|
||||
},
|
||||
focusParentElement() {
|
||||
ref.current?.parentElement?.focus();
|
||||
},
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
|
||||
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan} tabIndex={-1}>
|
||||
<div {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
+18
-6
@@ -1,22 +1,34 @@
|
||||
import { HTMLAttributes, memo, PropsWithChildren } from 'react';
|
||||
import { forwardRef, HTMLAttributes, memo, PropsWithChildren, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import style from './TextLikeInput.module.scss';
|
||||
|
||||
export default memo(TextLikeInput);
|
||||
|
||||
interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
delayed?: boolean;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
function TextLikeInput(props: PropsWithChildren<TextLikeInputProps>) {
|
||||
const TextLikeInput = forwardRef((props: PropsWithChildren<TextLikeInputProps>, textRef) => {
|
||||
const { delayed, muted, children, className, ...elementProps } = props;
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const classes = cx([style.textInput, delayed && style.delayed, muted && style.muted, className]);
|
||||
|
||||
useImperativeHandle(textRef, () => {
|
||||
return {
|
||||
focusParentElement() {
|
||||
ref.current?.parentElement?.focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={classes} {...elementProps} tabIndex={0}>
|
||||
<div className={classes} {...elementProps} tabIndex={0} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
TextLikeInput.displayName = 'TextLikeInput';
|
||||
|
||||
export default memo(TextLikeInput);
|
||||
|
||||
+22
-3
@@ -11,6 +11,10 @@ interface TimeInputDurationProps {
|
||||
onSubmit: (value: string) => void;
|
||||
}
|
||||
|
||||
interface ParentFocusableInput extends HTMLInputElement {
|
||||
focusParentElement: () => void;
|
||||
}
|
||||
|
||||
export default memo(TimeInputDuration);
|
||||
|
||||
function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
|
||||
@@ -18,7 +22,8 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<ParentFocusableInput>(null);
|
||||
const textRef = useRef<ParentFocusableInput>(null);
|
||||
|
||||
// when we go into edit mode, set focus to the input
|
||||
useEffect(() => {
|
||||
@@ -36,7 +41,10 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
|
||||
}, [initialValue, isEditing]);
|
||||
|
||||
const handleFakeFocus = () => setIsEditing(true);
|
||||
const handleFakeBlur = () => setIsEditing(false);
|
||||
const handleFakeBlur = () => {
|
||||
setIsEditing(false);
|
||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||
};
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(newValue: string) => {
|
||||
@@ -45,28 +53,33 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
|
||||
// if the user sends an empty string, we want to clear the value
|
||||
if (newValue === '') {
|
||||
onSubmit(newValue);
|
||||
inputRef.current?.focusParentElement();
|
||||
return;
|
||||
}
|
||||
|
||||
// we dont know the values in the rundown, escalate to handler
|
||||
if (newValue.startsWith('p') || newValue.startsWith('+')) {
|
||||
onSubmit(newValue);
|
||||
inputRef.current?.focusParentElement();
|
||||
return;
|
||||
}
|
||||
|
||||
const valueInMillis = parseUserTime(newValue);
|
||||
if (valueInMillis < 0 || isNaN(valueInMillis)) {
|
||||
setValue(initialValue);
|
||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||
return;
|
||||
}
|
||||
|
||||
// if the value is the same, we may still want to push the lock change
|
||||
if (valueInMillis === initialValue && lockedValue) {
|
||||
inputRef.current?.focusParentElement();
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit(newValue);
|
||||
setValue(Number(newValue));
|
||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||
},
|
||||
[initialValue, lockedValue, onSubmit],
|
||||
);
|
||||
@@ -82,7 +95,13 @@ function TimeInputDuration(props: PropsWithChildren<TimeInputDurationProps>) {
|
||||
handleCancelUpdate={handleFakeBlur}
|
||||
/>
|
||||
) : (
|
||||
<TextLikeInput onClick={handleFakeFocus} onFocus={handleFakeFocus} muted={!lockedValue} delayed={delayed}>
|
||||
<TextLikeInput
|
||||
onClick={handleFakeFocus}
|
||||
onFocus={handleFakeFocus}
|
||||
muted={!lockedValue}
|
||||
delayed={delayed}
|
||||
ref={textRef}
|
||||
>
|
||||
{children}
|
||||
</TextLikeInput>
|
||||
);
|
||||
|
||||
@@ -64,11 +64,16 @@ export default function Public(props: BackstageProps) {
|
||||
// gather timer data
|
||||
const clock = formatTime(time.clock);
|
||||
const isPendingStart = getIsPendingStart(time.playback, time.phase);
|
||||
const scheduledStart = hasEvents && showNow ? '' : getFirstStartTime(events[0]);
|
||||
const scheduledStart = (() => {
|
||||
if (showNow) return undefined;
|
||||
if (!hasEvents) return undefined;
|
||||
return getFirstStartTime(events[0]);
|
||||
})();
|
||||
|
||||
// gather presentation styles
|
||||
const qrSize = Math.max(window.innerWidth / 15, 72);
|
||||
const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
|
||||
const showPending = scheduledStart !== undefined;
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
@@ -92,7 +97,7 @@ export default function Public(props: BackstageProps) {
|
||||
{showNow && hasEvents && (
|
||||
<TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />
|
||||
)}
|
||||
{!showNow && scheduledStart && (
|
||||
{showPending && (
|
||||
<div className='event'>
|
||||
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
|
||||
<div className='timer-group'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "3.13.1",
|
||||
"version": "3.14.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.13.1",
|
||||
"version": "3.14.2",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { generateAuthenticatedUrl } from '../session.service.js';
|
||||
|
||||
describe('generateAuthenticatedUrl()', () => {
|
||||
describe('for local IP addresses', () => {
|
||||
it('generates a link without locking or authentication', () => {
|
||||
const localhostNotLocked = generateAuthenticatedUrl('http://localhost:3000', 'timer', false, false);
|
||||
expect(localhostNotLocked.toString()).toBe('http://localhost:3000/timer');
|
||||
});
|
||||
|
||||
it('generates a link with IP locking enabled', () => {
|
||||
const ipLocked = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, false);
|
||||
expect(ipLocked.toString()).toBe('http://192.168.10.173:4001/timer?locked=true');
|
||||
});
|
||||
|
||||
it('generates a link with authentication token and IP locking', () => {
|
||||
const withAuth = generateAuthenticatedUrl('http://192.168.10.173:4001', 'timer', true, true, undefined, '1234');
|
||||
expect(withAuth.toString()).toBe('http://192.168.10.173:4001/timer?token=1234&locked=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('for ontime-cloud URLs', () => {
|
||||
it('generates a link without locking or authentication', () => {
|
||||
const cloudNotLocked = generateAuthenticatedUrl(
|
||||
'https://cloud.getontime.no/userhash',
|
||||
'timer',
|
||||
false,
|
||||
false,
|
||||
'prefix',
|
||||
);
|
||||
expect(cloudNotLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer');
|
||||
});
|
||||
|
||||
it('generates a link with IP locking enabled', () => {
|
||||
const ipLocked = generateAuthenticatedUrl('https://cloud.getontime.no/prefix', 'timer', true, false, 'prefix');
|
||||
expect(ipLocked.toString()).toBe('https://cloud.getontime.no/prefix/timer?locked=true');
|
||||
});
|
||||
|
||||
it('generates a link with authentication token and IP locking', () => {
|
||||
const withAuth = generateAuthenticatedUrl(
|
||||
'https://cloud.getontime.no/prefix',
|
||||
'timer',
|
||||
true,
|
||||
true,
|
||||
'prefix',
|
||||
'1234',
|
||||
);
|
||||
expect(withAuth.toString()).toBe('https://cloud.getontime.no/prefix/timer?token=1234&locked=true');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { getLastLoadedProject } from '../../services/app-state-service/AppStateS
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||
import { getTimezoneLabel } from '../../utils/time.js';
|
||||
import { password } from '../../externals.js';
|
||||
import { password, routerPrefix } from '../../externals.js';
|
||||
import { hashPassword } from '../../utils/hash.js';
|
||||
|
||||
const startedAt = new Date();
|
||||
@@ -55,10 +55,19 @@ export const hashedPassword = hasPassword ? hashPassword(password as string) : u
|
||||
/**
|
||||
* Generates a pre-authenticated URL by injecting a token in the URL params
|
||||
*/
|
||||
export function generateAuthenticatedUrl(baseUrl: string, path: string, lock: boolean, authenticate: boolean): URL {
|
||||
const url = new URL(path, baseUrl);
|
||||
if (authenticate && hashedPassword) {
|
||||
url.searchParams.append('token', hashedPassword);
|
||||
export function generateAuthenticatedUrl(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
lock: boolean,
|
||||
authenticate: boolean,
|
||||
prefix = routerPrefix,
|
||||
hash = hashedPassword,
|
||||
): URL {
|
||||
const url = new URL(baseUrl);
|
||||
url.pathname = prefix ? `${prefix}/${path}` : path;
|
||||
|
||||
if (authenticate && hash) {
|
||||
url.searchParams.append('token', hash);
|
||||
}
|
||||
if (lock) {
|
||||
url.searchParams.append('locked', 'true');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
@@ -17,6 +17,7 @@ import { throttle } from '../utils/throttle.js';
|
||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||
|
||||
import { handleLegacyMessageConversion } from './integration.legacy.js';
|
||||
import { coerceEnum } from '../utils/coerceType.js';
|
||||
|
||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
||||
let lastRequest: Date | null = null;
|
||||
@@ -259,20 +260,6 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
}
|
||||
throw new Error('No matching method provided');
|
||||
},
|
||||
/* Speed */
|
||||
'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);
|
||||
@@ -300,6 +287,11 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
|
||||
throw new Error('No matching method provided');
|
||||
},
|
||||
offsetmode: (payload) => {
|
||||
const mode = coerceEnum<OffsetMode>(payload, OffsetMode);
|
||||
runtimeService.setOffsetMode(mode);
|
||||
return { payload: 'success' };
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,13 +16,14 @@ export const isDocker = env === 'docker';
|
||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
||||
export const password = process.env.SESSION_PASSWORD;
|
||||
export const routerPrefix = process.env.ROUTER_PREFIX;
|
||||
|
||||
/**
|
||||
* Updates the router prefix in the index.html file
|
||||
* This is only needed in the cloud environment where the client is not at the root segment
|
||||
* ie: https://cloud.getontime.com/client-hash/timer
|
||||
*/
|
||||
export function updateRouterPrefix(prefix: string | undefined = process.env.ROUTER_PREFIX): string {
|
||||
export function updateRouterPrefix(prefix: string | undefined = routerPrefix): string {
|
||||
if (!prefix) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime
|
||||
import {
|
||||
getCurrent,
|
||||
getExpectedFinish,
|
||||
getRelativeOffset,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
normaliseEndTime,
|
||||
@@ -1046,6 +1047,84 @@ describe('getRuntimeOffset()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelativeOffset()', () => {
|
||||
it('relative offset is 0 when starting at the planed time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 150,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150,
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
actualStart: 150,
|
||||
plannedStart: 150,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
state.runtime.offset = getRuntimeOffset(state);
|
||||
expect(state.runtime.offset).toBe(0);
|
||||
const relativeOffsetoffset = getRelativeOffset(state);
|
||||
expect(relativeOffsetoffset).toBe(0);
|
||||
});
|
||||
it('relative offset is 0 when starting after the planed time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 100,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 150,
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
actualStart: 150,
|
||||
plannedStart: 100,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
state.runtime.offset = getRuntimeOffset(state);
|
||||
expect(state.runtime.offset).toBe(-50);
|
||||
const relativeOffsetoffset = getRelativeOffset(state);
|
||||
expect(relativeOffsetoffset).toBe(0);
|
||||
});
|
||||
it('relative offset is 0 when starting before the planed time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 150,
|
||||
},
|
||||
timer: {
|
||||
startedAt: 100,
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
},
|
||||
runtime: {
|
||||
actualStart: 100,
|
||||
plannedStart: 150,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
state.runtime.offset = getRuntimeOffset(state);
|
||||
expect(state.runtime.offset).toBe(50);
|
||||
const relativeOffsetoffset = getRelativeOffset(state);
|
||||
expect(relativeOffsetoffset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimerPhase()', () => {
|
||||
it('should be None if the timer is not running', () => {
|
||||
const state = {
|
||||
@@ -1169,9 +1248,11 @@ describe('getTimerPhase()', () => {
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
},
|
||||
_rundown: {
|
||||
totalDelay: 0,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
@@ -1208,9 +1289,11 @@ describe('getTimerPhase()', () => {
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
},
|
||||
_rundown: {
|
||||
totalDelay: 0,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isPlayableEvent,
|
||||
LogOrigin,
|
||||
MaybeNumber,
|
||||
OffsetMode,
|
||||
OntimeEvent,
|
||||
Playback,
|
||||
TimerLifeCycle,
|
||||
@@ -68,6 +69,11 @@ class RuntimeService {
|
||||
RuntimeService.previousState = {} as RuntimeState;
|
||||
}
|
||||
|
||||
@broadcastResult
|
||||
setOffsetMode(mode: OffsetMode) {
|
||||
runtimeState.setOffsetMode(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks result of an update and notifies integrations as needed
|
||||
* This is the only exception of a private method that has broadcast result
|
||||
@@ -666,43 +672,6 @@ class RuntimeService {
|
||||
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {
|
||||
if (speed < 0.5 || speed > 2.0) {
|
||||
logger.warning(LogOrigin.Server, `Speed out of bounds: ${speed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = runtimeState.getState();
|
||||
if (currentState.eventNow === null) {
|
||||
logger.warning(LogOrigin.Server, 'No event running to set speed to');
|
||||
}
|
||||
|
||||
const newSpeed = runtimeState.setSpeed(speed);
|
||||
logger.info(LogOrigin.Server, `Speed set to ${newSpeed}`);
|
||||
return newSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the speed of the current timer
|
||||
*/
|
||||
public resetSpeed(): number {
|
||||
const speed = runtimeState.resetSpeed();
|
||||
logger.info(LogOrigin.Server, `Speed set to ${speed}`);
|
||||
return speed;
|
||||
}
|
||||
}
|
||||
|
||||
// calculate at 30fps, refresh at 1fps
|
||||
@@ -734,11 +703,14 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
// for the very fist run there will be nothing in the previousState so we force an update
|
||||
const justStarted = !RuntimeService.previousState?.timer;
|
||||
|
||||
// offset mode has been changed
|
||||
const offsetModeChanged = RuntimeService.previousState?.runtime?.offsetMode !== state.runtime.offsetMode;
|
||||
|
||||
// if playback changes most things should update
|
||||
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
||||
|
||||
// combine all big changes
|
||||
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback;
|
||||
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged;
|
||||
|
||||
/**
|
||||
* Timer should be updated if
|
||||
|
||||
@@ -39,5 +39,6 @@ export function getShouldTimerUpdate(previousValue: MaybeNumber, currentValue: M
|
||||
export function getForceUpdate(previousUpdate: number, now: number): boolean {
|
||||
const isClockBehind = now < previousUpdate;
|
||||
const hasExceededRate = now - previousUpdate >= timerConfig.notificationRate;
|
||||
return isClockBehind || hasExceededRate;
|
||||
const newSeconds = millisToSeconds(previousUpdate) !== millisToSeconds(now);
|
||||
return isClockBehind || hasExceededRate || newSeconds;
|
||||
}
|
||||
|
||||
@@ -37,23 +37,6 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
return timeEnd + addedTime + pausedTime;
|
||||
}
|
||||
|
||||
// TODO: need to calculate the expected end taking the speed into account
|
||||
/**
|
||||
* // For normal timer events:
|
||||
// 1. Compute how much real time (excluding paused time) has elapsed.
|
||||
const realElapsed = clock - startedAt - pausedTime;
|
||||
// 2. Translate real elapsed time to virtual elapsed time using the speed factor.
|
||||
const virtualElapsed = realElapsed * speed;
|
||||
// 3. The virtual total time is the base duration plus any added time.
|
||||
const virtualTotal = duration! + addedTime;
|
||||
// 4. The remaining virtual time is the total virtual time minus what’s already elapsed.
|
||||
const virtualRemaining = virtualTotal - virtualElapsed;
|
||||
// 5. Convert the remaining virtual time back to real time.
|
||||
const remainingReal = virtualRemaining / speed;
|
||||
// 6. The new expected finish time is the current clock plus the adjusted remaining time.
|
||||
const adjustedFinish = clock + remainingReal;
|
||||
*/
|
||||
|
||||
// handle events that finish the day after
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
|
||||
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
|
||||
@@ -173,6 +156,23 @@ export function getRuntimeOffset(state: RuntimeState): number {
|
||||
return startOffset - addedTime - pausedTime + overtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates relative offset
|
||||
* should always be calculated after the absolute offset
|
||||
*/
|
||||
export function getRelativeOffset(state: RuntimeState): number {
|
||||
const { actualStart, plannedStart, offset } = state.runtime;
|
||||
// eslint-disable-next-line no-unused-labels -- dev code path
|
||||
DEV: {
|
||||
// we know actualStart and plannedStart exists as long as a timer is running
|
||||
if (actualStart === null || plannedStart === null) {
|
||||
throw new Error('timerUtils.calculate: actualStart and plannedStart must be set');
|
||||
}
|
||||
}
|
||||
const relativeStartOffset = actualStart - plannedStart;
|
||||
return offset + relativeStartOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the expected end of the rundown
|
||||
*/
|
||||
@@ -181,7 +181,7 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
||||
if (state.runtime.actualStart === null || state.runtime.plannedEnd === null) {
|
||||
return null;
|
||||
}
|
||||
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
||||
return state.runtime.plannedEnd - state.runtime.offset + state._rundown.totalDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TimerPhase, Playback } from 'ontime-types';
|
||||
import { TimerPhase, Playback, OffsetMode } from 'ontime-types';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
import type { RuntimeState } from '../runtimeState.js';
|
||||
|
||||
@@ -16,10 +16,12 @@ const baseState: RuntimeState = {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
offset: 0,
|
||||
relativeOffset: 0,
|
||||
plannedStart: 0,
|
||||
plannedEnd: 0,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
@@ -32,14 +34,15 @@ const baseState: RuntimeState = {
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
speed: 1,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
},
|
||||
_rundown: {
|
||||
totalDelay: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export function makeRuntimeStateData(patch?: Partial<RuntimeState>): RuntimeState {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { deepmerge } from 'ontime-utils';
|
||||
import {
|
||||
type RuntimeState,
|
||||
addTime,
|
||||
clear,
|
||||
hardClear,
|
||||
getState,
|
||||
load,
|
||||
loadBlock,
|
||||
@@ -71,7 +71,7 @@ beforeAll(() => {
|
||||
|
||||
describe('mutation on runtimeState', () => {
|
||||
beforeEach(() => {
|
||||
clear();
|
||||
hardClear();
|
||||
|
||||
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
@@ -246,7 +246,7 @@ describe('roll mode', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime('jan 1 00:00');
|
||||
clear();
|
||||
hardClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isPlayableEvent,
|
||||
MaybeNumber,
|
||||
MaybeString,
|
||||
OffsetMode,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
PlayableEvent,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
getCurrent,
|
||||
getExpectedEnd,
|
||||
getExpectedFinish,
|
||||
getRelativeOffset,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
} from '../services/timerUtils.js';
|
||||
@@ -45,10 +47,12 @@ export type RuntimeState = {
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
pausedAt: MaybeNumber;
|
||||
secondaryTarget: MaybeNumber;
|
||||
};
|
||||
_rundown: {
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
};
|
||||
};
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
@@ -62,10 +66,12 @@ const runtimeState: RuntimeState = {
|
||||
timer: { ...runtimeStorePlaceholder.timer },
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
},
|
||||
_rundown: {
|
||||
totalDelay: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export function getState(): Readonly<RuntimeState> {
|
||||
@@ -79,10 +85,36 @@ export function getState(): Readonly<RuntimeState> {
|
||||
runtime: { ...runtimeState.runtime },
|
||||
timer: { ...runtimeState.timer },
|
||||
_timer: { ...runtimeState._timer },
|
||||
_rundown: { totalDelay: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
//TODO: find other things that dose not need clearing
|
||||
export function softClear() {
|
||||
runtimeState.eventNow = null;
|
||||
runtimeState.publicEventNow = null;
|
||||
runtimeState.eventNext = null;
|
||||
runtimeState.publicEventNext = null;
|
||||
|
||||
// runtimeState.currentBlock.block = null;
|
||||
// runtimeState.currentBlock.startedAt = null;
|
||||
|
||||
runtimeState.runtime.offset = 0;
|
||||
runtimeState.runtime.relativeOffset = 0;
|
||||
runtimeState.runtime.expectedEnd = null;
|
||||
runtimeState.runtime.selectedEventIndex = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = clock.timeNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
runtimeState._timer.forceFinish = null;
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
}
|
||||
|
||||
export function hardClear() {
|
||||
runtimeState.eventNow = null;
|
||||
runtimeState.publicEventNow = null;
|
||||
runtimeState.eventNext = null;
|
||||
@@ -93,6 +125,7 @@ export function clear() {
|
||||
runtimeState.publicEventNext = null;
|
||||
|
||||
runtimeState.runtime.offset = 0;
|
||||
runtimeState.runtime.relativeOffset = 0;
|
||||
runtimeState.runtime.actualStart = null;
|
||||
runtimeState.runtime.expectedEnd = null;
|
||||
runtimeState.runtime.selectedEventIndex = null;
|
||||
@@ -137,7 +170,7 @@ type RundownData = {
|
||||
*/
|
||||
export function updateRundownData(rundownData: RundownData) {
|
||||
// we keep this in private state since there is no UI use case for it
|
||||
runtimeState._timer.totalDelay = rundownData.totalDelay;
|
||||
runtimeState._rundown.totalDelay = rundownData.totalDelay;
|
||||
|
||||
runtimeState.runtime.numEvents = rundownData.numEvents;
|
||||
runtimeState.runtime.plannedStart = rundownData.firstStart;
|
||||
@@ -154,10 +187,7 @@ export function load(
|
||||
rundown: OntimeRundown,
|
||||
initialData?: Partial<TimerState & RestorePoint>,
|
||||
): boolean {
|
||||
// we need to persist the current block state across loads
|
||||
const prevCurrentBlock = { ...runtimeState.currentBlock };
|
||||
clear();
|
||||
runtimeState.currentBlock = prevCurrentBlock;
|
||||
softClear();
|
||||
|
||||
// filter rundown
|
||||
const timedEvents = filterTimedEvents(rundown);
|
||||
@@ -185,6 +215,7 @@ export function load(
|
||||
if (firstStart === null || typeof firstStart === 'number') {
|
||||
runtimeState.runtime.actualStart = firstStart;
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.relativeOffset = getRelativeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
}
|
||||
if (typeof initialData.blockStartAt === 'number') {
|
||||
@@ -350,26 +381,6 @@ export function updateAll(rundown: OntimeRundown) {
|
||||
loadBlock(rundown);
|
||||
}
|
||||
|
||||
export function setSpeed(speed: number): number {
|
||||
const remainingMs = runtimeState.timer.current;
|
||||
const adjustedRemainingTimeMs = remainingMs / speed;
|
||||
const newFinishTimeMs = runtimeState.clock + adjustedRemainingTimeMs;
|
||||
|
||||
runtimeState.timer.speed = speed;
|
||||
runtimeState.timer.expectedFinish = newFinishTimeMs;
|
||||
|
||||
return runtimeState.timer.speed;
|
||||
}
|
||||
|
||||
export function resetSpeed() {
|
||||
runtimeState.timer.speed = 1.0;
|
||||
return runtimeState.timer.speed;
|
||||
}
|
||||
|
||||
export function getSpeed() {
|
||||
return runtimeState.timer.speed;
|
||||
}
|
||||
|
||||
export function start(state: RuntimeState = runtimeState): boolean {
|
||||
if (state.eventNow === null) {
|
||||
return false;
|
||||
@@ -410,6 +421,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
|
||||
// update offset
|
||||
state.runtime.offset = getRuntimeOffset(state);
|
||||
state.runtime.relativeOffset = getRelativeOffset(state);
|
||||
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
|
||||
return true;
|
||||
}
|
||||
@@ -429,9 +441,7 @@ export function stop(state: RuntimeState = runtimeState): boolean {
|
||||
if (state.timer.playback === Playback.Stop) {
|
||||
return false;
|
||||
}
|
||||
clear();
|
||||
runtimeState.runtime.actualStart = null;
|
||||
runtimeState.runtime.expectedEnd = null;
|
||||
hardClear();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -473,6 +483,7 @@ export function addTime(amount: number) {
|
||||
|
||||
// update runtime delays: over - under
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.relativeOffset = getRelativeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
|
||||
return true;
|
||||
@@ -484,7 +495,6 @@ 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
|
||||
@@ -511,12 +521,6 @@ 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);
|
||||
@@ -525,6 +529,7 @@ export function update(): UpdateResult {
|
||||
|
||||
// update runtime, needs up-to-date timer state
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.relativeOffset = getRelativeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
|
||||
const finishedNow =
|
||||
@@ -631,9 +636,7 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
|
||||
}
|
||||
|
||||
// we need to persist the current block state across loads
|
||||
const prevCurrentBlock = { ...runtimeState.currentBlock };
|
||||
clear();
|
||||
runtimeState.currentBlock = prevCurrentBlock;
|
||||
softClear();
|
||||
|
||||
//account for offset but we only keep it if passed to us
|
||||
runtimeState.runtime.offset = offset;
|
||||
@@ -723,3 +726,7 @@ export function loadBlock(rundown: OntimeRundown, state = runtimeState) {
|
||||
// update the block anyway
|
||||
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
|
||||
}
|
||||
|
||||
export function setOffsetMode(mode: OffsetMode) {
|
||||
runtimeState.runtime.offsetMode = mode;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('linked time until', async ({ page }) => {
|
||||
test('time until absolute', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
@@ -11,7 +11,46 @@ test('linked time until', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Absolute' }).click();
|
||||
await page.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(page.getByTestId('offset')).not.toContainText('00:00:00'); // This might be a bad test requires that the test is not run at 0h
|
||||
await page.getByLabel('Pause event').click();
|
||||
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
|
||||
await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('19m');
|
||||
await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('29m');
|
||||
|
||||
await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
|
||||
await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('6h');
|
||||
await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
|
||||
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5h59m');
|
||||
await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('6h9m');
|
||||
await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('6h19m');
|
||||
|
||||
await page.getByTestId('entry-1').getByTestId('time-input-duration').click();
|
||||
await page.getByTestId('entry-1').getByTestId('time-input-duration').fill('30s');
|
||||
await page.getByTestId('entry-1').getByTestId('time-input-duration').press('Enter');
|
||||
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('29s');
|
||||
await expect(page.getByTestId('entry-3').locator('#event-block')).toContainText('10m');
|
||||
await expect(page.getByTestId('entry-4').locator('#event-block')).toContainText('20m');
|
||||
});
|
||||
|
||||
test('time until relative', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/editor');
|
||||
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Relative' }).click();
|
||||
await page.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(page.getByTestId('offset')).toContainText('00:00:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
|
||||
await page.getByLabel('Pause event').click();
|
||||
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('9m');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.13.1",
|
||||
"version": "3.14.2",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||
|
||||
export enum OffsetMode {
|
||||
Absolute = 'absolute',
|
||||
Relative = 'relative',
|
||||
}
|
||||
|
||||
export type Runtime = {
|
||||
numEvents: number;
|
||||
selectedEventIndex: MaybeNumber;
|
||||
offset: number;
|
||||
relativeOffset: number;
|
||||
plannedStart: MaybeNumber;
|
||||
actualStart: MaybeNumber;
|
||||
plannedEnd: MaybeNumber;
|
||||
expectedEnd: MaybeNumber;
|
||||
offsetMode: OffsetMode;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { SimpleDirection, SimplePlayback } from './AuxTimer.type.js';
|
||||
import { Playback } from './Playback.type.js';
|
||||
import { OffsetMode } from './Runtime.type.js';
|
||||
import type { RuntimeStore } from './RuntimeStore.type.js';
|
||||
import { TimerPhase } from './TimerState.type.js';
|
||||
|
||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
||||
clock: 0,
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
@@ -16,7 +17,6 @@ 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: {
|
||||
@@ -33,10 +33,12 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
selectedEventIndex: null, // changes if rundown changes or we load a new event
|
||||
numEvents: 0, // change initiated by user
|
||||
offset: 0, // changes at runtime
|
||||
relativeOffset: 0, // changes at runtime
|
||||
plannedStart: 0, // only changes if event changes
|
||||
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
|
||||
actualStart: null, // set once we start the timer
|
||||
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
|
||||
offsetMode: OffsetMode.Absolute,
|
||||
},
|
||||
currentBlock: {
|
||||
block: null,
|
||||
|
||||
@@ -30,6 +30,4 @@ 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;
|
||||
};
|
||||
|
||||
@@ -87,6 +87,7 @@ export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLi
|
||||
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
|
||||
|
||||
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
||||
export { OffsetMode } from './definitions/runtime/Runtime.type.js';
|
||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||
export { runtimeStorePlaceholder } from './definitions/runtime/RuntimeStore.js';
|
||||
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
||||
|
||||
Generated
+17
@@ -119,6 +119,9 @@ importers:
|
||||
'@sentry/react':
|
||||
specifier: ^8.43.0
|
||||
version: 8.45.0(react@18.3.1)
|
||||
'@table-nav/react':
|
||||
specifier: ^0.0.7
|
||||
version: 0.0.7(@table-nav/core@0.0.7)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.62.7
|
||||
version: 5.62.7(react@18.3.1)
|
||||
@@ -1949,6 +1952,14 @@ packages:
|
||||
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
'@table-nav/core@0.0.7':
|
||||
resolution: {integrity: sha512-pCh18jHDRe3tw9sJZXfKi4cSD6VjHbn40CYdqhp5X91SIX7rakDEQAsTx6F7Fv9TUv265l+5rUDcYNaJ0N0cqQ==}
|
||||
|
||||
'@table-nav/react@0.0.7':
|
||||
resolution: {integrity: sha512-S+DsD/qDqp50Z4dqt5tZFMWA3sRu0OOT/grMQuq/z/52jPEKJB+b9t+YSH8Ms55vCJOJ0DxuYldJpYrJLMG5ew==}
|
||||
peerDependencies:
|
||||
'@table-nav/core': ^0.0.7
|
||||
|
||||
'@tanstack/eslint-plugin-query@5.8.4':
|
||||
resolution: {integrity: sha512-KVgcMc+Bn1qbwkxYVWQoiVSNEIN4IAiLj3cUH/SAHT8m8E59Y97o8ON1syp0Rcw094ItG8pEVZFyQuOaH6PDgQ==}
|
||||
peerDependencies:
|
||||
@@ -6698,6 +6709,12 @@ snapshots:
|
||||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
|
||||
'@table-nav/core@0.0.7': {}
|
||||
|
||||
'@table-nav/react@0.0.7(@table-nav/core@0.0.7)':
|
||||
dependencies:
|
||||
'@table-nav/core': 0.0.7
|
||||
|
||||
'@tanstack/eslint-plugin-query@5.8.4(eslint@8.56.0)(typescript@5.5.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 5.62.0(eslint@8.56.0)(typescript@5.5.3)
|
||||
|
||||
Reference in New Issue
Block a user