Compare commits

..

24 Commits

Author SHA1 Message Date
google-labs-jules[bot] fbbbaa5922 fix: stabilize cuesheet table components to prevent focus and data loss
This commit addresses the issue where focusing cells or inputs in the cuesheet table was lost during re-renders.

Changes:
- Extracted Virtuoso components (Table, TableRow, TableHead, EmptyPlaceholder) into stable, memoized components outside the main CuesheetTable component.
- Used Virtuoso's context API to pass dynamic state to the extracted components.
- Stabilized the 'meta' object of TanStack Table by using a ref for the data, reducing unnecessary re-renders of cell components.
- Modified useReactiveTextInput to skip synchronizing the 'text' state with 'initialText' when the input is focused, preventing user input from being overwritten by external changes.

These changes ensure that focus is maintained even when the table re-renders due to concurrent edits or submitting changes, and typed content is preserved.

Co-authored-by: cpvalente <34649812+cpvalente@users.noreply.github.com>
2026-02-08 11:24:48 +00:00
Carlos Valente aecf9575b1 refactor: link to settings section 2026-02-08 11:20:09 +01:00
Carlos Valente 773229ce4c refactor: align inputs with group, refs #1857 2026-02-08 11:20:09 +01:00
Carlos Valente da02ecd113 feat: editor layout 2026-02-08 11:20:09 +01:00
Carlos Valente 967e92e2c8 feat: extend rundown shortcuts 2026-02-08 11:20:09 +01:00
Carlos Valente d7af73d9ed feat: unify rundown features 2026-02-08 11:20:09 +01:00
Carlos Valente c5045ad82e refactor: table mode and context 2026-02-08 11:20:09 +01:00
Carlos Valente 8c22968bc9 fix: tsconfig references for utility tests 2026-02-08 11:20:09 +01:00
Carlos Valente 8aad271ffe refactor: create utility for enums 2026-02-08 11:20:09 +01:00
Carlos Valente b28573455a feat: create reusable scroll area 2026-02-08 11:20:09 +01:00
Carlos Valente e25725ea8b refactor: expose actions through context 2026-02-08 11:20:09 +01:00
Carlos Valente 1f8065143a refactor: improve context menu performance
- extract menu from rundown tree
- lazy create actions
2026-02-08 11:20:09 +01:00
Carlos Valente 8e32314667 refactor: improve performance in rundown
- leverage compiler
- correct subscriptions from zustand
2026-02-08 11:20:09 +01:00
Carlos Valente 0de55e74a8 feat: table mode in editor 2026-02-08 11:20:09 +01:00
Carlos Valente e5c5ec18d3 fix: automation form validation 2026-02-08 11:20:09 +01:00
Carlos Valente f9f6cb74ad chore: update npm link 2026-02-01 18:03:56 +01:00
Alex Christoffer Rasmussen 026e24bf84 Feat: unify view search params (#1947)
* feat: main src in backstage view

* feat: main src in timeline view

* feat: main src in countdown view

* feat: main src in studio view

* feat: hide past events in countdown view

* notes can not be set as main src

* feat: show group title as secondary src

* chore: spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-01-27 20:42:07 +01:00
Joel Wetzell ec1890c275 update docker actions and pin to commit hash 2026-01-26 14:10:09 +01:00
Joel Wetzell 046f1176eb add docker metadata action to setup tags 2026-01-26 14:10:09 +01:00
Alex Christoffer Rasmussen 04c5b67475 fix: missing demo logo (#1954) 2026-01-26 13:52:29 +01:00
google-labs-jules[bot] 7b5f797c95 refactor: move welcome modal logic to AppStateService
The logic for deciding whether to show the welcome modal has been moved from `app.ts` to `AppStateService.ts`. The `getShowWelcomeDialog` function now accepts a boolean indicating whether a restore point exists and returns `false` if it does. This keeps the business logic out of the main application file and in the relevant service.
2026-01-24 07:45:56 +01:00
google-labs-jules[bot] 14f9472250 fix: hide welcome modal on restore
The welcome modal is now hidden if the application is started from a restore point.
2026-01-24 07:45:56 +01:00
Alex Christoffer Rasmussen efede83271 Fix: automation (#1943)
* Fix(automation): Handles undefined values in conditions (#1933)

* Fix(automation): Handles undefined values in conditions

Addresses an issue where conditions with the 'not_equals' operator incorrectly evaluated undefined values. This change ensures that empty string comparisons correctly identify missing values in automation rules.

* Fixes automation "not equals" logic

Simplifies the 'not_equals' condition evaluation in automations by reusing the 'equals' condition, improving code readability and consistency.

Fixes #1932

* Test(automation): Expanding filter condition testing

Expanding test cases to cover various data types and edge case for each operators.
Unexpected behavior have been mark with a TO_DO.

* Fix(automation): Handles default filter case

- Ensures that their is a default scenario.
- Fixing Deepsource issue "No default cases in switch statements JS-0047"

* Test(automation): Remove abstraction in test

* move to test.each

* extract a isEquivalent function

* lowercase contains test

---------

Co-authored-by: Philippe Allard-Rousse <philrousse@gmail.com>
2026-01-21 16:07:21 +01:00
Carlos Valente 904db95f59 docs: update linux links 2026-01-16 07:52:56 +01:00
60 changed files with 1094 additions and 352 deletions
+15 -16
View File
@@ -37,32 +37,31 @@ jobs:
run: pnpm build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Setup Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
- name: Build and push stable release
if: github.event.release.prerelease == false
uses: docker/build-push-action@v4.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+6 -4
View File
@@ -10,13 +10,15 @@
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.4.0-beta.0",
"version": "4.3.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.4.0-beta.0",
"version": "4.3.1",
"private": true,
"type": "module",
"dependencies": {
@@ -25,12 +25,15 @@ export default function useReactiveTextInput(
const isKeyboardSubmitting = useRef(false);
useEffect(() => {
const isFocused = document.activeElement === ref.current;
if (isFocused) return;
if (typeof initialText === 'undefined') {
setText('');
} else {
setText(initialText);
}
}, [initialText]);
}, [initialText, ref]);
/**
* @description Handles Input value change
@@ -16,6 +16,7 @@
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
min-height: 1.2em;
}
.title-card__placeholder {
@@ -90,7 +90,10 @@ const staticOptions = [
},
] as const;
export type SettingsOptionId = (typeof staticOptions)[number]['id'];
// a child of navigation or a child of secondary navigation
export type SettingsOptionId =
| (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
export function useAppSettingsMenu() {
const { data } = useAppVersion();
@@ -21,7 +21,7 @@
}
&[data-phase='overtime'] {
color: $playback-negative;;
color: $playback-negative;
}
&[data-phase='pending'] {
+27 -12
View File
@@ -3,7 +3,14 @@ import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { closestCenter, DndContext } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types';
import {
type EntryId,
type Rundown as RundownType,
isOntimeEvent,
isOntimeGroup,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
@@ -28,10 +35,10 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: Rundown['entries'];
id: Rundown['id'];
order: Rundown['order'];
flatOrder: Rundown['flatOrder'];
entries: RundownType['entries'];
id: RundownType['id'];
order: RundownType['order'];
flatOrder: RundownType['flatOrder'];
rundownMetadata: RundownMetadataObject;
featureData: {
playback: Playback;
@@ -145,19 +152,27 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
};
}, [visibleData, dnd.isDraggingRef, setScrollHandler]);
// Keep selected cursor visible by expanding its parent group in any mode.
// Finder can select entries while layout is in Run mode, where we do not follow cursor scroll.
useEffect(() => {
if (!cursor) {
return;
}
const entry = entries[cursor];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
}, [cursor, entries, expandGroup]);
// Auto-follow behavior in Edit mode: follow the user's cursor
useEffect(() => {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[cursor];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(cursor);
}, [editorMode, cursor, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]);
}, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]);
// Auto-follow behavior in Run mode: follow the currently playing event
useEffect(() => {
@@ -317,7 +332,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
// Virtuoso components - extracted to prevent recreation on every render
const VirtuosoListComponent = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ children, ...props }, ref) => (
<div ref={ref} className={style.list} {...props}>
<div ref={ref} {...props} className={style.list}>
{children}
</div>
));
@@ -22,7 +22,7 @@
}
.rundownRoot {
height: calc(100% - 1.5rem);
height: calc(100% - 1rem);
width: 100%;
display: flex;
flex-direction: column;
@@ -33,6 +33,7 @@
display: flex;
height: inherit;
padding-inline: 0;
padding-bottom: 0;
box-shadow: $box-shadow-right;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
@@ -136,7 +136,6 @@ function EventEditorEmpty() {
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
<AuxKey>/</AuxKey>
</td>
</tr>
<tr className={style.spacer} />
@@ -1,6 +1,7 @@
import { useHotkeys } from '@mantine/hooks';
import { type OntimeEntry, EntryId, SupportedEntry } from 'ontime-types';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { useEventSelection } from '../useEventSelection';
interface UseRundownKeyboardOptions {
@@ -20,6 +21,20 @@ interface UseRundownKeyboardOptions {
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
}
/**
* Returns true when a keyboard event target is a text input element.
* Use this to avoid intercepting browser-native copy/cut/paste shortcuts
* while users are typing in form fields.
*/
function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
const tagName = target.tagName.toLowerCase();
return tagName === 'input' || tagName === 'textarea';
}
export function useRundownKeyboard({
cursor,
commands,
@@ -174,10 +189,50 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true, usePhysicalKeys: true }],
['mod + X', () => setEntryCopyId(cursor, 'cut'), { preventDefault: true, usePhysicalKeys: true }],
['mod + V', () => commands.insertCopyAtId(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'mod + C',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
return;
}
event.preventDefault();
setEntryCopyId(cursor);
},
{ usePhysicalKeys: true },
],
[
'mod + X',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
return;
}
event.preventDefault();
setEntryCopyId(cursor, 'cut');
},
{ usePhysicalKeys: true },
],
[
'mod + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor);
},
{ usePhysicalKeys: true },
],
['mod + D', () => commands.cloneEntry(cursor), { preventDefault: true, usePhysicalKeys: true }],
['mod + shift + V', () => commands.insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
[
'mod + shift + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor, true);
},
{ usePhysicalKeys: true },
],
]);
}
@@ -2,7 +2,7 @@
padding-inline: 1rem 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1rem;
:is([data-target='small-device']) & {
padding-inline: 0;
@@ -72,10 +72,3 @@
gap: 0.5rem;
align-self: start;
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: start;
}
@@ -76,43 +76,49 @@ function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProp
<Toolbar.Root className={style.header}>
{showRunEditToggle && (
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
<Tooltip text='Live playback view with auto-follow' render={<span />}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Tooltip
text='Live playback view with auto-follow'
render={<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton} />}
>
Run
</Tooltip>
<Tooltip text='Manual editing without playback automation' render={<span />}>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
<Tooltip
text='Manual editing without playback automation'
render={<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton} />}
>
Edit
</Tooltip>
</ToggleGroup>
)}
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
<Tooltip text='View rundown in list mode' render={<span />}>
<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton}>
List
</Toolbar.Button>
<Tooltip
text='View rundown in list mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton} />}
>
List
</Tooltip>
<Tooltip text='View rundown in table mode' render={<span />}>
<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton}>
Table
</Toolbar.Button>
<Tooltip
text='View rundown in table mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton} />}
>
Table
</Tooltip>
</ToggleGroup>
{showOffsetToggle && (
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
<Tooltip text='Offsets use fixed clock time' render={<span />}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
</Toolbar.Button>
<Tooltip
text='Offsets use fixed clock time'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton} />}
>
Absolute
</Tooltip>
<Tooltip text='Offsets follow the rundown relative start' render={<span />}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
Relative
</Toolbar.Button>
<Tooltip
text='Offsets follow the rundown relative start'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton} />}
>
Relative
</Tooltip>
</ToggleGroup>
)}
@@ -41,7 +41,7 @@ function RundownMenu({ allowNavigation }: RundownMenuProps) {
type: 'item',
label: 'Manage Rundowns...',
icon: IoList,
onClick: () => setLocation('manage'),
onClick: () => setLocation('manage__rundowns'),
disabled: !allowNavigation,
},
{ type: 'divider' },
@@ -19,7 +19,10 @@ export function useSelectAndRevealEntry(rundownId: string) {
({ id, index, parent }: SelectAndRevealOptions) => {
expandGroup(parent);
selectEntry({ id, index, selectMode: 'click' });
scrollToEntry(id);
// Wait one frame so collapsed-group expansion is reflected in rendered/visible entries before scrolling.
requestAnimationFrame(() => {
scrollToEntry(id);
});
},
[expandGroup, scrollToEntry, selectEntry],
);
@@ -21,6 +21,7 @@ export const langDe: TranslationObject = {
'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
'countdown.overtime': 'überfällig',
'countdown.all_have_finished': 'Alle ausgewählten Veranstaltungen sind beendet',
'timeline.live': 'live',
'timeline.done': 'Beendet',
'timeline.due': 'fällig',
@@ -21,6 +21,7 @@ export const langEs: TranslationObject = {
'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento',
'countdown.overtime': 'en tiempo extra',
'countdown.all_have_finished': 'Todos los eventos seleccionados han terminado',
'timeline.live': 'live',
'timeline.done': 'Terminado',
'timeline.due': 'pendiente',
@@ -21,6 +21,7 @@ export const langFr: TranslationObject = {
'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement',
'countdown.overtime': 'en dépassement',
'countdown.all_have_finished': 'Tous les évènements sélectionnés ont terminé',
'timeline.live': 'live',
'timeline.done': 'Terminé',
'timeline.due': 'dû',
@@ -21,6 +21,7 @@ export const langIt: TranslationObject = {
'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento",
'countdown.overtime': 'in ritardo',
'countdown.all_have_finished': 'Tutti gli eventi selezionati sono finiti',
'timeline.live': 'live',
'timeline.done': 'Terminato',
'timeline.due': 'previsto',
@@ -21,6 +21,7 @@ export const langPt: TranslationObject = {
'countdown.to_start': 'Tempo para iniciar',
'countdown.waiting': 'Aguardando o início do evento',
'countdown.overtime': 'em tempo extra',
'countdown.all_have_finished': 'Todos os eventos selecionados terminaram',
'timeline.live': 'live',
'timeline.done': 'Concluído',
'timeline.due': 'Pendente',
@@ -43,7 +43,7 @@ export default function BackstageLoader() {
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
const { getLocalizedString } = useTranslation();
const { secondarySource, extraInfo } = useBackstageOptions();
const { mainSource, secondarySource, extraInfo } = useBackstageOptions();
const { eventNext, eventNow, rundown, selectedEventId, time } = useBackstageSocket();
const [blinkClass, setBlinkClass] = useState(false);
const { height: screenHeight } = useViewportSize();
@@ -64,7 +64,7 @@ function Backstage({ events, customFields, projectData, isMirrored, settings }:
const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
eventNow,
eventNext,
'title',
mainSource,
secondarySource,
time.playback,
);
@@ -23,6 +23,7 @@ export const getBackstageOptions = (
{ value: 'note', label: 'Note' },
]);
const projectDataOptions = makeProjectDataOptions(projectData, [{ value: 'none', label: 'None' }]);
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
@@ -30,6 +31,14 @@ export const getBackstageOptions = (
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
{
id: 'secondary-src',
title: 'Event secondary text',
@@ -59,6 +68,7 @@ export const getBackstageOptions = (
};
type BackstageOptions = {
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
extraInfo: string | null;
};
@@ -72,6 +82,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
extraInfo: getValue('extra-info'),
};
+20 -2
View File
@@ -1,4 +1,12 @@
import { MaybeNumber, MaybeString, OntimeEvent, TimerState, TimerType } from 'ontime-types';
import {
MaybeNumber,
MaybeString,
OntimeEvent,
OntimeGroup,
RundownEntries,
TimerState,
TimerType,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero, removeSeconds } from 'ontime-utils';
import { timerPlaceholder, timerPlaceholderMin } from '../../common/utils/styleUtils';
@@ -66,12 +74,22 @@ export function makeColourString(hex: string | null): string | undefined {
/**
* Retrieves a dynamic property from an event
* Considers custom fields
* if rundown entries are provided it can also find the parent title
*/
export function getPropertyValue(event: OntimeEvent | null, property: MaybeString): string | undefined {
export function getPropertyValue(
event: OntimeEvent | null,
property: MaybeString,
entries?: RundownEntries,
): string | undefined {
if (!event || typeof property !== 'string' || property === 'none') {
return undefined;
}
if (property === 'parent') {
if (!entries || !event.parent) return undefined;
return (entries[event.parent] as OntimeGroup)?.title;
}
if (property.startsWith('custom-')) {
const field = property.split('custom-')[1];
return event.custom?.[field];
+16 -4
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5';
import { EntryId, isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView } from 'ontime-types';
import { EntryId, isOntimeEvent, isPlayableEvent, OntimeEvent, OntimeView, PlayableEvent } from 'ontime-types';
import Button from '../../common/components/buttons/Button';
import Empty from '../../common/components/state/Empty';
@@ -47,7 +47,9 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
const [editMode, setEditMode] = useState(false);
// gather rundown data
const playableEvents = rundownData.filter((entry) => isOntimeEvent(entry) && isPlayableEvent(entry));
const playableEvents = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent> => {
return isOntimeEvent(entry) && isPlayableEvent(entry);
});
// gather presentation data
const hasEvents = playableEvents.length > 0;
@@ -93,6 +95,7 @@ interface CountdownContentsProps {
function CountdownContents({ playableEvents, subscriptions, goToEditMode }: CountdownContentsProps) {
const { getLocalizedString } = useTranslation();
const { hidePast } = useCountdownOptions();
if (subscriptions.length === 0) {
return (
@@ -106,6 +109,7 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
}
const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents);
const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast);
if (subscribedEvents.length === 0) {
return (
@@ -118,13 +122,21 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
);
}
if (subscribedEvents.length === 1) {
if (subscribedEvents.length === 1 && eventsToShow.length === 1) {
const event = subscribedEvents.at(0);
if (!event) return null;
return <SingleEventCountdown subscribedEvent={event} goToEditMode={goToEditMode} />;
}
return <CountdownSubscriptions subscribedEvents={subscribedEvents} goToEditMode={goToEditMode} />;
if (eventsToShow.length === 0) {
return (
<div className='empty-container'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-container' />
</div>
);
}
return <CountdownSubscriptions subscribedEvents={eventsToShow} goToEditMode={goToEditMode} />;
}
function CountdownClock() {
@@ -36,7 +36,7 @@ interface CountdownSubscriptionsProps {
}
export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) {
const { secondarySource, showExpected } = useCountdownOptions();
const { mainSource, secondarySource, showExpected } = useCountdownOptions();
const playback = usePlayback();
const selectedEventId = useSelectedEventId();
const showFab = useFadeOutOnInactivity(true);
@@ -103,7 +103,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const isLive = getIsLive(event.id, selectedEventId, playback);
const isArmed = !isLive && event.id === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const title = event.title.length ? event.title : ' '; // insert utf-8 empty space to avoid the line collapsing
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
@@ -114,7 +114,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
<div className='sub__binder' style={{ '--user-color': event.colour }} />
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !event.title && 'subdued'])}>{title}</div>
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
@@ -23,7 +23,7 @@ interface SingleEventCountdownProps {
}
export default function SingleEventCountdown({ subscribedEvent, goToEditMode }: SingleEventCountdownProps) {
const { secondarySource, showExpected } = useCountdownOptions();
const { mainSource, secondarySource, showExpected } = useCountdownOptions();
const showFab = useFadeOutOnInactivity(true);
const { data: reportData } = useReport();
@@ -41,7 +41,8 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const title = subscribedEvent.title.length ? subscribedEvent.title : ' '; // insert utf-8 empty space to avoid the line collapsing
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
return (
@@ -14,6 +14,7 @@ export const getCountdownOptions = (
customFields: CustomFields,
persistedSubscriptions: EntryId[],
): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
@@ -25,6 +26,14 @@ export const getCountdownOptions = (
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
{
id: 'secondary-src',
title: 'Event secondary text',
@@ -48,6 +57,19 @@ export const getCountdownOptions = (
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
],
},
{
title: OptionTitle.Hidden,
options: [
@@ -65,8 +87,10 @@ export const getCountdownOptions = (
type CountdownOptions = {
subscriptions: EntryId[];
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
showExpected: boolean;
hidePast: boolean;
};
/**
@@ -87,8 +111,10 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
return {
subscriptions: getArrayValues('sub'),
mainSource: getValue('main') as keyof OntimeEvent | null,
secondarySource: getValue('secondary-src') as keyof OntimeEvent | null,
showExpected: isStringBoolean(getValue('showExpected')),
hidePast: isStringBoolean(getValue('hidePast')),
};
}
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import { ColumnDef, getCoreRowModel, Row, Table, useReactTable } from '@tanstack/react-table';
import { EntryId, isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField } from 'ontime-types';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
@@ -27,6 +27,115 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
import style from './CuesheetTable.module.scss';
interface VirtuosoContext {
table: Table<ExtendedEntry>;
rows: Row<ExtendedEntry>[];
cursor: EntryId | null;
columnSizeVars: Record<string, number | string>;
listeners: object;
}
const VirtuosoTable = memo(({ style: injectedStyles, context, ...virtuosoProps }: any) => {
const { columnSizeVars, listeners } = context as VirtuosoContext;
return (
<table
className={style.cuesheet}
id="cuesheet"
style={{ ...injectedStyles, ...columnSizeVars }}
{...listeners}
{...virtuosoProps}
/>
);
});
VirtuosoTable.displayName = 'VirtuosoTable';
const VirtuosoTableRow = memo(({ item: _item, context, ...virtuosoProps }: any) => {
const { table, rows, cursor } = context as VirtuosoContext;
const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex];
if (!row) return null;
const key = row.original.id;
const entry = row.original;
const hasCursor = entry.id === cursor;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return (
<DelayRow
key={key}
duration={entry.duration}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeMilestone(entry)) {
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={entry.isPast}
parentBgColour={entry.groupColour}
parentId={entry.parent}
colour={entry.colour}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
return (
<EventRow
key={row.id}
id={entry.id}
eventIndex={entry.eventIndex}
colour={entry.colour}
isFirstAfterGroup={entry.isFirstAfterGroup}
isLoaded={entry.isLoaded}
isPast={entry.isPast}
groupColour={entry.groupColour}
flag={entry.flag}
skip={entry.skip}
parent={entry.parent}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={virtuosoProps.style}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
});
VirtuosoTableRow.displayName = 'VirtuosoTableRow';
const VirtuosoTableHead = memo((props: any) => <thead className={style.tableHeader} {...props} />);
VirtuosoTableHead.displayName = 'VirtuosoTableHead';
const VirtuosoEmptyPlaceholder = memo(() => <EmptyTableBody text="No data in rundown" />);
VirtuosoEmptyPlaceholder.displayName = 'VirtuosoEmptyPlaceholder';
interface CuesheetTableProps {
columns: ColumnDef<ExtendedEntry>[];
cuesheetMode: AppMode;
@@ -49,11 +158,14 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const { listeners } = useTableNav();
const dataRef = useRef(data);
dataRef.current = data;
const meta = useMemo(
() => ({
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same
const event = data[rowIndex];
const event = dataRef.current[rowIndex];
if (!event) {
return;
@@ -84,7 +196,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
hideIndexColumn,
},
}),
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
[cuesheetMode, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
@@ -171,6 +283,17 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel();
const virtuosoContext = useMemo(
() => ({
table,
rows,
cursor,
columnSizeVars,
listeners,
}),
[columnSizeVars, cursor, listeners, rows, table],
);
const isLoading = !data || status === 'pending';
if (isLoading) {
@@ -191,98 +314,14 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
<TableVirtuoso
ref={virtuosoRef}
data={data}
context={virtuosoContext}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
increaseViewportBy={{ top: 100, bottom: 200 }}
components={{
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
Table: ({ style: injectedStyles, ...virtuosoProps }) => {
return (
<table
className={style.cuesheet}
id='cuesheet'
style={{ ...injectedStyles, ...columnSizeVars }}
{...listeners}
{...virtuosoProps}
/>
);
},
TableRow: ({ item: _item, style: injectedStyles, ...virtuosoProps }) => {
// eslint-disable-next-line react/destructuring-assignment
const rowIndex = virtuosoProps['data-index'];
const row = rows[rowIndex];
const key = row.original.id;
const entry = row.original;
const hasCursor = entry.id === cursor;
if (isOntimeGroup(entry)) {
return (
<GroupRow
key={key}
groupId={entry.id}
colour={entry.colour}
rowId={row.id}
rowIndex={row.index}
table={table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeDelay(entry)) {
return (
<DelayRow
key={key}
duration={entry.duration}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
if (isOntimeMilestone(entry)) {
return (
<MilestoneRow
key={key}
entryId={entry.id}
isPast={entry.isPast}
parentBgColour={entry.groupColour}
parentId={entry.parent}
colour={entry.colour}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
}
return (
<EventRow
key={row.id}
id={entry.id}
eventIndex={entry.eventIndex}
colour={entry.colour}
isFirstAfterGroup={entry.isFirstAfterGroup}
isLoaded={entry.isLoaded}
isPast={entry.isPast}
groupColour={entry.groupColour}
flag={entry.flag}
skip={entry.skip}
parent={entry.parent}
rowId={row.id}
rowIndex={rowIndex}
table={table}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
);
},
TableHead: (virtuosoProps) => <thead className={style.tableHeader} {...virtuosoProps} />,
EmptyPlaceholder: VirtuosoEmptyPlaceholder,
Table: VirtuosoTable,
TableRow: VirtuosoTableRow,
TableHead: VirtuosoTableHead,
}}
fixedHeaderContent={() => {
return table.getHeaderGroups().map((headerGroup) => {
@@ -48,4 +48,5 @@ $max-playback-width: 30rem;
flex: 0 0 $overview-width;
min-width: $overview-width;
max-width: $overview-width;
overflow: hidden;
}
@@ -2,8 +2,9 @@
.container {
height: 100%;
width: 100%;
background: $bg-container-l1;
margin-top: 1rem;
padding-top: 1rem;
}
.list {
+2 -2
View File
@@ -32,12 +32,12 @@ export default function StudioLoader() {
return <Studio {...data} />;
}
function Studio({ projectData, isMirrored, settings, viewSettings }: StudioData) {
function Studio({ customFields, projectData, isMirrored, settings, viewSettings }: StudioData) {
const { hideCards } = useStudioOptions();
// gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const studioOptions = useMemo(() => getStudioOptions(defaultFormat), [defaultFormat]);
const studioOptions = useMemo(() => getStudioOptions(defaultFormat, customFields), [defaultFormat, customFields]);
return (
<div className={cx(['studio', isMirrored && 'mirror'])} data-testid='studio-view'>
@@ -5,8 +5,10 @@ import { useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useS
import { getOffsetState } from '../../common/utils/offset';
import { cx } from '../../common/utils/styleUtils';
import { useTranslation } from '../../translation/TranslationProvider';
import { getPropertyValue } from '../common/viewUtils';
import { getTimerColour } from '../utils/presentation.utils';
import { useStudioOptions } from './studio.options';
import { getFormattedEventData, getFormattedScheduleTimes } from './studioTimers.utils';
import './StudioTimers.scss';
@@ -17,6 +19,7 @@ interface StudioTimersProps {
export default function StudioTimers({ viewSettings }: StudioTimersProps) {
const { getLocalizedString } = useTranslation();
const { mainSource } = useStudioOptions();
const { eventNow, eventNext, message, time, offset, rundown, expectedRundownEnd } = useStudioTimersSocket();
const schedule = getFormattedScheduleTimes({
@@ -24,8 +27,8 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
actualStart: rundown.actualStart,
expectedEnd: expectedRundownEnd,
});
const event = getFormattedEventData(eventNow, time);
const eventNextTitle = eventNext?.title || '-';
const event = getFormattedEventData(eventNow, time, mainSource);
const eventNextTitle = getPropertyValue(eventNext, mainSource ?? 'title') || '-';
const formattedTimerMessage = (message.timer.visible && message.timer.text) || '-';
const formattedSecondaryMessage = message.timer.secondarySource === 'secondary' ? message.secondary || '-' : '-';
+38 -16
View File
@@ -1,30 +1,51 @@
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
export const getStudioOptions = (timeFormat: string): ViewOption[] => [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideCards',
title: 'Hide cards section',
description: 'Hides the card section with the timers',
type: 'boolean',
defaultValue: false,
},
],
},
];
export const getStudioOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideCards',
title: 'Hide cards section',
description: 'Hides the card section with the timers',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
type StudioOptions = {
mainSource: keyof OntimeEvent | null;
hideCards: boolean;
};
@@ -37,6 +58,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
mainSource: getValue('main') as keyof OntimeEvent | null,
hideCards: isStringBoolean(getValue('hideCards')),
};
}
@@ -17,9 +17,13 @@ export function getFormattedScheduleTimes(data: {
};
}
export function getFormattedEventData(eventNow: OntimeEvent | null, timer: TimerState) {
export function getFormattedEventData(
eventNow: OntimeEvent | null,
timer: TimerState,
mainSource: keyof OntimeEvent | null,
) {
return {
title: eventNow?.title || '-',
title: (eventNow?.[mainSource ?? 'title'] as string) || '-',
startedAt: formatTime(timer.startedAt, timeFormat),
expectedEnd: formatTime(timer.expectedFinish, timeFormat),
timer: millisToString(timer.current),
@@ -1,5 +1,6 @@
import { ProjectData, Settings, ViewSettings } from 'ontime-types';
import { CustomFields, ProjectData, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
@@ -7,6 +8,7 @@ import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface StudioData {
customFields: CustomFields;
projectData: ProjectData;
isMirrored: boolean;
settings: Settings;
@@ -21,14 +23,16 @@ export function useStudioData(): ViewData<StudioData> {
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
return {
data: {
customFields,
projectData,
isMirrored,
settings,
viewSettings,
},
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus]),
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
};
}
+5 -2
View File
@@ -6,6 +6,7 @@ import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import useHorizontalFollowComponent from '../../common/hooks/useHorizontalFollowComponent';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../common/viewUtils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import { useTimelineOptions } from './timeline.options';
@@ -24,7 +25,7 @@ interface TimelineProps {
export default memo(Timeline);
function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: TimelineProps) {
const { width: screenWidth } = useViewportSize();
const { hidePast, fixedSize } = useTimelineOptions();
const { mainSource, hidePast, fixedSize } = useTimelineOptions();
const selectedRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -86,6 +87,8 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
const position = positions[index];
if (!position) return null;
const displayTitle = getPropertyValue(event, mainSource ?? 'title') || event.title;
return (
<TimelineEntry
key={event.id}
@@ -100,7 +103,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
totalGap={event.totalGap}
isLinkedToLoaded={event.isLinkedToLoaded}
dayOffset={event.dayOffset}
title={event.title}
title={displayTitle}
cue={event.cue}
width={position.width}
/>
@@ -12,7 +12,7 @@ import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import Timeline from './Timeline';
import { getTimelineOptions } from './timeline.options';
import { getTimelineOptions, useTimelineOptions } from './timeline.options';
import { getUpcomingEvents, useScopedRundown } from './timeline.utils';
import TimelineSections from './TimelineSections';
import { TimelineData, useTimelineData } from './useTimelineData';
@@ -35,8 +35,9 @@ export default function TimelinePageLoader() {
return <TimelinePage {...data} />;
}
function TimelinePage({ events, projectData, settings }: TimelineData) {
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
const selectedEventId = useSelectedEventId();
const { mainSource } = useTimelineOptions();
// holds copy of the rundown with only relevant events
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
@@ -47,7 +48,7 @@ function TimelinePage({ events, projectData, settings }: TimelineData) {
// populate options
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat), [defaultFormat]);
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
return (
<div className='timeline' data-testid='timeline-view'>
@@ -58,7 +59,7 @@ function TimelinePage({ events, projectData, settings }: TimelineData) {
<TimelineClock />
</div>
<TimelineSections now={now} next={next} followedBy={followedBy} />
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
<Timeline
firstStart={firstStart}
@@ -5,6 +5,7 @@ import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatDuration, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import { getPropertyValue } from '../common/viewUtils';
import TimelineSection from './timeline-section/TimelineSection';
@@ -12,17 +13,18 @@ interface TimelineSectionsProps {
now: ExtendedEntry<OntimeEvent> | null;
next: ExtendedEntry<OntimeEvent> | null;
followedBy: ExtendedEntry<OntimeEvent> | null;
mainSource: keyof OntimeEvent | null;
}
export default function TimelineSections({ now, next, followedBy }: TimelineSectionsProps) {
export default function TimelineSections({ now, next, followedBy, mainSource }: TimelineSectionsProps) {
const { getLocalizedString } = useTranslation();
const state = useExpectedStartData();
// gather card data
const titleNow = now?.title ?? '-';
const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-';
const dueText = getLocalizedString('timeline.due').toUpperCase();
const nextText = next !== null ? next.title : '-';
const followedByText = followedBy !== null ? followedBy.title : '-';
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
let nextStatus: string | undefined;
let followedByStatus: string | undefined;
@@ -1,15 +1,33 @@
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { getTimeOption } from '../../common/components/view-params-editor/common.options';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
export const getTimelineOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, [{ value: 'title', label: 'Title' }]);
return [
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'title',
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
@@ -34,6 +52,7 @@ export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
};
type TimelineOptions = {
mainSource: keyof OntimeEvent | null;
hidePast: boolean;
fixedSize: boolean;
};
@@ -47,6 +66,7 @@ function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URL
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
mainSource: getValue('main') as keyof OntimeEvent | null,
hidePast: isStringBoolean(getValue('hidePast')),
fixedSize: isStringBoolean(getValue('fixedSize')),
};
@@ -1,5 +1,6 @@
import { OntimeEntry, ProjectData, Settings } from 'ontime-types';
import { CustomFields, OntimeEntry, ProjectData, Settings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import { useFlatRundownWithMetadata } from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
@@ -8,6 +9,7 @@ import { aggregateQueryStatus, ViewData } from '../utils/viewLoader.utils';
export interface TimelineData {
events: ExtendedEntry<OntimeEntry>[];
customFields: CustomFields;
projectData: ProjectData;
settings: Settings;
}
@@ -17,13 +19,15 @@ export function useTimelineData(): ViewData<TimelineData> {
const { data: rundownData, status: rundownStatus } = useFlatRundownWithMetadata();
const { data: projectData, status: projectDataStatus } = useProjectData();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
return {
data: {
events: rundownData,
customFields,
projectData,
settings,
},
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus]),
status: aggregateQueryStatus([rundownStatus, projectDataStatus, settingsStatus, customFieldsStatus]),
};
}
+2 -1
View File
@@ -49,7 +49,7 @@ export default function TimerLoader() {
return <Timer {...data} />;
}
function Timer({ customFields, projectData, isMirrored, settings, viewSettings }: TimerData) {
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
const { eventNext, eventNow, message, time, clock, timerTypeNow, countToEndNow, auxTimer } = useTimerSocket();
const {
hideClock,
@@ -97,6 +97,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
secondarySource,
time.playback,
time.phase,
entries,
);
// gather timer data
+3 -3
View File
@@ -27,12 +27,12 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
const mainOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
]);
const secondaryOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'title', label: 'Title' },
{ value: 'note', label: 'Note' },
{ value: 'parent', label: 'Group Title' },
]);
return [
@@ -185,8 +185,8 @@ type TimerOptions = {
hideLogo: boolean;
hideTimerSeconds: boolean;
removeLeadingZeros: boolean;
mainSource: keyof OntimeEvent | null;
secondarySource: keyof OntimeEvent | null;
mainSource: keyof OntimeEvent | null | 'none';
secondarySource: keyof OntimeEvent | null | 'none';
timerType?: TimerType;
freezeOvertime: boolean;
freezeMessage: string;
+18 -8
View File
@@ -1,4 +1,13 @@
import { MaybeNumber, MessageState, OntimeEvent, Playback, TimerMessage, TimerPhase, TimerType } from 'ontime-types';
import {
MaybeNumber,
MessageState,
OntimeEvent,
Playback,
RundownEntries,
TimerMessage,
TimerPhase,
TimerType,
} from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils';
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
@@ -141,10 +150,11 @@ export function getSecondaryDisplay(
export function getCardData(
eventNow: OntimeEvent | null,
eventNext: OntimeEvent | null,
mainSource: keyof OntimeEvent | null,
secondarySource: keyof OntimeEvent | null,
mainSource: keyof OntimeEvent | null | 'none',
secondarySource: keyof OntimeEvent | null | 'none',
playback: Playback,
phase: TimerPhase,
entries: RundownEntries,
) {
if (playback === Playback.Stop) {
return {
@@ -162,19 +172,19 @@ export function getCardData(
// if we are loaded, we show the upcoming event as next
const nowMain = hasActiveTimer ? getPropertyValue(eventNow, mainSource ?? 'title') : undefined;
const nowSecondary = hasActiveTimer ? getPropertyValue(eventNow, secondarySource) : undefined;
const nowSecondary = hasActiveTimer ? getPropertyValue(eventNow, secondarySource, entries) : undefined;
const nextMain = hasActiveTimer
? getPropertyValue(eventNext, mainSource ?? 'title')
: getPropertyValue(eventNow, mainSource ?? 'title');
const nextSecondary = hasActiveTimer
? getPropertyValue(eventNext, secondarySource)
: getPropertyValue(eventNow, secondarySource);
? getPropertyValue(eventNext, secondarySource, entries)
: getPropertyValue(eventNow, secondarySource, entries);
return {
showNow: Boolean(nowMain) || Boolean(nowSecondary),
showNow: mainSource !== 'none' || Boolean(nowSecondary),
nowMain,
nowSecondary,
showNext: Boolean(nextMain) || Boolean(nextSecondary),
showNext: mainSource !== 'none' || Boolean(nextSecondary),
nextMain,
nextSecondary,
};
+13 -2
View File
@@ -1,7 +1,8 @@
import { CustomFields, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { CustomFields, ProjectData, RundownEntries, Settings, ViewSettings } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -13,6 +14,7 @@ export interface TimerData {
isMirrored: boolean;
settings: Settings;
viewSettings: ViewSettings;
entries: RundownEntries;
}
export function useTimerData(): ViewData<TimerData> {
@@ -24,6 +26,8 @@ export function useTimerData(): ViewData<TimerData> {
const { data: viewSettings, status: viewSettingsStatus } = useViewSettings();
const { data: settings, status: settingsStatus } = useSettings();
const { data: customFields, status: customFieldsStatus } = useCustomFields();
const { data: rundown, status: rundownStatus } = useRundown();
const { entries } = rundown;
return {
data: {
@@ -32,7 +36,14 @@ export function useTimerData(): ViewData<TimerData> {
isMirrored,
settings,
viewSettings,
entries,
},
status: aggregateQueryStatus([projectDataStatus, viewSettingsStatus, settingsStatus, customFieldsStatus]),
status: aggregateQueryStatus([
projectDataStatus,
viewSettingsStatus,
settingsStatus,
customFieldsStatus,
rundownStatus,
]),
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.4.0-beta.0",
"version": "4.3.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.4.0-beta.0",
"version": "4.3.1",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.4.0-beta.0",
"version": "4.3.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -9,6 +9,7 @@ import * as oscClient from '../clients/osc.client.js';
import * as httpClient from '../clients/http.client.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -100,117 +101,440 @@ describe('testConditions()', () => {
expect(result).toBe(true);
});
describe('equals operator', () => {
it('should compare two equal values', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'equals', value: '10' }], 'all', mockStore);
expect(result).toBe(true);
});
it('string comparisons should be case insensitive', () => {
const mockStore = makeRuntimeStateData({ eventNow: { title: 'Title' } as PlayableEvent });
const result = testConditions(
[{ field: 'eventNow.title', operator: 'equals', value: 'title' }],
'all',
mockStore,
);
expect(result).toBe(true);
});
it('should check if a value does not exist', () => {
const mockStore = makeRuntimeStateData({ eventNow: null });
const result = testConditions([{ field: 'eventNow.title', operator: 'equals', value: '' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle trueness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: true } as PlayableEvent });
describe('equals', () => {
test.each([
{
description: 'number and string number',
value: '10',
state: 10,
},
{
description: 'string and string',
value: 'Title',
state: 'Title',
},
{
description: 'null and empty string',
value: '',
state: null,
},
{
description: 'undefined and empty string',
value: '',
state: undefined,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
{
description: 'boolean false equals string false',
value: 'false',
state: false,
},
{
description: 'not case sensitive for strings',
value: 'title',
state: 'Title',
},
{
description: 'not case sensitive for booleans',
value: 'TRUE',
state: true,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore),
testConditions([{ field: 'test.path', operator: 'equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(false);
});
it('should handle falseness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: false } as PlayableEvent });
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(true);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore),
testConditions([{ field: 'test.path', operator: 'not_equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('not_equals operator', () => {
it('should check if two values are different', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'not_equals', value: '11' }], 'all', mockStore);
expect(result).toBe(true);
describe('not_equals', () => {
test.each([
{
description: 'boolean true and string false',
value: 'false',
state: true,
},
{
description: 'boolean false and string true',
value: 'true',
state: false,
},
{
description: 'number and non-numeric string',
value: 'lighting',
state: 10,
},
{
description: 'number and different number',
value: '100',
state: 10,
},
{
description: '10 and 11',
value: '11',
state: 10,
},
{
description: '10 and 5',
value: '5',
state: 10,
},
{
description: '10 and 1',
value: '1',
state: 10,
},
{
description: 'string and substring',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'string and different string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'null and non-empty string',
value: 'not-empty',
state: null,
},
{
description: 'undefined and non-empty string',
value: 'not-empty',
state: undefined,
},
{
description: 'undefined and " " string',
value: ' ',
state: undefined,
},
{
description: 'boolean false and empty string',
value: '',
state: false,
},
{
description: '0 and empty string',
value: '',
state: 0,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'not_equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'equals', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('greater_than operator', () => {
it('should check if the given value is smaller', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: '9' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
describe('greater_than', () => {
test.each([
{
description: '10 > 5',
value: '5',
state: 10,
},
{
description: '10 > 1',
value: '1',
state: 10,
},
{
description: '100 > 5',
value: '5',
state: 100,
},
{
description: '11 > 10',
value: '10',
state: 11,
},
{
description: '0 > -1',
value: '-1',
state: 0,
},
{
description: '-100 > -150',
value: '-150',
state: -100,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('less_than operator', () => {
it('should check if the given value is larger', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'less_than', value: '11' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'less_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
describe('less_than', () => {
test.each([
{
description: '10 > 5',
value: '10',
state: 5,
},
{
description: '10 > 1',
value: '10',
state: 1,
},
{
description: '100 > 5',
value: '100',
state: 5,
},
{
description: '11 > 10',
value: '11',
state: 10,
},
{
description: '0 > -1',
value: '0',
state: -1,
},
{
description: '-100 > -150',
value: '-100',
state: -150,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('contains operator', () => {
it('should check if value contains given string', () => {
const result = testConditions(
[{ field: 'eventNow.title', operator: 'contains', value: 'lighting' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result).toBe(true);
const result2 = testConditions(
[{ field: 'eventNow.title', operator: 'contains', value: 'sound' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result2).toBe(false);
describe('invalid and edge case for greater/less_than', () => {
test.each([
{
description: 'number and non-numeric string',
value: 'lighting',
state: 10,
},
{
description: '0 and empty string',
value: '',
state: 0,
},
{
description: 'equal values',
value: '10',
state: 10,
},
{
description: 'boolean false and empty string',
value: '',
state: false,
},
{
description: 'undefined and non-empty string',
value: 'not-empty',
state: undefined,
},
{
description: 'null and non-empty string',
value: 'not-empty',
state: null,
},
{
description: 'string and different string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'string and substring',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'boolean true and uppercase TRUE',
value: 'TRUE',
state: true,
},
{
description: 'string and lowercase string',
value: 'title',
state: 'Title',
},
{
description: 'boolean false and string true',
value: 'true',
state: false,
},
{
description: 'boolean false and string false',
value: 'false',
state: false,
},
{
description: 'boolean true and string false',
value: 'false',
state: true,
},
{
description: 'string and string',
value: 'Title',
state: 'Title',
},
{
description: 'null and empty string',
value: '',
state: null,
},
{
description: 'undefined and empty string',
value: '',
state: undefined,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'less_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
expect(
testConditions([{ field: 'test.path', operator: 'greater_than', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
describe('not_contains operator', () => {
it('should check if value does not contain given string', () => {
const result = testConditions(
[{ field: 'eventNow.title', operator: 'not_contains', value: 'lighting' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result).toBe(false);
describe('contains', () => {
test.each([
{
description: 'substring in string',
value: 'lighting',
state: 'testing-lighting-10',
},
{
description: 'number in string',
value: '10',
state: 'testing-lighting-10',
},
{
description: 'digit in string',
value: '1',
state: 'testing-lighting-10',
},
{
description: 'empty in empty',
value: '',
state: '',
},
{
description: 'empty in not-empty',
value: '',
state: '12345', // TODO: is this intended
},
{
description: 'string equals string',
value: 'Title',
state: 'Title',
},
{
description: 'case sensitive',
value: 'title',
state: 'Title',
},
{
description: 'number does contain string',
value: '10',
state: 2105,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'not_contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
const result2 = testConditions(
[{ field: 'eventNow.title', operator: 'not_contains', value: 'sound' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result2).toBe(true);
describe('not_contains', () => {
test.each([
{
description: 'number does not contain substring',
value: '456',
state: 12345,
},
{
description: 'substring not in string',
value: 'sound',
state: 'testing-lighting-10',
},
{
description: 'string and undefined',
value: 'sound',
state: undefined,
},
{
description: 'string and null',
value: 'sound',
state: null,
},
{
description: 'boolean true and string true',
value: 'true',
state: true,
},
{
description: 'boolean false and string false',
value: 'false',
state: false,
},
])('$description', ({ value, state }) => {
expect(
testConditions([{ field: 'test.path', operator: 'not_contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(true);
expect(
testConditions([{ field: 'test.path', operator: 'contains', value }], 'all', {
test: { path: state },
} as unknown as RuntimeState),
).toBe(false);
});
});
@@ -17,7 +17,7 @@ import { isOntimeCloud } from '../../setup/environment.js';
import { emitOSC } from './clients/osc.client.js';
import { emitHTTP } from './clients/http.client.js';
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
import { isBooleanEquals, isGreaterThan, isLessThan } from './automation.utils.js';
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
import { toOntimeAction } from './clients/ontime.client.js';
/**
@@ -92,29 +92,17 @@ export function testConditions(
// we use loose equality to be able to check for converted values (eg '10' == 10)
switch (operator) {
case 'equals':
// handle the case where we are comparing boolean strings
if (typeof fieldValue === 'boolean') {
return isBooleanEquals(fieldValue, lowerCasedValue);
}
// make string comparisons case insensitive
if (typeof fieldValue === 'string') {
return fieldValue.toLowerCase() === lowerCasedValue;
}
// overload the edge case where we use empty string to check if a value does not exist
if (value === '' && fieldValue === undefined) {
return true;
}
return fieldValue == value;
return isEquivalent(fieldValue, lowerCasedValue);
case 'not_equals':
return fieldValue != value;
return !isEquivalent(fieldValue, lowerCasedValue);
case 'greater_than':
return isGreaterThan(fieldValue, value);
case 'less_than':
return isLessThan(fieldValue, value);
case 'contains':
return typeof fieldValue === 'string' && fieldValue.includes(value);
return isContained(fieldValue, lowerCasedValue);
case 'not_contains':
return typeof fieldValue === 'string' && !fieldValue.includes(value);
return !isContained(fieldValue, lowerCasedValue);
default: {
operator satisfies never;
return false;
@@ -148,6 +148,38 @@ const quickAliases: AliasesDefinition = {
startedAt: { key: 'timer.startedAt', cb: (value: string) => formatDisplayFromString(value) },
};
export function isEquivalent(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return isBooleanEquals(a, b);
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase() === b;
}
// overload the edge case where we use empty string to check if a value does not exist
// this also avoids the case where 0 == ''
if (b === '') {
return a === null || a === undefined;
}
return a == b;
}
export function isContained(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return false;
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase().includes(b);
}
if (typeof a === 'number') {
return a.toString().toLowerCase().includes(b);
}
return false;
}
/**
* Utility encapsulates logic for comparing two strings which may encode numbers
* @example isGreaterThan('10', '5') // true
+1 -1
View File
@@ -184,7 +184,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
// the express server must be started before the socket otherwise the on error event listener will not attach properly
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
const showWelcome = await getShowWelcomeDialog();
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
socket.init(expressServer, showWelcome, prefix);
@@ -46,10 +46,12 @@ export async function setLastLoadedRundown(rundownKey: string): Promise<void> {
await config.write();
}
export async function getShowWelcomeDialog(): Promise<boolean> {
export async function getShowWelcomeDialog(restorePointExists: boolean): Promise<boolean> {
// in test environment, we do not want the dialog
if (isTest) return false;
if (restorePointExists) return false;
await config.read();
return config.data.showWelcomeDialog ?? true; // default to true
}
@@ -37,6 +37,7 @@ import {
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
import { populateOntimeLogo } from '../../setup/loadOntimeLogo.js';
type ProjectState =
| {
@@ -113,6 +114,7 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
* Loads the demo project
*/
export async function loadDemoProject(): Promise<string> {
populateOntimeLogo();
return createProject(config.demoProject, demoDb);
}
+2
View File
@@ -95,6 +95,8 @@ export const srcFiles = {
translationReadme: join(srcDir.root, config.user, config.translations.directory, 'README.md'),
/** Path to login */
login: join(srcDir.root, 'html/login.html'),
/** Path to ontime-logo */
logo: join(srcDir.root, config.user, config.logo, 'ontime-logo.png'),
};
/**
+18
View File
@@ -0,0 +1,18 @@
import { copyFileSync } from 'fs';
import { ensureDirectory } from '../utils/fileManagement.js';
import { publicDir, srcFiles } from './index.js';
import { basename, join } from 'path';
/**
* @description ensures directories exist and populates ontime logo
*/
export function populateOntimeLogo() {
ensureDirectory(publicDir.logoDir);
try {
const logoName = basename(srcFiles.logo);
copyFileSync(srcFiles.logo, join(publicDir.logoDir, logoName));
} catch (_) {
/* we do not handle this */
}
}
+94
View File
@@ -0,0 +1,94 @@
> ontime@4.3.1 dev /app
> turbo run dev
Attention:
Turborepo now collects completely anonymous telemetry regarding usage.
This information is used to shape the Turborepo roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://turborepo.com/docs/telemetry
turbo 2.5.8
• Packages in scope: @getontime/cli, @getontime/resolver, ontime-electron, ontime-server, ontime-types, ontime-ui, ontime-utils
• Running dev in 7 packages
• Remote caching disabled
ontime-server:dev: cache bypass, force executing 736e67d68a6b5681
ontime-ui:dev: cache bypass, force executing 9eacdb30152cd80e
ontime-ui:dev:
ontime-ui:dev: > ontime-ui@4.3.1 dev /app/apps/client
ontime-ui:dev: > cross-env BROWSER=none vite
ontime-ui:dev:
ontime-server:dev:
ontime-server:dev: > ontime-server@4.3.1 dev /app/apps/server
ontime-server:dev: > cross-env NODE_ENV=development tsx watch ./src/index.ts
ontime-server:dev:
ontime-ui:dev:
ontime-ui:dev: VITE v6.3.1 ready in 748 ms
ontime-ui:dev:
ontime-ui:dev: ➜ Local: http://localhost:3000/
ontime-ui:dev: ➜ Network: use --host to expose
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Starting Ontime version 4.3.1
ontime-server:dev: Ontime running in development environment
ontime-server:dev: Ontime source directory at /app/apps/server/src/
ontime-server:dev: Ontime public directory at /home/jules/.Ontime
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Initialise assets...
ontime-server:dev: [INFO] SERVER Loaded project demo project.json
ontime-server:dev: [INFO] SERVER Switch to rundown: default
ontime-server:dev: [INFO] SERVER Initialised Ontime with demo project.json
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Start server...
ontime-server:dev: [INFO] SERVER Runtime service started
ontime-server:dev: Local: http://localhost:4001/editor
ontime-server:dev: Network: http://192.168.0.2:4001/editor
ontime-server:dev: [INFO] SERVER Ontime is listening on port 4001
ontime-server:dev:
ontime-server:dev:
ontime-server:dev: Request: Start integrations...
ontime-server:dev: [INFO] SERVER Skipping OSC integration
ontime-ui:dev: Browserslist: browsers data (caniuse-lite) is 9 months old. Please run:
ontime-ui:dev: npx update-browserslist-db@latest
ontime-ui:dev: Why you should do it regularly: https://github.com/browserslist/update-db#readme
ontime-ui:dev: Browserslist: caniuse-lite is outdated. Please run:
ontime-ui:dev: npx update-browserslist-db@latest
ontime-ui:dev: Why you should do it regularly: https://github.com/browserslist/update-db#readme
ontime-server:dev: [INFO] CLIENT 1 Connections with new: low-fi instrument
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/db/all
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/db/all
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: low-fi instrument
ontime-server:dev: [INFO] CLIENT 1 Connections with new: mellow uplight
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: mellow uplight
ontime-server:dev: [INFO] CLIENT 1 Connections with new: nostalgic mix
ontime-ui:dev: Proxy: GET /data/settings
ontime-ui:dev: Proxy: GET /user/translations/translations.json
ontime-ui:dev: Proxy: GET /data/report
ontime-ui:dev: Proxy: GET /data/view-settings
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-ui:dev: Proxy: GET /data/project
ontime-ui:dev: Proxy: GET /data/custom-fields
ontime-ui:dev: Proxy: GET /data/rundowns/current
ontime-server:dev: [INFO] CLIENT 0 Connections with disconnected: nostalgic mix
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.4.0-beta.0",
"version": "4.3.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.4.0-beta.0",
"version": "4.3.1",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
+1
View File
@@ -19,6 +19,7 @@ export const langEn = {
'countdown.to_start': 'Time to start',
'countdown.waiting': 'Waiting for event start',
'countdown.overtime': 'in overtime',
'countdown.all_have_finished': 'All selected events have finished',
'timeline.live': 'live',
'timeline.done': 'done',
'timeline.due': 'due',