mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbbbaa5922 | |||
| aecf9575b1 | |||
| 773229ce4c | |||
| da02ecd113 | |||
| 967e92e2c8 | |||
| d7af73d9ed | |||
| c5045ad82e | |||
| 8c22968bc9 | |||
| 8aad271ffe | |||
| b28573455a | |||
| e25725ea8b | |||
| 1f8065143a | |||
| 8e32314667 | |||
| 0de55e74a8 | |||
| e5c5ec18d3 | |||
| f9f6cb74ad | |||
| 026e24bf84 | |||
| ec1890c275 | |||
| 046f1176eb | |||
| 04c5b67475 | |||
| 7b5f797c95 | |||
| 14f9472250 | |||
| efede83271 | |||
| 904db95f59 |
@@ -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 }}
|
||||
@@ -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,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,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'] {
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || '-' : '-';
|
||||
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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: [96mStarting Ontime version 4.3.1[0m
|
||||
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: [96mRequest: Initialise assets...[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Loaded project demo project.json[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Switch to rundown: default[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Initialised Ontime with demo project.json[0m
|
||||
ontime-server:dev:
|
||||
ontime-server:dev:
|
||||
ontime-server:dev: [96mRequest: Start server...[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Runtime service started[0m
|
||||
ontime-server:dev: [32mLocal: http://localhost:4001/editor[0m
|
||||
ontime-server:dev: [32mNetwork: http://192.168.0.2:4001/editor[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Ontime is listening on port 4001[0m
|
||||
ontime-server:dev:
|
||||
ontime-server:dev:
|
||||
ontime-server:dev: [96mRequest: Start integrations...[0m
|
||||
ontime-server:dev: [2m[INFO] SERVER Skipping OSC integration[0m
|
||||
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: [2m[INFO] CLIENT 1 Connections with new: low-fi instrument[0m
|
||||
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: [2m[INFO] CLIENT 0 Connections with disconnected: low-fi instrument[0m
|
||||
ontime-server:dev: [2m[INFO] CLIENT 1 Connections with new: mellow uplight[0m
|
||||
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: [2m[INFO] CLIENT 0 Connections with disconnected: mellow uplight[0m
|
||||
ontime-server:dev: [2m[INFO] CLIENT 1 Connections with new: nostalgic mix[0m
|
||||
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: [2m[INFO] CLIENT 0 Connections with disconnected: nostalgic mix[0m
|
||||
+1
-1
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"version": "4.4.0-beta.0",
|
||||
"version": "4.3.1",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user