Compare commits

..

57 Commits

Author SHA1 Message Date
Carlos Valente 079be74c16 bump version to 3.6.2-beta1 2024-10-22 20:30:13 +02:00
Carlos Valente c3db240148 chore: add win32 target 2024-10-22 20:25:48 +02:00
Carlos Valente f2d7f4ab82 bump version to 3.6.2 2024-10-22 20:24:22 +02:00
Alex Christoffer Rasmussen a5496434f8 todo note about sanitiseCustomFields (#1281) 2024-10-22 19:13:24 +02:00
Alex Christoffer Rasmussen 42984eed87 Improve xlsx import (#1271)
* use xlsx

* add existing custom fields to import map

* keep colours of existing custom fields

* use unknown

* fix

* revert

* allow space and upper case

* fix test

* define empty sheet
2024-10-22 19:13:07 +02:00
Carlos Valente 59c1c77d48 refactor: timeline has overrideable styles 2024-10-21 22:16:18 +02:00
Carlos Valente dd13eb31d8 refactor: expose public dir 2024-10-20 22:35:26 +02:00
Carlos Valente 82810f1cb1 feat: add sessions stats endpoint 2024-10-20 22:35:26 +02:00
Carlos Valente 71c468d069 refactor: session resource 2024-10-20 22:35:26 +02:00
Carlos Valente cf40001a2a improve style 2024-10-19 20:34:00 +02:00
arc-alex d458928090 fix external path and remove unneeded stuff 2024-10-19 20:34:00 +02:00
Carlos Valente 76e12af116 docs: add initial documentation to external assets 2024-10-19 20:34:00 +02:00
Carlos Valente b577057768 refactor: notes as data source 2024-10-19 20:33:36 +02:00
Carlos Valente dd4a165b08 refactor: add nif to navigation 2024-10-19 20:33:20 +02:00
Carlos Valente ab847a324b refactor: timer strategy is default 2024-10-19 14:31:58 +02:00
Carlos Valente cc5c097ddc refactor: tooltips for time locks 2024-10-19 14:31:58 +02:00
jwetzell ea2330e23b add invisible shortcuts for zooming in 2024-10-18 19:40:21 +02:00
jwetzell c5870452e3 make custom fields case sensitive (#1242)
* remove custom field lowercasing

* don't allow custom field form to submit duplicate field

* remove unused

* update custom field tests

* also keep upper case when editing

* show key in UI

* also kep upper case when creating a new field

* fix test

* allow old forced case keys to stay as is

* allow space

* add extension to import

* supply initial key to CustomFieldForm

* refactor: improve element contrast

* refactor: style and composition

* consistent use of `customFieldLabelToKey`

* fix CopyTag

* remove log

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-10-18 18:53:23 +02:00
Alex Christoffer Rasmussen b73b529c00 hide seconds toggle in cuesheet (#1272) 2024-10-18 13:08:34 +02:00
Carlos Valente 8ef807d6cb refactor: items active by default 2024-10-16 16:34:57 +02:00
Carlos Valente 1d4ddea596 chore: extract views directory 2024-10-15 22:28:50 +02:00
Carlos Valente 4cba970cda feat: add loader to views 2024-10-15 22:28:50 +02:00
Carlos Valente bad9dab0a0 refactor: compound timeline 2024-10-15 22:28:50 +02:00
Carlos Valente f11218a757 refactor: control container growth 2024-10-15 22:28:50 +02:00
Carlos Valente 77e87e1d0c refactor: make message service stateless 2024-10-15 16:11:53 +02:00
Carlos Valente a4aa513d1f refactor: share store initial state 2024-10-15 16:11:53 +02:00
Carlos Valente b73448f827 feat: implement colour picker 2024-10-15 16:01:42 +02:00
Carlos Valente 687ad68726 refactor: recover style override 2024-10-15 16:01:42 +02:00
Carlos Valente 089eba3877 refactor: param type colour 2024-10-15 16:01:42 +02:00
Carlos Valente 24b0cfd24e chore: consistent naming in externals 2024-10-15 15:51:01 +02:00
Carlos Valente 5950551da2 refactor: simplify setup of asset paths 2024-10-15 15:51:01 +02:00
Carlos Valente e46948772e refactor: network interfaces can be copied or followed 2024-10-13 20:39:58 +02:00
Carlos Valente 78fa1df99c refactor: blink style 2024-10-12 11:02:26 +02:00
Alex Christoffer Rasmussen 0500368982 Revert http test (#1257)
* Revert "refactor: allow messages not starting with http"

This reverts commit 887e5c448e.

* Revert "refactor: allow messages not starting with http"

This reverts commit 6ce275da7a.
2024-10-12 10:52:14 +02:00
Carlos Valente 2d53323f60 refactor: unify time to start logic 2024-10-11 16:01:44 +02:00
Carlos Valente 08ca3cd3a5 fix: calculate time to start 2024-10-11 16:01:44 +02:00
Carlos Valente 16fd44a441 refactor: allow aux change direction while playing 2024-10-11 16:01:22 +02:00
Carlos Valente 9a6ca7ac67 refactor: show electron dev tools 2024-10-11 13:26:15 +02:00
Carlos Valente 2c63bbff76 refactor: arrow used in new window navigation 2024-10-11 13:26:15 +02:00
Carlos Valente d2c34757f9 refactor: navigate in electron 2024-10-11 13:26:15 +02:00
Carlos Valente aeadc78578 refactor: remove unused db 2024-10-11 13:26:15 +02:00
Carlos Valente bf98f9792a feat: open asset directories 2024-10-11 13:26:15 +02:00
Carlos Valente 6d2b59ef73 refactor: collocate view navigation 2024-10-11 13:26:15 +02:00
Carlos Valente 0a53018930 refactor: improve native menu links 2024-10-11 13:26:15 +02:00
Carlos Valente f1cac8d1c4 feat: allow download of current project 2024-10-11 13:26:15 +02:00
Carlos Valente cfb76153a0 refactor: create project form state from params 2024-10-11 13:26:15 +02:00
Alex Christoffer Rasmussen fee2021ad7 Fix: time-to-end offset (#1252)
* flip the sign correctly for time-to-end offset
2024-10-10 16:20:59 +02:00
Carlos Valente db956f7955 refactor: await init rundown 2024-10-09 20:15:10 +02:00
Carlos Valente 887e5c448e refactor: allow messages not starting with http 2024-10-09 19:19:20 +02:00
Carlos Valente 6ce275da7a refactor: allow messages not starting with http 2024-10-09 19:19:20 +02:00
Carlos Valente 213f516f71 refactor: message does not have negative style 2024-10-09 19:18:47 +02:00
Alex Christoffer Rasmussen 86e6f8b58c bump version to 3.6.1 (#1237) 2024-10-03 14:50:05 +02:00
Carlos Valente 1de062a38e refactor: remove alt shortcut 2024-10-03 08:35:56 +02:00
Carlos Valente eb7a30fcf3 refactor: improve resizing of column 2024-10-02 22:34:19 +02:00
Carlos Valente e2cbe43a2b fix: form element alignment in form 2024-10-02 21:57:52 +02:00
Carlos Valente 7877af571f fix: apply delay 2024-10-02 21:44:03 +02:00
Carlos Valente 086bd5b644 docs: add discord link 2024-10-01 08:18:19 +02:00
168 changed files with 2713 additions and 1647 deletions
-3
View File
@@ -40,9 +40,6 @@ dist/
ontime-db
ontime-external/
# working database
apps/server/src/preloaded-db/db.json
# versioning file
**/ONTIME_VERSION.js
+2 -1
View File
@@ -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
+19 -18
View File
@@ -14,8 +14,10 @@
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime?
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
@@ -35,6 +37,16 @@ Ontime is made by entertainment and broadcast engineers and used by
- Theatres and opera houses
- Houses of worship
## Main features
- [x] **Multiplatform**: Available for Windows / MacOS, Linux. You can also self host with the docker image
- [x] **In any device**: Ontime is available in the local network to any device with a browser, eg: tablets, mobile phones, laptops, signage, media servers...
- [x] **Made for teams**: Ontime caters to different roles in your production team: directors, operators, backstage and front of house signage...
- [x] **Delay workflows**: Manage and communicate runtime delays in real-time to your team
- [x] **Automatable**: Ontime can be fully or partially controlled by an operator, or run standalone with the system clock
- [x] **Focus on integrations**: Use one of the APIs provided (OSC, HTTP, Websocket) or the available [Companion module](https://bitfocus.io/connections/getontime-ontime) to integrate into your workflow (vMix, disguise, Qlab, OBS)
... and a lot more ...
### For live environments
Ontime is designed for use in live environments. \
@@ -83,6 +95,7 @@ IP.ADDRESS:4001/clock > Simple clock view
IP.ADDRESS:4001/backstage > Stage Manager / Backstage view
IP.ADDRESS:4001/countdown > Countdown to anything
IP.ADDRESS:4001/studio > Studio Clock
IP.ADDRESS:4001/timeline > Timeline
```
```
@@ -100,26 +113,13 @@ IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
IP.ADDRESS:4001/operator > automated views for operators
```
More documentation is available [in our docs](https://docs.getontime.no)
## Main features
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
More information is available [in our docs](https://docs.getontime.no)
## Roadmap
### Continued development
Ontime is under active development. We continue adding and tweaking features in collaboration with users.
Ontime is under active development. We continue adding and improving features in collaboration with users.
Have an idea? Reach out via [email](mail@getontime.no)
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
@@ -161,8 +161,9 @@ If you are a developer and would like to contribute with code, please open an is
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
## Links
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
- [Ontime website](https://getontime.no)
- [Documentation](https://docs.getontime.no)
- [Ontime discord server](https://discord.com/invite/eje3CSUEXm)
## License
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.6.0",
"version": "3.6.2-beta1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.6.0",
"version": "3.6.2-beta1",
"private": true,
"type": "module",
"dependencies": {
+67 -10
View File
@@ -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 />} />
+3 -2
View File
@@ -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 -9
View File
@@ -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
+14
View File
@@ -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,
+1 -1
View File
@@ -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 -52
View File
@@ -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>(
+1
View File
@@ -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 (
@@ -78,7 +78,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}>
<Panel.Section className={style.innerColumn}>
<label>
Project title
<Input
@@ -145,7 +145,7 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
{...register('backstageUrl')}
/>
</label>
</div>
</Panel.Section>
</Panel.Section>
);
}
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_DATA } from '../../../../common/api/constants';
import { getDb, patchData } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../PanelUtils';
import { makeProjectPatch } from './project.utils';
@@ -92,7 +93,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.innerColumn}>
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
<Panel.Description>
Select partial data from {`"${fileName}"`} to merge into the current project.
<br /> This process is irreversible.
@@ -121,7 +122,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Switch variant='ontime' {...register('http')} />
HTTP Integration
</label>
</div>
</Panel.Section>
</Panel.Section>
);
}
@@ -53,9 +53,11 @@
display: flex;
flex-direction: column;
gap: 1em;
}
.inlineLabels {
label {
display: flex;
gap: 1em;
gap: 1rem;
}
}
@@ -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}
@@ -50,9 +50,15 @@ $table-header-font-size: calc(1rem - 3px);
font-size: $table-header-font-size;
color: $gray-700;
}
th {
background-color: $gray-1300;
th {
background-color: $gray-1300;
&:hover {
.resizer {
width: 0.5rem;
}
}
}
@@ -126,30 +132,19 @@ $table-header-font-size: calc(1rem - 3px);
.resizer {
cursor: col-resize;
opacity: 0;
opacity: $opacity-disabled;
display: inline-block;
width: 3px;
width: 0;
height: 100%;
position: absolute;
right: 0;
top: 0;
transform: translateX(50%);
background-color: $action-blue;
user-select: none;
touch-action: none;
transition-duration: $transition-time-action;
transition-property: width, background-color;
&:hover {
opacity: $opacity-disabled;
width: 6px;
}
&.isResizing {
opacity: 1;
width: 6px;
background-color: $action-blue;
}
}
@@ -4,8 +4,6 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import styles from '../Cuesheet.module.scss';
interface SortableCellProps {
@@ -29,8 +27,6 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
transition,
};
const resizerClasses = cx([styles.resizer, header.column.getIsResizing() ? styles.isResizing : null]);
return (
<th ref={setNodeRef} style={dragStyle} colSpan={colSpan}>
<div {...attributes} {...listeners}>
@@ -41,7 +37,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
className={resizerClasses}
className={styles.resizer}
/>
</th>
);
@@ -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 -1
View File
@@ -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',
},
+8
View File
@@ -21,3 +21,11 @@
overflow: hidden;
text-overflow: ellipsis;
}
@mixin rotate-fourty-five {
transform: rotate(45deg);
}
.fourtyfive {
@include rotate-fourty-five;
}
+4 -3
View File
@@ -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%;
}
+1 -1
View File
@@ -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);
+2
View File
@@ -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 -1
View File
@@ -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);
}
}
+32
View File
@@ -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;
}
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.6.0",
"version": "3.6.2-beta1",
"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",
@@ -23,7 +23,7 @@
"postinstall": "",
"lint": "eslint . --quiet",
"dev": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-win": "electron-builder --publish=never --x64 --ia32 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
"dist-linux": "electron-builder --publish=never --x64 --linux",
@@ -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/',
+98
View File
@@ -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,
};

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