Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46e9fcb3cb | |||
| f2d7f4ab82 | |||
| a5496434f8 | |||
| 42984eed87 | |||
| 59c1c77d48 | |||
| dd13eb31d8 | |||
| 82810f1cb1 | |||
| 71c468d069 | |||
| cf40001a2a | |||
| d458928090 | |||
| 76e12af116 | |||
| b577057768 | |||
| dd4a165b08 | |||
| ab847a324b | |||
| cc5c097ddc | |||
| ea2330e23b | |||
| c5870452e3 | |||
| b73b529c00 | |||
| 8ef807d6cb | |||
| 1d4ddea596 | |||
| 4cba970cda | |||
| bad9dab0a0 | |||
| f11218a757 | |||
| 77e87e1d0c | |||
| a4aa513d1f | |||
| b73448f827 | |||
| 687ad68726 | |||
| 089eba3877 | |||
| 24b0cfd24e | |||
| 5950551da2 | |||
| e46948772e | |||
| 78fa1df99c | |||
| 0500368982 | |||
| 2d53323f60 | |||
| 08ca3cd3a5 | |||
| 16fd44a441 | |||
| 9a6ca7ac67 | |||
| 2c63bbff76 | |||
| d2c34757f9 | |||
| aeadc78578 | |||
| bf98f9792a | |||
| 6d2b59ef73 | |||
| 0a53018930 | |||
| f1cac8d1c4 | |||
| cfb76153a0 | |||
| fee2021ad7 | |||
| db956f7955 | |||
| 887e5c448e | |||
| 6ce275da7a | |||
| 213f516f71 |
@@ -40,9 +40,6 @@ dist/
|
||||
ontime-db
|
||||
ontime-external/
|
||||
|
||||
# working database
|
||||
apps/server/src/preloaded-db/db.json
|
||||
|
||||
# versioning file
|
||||
**/ONTIME_VERSION.js
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ COPY --from=builder /app/apps/client/build ./client/
|
||||
# Prepare Backend
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
COPY --from=builder /app/apps/server/src/user/ ./user/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
@@ -32,4 +33,4 @@ CMD ["node", "server/docker.cjs"]
|
||||
# Build and run commands
|
||||
# !!! Note that this command needs pre-build versions of the UI and server apps
|
||||
# docker buildx build . -t getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/external/db/ -v ./ontime-styles:/external/styles/ getontime/ontime
|
||||
# docker run -p 4001:4001 -p 8888:8888/udp -p 9999:9999/udp -v ./ontime-db:/data/ getontime/ontime
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useClientPath } from './common/hooks/useClientPath';
|
||||
import Log from './features/log/Log';
|
||||
import withPreset from './features/PresetWrapper';
|
||||
import withData from './features/viewers/ViewWrapper';
|
||||
import ViewLoader from './views/ViewLoader';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION';
|
||||
import { sentryDsn, sentryRecommendedIgnore } from './sentry.config';
|
||||
|
||||
@@ -27,7 +28,7 @@ const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
|
||||
const Backstage = React.lazy(() => import('./features/viewers/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./features/viewers/timeline/TimelinePage'));
|
||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./features/viewers/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
@@ -75,16 +76,72 @@ export default function AppRouter() {
|
||||
<React.Suspense fallback={null}>
|
||||
<SentryRoutes>
|
||||
<Route path='/' element={<Navigate to='/timer' />} />
|
||||
<Route path='/timer' element={<STimer />} />
|
||||
<Route path='/public' element={<SPublic />} />
|
||||
<Route path='/minimal' element={<SMinimalTimer />} />
|
||||
<Route path='/clock' element={<SClock />} />
|
||||
|
||||
<Route path='/countdown' element={<SCountdown />} />
|
||||
<Route path='/backstage' element={<SBackstage />} />
|
||||
<Route path='/studio' element={<SStudio />} />
|
||||
<Route
|
||||
path='/timer'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/public'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SPublic />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/minimal'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SMinimalTimer />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/clock'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SClock />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/countdown'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SCountdown />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/backstage'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SBackstage />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/studio'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SStudio />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
{/*/!* Lower third cannot have a loading screen *!/*/}
|
||||
<Route path='/lower' element={<SLowerThird />} />
|
||||
<Route path='/timeline' element={<STimeline />} />
|
||||
<Route
|
||||
path='/timeline'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<STimeline />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='/editor' element={<Editor />} />
|
||||
|
||||
@@ -32,5 +32,6 @@ export const projectDataURL = `${serverURL}/project`;
|
||||
export const rundownURL = `${serverURL}/events`;
|
||||
export const ontimeURL = `${serverURL}/ontime`;
|
||||
|
||||
export const stylesPath = 'external/styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${stylesPath}`;
|
||||
export const userAssetsPath = 'user';
|
||||
export const cssOverridePath = 'styles/override.css';
|
||||
export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { DatabaseModel, GetInfo, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
@@ -138,14 +138,6 @@ export async function deleteProject(filename: string): Promise<MessageResponse>
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${dbPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets project from db
|
||||
* @param fileName
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
|
||||
const sessionPath = `${apiEntryUrl}/session`;
|
||||
|
||||
/**
|
||||
* HTTP request to retrieve application info
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const res = await axios.get(`${sessionPath}/info`);
|
||||
return res.data;
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
transform: rotate(45deg);
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { IoCopy } from '@react-icons/all-files/io5/IoCopy';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
@@ -7,26 +8,34 @@ import { Size } from '../../models/Util.type';
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
|
||||
interface CopyTagProps {
|
||||
copyValue: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { label, className, size = 'xs', disabled, children } = props;
|
||||
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClick = () => copyToClipboard(children as string);
|
||||
const handleClick = () => {
|
||||
copyToClipboard(copyValue);
|
||||
setCopied(true);
|
||||
|
||||
// reset copied state
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup size={size} isAttached className={className}>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} isDisabled={disabled}>
|
||||
<ButtonGroup size={size} isAttached>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={<IoCopy />}
|
||||
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -72,9 +72,33 @@ $button-size: 3rem;
|
||||
|
||||
.linkIcon {
|
||||
margin-left: auto;
|
||||
transform: rotate(45deg);
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
margin-top: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -18,14 +18,17 @@ import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutl
|
||||
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
|
||||
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { isLocalhost, serverPort } from '../../api/constants';
|
||||
import useClickOutside from '../../hooks/useClickOutside';
|
||||
import useElectronEvent from '../../hooks/useElectronEvent';
|
||||
import useInfo from '../../hooks-query/useInfo';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { handleLinks } from '../../utils/linkUtils';
|
||||
import { handleLinks, openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import CopyTag from '../copy-tag/CopyTag';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -54,7 +57,7 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isOpenRename} onClose={onCloseRename} />
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen} variant='ontime' data-testid='navigation__menu'>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent>
|
||||
<DrawerContent maxWidth='21rem'>
|
||||
<DrawerHeader>
|
||||
<DrawerCloseButton size='lg' />
|
||||
Ontime
|
||||
@@ -105,25 +108,22 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Editor
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</Link>
|
||||
<ClientLink to='cuesheet' current={location.pathname === '/cuesheet'}>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</ClientLink>
|
||||
<hr className={style.separator} />
|
||||
{navigatorConstants.map((route) => (
|
||||
<ClientLink key={route.url} to={route.url} current={location.pathname === `/${route.url}`}>
|
||||
{route.label}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</ClientLink>
|
||||
))}
|
||||
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -132,6 +132,45 @@ function NavigationMenu(props: NavigationMenuProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface OtherAddressesProps {
|
||||
currentLocation: string;
|
||||
}
|
||||
|
||||
function OtherAddresses(props: OtherAddressesProps) {
|
||||
const { currentLocation } = props;
|
||||
const { data } = useInfo();
|
||||
|
||||
// there is no point showing this if we only have one interface
|
||||
if (data.networkInterfaces.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.bottom}>
|
||||
<div className={style.sectionHeader}>Accessible on external networks</div>
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
if (nif.name === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const address = `http://${nif.address}:${serverPort}${currentLocation}`;
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
copyValue={address}
|
||||
onClick={() => openLink(address)}
|
||||
label='Copy IP or navigate to address'
|
||||
>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientLinkProps {
|
||||
current: boolean;
|
||||
to: string;
|
||||
@@ -147,6 +186,7 @@ function ClientLink(props: PropsWithChildren<ClientLinkProps>) {
|
||||
return (
|
||||
<button className={classes} tabIndex={0} onClick={(event) => handleLinks(event, to)}>
|
||||
{children}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// attempt to match with ontimeTextInputs
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 1rem;
|
||||
font-size: 1rem;
|
||||
height: 2.5rem;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import PopoverPicker from '../input/popover-picker/PopoverPicker';
|
||||
|
||||
import style from './InlineColourPicker.module.scss';
|
||||
|
||||
interface InlineColourPickerProps {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ensureHex = (value: string) => {
|
||||
if (!value.startsWith('#')) {
|
||||
return `#${value}`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export default function InlineColourPicker(props: InlineColourPickerProps) {
|
||||
const { name, value } = props;
|
||||
const [colour, setColour] = useState(() => ensureHex(value));
|
||||
|
||||
const debouncedChange = (value: string) => {
|
||||
setColour(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.inline}>
|
||||
<PopoverPicker color={colour} onChange={debouncedChange} />
|
||||
<input type='hidden' name={name} value={colour} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
import { ParamField } from './types';
|
||||
|
||||
interface EditFormInputProps {
|
||||
@@ -23,8 +24,8 @@ interface EditFormInputProps {
|
||||
}
|
||||
|
||||
export default function ParamInput(props: EditFormInputProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { id, type, defaultValue } = paramField;
|
||||
|
||||
if (type === 'persist') {
|
||||
@@ -81,6 +82,12 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'colour') {
|
||||
const currentvalue = `#${searchParams.get(id) ?? defaultValue}`;
|
||||
|
||||
return <InlineColourPicker name={id} value={currentvalue} />;
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||
const { prefix, placeholder } = paramField;
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||
|
||||
/**
|
||||
* Utility remove the # character from a hex string
|
||||
*/
|
||||
function sanitiseColour(colour: string) {
|
||||
if (colour.startsWith('#')) {
|
||||
return colour.substring(1);
|
||||
}
|
||||
return colour;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a new URLSearchParams object from the given params object
|
||||
*/
|
||||
@@ -40,8 +50,13 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
|
||||
|
||||
// compare which values are different from the default values
|
||||
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
|
||||
newSearchParams.set(id, value);
|
||||
if (typeof value === 'string' && value.length) {
|
||||
// we dont know which values contain colours
|
||||
// unfortunately this means we run all the strings through the sanitation
|
||||
const valueWithoutHash = sanitiseColour(value);
|
||||
if (defaultValues[id] !== valueWithoutHash) {
|
||||
newSearchParams.set(id, valueWithoutHash);
|
||||
}
|
||||
}
|
||||
});
|
||||
return newSearchParams;
|
||||
|
||||
@@ -23,10 +23,11 @@ type MultiOptionsField = {
|
||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
||||
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
|
||||
|
||||
export type ParamField = BaseField &
|
||||
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
|
||||
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
|
||||
export type ViewOption = ParamSection | ParamField;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GetInfo } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { APP_INFO } from '../api/constants';
|
||||
import { getInfo } from '../api/db';
|
||||
import { getInfo } from '../api/session';
|
||||
import { ontimePlaceholderInfo } from '../models/Info';
|
||||
|
||||
export default function useInfo() {
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
export default function useElectronEvent() {
|
||||
const isElectron = window?.process?.type === 'renderer';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const sendToElectron = (channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron) {
|
||||
window?.ipcRenderer.send(channel, args);
|
||||
const isElectron = window.process?.type === 'renderer';
|
||||
const ipcRenderer = isElectron ? window.require('electron').ipcRenderer : null;
|
||||
|
||||
export default function useElectronEvent() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sendToElectron = useCallback((channel: string, args?: string | Record<string, unknown>) => {
|
||||
if (isElectron && ipcRenderer) {
|
||||
ipcRenderer.send(channel, args);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// listen to requests to change the editor location
|
||||
useEffect(() => {
|
||||
if (isElectron) {
|
||||
ipcRenderer.on('request-editor-location', (_event: unknown, location: string) => {
|
||||
navigate(location, { relative: 'route' });
|
||||
});
|
||||
}
|
||||
|
||||
// Clean the listener after the component is dismounted
|
||||
return () => {
|
||||
ipcRenderer?.removeAllListeners();
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
return { isElectron, sendToElectron };
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
parseUserTime,
|
||||
reorderArray,
|
||||
swapEventData,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
@@ -37,6 +35,7 @@ export const useEventAction = () => {
|
||||
const {
|
||||
defaultPublic,
|
||||
linkPrevious,
|
||||
defaultTimeStrategy,
|
||||
defaultDuration,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
@@ -116,11 +115,15 @@ export const useEventAction = () => {
|
||||
}
|
||||
|
||||
if (newEvent.timerType === undefined) {
|
||||
newEvent.timerType = validateTimerType(defaultTimerType);
|
||||
newEvent.timerType = defaultTimerType;
|
||||
}
|
||||
|
||||
if (newEvent.endAction === undefined) {
|
||||
newEvent.endAction = validateEndAction(defaultEndAction);
|
||||
newEvent.endAction = defaultEndAction;
|
||||
}
|
||||
|
||||
if (newEvent.timeStrategy === undefined) {
|
||||
newEvent.timeStrategy = defaultTimeStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +145,7 @@ export const useEventAction = () => {
|
||||
defaultEndAction,
|
||||
defaultPublic,
|
||||
defaultTimerType,
|
||||
defaultTimeStrategy,
|
||||
defaultWarnTime,
|
||||
linkPrevious,
|
||||
queryClient,
|
||||
|
||||
@@ -7,5 +7,5 @@ export const ontimePlaceholderInfo: GetInfo = {
|
||||
version: '2.0.0',
|
||||
serverPort: 4001,
|
||||
osc: oscPlaceholderSettings,
|
||||
cssOverride: '',
|
||||
publicDir: '',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EndAction, TimerType } from 'ontime-types';
|
||||
import { validateEndAction, validateTimerType } from 'ontime-utils';
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { validateEndAction, validateTimerType, validateTimeStrategy } from 'ontime-utils';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
@@ -7,6 +7,7 @@ import { booleanFromLocalStorage } from '../utils/localStorage';
|
||||
type EditorSettingsStore = {
|
||||
defaultDuration: string;
|
||||
linkPrevious: boolean;
|
||||
defaultTimeStrategy: TimeStrategy;
|
||||
defaultWarnTime: string;
|
||||
defaultDangerTime: string;
|
||||
defaultPublic: boolean;
|
||||
@@ -14,6 +15,7 @@ type EditorSettingsStore = {
|
||||
defaultEndAction: EndAction;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
setDefaultPublic: (defaultPublic: boolean) => void;
|
||||
@@ -24,6 +26,7 @@ type EditorSettingsStore = {
|
||||
export const editorSettingsDefaults = {
|
||||
duration: '00:10:00',
|
||||
linkPrevious: true,
|
||||
timeStrategy: TimeStrategy.LockDuration,
|
||||
warnTime: '00:02:00', // 120000 same as backend
|
||||
dangerTime: '00:01:00', // 60000 same as backend
|
||||
isPublic: true,
|
||||
@@ -34,6 +37,7 @@ export const editorSettingsDefaults = {
|
||||
enum EditorSettingsKeys {
|
||||
DefaultDuration = 'ontime-default-duration',
|
||||
LinkPrevious = 'ontime-link-previous',
|
||||
DefaultTimeStrategy = 'ontime-time-strategy',
|
||||
DefaultWarnTime = 'ontime-default-warn-time',
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
DefaultPublic = 'ontime-default-public',
|
||||
@@ -45,6 +49,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
return {
|
||||
defaultDuration: localStorage.getItem(EditorSettingsKeys.DefaultDuration) ?? editorSettingsDefaults.duration,
|
||||
linkPrevious: booleanFromLocalStorage(EditorSettingsKeys.LinkPrevious, editorSettingsDefaults.linkPrevious),
|
||||
defaultTimeStrategy: validateTimeStrategy(
|
||||
localStorage.getItem(EditorSettingsKeys.DefaultTimeStrategy),
|
||||
editorSettingsDefaults.timeStrategy,
|
||||
),
|
||||
defaultWarnTime: localStorage.getItem(EditorSettingsKeys.DefaultWarnTime) ?? editorSettingsDefaults.warnTime,
|
||||
defaultDangerTime: localStorage.getItem(EditorSettingsKeys.DefaultDangerTime) ?? editorSettingsDefaults.dangerTime,
|
||||
defaultPublic: booleanFromLocalStorage(EditorSettingsKeys.DefaultPublic, editorSettingsDefaults.isPublic),
|
||||
@@ -68,6 +76,12 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.LinkPrevious, String(linkPrevious));
|
||||
return { linkPrevious };
|
||||
}),
|
||||
setTimeStrategy: (defaultTimeStrategy) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultTimeStrategy, String(defaultTimeStrategy));
|
||||
return { defaultTimeStrategy };
|
||||
}),
|
||||
|
||||
setWarnTime: (defaultWarnTime) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultWarnTime, String(defaultWarnTime));
|
||||
|
||||
@@ -1,58 +1,7 @@
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
||||
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types';
|
||||
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
clock: 0,
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: TimerPhase.None,
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
onAir: false,
|
||||
message: {
|
||||
timer: {
|
||||
text: '',
|
||||
visible: false,
|
||||
blink: false,
|
||||
blackout: false,
|
||||
secondarySource: null,
|
||||
},
|
||||
external: '',
|
||||
},
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 0,
|
||||
offset: 0,
|
||||
plannedStart: 0,
|
||||
plannedEnd: 0,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
},
|
||||
currentBlock: {
|
||||
block: null,
|
||||
startedAt: null,
|
||||
},
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNow: null,
|
||||
publicEventNext: null,
|
||||
auxtimer1: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
},
|
||||
frozen: false,
|
||||
};
|
||||
|
||||
const deepCompare = <T>(a: T, b: T) => isEqual(a, b);
|
||||
|
||||
export const runtimeStore = createWithEqualityFn<RuntimeStore>(
|
||||
|
||||
@@ -3,3 +3,4 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
|
||||
export const websiteUrl = 'https://www.getontime.no';
|
||||
|
||||
export const documentationUrl = 'https://docs.getontime.no';
|
||||
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.halfWidth {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -37,3 +41,9 @@
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.twoCols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function UrlPresetsForm() {
|
||||
|
||||
const addNew = () => {
|
||||
prepend({
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
alias: '',
|
||||
pathAndParams: '',
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
|
||||
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
|
||||
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
@@ -36,6 +37,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
onSubmit={handleEdit}
|
||||
initialColour={colour}
|
||||
initialLabel={label}
|
||||
initialKey={field}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -47,7 +49,12 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
|
||||
<td>
|
||||
<Swatch color={colour} />
|
||||
</td>
|
||||
<td className={style.fullWidth}>{label}</td>
|
||||
<td className={style.halfWidth}>{label}</td>
|
||||
<td className={style.fullWidth}>
|
||||
<CopyTag label='Copy key to use in integrations' copyValue={field}>
|
||||
{field}
|
||||
</CopyTag>
|
||||
</td>
|
||||
<td className={style.actions}>
|
||||
<IconButton
|
||||
size='sm'
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { CustomField } from 'ontime-types';
|
||||
import { isAlphanumeric } from 'ontime-utils';
|
||||
import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import style from '../FeatureSettings.module.scss';
|
||||
@@ -15,10 +16,13 @@ interface CustomFieldsFormProps {
|
||||
onCancel: () => void;
|
||||
initialColour?: string;
|
||||
initialLabel?: string;
|
||||
initialKey?: string;
|
||||
}
|
||||
|
||||
export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
const { onSubmit, onCancel, initialColour, initialLabel } = props;
|
||||
const { onSubmit, onCancel, initialColour, initialLabel, initialKey } = props;
|
||||
const { data } = useCustomFields();
|
||||
|
||||
// we use this to force an update
|
||||
const [_, setColour] = useState(initialColour || '');
|
||||
|
||||
@@ -31,7 +35,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid, isDirty },
|
||||
} = useForm({
|
||||
defaultValues: { label: initialLabel || '', colour: initialColour || '' },
|
||||
defaultValues: { label: initialLabel || '', colour: initialColour || '', key: initialKey || '' },
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
@@ -66,28 +70,38 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
<div className={style.column}>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumeric(value)) return 'Only alphanumeric characters are allowed';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div className={style.twoCols}>
|
||||
<div>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
onChange: () => setValue('key', customFieldLabelToKey(getValues('label')) ?? 'N/A'),
|
||||
validate: (value) => {
|
||||
if (value.trim().length === 0) return 'Required field';
|
||||
if (!isAlphanumericWithSpace(value)) return 'Only alphanumeric characters and space are allowed';
|
||||
if (Object.keys(data).includes(value)) return 'Custom fields must be unique';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Key (auto-generated value for use in Integrations and API)</Panel.Description>
|
||||
<Input {...register('key')} disabled size='sm' variant='ontime-filled' autoComplete='off' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Panel.Description>Colour</Panel.Description>
|
||||
<SwatchSelect name='colour' value={colour} handleChange={(_field, value) => handleSelectColour(value)} />
|
||||
</div>
|
||||
|
||||
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<div className={style.buttonRow}>
|
||||
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
|
||||
|
||||
@@ -6,13 +6,12 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
|
||||
import CustomFieldEntry from './CustomFieldEntry';
|
||||
import CustomFieldForm from './CustomFieldForm';
|
||||
|
||||
const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
|
||||
|
||||
export default function CustomFields() {
|
||||
const { data, refetch } = useCustomFields();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
@@ -59,9 +58,14 @@ export default function CustomFields() {
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Custom fields allow for additional information to be added to an event (eg. light, sound, camera). <br />
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
This data is not used by Ontime.
|
||||
<br />
|
||||
This data is not used by Ontime, but provides place for cueing or department specific information (eg.
|
||||
light, sound, camera).
|
||||
<br />
|
||||
<br />
|
||||
Custom fields can be used width the Integrations feature using the generated key.
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -73,6 +77,7 @@ export default function CustomFields() {
|
||||
<tr>
|
||||
<th>Colour</th>
|
||||
<th>Name</th>
|
||||
<th>Key (used in Integrations)</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -84,8 +84,8 @@ export default function ViewSettingsForm() {
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
You can override the styles of the viewers with a custom CSS file. <br />
|
||||
{info?.cssOverride && `In your installation the file is at ${info?.cssOverride}`}
|
||||
You can the Ontime views or customise its styles by modifying the provided CSS file. <br />
|
||||
The CSS file is in the user directory at {`${info.publicDir}/user/styles/override.css`}
|
||||
<br />
|
||||
<br />
|
||||
<ExternalLink href={cssOverrideDocsUrl}>See the docs</ExternalLink>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function HttpIntegrations() {
|
||||
id: generateId(),
|
||||
cycle: 'onLoad',
|
||||
message: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function OscIntegrations() {
|
||||
cycle: 'onLoad',
|
||||
address: '',
|
||||
payload: '',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Select, Switch } from '@chakra-ui/react';
|
||||
import { EndAction, TimerType } from 'ontime-types';
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
@@ -10,6 +10,7 @@ export default function EditorSettingsForm() {
|
||||
const {
|
||||
defaultDuration,
|
||||
linkPrevious,
|
||||
defaultTimeStrategy,
|
||||
defaultWarnTime,
|
||||
defaultDangerTime,
|
||||
defaultPublic,
|
||||
@@ -17,6 +18,7 @@ export default function EditorSettingsForm() {
|
||||
defaultEndAction,
|
||||
setDefaultDuration,
|
||||
setLinkPrevious,
|
||||
setTimeStrategy,
|
||||
setWarnTime,
|
||||
setDangerTime,
|
||||
setDefaultPublic,
|
||||
@@ -36,15 +38,6 @@ export default function EditorSettingsForm() {
|
||||
<Panel.Section>
|
||||
<Panel.Title>Rundown defaults for new events</Panel.Title>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default duration' description='Default duration for new events' />
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder={editorSettingsDefaults.duration}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Link previous'
|
||||
@@ -57,6 +50,33 @@ export default function EditorSettingsForm() {
|
||||
onChange={(event) => setLinkPrevious(event.target.checked)}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Timer strategy'
|
||||
description='Which time should be maintained when event schedule is recalculated'
|
||||
/>
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
width='auto'
|
||||
value={defaultTimeStrategy}
|
||||
onChange={(event) => setTimeStrategy(event.target.value as TimeStrategy)}
|
||||
>
|
||||
<option value={TimeStrategy.LockDuration}>Duration</option>
|
||||
<option value={TimeStrategy.LockEnd}>End Time</option>
|
||||
</Select>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Default duration' description='Default duration for new events' />
|
||||
<TimeInput<'defaultDuration'>
|
||||
name='defaultDuration'
|
||||
submitHandler={(_field, value) => setDefaultDuration(value)}
|
||||
time={durationInMs}
|
||||
placeholder={editorSettingsDefaults.duration}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Timer type' description='Default type of timer for new events' />
|
||||
<Select
|
||||
|
||||
@@ -4,3 +4,9 @@
|
||||
gap: $section-spacing;
|
||||
row-gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { serverPort } from '../../../../common/api/constants';
|
||||
import AppLink from '../../../../common/components/app-link/AppLink';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { openLink } from '../../../../common/utils/linkUtils';
|
||||
|
||||
import style from './NetworkInterfaces.module.scss';
|
||||
|
||||
export default function InfoNif() {
|
||||
const { data } = useInfo();
|
||||
|
||||
const handleClick = (address: string) => openLink(address);
|
||||
|
||||
return (
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => (
|
||||
<AppLink key={nif.address} href={`http://${nif.address}:${serverPort}`}>
|
||||
{`${nif.name} - ${nif.address}`}
|
||||
</AppLink>
|
||||
))}
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
const address = `http://${nif.address}:${serverPort}`;
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
copyValue={address}
|
||||
onClick={() => handleClick(address)}
|
||||
label='Copy IP or navigate to address'
|
||||
>
|
||||
{`${nif.name} - ${nif.address}`} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChangeEvent, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button, Input } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
|
||||
@@ -13,14 +14,17 @@ import ProjectList from './ProjectList';
|
||||
import style from './ProjectPanel.module.scss';
|
||||
|
||||
export default function ManageProjects() {
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState<'import' | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isCreatingProject = searchParams.get('create') === 'true';
|
||||
|
||||
const handleToggleCreate = () => {
|
||||
setIsCreatingProject((prev) => !prev);
|
||||
searchParams.set('create', isCreatingProject ? 'false' : 'true');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const handleSelectFile = () => {
|
||||
@@ -49,7 +53,8 @@ export default function ManageProjects() {
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setIsCreatingProject(false);
|
||||
searchParams.delete('create');
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -193,7 +193,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
|
||||
<Panel.ListGroup>
|
||||
<div className={style.buttonRow}>
|
||||
{isAuthenticating && <Spinner />}
|
||||
<CopyTag label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
|
||||
{authKey ? authKey : 'Upload files to generate Auth Key'}
|
||||
</CopyTag>
|
||||
<Button
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Select, Tooltip } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
import { ImportMap, isAlphanumericWithSpace } from 'ontime-utils';
|
||||
|
||||
import { isAlphanumeric } from '../../../../../common/utils/regex';
|
||||
import * as Panel from '../../PanelUtils';
|
||||
import useGoogleSheet from '../useGoogleSheet';
|
||||
import { useSheetStore } from '../useSheetStore';
|
||||
@@ -190,9 +189,10 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
defaultValue={ontimeName}
|
||||
placeholder='Name of the field as shown in Ontime'
|
||||
{...register(`custom.${index}.ontimeName`, {
|
||||
pattern: {
|
||||
value: isAlphanumeric,
|
||||
message: 'Custom field name must be alphanumeric',
|
||||
validate: (value) => {
|
||||
if (!isAlphanumericWithSpace(value))
|
||||
return 'Only alphanumeric characters and space are allowed';
|
||||
return true;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
// we only count Ontime Events which are 1 based in client
|
||||
let eventIndex = 0;
|
||||
|
||||
const fieldHeaders = Object.keys(customFields);
|
||||
const fieldKeys = Object.keys(customFields);
|
||||
const fieldLabels = fieldKeys.map((key) => customFields[key].label);
|
||||
|
||||
return (
|
||||
<Panel.Table>
|
||||
@@ -44,8 +45,8 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<th>Colour</th>
|
||||
<th>Timer Type</th>
|
||||
<th>End Action</th>
|
||||
{fieldHeaders.map((field) => (
|
||||
<th key={field}>{field}</th>
|
||||
{fieldLabels.map((label) => (
|
||||
<th key={label}>{label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -102,7 +103,7 @@ export default function PreviewRundown(props: PreviewRundownProps) {
|
||||
<Tag>{event.endAction}</Tag>
|
||||
</td>
|
||||
{isOntimeEvent(event) &&
|
||||
fieldHeaders.map((field) => {
|
||||
fieldKeys.map((field) => {
|
||||
let value = '';
|
||||
if (field in event.custom) {
|
||||
value = event.custom[field];
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
.corner {
|
||||
transform: rotate(45deg);
|
||||
@include rotate-fourty-five;
|
||||
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function TimerPreview() {
|
||||
<div className={contentClasses}>
|
||||
<div
|
||||
className={style.mainContent}
|
||||
data-phase={phase}
|
||||
data-phase={showColourOverride && phase}
|
||||
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||
>
|
||||
{main}
|
||||
|
||||
@@ -29,10 +29,12 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
showPrevious,
|
||||
togglePreviousVisibility,
|
||||
showDelayBlock,
|
||||
hideSeconds,
|
||||
showDelayedTimes,
|
||||
toggleIndexColumn,
|
||||
toggleDelayedTimes,
|
||||
toggleDelayVisibility,
|
||||
toggleSecondsVisibility,
|
||||
} = useCuesheetSettings();
|
||||
|
||||
return (
|
||||
@@ -80,6 +82,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
<Switch variant='ontime' size='sm' isChecked={showDelayBlock} onChange={() => toggleDelayVisibility()} />
|
||||
Show delay blocks
|
||||
</label>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={hideSeconds} onChange={() => toggleSecondsVisibility()} />
|
||||
Hide seconds
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.rightPanel}>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
|
||||
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../common/components/delay-indicator/DelayIndicator';
|
||||
import RunningTime from '../viewers/common/running-time/RunningTime';
|
||||
@@ -19,20 +18,28 @@ function makePublic(row: CellContext<OntimeRundownEntry, unknown>) {
|
||||
|
||||
function MakeTimer({ getValue, row: { original } }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const showDelayedTimes = useCuesheetSettings((state) => state.showDelayedTimes);
|
||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
const delayValue = (original as OntimeEvent)?.delay ?? 0;
|
||||
|
||||
return (
|
||||
<span className={style.time}>
|
||||
<DelayIndicator delayValue={delayValue} />
|
||||
<RunningTime value={cellValue} />
|
||||
<RunningTime value={cellValue} hideSeconds={hideSeconds} />
|
||||
{delayValue !== 0 && showDelayedTimes && (
|
||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} />
|
||||
<RunningTime className={style.delayedTime} value={cellValue + delayValue} hideSeconds={hideSeconds} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const hideSeconds = useCuesheetSettings((state) => state.hideSeconds);
|
||||
const cellValue = (getValue() as number | null) ?? 0;
|
||||
|
||||
return <RunningTime value={cellValue} hideSeconds={hideSeconds} />;
|
||||
}
|
||||
|
||||
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
@@ -97,7 +104,7 @@ export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<Ontim
|
||||
accessorKey: 'duration',
|
||||
id: 'duration',
|
||||
header: 'Duration',
|
||||
cell: (row) => millisToString(row.getValue() as number | null),
|
||||
cell: MakeDuration,
|
||||
size: 75,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ interface CuesheetSettings {
|
||||
showPrevious: boolean;
|
||||
showDelayBlock: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
hideSeconds: boolean;
|
||||
|
||||
toggleSettings: (newValue?: boolean) => void;
|
||||
toggleFollow: (newValue?: boolean) => void;
|
||||
@@ -16,6 +17,7 @@ interface CuesheetSettings {
|
||||
toggleIndexColumn: (newValue?: boolean) => void;
|
||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
||||
toggleSecondsVisibility: (newValue?: boolean) => void;
|
||||
}
|
||||
|
||||
function toggle(oldValue: boolean, value?: boolean) {
|
||||
@@ -31,6 +33,7 @@ enum CuesheetKeys {
|
||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
||||
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
||||
Seconds = 'ontime-cuesheet-hide-sceconds',
|
||||
}
|
||||
|
||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
@@ -40,6 +43,7 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
||||
showDelayedTimes: booleanFromLocalStorage(CuesheetKeys.DelayedTimes, false),
|
||||
hideSeconds: booleanFromLocalStorage(CuesheetKeys.Seconds, false),
|
||||
|
||||
toggleSettings: (newValue?: boolean) => set((state) => ({ showSettings: toggle(state.showSettings, newValue) })),
|
||||
toggleFollow: (newValue?: boolean) =>
|
||||
@@ -72,4 +76,10 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
localStorage.setItem(CuesheetKeys.DelayedTimes, String(showDelayedTimes));
|
||||
return { showDelayedTimes };
|
||||
}),
|
||||
toggleSecondsVisibility: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const hideSeconds = toggle(state.hideSeconds, newValue);
|
||||
localStorage.setItem(CuesheetKeys.Seconds, String(hideSeconds));
|
||||
return { hideSeconds };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CSSProperties, useCallback, useEffect, useState } from 'react';
|
||||
import { CSSProperties, memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
@@ -126,10 +126,32 @@ export default function EventEditor() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.footer}>
|
||||
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
|
||||
<CopyTag label='OSC trigger by cue'>{`/ontime/load/cue "${event.cue}"`}</CopyTag>
|
||||
</div>
|
||||
<EventEditorFooter id={event.id} cue={event.cue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EventEditorFooterProps {
|
||||
id: string;
|
||||
cue: string;
|
||||
}
|
||||
|
||||
const EventEditorFooter = memo(_EventEditorFooter);
|
||||
|
||||
function _EventEditorFooter(props: EventEditorFooterProps) {
|
||||
const { id, cue } = props;
|
||||
|
||||
const loadById = `/ontime/load/id "${id}"`;
|
||||
const loadByCue = `/ontime/load/cue "${cue}"`;
|
||||
|
||||
return (
|
||||
<div className={style.footer}>
|
||||
<CopyTag copyValue={loadById} label='OSC trigger by ID'>
|
||||
{loadById}
|
||||
</CopyTag>
|
||||
<CopyTag copyValue={loadByCue} label='OSC trigger by cue'>
|
||||
{loadByCue}
|
||||
</CopyTag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
|
||||
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
|
||||
|
||||
import style from './TimeInputFlow.module.scss';
|
||||
|
||||
@@ -66,10 +66,12 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
placeholder='Start'
|
||||
disabled={Boolean(linkStart)}
|
||||
>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
<span className={style.timeLabel}>S</span>
|
||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||
</InputRightElement>
|
||||
<Tooltip label='Link start to previous end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
|
||||
<span className={style.timeLabel}>S</span>
|
||||
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<TimeInputWithButton<TimeActions>
|
||||
@@ -80,14 +82,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
disabled={isLockedDuration}
|
||||
placeholder='End'
|
||||
>
|
||||
<InputRightElement
|
||||
className={activeEnd}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||
data-testid='lock__end'
|
||||
>
|
||||
<span className={style.timeLabel}>E</span>
|
||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
<Tooltip label='Lock end' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeEnd}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
|
||||
data-testid='lock__end'
|
||||
>
|
||||
<span className={style.timeLabel}>E</span>
|
||||
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
<TimeInputWithButton<TimeActions>
|
||||
@@ -97,14 +101,16 @@ const TimeInputFlow = (props: EventBlockTimerProps) => {
|
||||
disabled={isLockedEnd}
|
||||
placeholder='Duration'
|
||||
>
|
||||
<InputRightElement
|
||||
className={activeDuration}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||
data-testid='lock__duration'
|
||||
>
|
||||
<span className={style.timeLabel}>D</span>
|
||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
<Tooltip label='Lock duration' openDelay={tooltipDelayMid}>
|
||||
<InputRightElement
|
||||
className={activeDuration}
|
||||
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
|
||||
data-testid='lock__duration'
|
||||
>
|
||||
<span className={style.timeLabel}>D</span>
|
||||
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
|
||||
</InputRightElement>
|
||||
</Tooltip>
|
||||
</TimeInputWithButton>
|
||||
|
||||
{overMidnight && (
|
||||
|
||||
@@ -86,11 +86,6 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
timerType: eventNow?.timerType ?? null,
|
||||
};
|
||||
|
||||
// prevent render until we get all the data we need
|
||||
if (!viewSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewNavigationMenu />
|
||||
|
||||
@@ -2,17 +2,15 @@ import { useEffect, useState } from 'react';
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, SupportedEvent } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ProgressBar from '../../../common/components/progress-bar/ProgressBar';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { timerPlaceholderMin } from '../../../common/utils/styleUtils';
|
||||
@@ -37,25 +35,12 @@ interface BackstageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
selectedId: string | null;
|
||||
general: ProjectData;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Backstage(props: BackstageProps) {
|
||||
const {
|
||||
customFields,
|
||||
isMirrored,
|
||||
eventNow,
|
||||
eventNext,
|
||||
time,
|
||||
backstageEvents,
|
||||
selectedId,
|
||||
general,
|
||||
viewSettings,
|
||||
settings,
|
||||
} = props;
|
||||
const { customFields, isMirrored, eventNow, eventNext, time, backstageEvents, selectedId, general, settings } = props;
|
||||
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [blinkClass, setBlinkClass] = useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -73,11 +58,6 @@ export default function Backstage(props: BackstageProps) {
|
||||
return () => clearTimeout(timer);
|
||||
}, [selectedId]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock);
|
||||
const startedAt = formatTime(time.startedAt);
|
||||
const isNegative = (time.current ?? 0) < 0;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Settings, ViewSettings } from 'ontime-types';
|
||||
import { Settings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { OverridableOptions } from '../../../common/models/View.types';
|
||||
@@ -17,22 +15,15 @@ import './Clock.scss';
|
||||
interface ClockProps {
|
||||
isMirrored: boolean;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Clock(props: ClockProps) {
|
||||
const { isMirrored, time, viewSettings, settings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { isMirrored, time, settings } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Clock');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// get config from url: key, text, font, size, hidenav
|
||||
// eg. http://localhost:3000/clock?key=f00&text=fff
|
||||
// Check for user options
|
||||
|
||||
@@ -8,26 +8,23 @@ export const getClockOptions = (timeFormat: string): ViewOption[] => [
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background or key colour for entire view. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'fffff (default)',
|
||||
description: 'Text colour. Default: #FFFFFF',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFFFFF',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Colour of text background in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
OntimeEvent,
|
||||
OntimeRundownEntry,
|
||||
Playback,
|
||||
Runtime,
|
||||
Settings,
|
||||
SupportedEvent,
|
||||
TimerPhase,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { OntimeEvent, OntimeRundownEntry, Playback, Runtime, Settings, SupportedEvent, TimerPhase } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
@@ -34,12 +23,10 @@ interface CountdownProps {
|
||||
selectedId: string | null;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
export default function Countdown(props: CountdownProps) {
|
||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { isMirrored, backstageEvents, runtime, selectedId, settings, time } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
@@ -80,11 +67,6 @@ export default function Countdown(props: CountdownProps) {
|
||||
}
|
||||
}, [backstageEvents, searchParams]);
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
|
||||
|
||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
||||
|
||||
@@ -39,16 +39,16 @@ const defaultOptions: Readonly<LowerOptions> = {
|
||||
width: 45,
|
||||
topSrc: 'title',
|
||||
bottomSrc: 'lowerMsg',
|
||||
topColour: '000000ff',
|
||||
bottomColour: '000000ff',
|
||||
topBg: '00000000',
|
||||
bottomBg: '00000000',
|
||||
topColour: '000000',
|
||||
bottomColour: '000000',
|
||||
topBg: 'FFF0',
|
||||
bottomBg: 'FFF0',
|
||||
topSize: '65px',
|
||||
bottomSize: '40px',
|
||||
transition: 3,
|
||||
delay: 3,
|
||||
key: 'ffffffff',
|
||||
lineColour: 'ff0000ff',
|
||||
key: 'FFF0',
|
||||
lineColour: 'FF0000',
|
||||
lineHeight: '0.4em',
|
||||
};
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { ViewOption } from '../../../common/components/view-params-editor/types'
|
||||
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
|
||||
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
note: 'Note',
|
||||
});
|
||||
|
||||
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
|
||||
title: 'Title',
|
||||
note: 'Note',
|
||||
none: 'None',
|
||||
});
|
||||
|
||||
@@ -31,39 +33,22 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
||||
values: bottomSourceOptions,
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{ section: 'View animation' },
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'delay',
|
||||
title: 'Delay',
|
||||
description: 'Delay between transition in and out in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{ section: 'View style override' },
|
||||
{
|
||||
id: 'top-colour',
|
||||
title: 'Top Text Colour',
|
||||
description: 'Top text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '0000ff (default)',
|
||||
},
|
||||
{
|
||||
id: 'bottom-colour',
|
||||
title: 'Bottom Text Colour',
|
||||
description: 'Bottom text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '0000ff (default)',
|
||||
},
|
||||
{
|
||||
id: 'top-bg',
|
||||
title: 'Top Background Colour',
|
||||
description: 'Top text background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
},
|
||||
{
|
||||
id: 'bottom-bg',
|
||||
title: 'Bottom Background Colour',
|
||||
description: 'Bottom text background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
},
|
||||
{
|
||||
id: 'top-size',
|
||||
title: 'Top Text Size',
|
||||
@@ -86,35 +71,47 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
|
||||
prefix: '%',
|
||||
placeholder: '45 (default)',
|
||||
},
|
||||
{
|
||||
id: 'transition',
|
||||
title: 'Transition',
|
||||
description: 'Transition in time in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'delay',
|
||||
title: 'Delay',
|
||||
description: 'Delay between transition in and out in seconds (default 3)',
|
||||
type: 'number',
|
||||
placeholder: '3 (default)',
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Colour of the background',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'ffffffff (default)',
|
||||
description: 'Colour of the background. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'top-colour',
|
||||
title: 'Top Text Colour',
|
||||
description: 'Top text colour. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'bottom-colour',
|
||||
title: 'Bottom Text Colour',
|
||||
description: 'Bottom text colour. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'top-bg',
|
||||
title: 'Top Background Colour',
|
||||
description: 'Top text background colour. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'bottom-bg',
|
||||
title: 'Bottom Background Colour',
|
||||
description: 'Bottom text background colour. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'line-colour',
|
||||
title: 'Line Colour',
|
||||
description: 'Colour of the line',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'ff0000ff (default)',
|
||||
description: 'Colour of the line. Default: #FF0000',
|
||||
type: 'colour',
|
||||
defaultValue: 'FF0000',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { OverridableOptions } from '../../../common/models/View.types';
|
||||
@@ -22,17 +20,11 @@ interface MinimalTimerProps {
|
||||
|
||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const { isMirrored, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Minimal Timer');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: this should be tied to the params
|
||||
// USER OPTIONS
|
||||
const userOptions: OverridableOptions = {
|
||||
@@ -169,6 +161,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
<div
|
||||
className={timerClasses}
|
||||
style={{
|
||||
color: userOptions.textColour,
|
||||
fontSize: `${timerFontSize}vw`,
|
||||
fontFamily: userOptions.font,
|
||||
top: userOptions.top,
|
||||
|
||||
@@ -23,26 +23,23 @@ export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
|
||||
{
|
||||
id: 'key',
|
||||
title: 'Key Colour',
|
||||
description: 'Background colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background or key colour for entire view. Default: #000000',
|
||||
type: 'colour',
|
||||
defaultValue: '000000',
|
||||
},
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text Colour',
|
||||
description: 'Text colour in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: 'fffff (default)',
|
||||
description: 'Text colour. Default: #FFFFFF',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFFFFF',
|
||||
},
|
||||
{
|
||||
id: 'textbg',
|
||||
title: 'Text Background',
|
||||
description: 'Colour of text background in hexadecimal',
|
||||
prefix: '#',
|
||||
type: 'string',
|
||||
placeholder: '00000000 (default)',
|
||||
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
|
||||
type: 'colour',
|
||||
defaultValue: 'FFF0',
|
||||
},
|
||||
{
|
||||
id: 'font',
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import QRCode from 'react-qr-code';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
@@ -33,7 +31,6 @@ interface BackstageProps {
|
||||
events: OntimeEvent[];
|
||||
publicSelectedId: string | null;
|
||||
general: ProjectData;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
@@ -47,21 +44,14 @@ export default function Public(props: BackstageProps) {
|
||||
events,
|
||||
publicSelectedId,
|
||||
general,
|
||||
viewSettings,
|
||||
settings,
|
||||
} = props;
|
||||
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Public Schedule');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clock = formatTime(time.clock);
|
||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/comp
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
|
||||
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields);
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
|
||||
return [
|
||||
{ section: 'Clock Options' },
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import type { MaybeString, OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
|
||||
import type { MaybeString, OntimeEvent, OntimeRundown, Settings } from 'ontime-types';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
@@ -25,24 +23,16 @@ interface StudioClockProps {
|
||||
selectedId: MaybeString;
|
||||
nextId: MaybeString;
|
||||
onAir: boolean;
|
||||
viewSettings: ViewSettings;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function StudioClock(props: StudioClockProps) {
|
||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
|
||||
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, settings } = props;
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Studio Clock');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeIndicators = [...Array(12).keys()];
|
||||
const secondsIndicators = [...Array(60).keys()];
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||
import TimelineProgressBar from './timeline-progress-bar/TimelineProgressBar';
|
||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
|
||||
|
||||
import style from './Timeline.module.scss';
|
||||
|
||||
interface TimelineProps {
|
||||
firstStart: number;
|
||||
rundown: OntimeRundown;
|
||||
selectedEventId: string | null;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
export default memo(Timeline);
|
||||
|
||||
function Timeline(props: TimelineProps) {
|
||||
const { firstStart, rundown, selectedEventId, totalDuration } = props;
|
||||
const { width: screenWidth } = useViewportSize();
|
||||
|
||||
if (totalDuration === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { lastEvent } = getLastEvent(rundown);
|
||||
const startHour = getStartHour(firstStart);
|
||||
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
||||
|
||||
let previousEventStartTime: MaybeNumber = null;
|
||||
// we use selectedEventId as a signifier on whether the timeline is live
|
||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||
let elapsedDays = 0;
|
||||
|
||||
return (
|
||||
<div className={style.timeline}>
|
||||
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
||||
<TimelineProgressBar startHour={startHour} endHour={endHour} />
|
||||
<div className={style.timelineEvents}>
|
||||
{rundown.map((event) => {
|
||||
// for now we dont render delays and blocks
|
||||
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// keep track of progress of rundown
|
||||
if (eventStatus === 'live') {
|
||||
eventStatus = 'future';
|
||||
}
|
||||
if (event.id === selectedEventId) {
|
||||
eventStatus = 'live';
|
||||
}
|
||||
|
||||
// we only need to check for next day if we have a previous event
|
||||
if (
|
||||
previousEventStartTime !== null &&
|
||||
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
||||
) {
|
||||
elapsedDays++;
|
||||
}
|
||||
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
||||
|
||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||
startHour * MILLIS_PER_HOUR,
|
||||
endHour * MILLIS_PER_HOUR,
|
||||
normalisedStart + (event.delay ?? 0),
|
||||
event.duration,
|
||||
screenWidth,
|
||||
);
|
||||
|
||||
// prepare values for next iteration
|
||||
previousEventStartTime = normalisedStart;
|
||||
|
||||
return (
|
||||
<TimelineEntry
|
||||
key={event.id}
|
||||
colour={event.colour}
|
||||
delay={event.delay ?? 0}
|
||||
duration={event.duration}
|
||||
left={elementLeftPosition}
|
||||
status={eventStatus}
|
||||
start={normalisedStart} // dataset solves issues related to crossing midnight
|
||||
title={event.title}
|
||||
width={elementWidth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.markers {
|
||||
width: 100%;
|
||||
color: $ui-white;
|
||||
display: flex;
|
||||
height: 1rem;
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
justify-content: space-evenly;
|
||||
|
||||
& > span {
|
||||
flex-grow: 1;
|
||||
border-left: 1px solid $white-7;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.progressBar {
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
position: relative;
|
||||
|
||||
background-color: $gray-1000;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
|
||||
background-color: $active-red;
|
||||
transition-duration: 0.3s;
|
||||
transition-property: width;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { useClock } from '../../../../common/hooks/useSocket';
|
||||
import { getRelativePositionX } from '../timeline.utils';
|
||||
|
||||
import style from './TimelineProgressBar.module.scss';
|
||||
|
||||
interface ProgressBarProps {
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { startHour, endHour } = props;
|
||||
// TODO: how to account for days?
|
||||
const { clock } = useClock();
|
||||
|
||||
const width = getRelativePositionX(startHour * MILLIS_PER_HOUR, endHour * MILLIS_PER_HOUR, clock);
|
||||
|
||||
return (
|
||||
<div className={style.progressBar}>
|
||||
<div className={style.progress} style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import { FitText } from '../../../common/components/fit-text/FitText';
|
||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
@@ -62,17 +60,11 @@ interface TimerProps {
|
||||
export default function Timer(props: TimerProps) {
|
||||
const { auxTimer, customFields, eventNow, eventNext, isMirrored, message, settings, time, viewSettings } = props;
|
||||
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
useWindowTitle('Timer');
|
||||
|
||||
// defer rendering until we load stylesheets
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// USER OPTIONS
|
||||
const userOptions = {
|
||||
hideClock: false,
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
|
||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
|
||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
return [
|
||||
{ section: 'Clock Options' },
|
||||
getTimeOption(timeFormat),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const ontimeAlertOnDark = {
|
||||
container: {
|
||||
fontSize: 'calc(1rem - 1px)',
|
||||
backgroundColor: '#1a1a1a', // $gray-1300
|
||||
backgroundColor: '#202020', // $gray-1200
|
||||
color: '#e2e2e2', // $gray-200
|
||||
borderRadius: '3px',
|
||||
},
|
||||
|
||||
@@ -21,3 +21,11 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@mixin rotate-fourty-five {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.fourtyfive {
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
$transition-time-action: 0.1s;
|
||||
$transition-time-feedback: 0.3s;
|
||||
|
||||
$blinking-time: 1.2s;
|
||||
|
||||
$component-border-radius-md: 3px;
|
||||
$component-border-radius-sm: 2px;
|
||||
|
||||
@@ -61,10 +59,13 @@ $text-body-size: calc(1rem - 1px);
|
||||
$min-tablet: 500px;
|
||||
|
||||
.blink {
|
||||
animation: blink $blinking-time linear infinite;
|
||||
animation: blink 1s step-start infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 100%;
|
||||
}
|
||||
50% {
|
||||
opacity: 20%;
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ $external-color: rgba(white, 85%); // --external-color-override
|
||||
|
||||
// properties of other timers (clock and countdown)
|
||||
$timer-label-size: clamp(16px, 1.5vw, 24px);
|
||||
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
|
||||
|
||||
@@ -4,6 +4,8 @@ export const ontimeDrawer = {
|
||||
backgroundColor: '#202020', // $gray-1250
|
||||
},
|
||||
body: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: '#fefefe', // $gray-50
|
||||
backgroundColor: '#202020', // $gray-1250
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const navigatorConstants = [
|
||||
{ url: 'timer', label: 'Timer' },
|
||||
{ url: 'clock', label: 'Clock' },
|
||||
{ url: 'minimal', label: 'Minimal Timer' },
|
||||
{ url: 'clock', label: 'Wall Clock' },
|
||||
{ url: 'backstage', label: 'Backstage' },
|
||||
{ url: 'timeline', label: 'Timeline (beta)' },
|
||||
{ url: 'public', label: 'Public' },
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
@use '../theme/viewerDefs' as *;
|
||||
|
||||
$dot-size: 0.5rem;
|
||||
$dot-spacing: 1.5rem;
|
||||
|
||||
.loader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--background-color-override, $viewer-background-color);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 5rem;
|
||||
|
||||
div {
|
||||
position: absolute;
|
||||
width: $dot-size;
|
||||
height: $dot-size;
|
||||
border-radius: 50%;
|
||||
background-color: var(--accent-color-override, $ontime-color);
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
|
||||
&:nth-child(1) {
|
||||
left: $dot-size;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
left: $dot-size;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
left: calc($dot-size + $dot-spacing);
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
left: calc($dot-size + 2 * $dot-spacing);
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate($dot-spacing, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { overrideStylesURL } from '../common/api/constants';
|
||||
import { useRuntimeStylesheet } from '../common/hooks/useRuntimeStylesheet';
|
||||
import useViewSettings from '../common/hooks-query/useViewSettings';
|
||||
|
||||
import style from './ViewLoader.module.scss';
|
||||
|
||||
export default function ViewLoader({ children }: PropsWithChildren) {
|
||||
const { data } = useViewSettings();
|
||||
const { shouldRender } = useRuntimeStylesheet(data.overrideStyles && overrideStylesURL);
|
||||
|
||||
// eventually we would want to leverage suspense here
|
||||
// while the feature is not ready, we simply trigger a loader
|
||||
// suspense would have the advantage of being triggered also by react-query
|
||||
|
||||
if (!shouldRender) {
|
||||
return (
|
||||
<div className={style.loader}>
|
||||
<div className={style.ellipsis}>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment -- ensuring JSX return
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,39 +1,40 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
@use '../../theme/viewerDefs' as *;
|
||||
|
||||
$timeline-entry-height: 20px;
|
||||
$lane-height: 120px;
|
||||
$timeline-height: 1rem;
|
||||
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
|
||||
|
||||
.timeline {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: $ui-white;
|
||||
background-color: $ui-black;
|
||||
}
|
||||
|
||||
.timelineEvents {
|
||||
color: var(--color-override, $viewer-color);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
box-sizing: content-box;
|
||||
// create progress background
|
||||
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
border-left: 1px solid $ui-black;
|
||||
border-left: 1px solid var(--background-color-override, $viewer-background-color);
|
||||
// avoiding content being larger than the view
|
||||
height: calc(100% - 3rem);
|
||||
}
|
||||
|
||||
// decorate timeline element
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
box-sizing: content-box;
|
||||
top: -$timeline-height;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: $timeline-height;
|
||||
background-color: $white-40;
|
||||
// generate combined timeline
|
||||
.timelineBlock {
|
||||
height: $timeline-height;
|
||||
background: $timeline-color;
|
||||
width: 100%;
|
||||
|
||||
&[data-status='done'] {
|
||||
background: $active-red;
|
||||
}
|
||||
|
||||
&[data-status='live'] {
|
||||
background: linear-gradient(to right, $active-red var(--progress, 0%), $timeline-color var(--progress, 0%));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +66,9 @@ $timeline-height: 1rem;
|
||||
overflow: hidden;
|
||||
line-height: 1rem;
|
||||
|
||||
background-color: var(--lighter, $viewer-card-bg-color);
|
||||
border-bottom: 2px solid $ui-black;
|
||||
background-color: var(--lighter, var(--card-background-color-override, $viewer-card-bg-color));
|
||||
border-bottom: 2px solid var(--background-color-override, $viewer-background-color);
|
||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
|
||||
|
||||
&[data-status='done'] {
|
||||
@@ -0,0 +1,91 @@
|
||||
import { memo } from 'react';
|
||||
import { useViewportSize } from '@mantine/hooks';
|
||||
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
|
||||
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import TimelineMarkers from './timeline-markers/TimelineMarkers';
|
||||
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
|
||||
import { ProgressStatus, TimelineEntry } from './TimelineEntry';
|
||||
|
||||
import style from './Timeline.module.scss';
|
||||
|
||||
interface TimelineProps {
|
||||
firstStart: number;
|
||||
rundown: OntimeRundown;
|
||||
selectedEventId: string | null;
|
||||
totalDuration: number;
|
||||
}
|
||||
|
||||
export default memo(Timeline);
|
||||
|
||||
function Timeline(props: TimelineProps) {
|
||||
const { firstStart, rundown, selectedEventId, totalDuration } = props;
|
||||
const { width: screenWidth } = useViewportSize();
|
||||
|
||||
if (totalDuration === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { lastEvent } = getLastEvent(rundown);
|
||||
const startHour = getStartHour(firstStart);
|
||||
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
|
||||
|
||||
let previousEventStartTime: MaybeNumber = null;
|
||||
// we use selectedEventId as a signifier on whether the timeline is live
|
||||
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||
let elapsedDays = 0;
|
||||
|
||||
return (
|
||||
<div className={style.timeline}>
|
||||
<TimelineMarkers startHour={startHour} endHour={endHour} />
|
||||
{rundown.map((event) => {
|
||||
// for now we dont render delays and blocks
|
||||
if (!isOntimeEvent(event) || !isPlayableEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// keep track of progress of rundown
|
||||
if (eventStatus === 'live') {
|
||||
eventStatus = 'future';
|
||||
}
|
||||
if (event.id === selectedEventId) {
|
||||
eventStatus = 'live';
|
||||
}
|
||||
|
||||
// we only need to check for next day if we have a previous event
|
||||
if (
|
||||
previousEventStartTime !== null &&
|
||||
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
|
||||
) {
|
||||
elapsedDays++;
|
||||
}
|
||||
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
|
||||
|
||||
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
|
||||
startHour * MILLIS_PER_HOUR,
|
||||
endHour * MILLIS_PER_HOUR,
|
||||
normalisedStart + (event.delay ?? 0),
|
||||
event.duration,
|
||||
screenWidth,
|
||||
);
|
||||
|
||||
// prepare values for next iteration
|
||||
previousEventStartTime = normalisedStart;
|
||||
|
||||
return (
|
||||
<TimelineEntry
|
||||
key={event.id}
|
||||
colour={event.colour}
|
||||
delay={event.delay ?? 0}
|
||||
duration={event.duration}
|
||||
left={elementLeftPosition}
|
||||
status={eventStatus}
|
||||
start={normalisedStart} // dataset solves issues related to crossing midnight
|
||||
title={event.title}
|
||||
width={elementWidth}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useTimelineStatus } from '../../../common/hooks/useSocket';
|
||||
import { alpha, cx } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { useTimelineStatus, useTimer } from '../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../common/utils/getProgress';
|
||||
import { alpha, cx } from '../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../common/utils/time';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import { getStatusLabel } from './timeline.utils';
|
||||
import { getStatusLabel, getTimeToStart } from './timeline.utils';
|
||||
|
||||
import style from './Timeline.module.scss';
|
||||
|
||||
@@ -48,6 +49,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
||||
width: `${width}px`,
|
||||
}}
|
||||
>
|
||||
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
|
||||
<div
|
||||
className={contentClasses}
|
||||
data-status={status}
|
||||
@@ -63,7 +65,7 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
||||
{status !== 'done' && (
|
||||
<>
|
||||
<div className={style.duration}>{formattedDuration}</div>
|
||||
<TimelineEntryStatus status={status} start={delayedStart} />
|
||||
<TimelineEntryStatus delay={delay} start={start} status={status} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -72,18 +74,19 @@ export function TimelineEntry(props: TimelineEntryProps) {
|
||||
}
|
||||
|
||||
interface TimelineEntryStatusProps {
|
||||
status: ProgressStatus;
|
||||
delay: number;
|
||||
start: number;
|
||||
status: ProgressStatus;
|
||||
}
|
||||
|
||||
// we isolate this component to avoid isolate re-renders provoked by the clock changes
|
||||
// extract component to isolate re-renders provoked by the clock changes
|
||||
function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
||||
const { status, start } = props;
|
||||
const { delay, start, status } = props;
|
||||
const { clock, offset } = useTimelineStatus();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
// start times need to be normalised in a rundown that crosses midnight
|
||||
let statusText = getStatusLabel(start - clock + offset, status);
|
||||
let statusText = getStatusLabel(getTimeToStart(clock, start, delay, offset), status);
|
||||
if (statusText === 'live') {
|
||||
statusText = getLocalizedString('timeline.live');
|
||||
} else if (statusText === 'pending') {
|
||||
@@ -92,3 +95,10 @@ function TimelineEntryStatus(props: TimelineEntryStatusProps) {
|
||||
|
||||
return <div className={style.status}>{statusText}</div>;
|
||||
}
|
||||
|
||||
/** Generates a block level progress bar */
|
||||
function ActiveBlock() {
|
||||
const { current, duration } = useTimer();
|
||||
const progress = getProgress(current, duration);
|
||||
return <div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` }} />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
@use '../../theme/viewerDefs' as *;
|
||||
|
||||
.timeline {
|
||||
width: 100vw;
|
||||
@@ -85,6 +85,14 @@
|
||||
font-size: 3rem;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
|
||||
max-height: 2em;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.section-content--now {
|
||||
@@ -1,29 +1,27 @@
|
||||
import { useMemo } from 'react';
|
||||
import { MaybeString, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||
import { MaybeString, OntimeEvent, ProjectData, Runtime, Settings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||
import { formatDuration, formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import Section from './timeline-section/TimelineSection';
|
||||
import Timeline from './Timeline';
|
||||
import { getTimelineOptions } from './timeline.options';
|
||||
import { getFormattedTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
|
||||
import { getTimeToStart, getUpcomingEvents, useScopedRundown } from './timeline.utils';
|
||||
|
||||
import './TimelinePage.scss';
|
||||
|
||||
interface TimelinePageProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
general: ProjectData;
|
||||
runtime: Runtime;
|
||||
selectedId: MaybeString;
|
||||
settings: Settings | undefined;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,8 +30,7 @@ interface TimelinePageProps {
|
||||
* There is little point splitting or memoising top level elements
|
||||
*/
|
||||
export default function TimelinePage(props: TimelinePageProps) {
|
||||
const { backstageEvents, general, selectedId, settings, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { backstageEvents, general, runtime, selectedId, settings, time } = props;
|
||||
// holds copy of the rundown with only relevant events
|
||||
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(backstageEvents, selectedId);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
@@ -45,10 +42,6 @@ export default function TimelinePage(props: TimelinePageProps) {
|
||||
|
||||
useWindowTitle('Timeline');
|
||||
|
||||
if (!shouldRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// populate options
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const progressOptions = getTimelineOptions(defaultFormat);
|
||||
@@ -57,10 +50,26 @@ export default function TimelinePage(props: TimelinePageProps) {
|
||||
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
||||
const nextText = next !== null ? next.title : '-';
|
||||
const followedByText = followedBy !== null ? followedBy.title : '-';
|
||||
let nextStatus: string | undefined;
|
||||
let followedByStatus: string | undefined;
|
||||
|
||||
const nextStatus = next !== null ? getFormattedTimeToStart(next, time.clock, dueText) : undefined;
|
||||
const followedByStatus = followedBy !== null ? getFormattedTimeToStart(followedBy, time.clock, dueText) : undefined;
|
||||
if (next !== null) {
|
||||
const timeToStart = getTimeToStart(time.clock, next.timeStart, next?.delay ?? 0, runtime.offset);
|
||||
if (timeToStart < 0) {
|
||||
nextStatus = dueText;
|
||||
} else {
|
||||
nextStatus = `T - ${formatDuration(timeToStart)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (followedBy !== null) {
|
||||
const timeToStart = getTimeToStart(time.clock, followedBy.timeStart, followedBy?.delay ?? 0, runtime.offset);
|
||||
if (timeToStart < 0) {
|
||||
followedByStatus = dueText;
|
||||
} else {
|
||||
followedByStatus = `T - ${formatDuration(timeToStart)}`;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className='timeline'>
|
||||
<ViewParamsEditor viewOptions={progressOptions} />
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { getElementPosition, makeTimelineSections } from '../timeline.utils';
|
||||
import { getElementPosition, getTimeToStart, makeTimelineSections } from '../timeline.utils';
|
||||
|
||||
describe('getCSSPosition()', () => {
|
||||
it('accounts for rundown with one event', () => {
|
||||
@@ -53,14 +53,40 @@ describe('getCSSPosition()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeTmelineSections', () => {
|
||||
describe('makeTimelineSections', () => {
|
||||
it('creates an array between the hours given, end excluded', () => {
|
||||
const result = makeTimelineSections(11, 17);
|
||||
expect(result).toEqual(['11:00', '12:00', '13:00', '14:00', '15:00', '16:00']);
|
||||
});
|
||||
|
||||
it('wraps around midnight', () => {
|
||||
const result = makeTimelineSections(22, 26);
|
||||
expect(result).toEqual(['22:00', '23:00', '00:00', '01:00']);
|
||||
expect(result).toEqual([11, 12, 13, 14, 15, 16]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeToStart()', () => {
|
||||
it("is the gap between now and the event's start time accounted for delays", () => {
|
||||
const now = 150;
|
||||
const start = 150;
|
||||
const delay = 50;
|
||||
|
||||
const result = getTimeToStart(now, start, delay, 0);
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
|
||||
it('accounts for offsets when running behind', () => {
|
||||
const now = 150;
|
||||
const start = 150;
|
||||
const delay = 50;
|
||||
const offset = -50; // running behind
|
||||
|
||||
const result = getTimeToStart(now, start, delay, offset);
|
||||
expect(result).toBe(50 + 50);
|
||||
});
|
||||
|
||||
it('accounts for offsets when running ahead', () => {
|
||||
const now = 150;
|
||||
const start = 150;
|
||||
const delay = 50;
|
||||
const offset = 10; // running behind
|
||||
|
||||
const result = getTimeToStart(now, start, delay, offset);
|
||||
expect(result).toBe(50 - 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
.markers {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
|
||||
& > span {
|
||||
flex-grow: 1;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-left: 1px solid $white-7;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ interface TimelineMarkersProps {
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
/** Creates a line for every hour in the timeline */
|
||||
export default function TimelineMarkers(props: TimelineMarkersProps) {
|
||||
const { startHour, endHour } = props;
|
||||
|
||||
@@ -14,9 +15,9 @@ export default function TimelineMarkers(props: TimelineMarkersProps) {
|
||||
|
||||
return (
|
||||
<div className={style.markers}>
|
||||
{elements.map((tag, index) => {
|
||||
return <span key={`${index}-${tag}`}>{tag}</span>;
|
||||
})}
|
||||
{elements.map((tag) => (
|
||||
<span key={tag} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from 'react';
|
||||
import { MaybeString } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
interface SectionProps {
|
||||
category: 'now' | 'next';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../../common/components/view-params-editor/types';
|
||||
import { getTimeOption } from '../../common/components/view-params-editor/constants';
|
||||
import { ViewOption } from '../../common/components/view-params-editor/types';
|
||||
|
||||
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
|
||||
return [
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
getTimeFromPrevious,
|
||||
isNewLatest,
|
||||
MILLIS_PER_HOUR,
|
||||
millisToString,
|
||||
removeSeconds,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { clamp } from '../../../common/utils/math';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
import { isStringBoolean } from '../common/viewUtils';
|
||||
import { clamp } from '../../common/utils/math';
|
||||
import { formatDuration } from '../../common/utils/time';
|
||||
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
import type { ProgressStatus } from './TimelineEntry';
|
||||
|
||||
@@ -71,7 +69,7 @@ export function getEndHour(endTime: number): number {
|
||||
export function makeTimelineSections(firstHour: number, lastHour: number) {
|
||||
const timelineSections = [];
|
||||
for (let i = firstHour; i < lastHour; i++) {
|
||||
timelineSections.push(removeSeconds(millisToString((i % 24) * MILLIS_PER_HOUR)));
|
||||
timelineSections.push(i);
|
||||
}
|
||||
return timelineSections;
|
||||
}
|
||||
@@ -200,12 +198,9 @@ export function getUpcomingEvents(events: OntimeRundown, selectedId: MaybeString
|
||||
};
|
||||
}
|
||||
|
||||
export function getFormattedTimeToStart(event: OntimeEvent, now: number, dueText: string): string {
|
||||
const timeToStart = event.timeStart - now;
|
||||
|
||||
if (timeToStart < 0) {
|
||||
return dueText;
|
||||
}
|
||||
|
||||
return `T - ${formatDuration(timeToStart)}`;
|
||||
/**
|
||||
* Utility function calculates time to start
|
||||
*/
|
||||
export function getTimeToStart(now: number, start: number, delay: number, offset: number): number {
|
||||
return start + delay - now - offset;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.2",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -10,7 +10,7 @@
|
||||
"timer"
|
||||
],
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "main.js",
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "^31.2.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
@@ -77,7 +77,7 @@
|
||||
},
|
||||
"files": [
|
||||
"**/*",
|
||||
"assets/",
|
||||
"src/assets/",
|
||||
"!**/{yarn.lock,yarn-error.log}",
|
||||
"!**/{pnpm-lock.yaml}",
|
||||
"!**/{test,tests,__test__,__tests__}",
|
||||
@@ -85,7 +85,7 @@
|
||||
"!*{.spec.js,*.test.js,*.spec.ts,.test.ts}"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "./assets/"
|
||||
"buildResources": "./src/assets/"
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
@@ -113,15 +113,15 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/src/preloaded-db/",
|
||||
"to": "extraResources/preloaded-db/",
|
||||
"from": "../server/src/external/",
|
||||
"to": "extraResources/external/",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/src/external/",
|
||||
"to": "extraResources/external/",
|
||||
"from": "../server/src/user/",
|
||||
"to": "extraResources/user/",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
|
||||
|
Before Width: | Height: | Size: 567 B After Width: | Height: | Size: 567 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
@@ -7,7 +7,7 @@ module.exports = {
|
||||
production: (port = 4001) => `http://localhost:${port}`,
|
||||
},
|
||||
server: {
|
||||
pathToEntrypoint: '../extraResources/server/index.cjs',
|
||||
pathToEntrypoint: '../../extraResources/server/index.cjs',
|
||||
},
|
||||
assets: {
|
||||
pathToAssets: './assets/',
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* This file contains a list of constants that may need to be resolved at runtime
|
||||
*/
|
||||
const path = require('path');
|
||||
|
||||
const { version } = require('../package.json');
|
||||
|
||||
const electronConfig = require('./electron.config.js');
|
||||
|
||||
// external links
|
||||
const linkToDocs = 'https://docs.getontime.no/';
|
||||
const linkToGitHub = 'https://github.com/cpvalente/ontime';
|
||||
const linkToDiscord = 'https://discord.com/invite/eje3CSUEXm';
|
||||
|
||||
// environment and platform constants
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const isProduction = env === 'production';
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isLinux = process.platform === 'linux';
|
||||
|
||||
const releaseTag = `v${version}`;
|
||||
|
||||
/** path to server directory */
|
||||
const nodePath = isProduction
|
||||
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
||||
: path.join(__dirname, '../../server/dist/index.cjs');
|
||||
|
||||
/**
|
||||
* Resolves correct URL for client
|
||||
* @param {number | undefined} port - the port at which the server is running
|
||||
* @returns {string}
|
||||
*/
|
||||
const getClientUrl = (port) =>
|
||||
isProduction ? electronConfig.reactAppUrl.production(port) : electronConfig.reactAppUrl.development(port);
|
||||
|
||||
/**
|
||||
* Resolves correct URL for server
|
||||
* @param {number | undefined} port - the port at which the server is running
|
||||
* @returns {string}
|
||||
*/
|
||||
const getServerUrl = (port) => `http://localhost:${port}`;
|
||||
|
||||
/** Resolves URL path to download resources */
|
||||
const downloadPath = '/data/db/';
|
||||
|
||||
/**
|
||||
* @description Returns public path depending on OS
|
||||
* This is the correct path for the app running in production mode
|
||||
*/
|
||||
function getAppDataPath() {
|
||||
if (isMac) {
|
||||
return path.join(process.env.HOME, 'Library', 'Application Support', 'Ontime');
|
||||
}
|
||||
|
||||
if (isWindows) {
|
||||
return path.join(process.env.APPDATA, 'Ontime');
|
||||
}
|
||||
|
||||
if (isLinux) {
|
||||
return path.join(process.env.HOME, '.Ontime');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const projectsPath = path.join(getAppDataPath(), 'projects');
|
||||
const corruptProjectsPath = path.join(getAppDataPath(), 'corrupt files');
|
||||
const crashLogPath = path.join(getAppDataPath(), 'crash logs');
|
||||
const stylesPath = path.join(getAppDataPath(), 'user', 'styles');
|
||||
const externalPath = path.join(getAppDataPath(), 'external');
|
||||
|
||||
/** path to tray icon */
|
||||
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
||||
/** path to app icon directory */
|
||||
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
||||
|
||||
module.exports = {
|
||||
linkToDocs,
|
||||
linkToGitHub,
|
||||
linkToDiscord,
|
||||
env,
|
||||
isProduction,
|
||||
isMac,
|
||||
isWindows,
|
||||
releaseTag,
|
||||
nodePath,
|
||||
getClientUrl,
|
||||
getServerUrl,
|
||||
projectsPath,
|
||||
corruptProjectsPath,
|
||||
crashLogPath,
|
||||
stylesPath,
|
||||
externalPath,
|
||||
downloadPath,
|
||||
trayIcon,
|
||||
appIcon,
|
||||
};
|
||||
@@ -1,18 +1,20 @@
|
||||
const { app, BrowserWindow, Menu, globalShortcut, Tray, dialog, ipcMain, shell, Notification } = require('electron');
|
||||
const path = require('path');
|
||||
const electronConfig = require('./electron.config');
|
||||
const { version } = require('./package.json');
|
||||
const { getApplicationMenu } = require('./src/menu/applicationMenu.js');
|
||||
|
||||
const env = process.env.NODE_ENV || 'production';
|
||||
const isProduction = env === 'production';
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isWindows = process.platform === 'win32';
|
||||
const { getApplicationMenu } = require('./menu/applicationMenu.js');
|
||||
const { getTrayMenu } = require('./menu/trayMenu.js');
|
||||
|
||||
// path to server
|
||||
const nodePath = isProduction
|
||||
? path.join(__dirname, electronConfig.server.pathToEntrypoint)
|
||||
: path.join(__dirname, '../server/dist/index.cjs');
|
||||
const electronConfig = require('./electron.config.js');
|
||||
const {
|
||||
env,
|
||||
isProduction,
|
||||
isWindows,
|
||||
nodePath,
|
||||
getClientUrl,
|
||||
trayIcon,
|
||||
appIcon,
|
||||
getServerUrl,
|
||||
} = require('./externals.js');
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`Electron running in ${env} environment`);
|
||||
@@ -20,10 +22,13 @@ if (!isProduction) {
|
||||
process.traceProcessWarnings = true;
|
||||
}
|
||||
|
||||
// path to icons
|
||||
const trayIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'background.png');
|
||||
const appIcon = path.join(__dirname, electronConfig.assets.pathToAssets, 'logo.png');
|
||||
let loaded = 'Nothing loaded';
|
||||
/** Flag holds server loading state */
|
||||
let loaded = 'Ontime running';
|
||||
|
||||
/**
|
||||
* Flag whether user has requested a quit
|
||||
* Used to coordinate window closes without exit
|
||||
*/
|
||||
let isQuitting = false;
|
||||
|
||||
// initialise
|
||||
@@ -56,13 +61,13 @@ async function startBackend() {
|
||||
|
||||
/**
|
||||
* @description utility function to create a notification
|
||||
* @param title
|
||||
* @param text
|
||||
* @param {string} title - Notification title
|
||||
* @param {string} body - Notification body
|
||||
*/
|
||||
function showNotification(title, text) {
|
||||
function showNotification(title, body) {
|
||||
new Notification({
|
||||
title,
|
||||
body: text,
|
||||
body,
|
||||
silent: true,
|
||||
}).show();
|
||||
}
|
||||
@@ -108,6 +113,14 @@ function escalateError(error) {
|
||||
dialog.showErrorBox('An unrecoverable error occurred', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows electron to ask react app redirect
|
||||
* @param string location
|
||||
*/
|
||||
function redirectWindow(location) {
|
||||
win.webContents.send('request-editor-location', location);
|
||||
}
|
||||
|
||||
// Ensure there isn't another instance of the app running already
|
||||
const lock = app.requestSingleInstanceLock();
|
||||
if (!lock) {
|
||||
@@ -141,7 +154,7 @@ function createWindow() {
|
||||
skipTaskbar: true,
|
||||
});
|
||||
splash.setIgnoreMouseEvents(true);
|
||||
const splashPath = path.join('file://', __dirname, '/src/splash/splash.html');
|
||||
const splashPath = path.join('file://', __dirname, '/splash/splash.html');
|
||||
splash.loadURL(splashPath);
|
||||
|
||||
win = new BrowserWindow({
|
||||
@@ -156,7 +169,7 @@ function createWindow() {
|
||||
enableWebSQL: false,
|
||||
darkTheme: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, './src/preload.js'),
|
||||
preload: path.join(__dirname, './preload.js'),
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
},
|
||||
@@ -173,18 +186,13 @@ app.whenReady().then(() => {
|
||||
}
|
||||
|
||||
createWindow();
|
||||
|
||||
startBackend()
|
||||
.then((port) => {
|
||||
// Load page served by node or use React dev run
|
||||
const clientUrl = isProduction
|
||||
? electronConfig.reactAppUrl.production(port)
|
||||
: electronConfig.reactAppUrl.development(port);
|
||||
|
||||
const template = getApplicationMenu(isMac, askToQuit, clientUrl, `v${version}`, (path) => {
|
||||
win.loadURL(`${clientUrl}/${path}`);
|
||||
});
|
||||
const menu = Menu.buildFromTemplate(template);
|
||||
const clientUrl = getClientUrl(port);
|
||||
const serverUrl = getServerUrl(port);
|
||||
const menu = getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, (url) =>
|
||||
win.webContents.downloadURL(url),
|
||||
);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
win
|
||||
@@ -229,13 +237,9 @@ app.whenReady().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// create tray
|
||||
// create tray and set its context menu
|
||||
tray = new Tray(trayIcon);
|
||||
|
||||
// Define context menu
|
||||
const { getTrayMenu } = require('./src/menu/trayMenu.js');
|
||||
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
|
||||
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
|
||||
const trayContextMenu = getTrayMenu(bringToFront, askToQuit);
|
||||
tray.setContextMenu(trayContextMenu);
|
||||
});
|
||||
|
||||
@@ -261,7 +265,7 @@ ipcMain.on('shutdown', () => {
|
||||
/**
|
||||
* Handles requests to set window properties
|
||||
*/
|
||||
ipcMain.on('set-window', (event, arg) => {
|
||||
ipcMain.on('set-window', (_event, arg) => {
|
||||
switch (arg) {
|
||||
case 'show-dev':
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
@@ -274,6 +278,10 @@ ipcMain.on('set-window', (event, arg) => {
|
||||
/**
|
||||
* Handles requests to open external links
|
||||
*/
|
||||
ipcMain.on('send-to-link', (event, arg) => {
|
||||
shell.openExternal(arg);
|
||||
ipcMain.on('send-to-link', (_event, arg) => {
|
||||
try {
|
||||
shell.openExternal(arg);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
});
|
||||
@@ -1,185 +1,315 @@
|
||||
const { shell } = require('electron');
|
||||
const { Menu, shell } = require('electron');
|
||||
|
||||
const {
|
||||
linkToGitHub,
|
||||
linkToDocs,
|
||||
linkToDiscord,
|
||||
isProduction,
|
||||
isMac,
|
||||
releaseTag,
|
||||
projectsPath,
|
||||
corruptProjectsPath,
|
||||
crashLogPath,
|
||||
stylesPath,
|
||||
externalPath,
|
||||
downloadPath,
|
||||
} = require('../externals.js');
|
||||
|
||||
/**
|
||||
* Build description of application menu
|
||||
* @param {boolean} isMac - Whether the target platform is mac
|
||||
* Creates the application menu
|
||||
* @param {function} askToQuit - function for quitting process
|
||||
* @param {string} clientUrl - base url for the application
|
||||
* @param {string} serverUrl - base url for the application
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @param {function} download - function to download a resource from url
|
||||
* @returns {Menu} - application menu
|
||||
*/
|
||||
function getApplicationMenu(isMac, askToQuit, urlBase, version, redirectWindow) {
|
||||
return [
|
||||
...(isMac
|
||||
? [
|
||||
{
|
||||
label: 'Ontime',
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => askToQuit(),
|
||||
accelerator: 'Cmd+Q',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [isMac ? { role: 'close' } : { role: 'quit' }],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
...(isMac
|
||||
? [
|
||||
{ role: 'pasteAndMatchStyle' },
|
||||
{ role: 'delete' },
|
||||
{ role: 'selectAll' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Speech',
|
||||
submenu: [{ role: 'startSpeaking' }, { role: 'stopSpeaking' }],
|
||||
},
|
||||
]
|
||||
: [{ role: 'delete' }, { type: 'separator' }, { role: 'selectAll' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Views',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Ontime Views (opens in browser)',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Public',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/public`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Lower Thirds',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/lower`);
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Timer',
|
||||
accelerator: 'CmdOrCtrl+V',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/timer`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clock',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/clock`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Minimal Timer',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/minimal`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Backstage',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/backstage`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Timeline (beta)',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/timeline`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Studio Clock',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/studio`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Countdown',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/countdown`);
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Editor',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/editor`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Cuesheet',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/cuesheet`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Operator',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/op`);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'forceReload' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
...(isMac
|
||||
? [{ type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }]
|
||||
: [{ role: 'close' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About',
|
||||
click: () => redirectWindow('editor?settings=about'),
|
||||
},
|
||||
{
|
||||
label: version,
|
||||
click: () => redirectWindow('editor?settings=about'),
|
||||
},
|
||||
{
|
||||
label: 'See on github',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://github.com/cpvalente/ontime');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Online documentation',
|
||||
click: async () => {
|
||||
await shell.openExternal('https://docs.getontime.no/');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
function getApplicationMenu(askToQuit, clientUrl, serverUrl, redirectWindow, download) {
|
||||
const template = [
|
||||
...(isMac ? [makeMacMenu(askToQuit)] : []),
|
||||
makeFileMenu(serverUrl, redirectWindow, download),
|
||||
makeViewMenu(clientUrl),
|
||||
makeSettingsMenu(redirectWindow),
|
||||
makeHelpMenu(redirectWindow),
|
||||
...(isProduction ? [] : [{ label: 'Dev', submenu: [{ role: 'toggleDevTools' }] }]),
|
||||
];
|
||||
return Menu.buildFromTemplate(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the app menu (macOS only)
|
||||
* @param {function} askToQuit - function for quitting process
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeMacMenu(askToQuit) {
|
||||
return {
|
||||
label: 'Ontime',
|
||||
submenu: [
|
||||
{ role: 'about', label: 'About Ontime' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide', label: 'Hide Ontime' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => askToQuit(),
|
||||
accelerator: isMac ? 'Cmd+Q' : 'Alt+F4',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the file menu
|
||||
* @param {string} serverUrl - base url for the application
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @param {function} download - function to download a resource from url
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeFileMenu(serverUrl, redirectWindow, download) {
|
||||
const downloadProject = () => {
|
||||
try {
|
||||
download(serverUrl + downloadPath);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__manage&create=true'),
|
||||
},
|
||||
{
|
||||
label: 'Edit project info',
|
||||
click: () => redirectWindow('/editor?settings=project__data'),
|
||||
},
|
||||
{
|
||||
label: 'Manage projects...',
|
||||
click: () => redirectWindow('/editor?settings=project__manage'),
|
||||
},
|
||||
{
|
||||
label: 'Download project',
|
||||
click: downloadProject,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Open directory',
|
||||
submenu: [
|
||||
makeItemOpenInDesktop('Projects', projectsPath),
|
||||
makeItemOpenInDesktop('Corrupted projects', corruptProjectsPath),
|
||||
makeItemOpenInDesktop('Crash logs', crashLogPath),
|
||||
makeItemOpenInDesktop('CSS override', stylesPath),
|
||||
makeItemOpenInDesktop('External', externalPath),
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: isMac ? 'close' : 'quit' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the views menu
|
||||
* @param {string} clientUrl - base url for the application
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeViewMenu(clientUrl) {
|
||||
return {
|
||||
label: 'Views',
|
||||
submenu: [
|
||||
makeItemOpenInBrowser('Public', `${clientUrl}/public`),
|
||||
makeItemOpenInBrowser('Lower Thirds', `${clientUrl}/lower`),
|
||||
{ type: 'separator' },
|
||||
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
|
||||
makeItemOpenInBrowser('Minimal Timer', `${clientUrl}/minimal`),
|
||||
makeItemOpenInBrowser('Clock', `${clientUrl}/clock`),
|
||||
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
|
||||
makeItemOpenInBrowser('Timeline (beta)', `${clientUrl}/timeline`),
|
||||
makeItemOpenInBrowser('Studio Clock', `${clientUrl}/studio`),
|
||||
makeItemOpenInBrowser('Countdown', `${clientUrl}/countdown`),
|
||||
{ type: 'separator' },
|
||||
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
|
||||
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
|
||||
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
|
||||
|
||||
{ type: 'separator' },
|
||||
{ role: 'forceReload' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
// NOTE: I still contend this zoomin mess is an electron bug
|
||||
{ role: 'zoomIn', accelerator: 'CmdOrCtrl+Plus' },
|
||||
{ role: 'zoomIn', accelerator: 'CmdOrCtrl+=', visible: false },
|
||||
{ role: 'zoomIn', accelerator: 'CmdOrCtrl+numadd', visible: false },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the settings menu
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeSettingsMenu(redirectWindow) {
|
||||
return {
|
||||
label: 'Settings',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open Settings',
|
||||
accelerator: 'CommandOrControl+,',
|
||||
click: () => redirectWindow('/editor?settings=project'),
|
||||
},
|
||||
{
|
||||
label: 'Project',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Project data',
|
||||
click: () => redirectWindow('/editor?settings=project__data'),
|
||||
},
|
||||
{
|
||||
label: 'Manage projects',
|
||||
click: () => redirectWindow('/editor?settings=project__manage'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'App Settings',
|
||||
submenu: [
|
||||
{
|
||||
label: 'General settings',
|
||||
click: () => redirectWindow('/editor?settings=general__settings'),
|
||||
},
|
||||
{
|
||||
label: 'Editor settings',
|
||||
click: () => redirectWindow('/editor?settings=general__editor'),
|
||||
},
|
||||
{
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=general__view'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Feature Settings',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Custom fields',
|
||||
click: () => redirectWindow('/editor?settings=feature_settings__custom'),
|
||||
},
|
||||
{
|
||||
label: 'URL presets',
|
||||
click: () => redirectWindow('/editor?settings=feature_settings__urlpresets'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Data Sources',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Import spreadsheet',
|
||||
click: () => redirectWindow('/editor?settings=sources__xlsx'),
|
||||
},
|
||||
{
|
||||
label: 'Sync with Google Sheet',
|
||||
click: () => redirectWindow('/editor?settings=sources__gsheet'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Integrations',
|
||||
submenu: [
|
||||
{
|
||||
label: 'OSC settings',
|
||||
click: () => redirectWindow('/editor?settings=integrations__osc'),
|
||||
},
|
||||
{
|
||||
label: 'HTTP settings',
|
||||
click: () => redirectWindow('/editor?settings=integrations__http'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Network',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Event log',
|
||||
click: () => redirectWindow('/editor?settings=network__log'),
|
||||
},
|
||||
{
|
||||
label: 'Manage cleints',
|
||||
click: () => redirectWindow('/editor?settings=network__clients'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function generates the help menu
|
||||
* @param {function} redirectWindow - function to redirect main window content
|
||||
* @returns {Object}
|
||||
*/
|
||||
function makeHelpMenu(redirectWindow) {
|
||||
return {
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: `Ontime ${releaseTag}`,
|
||||
click: () => redirectWindow('/editor?settings=about'),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
makeItemOpenInBrowser('See on github', linkToGitHub),
|
||||
makeItemOpenInBrowser('Online documentation', linkToDocs),
|
||||
makeItemOpenInBrowser('Join us on Discord', linkToDiscord),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to safely open a URL in the default browser
|
||||
* @param {string} label
|
||||
* @param {string} url
|
||||
* @returns {object} - MenuItem
|
||||
*/
|
||||
function makeItemOpenInBrowser(label, url) {
|
||||
return {
|
||||
label: `${label} ↗`,
|
||||
click: async () => {
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to open a file in the OS explorer / finder
|
||||
* @param {string} label
|
||||
* @param {string} path
|
||||
* @returns {object} - MenuItem
|
||||
*/
|
||||
function makeItemOpenInDesktop(label, path) {
|
||||
return {
|
||||
label,
|
||||
click: () => {
|
||||
try {
|
||||
shell.openPath(path);
|
||||
} catch (_error) {
|
||||
/** unhandled error */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getApplicationMenu };
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
const { Menu } = require('electron');
|
||||
|
||||
/**
|
||||
* Build description of tray context menu
|
||||
* Creates the application tray menu
|
||||
* @param {function} showApp - function for making the window visible
|
||||
* @param {function} askToQuit - function for quitting process
|
||||
* @returns {Menu} - application tray menu
|
||||
*/
|
||||
function getTrayMenu(showApp, askToQuit) {
|
||||
return [
|
||||
return Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show App',
|
||||
click: () => showApp(),
|
||||
click: showApp,
|
||||
},
|
||||
{
|
||||
label: 'Shutdown',
|
||||
click: () => askToQuit(),
|
||||
click: askToQuit,
|
||||
},
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = { getTrayMenu };
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<img src="../../assets/logo.png" />
|
||||
<img src="../assets/logo.png" />
|
||||
<h1>ontime · event timers</h1>
|
||||
<div class="lds-ellipsis">
|
||||
<div></div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.6.1",
|
||||
"version": "3.6.2",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -17,11 +17,11 @@
|
||||
"lowdb": "^7.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-osc": "^9.0.2",
|
||||
"node-xlsx": "^0.23.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"steno": "^4.0.2",
|
||||
"ws": "^8.13.0"
|
||||
"ws": "^8.13.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
|
||||
@@ -33,6 +33,7 @@ export class SocketServer implements IAdapter {
|
||||
|
||||
private wss: WebSocketServer | null;
|
||||
private readonly clients: Map<string, Client>;
|
||||
private lastConnection: Date | null = null;
|
||||
|
||||
constructor() {
|
||||
if (instance) {
|
||||
@@ -58,6 +59,7 @@ export class SocketServer implements IAdapter {
|
||||
path: '',
|
||||
});
|
||||
|
||||
this.lastConnection = new Date();
|
||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`);
|
||||
|
||||
ws.send(
|
||||
@@ -171,6 +173,13 @@ export class SocketServer implements IAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
connectedClients: this.clients.size,
|
||||
lastConnection: this.lastConnection,
|
||||
};
|
||||
}
|
||||
|
||||
private sendClientList(): void {
|
||||
const payload = Object.fromEntries(this.clients.entries());
|
||||
this.sendAsJson({ type: 'client-list', payload });
|
||||
|
||||