Compare commits

...

19 Commits

Author SHA1 Message Date
Carlos Valente a3bbd74db7 chore: reduce sentry error logging (#498)
* chore: reduce sentry error logging

* chore: version bump

* chore: improve test selectors
2023-08-27 22:00:18 +02:00
Carlos Valente 5323e627fb feat: count to end (#500) 2023-08-27 19:25:40 +02:00
Carlos Valente cd9dbcdc68 fix: add event (#499)
* refactor: remove guards on event creation

* style: minimum safe block size

* fix: insert after finds previous valid cue
2023-08-26 21:45:51 +02:00
Carlos Valente af99882919 release test fixes (#496)
* refactor: handle missing type

* fix: prevent stale data on cancel delay
2023-08-24 22:53:23 +02:00
Carlos Valente 1d4dc3a26f feat: unlock changing port in UI (#476)
* refactor: unlock changing port in UI

* Change server port (#464)

* Ability to save custom port

* Launch server on custom port & pass that port to electron

* Add serverPort validation

* Prevent changing port in docker

---------

Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>

---------

Co-authored-by: স্নেহাংশু ফুকন <hello@snehanshu.com>
Co-authored-by: Snehanshu Phukon <snehanshu@mamboemm.com>
2023-08-23 22:31:57 +02:00
Carlos Valente e5fdf27f6c fix: apply delay (#494)
* fix: bug with applying delays

* fix: show delay data only in production screens

* chore: cover delay with feature test

* refactor: simplify type assertion
2023-08-23 22:31:15 +02:00
Carlos Valente 657cc22b44 feat: event cue (#473)
* feat: parse cue

* refactor: get delay from backend

* feat: add cue to UI

* refactor: remove deprecated delay logic

* refactor: extract studio clock specific logic

* refactor: extract utilities

* refactor: fix issue with missing key

* style: prevent cue overflow

* style: prevent whitespace wrap

* feat: add support for cues in integrations
2023-08-17 21:43:11 +02:00
asharonbaltazar 51c31adaf1 Swap Event Data (#446)
* use zustand store in `ContextMenuContext`

* add `withDivider` prop to context menu `Option`

* add `isDisabled` prop to `Option`

* create `eventIdSwapping` store

* create swapping context menu options

* fix `key` in ContextMenu

* address `tanstack-eslint` errors

* create initial `requestEventSwap` event

* create initial `swapEvents` action

* use `swapEvents` in `EventBlock`

* move from `emitError` to `logAxiosError`

* remove extra curly brace from Copy ID

* add `clearEventId` func

* finalize context menu swapping logic

* write optimistic swapping logic

* remove unused import and type out handler in `rundownController`

* create `rundownSwapValidator`

* move index increase to `EventBlock`

* move swapping logic from `id` to `index`

* create `swapEvents` endpoint

* move `useEventIdSwapping` hook into its own file

* revert to using event `id`

* logic now uses indexes to swap events

* add `todo` to `swapEvents`

* revert index increment

* create `swapOntimeEvents` and export in `index.ts`

* use `swapOntimeEvents` in frontend & server

* update import path

* remove extra `setCached`
2023-08-15 16:04:53 -04:00
Carlos Valente a46a0e1631 fix: typo in macos release (#488) 2023-08-12 11:52:05 +02:00
Carlos Valente 4f298e6fd9 version bump (#487) 2023-08-12 11:38:36 +02:00
Carlos Valente f3610059ef fix paused (#486)
* fix: prevent timer continue on paused count up

* refactor: throw if not found on delete
2023-08-12 11:24:35 +02:00
Carlos Valente 023aac4ead fix: midnight roll (#484)
* refactor: extract utilities and types

* refactor: extract and simplify progress bar logic

* refactor: duration validation handles midnight

* fix: prevent stale event loader

* refactor: consider next event in timer invalidation

* refactor: reload changes on changed roll target

* refactor: timer load accounts for midnight

* fix: issues with midnight on roll

* refactor: prevent stale secondary event
2023-08-12 10:42:43 +02:00
Carlos Valente 8c9cb908cf chore: improve demo file (#482) 2023-08-12 10:00:30 +02:00
Carlos Valente 839cee18f3 feat: add french translations (#483) 2023-08-12 10:00:14 +02:00
Olayinka Zadat O b83ebf53a9 Adding Alias as a Parameter (#471)
* redirect on alias to a new page

* move alias to a HOC wrapper
2023-08-11 14:53:36 +02:00
Carlos Valente 1b8392bba4 dx: improve local build command (#480) 2023-08-11 10:55:24 +02:00
Carlos Valente af1241bec7 feat: event description (#481)
* feat: enable configuration of <Info> panel

* feat: add description field to event data
2023-08-11 10:55:02 +02:00
Carlos Valente c361ff4b3f refactor: handle undefined (#479) 2023-08-11 10:54:24 +02:00
Cody Wilson b579a4c57e dist-mac: create separate dists for aarch64, x64 (#470)
* dist-mac: create separate dists for aarch64, x64
2023-08-11 10:51:00 +02:00
145 changed files with 3848 additions and 2302 deletions
+3 -1
View File
@@ -35,7 +35,9 @@ jobs:
- name: Release - name: Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
files: './apps/electron/dist/ontime-macOS.dmg' files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+26 -5
View File
@@ -1,38 +1,48 @@
# GETTING STARTED # GETTING STARTED
Ontime consists of 3 distinct parts Ontime consists of 3 distinct parts
- __client__: A React app for Ontime's UI and web clients - __client__: A React app for Ontime's UI and web clients
- __electron__: An electron app which facilitates the cross-platform distribution of Ontime - __electron__: An electron app which facilitates the cross-platform distribution of Ontime
- __server__: A node application which handles the domains services and integrations - __server__: A node application which handles the domains services and integrations
The steps below will assume you have locally installed the necessary dependencies. The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup Other dependencies will be installed as part of the setup
- __node__ (>=16.16) - __node__ (>=16.16)
- __pnpm__ (>=7) - __pnpm__ (>=7)
- __docker__ (only necessary to run and build docker images) - __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT ## LOCAL DEVELOPMENT
The electron app is only necessary to distribute an installable version of the app and is not required for local development. The electron app is only necessary to distribute an installable version of the app and is not required for local
development.
Locally, we would need to run both the React client and the node.js server in development mode Locally, we would need to run both the React client and the node.js server in development mode
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Run dev mode__ by running `turbo dev` - __Run dev mode__ by running `turbo dev`
### Debugging backend ### Debugging backend
To debug backend code in Node.js: To debug backend code in Node.js:
- Open two separate terminals and navigate to the `apps/client` and `apps/server` directories. - Open two separate terminals and navigate to the `apps/client` and `apps/server` directories.
- In each terminal, run the command `pnpm dev` to start the development servers for both the client and server applications. - In each terminal, run the command `pnpm dev` to start the development servers for both the client and server
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by running `pnpm dev:inspect`. applications.
- If you need to set breakpoints and inspect the code execution, enable Node.js inspect mode by
running `pnpm dev:inspect`.
## TESTING ## TESTING
Generally we have 2 types of tests. Generally we have 2 types of tests.
- Unit tests for functions that contain business logic - Unit tests for functions that contain business logic
- End-to-end tests for core features - End-to-end tests for core features
### Unit tests ### Unit tests
Unit tests are contained in mostly all the apps and packages (client, server and utils) Unit tests are contained in mostly all the apps and packages (client, server and utils)
You can run unit tests by running turbo `turbo test:pipeline` from the project root. You can run unit tests by running turbo `turbo test:pipeline` from the project root.
@@ -41,12 +51,20 @@ This will run all tests and close test runner.
Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode Alternatively you can navigate to an app or project and run `pnpm test` to run those tests in watch mode
### E2E tests ### E2E tests
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the webserver to test against
E2E tests are in a separate package. On running, [playwright](https://playwright.dev/) will spin up an instance of the
webserver to test against
These tests also run against a separate version of the DB (test-db) These tests also run against a separate version of the DB (test-db)
You can run playwright tests from project root with `pnpm e2e` You can run playwright tests from project root with `pnpm e2e`
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually start the webserver with `pnpm dev:server` When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
start the webserver with `pnpm dev:server`
Some other useful commands
- `pnpm e2e --ui` open playwright UI
- `pnpm e2e --headed` run tests with a visible browser window
## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux) ## CREATE AN INSTALLABLE FILE (Windows | MacOS | Linux)
@@ -54,6 +72,7 @@ Ontime uses Electron to distribute the application.
You can generate a distribution for your OS by running the following steps. You can generate a distribution for your OS by running the following steps.
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Build the UI and server__ by running `turbo build:local` - __Build the UI and server__ by running `turbo build:local`
- __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux` - __Create the package__ by running `turbo dist-win`, `turbo dist-mac` or `turbo dist-linux`
@@ -66,10 +85,12 @@ Ontime provides a docker-compose file to aid with building and running docker im
While it should allow for a generic setup, it might need to be modified to fit your infrastructure. While it should allow for a generic setup, it might need to be modified to fit your infrastructure.
From the project root, run the following commands From the project root, run the following commands
- __Install the project dependencies__ by running `pnpm i` - __Install the project dependencies__ by running `pnpm i`
- __Build docker image from__ by running `docker build -t getontime/ontime` - __Build docker image from__ by running `docker build -t getontime/ontime`
- __Run docker image from compose__ by running `docker-compose up -d` - __Run docker image from compose__ by running `docker-compose up -d`
Other useful commands Other useful commands
- __List running processes__ by running `docker ps` - __List running processes__ by running `docker ps`
- __Kill running process__ by running `docker kill <process-id>` - __Kill running process__ by running `docker kill <process-id>`
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "2.0.9", "version": "2.7.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@chakra-ui/react": "^2.7.0", "@chakra-ui/react": "^2.7.0",
+12 -12
View File
@@ -4,9 +4,9 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query'; import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext'; import { AppContextProvider } from './common/context/AppContext';
import { ContextMenuProvider } from './common/context/ContextMenuContext';
import useElectronEvent from './common/hooks/useElectronEvent'; import useElectronEvent from './common/hooks/useElectronEvent';
import { ontimeQueryClient } from './common/queryClient'; import { ontimeQueryClient } from './common/queryClient';
import { socketClientName } from './common/stores/connectionName'; import { socketClientName } from './common/stores/connectionName';
@@ -52,18 +52,18 @@ function App() {
<ChakraProvider resetCSS theme={theme}> <ChakraProvider resetCSS theme={theme}>
<QueryClientProvider client={ontimeQueryClient}> <QueryClientProvider client={ontimeQueryClient}>
<AppContextProvider> <AppContextProvider>
<ContextMenuProvider> <BrowserRouter>
<BrowserRouter> <div className='App'>
<div className='App'> <ErrorBoundary>
<ErrorBoundary> <TranslationProvider>
<TranslationProvider> <ContextMenu>
<AppRouter /> <AppRouter />
</TranslationProvider> </ContextMenu>
</ErrorBoundary> </TranslationProvider>
<ReactQueryDevtools initialIsOpen={false} /> </ErrorBoundary>
</div> <ReactQueryDevtools initialIsOpen={false} />
</BrowserRouter> </div>
</ContextMenuProvider> </BrowserRouter>
</AppContextProvider> </AppContextProvider>
</QueryClientProvider> </QueryClientProvider>
</ChakraProvider> </ChakraProvider>
+11 -27
View File
@@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect } from 'react'; import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import useAliases from './common/hooks-query/useAliases'; import withAlias from './features/AliasWrapper';
import withData from './features/viewers/ViewWrapper'; import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor')); const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
@@ -17,14 +17,14 @@ const Public = lazy(() => import('./features/viewers/public/Public'));
const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper')); const Lower = lazy(() => import('./features/viewers/lower-thirds/LowerWrapper'));
const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock')); const StudioClock = lazy(() => import('./features/viewers/studio/StudioClock'));
const STimer = withData(TimerView); const STimer = withAlias(withData(TimerView));
const SMinimalTimer = withData(MinimalTimerView); const SMinimalTimer = withAlias(withData(MinimalTimerView));
const SClock = withData(ClockView); const SClock = withAlias(withData(ClockView));
const SCountdown = withData(Countdown); const SCountdown = withAlias(withData(Countdown));
const SBackstage = withData(Backstage); const SBackstage = withAlias(withData(Backstage));
const SPublic = withData(Public); const SPublic = withAlias(withData(Public));
const SLowerThird = withData(Lower); const SLowerThird = withAlias(withData(Lower));
const SStudio = withData(StudioClock); const SStudio = withAlias(withData(StudioClock));
const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper')); const EditorFeatureWrapper = lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = lazy(() => import('./features/rundown/RundownExport')); const RundownPanel = lazy(() => import('./features/rundown/RundownExport'));
@@ -33,22 +33,6 @@ const MessageControl = lazy(() => import('./features/control/message/MessageCont
const Info = lazy(() => import('./features/info/InfoExport')); const Info = lazy(() => import('./features/info/InfoExport'));
export default function AppRouter() { export default function AppRouter() {
const { data } = useAliases();
const location = useLocation();
const navigate = useNavigate();
// navigate if is alias route
useEffect(() => {
if (!data) return;
for (const d of data) {
if (`/${d.alias}` === location.pathname && d.enabled) {
navigate(`/${d.pathAndParams}`);
break;
}
}
}, [data, location, navigate]);
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Routes> <Routes>
+2 -3
View File
@@ -1,5 +1,3 @@
export const STATIC_PORT = 4001;
// REST stuff // REST stuff
export const EVENT_DATA = ['eventdata']; export const EVENT_DATA = ['eventdata'];
export const ALIASES = ['aliases']; export const ALIASES = ['aliases'];
@@ -15,8 +13,9 @@ export const RUNTIME = ['runtimeStore'];
const location = window.location; const location = window.location;
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
const STATIC_PORT = 4001;
export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port; export const serverPort = import.meta.env.DEV ? STATIC_PORT : location.port;
export const serverURL = import.meta.env.DEV ? `http://${location.hostname}:${serverPort}` : location.origin; export const serverURL = `${location.protocol}//${location.hostname}:${serverPort}`;
export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`; export const websocketUrl = `${socketProtocol}://${location.hostname}:${serverPort}/ws`;
export const eventURL = `${serverURL}/eventdata`; export const eventURL = `${serverURL}/eventdata`;
+8 -3
View File
@@ -6,9 +6,14 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) { export function logAxiosError(prepend: string, error: unknown) {
const message = axios.isAxiosError(error) let message;
? `${prepend}, ${(error as AxiosError).response?.statusText}: ${(error as AxiosError).response?.data}` if (axios.isAxiosError(error)) {
: `${prepend}: ${error}`; const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
} else {
message = `${prepend}: ${error}`;
}
addLog({ addLog({
id: generateId(), id: generateId(),
+13
View File
@@ -50,6 +50,19 @@ export async function requestApplyDelay(eventId: string) {
return axios.patch(`${rundownURL}/applydelay/${eventId}`); return axios.patch(`${rundownURL}/applydelay/${eventId}`);
} }
export type SwapEntry = {
from: string;
to: string;
};
/**
* @description HTTP request to swap two events
* @return {Promise}
*/
export async function requestEventSwap(data: SwapEntry) {
return axios.patch(`${rundownURL}/swap`, data);
}
/** /**
* @description HTTP request to delete given event * @description HTTP request to delete given event
* @return {Promise} * @return {Promise}
+1 -9
View File
@@ -1,13 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { import { Alias, EventData, OSCSettings, OscSubscription, Settings, UserFields, ViewSettings } from 'ontime-types';
Alias,
EventData,
OSCSettings,
OscSubscription,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { apiRepoLatest } from '../../externals'; import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info'; import { InfoType } from '../models/Info';
@@ -0,0 +1,84 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { Fragment, ReactElement } from 'react';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import { create } from 'zustand';
import style from './ContextMenu.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
withDivider?: boolean;
isDisabled?: boolean;
};
type ContextMenuStore = {
coords: ContextMenuCoords;
options: Option[];
isOpen: boolean;
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
setIsOpen: (newIsOpen: boolean) => void;
};
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
coords: { x: 0, y: 0 },
options: [],
isOpen: false,
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
}));
interface ContextMenuProps {
// ReactElement type required due to early `return` (line 51) returning {children}
children: ReactElement;
}
export const ContextMenu = ({ children }: ContextMenuProps) => {
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => {
return setIsOpen(false);
};
if (!isOpen) {
return children;
}
return (
<>
{children}
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick, withDivider, isDisabled }, i) => (
<Fragment key={label}>
{withDivider && <MenuDivider />}
<MenuItem key={i} icon={<Icon />} onClick={onClick} isDisabled={isDisabled}>
{label}
</MenuItem>
</Fragment>
))}
</MenuList>
</Menu>
</>
);
};
@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles'; import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import Swatch from './Swatch'; import Swatch from './Swatch';
@@ -37,7 +37,17 @@ function ButtonTooltip(name: TimeEntryField, warning?: string) {
} }
export default function TimeInput(props: TimeInputProps) { export default function TimeInput(props: TimeInputProps) {
const { id, name, submitHandler, time = 0, delay = 0, placeholder, validationHandler, previousEnd = 0, warning } = props; const {
id,
name,
submitHandler,
time = 0,
delay = 0,
placeholder,
validationHandler,
previousEnd = 0,
warning,
} = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
@@ -191,7 +201,7 @@ export default function TimeInput(props: TimeInputProps) {
<Input <Input
ref={inputRef} ref={inputRef}
id={id} id={id}
data-testid='time-input' data-testid={`time-input-${name}`}
className={style.inputField} className={style.inputField}
type='text' type='text'
placeholder={placeholder} placeholder={placeholder}
@@ -6,10 +6,11 @@ import ScheduleItem from './ScheduleItem';
import './Schedule.scss'; import './Schedule.scss';
interface ScheduleProps { interface ScheduleProps {
isProduction?: boolean;
className?: string; className?: string;
} }
export default function Schedule({ className }: ScheduleProps) { export default function Schedule({ isProduction, className }: ScheduleProps) {
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule(); const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
if (paginatedEvents?.length < 1) { if (paginatedEvents?.length < 1) {
@@ -30,12 +31,16 @@ export default function Schedule({ className }: ScheduleProps) {
selectedState = 'future'; selectedState = 'future';
} }
} }
const timeStart = isProduction ? event.timeStart + (event?.delay ?? 0) : event.timeStart;
const timeEnd = isProduction ? event.timeEnd + (event?.delay ?? 0) : event.timeEnd;
return ( return (
<ScheduleItem <ScheduleItem
key={event.id} key={event.id}
selected={selectedState} selected={selectedState}
timeStart={event.timeStart} timeStart={timeStart}
timeEnd={event.timeEnd} timeEnd={timeEnd}
title={event.title} title={event.title}
colour={isBackstage ? event.colour : ''} colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic} backstageEvent={!event.isPublic}
@@ -1,76 +0,0 @@
// logic (with some modifications) culled from:
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
import { createContext, ReactNode, useState } from 'react';
import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { IconType } from '@react-icons/all-files';
import style from './ContextMenuContext.module.scss';
type ContextMenuCoords = {
x: number;
y: number;
};
type ContextMenuContextType = {
createContextMenu: (options: Option[], menuCoordinates: ContextMenuCoords) => void;
};
export const ContextMenuContext = createContext<ContextMenuContextType | null>(null);
export type Option = {
label: string;
icon: IconType;
onClick: () => void;
};
interface ContextMenuProviderProps {
children: ReactNode;
}
export const ContextMenuProvider = ({ children }: ContextMenuProviderProps) => {
const [isOpen, setIsOpen] = useState(false);
const [coords, setCoords] = useState<ContextMenuCoords>({ x: 0, y: 0 });
const [options, setOptions] = useState<Option[]>([]);
const onClose = () => {
return setIsOpen(false);
};
const createContextMenu = (options: Option[], menuCoords: ContextMenuCoords) => {
setCoords(menuCoords);
setOptions(options);
setIsOpen(true);
};
return (
<ContextMenuContext.Provider value={{ createContextMenu }}>
{children}
{isOpen && (
<>
<div className={style.contextMenuBackdrop} />
<Menu isOpen gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<MenuButton
className={style.contextMenuButton}
aria-hidden
w={1}
h={1}
style={{
left: coords.x,
top: coords.y,
}}
/>
<MenuList>
{options.map(({ label, icon: Icon, onClick }, i) => (
<MenuItem key={i} icon={<Icon />} onClick={onClick}>
{label}
</MenuItem>
))}
</MenuList>
</Menu>
</>
)}
</ContextMenuContext.Provider>
);
};
@@ -1,22 +1,16 @@
import { MouseEvent, useContext } from 'react'; import { MouseEvent } from 'react';
import { ContextMenuContext, Option } from '../context/ContextMenuContext'; import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => { export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
const contextMenuContext = useContext(ContextMenuContext); const { setContextMenu } = useContextMenuStore();
if (contextMenuContext === null) {
throw new Error('useContextMenu should be wrapped by ContextMenuProvider');
}
const { createContextMenu } = contextMenuContext;
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => { const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
// prevent browser default context menu from showing up // prevent browser default context menu from showing up
contextMenuEvent.preventDefault(); contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent; const { pageX, pageY } = contextMenuEvent;
return createContextMenu(options, { x: pageX, y: pageY }); return setContextMenu({ x: pageX, y: pageY }, options);
}; };
return [localCreateContextMenu]; return [localCreateContextMenu];
+88 -14
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants'; import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils'; import { logAxiosError } from '../api/apiUtils';
@@ -9,18 +10,20 @@ import {
requestApplyDelay, requestApplyDelay,
requestDelete, requestDelete,
requestDeleteAll, requestDeleteAll,
requestEventSwap,
requestPostEvent, requestPostEvent,
requestPutEvent, requestPutEvent,
requestReorderEvent, requestReorderEvent,
SwapEntry,
} from '../api/eventsApi'; } from '../api/eventsApi';
import { useLocalEvent } from '../stores/localEvent'; import { useEditorSettings } from '../stores/editorSettings';
/** /**
* @description Set of utilities for events * @description Set of utilities for events
*/ */
export const useEventAction = () => { export const useEventAction = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
@@ -28,9 +31,10 @@ export const useEventAction = () => {
* Calls mutation to add new event * Calls mutation to add new event
* @private * @private
*/ */
const _addEventMutation = useMutation(requestPostEvent, { const _addEventMutation = useMutation({
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
mutationFn: requestPostEvent,
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE); queryClient.invalidateQueries(RUNDOWN_TABLE);
}, },
@@ -55,7 +59,7 @@ export const useEventAction = () => {
const newEvent: Partial<OntimeRundownEntry> = { ...event }; const newEvent: Partial<OntimeRundownEntry> = { ...event };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (newEvent.type === SupportedEvent.Event) { if (isOntimeEvent(newEvent)) {
const applicationOptions = { const applicationOptions = {
defaultPublic: options?.defaultPublic ?? defaultPublic, defaultPublic: options?.defaultPublic ?? defaultPublic,
startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd, startTimeIsLastEnd: options?.startTimeIsLastEnd ?? startTimeIsLastEnd,
@@ -63,16 +67,20 @@ export const useEventAction = () => {
after: options?.after, after: options?.after,
}; };
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
}
// hard coding duration value to be as expected for now // hard coding duration value to be as expected for now
// this until timeOptions gets implemented // this until timeOptions gets implemented
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') { if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0; newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
} }
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) { if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown; const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId); const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') { if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd; newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd; newEvent.timeEnd = previousEvent.timeEnd;
} }
@@ -102,7 +110,8 @@ export const useEventAction = () => {
* Calls mutation to update existing event * Calls mutation to update existing event
* @private * @private
*/ */
const _updateEventMutation = useMutation(requestPutEvent, { const _updateEventMutation = useMutation({
mutationFn: requestPutEvent,
// we optimistically update here // we optimistically update here
onMutate: async (newEvent) => { onMutate: async (newEvent) => {
// cancel ongoing queries // cancel ongoing queries
@@ -117,7 +126,6 @@ export const useEventAction = () => {
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousEvent, newEvent }; return { previousEvent, newEvent };
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent); queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
@@ -148,7 +156,8 @@ export const useEventAction = () => {
* Calls mutation to delete an event * Calls mutation to delete an event
* @private * @private
*/ */
const _deleteEventMutation = useMutation(requestDelete, { const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// we optimistically update here // we optimistically update here
onMutate: async (eventId) => { onMutate: async (eventId) => {
// cancel ongoing queries // cancel ongoing queries
@@ -196,7 +205,8 @@ export const useEventAction = () => {
* Calls mutation to delete all events * Calls mutation to delete all events
* @private * @private
*/ */
const _deleteAllEventsMutation = useMutation(requestDeleteAll, { const _deleteAllEventsMutation = useMutation({
mutationFn: requestDeleteAll,
// we optimistically update here // we optimistically update here
onMutate: async () => { onMutate: async () => {
// cancel ongoing queries // cancel ongoing queries
@@ -239,7 +249,8 @@ export const useEventAction = () => {
* Calls mutation to apply a delay * Calls mutation to apply a delay
* @private * @private
*/ */
const _applyDelayMutation = useMutation(requestApplyDelay, { const _applyDelayMutation = useMutation({
mutationFn: requestApplyDelay,
// Mutation finished, failed or successful // Mutation finished, failed or successful
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE); queryClient.invalidateQueries(RUNDOWN_TABLE);
@@ -265,7 +276,8 @@ export const useEventAction = () => {
* Calls mutation to reorder an event * Calls mutation to reorder an event
* @private * @private
*/ */
const _reorderEventMutation = useMutation(requestReorderEvent, { const _reorderEventMutation = useMutation({
mutationFn: requestReorderEvent,
// we optimistically update here // we optimistically update here
onMutate: async (data) => { onMutate: async (data) => {
// cancel ongoing queries // cancel ongoing queries
@@ -316,5 +328,67 @@ export const useEventAction = () => {
[_reorderEventMutation], [_reorderEventMutation],
); );
return { addEvent, updateEvent, deleteEvent, deleteAllEvents, applyDelay, reorderEvent }; /**
* Calls mutation to swap events
* @private
*/
const _swapEvents = useMutation({
mutationFn: requestEventSwap,
// we optimistically update here
onMutate: async ({ from, to }) => {
// cancel ongoing queries
await queryClient.cancelQueries(RUNDOWN_TABLE, { exact: true });
// Snapshot the previous value
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const fromEventIndex = rundown.findIndex((event) => event.id === from);
const toEventIndex = rundown.findIndex((event) => event.id === to);
const previousEvents = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
// optimistically update object
queryClient.setQueryData(RUNDOWN_TABLE, previousEvents);
// Return a context with the previous events
return { previousEvents };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN_TABLE, context?.previousEvents);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries(RUNDOWN_TABLE);
},
networkMode: 'always',
});
/**
* Swaps the schedule of two events
*/
const swapEvents = useCallback(
async ({ from, to }: SwapEntry) => {
// TODO: before calling `/swapEvents`,
// we should determine the events are of type `OntimeEvent`
try {
await _swapEvents.mutateAsync({ from, to });
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
},
[_swapEvents],
);
return {
addEvent,
updateEvent,
deleteEvent,
deleteAllEvents,
applyDelay,
reorderEvent,
swapEvents,
};
}; };
@@ -2,6 +2,7 @@ import { EventData } from 'ontime-types';
export const eventDataPlaceholder: EventData = { export const eventDataPlaceholder: EventData = {
title: '', title: '',
description: '',
publicUrl: '', publicUrl: '',
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EditorSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
showNif: boolean;
};
type EditorSettingsStore = {
eventSettings: EditorSettings;
setLocalEventSettings: (newState: EditorSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
setShowNif: (showNif: boolean) => void;
};
enum EditorSettingsKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
ShowNif = 'ontime-show-nif',
}
export const useEditorSettings = create<EditorSettingsStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.ShowQuickEntry, true),
showNif: booleanFromLocalStorage(EditorSettingsKeys.ShowNif, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
setShowNif: (showNif) =>
set((state) => {
localStorage.setItem(EditorSettingsKeys.ShowNif, String(showNif));
return { eventSettings: { ...state.eventSettings, showNif } };
}),
}));
@@ -1,57 +0,0 @@
import { create } from 'zustand';
import { booleanFromLocalStorage } from '../utils/localStorage';
type EventSettings = {
showQuickEntry: boolean;
startTimeIsLastEnd: boolean;
defaultPublic: boolean;
};
type LocalEventStore = {
eventSettings: EventSettings;
setLocalEventSettings: (newState: EventSettings) => void;
setShowQuickEntry: (showQuickEntry: boolean) => void;
setStartTimeIsLastEnd: (startTimeIsLastEnd: boolean) => void;
setDefaultPublic: (defaultPublic: boolean) => void;
};
enum LocalEventKeys {
ShowQuickEntry = 'ontime-show-quick-entry',
StartTimeIsLastEnd = 'ontime-start-is-last-end',
DefaultPublic = 'ontime-default-public',
}
export const useLocalEvent = create<LocalEventStore>((set) => ({
eventSettings: {
showQuickEntry: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, false),
startTimeIsLastEnd: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
defaultPublic: booleanFromLocalStorage(LocalEventKeys.ShowQuickEntry, true),
},
setLocalEventSettings: (value) =>
set(() => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(value.showQuickEntry));
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(value.startTimeIsLastEnd));
localStorage.setItem(LocalEventKeys.DefaultPublic, String(value.defaultPublic));
return { eventSettings: value };
}),
setShowQuickEntry: (showQuickEntry) =>
set((state) => {
localStorage.setItem(LocalEventKeys.ShowQuickEntry, String(showQuickEntry));
return { eventSettings: { ...state.eventSettings, showQuickEntry } };
}),
setStartTimeIsLastEnd: (startTimeIsLastEnd) =>
set((state) => {
localStorage.setItem(LocalEventKeys.StartTimeIsLastEnd, String(startTimeIsLastEnd));
return { eventSettings: { ...state.eventSettings, startTimeIsLastEnd } };
}),
setDefaultPublic: (defaultPublic) =>
set((state) => {
localStorage.setItem(LocalEventKeys.DefaultPublic, String(defaultPublic));
return { eventSettings: { ...state.eventSettings, defaultPublic } };
}),
}));
@@ -1,4 +1,5 @@
import { validateAlias } from '../aliases'; import { resolvePath } from 'react-router-dom';
import { validateAlias, generateURLFromAlias, getAliasRoute } from '../aliases';
describe('An alias fails if incorrect', () => { describe('An alias fails if incorrect', () => {
const testsToFail = [ const testsToFail = [
@@ -23,3 +24,79 @@ describe('An alias fails if incorrect', () => {
}), }),
); );
}); });
describe('generateURLFromAlias and getAliasRoute function', () => {
test('generate the expected url from an alias', () => {
const testData = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(generateURLFromAlias(testData[0])).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL is just the alias', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the alias
const location = resolvePath(aliases[0].alias);
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(getAliasRoute(location, aliases, null)).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', aliases[0].alias); //
// update current alias with extra param
aliases[0].pathAndParams += '&eventId=674';
const expected = [
{
url: '/timer?user=guest&eventId=674&alias=demopage',
},
];
expect(getAliasRoute(location, aliases, urlSearchParams)).toStrictEqual(expected[0].url);
});
test('generate no url to redirect to when the current URL the same url', () => {
const aliases = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(aliases[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', aliases[0].alias); //
expect(getAliasRoute(location, aliases, urlSearchParams)).toBeNull();
});
});
@@ -1,536 +0,0 @@
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
describe('getEventsWithDelay function', () => {
test('with positive delays', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
type: 'block',
id: 'e6a1',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
test('with negative delays', () => {
const testData = [
{
duration: -20,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 100,
timeEnd: 200,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 80,
timeEnd: 180,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('getEventsWithDelay edge cases', () => {
it('ensures time start cannot be below 0', () => {
const testData = [
{
duration: -200,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 0,
timeEnd: 0,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('does not modify original array', () => {
const testData = [
{
duration: 10,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
const expectedSafe = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected);
});
it('given an empty array', () => {
const emptyArray = {
test: [],
expect: [],
};
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
});
it('given an undefined object', () => {
const withUndefined = {
test: undefined,
expect: [],
};
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
});
it('given a corrupted event object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('given a corrupted delay object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,55 @@
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
cue: 'cue',
subtitle: 'subtitle',
presenter: 'presenter',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
endAction: EndAction.None,
isPublic: false,
skip: false,
colour: 'F00',
revision: 10,
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
} as OntimeEvent;
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.subtitle).toBe(original.subtitle);
expect(cloned.presenter).toBe(original.presenter);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
expect(cloned.timeStart).toBe(original.timeStart);
expect(cloned.timeEnd).toBe(original.timeEnd);
expect(cloned.duration).toBe(original.duration);
expect(cloned.isPublic).toBe(original.isPublic);
expect(cloned.skip).toBe(original.skip);
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
});
});
@@ -1,56 +0,0 @@
import getDelayTo from '../getDelayTo';
describe('getDelayTo function', () => {
it('handles list with delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 0);
expect(notDelayed).toBe(0);
const delayedEvent = getDelayTo(events, 2);
expect(delayedEvent).toBe(delayDuration);
});
it('handles list without delays', () => {
const events = [{ type: 'event' }, { type: 'event' }];
const notDelayed = getDelayTo(events, 1);
expect(notDelayed).toBe(0);
});
it('handles list with multiple delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const doubleDelay = getDelayTo(events, 4);
expect(doubleDelay).toBe(delayDuration * 2);
});
it('handles list with blocks', () => {
const events = [
{ type: 'event' },
{ type: 'delay', duration: 100 },
{ type: 'event' },
{ type: 'block' },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 4);
expect(notDelayed).toBe(0);
});
it('handles index greater than list', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, 3);
expect(notDelayed).toBe(0);
});
it('handles negative index (not found)', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, -1);
expect(notDelayed).toBe(0);
});
});
@@ -1,27 +0,0 @@
import { calculateDuration, DAY_TO_MS } from '../timesManager';
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('calculates duration correctly', () => {
const testStart = 1;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
describe('Handles edge cases', () => {
it('when start is after end', () => {
const testStart = 3;
const testEnd = 2;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd + DAY_TO_MS - testStart);
});
it('when both are equal', () => {
const testStart = 1;
const testEnd = 1;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
});
});
+49 -1
View File
@@ -1,10 +1,13 @@
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Alias } from 'ontime-types';
/** /**
* Validates an alias against defined parameters * Validates an alias against defined parameters
* @param {string} alias * @param {string} alias
* @returns {{message: string, status: boolean}} * @returns {{message: string, status: boolean}}
*/ */
export const validateAlias = (alias: string) => { export const validateAlias = (alias: string) => {
const valid = { status: true, message: 'ok' }; const valid = { status: true, message: 'ok' };
if (alias === '' || alias == null) { if (alias === '' || alias == null) {
@@ -27,3 +30,48 @@ export const validateAlias = (alias: string) => {
return valid; return valid;
}; };
/**
* Gets the URL to send an alias to
* @param location
* @param data
* @param searchParams
*/
export const getAliasRoute = (location: Location, data: Alias[], searchParams: URLSearchParams) => {
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundAlias = data.filter((d) => d.alias === currentURL && d.enabled)[0];
if (foundAlias) {
return generateURLFromAlias(foundAlias);
}
const aliasOnPage = searchParams.get('alias');
for (const d of data) {
if (aliasOnPage) {
// if the alias fits the alias on this page, but the URL is diferent, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
const newAliasPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newAliasPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newAliasPath.pathname}?${urlParams}`;
}
}
}
}
return null;
};
/**
* Generate URL from an alias
* @param aliasData
*/
export const generateURLFromAlias = (aliasData: Alias) => {
const newAliasPath = resolvePath(aliasData.pathAndParams);
const urlParams = new URLSearchParams(newAliasPath.search);
urlParams.set('alias', aliasData.alias);
return `${newAliasPath.pathname}?${urlParams}`;
};
+13 -155
View File
@@ -1,174 +1,32 @@
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { formatTime } from './time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (rundown == null) return [];
const delayedEvents: OntimeEvent[] = [];
// Add running delay
let delay = 0;
for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') {
delay += event.duration;
}
} else if (event.type === SupportedEvent.Event) {
const delayedEvent = { ...event };
if (delay !== 0) {
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
}
delayedEvents.push(delayedEvent);
}
}
return delayedEvents;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
const givenEvents = [...rundown];
// format list
const formattedEvents = [];
for (const event of givenEvents) {
const start = formatTime(event.timeStart);
const end = formatTime(event.timeEnd);
formattedEvents.push({
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
});
}
return formattedEvents;
};
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
/** /**
* @description Creates a safe duplicate of an event * @description Creates a safe duplicate of an event
* @param {object} event * @param {OntimeEvent} event
* @return {object} clean event * @param {string} [after]
* @return {OntimeEvent} clean event
*/ */
type ClonedEvent = OntimeEvent | { after?: string }; type ClonedEvent = Omit<
OntimeEvent,
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => { export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return { return {
type: SupportedEvent.Event, type: SupportedEvent.Event,
title: event.title, title: event.title,
cue: event.cue,
subtitle: event.subtitle, subtitle: event.subtitle,
presenter: event.presenter, presenter: event.presenter,
note: event.note, note: event.note,
timeStart: event.timeStart, timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd, timeEnd: event.timeEnd,
timerType: event.timerType,
endAction: event.endAction,
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
after: after, after: after,
revision: 0,
}; };
}; };
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
@@ -1,25 +0,0 @@
/**
* @description calculates delay to a given event
* @param {array} events
* @param {number} eventIndex
* @return {number} - delay value of given event
*/
export default function getDelayTo(events, eventIndex) {
let delay = 0;
let index = 0;
if (eventIndex >= 0) {
for (const event of events) {
if (eventIndex === index) {
return delay;
}
if (event.type === 'delay') {
delay += event.duration;
} else if (event.type === 'block') {
delay = 0;
}
index++;
}
}
return 0;
}
@@ -10,15 +10,8 @@ export const mts = 1000;
*/ */
export const mtm = 1000 * 60; export const mtm = 1000 * 60;
/** /**
* millis to hours * millis to hours
* @type {number} * @type {number}
*/ */
export const mth = 1000 * 60 * 60; export const mth = 1000 * 60 * 60;
/**
* milliseconds in a day
* @type {number}
*/
export const DAY_TO_MS = 86400000;
@@ -1,16 +1,5 @@
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride'; export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
/**
* @description Milliseconds in a day
*/
export const DAY_TO_MS = 86400000;
/**
* @description calculates duration from given values
*/
export const calculateDuration = (start: number, end: number): number =>
start > end ? end + DAY_TO_MS - start : end - start;
/** /**
* @description Checks which field the value relates to * @description Checks which field the value relates to
*/ */
+29
View File
@@ -0,0 +1,29 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
const withAlias = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useAliases();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getAliasRoute(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
}
}, [data, searchParams, navigate, location]);
return <Component {...props} />;
};
};
export default withAlias;
@@ -12,7 +12,7 @@ import {
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../common/hooks/useLocalStorage'; import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig'; import { millisToDelayString } from '../../common/utils/dateConfig';
@@ -196,15 +196,14 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
<tbody> <tbody>
{table.getRowModel().rows.map((row) => { {table.getRowModel().rows.map((row) => {
const entryType = row.original.type as SupportedEvent;
const key = row.original.id; const key = row.original.id;
const isSelected = selectedId === key; const isSelected = selectedId === key;
if (isSelected) { if (isSelected) {
isPast = false; isPast = false;
} }
if (entryType === SupportedEvent.Block) { if (isOntimeBlock(row.original)) {
const title = (row.original as OntimeBlock).title; const title = row.original.title;
return ( return (
<tr key={key} className={style.blockRow}> <tr key={key} className={style.blockRow}>
@@ -212,8 +211,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</tr> </tr>
); );
} }
if (entryType === SupportedEvent.Delay) { if (isOntimeDelay(row.original)) {
const delayVal = (row.original as OntimeDelay).duration; const delayVal = row.original.duration;
if (!showDelayBlock || delayVal === 0) { if (!showDelayBlock || delayVal === 0) {
return null; return null;
@@ -226,7 +225,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
</tr> </tr>
); );
} }
if (entryType === SupportedEvent.Event) { if (isOntimeEvent(row.original)) {
eventIndex++; eventIndex++;
const isSelected = key === selectedId; const isSelected = key === selectedId;
if (isSelected) { if (isSelected) {
@@ -238,9 +237,9 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
} }
const bgFallback = 'transparent'; const bgFallback = 'transparent';
const bgColour = (row.original as OntimeEvent).colour || bgFallback; const bgColour = row.original.colour || bgFallback;
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour); const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
const isSkipped = (row.original as OntimeEvent).skip; const isSkipped = row.original.skip;
let rowBgColour: string | undefined; let rowBgColour: string | undefined;
if (row.original.id === selectedId) { if (row.original.id === selectedId) {
@@ -77,6 +77,13 @@ function MakeUserField({ getValue, row: { index }, column: { id }, table }: Cell
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] { export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
return [ return [
{
accessorKey: 'cue',
id: 'cue',
header: 'Cue',
cell: (row) => row.getValue(),
size: 75,
},
{ {
accessorKey: 'isPublic', accessorKey: 'isPublic',
id: 'isPublic', id: 'isPublic',
@@ -5,6 +5,7 @@ import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
*/ */
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [ export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic', 'isPublic',
'cue',
'timeStart', 'timeStart',
'timeEnd', 'timeEnd',
'duration', 'duration',
@@ -215,6 +215,11 @@ $playback-width: 26rem;
flex-direction: column; flex-direction: column;
} }
.mainContainer > .rundown {
padding: 1rem 0;
}
.content { .content {
padding-top: 1.5rem; padding-top: 1.5rem;
} }
@@ -6,10 +6,14 @@
gap: max(1rem, 2vh); gap: max(1rem, 2vh);
display: grid; display: grid;
grid-template-areas: grid-template-areas: 'time left right';
'eventInfo eventActions' grid-template-columns: auto 1fr 1fr;
'timeOptions titles'; }
grid-template-columns: auto 1fr;
.timeOptions {
grid-area: time;
display: flex;
gap: 1.5rem;
.timers, .timers,
.timeSettings { .timeSettings {
@@ -19,51 +23,23 @@
} }
} }
.eventInfo { .left,
grid-area: eventInfo; .right {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 0.5rem;
.eventId {
margin-left:$element-spacing;
}
} }
.eventActions { .left {
grid-area: eventActions; grid-area: left;
margin-left: auto; padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
} }
.timeOptions { .right {
grid-area: timeOptions; padding-left: 1rem;
display: flex; grid-area: right;
gap: 1.5rem; border-left: 1px solid $border-color-ondark;
}
.titles {
grid-area: titles;
display: grid;
grid-template-areas: 'left right';
grid-template-columns: 1fr 1fr;
.left,
.right {
display: flex;
flex-direction: column;
gap: 8px;
}
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
} }
@mixin input-label() { @mixin input-label() {
@@ -94,6 +70,12 @@
} }
} }
.eventActions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.spacer { .spacer {
height: 1.25rem; height: 1.25rem;
} }
@@ -104,6 +86,12 @@
gap: 1rem; gap: 1rem;
} }
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.column { .column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1,38 +1,45 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { OntimeEvent } from 'ontime-types'; import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag'; import CopyTag from '../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../common/hooks/useEventAction';
import useRundown from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import { useAppMode } from '../../common/stores/appModeStore'; import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo';
import EventEditorDataLeft from './composite/EventEditorDataLeft';
import EventEditorDataRight from './composite/EventEditorDataRight';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import style from './EventEditor.module.scss'; import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent; export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
export default function EventEditor() { export default function EventEditor() {
const openId = useAppMode((state) => state.editId); const openId = useAppMode((state) => state.editId);
const { data } = useRundown(); const { data } = useRundown();
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
useEffect(() => { useEffect(() => {
if (!data || !openId) { if (!data || !openId) {
setEvent(null);
return; return;
} }
const eventIndex = data.findIndex((event) => event.id === openId); const event = data.find((event) => event.id === openId);
if (eventIndex > -1) { if (event && isOntimeEvent(event)) {
const event = data[eventIndex]; setEvent(event);
if (event.type === 'event') {
setDelay(getDelayTo(data, eventIndex));
setEvent(data[eventIndex] as OntimeEvent);
}
} }
}, [data, event, openId]); }, [data, openId]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
updateEvent({ id: event?.id, [field]: value });
},
[event?.id, updateEvent],
);
if (!event) { if (!event) {
return <span>Loading...</span>; return <span>Loading...</span>;
@@ -40,34 +47,35 @@ export default function EventEditor() {
return ( return (
<div className={style.eventEditor}> <div className={style.eventEditor}>
<div className={style.eventInfo}>
Event ID
<span className={style.eventId}>
<CopyTag label={event.id}>{event.id}</CopyTag>
</span>
</div>
<div className={style.eventActions}>
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
</div>
<EventEditorTimes <EventEditorTimes
eventId={event.id} eventId={event.id}
timeStart={event.timeStart} timeStart={event.timeStart}
timeEnd={event.timeEnd} timeEnd={event.timeEnd}
duration={event.duration} duration={event.duration}
delay={delay} delay={event.delay ?? 0}
isPublic={event.isPublic} isPublic={event.isPublic}
endAction={event.endAction} endAction={event.endAction}
timerType={event.timerType} timerType={event.timerType}
/> />
<EventEditorTitles <EventEditorDataLeft
key={event.id} key={`${event.id}-left`}
eventId={event.id} eventId={event.id}
cue={event.cue}
title={event.title} title={event.title}
presenter={event.presenter} presenter={event.presenter}
subtitle={event.subtitle} subtitle={event.subtitle}
handleSubmit={handleSubmit}
/>
<EventEditorDataRight
key={`${event.id}-right`}
note={event.note} note={event.note}
colour={event.colour} colour={event.colour}
/> handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
</EventEditorDataRight>
</div> </div>
); );
} }
@@ -3,7 +3,7 @@ import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles'; import { TitleActions } from './EventEditorDataLeft';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
@@ -24,7 +24,9 @@ export default function CountedTextArea(props: CountedTextAreaProps) {
return ( return (
<div className={`${style.column} ${style.fullHeight}`}> <div className={`${style.column} ${style.fullHeight}`}>
<div className={style.countedInput}> <div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label> <label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span> <span className={style.charCount}>{`${value.length} characters`}</span>
</div> </div>
<Textarea <Textarea
@@ -1,23 +1,26 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Input } from '@chakra-ui/react'; import { Input, InputProps } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor';
import { TitleActions } from './EventEditorTitles';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
interface CountedTextInputProps { interface CountedTextInputProps extends InputProps {
field: TitleActions; field: EditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
submitHandler: (field: TitleActions, value: string) => void; submitHandler: (field: EditorUpdateFields, value: string) => void;
} }
export default function CountedTextInput(props: CountedTextInputProps) { export default function CountedTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler } = props; const { field, label, initialValue, submitHandler, maxLength } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]); const submitCallback = useCallback(
(newValue: string) => submitHandler(field, sanitiseCue(newValue)),
[field, submitHandler],
);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, { const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
submitOnEnter: true, submitOnEnter: true,
@@ -26,7 +29,9 @@ export default function CountedTextInput(props: CountedTextInputProps) {
return ( return (
<div className={style.column}> <div className={style.column}>
<div className={style.countedInput}> <div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label> <label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span> <span className={style.charCount}>{`${value.length} characters`}</span>
</div> </div>
<Input <Input
@@ -35,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
variant='ontime-filled' variant='ontime-filled'
data-testid='input-textfield' data-testid='input-textfield'
value={value} value={value}
maxLength={maxLength || 50}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@@ -0,0 +1,49 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { type EditorUpdateFields } from '../EventEditor';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorLeftProps {
eventId: string;
cue: string;
title: string;
presenter: string;
subtitle: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
return (
<div className={style.left}>
<div className={style.splitTwo}>
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor='eventId'>
Event ID (read only)
</label>
</div>
<Input
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
</div>
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={handleSubmit} maxLength={10} />
</div>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
);
};
export default memo(EventEditorDataLeft);
@@ -0,0 +1,33 @@
import { memo, PropsWithChildren } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { EditorUpdateFields } from '../EventEditor';
import CountedTextArea from './CountedTextArea';
import style from '../EventEditor.module.scss';
interface EventEditorRightProps {
note: string;
colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataRight = (props: PropsWithChildren<EventEditorRightProps>) => {
const { children, note, colour, handleSubmit } = props;
return (
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
<div className={style.eventActions}>{children}</div>
</div>
);
};
export default memo(EventEditorDataRight);
@@ -1,13 +1,13 @@
import { memo, useState } from 'react'; import { memo, useState } from 'react';
import { Select, Switch } from '@chakra-ui/react'; import { Select, Switch } from '@chakra-ui/react';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types'; import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { calculateDuration, millisToString } from 'ontime-utils';
import TimeInput from '../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../common/utils/timesManager'; import { TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
@@ -130,6 +130,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
> >
<option value={TimerType.CountDown}>Count down</option> <option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option> <option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option> <option value={TimerType.Clock}>Clock</option>
</Select> </Select>
<label className={style.inputLabel}>End Action</label> <label className={style.inputLabel}>End Action</label>
@@ -1,50 +0,0 @@
import { memo } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { useEventAction } from '../../../common/hooks/useEventAction';
import CountedTextArea from './CountedTextArea';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
title: string;
presenter: string;
subtitle: string;
note: string;
colour: string;
}
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, title, presenter, subtitle, note, colour } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TitleActions, value: string) => {
updateEvent({ id: eventId, [field]: value });
};
return (
<div className={style.titles}>
<div className={style.left}>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
</div>
);
};
export default memo(EventEditorTitles);
@@ -19,6 +19,13 @@
} }
} }
.description {
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.labels { .labels {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
display: flex; display: flex;
+7 -3
View File
@@ -1,4 +1,5 @@
import { useInfoPanel } from '../../common/hooks/useSocket'; import { useInfoPanel } from '../../common/hooks/useSocket';
import { useEditorSettings } from '../../common/stores/editorSettings';
import InfoHeader from './info-header/InfoHeader'; import InfoHeader from './info-header/InfoHeader';
import CollapsableInfo from './CollapsableInfo'; import CollapsableInfo from './CollapsableInfo';
@@ -8,6 +9,7 @@ import InfoTitles from './InfoTitles';
export default function Info() { export default function Info() {
const data = useInfoPanel(); const data = useInfoPanel();
const showNif = useEditorSettings((state) => state.eventSettings.showNif);
const titlesNow = { const titlesNow = {
title: data.titles.titleNow || '', title: data.titles.titleNow || '',
@@ -32,9 +34,11 @@ export default function Info() {
return ( return (
<> <>
<InfoHeader selected={selected} /> <InfoHeader selected={selected} />
<CollapsableInfo title='Network Info'> {showNif && (
<InfoNif /> <CollapsableInfo title='Network Info'>
</CollapsableInfo> <InfoNif />
</CollapsableInfo>
)}
<CollapsableInfo title='Playing Now'> <CollapsableInfo title='Playing Now'>
<InfoTitles data={titlesNow} /> <InfoTitles data={titlesNow} />
</CollapsableInfo> </CollapsableInfo>
+2 -1
View File
@@ -1,5 +1,6 @@
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { serverPort } from '../../common/api/apiConstants';
import useInfo from '../../common/hooks-query/useInfo'; import useInfo from '../../common/hooks-query/useInfo';
import { openLink } from '../../common/utils/linkUtils'; import { openLink } from '../../common/utils/linkUtils';
@@ -9,7 +10,7 @@ export default function InfoNif() {
const { data } = useInfo(); const { data } = useInfo();
const handleClick = (address: string) => { const handleClick = (address: string) => {
const baseURL = 'http://__IP__:4001'; const baseURL = `http://__IP__:${serverPort}`;
openLink(baseURL.replace('__IP__', address)); openLink(baseURL.replace('__IP__', address));
}; };
@@ -6,9 +6,12 @@ export default function InfoHeader({ selected }: { selected: string }) {
const { data } = useEventData(); const { data } = useEventData();
return ( return (
<div className={style.panelHeader}> <>
<span className={style.title}>{data?.title || ''}</span> <div className={style.panelHeader}>
<span className={style.selected}>{selected}</span> <span className={style.title}>{data?.title || ''}</span>
</div> <span className={style.selected}>{selected}</span>
</div>
<div className={style.description}>{data?.description || ''}</div>
</>
); );
} }
@@ -1,4 +1,4 @@
.headerButtons { .headerButtons {
text-align: right; text-align: right;
padding-top: 24px; padding: 1.5rem 1rem 0 1rem;
} }
@@ -102,6 +102,18 @@ export default function QuickStart({ onClose, isOpen }: QuickStartProps) {
/> />
</label> </label>
</div> </div>
<div className={styles.entryRow}>
<label className={styles.sectionTitle}>
Event description
<Input
variant='ontime-filled-on-light'
size='sm'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
{...register('description')}
/>
</label>
</div>
<div className={styles.entryRow}> <div className={styles.entryRow}>
<label className={styles.sectionTitle}> <label className={styles.sectionTitle}>
Public Info Public Info
@@ -131,7 +131,7 @@ export default function AliasesForm() {
isDisabled={disableInputs} isDisabled={disableInputs}
/> />
<TooltipActionBtn <TooltipActionBtn
clickHandler={(event) => handleLinks(event, alias.pathAndParams)} clickHandler={(event) => handleLinks(event, alias.alias)}
tooltip='Test alias' tooltip='Test alias'
aria-label='Test alias' aria-label='Test alias'
size='xs' size='xs'
@@ -61,7 +61,7 @@ export default function AppSettingsModal() {
<ModalSplitInput <ModalSplitInput
field='serverPort' field='serverPort'
title='Ontime is available on port' title='Ontime is available on port'
description='Default 4001' description='Default 4001 (needs app restart to change)'
error={errors.serverPort?.message} error={errors.serverPort?.message}
> >
<Input <Input
@@ -69,7 +69,6 @@ export default function AppSettingsModal() {
size='sm' size='sm'
textAlign='right' textAlign='right'
maxLength={5} maxLength={5}
disabled
variant='ontime-filled-on-light' variant='ontime-filled-on-light'
{...register('serverPort', { {...register('serverPort', {
required: { value: true, message: 'Required field' }, required: { value: true, message: 'Required field' },
@@ -118,6 +117,7 @@ export default function AppSettingsModal() {
> >
<Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}> <Select backgroundColor='white' size='sm' width='auto' isDisabled={disableInputs} {...register('language')}>
<option value='en'>English</option> <option value='en'>English</option>
<option value='fr'>French</option>
<option value='de'>German</option> <option value='de'>German</option>
<option value='no'>Norwegian</option> <option value='no'>Norwegian</option>
<option value='pt'>Portuguese</option> <option value='pt'>Portuguese</option>
@@ -1,15 +1,16 @@
import { Switch } from '@chakra-ui/react'; import { Switch } from '@chakra-ui/react';
import { useLocalEvent } from '../../../common/stores/localEvent'; import { useEditorSettings } from '../../../common/stores/editorSettings';
import ModalSplitInput from '../ModalSplitInput'; import ModalSplitInput from '../ModalSplitInput';
import style from './SettingsModal.module.scss'; import style from './SettingsModal.module.scss';
export default function EditorSettings() { export default function EditorSettings() {
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const setShowQuickEntry = useLocalEvent((state) => state.setShowQuickEntry); const setShowQuickEntry = useEditorSettings((state) => state.setShowQuickEntry);
const setStartTimeIsLastEnd = useLocalEvent((state) => state.setStartTimeIsLastEnd); const setStartTimeIsLastEnd = useEditorSettings((state) => state.setStartTimeIsLastEnd);
const setDefaultPublic = useLocalEvent((state) => state.setDefaultPublic); const setDefaultPublic = useEditorSettings((state) => state.setDefaultPublic);
const setShowNif = useEditorSettings((state) => state.setShowNif);
return ( return (
<div className={style.sectionContainer}> <div className={style.sectionContainer}>
@@ -39,6 +40,18 @@ export default function EditorSettings() {
onChange={(event) => setDefaultPublic(event.target.checked)} onChange={(event) => setDefaultPublic(event.target.checked)}
/> />
</ModalSplitInput> </ModalSplitInput>
<span className={style.title}>Info settings</span>
<ModalSplitInput
field=''
title='Show network'
description='Whether to available show network interfaces in the panel'
>
<Switch
variant='ontime-on-light'
defaultChecked={eventSettings.showQuickEntry}
onChange={(event) => setShowNif(event.target.checked)}
/>
</ModalSplitInput>
</div> </div>
); );
} }
@@ -71,6 +71,21 @@ export default function EventDataForm() {
{...register('title')} {...register('title')}
/> />
</ModalInput> </ModalInput>
<ModalInput
field='description'
title='Event description'
description='Free field, shown in editor'
error={errors.description?.message}
>
<Input
{...inputProps}
variant='ontime-filled-on-light'
maxLength={100}
placeholder='Euro Love, Malmö 2024'
isDisabled={disableInputs}
{...register('description')}
/>
</ModalInput>
<div style={{ height: '16px' }} /> <div style={{ height: '16px' }} />
<ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'> <ModalInput field='publicInfo' title='Public Info' description='Information shown in public screens'>
<Textarea <Textarea
@@ -1,8 +1,12 @@
@use '../../theme/v2Styles' as *;
.eventContainer { .eventContainer {
flex: 1;
margin-top: 1em; margin-top: 1em;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 8px 4px 8px 0; padding: 0 4px;
overflow-y: scroll; overflow-y: scroll;
-ms-overflow-style: -ms-autohiding-scrollbar; -ms-overflow-style: -ms-autohiding-scrollbar;
height: 100%; height: 100%;
@@ -22,11 +26,29 @@
.alignCenter { .alignCenter {
text-align: center; text-align: center;
flex-direction: column; flex-direction: column;
.spaceTop { .spaceTop {
margin-top: 24px; margin-top: 1.5rem;
} }
} }
.spacer { .spacer {
min-height: 50vh; min-height: 50vh;
} }
.entryWrapper {
display: flex;
gap: 0.5rem;
align-items: center;
}
.entryIndex {
text-align: right;
min-width: 2em;
color: $label-gray;
font-size: calc(1rem - 3px);
}
.entry {
flex: 1;
}
+30 -26
View File
@@ -1,13 +1,14 @@
import { lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types'; import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent'; import { useEditorSettings } from '../../common/stores/editorSettings';
import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
@@ -26,7 +27,7 @@ export default function Rundown(props: RundownProps) {
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { addEvent, reorderEvent } = useEventAction(); const { addEvent, reorderEvent } = useEventAction();
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const showQuickEntry = eventSettings.showQuickEntry; const showQuickEntry = eventSettings.showQuickEntry;
@@ -59,8 +60,7 @@ export default function Rundown(props: RundownProps) {
if (type === 'clone') { if (type === 'clone') {
const cursorEvent = entries.find((event) => event.id === cursor); const cursorEvent = entries.find((event) => event.id === cursor);
if (cursorEvent?.type === SupportedEvent.Event) { if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent); const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
newEvent.after = cursorEvent.id;
addEvent(newEvent); addEvent(newEvent);
} }
} else if (type === SupportedEvent.Event) { } else if (type === SupportedEvent.Event) {
@@ -93,7 +93,7 @@ export default function Rundown(props: RundownProps) {
if (entries.length < 1) { if (entries.length < 1) {
return; return;
} }
const nextEvent = cursor == null ? getFirstEvent(entries) : getNextEvent(entries, cursor); const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor);
if (nextEvent) { if (nextEvent) {
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event); moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
} }
@@ -104,7 +104,7 @@ export default function Rundown(props: RundownProps) {
return; return;
} }
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirstEvent(entries) : getPreviousEvent(entries, cursor); const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor);
if (previousEvent) { if (previousEvent) {
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event); moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
} }
@@ -206,7 +206,7 @@ export default function Rundown(props: RundownProps) {
let previousEnd = 0; let previousEnd = 0;
let thisEnd = 0; let thisEnd = 0;
let previousEventId: string | undefined; let previousEventId: string | undefined;
let eventIndex = -1; let eventIndex = 0;
let isPast = Boolean(featureData?.selectedEventId); let isPast = Boolean(featureData?.selectedEventId);
return ( return (
@@ -216,7 +216,7 @@ export default function Rundown(props: RundownProps) {
<div className={style.list}> <div className={style.list}>
{statefulEntries.map((entry, index) => { {statefulEntries.map((entry, index) => {
if (index === 0) { if (index === 0) {
eventIndex = -1; eventIndex = 0;
} }
if (entry.type === SupportedEvent.Event) { if (entry.type === SupportedEvent.Event) {
eventIndex++; eventIndex++;
@@ -233,21 +233,25 @@ export default function Rundown(props: RundownProps) {
} }
return ( return (
<div key={entry.id} ref={hasCursor ? cursorRef : undefined}> <Fragment key={entry.id}>
<RundownEntry <div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
type={entry.type} {entry.type === SupportedEvent.Event && <div className={style.entryIndex}>{eventIndex}</div>}
eventIndex={eventIndex} <div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
isPast={isPast} <RundownEntry
data={entry} type={entry.type}
selected={isSelected} isPast={isPast}
hasCursor={hasCursor} data={entry}
next={isNext} selected={isSelected}
previousEnd={previousEnd} hasCursor={hasCursor}
previousEventId={previousEventId} next={isNext}
playback={isSelected ? featureData.playback : undefined} previousEnd={previousEnd}
isRolling={featureData.playback === Playback.Roll} previousEventId={previousEventId}
disableEdit={isExtracted} playback={isSelected ? featureData.playback : undefined}
/> isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
/>
</div>
</div>
{((showQuickEntry && hasCursor) || isLast) && ( {((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock <QuickAddBlock
showKbd={hasCursor} showKbd={hasCursor}
@@ -257,7 +261,7 @@ export default function Rundown(props: RundownProps) {
disableAddBlock={entry.type === SupportedEvent.Block} disableAddBlock={entry.type === SupportedEvent.Block}
/> />
)} )}
</div> </Fragment>
); );
})} })}
<div className={style.spacer} /> <div className={style.spacer} />
@@ -1,22 +1,23 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { calculateDuration, getCueCandidate } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../common/api/apiConstants';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import { ontimeQueryClient } from '../../common/queryClient';
import { useAppMode } from '../../common/stores/appModeStore'; import { useAppMode } from '../../common/stores/appModeStore';
import { useLocalEvent } from '../../common/stores/localEvent'; import { useEditorSettings } from '../../common/stores/editorSettings';
import { useEmitLog } from '../../common/stores/logger'; import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager'; import { cloneEvent } from '../../common/utils/eventsManager';
import { calculateDuration } from '../../common/utils/timesManager';
import BlockBlock from './block-block/BlockBlock'; import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock'; import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock'; import EventBlock from './event-block/EventBlock';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update'; export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEvent;
eventIndex: number;
isPast: boolean; isPast: boolean;
data: OntimeRundownEntry; data: OntimeRundownEntry;
selected: boolean; selected: boolean;
@@ -30,21 +31,10 @@ interface RundownEntryProps {
} }
export default function RundownEntry(props: RundownEntryProps) { export default function RundownEntry(props: RundownEntryProps) {
const { const { isPast, data, selected, hasCursor, next, previousEnd, previousEventId, playback, isRolling, disableEdit } =
eventIndex, props;
isPast,
data,
selected,
hasCursor,
next,
previousEnd,
previousEventId,
playback,
isRolling,
disableEdit,
} = props;
const { emitError } = useEmitLog(); const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent } = useEventAction(); const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
const cursor = useAppMode((state) => state.cursor); const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor); const setCursor = useAppMode((state) => state.setCursor);
@@ -61,7 +51,7 @@ export default function RundownEntry(props: RundownEntryProps) {
} }
}, [cursor, data.id, openId, setCursor, setEditId]); }, [cursor, data.id, openId, setCursor, setEditId]);
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
@@ -95,6 +85,12 @@ export default function RundownEntry(props: RundownEntryProps) {
addEvent({ type: SupportedEvent.Block }, { after: data.id }); addEvent({ type: SupportedEvent.Block }, { after: data.id });
break; break;
} }
case 'swap': {
const { value } = payload as FieldValue;
swapEvents({ from: value as string, to: data.id });
break;
}
case 'delete': { case 'delete': {
if (openId === data.id) { if (openId === data.id) {
removeOpenEvent(); removeOpenEvent();
@@ -104,6 +100,7 @@ export default function RundownEntry(props: RundownEntryProps) {
} }
case 'clone': { case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id); const newEvent = cloneEvent(data as OntimeEvent, data.id);
newEvent.cue = getCueCandidate(ontimeQueryClient.getQueryData(RUNDOWN_TABLE) || [], data.id);
addEvent(newEvent); addEvent(newEvent);
break; break;
} }
@@ -149,16 +146,17 @@ export default function RundownEntry(props: RundownEntryProps) {
removeOpenEvent, removeOpenEvent,
startTimeIsLastEnd, startTimeIsLastEnd,
updateEvent, updateEvent,
swapEvents,
], ],
); );
if (data.type === SupportedEvent.Event) { if (data.type === SupportedEvent.Event) {
return ( return (
<EventBlock <EventBlock
cue={data.cue}
timeStart={data.timeStart} timeStart={data.timeStart}
timeEnd={data.timeEnd} timeEnd={data.timeEnd}
duration={data.duration} duration={data.duration}
eventIndex={eventIndex + 1}
eventId={data.id} eventId={data.id}
isPublic={data.isPublic} isPublic={data.isPublic}
endAction={data.endAction} endAction={data.endAction}
@@ -6,11 +6,12 @@
background-color: $block-bg2; background-color: $block-bg2;
min-width: 34rem;
display: grid; display: grid;
grid-template-columns: 32px 1fr auto; grid-template-columns: 2rem 1fr auto;
align-items: center; align-items: center;
height: $secondary-block-height; height: $secondary-block-height;
gap: 8px; gap: 0.5rem;
&.hasCursor { &.hasCursor {
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
@@ -59,7 +59,7 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo /> <IoReorderTwo />
</span> </span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> <EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockActionMenu className={style.actionMenu} showAdd showDelay enableDelete actionHandler={actionHandler} /> <BlockActionMenu className={style.actionMenu} enableDelete actionHandler={actionHandler} />
</div> </div>
); );
} }
@@ -6,12 +6,13 @@
background-color: $block-bg2; background-color: $block-bg2;
min-width: 34rem;
display: grid; display: grid;
grid-template-columns: 32px 1fr auto; grid-template-columns: 2rem 1fr auto;
grid-template-areas: 'drag inpt btns'; grid-template-areas: 'drag inpt btns';
align-items: center; align-items: center;
height: $secondary-block-height; height: $secondary-block-height;
gap: 8px; gap: 0.5rem;
&.hasCursor { &.hasCursor {
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
@@ -79,7 +79,7 @@ export default function DelayBlock(props: DelayBlockProps) {
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'> <Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
Cancel Cancel
</Button> </Button>
<BlockActionMenu showAdd enableDelete actionHandler={actionHandler} /> <BlockActionMenu enableDelete actionHandler={actionHandler} />
</HStack> </HStack>
</div> </div>
); );
@@ -90,6 +90,18 @@ $skip-opacity: 0.1;
position: absolute; position: absolute;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.cue {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 5.5rem;
text-align: center;
font-weight: 600;
letter-spacing: 0.5px;
rotate: -90deg;
}
} }
.playbackActions { .playbackActions {
@@ -1,27 +1,29 @@
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline'; import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline'; import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo'; import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types'; import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useAppMode } from '../../../common/stores/appModeStore'; import { useAppMode } from '../../../common/stores/appModeStore';
import copyToClipboard from '../../../common/utils/copyToClipboard'; import copyToClipboard from '../../../common/utils/copyToClipboard';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry'; import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import EventBlockInner from './EventBlockInner'; import EventBlockInner from './EventBlockInner';
import style from './EventBlock.module.scss'; import style from './EventBlock.module.scss';
interface EventBlockProps { interface EventBlockProps {
cue: string;
timeStart: number; timeStart: number;
timeEnd: number; timeEnd: number;
duration: number; duration: number;
eventIndex: number;
eventId: string; eventId: string;
isPublic: boolean; isPublic: boolean;
endAction: EndAction; endAction: EndAction;
@@ -52,11 +54,11 @@ interface EventBlockProps {
export default function EventBlock(props: EventBlockProps) { export default function EventBlock(props: EventBlockProps) {
const { const {
eventId,
cue,
timeStart, timeStart,
timeEnd, timeEnd,
duration, duration,
eventIndex,
eventId,
isPublic = true, isPublic = true,
endAction, endAction,
timerType, timerType,
@@ -75,22 +77,37 @@ export default function EventBlock(props: EventBlockProps) {
actionHandler, actionHandler,
disableEdit, disableEdit,
} = props; } = props;
const { updateEvent } = useEventAction(); const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const moveCursorTo = useAppMode((state) => state.setCursor); const moveCursorTo = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const openId = useAppMode((state) => state.editId); const openId = useAppMode((state) => state.editId);
const [onContextMenu] = useContextMenu<HTMLDivElement>([ const [onContextMenu] = useContextMenu<HTMLDivElement>([
{ label: `Copy ID: ${eventId}}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) }, { label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{ {
label: 'Toggle public', label: 'Toggle public',
icon: IoPeopleOutline, icon: IoPeopleOutline,
onClick: () => onClick: () =>
updateEvent({ actionHandler('update', {
id: eventId, field: 'isPublic',
isPublic: !isPublic, value: !isPublic,
}), }),
}, },
{
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
]); ]);
const { const {
@@ -170,7 +187,7 @@ export default function EventBlock(props: EventBlockProps) {
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}> <span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo /> <IoReorderTwo />
</span> </span>
{eventIndex} <span className={style.cue}>{cue}</span>
</div> </div>
{isVisible && ( {isVisible && (
<EventBlockInner <EventBlockInner
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { BiArrowToBottom } from '@react-icons/all-files/bi/BiArrowToBottom';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown'; import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp'; import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
@@ -144,7 +145,10 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</Tooltip> </Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}> <Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<span> <span>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} /> <IoPeople
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
data-isPublic={isPublic}
/>
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
@@ -163,7 +167,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
color={isOpen ? 'white' : '#f6f6f6'} color={isOpen ? 'white' : '#f6f6f6'}
isDisabled={disableEdit} isDisabled={disableEdit}
/> />
<BlockActionMenu showAdd showDelay showBlock showClone enableDelete={!selected} actionHandler={actionHandler} /> <BlockActionMenu showClone enableDelete={!selected} actionHandler={actionHandler} />
</div> </div>
</> </>
); );
@@ -193,5 +197,8 @@ function TimerIcon(props: { type: TimerType; className: string }) {
if (type === TimerType.Clock) { if (type === TimerType.Clock) {
return <IoTime className={className} />; return <IoTime className={className} />;
} }
if (type === TimerType.TimeToEnd) {
return <BiArrowToBottom className={className} />;
}
return <IoArrowDown className={className} />; return <IoArrowDown className={className} />;
} }
@@ -11,9 +11,6 @@ import { tooltipDelayMid } from '../../../../ontimeConfig';
import { EventItemActions } from '../../RundownEntry'; import { EventItemActions } from '../../RundownEntry';
interface BlockActionMenuProps { interface BlockActionMenuProps {
showAdd?: boolean;
showDelay?: boolean;
showBlock?: boolean;
enableDelete?: boolean; enableDelete?: boolean;
showClone?: boolean; showClone?: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void; actionHandler: (action: EventItemActions, payload?: any) => void;
@@ -21,7 +18,7 @@ interface BlockActionMenuProps {
} }
export default function BlockActionMenu(props: BlockActionMenuProps) { export default function BlockActionMenu(props: BlockActionMenuProps) {
const { showAdd, showDelay, showBlock, enableDelete, showClone, actionHandler, className } = props; const { enableDelete, showClone, actionHandler, className } = props;
const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]); const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]);
const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]); const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]);
@@ -43,17 +40,17 @@ export default function BlockActionMenu(props: BlockActionMenuProps) {
/> />
</Tooltip> </Tooltip>
<MenuList> <MenuList>
<MenuItem icon={<IoAdd />} onClick={handleAddEvent} isDisabled={!showAdd}> <MenuItem icon={<IoAdd />} onClick={handleAddEvent}>
Add Event after Add Event after
</MenuItem> </MenuItem>
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay} isDisabled={!showDelay}> <MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay}>
Add Delay after Add Delay after
</MenuItem> </MenuItem>
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock} isDisabled={!showBlock}> <MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock}>
Add Block after Add Block after
</MenuItem> </MenuItem>
{showClone && ( {showClone && (
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone} isDisabled={!showBlock}> <MenuItem icon={<IoDuplicateOutline />} onClick={handleClone}>
Clone event Clone event
</MenuItem> </MenuItem>
)} )}
@@ -1,4 +1,4 @@
import { Playback } from 'ontime-types'; import { MaybeNumber, Playback } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket'; import { useTimer } from '../../../../common/hooks/useSocket';
import { clamp } from '../../../../common/utils/math'; import { clamp } from '../../../../common/utils/math';
@@ -9,18 +9,26 @@ interface EventBlockProgressBarProps {
playback?: Playback; playback?: Playback;
} }
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
if (remaining === null || total === null) {
return 0;
}
if (remaining <= 0) {
return 100;
}
if (remaining === total) {
return 0;
}
return clamp(100 - (remaining * 100) / total, 0, 100);
}
export default function EventBlockProgressBar(props: EventBlockProgressBarProps) { export default function EventBlockProgressBar(props: EventBlockProgressBarProps) {
const { playback } = props; const { playback } = props;
const timer = useTimer(); const timer = useTimer();
const now = Math.floor(Math.max((timer?.current ?? 1) / 1000, 0)); const progress = `${getPercentComplete(timer.current, timer.duration)}%`;
const complete = (timer?.duration ?? 1) / 1000;
const elapsed = clamp(100 - (now * 100) / complete, 0, 100);
const progress = `${elapsed}%`;
if ((timer?.current ?? 0) < 0) {
return <div className={`${style.progressBar} ${style.overtime}`} style={{ width: '100%' }} />;
}
return <div className={`${style.progressBar} ${playback ? style[playback] : ''}`} style={{ width: progress }} />; return <div className={`${style.progressBar} ${playback ? style[playback] : ''}`} style={{ width: progress }} />;
} }
@@ -1,11 +1,11 @@
import { memo, useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { OntimeEvent } from 'ontime-types'; import { OntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { calculateDuration, millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction'; import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { calculateDuration, TimeEntryField, validateEntry } from '../../../../common/utils/timesManager'; import { TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
import style from '../EventBlock.module.scss'; import style from '../EventBlock.module.scss';
@@ -0,0 +1,27 @@
import { dayInMs } from 'ontime-utils';
import { getPercentComplete } from '../EventBlockProgressBar';
describe('getPercentComplete()', () => {
describe('calculates progress in normal cases', () => {
const testScenarios = [
{ current: 0, duration: 0, expect: 100 },
{ current: 0, duration: 100, expect: 100 },
{ current: 0, duration: dayInMs, expect: 100 },
{ current: 10, duration: 100, expect: 90 },
{ current: 50, duration: 100, expect: 50 },
{ current: 100, duration: 100, expect: 0 },
];
testScenarios.forEach((testCase) => {
it(`handles ${testCase.current} / ${testCase.duration}`, () => {
const progress = getPercentComplete(testCase.current, testCase.duration);
expect(progress).toBe(testCase.expect);
});
});
});
it('is 0 if we dont have a current or duration', () => {
const progress = getPercentComplete(null, null);
expect(progress).toBe(0);
});
});
@@ -4,26 +4,27 @@
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
align-items: center; align-items: center;
margin: 4px 0; margin: 0.25rem 0;
font-size: 12px; font-size: $inner-section-text-size;
padding: 0 10px; padding: 0 0.75rem;
gap: 1rem;
} }
.btnRow { .btnRow {
justify-self: center; justify-self: center;
display: flex; display: flex;
gap: 10%; gap: max(0.5rem, 2rem);
.quickBtn { .quickBtn {
font-weight: 400; font-weight: 400;
width: auto; width: auto;
padding: 0 32px; padding: 0 1rem;
} }
} }
.keyboard { .keyboard {
margin-left: 8px; margin-left: 0.5rem;
padding: 0 4px; padding: 0 0.25rem;
color: $label-gray; color: $label-gray;
border-radius: 2px; border-radius: 2px;
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
@@ -32,4 +33,5 @@
.options { .options {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
white-space: nowrap;
} }
@@ -3,7 +3,7 @@ import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types'; import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { useLocalEvent } from '../../../common/stores/localEvent'; import { useEditorSettings } from '../../../common/stores/editorSettings';
import { useEmitLog } from '../../../common/stores/logger'; import { useEmitLog } from '../../../common/stores/logger';
import { tooltipDelayMid } from '../../../ontimeConfig'; import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -25,7 +25,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
const doStartTime = useRef<HTMLInputElement | null>(null); const doStartTime = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null); const doPublic = useRef<HTMLInputElement | null>(null);
const eventSettings = useLocalEvent((state) => state.eventSettings); const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic; const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd; const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
@@ -80,6 +80,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
data-testid='quick-add-event'
> >
Event {showKbd && <span className={style.keyboard}>Alt + E</span>} Event {showKbd && <span className={style.keyboard}>Alt + E</span>}
</Button> </Button>
@@ -91,6 +92,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
variant='ontime-subtle-white' variant='ontime-subtle-white'
disabled={disableAddDelay} disabled={disableAddDelay}
className={style.quickBtn} className={style.quickBtn}
data-testid='quick-add-delay'
> >
Delay {showKbd && <span className={style.keyboard}>Alt + D</span>} Delay {showKbd && <span className={style.keyboard}>Alt + D</span>}
</Button> </Button>
@@ -102,6 +104,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
variant='ontime-subtle-white' variant='ontime-subtle-white'
disabled={disableAddBlock} disabled={disableAddBlock}
className={style.quickBtn} className={style.quickBtn}
data-testid='quick-add-block'
> >
Block {showKbd && <span className={style.keyboard}>Alt + B</span>} Block {showKbd && <span className={style.keyboard}>Alt + B</span>}
</Button> </Button>
@@ -0,0 +1,13 @@
import { create } from 'zustand';
interface EventIdSwappingStore {
selectedEventId: string | null;
setSelectedEventId: (newEventId: string | null) => void;
clearSelectedEventId: () => void;
}
export const useEventIdSwapping = create<EventIdSwappingStore>((set) => ({
selectedEventId: null,
setSelectedEventId: (newEventId) => set(() => ({ selectedEventId: newEventId })),
clearSelectedEventId: () => set(() => ({ selectedEventId: null })),
}));
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types'; import { EventData, Message, OntimeEvent, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils'; import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants'; import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -15,7 +15,6 @@ import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-edito
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { getEventsWithDelay } from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation'; import { titleVariants } from '../common/animation';
@@ -74,7 +73,7 @@ export default function Backstage(props: BackstageProps) {
: formatTime(time.expectedFinish, formatOptions); : formatTime(time.expectedFinish, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128); const qrSize = Math.max(window.innerWidth / 15, 128);
const filteredEvents = getEventsWithDelay(backstageEvents); const filteredEvents = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const showPublicMessage = publ.text && publ.visible; const showPublicMessage = publ.text && publ.visible;
const showProgress = time.playback !== 'stop'; const showProgress = time.playback !== 'stop';
@@ -167,7 +166,7 @@ export default function Backstage(props: BackstageProps) {
<ScheduleProvider events={filteredEvents} selectedEventId={selectedId} isBackstage> <ScheduleProvider events={filteredEvents} selectedEventId={selectedId} isBackstage>
<ScheduleNav className='schedule-nav-container' /> <ScheduleNav className='schedule-nav-container' />
<Schedule className='schedule-container' /> <Schedule isProduction className='schedule-container' />
</ScheduleProvider> </ScheduleProvider>
<div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}> <div className={showPublicMessage ? 'public-container' : 'public-container public-container--hidden'}>
@@ -17,7 +17,7 @@ export function getTimerByType(timerObject?: TimerTypeParams): string | number |
return timer; return timer;
} }
if (timerObject.timerType === TimerType.CountDown) { if (timerObject.timerType === TimerType.CountDown || timerObject.timerType === TimerType.TimeToEnd) {
timer = timerObject.current; timer = timerObject.current;
} else if (timerObject.timerType === TimerType.CountUp) { } else if (timerObject.timerType === TimerType.CountUp) {
timer = timerObject.elapsed; timer = timerObject.elapsed;
@@ -9,7 +9,6 @@ import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-edito
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { TimeManagerType } from '../../../common/models/TimeManager.type';
import getDelayTo from '../../../common/utils/getDelayTo';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider'; import { useTranslation } from '../../../translation/TranslationProvider';
@@ -72,7 +71,7 @@ export default function Countdown(props: CountdownProps) {
if (followThis !== null) { if (followThis !== null) {
setFollow(followThis); setFollow(followThis);
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id); const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
const delayToEvent = getDelayTo(backstageEvents, idx); const delayToEvent = backstageEvents[idx]?.delay ?? 0;
setDelay(delayToEvent); setDelay(delayToEvent);
} }
}, [backstageEvents, searchParams]); }, [backstageEvents, searchParams]);
@@ -1,4 +1,5 @@
import { DAY_TO_MS } from '../../../../common/utils/timeConstants'; import { dayInMs } from 'ontime-utils';
import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers'; import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
describe('sanitiseTitle() function', () => { describe('sanitiseTitle() function', () => {
@@ -73,11 +74,11 @@ describe('fetchTimerData() function', () => {
const timeNow = 15000; const timeNow = 15000;
const followId = 'testId'; const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.waiting); expect(message).toBe(TimerMessage.waiting);
expect(timer).toBe(DAY_TO_MS + endMockValue - startMockValue); expect(timer).toBe(dayInMs + endMockValue - startMockValue);
}); });
it('handle an current event that finishes after midnight', () => { it('handle an current event that finishes after midnight', () => {
@@ -86,11 +87,11 @@ describe('fetchTimerData() function', () => {
const timeNow = 15000; const timeNow = 15000;
const followId = 'testId'; const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, followId); const { message, timer } = fetchTimerData(time, follow, followId);
expect(message).toBe(TimerMessage.running); expect(message).toBe(TimerMessage.running);
expect(timer).toBe(DAY_TO_MS + endMockValue - startMockValue); expect(timer).toBe(dayInMs + endMockValue - startMockValue);
}); });
it('handle an event that finishes after midnight but hasnt started', () => { it('handle an event that finishes after midnight but hasnt started', () => {
@@ -99,7 +100,7 @@ describe('fetchTimerData() function', () => {
const timeNow = 2000; const timeNow = 2000;
const followId = 'testId'; const followId = 'testId';
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue }; const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
const time = { clock: timeNow, current: DAY_TO_MS + endMockValue - startMockValue }; const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent'); const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
expect(message).toBe(TimerMessage.toStart); expect(message).toBe(TimerMessage.toStart);
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import type { OntimeRundown, ViewSettings } from 'ontime-types'; import type { OntimeEvent, OntimeRundown, ViewSettings } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { formatDisplay } from 'ontime-utils'; import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants'; import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -11,15 +12,11 @@ import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet'; import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type'; import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { secondsInMillis } from '../../../common/utils/dateConfig'; import { secondsInMillis } from '../../../common/utils/dateConfig';
import {
formatEventList,
getEventsWithDelay,
type ScheduleEvent,
trimRundown,
} from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
import { TitleManager } from '../ViewWrapper'; import { TitleManager } from '../ViewWrapper';
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
import './StudioClock.scss'; import './StudioClock.scss';
const formatOptions = { const formatOptions = {
@@ -66,8 +63,8 @@ export default function StudioClock(props: StudioClockProps) {
return; return;
} }
const delayed = getEventsWithDelay(backstageEvents); const delayed = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const trimmed = trimRundown(delayed, selectedId || '', MAX_TITLES); const trimmed = trimRundown(delayed as OntimeEvent[], selectedId || '', MAX_TITLES);
const formatted = formatEventList(trimmed, selectedId || '', nextId || '', { const formatted = formatEventList(trimmed, selectedId || '', nextId || '', {
showEnd: false, showEnd: false,
@@ -0,0 +1,203 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { formatEventList, trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,76 @@
import { OntimeEvent } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
// format list
return rundown.map((event) => {
const start = formatTime(event.timeStart + (event.delay || 0));
const end = formatTime(event.timeEnd + (event.delay || 0));
return {
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
};
});
};
+2 -2
View File
@@ -15,10 +15,10 @@ const root = createRoot(container as Element);
Sentry.init({ Sentry.init({
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360', dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
integrations: [new BrowserTracing()], integrations: [new BrowserTracing()],
tracesSampleRate: 1.0, tracesSampleRate: 0.3,
release: ONTIME_VERSION, release: ONTIME_VERSION,
enabled: import.meta.env.PROD, enabled: import.meta.env.PROD,
ignoreErrors: ['top.GLOBALS', 'Unable to preload CSS'], ignoreErrors: ['top.GLOBALS', 'Unable to preload CSS', 'Failed to fetch dynamically imported module'],
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i], denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
}); });
@@ -5,6 +5,7 @@ import useSettings from '../common/hooks-query/useSettings';
import { langDe } from './languages/de'; import { langDe } from './languages/de';
import { langEn } from './languages/en'; import { langEn } from './languages/en';
import { langEs } from './languages/es'; import { langEs } from './languages/es';
import { langFr } from './languages/fr';
import { langNo } from './languages/no'; import { langNo } from './languages/no';
import { langPt } from './languages/pt'; import { langPt } from './languages/pt';
import { langSv } from './languages/sv'; import { langSv } from './languages/sv';
@@ -12,6 +13,7 @@ import { langSv } from './languages/sv';
const translationsList = { const translationsList = {
en: langEn, en: langEn,
es: langEs, es: langEs,
fr: langFr,
de: langDe, de: langDe,
no: langNo, no: langNo,
pt: langPt, pt: langPt,
@@ -0,0 +1,19 @@
import { TranslationObject } from './en';
export const langFr: TranslationObject = {
'common.end_time': 'Termine à',
'common.expected_finish': 'Fin estimée à',
'common.now': 'Maintenant',
'common.next': 'A suivre',
'common.public_message': 'Message public',
'common.start_time': 'Heure de début',
'common.stage_timer': 'Minuteur de scène',
'common.started_at': 'Commencé à',
'common.time_now': 'Heure',
'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours',
'countdown.select_event': 'Sélectionnez un évènement à suivre',
'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement',
'countdown.overtime': 'en dépassement',
};
+1 -1
View File
@@ -14,6 +14,6 @@ export const langSv: TranslationObject = {
'countdown.running': 'Evenemang pågår', 'countdown.running': 'Evenemang pågår',
'countdown.select_event': 'Välj ett evenemang att följa', 'countdown.select_event': 'Välj ett evenemang att följa',
'countdown.to_start': 'Tid till start', 'countdown.to_start': 'Tid till start',
'countdown.waiting': '"Väntar på att evenemanget ska starta', 'countdown.waiting': 'Väntar på att evenemanget ska starta',
'countdown.overtime': 'i övertid', 'countdown.overtime': 'i övertid',
}; };
+4 -4
View File
@@ -3,13 +3,13 @@ module.exports = {
shutdownCode: 99, shutdownCode: 99,
}, },
reactAppUrl: { reactAppUrl: {
development: 'http://localhost:3000/editor', development: (port = 4001) => `http://localhost:${port}/editor`,
production: 'http://localhost:4001/editor', production: (port = 4001) => `http://localhost:${port}/editor`,
}, },
server: { server: {
pathToEntrypoint: '../extraResources/server/index.cjs' pathToEntrypoint: '../extraResources/server/index.cjs',
}, },
assets: { assets: {
pathToAssets: './assets/', pathToAssets: './assets/',
} },
}; };
+18 -14
View File
@@ -30,21 +30,23 @@ let splash;
let tray = null; let tray = null;
async function startBackend() { async function startBackend() {
await (async () => { // in dev mode, we expect both UI and server to be running
// in dev mode, we expect both UI and server to be running if (!isProduction) {
if (!isProduction) { return;
return; }
}
const ontimeServer = require(nodePath); const ontimeServer = require(nodePath);
const { initAssets, startServer, startOSCServer, startIntegrations } = ontimeServer; const { initAssets, startServer, startOSCServer, startIntegrations } = ontimeServer;
await initAssets(); await initAssets();
loaded = await startServer(); const result = await startServer();
await startOSCServer(); loaded = result.message;
await startIntegrations();
})(); await startOSCServer();
await startIntegrations();
return result.serverPort;
} }
/** /**
@@ -155,9 +157,11 @@ app.whenReady().then(() => {
}); });
startBackend() startBackend()
.then(() => { .then((port) => {
// Load page served by node or use React dev run // Load page served by node or use React dev run
const clientUrl = isProduction ? electronConfig.reactAppUrl.production : electronConfig.reactAppUrl.development; const clientUrl = isProduction
? electronConfig.reactAppUrl.production(port)
: electronConfig.reactAppUrl.development(port);
win win
.loadURL(clientUrl) .loadURL(clientUrl)
+10 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "2.0.9", "version": "2.7.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -22,7 +22,7 @@
"postinstall": "", "postinstall": "",
"dev:electron": "NODE_ENV=development electron .", "dev:electron": "NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win", "dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --x64 --mac", "dist-mac": "electron-builder --publish=never --mac",
"dist-linux": "electron-builder --publish=never --x64 --linux", "dist-linux": "electron-builder --publish=never --x64 --linux",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
}, },
@@ -31,11 +31,17 @@
"appId": "no.lightdev.ontime", "appId": "no.lightdev.ontime",
"asar": true, "asar": true,
"dmg": { "dmg": {
"artifactName": "ontime-macOS.dmg", "artifactName": "ontime-macOS-${arch}.dmg",
"icon": "icon.icns" "icon": "icon.icns"
}, },
"mac": { "mac": {
"target": "dmg", "target": {
"target": "dmg",
"arch": [
"x64",
"arm64"
]
},
"category": "public.app-category.productivity", "category": "public.app-category.productivity",
"icon": "icon.icns" "icon": "icon.icns"
}, },
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "2.0.9", "version": "2.7.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"body-parser": "^1.20.0", "body-parser": "^1.20.0",
@@ -48,7 +48,7 @@
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts", "dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts",
"prebuild": "pnpm setdb", "prebuild": "pnpm setdb",
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs", "build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
"build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs", "build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs", "build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs", "build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
"lint": "eslint .", "lint": "eslint .",
+2 -2
View File
@@ -131,7 +131,7 @@ export const initAssets = async () => {
export const startServer = async () => { export const startServer = async () => {
checkStart(OntimeStartOrder.InitServer); checkStart(OntimeStartOrder.InitServer);
const serverPort = 4001; // hardcoded for now const { serverPort } = DataProvider.getSettings();
const returnMessage = `Ontime is listening on port ${serverPort}`; const returnMessage = `Ontime is listening on port ${serverPort}`;
expressServer = http.createServer(app); expressServer = http.createServer(app);
@@ -144,7 +144,7 @@ export const startServer = async () => {
expressServer.listen(serverPort, '0.0.0.0'); expressServer.listen(serverPort, '0.0.0.0');
return returnMessage; return { message: returnMessage, serverPort };
}; };
/** /**
@@ -2,7 +2,7 @@
* Class Event Provider is a mediator for handling the local db * Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data * and adds logic specific to ontime data
*/ */
import { EventData, ViewSettings } from 'ontime-types'; import { EventData, OntimeRundown, ViewSettings } from 'ontime-types';
import { data, db } from '../../modules/loadDb.js'; import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js'; import { safeMerge } from './DataProvider.utils.js';
@@ -22,19 +22,15 @@ export class DataProvider {
return data.eventData; return data.eventData;
} }
static async setRundown(newData) { static async setRundown(newData: OntimeRundown) {
data.rundown = [...newData]; data.rundown = [...newData];
await this.persist(); await this.persist();
} }
static getIndexOf(eventId) { static getIndexOf(eventId: string) {
return data.rundown.findIndex((e) => e.id === eventId); return data.rundown.findIndex((e) => e.id === eventId);
} }
static getEventById(eventId) {
return data.rundown.find((e) => e.id === eventId);
}
static getRundownLength() { static getRundownLength() {
return data.rundown.length; return data.rundown.length;
} }
@@ -85,6 +85,16 @@ export class EventLoader {
return timedEvents.find((event) => event.id === eventId); return timedEvents.find((event) => event.id === eventId);
} }
/**
* returns first event given its cue
* @param {string} cue
* @return {object | undefined}
*/
static getEventWithCue(cue) {
const timedEvents = EventLoader.getTimedEvents();
return timedEvents.find((event) => event.cue === cue);
}
/** /**
* loads an event given its id * loads an event given its id
* @param {string} eventId * @param {string} eventId
@@ -354,46 +364,83 @@ export class EventLoader {
* @private * @private
*/ */
private _loadThisTitles(event, type) { private _loadThisTitles(event, type) {
if (!event) { if (type === 'now') {
return; if (event === null) {
// public
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.titlesPublic.noteNow = null;
this.loaded.selectedPublicEventId = null;
// private
this.titles.titleNow = null;
this.titles.subtitleNow = null;
this.titles.presenterNow = null;
this.titles.noteNow = null;
this.loaded.selectedEventId = null;
} else {
// public
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.loaded.selectedPublicEventId = event.id;
// private
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.loaded.selectedEventId = event.id;
}
} else if (type === 'now-public') {
if (event === null) {
this.titlesPublic.titleNow = null;
this.titlesPublic.subtitleNow = null;
this.titlesPublic.presenterNow = null;
this.titlesPublic.noteNow = null;
this.loaded.selectedPublicEventId = null;
} else {
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.loaded.selectedPublicEventId = event.id;
}
} else if (type === 'now-private') {
if (event === null) {
this.titles.titleNow = null;
this.titles.subtitleNow = null;
this.titles.presenterNow = null;
this.titles.noteNow = null;
this.loaded.selectedEventId = null;
} else {
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.loaded.selectedEventId = event.id;
}
} }
switch (type) { // next, load to both public and private
// now, load to both public and private else if (type === 'next') {
case 'now': if (event === null) {
// public // public
this.titlesPublic.titleNow = event.title; this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNow = event.subtitle; this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNow = event.presenter; this.titlesPublic.presenterNext = null;
this.titlesPublic.noteNow = event.note; this.titlesPublic.noteNext = null;
this.loaded.selectedPublicEventId = event.id; this.loaded.nextPublicEventId = null;
// private // private
this.titles.titleNow = event.title; this.titles.titleNext = null;
this.titles.subtitleNow = event.subtitle; this.titles.subtitleNext = null;
this.titles.presenterNow = event.presenter; this.titles.presenterNext = null;
this.titles.noteNow = event.note; this.titles.noteNext = null;
this.loaded.selectedEventId = event.id; this.loaded.nextEventId = null;
break; } else {
case 'now-public':
this.titlesPublic.titleNow = event.title;
this.titlesPublic.subtitleNow = event.subtitle;
this.titlesPublic.presenterNow = event.presenter;
this.titlesPublic.noteNow = event.note;
this.loaded.selectedPublicEventId = event.id;
break;
case 'now-private':
this.titles.titleNow = event.title;
this.titles.subtitleNow = event.subtitle;
this.titles.presenterNow = event.presenter;
this.titles.noteNow = event.note;
this.loaded.selectedEventId = event.id;
break;
// next, load to both public and private
case 'next':
// public // public
this.titlesPublic.titleNext = event.title; this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle; this.titlesPublic.subtitleNext = event.subtitle;
@@ -407,26 +454,37 @@ export class EventLoader {
this.titles.presenterNext = event.presenter; this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note; this.titles.noteNext = event.note;
this.loaded.nextEventId = event.id; this.loaded.nextEventId = event.id;
break; }
} else if (type === 'next-public') {
case 'next-public': if (event === null) {
this.titlesPublic.titleNext = null;
this.titlesPublic.subtitleNext = null;
this.titlesPublic.presenterNext = null;
this.titlesPublic.noteNext = null;
this.loaded.nextPublicEventId = null;
} else {
this.titlesPublic.titleNext = event.title; this.titlesPublic.titleNext = event.title;
this.titlesPublic.subtitleNext = event.subtitle; this.titlesPublic.subtitleNext = event.subtitle;
this.titlesPublic.presenterNext = event.presenter; this.titlesPublic.presenterNext = event.presenter;
this.titlesPublic.noteNext = event.note; this.titlesPublic.noteNext = event.note;
this.loaded.nextPublicEventId = event.id; this.loaded.nextPublicEventId = event.id;
break; }
} else if (type === 'next-private') {
case 'next-private': if (event === null) {
this.titles.titleNext = null;
this.titles.subtitleNext = null;
this.titles.presenterNext = null;
this.titles.noteNext = null;
this.loaded.nextEventId = null;
} else {
this.titles.titleNext = event.title; this.titles.titleNext = event.title;
this.titles.subtitleNext = event.subtitle; this.titles.subtitleNext = event.subtitle;
this.titles.presenterNext = event.presenter; this.titles.presenterNext = event.presenter;
this.titles.noteNext = event.note; this.titles.noteNext = event.note;
this.loaded.nextEventId = event.id; this.loaded.nextEventId = event.id;
break; }
} else {
default: throw new Error(`Unhandled title type: ${type}`);
throw new Error(`Unhandled title type: ${type}`);
} }
} }
} }
@@ -1,21 +1,26 @@
import { RequestHandler } from 'express';
import { EventData } from 'ontime-types';
import { removeUndefined } from '../utils/parserUtils.js'; import { removeUndefined } from '../utils/parserUtils.js';
import { failEmptyObjects } from '../utils/routerUtils.js'; import { failEmptyObjects } from '../utils/routerUtils.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js';
// Create controller for GET request to 'event' // Create controller for GET request to 'event'
export const getEventData = async (req, res) => { export const getEventData: RequestHandler = async (req, res) => {
res.json(DataProvider.getEventData()); res.json(DataProvider.getEventData());
}; };
// Create controller for POST request to 'event' // Create controller for POST request to 'event'
export const postEventData = async (req, res) => { export const postEventData: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
try { try {
const newEvent = removeUndefined({ const newEvent: Partial<EventData> = removeUndefined({
title: req.body?.title, title: req.body?.title,
description: req.body?.description,
publicUrl: req.body?.publicUrl, publicUrl: req.body?.publicUrl,
publicInfo: req.body?.publicInfo, publicInfo: req.body?.publicInfo,
backstageUrl: req.body?.backstageUrl, backstageUrl: req.body?.backstageUrl,
@@ -2,6 +2,7 @@ import { body, validationResult } from 'express-validator';
export const eventDataSanitizer = [ export const eventDataSanitizer = [
body('title').optional().isString().trim(), body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(), body('publicUrl').optional().isString().trim(),
body('publicInfo').optional().isString().trim(), body('publicInfo').optional().isString().trim(),
body('backstageUrl').optional().isString().trim(), body('backstageUrl').optional().isString().trim(),
@@ -123,6 +123,15 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os
PlaybackService.startById(payload); PlaybackService.startById(payload);
break; break;
} }
case 'startcue': {
if (!payload || typeof payload !== 'string') {
throw new Error(`Event cue not recognised: ${payload}`);
}
PlaybackService.startByCue(payload);
break;
}
case 'pause': { case 'pause': {
PlaybackService.pause(); PlaybackService.pause();
break; break;
@@ -189,6 +198,19 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os
} }
break; break;
} }
case 'gotocue':
case 'loadcue': {
if (!payload || typeof payload !== 'string') {
throw new Error(`Event cue not recognised: ${payload}`);
}
try {
PlaybackService.loadByCue(payload);
} catch (error) {
throw new Error(`OSC IN: error calling goto ${error}`);
}
break;
}
case 'get-playback': { case 'get-playback': {
const playback = eventStore.get('playback'); const playback = eventStore.get('playback');
@@ -1,5 +1,6 @@
import { Alias, EventData, LogOrigin } from 'ontime-types'; import { Alias, EventData, LogOrigin } from 'ontime-types';
import { RequestHandler } from 'express';
import fs from 'fs'; import fs from 'fs';
import { networkInterfaces } from 'os'; import { networkInterfaces } from 'os';
@@ -9,7 +10,7 @@ import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { mergeObject } from '../utils/parserUtils.js'; import { mergeObject } from '../utils/parserUtils.js';
import { PlaybackService } from '../services/PlaybackService.js'; import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js'; import { eventStore } from '../stores/EventStore.js';
import { resolveDbPath } from '../setup.js'; import { isDocker, resolveDbPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js'; import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { logger } from '../classes/Logger.js'; import { logger } from '../classes/Logger.js';
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js'; import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js';
@@ -207,6 +208,11 @@ export const postSettings = async (req, res) => {
const editorKey = extractPin(req.body?.editorKey, settings.editorKey); const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey); const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
if (isDocker && req.body?.serverPort) {
return res.status(403).json({ message: `Can't change port when running inside docker` });
}
const serverPort = parseInt(req.body?.serverPort ?? settings.serverPort, 10);
let timeFormat = settings.timeFormat; let timeFormat = settings.timeFormat;
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') { if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
timeFormat = req.body.timeFormat; timeFormat = req.body.timeFormat;
@@ -220,6 +226,7 @@ export const postSettings = async (req, res) => {
operatorKey, operatorKey,
timeFormat, timeFormat,
language, language,
serverPort,
}; };
await DataProvider.setSettings(newData); await DataProvider.setSettings(newData);
res.status(200).send(newData); res.status(200).send(newData);
@@ -325,10 +332,11 @@ export const dbUpload = async (req, res) => {
}; };
// Create controller for POST request to '/ontime/new' // Create controller for POST request to '/ontime/new'
export const postNew = async (req, res) => { export const postNew: RequestHandler = async (req, res) => {
try { try {
const newEventData: Omit<EventData, 'endMessage'> = { const newEventData: EventData = {
title: req.body?.title ?? '', title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '', publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '', publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '', backstageUrl: req.body?.backstageUrl ?? '',
@@ -63,6 +63,7 @@ export const validateSettings = [
body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }), body('operatorKey').isString().isLength({ min: 0, max: 4 }).optional({ nullable: true }),
body('timeFormat').isString().isIn(['12', '24']), body('timeFormat').isString().isIn(['12', '24']),
body('language').isString(), body('language').isString(),
body('serverPort').isPort().optional(),
(req, res, next) => { (req, res, next) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
@@ -1,4 +1,3 @@
import { OntimeEvent } from 'ontime-types';
import { failEmptyObjects } from '../utils/routerUtils.js'; import { failEmptyObjects } from '../utils/routerUtils.js';
import { import {
addEvent, addEvent,
@@ -7,19 +6,21 @@ import {
deleteEvent, deleteEvent,
editEvent, editEvent,
reorderEvent, reorderEvent,
swapEvents,
} from '../services/rundown-service/RundownService.js'; } from '../services/rundown-service/RundownService.js';
import { getDelayedRundown } from '../services/rundown-service/delayedRundown.utils.js'; import { getDelayedRundown } from '../services/rundown-service/delayedRundown.utils.js';
import { RequestHandler } from 'express';
// Create controller for GET request to '/events' // Create controller for GET request to '/events'
// Returns - // Returns -
export const rundownGetAll = async (req, res) => { export const rundownGetAll: RequestHandler = async (_req, res) => {
const delayedRundown = getDelayedRundown(); const delayedRundown = getDelayedRundown();
res.json(delayedRundown); res.json(delayedRundown);
}; };
// Create controller for POST request to '/events/' // Create controller for POST request to '/events/'
// Returns - // Returns -
export const rundownPost = async (req, res) => { export const rundownPost: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -34,7 +35,7 @@ export const rundownPost = async (req, res) => {
// Create controller for PUT request to '/events/' // Create controller for PUT request to '/events/'
// Returns - // Returns -
export const rundownPut = async (req, res) => { export const rundownPut: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -47,7 +48,7 @@ export const rundownPut = async (req, res) => {
} }
}; };
export const rundownReorder = async (req, res) => { export const rundownReorder: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -61,9 +62,23 @@ export const rundownReorder = async (req, res) => {
} }
}; };
export const rundownSwap: RequestHandler = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { from, to } = req.body;
await swapEvents(from, to);
res.sendStatus(200);
} catch (error) {
res.status(400).send(error);
}
};
// Create controller for PATCH request to '/events/applydelay/:eventId' // Create controller for PATCH request to '/events/applydelay/:eventId'
// Returns - // Returns -
export const rundownApplyDelay = async (req, res) => { export const rundownApplyDelay: RequestHandler = async (req, res) => {
try { try {
await applyDelay(req.params.eventId); await applyDelay(req.params.eventId);
res.sendStatus(200); res.sendStatus(200);
@@ -74,7 +89,7 @@ export const rundownApplyDelay = async (req, res) => {
// Create controller for DELETE request to '/events/:eventId' // Create controller for DELETE request to '/events/:eventId'
// Returns - // Returns -
export const deleteEventById = async (req, res) => { export const deleteEventById: RequestHandler = async (req, res) => {
try { try {
await deleteEvent(req.params.eventId); await deleteEvent(req.params.eventId);
res.sendStatus(204); res.sendStatus(204);
@@ -85,7 +100,7 @@ export const deleteEventById = async (req, res) => {
// Create controller for DELETE request to '/events/' // Create controller for DELETE request to '/events/'
// Returns - // Returns -
export const rundownDelete = async (req, res) => { export const rundownDelete: RequestHandler = async (req, res) => {
try { try {
await deleteAllEvents(); await deleteAllEvents();
res.sendStatus(204); res.sendStatus(204);
@@ -29,6 +29,16 @@ export const rundownReorderValidator = [
}, },
]; ];
export const rundownSwapValidator = [
body('from').isString().exists(),
body('to').isString().exists(),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
export const paramsMustHaveEventId = [ export const paramsMustHaveEventId = [
param('eventId').exists(), param('eventId').exists(),
(req, res, next) => { (req, res, next) => {
+1
View File
@@ -4,6 +4,7 @@ export const dbModel: DatabaseModel = {
rundown: [], rundown: [],
eventData: { eventData: {
title: '', title: '',
description: '',
publicUrl: '', publicUrl: '',
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
+1 -1
View File
@@ -1,6 +1,6 @@
import { EndAction, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types'; import { EndAction, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'delay'> = { export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
title: '', title: '',
subtitle: '', subtitle: '',
presenter: '', presenter: '',
+4
View File
@@ -7,12 +7,14 @@ import {
rundownPost, rundownPost,
rundownPut, rundownPut,
rundownReorder, rundownReorder,
rundownSwap,
} from '../controllers/rundownController.js'; } from '../controllers/rundownController.js';
import { import {
paramsMustHaveEventId, paramsMustHaveEventId,
rundownPostValidator, rundownPostValidator,
rundownPutValidator, rundownPutValidator,
rundownReorderValidator, rundownReorderValidator,
rundownSwapValidator,
} from '../controllers/rundownController.validate.js'; } from '../controllers/rundownController.validate.js';
export const router = express.Router(); export const router = express.Router();
@@ -29,6 +31,8 @@ router.put('/', rundownPutValidator, rundownPut);
// create route between controller and '/events/reorder' endpoint // create route between controller and '/events/reorder' endpoint
router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
// create route between controller and '/events/applydelay/:eventId' endpoint // create route between controller and '/events/applydelay/:eventId' endpoint
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
+30 -1
View File
@@ -1,4 +1,4 @@
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types'; import { LogOrigin, OntimeEvent } from 'ontime-types';
import { validatePlayback } from 'ontime-utils'; import { validatePlayback } from 'ontime-utils';
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js'; import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
@@ -62,6 +62,21 @@ export class PlaybackService {
return success; return success;
} }
/**
* starts first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static startByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/** /**
* loads event matching given ID * loads event matching given ID
* @param {string} eventId * @param {string} eventId
@@ -90,6 +105,20 @@ export class PlaybackService {
return success; return success;
} }
/**
* loads first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static loadByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/** /**
* Loads event before currently selected * Loads event before currently selected
*/ */
+45 -13
View File
@@ -1,11 +1,11 @@
import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState } from 'ontime-types'; import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import { eventStore } from '../stores/EventStore.js'; import { eventStore } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js'; import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js'; import { updateRoll } from './rollUtils.js';
import { DAY_TO_MS } from '../utils/time.js';
import { integrationService } from './integration-service/IntegrationService.js'; import { integrationService } from './integration-service/IntegrationService.js';
import { getCurrent, getElapsed, getExpectedFinish } from './timerUtils.js'; import { getCurrent, getExpectedFinish } from './timerUtils.js';
import { clock } from './Clock.js'; import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js'; import { logger } from '../classes/Logger.js';
@@ -24,6 +24,9 @@ export class TimerService {
timer: TimerState; timer: TimerState;
loadedTimerId: string | null; loadedTimerId: string | null;
private loadedTimerStart: number | null;
private loadedTimerEnd: number | null;
private pausedTime: number; private pausedTime: number;
private pausedAt: number | null; private pausedAt: number | null;
private secondaryTarget: number | null; private secondaryTarget: number | null;
@@ -61,6 +64,9 @@ export class TimerService {
endAction: null, endAction: null,
}; };
this.loadedTimerId = null; this.loadedTimerId = null;
this.loadedTimerStart = null;
this.loadedTimerEnd = null;
this.pausedTime = 0; this.pausedTime = 0;
this.pausedAt = null; this.pausedAt = null;
this.secondaryTarget = null; this.secondaryTarget = null;
@@ -90,9 +96,11 @@ export class TimerService {
// TODO: check if any relevant information warrants update // TODO: check if any relevant information warrants update
// update relevant information and force update // update relevant information and force update
this.timer.duration = timer.duration; this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.timer.timerType = timer.timerType; this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction; this.timer.endAction = timer.endAction;
this.loadedTimerStart = timer.timeStart;
this.loadedTimerEnd = timer.timeEnd;
// this might not be ideal // this might not be ideal
this.timer.finishedAt = null; this.timer.finishedAt = null;
@@ -102,9 +110,11 @@ export class TimerService {
this.timer.duration, this.timer.duration,
this.pausedTime, this.pausedTime,
this.timer.addedTime, this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
); );
if (this.timer.startedAt === null) { if (this.timer.startedAt === null) {
this.timer.current = timer.duration; this.timer.current = this.timer.duration;
} }
this.update(true); this.update(true);
} }
@@ -112,6 +122,7 @@ export class TimerService {
/** /**
* Loads given timer to object * Loads given timer to object
* @param {object} timer * @param {object} timer
* @param initialData
* @param {number} timer.id * @param {number} timer.id
* @param {number} timer.timeStart * @param {number} timer.timeStart
* @param {number} timer.timeEnd * @param {number} timer.timeEnd
@@ -128,14 +139,22 @@ export class TimerService {
this._clear(); this._clear();
this.loadedTimerId = timer.id; this.loadedTimerId = timer.id;
this.timer.duration = timer.duration; this.loadedTimerStart = timer.timeStart;
this.timer.current = timer.duration; this.loadedTimerEnd = timer.timeEnd;
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
this.playback = Playback.Armed; this.playback = Playback.Armed;
this.timer.timerType = timer.timerType; this.timer.timerType = timer.timerType;
this.timer.endAction = timer.endAction; this.timer.endAction = timer.endAction;
this.pausedTime = 0; this.pausedTime = 0;
this.pausedAt = 0; this.pausedAt = 0;
this.timer.current = this.timer.duration;
if (this.timer.timerType === TimerType.TimeToEnd) {
const now = clock.timeNow();
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
}
if (typeof initialData !== 'undefined') { if (typeof initialData !== 'undefined') {
this.timer = { ...this.timer, ...initialData }; this.timer = { ...this.timer, ...initialData };
} }
@@ -168,6 +187,8 @@ export class TimerService {
} }
this.timer.clock = clock.timeNow(); this.timer.clock = clock.timeNow();
this.timer.secondaryTimer = null;
this.secondaryTarget = null;
// add paused time if it exists // add paused time if it exists
if (this.pausedTime) { if (this.pausedTime) {
@@ -185,6 +206,8 @@ export class TimerService {
this.timer.duration, this.timer.duration,
this.pausedTime, this.pausedTime,
this.timer.addedTime, this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
); );
this._onStart(); this._onStart();
} }
@@ -267,7 +290,7 @@ export class TimerService {
_finishAt: _finishAt:
this.timer.expectedFinish >= this.timer.startedAt this.timer.expectedFinish >= this.timer.startedAt
? this.timer.expectedFinish ? this.timer.expectedFinish
: this.timer.expectedFinish + DAY_TO_MS, : this.timer.expectedFinish + dayInMs,
clock: this.timer.clock, clock: this.timer.clock,
secondaryTimer: this.timer.secondaryTimer, secondaryTimer: this.timer.secondaryTimer,
@@ -277,7 +300,7 @@ export class TimerService {
this.timer.current = updatedTimer; this.timer.current = updatedTimer;
this.timer.secondaryTimer = updatedSecondaryTimer; this.timer.secondaryTimer = updatedSecondaryTimer;
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock); this.timer.elapsed = this.timer.duration - this.timer.current;
if (isFinished) { if (isFinished) {
this.timer.selectedEventId = null; this.timer.selectedEventId = null;
@@ -296,7 +319,8 @@ export class TimerService {
this.pausedTime = this.timer.clock - this.pausedAt; this.pausedTime = this.timer.clock - this.pausedAt;
} }
if (this.playback === Playback.Play && this.timer.current <= 0 && this.timer.finishedAt === null) { const finishedNow = this.timer.current <= 0 && this.timer.finishedAt === null;
if (this.playback === Playback.Play && finishedNow) {
this.timer.finishedAt = this.timer.clock; this.timer.finishedAt = this.timer.clock;
this._onFinish(); this._onFinish();
} else { } else {
@@ -306,6 +330,8 @@ export class TimerService {
this.timer.duration, this.timer.duration,
this.pausedTime, this.pausedTime,
this.timer.addedTime, this.timer.addedTime,
this.loadedTimerEnd,
this.timer.timerType,
); );
} }
this.timer.current = getCurrent( this.timer.current = getCurrent(
@@ -314,8 +340,10 @@ export class TimerService {
this.timer.addedTime, this.timer.addedTime,
this.pausedTime, this.pausedTime,
this.timer.clock, this.timer.clock,
this.loadedTimerEnd,
this.timer.timerType,
); );
this.timer.elapsed = getElapsed(this.timer.startedAt, this.timer.clock); this.timer.elapsed = this.timer.duration - this.timer.current;
} }
update(force = false) { update(force = false) {
@@ -382,16 +410,20 @@ export class TimerService {
this.timer.secondaryTimer = null; this.timer.secondaryTimer = null;
this.secondaryTarget = null; this.secondaryTarget = null;
// account for event that finishes the day after
const endTime =
currentEvent.timeEnd < currentEvent.timeStart ? currentEvent.timeEnd + dayInMs : currentEvent.timeEnd;
// when we load a timer in roll, we do the same things as before // when we load a timer in roll, we do the same things as before
// but also pre-populate some data as to the running state // but also pre-populate some data as to the running state
this.load(currentEvent, { this.load(currentEvent, {
startedAt: currentEvent.timeStart, startedAt: currentEvent.timeStart,
expectedFinish: currentEvent.timeEnd, expectedFinish: currentEvent.timeEnd,
current: currentEvent.timeEnd - this.timer.clock, current: endTime - this.timer.clock,
}); });
} else if (nextEvent) { } else if (nextEvent) {
// account for day after // account for day after
const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + DAY_TO_MS : nextEvent.timeStart; const nextStart = nextEvent.timeStart < this.timer.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
// nothing now, but something coming up // nothing now, but something coming up
this.timer.secondaryTimer = nextStart - this.timer.clock; this.timer.secondaryTimer = nextStart - this.timer.clock;
this.secondaryTarget = nextStart; this.secondaryTarget = nextStart;

Some files were not shown because too many files have changed in this diff Show More