mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-05 14:29:20 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d2c86d6be |
@@ -7,15 +7,13 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#101010" />
|
<meta name="theme-color" content="#101010" />
|
||||||
<meta name="ontime" content="ontime - time keeping for live events" />
|
<meta name="ontime" content="ontime - time keeping for live events" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Ontime" />
|
|
||||||
<link rel="apple-touch-icon" href="ontime-logo.png" />
|
<link rel="apple-touch-icon" href="ontime-logo.png" />
|
||||||
<link rel="icon" type="image/png" href="ontime-logo.png" />
|
<link rel="icon" type="image/png" href="ontime-logo.png" />
|
||||||
|
<link rel="manifest" href="site.webmanifest" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<title>Ontime</title>
|
<title>ontime</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "Ontime",
|
"name": "ontime",
|
||||||
"short_name": "Ontime",
|
"short_name": "ontime",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "ontime-logo-192.png",
|
"src": "favicon.ico",
|
||||||
"sizes": "192x192",
|
"type": "image/x-icon"
|
||||||
"type": "image/png"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "ontime-logo-512.png",
|
"src": "ontime-logo.png",
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scope": "./",
|
"scope": "./",
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
"display": "standalone",
|
"display": "",
|
||||||
"theme_color": "#101010",
|
"theme_color": "#121212",
|
||||||
"background_color": "#101010"
|
"background_color": "#101010"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"short_name": "",
|
||||||
|
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
|
||||||
|
"theme_color": "#121212",
|
||||||
|
"background_color": "#101010",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Playback, RuntimeStore, TimerPhase, TimerType, runtimeStorePlaceholder } from 'ontime-types';
|
||||||
|
|
||||||
|
import { resolveTimerDisplay } from '../useSocket.utils';
|
||||||
|
|
||||||
|
const eventTimer = { ...runtimeStorePlaceholder.timer, current: 5_000 };
|
||||||
|
const groupTimer = {
|
||||||
|
...runtimeStorePlaceholder.timer,
|
||||||
|
current: 25_000,
|
||||||
|
phase: TimerPhase.Default,
|
||||||
|
playback: Playback.Play,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeState(patch: Partial<RuntimeStore> = {}): RuntimeStore {
|
||||||
|
return {
|
||||||
|
...runtimeStorePlaceholder,
|
||||||
|
timer: eventTimer,
|
||||||
|
eventNow: {
|
||||||
|
id: 'event-1',
|
||||||
|
timerType: TimerType.CountUp,
|
||||||
|
countToEnd: true,
|
||||||
|
} as RuntimeStore['eventNow'],
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveTimerDisplay()', () => {
|
||||||
|
it('uses the event timer by default', () => {
|
||||||
|
expect(resolveTimerDisplay(makeState())).toMatchObject({
|
||||||
|
time: eventTimer,
|
||||||
|
timerType: TimerType.CountUp,
|
||||||
|
countToEnd: true,
|
||||||
|
usesGroupTimer: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the group timer and display type when enabled', () => {
|
||||||
|
const display = resolveTimerDisplay(
|
||||||
|
makeState({
|
||||||
|
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||||
|
groupTimer,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(display).toMatchObject({
|
||||||
|
time: groupTimer,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
usesGroupTimer: true,
|
||||||
|
eventTimer,
|
||||||
|
eventTimerType: TimerType.CountUp,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a group timer when the group setting is disabled', () => {
|
||||||
|
const display = resolveTimerDisplay(
|
||||||
|
makeState({
|
||||||
|
groupNow: { useGroupTimer: false, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||||
|
groupTimer,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(display.time).toBe(eventTimer);
|
||||||
|
expect(display.usesGroupTimer).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back entirely to the event display while group timer data is unavailable', () => {
|
||||||
|
const display = resolveTimerDisplay(
|
||||||
|
makeState({
|
||||||
|
groupNow: { useGroupTimer: true, timerType: TimerType.CountDown } as RuntimeStore['groupNow'],
|
||||||
|
groupTimer: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(display).toMatchObject({
|
||||||
|
time: eventTimer,
|
||||||
|
timerType: TimerType.CountUp,
|
||||||
|
countToEnd: true,
|
||||||
|
usesGroupTimer: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
|
|||||||
import { RefObject, useCallback, useEffect } from 'react';
|
import { RefObject, useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
|
||||||
componentRef: RefObject<ComponentRef | null>,
|
componentRef: RefObject<ComponentRef>,
|
||||||
scrollRef: RefObject<ScrollRef | null>,
|
scrollRef: RefObject<ScrollRef>,
|
||||||
topOffset: number,
|
topOffset: number,
|
||||||
) {
|
) {
|
||||||
if (!componentRef.current || !scrollRef.current) {
|
if (!componentRef.current || !scrollRef.current) {
|
||||||
@@ -21,16 +21,18 @@ interface UseFollowComponentProps {
|
|||||||
followRef: RefObject<HTMLElement | null>;
|
followRef: RefObject<HTMLElement | null>;
|
||||||
scrollRef: RefObject<HTMLElement | null>;
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
doFollow: boolean;
|
doFollow: boolean;
|
||||||
followTrigger: MaybeString; // this would be an entry id or null
|
topOffset?: number;
|
||||||
getTopOffset: () => number;
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
|
followTrigger?: MaybeString; // this would be an entry id or null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useFollowComponent({
|
export default function useFollowComponent({
|
||||||
followRef,
|
followRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow,
|
doFollow,
|
||||||
|
topOffset = 100,
|
||||||
|
setScrollFlag,
|
||||||
followTrigger,
|
followTrigger,
|
||||||
getTopOffset,
|
|
||||||
}: UseFollowComponentProps) {
|
}: UseFollowComponentProps) {
|
||||||
// when trigger moves, view should follow
|
// when trigger moves, view should follow
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,17 +41,25 @@ export default function useFollowComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (followRef.current && scrollRef.current) {
|
if (followRef.current && scrollRef.current) {
|
||||||
|
setScrollFlag?.(true);
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
// resolve the offset after layout, so that measured values are up to date
|
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
|
||||||
scrollToComponent(followRef, scrollRef, getTopOffset());
|
setScrollFlag?.(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
|
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
||||||
|
|
||||||
const scrollToRefComponent = useCallback(() => {
|
const scrollToRefComponent = useCallback(
|
||||||
scrollToComponent(followRef, scrollRef, getTopOffset());
|
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||||
}, [followRef, scrollRef, getTopOffset]);
|
if (componentRef && containerRef) {
|
||||||
|
// @ts-expect-error -- we know this are not null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
scrollToComponent(componentRef!, containerRef!, offset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[followRef, scrollRef, topOffset],
|
||||||
|
);
|
||||||
|
|
||||||
return scrollToRefComponent;
|
return scrollToRefComponent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage, TimerType } from 'ontime-types';
|
import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types';
|
||||||
|
|
||||||
import { useRuntimeStore } from '../stores/runtime';
|
import { useRuntimeStore } from '../stores/runtime';
|
||||||
import { sendSocket } from '../utils/socket';
|
import { sendSocket } from '../utils/socket';
|
||||||
|
import { resolveTimerDisplay } from './useSocket.utils';
|
||||||
|
|
||||||
const createSelector =
|
const createSelector =
|
||||||
<T>(selector: (state: RuntimeStore) => T) =>
|
<T>(selector: (state: RuntimeStore) => T) =>
|
||||||
@@ -38,15 +39,19 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
|
|||||||
visible: state.message.timer.secondarySource === 'secondary',
|
visible: state.message.timer.secondarySource === 'secondary',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
export const useMessagePreview = createSelector((state: RuntimeStore) => {
|
||||||
blink: state.message.timer.blink,
|
const timerDisplay = resolveTimerDisplay(state);
|
||||||
blackout: state.message.timer.blackout,
|
return {
|
||||||
phase: state.timer.phase,
|
blink: state.message.timer.blink,
|
||||||
secondarySource: state.message.timer.secondarySource,
|
blackout: state.message.timer.blackout,
|
||||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
phase: timerDisplay.time.phase,
|
||||||
timerType: state.eventNow?.timerType ?? null,
|
secondarySource: state.message.timer.secondarySource,
|
||||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||||
}));
|
timerType: timerDisplay.timerType,
|
||||||
|
countToEnd: timerDisplay.countToEnd,
|
||||||
|
usesGroupTimer: timerDisplay.usesGroupTimer,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const setMessage = {
|
export const setMessage = {
|
||||||
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
|
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
|
||||||
@@ -230,20 +235,26 @@ export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
|||||||
|
|
||||||
/* ======================= View specific subscriptions ======================= */
|
/* ======================= View specific subscriptions ======================= */
|
||||||
|
|
||||||
export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
export const useTimerSocket = createSelector((state: RuntimeStore) => {
|
||||||
eventNext: state.eventNext,
|
const timerDisplay = resolveTimerDisplay(state);
|
||||||
eventNow: state.eventNow,
|
return {
|
||||||
message: state.message,
|
eventNext: state.eventNext,
|
||||||
time: state.timer,
|
eventNow: state.eventNow,
|
||||||
clock: state.clock,
|
message: state.message,
|
||||||
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
time: timerDisplay.time,
|
||||||
countToEndNow: state.eventNow?.countToEnd ?? false,
|
eventTimer: timerDisplay.eventTimer,
|
||||||
auxTimer: {
|
clock: state.clock,
|
||||||
aux1: state.auxtimer1.current,
|
timerTypeNow: timerDisplay.timerType,
|
||||||
aux2: state.auxtimer2.current,
|
eventTimerType: timerDisplay.eventTimerType,
|
||||||
aux3: state.auxtimer3.current,
|
countToEndNow: timerDisplay.countToEnd,
|
||||||
},
|
usesGroupTimer: timerDisplay.usesGroupTimer,
|
||||||
}));
|
auxTimer: {
|
||||||
|
aux1: state.auxtimer1.current,
|
||||||
|
aux2: state.auxtimer2.current,
|
||||||
|
aux3: state.auxtimer3.current,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
|
export const useCountdownSocket = createSelector((state: RuntimeStore) => ({
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { RuntimeStore, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
type TimerDisplaySource = Pick<RuntimeStore, 'eventNow' | 'groupNow' | 'groupTimer' | 'timer'>;
|
||||||
|
|
||||||
|
export function resolveTimerDisplay(state: TimerDisplaySource) {
|
||||||
|
const eventTimerType = state.eventNow?.timerType ?? TimerType.CountDown;
|
||||||
|
|
||||||
|
if (state.groupNow?.useGroupTimer === true && state.groupTimer !== null) {
|
||||||
|
return {
|
||||||
|
time: state.groupTimer,
|
||||||
|
timerType: state.groupNow.timerType,
|
||||||
|
countToEnd: false,
|
||||||
|
usesGroupTimer: true,
|
||||||
|
eventTimer: state.timer,
|
||||||
|
eventTimerType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: state.timer,
|
||||||
|
timerType: eventTimerType,
|
||||||
|
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||||
|
usesGroupTimer: false,
|
||||||
|
eventTimer: state.timer,
|
||||||
|
eventTimerType,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,6 @@ import { useEffect } from 'react';
|
|||||||
*/
|
*/
|
||||||
export function useWindowTitle(title: string) {
|
export function useWindowTitle(title: string) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `Ontime - ${title}`;
|
document.title = `ontime - ${title}`;
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
import { OntimeDelay, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { initRundownMetadata } from '../rundownMetadata';
|
import { getFlatRundownMetadata, initRundownMetadata } from '../rundownMetadata';
|
||||||
|
|
||||||
describe('initRundownMetadata()', () => {
|
describe('initRundownMetadata()', () => {
|
||||||
it('processes nested rundown data', () => {
|
it('processes nested rundown data', () => {
|
||||||
@@ -300,3 +300,36 @@ describe('initRundownMetadata()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getFlatRundownMetadata()', () => {
|
||||||
|
it('exposes group timer settings on a group and its events', () => {
|
||||||
|
const group = {
|
||||||
|
id: 'group',
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
entries: ['event'],
|
||||||
|
colour: 'red',
|
||||||
|
useGroupTimer: true,
|
||||||
|
timerType: TimerType.CountUp,
|
||||||
|
} as OntimeGroup;
|
||||||
|
const event = {
|
||||||
|
id: 'event',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
parent: group.id,
|
||||||
|
timeStart: 0,
|
||||||
|
timeEnd: 1,
|
||||||
|
duration: 1,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
skip: false,
|
||||||
|
linkStart: false,
|
||||||
|
} as OntimeEvent;
|
||||||
|
|
||||||
|
const flat = getFlatRundownMetadata(
|
||||||
|
{ entries: { [group.id]: group, [event.id]: event }, flatOrder: [group.id, event.id] },
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(flat[0]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
|
||||||
|
expect(flat[1]).toMatchObject({ groupUsesTimer: true, groupTimerType: TimerType.CountUp });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
|
OntimeGroup,
|
||||||
OntimeMilestone,
|
OntimeMilestone,
|
||||||
PlayableEvent,
|
PlayableEvent,
|
||||||
Rundown,
|
Rundown,
|
||||||
|
TimerType,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isOntimeGroup,
|
isOntimeGroup,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
@@ -29,7 +31,11 @@ export type RundownMetadata = {
|
|||||||
isFirstAfterGroup: boolean;
|
isFirstAfterGroup: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T & RundownMetadata;
|
export type ExtendedEntry<T extends OntimeEntry = OntimeEntry> = T &
|
||||||
|
RundownMetadata & {
|
||||||
|
groupUsesTimer?: boolean;
|
||||||
|
groupTimerType?: TimerType;
|
||||||
|
};
|
||||||
|
|
||||||
export const lastMetadataKey = 'LAST';
|
export const lastMetadataKey = 'LAST';
|
||||||
|
|
||||||
@@ -65,10 +71,23 @@ export function getFlatRundownMetadata(
|
|||||||
): ExtendedEntry[] {
|
): ExtendedEntry[] {
|
||||||
const { process } = initRundownMetadata(selectedEventId);
|
const { process } = initRundownMetadata(selectedEventId);
|
||||||
const flatRundown: ExtendedEntry[] = [];
|
const flatRundown: ExtendedEntry[] = [];
|
||||||
|
let activeGroup: OntimeGroup | null = null;
|
||||||
|
|
||||||
for (const id of data.flatOrder) {
|
for (const id of data.flatOrder) {
|
||||||
const entry = data.entries[id];
|
const entry = data.entries[id];
|
||||||
const extendedEntry = { ...entry, ...process(entry) };
|
if (isOntimeGroup(entry)) {
|
||||||
|
activeGroup = entry;
|
||||||
|
} else if (entry.parent !== activeGroup?.id) {
|
||||||
|
activeGroup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerGroup = isOntimeGroup(entry) ? entry : activeGroup;
|
||||||
|
const extendedEntry = {
|
||||||
|
...entry,
|
||||||
|
...process(entry),
|
||||||
|
groupUsesTimer: timerGroup?.useGroupTimer ?? false,
|
||||||
|
groupTimerType: timerGroup?.timerType,
|
||||||
|
};
|
||||||
flatRundown.push(extendedEntry);
|
flatRundown.push(extendedEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ShutdownPanel() {
|
|||||||
{!isOntimeCloud && (
|
{!isOntimeCloud && (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
|
<Button variant='destructive' onClick={handler.open} disabled={!canShutdown}>
|
||||||
Shutdown Ontime
|
Shutdown ontime
|
||||||
</Button>
|
</Button>
|
||||||
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
|
{!canShutdown && <Panel.Description>Only available from the machine running Ontime.</Panel.Description>}
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
border-top: 1px solid $white-7;
|
border-top: 1px solid $white-7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timerSource {
|
||||||
|
color: $active-indicator;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.blackout {
|
.blackout {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
import { IoArrowDown, IoArrowUp, IoBan, IoTime, IoTimerOutline } from 'react-icons/io5';
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
|
|
||||||
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
@@ -20,7 +20,8 @@ const secondarySourceLabels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TimerPreview() {
|
export default function TimerPreview() {
|
||||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType, usesGroupTimer } =
|
||||||
|
useMessagePreview();
|
||||||
const { data } = useViewSettings();
|
const { data } = useViewSettings();
|
||||||
|
|
||||||
const main = (() => {
|
const main = (() => {
|
||||||
@@ -35,7 +36,9 @@ export default function TimerPreview() {
|
|||||||
|
|
||||||
const secondary = (() => {
|
const secondary = (() => {
|
||||||
// message is a fullscreen overlay or secondary is not active
|
// message is a fullscreen overlay or secondary is not active
|
||||||
if (showTimerMessage || !secondarySource) return null;
|
if (showTimerMessage) return null;
|
||||||
|
if (usesGroupTimer) return 'Event timer';
|
||||||
|
if (!secondarySource) return null;
|
||||||
|
|
||||||
// we need to check aux first since it takes priority
|
// we need to check aux first since it takes priority
|
||||||
return secondarySourceLabels[secondarySource];
|
return secondarySourceLabels[secondarySource];
|
||||||
@@ -55,6 +58,7 @@ export default function TimerPreview() {
|
|||||||
<div className={style.preview}>
|
<div className={style.preview}>
|
||||||
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||||
<div className={contentClasses}>
|
<div className={contentClasses}>
|
||||||
|
{usesGroupTimer && <div className={style.timerSource}>Group timer</div>}
|
||||||
<div
|
<div
|
||||||
className={style.mainContent}
|
className={style.mainContent}
|
||||||
data-phase={showColourOverride && phase}
|
data-phase={showColourOverride && phase}
|
||||||
@@ -65,6 +69,14 @@ export default function TimerPreview() {
|
|||||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.eventStatus}>
|
<div className={style.eventStatus}>
|
||||||
|
<Tooltip
|
||||||
|
text='Timer display controlled by group'
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={usesGroupTimer}
|
||||||
|
>
|
||||||
|
<IoTimerOutline />
|
||||||
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
text='Time type: Count down'
|
text='Time type: Count down'
|
||||||
render={<span />}
|
render={<span />}
|
||||||
|
|||||||
@@ -18,12 +18,6 @@
|
|||||||
padding-bottom: 95vh;
|
padding-bottom: 95vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupSection {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editPrompt {
|
.editPrompt {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: $zindex-dialog;
|
z-index: $zindex-dialog;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
@@ -25,10 +25,7 @@ import { OperatorData, useOperatorData } from './useOperatorData';
|
|||||||
|
|
||||||
import style from './Operator.module.scss';
|
import style from './Operator.module.scss';
|
||||||
|
|
||||||
/** Keeps the running event clear of the list edge when no group header is pinned above it */
|
const selectedOffset = 50;
|
||||||
const edgeOffset = 50;
|
|
||||||
/** How far the running event may drift from where we placed it before we stop following */
|
|
||||||
const followTolerance = 50;
|
|
||||||
|
|
||||||
export default function OperatorLoader() {
|
export default function OperatorLoader() {
|
||||||
const { data, status } = useOperatorData();
|
const { data, status } = useOperatorData();
|
||||||
@@ -57,20 +54,11 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
// The header height varies with the viewport, so measure it at scroll time.
|
|
||||||
const getTopOffset = useCallback(() => {
|
|
||||||
const header = stickyHeaderRef.current;
|
|
||||||
// Sit right under the pinned header, so it covers the previous event instead of half of it.
|
|
||||||
return header ? header.offsetHeight + 2 : edgeOffset;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollToComponent = useFollowComponent({
|
const scrollToComponent = useFollowComponent({
|
||||||
followRef: selectedRef,
|
followRef: selectedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
getTopOffset,
|
topOffset: selectedOffset,
|
||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,16 +82,15 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
|
|
||||||
// prevent considering automated scrolls as user scrolls
|
// prevent considering automated scrolls as user scrolls
|
||||||
const handleUserScroll = () => {
|
const handleUserScroll = () => {
|
||||||
if (!selectedRef.current || !scrollRef.current) {
|
if (selectedRef?.current && scrollRef?.current) {
|
||||||
return;
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
if (selectedRect && scrollerRect) {
|
||||||
|
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||||
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||||
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
|
||||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
// Measure the drift from where an automated scroll would place the event.
|
|
||||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getTopOffset();
|
|
||||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > followTolerance;
|
|
||||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
|
||||||
};
|
};
|
||||||
const throttledHandleScroll = throttle(handleUserScroll, 1000);
|
const throttledHandleScroll = throttle(handleUserScroll, 1000);
|
||||||
|
|
||||||
@@ -199,9 +186,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.groupSection} key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
<OperatorGroup
|
<OperatorGroup
|
||||||
ref={isCurrentParent ? stickyHeaderRef : undefined}
|
key={entry.id}
|
||||||
title={entry.title}
|
title={entry.title}
|
||||||
colour={entry.colour}
|
colour={entry.colour}
|
||||||
count={entry.entries.length}
|
count={entry.entries.length}
|
||||||
@@ -252,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
.group {
|
.group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Padding is kept under the min-height so a single line fits without the list having to shrink the header,
|
|
||||||
while a taller title still grows the row. */
|
|
||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
||||||
background-color: $gray-1350;
|
background-color: $gray-1350;
|
||||||
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
position: sticky;
|
|
||||||
/* Cover the list padding so rows cannot scroll above the header. */
|
|
||||||
top: -0.25rem;
|
|
||||||
z-index: 1;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type CSSProperties, type Ref, memo } from 'react';
|
import { CSSProperties, memo } from 'react';
|
||||||
|
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
@@ -10,16 +10,15 @@ interface OperatorGroup {
|
|||||||
colour: string;
|
colour: string;
|
||||||
count: number;
|
count: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
ref?: Ref<HTMLDivElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(OperatorGroup);
|
export default memo(OperatorGroup);
|
||||||
function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
|
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
||||||
const groupColour = colour || '#929292';
|
const groupColour = colour || '#929292';
|
||||||
const groupColours = getAccessibleColour(groupColour);
|
const groupColours = getAccessibleColour(groupColour);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}>
|
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
||||||
<span className={style.title}>{title}</span>
|
<span className={style.title}>{title}</span>
|
||||||
<span className={style.meta}>
|
<span className={style.meta}>
|
||||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
||||||
|
|||||||
@@ -32,6 +32,12 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timerDisplaySettings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { MaybeNumber, OntimeGroup } from 'ontime-types';
|
import { MaybeNumber, OntimeGroup, TimerType } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
|
||||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||||
|
import Select from '../../../common/components/select/Select';
|
||||||
|
import Switch from '../../../common/components/switch/Switch';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
import { getOffsetState } from '../../../common/utils/offset';
|
||||||
@@ -107,6 +109,38 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
|||||||
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
|
<EventTextArea field='note' label='Note' initialValue={group.note} submitHandler={handleSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={style.column}>
|
||||||
|
<Editor.Title>Timer display</Editor.Title>
|
||||||
|
<div className={style.timerDisplaySettings}>
|
||||||
|
<div>
|
||||||
|
<Editor.Label htmlFor='useGroupTimer'>Use group timer</Editor.Label>
|
||||||
|
<Editor.Label className={style.switchLabel}>
|
||||||
|
<Switch
|
||||||
|
id='useGroupTimer'
|
||||||
|
checked={group.useGroupTimer}
|
||||||
|
onCheckedChange={(useGroupTimer) => updateEntry({ id: group.id, useGroupTimer })}
|
||||||
|
/>
|
||||||
|
{group.useGroupTimer ? 'On' : 'Off'}
|
||||||
|
</Editor.Label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Editor.Label htmlFor='groupTimerType'>Timer type</Editor.Label>
|
||||||
|
<Select
|
||||||
|
id='groupTimerType'
|
||||||
|
disabled={!group.useGroupTimer}
|
||||||
|
value={group.timerType}
|
||||||
|
onValueChange={(timerType: TimerType | null) => {
|
||||||
|
if (timerType !== null) updateEntry({ id: group.id, timerType });
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: TimerType.CountDown, label: 'Count down' },
|
||||||
|
{ value: TimerType.CountUp, label: 'Count up' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={style.column}>
|
<div className={style.column}>
|
||||||
<Editor.Title>
|
<Editor.Title>
|
||||||
Custom Fields
|
Custom Fields
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ export default function RundownEvent({
|
|||||||
eventIndex={eventIndex}
|
eventIndex={eventIndex}
|
||||||
endAction={endAction}
|
endAction={endAction}
|
||||||
timerType={timerType}
|
timerType={timerType}
|
||||||
|
groupTimerType={parentGroup?.useGroupTimer ? parentGroup.timerType : undefined}
|
||||||
title={title}
|
title={title}
|
||||||
note={note}
|
note={note}
|
||||||
delay={delay}
|
delay={delay}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IoPlayForward,
|
IoPlayForward,
|
||||||
IoPlaySkipForward,
|
IoPlaySkipForward,
|
||||||
IoTime,
|
IoTime,
|
||||||
|
IoTimerOutline,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ interface RundownEventInnerProps {
|
|||||||
eventIndex: number;
|
eventIndex: number;
|
||||||
endAction: EndAction;
|
endAction: EndAction;
|
||||||
timerType: TimerType;
|
timerType: TimerType;
|
||||||
|
groupTimerType?: TimerType;
|
||||||
title: string;
|
title: string;
|
||||||
note: string;
|
note: string;
|
||||||
delay: number;
|
delay: number;
|
||||||
@@ -62,6 +64,7 @@ function RundownEventInner({
|
|||||||
countToEnd,
|
countToEnd,
|
||||||
endAction,
|
endAction,
|
||||||
timerType,
|
timerType,
|
||||||
|
groupTimerType,
|
||||||
title,
|
title,
|
||||||
note,
|
note,
|
||||||
delay,
|
delay,
|
||||||
@@ -150,6 +153,14 @@ function RundownEventInner({
|
|||||||
{loaded && <EventBlockProgressBar />}
|
{loaded && <EventBlockProgressBar />}
|
||||||
</div>
|
</div>
|
||||||
<div className={style.eventStatus} tabIndex={-1}>
|
<div className={style.eventStatus} tabIndex={-1}>
|
||||||
|
{groupTimerType && (
|
||||||
|
<Tooltip
|
||||||
|
text={`Timer display controlled by group (${groupTimerType === TimerType.CountUp ? 'count up' : 'count down'})`}
|
||||||
|
render={<span />}
|
||||||
|
>
|
||||||
|
<IoTimerOutline className={cx([style.statusIcon, style.active])} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip text={`Time type: ${timerType}`} render={<span />}>
|
<Tooltip text={`Time type: ${timerType}`} render={<span />}>
|
||||||
<TimerIcon type={timerType} className={style.statusIcon} />
|
<TimerIcon type={timerType} className={style.statusIcon} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -55,6 +55,14 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timerIndicator {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 1.5rem;
|
||||||
|
place-items: center;
|
||||||
|
color: $active-indicator;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.metaRow {
|
.metaRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $block-clearance; // same as RundownEvent.eventTimers
|
gap: $block-clearance; // same as RundownEvent.eventTimers
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
import { EntryId, OntimeGroup, TimerType } from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
import { MouseEvent, useCallback, useRef } from 'react';
|
import { MouseEvent, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IoDuplicateOutline,
|
IoDuplicateOutline,
|
||||||
IoFolderOpenOutline,
|
IoFolderOpenOutline,
|
||||||
IoReorderTwo,
|
IoReorderTwo,
|
||||||
|
IoTimerOutline,
|
||||||
IoTrash,
|
IoTrash,
|
||||||
IoLockClosed,
|
IoLockClosed,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
@@ -174,6 +175,14 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<div className={style.titleRow}>
|
<div className={style.titleRow}>
|
||||||
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
|
<TitleEditor title={data.title} entryId={data.id} placeholder='Group title' />
|
||||||
|
{data.useGroupTimer && (
|
||||||
|
<Tooltip
|
||||||
|
text={`Group timer (${data.timerType === TimerType.CountUp ? 'count up' : 'count down'})`}
|
||||||
|
render={<span className={style.timerIndicator} />}
|
||||||
|
>
|
||||||
|
<IoTimerOutline />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
|
<IconButton aria-label='Collapse' variant='subtle-white' onClick={() => onCollapse(!collapsed, data.id)}>
|
||||||
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -100,20 +100,7 @@ $item-height: 3.5rem;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
padding-bottom: 95vh;
|
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
|
||||||
}
|
|
||||||
|
|
||||||
/* Flex prevents row margins collapsing and bounds the sticky header to its group. */
|
|
||||||
.sub-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
/* The select view renders the same cards in a flat list. */
|
|
||||||
.sub--group {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== LIST-ITEM ======================*/
|
/* ====================== LIST-ITEM ======================*/
|
||||||
@@ -209,14 +196,9 @@ $item-height: 3.5rem;
|
|||||||
|
|
||||||
.sub--group {
|
.sub--group {
|
||||||
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
||||||
/* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */
|
background:
|
||||||
background-color: var(--background-color-override, $viewer-background-color);
|
|
||||||
background-image:
|
|
||||||
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
||||||
linear-gradient(
|
var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
var(--card-background-color-override, $viewer-card-bg-color),
|
|
||||||
var(--card-background-color-override, $viewer-card-bg-color)
|
|
||||||
);
|
|
||||||
|
|
||||||
.sub__binder {
|
.sub__binder {
|
||||||
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
||||||
@@ -248,16 +230,6 @@ $item-height: 3.5rem;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reserve the green fill for the running event. */
|
|
||||||
.sub--group.sub--live {
|
|
||||||
box-shadow: inset 0 0 0 2px $active-green;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep the armed state quieter than the live ring. */
|
|
||||||
.sub--group.sub--armed {
|
|
||||||
box-shadow: inset 0 0 0 2px $gray-1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub__title {
|
.sub__title {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs } from 'ontime-utils';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
CountdownTarget,
|
CountdownTarget,
|
||||||
extendEventData,
|
extendEventData,
|
||||||
getIsLive,
|
getIsLive,
|
||||||
groupSubscriptionTargets,
|
|
||||||
isOutsideRange,
|
isOutsideRange,
|
||||||
preferredFormat12,
|
preferredFormat12,
|
||||||
preferredFormat24,
|
preferredFormat24,
|
||||||
@@ -49,22 +48,11 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
|
|
||||||
|
|
||||||
// Responsive sizing and wrapped titles make the sticky header height variable, so measure it at scroll time.
|
|
||||||
const getStickyOffset = useCallback(() => {
|
|
||||||
const header = stickyHeaderRef.current;
|
|
||||||
// Preserve the combined margins between the header and running event.
|
|
||||||
return header ? header.offsetHeight + 4 : 0;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollToComponent = useFollowComponent({
|
const scrollToComponent = useFollowComponent({
|
||||||
followRef: selectedRef,
|
followRef: selectedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
getTopOffset: getStickyOffset,
|
topOffset: 0,
|
||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,16 +75,15 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
|
|
||||||
// prevent considering automated scrolls as user scrolls
|
// prevent considering automated scrolls as user scrolls
|
||||||
const handleUserScroll = () => {
|
const handleUserScroll = () => {
|
||||||
if (!selectedRef.current || !scrollRef.current) {
|
if (selectedRef?.current && scrollRef?.current) {
|
||||||
return;
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
|
if (selectedRect && scrollerRect) {
|
||||||
|
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||||
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
||||||
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
|
||||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
|
||||||
// Keep the threshold relative to the visible rows below the sticky header.
|
|
||||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
|
||||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
|
||||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
|
||||||
};
|
};
|
||||||
const throttledHandleScroll = throttle(handleUserScroll, 1000);
|
const throttledHandleScroll = throttle(handleUserScroll, 1000);
|
||||||
|
|
||||||
@@ -111,63 +98,41 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
{sections.map((section) => {
|
{subscribedEvents.map((event) => {
|
||||||
const rows = section.group ? [section.group, ...section.events] : section.events;
|
// while a group is live, surface the running event's title as the secondary line
|
||||||
// the running event anchors the scroll, the group header stays pinned above it
|
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||||
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
|
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
||||||
|
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
||||||
|
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
||||||
|
// a subscribed group is live when any of its children is the selected/running event
|
||||||
|
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
||||||
|
const isArmed = !isLive && activeEntryId === selectedEventId;
|
||||||
|
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||||||
|
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
||||||
return (
|
return (
|
||||||
<div key={section.group?.id ?? rows[0].id} className='sub-section'>
|
<div
|
||||||
{rows.map((event) => {
|
key={event.id}
|
||||||
// while a group is live, surface the running event's title as the secondary line
|
ref={isLive ? selectedRef : undefined}
|
||||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
className={cx([
|
||||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
'sub',
|
||||||
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
isLive && 'sub--live',
|
||||||
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
isArmed && 'sub--armed',
|
||||||
// a subscribed group is live when any of its children is the selected/running event
|
event.isGroup && 'sub--group',
|
||||||
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
isGroupedEvent && 'sub--in-group',
|
||||||
const isArmed = !isLive && activeEntryId === selectedEventId;
|
])}
|
||||||
// only ever hand the ref to a single row, sharing it would null it out on the next commit
|
data-testid={event.cue}
|
||||||
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
|
>
|
||||||
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
|
<div
|
||||||
const countdownEvent = extendEventData(
|
className='sub__binder'
|
||||||
event,
|
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
||||||
currentDay,
|
/>
|
||||||
actualStart,
|
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||||
plannedStart,
|
<SubscriptionStatus event={countdownEvent} />
|
||||||
offset,
|
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
||||||
mode,
|
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
||||||
reportData,
|
{displayTitle}
|
||||||
);
|
</div>
|
||||||
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={event.id}
|
|
||||||
ref={rowRef}
|
|
||||||
className={cx([
|
|
||||||
'sub',
|
|
||||||
isLive && 'sub--live',
|
|
||||||
isArmed && 'sub--armed',
|
|
||||||
event.isGroup && 'sub--group',
|
|
||||||
isGroupedEvent && 'sub--in-group',
|
|
||||||
])}
|
|
||||||
data-testid={event.cue}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='sub__binder'
|
|
||||||
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
|
||||||
/>
|
|
||||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
|
||||||
<SubscriptionStatus event={countdownEvent} />
|
|
||||||
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
|
||||||
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
|
||||||
{displayTitle}
|
|
||||||
</div>
|
|
||||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
|
import { resolveSubscriptionTarget } from './countdown.utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
||||||
@@ -126,89 +126,3 @@ describe('resolveSubscriptionTarget()', () => {
|
|||||||
expect(result?.liveEntry).toBeNull();
|
expect(result?.liveEntry).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('groupSubscriptionTargets()', () => {
|
|
||||||
/**
|
|
||||||
* Resolves a group the same way the view does, so that the tests exercise the real target shape
|
|
||||||
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
|
|
||||||
*/
|
|
||||||
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
|
|
||||||
const resolved = resolveSubscriptionTarget(group, flat);
|
|
||||||
if (resolved === null) {
|
|
||||||
throw new Error('test setup: group has no playable children');
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns no sections for an empty subscription list', () => {
|
|
||||||
expect(groupSubscriptionTargets([])).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives each ungrouped event its own section', () => {
|
|
||||||
const e1 = makeEvent({ id: 'e1' });
|
|
||||||
const e2 = makeEvent({ id: 'e2' });
|
|
||||||
|
|
||||||
expect(groupSubscriptionTargets([e1, e2])).toEqual([
|
|
||||||
{ group: null, events: [e1] },
|
|
||||||
{ group: null, events: [e2] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('absorbs the children of a subscribed group into its section', () => {
|
|
||||||
const group = makeGroup({ id: 'g1' });
|
|
||||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
|
||||||
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
|
|
||||||
const resolved = resolveGroup(group, [group, c1, c2]);
|
|
||||||
|
|
||||||
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a subscribed group with no subscribed children as an empty section', () => {
|
|
||||||
const group = makeGroup({ id: 'g1' });
|
|
||||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
|
||||||
const resolved = resolveGroup(group, [group, c1]);
|
|
||||||
|
|
||||||
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not absorb an event which belongs to a different group', () => {
|
|
||||||
const group1 = makeGroup({ id: 'g1' });
|
|
||||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
|
||||||
const group2 = makeGroup({ id: 'g2' });
|
|
||||||
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
|
|
||||||
const flat = [group1, c1, group2, c2];
|
|
||||||
const resolved1 = resolveGroup(group1, flat);
|
|
||||||
const resolved2 = resolveGroup(group2, flat);
|
|
||||||
|
|
||||||
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
|
|
||||||
{ group: resolved1, events: [c1] },
|
|
||||||
{ group: resolved2, events: [c2] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not absorb an event whose parent group is not subscribed', () => {
|
|
||||||
const group1 = makeGroup({ id: 'g1' });
|
|
||||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
|
||||||
const group2 = makeGroup({ id: 'g2' });
|
|
||||||
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
|
|
||||||
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
|
|
||||||
|
|
||||||
// only the first group is subscribed, so the second group's child stands alone
|
|
||||||
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
|
|
||||||
{ group: resolved1, events: [c1] },
|
|
||||||
{ group: null, events: [c2] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes a section when an ungrouped event follows a group', () => {
|
|
||||||
const group = makeGroup({ id: 'g1' });
|
|
||||||
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
|
||||||
const e1 = makeEvent({ id: 'e1' });
|
|
||||||
const resolved = resolveGroup(group, [group, c1]);
|
|
||||||
|
|
||||||
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
|
|
||||||
{ group: resolved, events: [c1] },
|
|
||||||
{ group: null, events: [e1] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -252,42 +252,6 @@ export function resolveSubscriptionTarget(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A subscribed group along with the subscribed events which belong to it.
|
|
||||||
* Events without a subscribed parent group form their own section with no group.
|
|
||||||
*/
|
|
||||||
export type CountdownSection = {
|
|
||||||
group: CountdownTarget | null;
|
|
||||||
events: CountdownTarget[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Folds the flat, rundown ordered subscription targets into sections.
|
|
||||||
* A group opens a section which absorbs the following targets that declare it as parent,
|
|
||||||
* which allows the group to be rendered as a sticky header for its own events.
|
|
||||||
*/
|
|
||||||
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
|
|
||||||
const sections: CountdownSection[] = [];
|
|
||||||
|
|
||||||
for (const target of targets) {
|
|
||||||
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
|
|
||||||
if (target.isGroup) {
|
|
||||||
sections.push({ group: target, events: [] });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousSection = sections.at(-1);
|
|
||||||
if (previousSection?.group?.id === target.parent) {
|
|
||||||
previousSection.events.push(target);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
sections.push({ group: null, events: [target] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extendEventData(
|
export function extendEventData(
|
||||||
event: CountdownTarget,
|
event: CountdownTarget,
|
||||||
currentDay: number,
|
currentDay: number,
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
.timerOverrideCell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerOverrideIndicator {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: $active-indicator;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
+39
-6
@@ -1,8 +1,18 @@
|
|||||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
import {
|
||||||
|
CustomFields,
|
||||||
|
TimeStrategy,
|
||||||
|
TimerType,
|
||||||
|
URLPreset,
|
||||||
|
isOntimeDelay,
|
||||||
|
isOntimeEvent,
|
||||||
|
isOntimeGroup,
|
||||||
|
} from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import { IoTimerOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
|
||||||
|
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
@@ -17,6 +27,8 @@ import MutedText from './MutedText';
|
|||||||
import SingleLineCell from './SingleLineCell';
|
import SingleLineCell from './SingleLineCell';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
|
||||||
|
import style from './cuesheetColsFactory.module.scss';
|
||||||
|
|
||||||
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
||||||
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
||||||
}
|
}
|
||||||
@@ -193,17 +205,38 @@ function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canWrite = column.columnDef.meta?.canWrite;
|
const canWrite = column.columnDef.meta?.canWrite;
|
||||||
if (!canWrite) {
|
const content = canWrite ? (
|
||||||
return <GhostedText>{initialValue}</GhostedText>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SingleLineCell
|
<SingleLineCell
|
||||||
initialValue={initialValue as string}
|
initialValue={initialValue as string}
|
||||||
fieldId={column.id}
|
fieldId={column.id}
|
||||||
fieldLabel={getColumnLabel(column)}
|
fieldLabel={getColumnLabel(column)}
|
||||||
handleUpdate={update}
|
handleUpdate={update}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<GhostedText>{initialValue}</GhostedText>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (column.id !== 'title') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroupOverride = isOntimeGroup(row.original) && row.original.useGroupTimer;
|
||||||
|
const isEventOverride = isOntimeEvent(row.original) && row.original.groupUsesTimer;
|
||||||
|
if (!isGroupOverride && !isEventOverride) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerType = isOntimeGroup(row.original) ? row.original.timerType : row.original.groupTimerType;
|
||||||
|
const direction = timerType === TimerType.CountUp ? 'count up' : 'count down';
|
||||||
|
const tooltip = isGroupOverride ? `Group timer (${direction})` : `Timer display controlled by group (${direction})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.timerOverrideCell}>
|
||||||
|
{content}
|
||||||
|
<Tooltip text={tooltip} render={<span className={style.timerOverrideIndicator} />}>
|
||||||
|
<IoTimerOutline />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
.timer-source {
|
||||||
|
color: $viewer-label-color;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.timer {
|
.timer {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
font-family: $viewer-font-family;
|
font-family: $viewer-font-family;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cx } from '../../../common/utils/styleUtils';
|
|||||||
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
import { getFormattedTimer, getTimerByType } from '../../common/viewUtils';
|
||||||
import {
|
import {
|
||||||
getEstimatedFontSize,
|
getEstimatedFontSize,
|
||||||
|
getEventTimerSecondary,
|
||||||
getIsPlaying,
|
getIsPlaying,
|
||||||
getSecondaryDisplay,
|
getSecondaryDisplay,
|
||||||
getShowMessage,
|
getShowMessage,
|
||||||
@@ -23,7 +24,18 @@ interface PipTimerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PipTimer({ viewSettings }: PipTimerProps) {
|
export function PipTimer({ viewSettings }: PipTimerProps) {
|
||||||
const { eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
const {
|
||||||
|
eventNow,
|
||||||
|
message,
|
||||||
|
time,
|
||||||
|
eventTimer,
|
||||||
|
clock,
|
||||||
|
timerTypeNow,
|
||||||
|
eventTimerType,
|
||||||
|
countToEndNow,
|
||||||
|
usesGroupTimer,
|
||||||
|
auxTimer,
|
||||||
|
} = useTimerSocket();
|
||||||
|
|
||||||
// gather modifiers
|
// gather modifiers
|
||||||
const showOverlay = getShowMessage(message.timer);
|
const showOverlay = getShowMessage(message.timer);
|
||||||
@@ -59,7 +71,9 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
const secondaryContent = usesGroupTimer
|
||||||
|
? getEventTimerSecondary(eventTimer, eventTimerType, clock, 'min', false, true)
|
||||||
|
: getSecondaryDisplay(message, currentAux, 'min', false, true, false);
|
||||||
|
|
||||||
// gather presentation styles
|
// gather presentation styles
|
||||||
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
|
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
|
||||||
@@ -77,6 +91,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='timer-container'>
|
<div className='timer-container'>
|
||||||
|
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
|
||||||
<div
|
<div
|
||||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||||
style={{ fontSize: `${timerFontSize}vw` }}
|
style={{ fontSize: `${timerFontSize}vw` }}
|
||||||
@@ -96,11 +111,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||||
now={time.current}
|
now={time.current}
|
||||||
complete={totalTime}
|
complete={totalTime}
|
||||||
eventId={eventNow?.id}
|
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||||
normalColor={viewSettings.normalColor}
|
normalColor={viewSettings.normalColor}
|
||||||
warning={eventNow?.timeWarning}
|
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||||
warningColor={viewSettings.warningColor}
|
warningColor={viewSettings.warningColor}
|
||||||
danger={eventNow?.timeDanger}
|
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||||
dangerColor={viewSettings.dangerColor}
|
dangerColor={viewSettings.dangerColor}
|
||||||
hideOvertime={!showFinished}
|
hideOvertime={!showFinished}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -89,6 +89,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
.timer-source {
|
||||||
|
color: var(--label-color-override, $viewer-label-color);
|
||||||
|
font-size: $timer-label-size;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.end-message {
|
.end-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11.5vw;
|
font-size: 11.5vw;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { getTimerOptions, useTimerOptions } from './timer.options';
|
|||||||
import {
|
import {
|
||||||
getCardData,
|
getCardData,
|
||||||
getEstimatedFontSize,
|
getEstimatedFontSize,
|
||||||
|
getEventTimerSecondary,
|
||||||
getIsPlaying,
|
getIsPlaying,
|
||||||
getSecondaryDisplay,
|
getSecondaryDisplay,
|
||||||
getShowClock,
|
getShowClock,
|
||||||
@@ -52,7 +53,19 @@ export default function TimerLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
|
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
|
||||||
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
|
const {
|
||||||
|
eventNext,
|
||||||
|
eventNow,
|
||||||
|
message,
|
||||||
|
time,
|
||||||
|
eventTimer,
|
||||||
|
clock,
|
||||||
|
timerTypeNow,
|
||||||
|
eventTimerType,
|
||||||
|
countToEndNow,
|
||||||
|
usesGroupTimer,
|
||||||
|
auxTimer,
|
||||||
|
} = useTimerSocket();
|
||||||
const {
|
const {
|
||||||
hideClock,
|
hideClock,
|
||||||
hideCards,
|
hideCards,
|
||||||
@@ -78,7 +91,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const localisedMinutes = getLocalizedString('common.minutes');
|
const localisedMinutes = getLocalizedString('common.minutes');
|
||||||
|
|
||||||
const showSoundPrompt = useTimerSound(time.phase, endSound);
|
const showSoundPrompt = useTimerSound(eventTimer.phase, endSound);
|
||||||
|
|
||||||
// gather modifiers
|
// gather modifiers
|
||||||
const viewTimerType = timerType ?? timerTypeNow;
|
const viewTimerType = timerType ?? timerTypeNow;
|
||||||
@@ -128,14 +141,18 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const secondaryContent = getSecondaryDisplay(
|
const secondaryContent =
|
||||||
message,
|
usesGroupTimer && !hideSecondary
|
||||||
currentAux,
|
? getEventTimerSecondary(
|
||||||
localisedMinutes,
|
eventTimer,
|
||||||
hideTimerSeconds,
|
eventTimerType,
|
||||||
removeLeadingZeros,
|
clock,
|
||||||
hideSecondary,
|
localisedMinutes,
|
||||||
);
|
hideTimerSeconds,
|
||||||
|
removeLeadingZeros,
|
||||||
|
timeformat,
|
||||||
|
)
|
||||||
|
: getSecondaryDisplay(message, currentAux, localisedMinutes, hideTimerSeconds, removeLeadingZeros, hideSecondary);
|
||||||
|
|
||||||
// gather presentation styles
|
// gather presentation styles
|
||||||
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
||||||
@@ -176,6 +193,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
|
{showClock && <TimerAutoTickingClock clockFormat={timeformat} />}
|
||||||
|
|
||||||
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
||||||
|
{usesGroupTimer && <div className='timer-source'>Group timer</div>}
|
||||||
{showEndMessage ? (
|
{showEndMessage ? (
|
||||||
<FitText mode='multi' min={64} max={256} className='end-message'>
|
<FitText mode='multi' min={64} max={256} className='end-message'>
|
||||||
{freezeMessage}
|
{freezeMessage}
|
||||||
@@ -202,11 +220,11 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
|||||||
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
className={cx(['progress-container', !isPlaying && 'progress-container--paused'])}
|
||||||
now={time.current}
|
now={time.current}
|
||||||
complete={totalTime}
|
complete={totalTime}
|
||||||
eventId={eventNow?.id}
|
eventId={usesGroupTimer ? undefined : eventNow?.id}
|
||||||
normalColor={viewSettings.normalColor}
|
normalColor={viewSettings.normalColor}
|
||||||
warning={eventNow?.timeWarning}
|
warning={usesGroupTimer ? undefined : eventNow?.timeWarning}
|
||||||
warningColor={viewSettings.warningColor}
|
warningColor={viewSettings.warningColor}
|
||||||
danger={eventNow?.timeDanger}
|
danger={usesGroupTimer ? undefined : eventNow?.timeDanger}
|
||||||
dangerColor={viewSettings.dangerColor}
|
dangerColor={viewSettings.dangerColor}
|
||||||
hideOvertime={!showFinished}
|
hideOvertime={!showFinished}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,32 @@
|
|||||||
import { TimerPhase } from 'ontime-types';
|
import { TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { shouldPlayEndSound } from '../timer.utils';
|
import { getEventTimerSecondary, shouldPlayEndSound } from '../timer.utils';
|
||||||
|
|
||||||
|
describe('getEventTimerSecondary()', () => {
|
||||||
|
it('formats the event countdown as a labelled secondary value', () => {
|
||||||
|
expect(
|
||||||
|
getEventTimerSecondary({ current: 65_000, elapsed: 5_000 }, TimerType.CountDown, 0, 'min', false, false),
|
||||||
|
).toBe('Event timer 00:01:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the event count-up display', () => {
|
||||||
|
expect(getEventTimerSecondary({ current: 55_000, elapsed: 5_000 }, TimerType.CountUp, 0, 'min', false, false)).toBe(
|
||||||
|
'Event timer 00:00:05',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to remaining time when the event timer is hidden', () => {
|
||||||
|
expect(getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.None, 0, 'min', false, false)).toBe(
|
||||||
|
'Event timer 00:00:05',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows event progress instead of wall-clock time for clock events', () => {
|
||||||
|
expect(
|
||||||
|
getEventTimerSecondary({ current: 5_000, elapsed: 55_000 }, TimerType.Clock, 12_000, 'min', false, false),
|
||||||
|
).toBe('Event timer 00:00:05');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('shouldPlayEndSound()', () => {
|
describe('shouldPlayEndSound()', () => {
|
||||||
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
test.each([TimerPhase.Default, TimerPhase.Warning, TimerPhase.Danger])(
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import {
|
|||||||
RundownEntries,
|
RundownEntries,
|
||||||
TimerMessage,
|
TimerMessage,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
|
TimerState,
|
||||||
TimerType,
|
TimerType,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { isPlaybackActive } from 'ontime-utils';
|
import { isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
|
import { getFormattedTimer, getPropertyValue, getTimerByType } from '../common/viewUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a message should be shown
|
* Whether a message should be shown
|
||||||
@@ -144,6 +145,25 @@ export function getSecondaryDisplay(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEventTimerSecondary(
|
||||||
|
timer: Pick<TimerState, 'current' | 'elapsed'>,
|
||||||
|
timerType: TimerType,
|
||||||
|
clock: number,
|
||||||
|
localisedMinutes: string,
|
||||||
|
removeSeconds: boolean,
|
||||||
|
removeLeadingZero: boolean,
|
||||||
|
clockFormat?: string | null,
|
||||||
|
): string {
|
||||||
|
const effectiveType = timerType === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
|
||||||
|
const value = getTimerByType(false, effectiveType, clock, timer);
|
||||||
|
const display = getFormattedTimer(value, effectiveType, localisedMinutes, {
|
||||||
|
removeSeconds,
|
||||||
|
removeLeadingZero,
|
||||||
|
clockFormat,
|
||||||
|
});
|
||||||
|
return `Event timer ${display}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What should we be showing in the cards?
|
* What should we be showing in the cards?
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
|
content="default-src 'self'; style-src 'unsafe-inline'; script-src 'self'"
|
||||||
/>
|
/>
|
||||||
<title>Ontime</title>
|
<title>ontime</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<img src="../assets/logo.png" />
|
<img src="../assets/logo.png" />
|
||||||
<h1>Ontime · event timers</h1>
|
<h1>ontime · event timers</h1>
|
||||||
<div class="lds-ellipsis">
|
<div class="lds-ellipsis">
|
||||||
<div></div>
|
<div></div>
|
||||||
<div></div>
|
<div></div>
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ export function migrateRundown(
|
|||||||
timeEnd: null,
|
timeEnd: null,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
isFirstLinked: false,
|
isFirstLinked: false,
|
||||||
});
|
} as unknown as OntimeEntry);
|
||||||
} else if (entry.type === 'delay') {
|
} else if (entry.type === 'delay') {
|
||||||
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
|
append({ id: entry.id, type: SupportedEntry.Delay, duration: entry.duration, parent });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry } from 'ontime-types';
|
import { CustomFields, OntimeEvent, OntimeGroup, Rundown, SupportedEntry, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { makeNewRundown } from '../../../models/dataModel.js';
|
import { makeNewRundown } from '../../../models/dataModel.js';
|
||||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
|
||||||
@@ -276,7 +276,13 @@ describe('parseRundown()', () => {
|
|||||||
expect(parsedRundown.order).toStrictEqual(['group']);
|
expect(parsedRundown.order).toStrictEqual(['group']);
|
||||||
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
|
expect(parsedRundown.flatOrder).toStrictEqual(['group', '1', '2']);
|
||||||
expect(parsedRundown.entries).toMatchObject({
|
expect(parsedRundown.entries).toMatchObject({
|
||||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['1', '2'] },
|
group: {
|
||||||
|
id: 'group',
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
entries: ['1', '2'],
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
},
|
||||||
'1': { id: '1', type: SupportedEntry.Event },
|
'1': { id: '1', type: SupportedEntry.Event },
|
||||||
'2': { id: '2', type: SupportedEntry.Milestone },
|
'2': { id: '2', type: SupportedEntry.Milestone },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { parseRundown } from '../rundown.parser.js';
|
|||||||
import {
|
import {
|
||||||
calculateDayOffset,
|
calculateDayOffset,
|
||||||
cloneEntryData,
|
cloneEntryData,
|
||||||
|
createGroupPatch,
|
||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
getIntegerAndFraction,
|
getIntegerAndFraction,
|
||||||
@@ -36,6 +37,25 @@ import {
|
|||||||
} from '../rundown.utils.js';
|
} from '../rundown.utils.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
|
it('creates groups with the shared timer disabled by default', () => {
|
||||||
|
expect(createGroup({ id: 'group' })).toMatchObject({
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits group timers to count down and count up', () => {
|
||||||
|
expect(createGroup({ timerType: TimerType.CountUp }).timerType).toBe(TimerType.CountUp);
|
||||||
|
expect(createGroup({ timerType: TimerType.Clock }).timerType).toBe(TimerType.CountDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-boolean group timer updates', () => {
|
||||||
|
const group = createGroup({ useGroupTimer: false });
|
||||||
|
const updated = createGroupPatch(group, { useGroupTimer: 'true' as never });
|
||||||
|
|
||||||
|
expect(updated.useGroupTimer).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('validates a good object', () => {
|
it('validates a good object', () => {
|
||||||
const event = {
|
const event = {
|
||||||
title: 'test',
|
title: 'test',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
makeString,
|
makeString,
|
||||||
maxDuration,
|
maxDuration,
|
||||||
validateEndAction,
|
validateEndAction,
|
||||||
|
validateGroupTimerType,
|
||||||
validateTimerType,
|
validateTimerType,
|
||||||
validateTimes,
|
validateTimes,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
@@ -180,6 +181,9 @@ export function createGroupPatch(originalGroup: OntimeGroup, patchGroup: Partial
|
|||||||
note: makeString(patchGroup.note, originalGroup.note),
|
note: makeString(patchGroup.note, originalGroup.note),
|
||||||
entries: patchGroup.entries ?? originalGroup.entries,
|
entries: patchGroup.entries ?? originalGroup.entries,
|
||||||
targetDuration: maybeTargetDuration(),
|
targetDuration: maybeTargetDuration(),
|
||||||
|
useGroupTimer:
|
||||||
|
typeof patchGroup.useGroupTimer === 'boolean' ? patchGroup.useGroupTimer : originalGroup.useGroupTimer,
|
||||||
|
timerType: validateGroupTimerType(patchGroup.timerType, originalGroup.timerType),
|
||||||
colour: makeString(patchGroup.colour, originalGroup.colour),
|
colour: makeString(patchGroup.colour, originalGroup.colour),
|
||||||
revision: originalGroup.revision,
|
revision: originalGroup.revision,
|
||||||
timeStart: originalGroup.timeStart,
|
timeStart: originalGroup.timeStart,
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
|||||||
eventStore.init({
|
eventStore.init({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
timer: state.timer,
|
timer: state.timer,
|
||||||
|
groupTimer: state.groupTimer,
|
||||||
message: { ...runtimeStorePlaceholder.message },
|
message: { ...runtimeStorePlaceholder.message },
|
||||||
offset: state.offset,
|
offset: state.offset,
|
||||||
rundown: state.rundown,
|
rundown: state.rundown,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #101010;
|
background: #121212;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -29,19 +29,15 @@ function makeHeadersWithFailingAuthorization(cookie?: string) {
|
|||||||
|
|
||||||
describe('isPublicAssetRequest()', () => {
|
describe('isPublicAssetRequest()', () => {
|
||||||
it('allows root public assets without a prefix', () => {
|
it('allows root public assets without a prefix', () => {
|
||||||
|
expect(isPublicAssetRequest('/site.webmanifest', '')).toBe(true);
|
||||||
expect(isPublicAssetRequest('/manifest.json', '')).toBe(true);
|
expect(isPublicAssetRequest('/manifest.json', '')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows prefixed public assets in cloud deployments', () => {
|
it('allows prefixed public assets in cloud deployments', () => {
|
||||||
expect(isPublicAssetRequest('/stage-hash/manifest.json', '/stage-hash')).toBe(true);
|
expect(isPublicAssetRequest('/stage-hash/site.webmanifest', '/stage-hash')).toBe(true);
|
||||||
expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true);
|
expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows the PWA install icons', () => {
|
|
||||||
expect(isPublicAssetRequest('/ontime-logo-192.png', '')).toBe(true);
|
|
||||||
expect(isPublicAssetRequest('/ontime-logo-512.png', '')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps non-public paths protected', () => {
|
it('keeps non-public paths protected', () => {
|
||||||
expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false);
|
expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false);
|
||||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ const publicAssets = new Set([
|
|||||||
'/favicon.ico',
|
'/favicon.ico',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'/ontime-logo.png',
|
'/ontime-logo.png',
|
||||||
'/ontime-logo-192.png',
|
|
||||||
'/ontime-logo-512.png',
|
|
||||||
'/robots.txt',
|
'/robots.txt',
|
||||||
|
'/site.webmanifest',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean {
|
export function isPublicAssetRequest(originalUrl: string, prefix: string): boolean {
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -172,6 +174,8 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['0aaa7d'],
|
entries: ['0aaa7d'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -219,6 +223,8 @@ export const stageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -360,6 +366,8 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
entries: ['bs0101', 'bs0102', 'bs0103', 'bs0104'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#A790F5',
|
colour: '#A790F5',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -476,6 +484,8 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
entries: ['bs0201', 'bs0202', 'bs0203', 'bs0204'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -588,6 +598,8 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0301'],
|
entries: ['bs0301'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -634,6 +646,8 @@ export const backstageRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
entries: ['bs0401', 'bs0402', 'bs0403', 'bs0404'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -768,6 +782,8 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0101', 'br0102', 'br0103'],
|
entries: ['br0101', 'br0102', 'br0103'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#ED3333',
|
colour: '#ED3333',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -851,6 +867,8 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
entries: ['br0201', 'br0202', 'br0203', 'br0204'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -959,6 +977,8 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0301', 'br0302'],
|
entries: ['br0301', 'br0302'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#3E75E8',
|
colour: '#3E75E8',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -1029,6 +1049,8 @@ export const broadcastRundown: Rundown = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: ['br0401', 'br0402', 'br0403'],
|
entries: ['br0401', 'br0402', 'br0403'],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '#339E4E',
|
colour: '#339E4E',
|
||||||
custom: {},
|
custom: {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisT
|
|||||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||||
import {
|
import {
|
||||||
findDayOffset,
|
findDayOffset,
|
||||||
|
getGroupTimer,
|
||||||
getCurrent,
|
getCurrent,
|
||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
@@ -16,6 +17,91 @@ import {
|
|||||||
|
|
||||||
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
|
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
|
||||||
|
|
||||||
|
describe('getGroupTimer()', () => {
|
||||||
|
const makeState = (patch: Partial<RuntimeState> = {}) =>
|
||||||
|
({
|
||||||
|
clock: asTimeOfDay(10_000),
|
||||||
|
groupNow: {
|
||||||
|
duration: 20_000,
|
||||||
|
},
|
||||||
|
rundown: {
|
||||||
|
actualGroupStart: 5_000,
|
||||||
|
},
|
||||||
|
timer: {
|
||||||
|
addedTime: 4_000,
|
||||||
|
current: 1_000,
|
||||||
|
elapsed: 99_000,
|
||||||
|
phase: TimerPhase.Danger,
|
||||||
|
playback: Playback.Play,
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
}) as RuntimeState;
|
||||||
|
|
||||||
|
it('returns null outside a group', () => {
|
||||||
|
expect(getGroupTimer(makeState({ groupNow: null }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives elapsed and remaining time from the group start and duration', () => {
|
||||||
|
expect(getGroupTimer(makeState())).toMatchObject({
|
||||||
|
addedTime: 0,
|
||||||
|
current: 15_000,
|
||||||
|
duration: 20_000,
|
||||||
|
elapsed: 5_000,
|
||||||
|
expectedFinish: null,
|
||||||
|
phase: TimerPhase.Default,
|
||||||
|
playback: Playback.Play,
|
||||||
|
secondaryTimer: null,
|
||||||
|
startedAt: 5_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not inherit event timer progress or added time', () => {
|
||||||
|
const first = getGroupTimer(makeState());
|
||||||
|
const second = getGroupTimer(
|
||||||
|
makeState({
|
||||||
|
timer: {
|
||||||
|
...makeState().timer,
|
||||||
|
addedTime: -8_000,
|
||||||
|
current: -50_000,
|
||||||
|
elapsed: 500_000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(second).toEqual(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps running while the loaded event is paused', () => {
|
||||||
|
const timer = getGroupTimer(makeState({ timer: { ...makeState().timer, playback: Playback.Pause } }));
|
||||||
|
|
||||||
|
expect(timer?.playback).toBe(Playback.Play);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a group running across midnight', () => {
|
||||||
|
const timer = getGroupTimer(
|
||||||
|
makeState({
|
||||||
|
clock: asTimeOfDay(1_000),
|
||||||
|
rundown: { ...makeState().rundown, actualGroupStart: 86_399_000 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(timer).toMatchObject({ current: 18_000, elapsed: 2_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the event phase before the group starts and overtime afterwards', () => {
|
||||||
|
const pending = getGroupTimer(
|
||||||
|
makeState({
|
||||||
|
rundown: { ...makeState().rundown, actualGroupStart: null },
|
||||||
|
timer: { ...makeState().timer, phase: TimerPhase.Pending },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const overtime = getGroupTimer(makeState({ clock: asTimeOfDay(30_001) }));
|
||||||
|
|
||||||
|
expect(pending).toMatchObject({ current: 20_000, elapsed: null, phase: TimerPhase.Pending });
|
||||||
|
expect(overtime).toMatchObject({ current: -5_001, elapsed: 25_001, phase: TimerPhase.Overtime });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getElapsed()', () => {
|
describe('getElapsed()', () => {
|
||||||
it('returns active elapsed time from startedAt without add-time adjustments', () => {
|
it('returns active elapsed time from startedAt without add-time adjustments', () => {
|
||||||
const state = {
|
const state = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findPreviousPlayableId,
|
findPreviousPlayableId,
|
||||||
getEventAtIndex,
|
getEventAtIndex,
|
||||||
getShouldClockUpdate,
|
getShouldClockUpdate,
|
||||||
|
getShouldGroupTimerUpdate,
|
||||||
getShouldOffsetUpdate,
|
getShouldOffsetUpdate,
|
||||||
getShouldTimerUpdate,
|
getShouldTimerUpdate,
|
||||||
isNewSecond,
|
isNewSecond,
|
||||||
@@ -95,6 +96,34 @@ describe('getShouldTimerUpdate()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getShouldGroupTimerUpdate()', () => {
|
||||||
|
const timer: TimerState = {
|
||||||
|
addedTime: 0,
|
||||||
|
current: 10_000,
|
||||||
|
duration: 10_000,
|
||||||
|
elapsed: 0,
|
||||||
|
expectedFinish: null,
|
||||||
|
phase: TimerPhase.Default,
|
||||||
|
playback: Playback.Play,
|
||||||
|
secondaryTimer: null,
|
||||||
|
startedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('updates when a group timer appears or disappears', () => {
|
||||||
|
expect(getShouldGroupTimerUpdate(null, timer)).toBe(true);
|
||||||
|
expect(getShouldGroupTimerUpdate(timer, null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not repeatedly publish an absent group timer', () => {
|
||||||
|
expect(getShouldGroupTimerUpdate(null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses normal timer tick semantics while a group timer exists', () => {
|
||||||
|
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 9_500 })).toBe(false);
|
||||||
|
expect(getShouldGroupTimerUpdate(timer, { ...timer, current: 8_999 })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getShouldOffsetUpdate()', () => {
|
describe('getShouldOffsetUpdate()', () => {
|
||||||
const baseOffset: Offset = {
|
const baseOffset: Offset = {
|
||||||
absolute: 0,
|
absolute: 0,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { logger } from '../../classes/Logger.js';
|
|||||||
import { timerConfig } from '../../setup/config.js';
|
import { timerConfig } from '../../setup/config.js';
|
||||||
import { eventStore } from '../../stores/EventStore.js';
|
import { eventStore } from '../../stores/EventStore.js';
|
||||||
import * as runtimeState from '../../stores/runtimeState.js';
|
import * as runtimeState from '../../stores/runtimeState.js';
|
||||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
import type { RuntimeState, RuntimeStateSnapshot } from '../../stores/runtimeState.js';
|
||||||
import { EventTimer } from '../EventTimer.js';
|
import { EventTimer } from '../EventTimer.js';
|
||||||
import { restoreService } from '../restore-service/restore.service.js';
|
import { restoreService } from '../restore-service/restore.service.js';
|
||||||
import type { RestorePoint } from '../restore-service/restore.type.js';
|
import type { RestorePoint } from '../restore-service/restore.type.js';
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
findPreviousPlayableId,
|
findPreviousPlayableId,
|
||||||
getEventAtIndex,
|
getEventAtIndex,
|
||||||
getShouldClockUpdate,
|
getShouldClockUpdate,
|
||||||
|
getShouldGroupTimerUpdate,
|
||||||
getShouldOffsetUpdate,
|
getShouldOffsetUpdate,
|
||||||
getShouldTimerUpdate,
|
getShouldTimerUpdate,
|
||||||
isNewSecond,
|
isNewSecond,
|
||||||
@@ -51,11 +52,11 @@ class RuntimeService {
|
|||||||
private lastIntegrationTimerValue = -1;
|
private lastIntegrationTimerValue = -1;
|
||||||
|
|
||||||
/** last known state */
|
/** last known state */
|
||||||
static previousState: RuntimeState;
|
static previousState: RuntimeStateSnapshot;
|
||||||
|
|
||||||
constructor(eventTimer: EventTimer) {
|
constructor(eventTimer: EventTimer) {
|
||||||
this.eventTimer = eventTimer;
|
this.eventTimer = eventTimer;
|
||||||
RuntimeService.previousState = {} as RuntimeState;
|
RuntimeService.previousState = {} as RuntimeStateSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@broadcastResult
|
@broadcastResult
|
||||||
@@ -710,6 +711,12 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
|||||||
RuntimeService.previousState.timer = { ...state.timer };
|
RuntimeService.previousState.timer = { ...state.timer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateGroupTimer = getShouldGroupTimerUpdate(RuntimeService.previousState.groupTimer, state.groupTimer);
|
||||||
|
if (updateGroupTimer) {
|
||||||
|
batch.add('groupTimer', state.groupTimer);
|
||||||
|
RuntimeService.previousState.groupTimer = state.groupTimer ? { ...state.groupTimer } : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* clock has changed by a second or more.
|
* clock has changed by a second or more.
|
||||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getShouldGroupTimerUpdate(
|
||||||
|
previousValue: TimerState | null | undefined,
|
||||||
|
currentValue: TimerState | null,
|
||||||
|
): boolean {
|
||||||
|
if (previousValue === undefined) return true;
|
||||||
|
if (previousValue === null || currentValue === null) return previousValue !== currentValue;
|
||||||
|
return getShouldTimerUpdate(previousValue, currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether we should update the offset values
|
* Checks whether we should update the offset values
|
||||||
* - `mode` triggers update
|
* - `mode` triggers update
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
import { Day, MaybeNumber, Playback, TimeOfDay, TimerPhase, TimerState } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||||
@@ -111,6 +111,37 @@ export function getElapsed(state: RuntimeState): MaybeNumber {
|
|||||||
return Math.max(0, activeElapsed);
|
return Math.max(0, activeElapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a timer for the active group from wall-clock time.
|
||||||
|
* Event timer controls such as pause and add time intentionally do not affect it.
|
||||||
|
*/
|
||||||
|
export function getGroupTimer(state: RuntimeState): TimerState | null {
|
||||||
|
if (state.groupNow === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actualGroupStart } = state.rundown;
|
||||||
|
const elapsed = actualGroupStart === null ? null : getTimeSinceStart(state.clock, actualGroupStart);
|
||||||
|
const current = elapsed === null ? state.groupNow.duration : state.groupNow.duration - elapsed;
|
||||||
|
|
||||||
|
let phase = state.timer.phase;
|
||||||
|
if (actualGroupStart !== null) {
|
||||||
|
phase = current < 0 ? TimerPhase.Overtime : TimerPhase.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addedTime: 0,
|
||||||
|
current,
|
||||||
|
duration: state.groupNow.duration,
|
||||||
|
elapsed,
|
||||||
|
expectedFinish: null,
|
||||||
|
phase,
|
||||||
|
playback: actualGroupStart === null ? state.timer.playback : Playback.Play,
|
||||||
|
secondaryTimer: null,
|
||||||
|
startedAt: actualGroupStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
||||||
if (clock < startedAt) {
|
if (clock < startedAt) {
|
||||||
return clock + dayInMs - startedAt;
|
return clock + dayInMs - startedAt;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Playback,
|
Playback,
|
||||||
Rundown,
|
Rundown,
|
||||||
RundownState,
|
RundownState,
|
||||||
|
RuntimeStore,
|
||||||
TimeOfDay,
|
TimeOfDay,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerState,
|
TimerState,
|
||||||
@@ -39,6 +40,7 @@ import {
|
|||||||
getCurrent,
|
getCurrent,
|
||||||
getElapsed,
|
getElapsed,
|
||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
|
getGroupTimer,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
hasCrossedMidnight,
|
hasCrossedMidnight,
|
||||||
@@ -104,7 +106,9 @@ const runtimeState: RuntimeState = {
|
|||||||
_startDayOffset: null,
|
_startDayOffset: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getState(): Readonly<RuntimeState> {
|
export type RuntimeStateSnapshot = RuntimeState & Pick<RuntimeStore, 'groupTimer'>;
|
||||||
|
|
||||||
|
export function getState(): Readonly<RuntimeStateSnapshot> {
|
||||||
// create a shallow copy of the state
|
// create a shallow copy of the state
|
||||||
return {
|
return {
|
||||||
...runtimeState,
|
...runtimeState,
|
||||||
@@ -115,6 +119,7 @@ export function getState(): Readonly<RuntimeState> {
|
|||||||
offset: { ...runtimeState.offset },
|
offset: { ...runtimeState.offset },
|
||||||
rundown: { ...runtimeState.rundown },
|
rundown: { ...runtimeState.rundown },
|
||||||
timer: { ...runtimeState.timer },
|
timer: { ...runtimeState.timer },
|
||||||
|
groupTimer: getGroupTimer(runtimeState),
|
||||||
_timer: { ...runtimeState._timer },
|
_timer: { ...runtimeState._timer },
|
||||||
_rundown: { ...runtimeState._rundown },
|
_rundown: { ...runtimeState._rundown },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ test.describe('pages routes are available', () => {
|
|||||||
test('editor', async ({ page }) => {
|
test('editor', async ({ page }) => {
|
||||||
await page.goto('/editor');
|
await page.goto('/editor');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await expect(page.getByTestId('editor-container')).toBeVisible();
|
await expect(page.getByTestId('editor-container')).toBeVisible();
|
||||||
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
await expect(page.getByTestId('panel-rundown')).toBeVisible();
|
||||||
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
|
await expect(page.getByTestId('panel-timer-control')).toBeVisible();
|
||||||
@@ -16,38 +16,38 @@ test.describe('pages routes are available', () => {
|
|||||||
test('cuesheet', async ({ page }) => {
|
test('cuesheet', async ({ page }) => {
|
||||||
await page.goto('/cuesheet');
|
await page.goto('/cuesheet');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await expect(page.getByTestId('cuesheet')).toBeVisible();
|
await expect(page.getByTestId('cuesheet')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('operator', async ({ page }) => {
|
test('operator', async ({ page }) => {
|
||||||
await page.goto('/op');
|
await page.goto('/op');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('timer', async ({ page }) => {
|
test('timer', async ({ page }) => {
|
||||||
await page.goto('/timer');
|
await page.goto('/timer');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backstage', async ({ page }) => {
|
test('backstage', async ({ page }) => {
|
||||||
await page.goto('/backstage');
|
await page.goto('/backstage');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('studio', async ({ page }) => {
|
test('studio', async ({ page }) => {
|
||||||
await page.goto('/studio');
|
await page.goto('/studio');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('countdown', async ({ page }) => {
|
test('countdown', async ({ page }) => {
|
||||||
await page.goto('/countdown?sub=32d31');
|
await page.goto('/countdown?sub=32d31');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
|
|
||||||
await expect(page.getByText('Albania')).toBeVisible();
|
await expect(page.getByText('Albania')).toBeVisible();
|
||||||
await expect(page.getByText('Latvia')).toBeHidden();
|
await expect(page.getByText('Latvia')).toBeHidden();
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ test.describe('test view navigation feature', () => {
|
|||||||
test('not-found', async ({ page }) => {
|
test('not-found', async ({ page }) => {
|
||||||
await page.goto('/not-found');
|
await page.goto('/not-found');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
|
||||||
|
|
||||||
await page.goto('/preset/not-found');
|
await page.goto('/preset/not-found');
|
||||||
|
|
||||||
await expect(page).toHaveTitle(/ontime/i);
|
await expect(page).toHaveTitle(/ontime/);
|
||||||
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Not found' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export type OntimeGroup = OntimeBaseEvent & {
|
|||||||
note: string;
|
note: string;
|
||||||
entries: EntryId[];
|
entries: EntryId[];
|
||||||
targetDuration: MaybeNumber;
|
targetDuration: MaybeNumber;
|
||||||
|
useGroupTimer: boolean;
|
||||||
|
timerType: TimerType;
|
||||||
colour: string;
|
colour: string;
|
||||||
custom: EntryCustomFields;
|
custom: EntryCustomFields;
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
|||||||
secondaryTimer: null, // change on every update
|
secondaryTimer: null, // change on every update
|
||||||
startedAt: null, // change can only be initiated by user
|
startedAt: null, // change can only be initiated by user
|
||||||
},
|
},
|
||||||
|
groupTimer: null,
|
||||||
message: {
|
message: {
|
||||||
timer: {
|
timer: {
|
||||||
text: '',
|
text: '',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type RuntimeStore = {
|
|||||||
// timer data
|
// timer data
|
||||||
clock: number;
|
clock: number;
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
|
groupTimer: TimerState | null;
|
||||||
|
|
||||||
// messages service
|
// messages service
|
||||||
message: MessageState;
|
message: MessageState;
|
||||||
|
|||||||
@@ -34,7 +34,14 @@ export {
|
|||||||
group as groupDef,
|
group as groupDef,
|
||||||
milestone as milestoneDef,
|
milestone as milestoneDef,
|
||||||
} from './src/rundown-utils/entryDefinitions.js';
|
} from './src/rundown-utils/entryDefinitions.js';
|
||||||
export { createDelay, createEvent, createGroup, createMilestone, makeString } from './src/rundown-utils/entryUtils.js';
|
export {
|
||||||
|
createDelay,
|
||||||
|
createEvent,
|
||||||
|
createGroup,
|
||||||
|
createMilestone,
|
||||||
|
makeString,
|
||||||
|
validateGroupTimerType,
|
||||||
|
} from './src/rundown-utils/entryUtils.js';
|
||||||
|
|
||||||
// time format utils
|
// time format utils
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const group: Omit<OntimeGroup, 'id'> = {
|
|||||||
note: '',
|
note: '',
|
||||||
entries: [],
|
entries: [],
|
||||||
targetDuration: null,
|
targetDuration: null,
|
||||||
|
useGroupTimer: false,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
colour: '',
|
colour: '',
|
||||||
custom: {},
|
custom: {},
|
||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||||
import { SupportedEntry, TimeStrategy } from 'ontime-types';
|
import { SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { generateId } from '../generate-id/generateId.js';
|
import { generateId } from '../generate-id/generateId.js';
|
||||||
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
|
import { validateEndAction, validateTimerType } from '../validate-events/validateEvent.js';
|
||||||
@@ -67,6 +67,8 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
|||||||
note: patch.note ?? '',
|
note: patch.note ?? '',
|
||||||
entries: patch.entries ?? [],
|
entries: patch.entries ?? [],
|
||||||
targetDuration: patch.targetDuration ?? null,
|
targetDuration: patch.targetDuration ?? null,
|
||||||
|
useGroupTimer: patch.useGroupTimer === true,
|
||||||
|
timerType: validateGroupTimerType(patch.timerType),
|
||||||
colour: makeString(patch.colour, ''),
|
colour: makeString(patch.colour, ''),
|
||||||
custom: patch.custom ?? {},
|
custom: patch.custom ?? {},
|
||||||
revision: 0,
|
revision: 0,
|
||||||
@@ -77,6 +79,16 @@ export function createGroup(patch?: Partial<OntimeGroup>): OntimeGroup {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateGroupTimerType(
|
||||||
|
value: unknown,
|
||||||
|
fallback: unknown = TimerType.CountDown,
|
||||||
|
): TimerType.CountDown | TimerType.CountUp {
|
||||||
|
if (value === TimerType.CountDown || value === TimerType.CountUp) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return fallback === TimerType.CountUp ? TimerType.CountUp : TimerType.CountDown;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new milestone from an optional patch
|
* Creates a new milestone from an optional patch
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user