mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 21:54:09 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d79764ffe | |||
| a67dbd8a59 | |||
| 361b6eb875 |
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -13,7 +13,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^9.5.1",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
@@ -29,7 +29,7 @@
|
||||
"react-dom": "^19.2.7",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-icons": "5.7.0",
|
||||
"react-icons": "5.6.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -60,8 +60,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
"@types/node": "catalog:",
|
||||
"@sentry/vite-plugin": "5.1.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -73,8 +72,8 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -19,15 +23,14 @@
|
||||
|
||||
width: 22rem;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem;
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
border-right: 1px solid $gray-1100;
|
||||
box-shadow: $box-shadow-right;
|
||||
|
||||
&[data-open] {
|
||||
transform: translateX(0%);
|
||||
@@ -47,63 +50,22 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
height: 3.5rem;
|
||||
padding-inline: 1.5rem;
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.clientName {
|
||||
font-size: $aux-text-size;
|
||||
color: $gray-600;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-block: 0.5rem 1rem;
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $gray-1000 transparent;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
padding: 0.5rem 1.25rem 0.25rem;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid $white-10;
|
||||
.note {
|
||||
margin-left: auto;
|
||||
color: $white-20;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { LuCoffee } from 'react-icons/lu';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
@@ -17,7 +17,6 @@ import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import ClientLink from './client-link/ClientLink';
|
||||
import EditorNavigation from './editor-navigation/EditorNavigation';
|
||||
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
|
||||
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
|
||||
import OtherAddresses from './other-addresses/OtherAddresses';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
@@ -34,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||
const { fullscreen, toggle } = useFullscreenDocument();
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||
const location = useLocation();
|
||||
@@ -53,77 +52,66 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<div className={style.headerText}>
|
||||
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
|
||||
{name && <span className={style.clientName}>{name}</span>}
|
||||
</div>
|
||||
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
|
||||
<Dialog.Title>Ontime</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={onClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.body}>
|
||||
<MenuGroup label='This screen'>
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onClick={toggle} />
|
||||
)}
|
||||
<NavigationMenuToggle
|
||||
checked={mirror}
|
||||
icon={<IoSwapVertical />}
|
||||
label='Flip Screen'
|
||||
onClick={() => toggleMirror()}
|
||||
/>
|
||||
{canUseWakeLock && (
|
||||
<NavigationMenuToggle
|
||||
checked={keepAwake}
|
||||
icon={<LuCoffee />}
|
||||
label='Keep Awake'
|
||||
onClick={toggleKeepAwake}
|
||||
/>
|
||||
)}
|
||||
<NavigationMenuItem onClick={handlers.open}>
|
||||
<IoPencilOutline />
|
||||
Rename Client
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
</NavigationMenuItem>
|
||||
</MenuGroup>
|
||||
)}
|
||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
||||
Flip Screen
|
||||
<IoSwapVertical />
|
||||
{mirror && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
{canUseWakeLock && (
|
||||
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
|
||||
Keep Awake
|
||||
<LuCoffee />
|
||||
{keepAwake && <span className={style.note}>Active</span>}
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
||||
|
||||
<MenuGroup label='Ontime app'>
|
||||
<EditorNavigation />
|
||||
<hr className={style.separator} />
|
||||
|
||||
<EditorNavigation />
|
||||
<ClientLink
|
||||
to='cuesheet'
|
||||
current={location.pathname === '/cuesheet'}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
</ClientLink>
|
||||
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
</ClientLink>
|
||||
|
||||
<hr className={style.separator} />
|
||||
|
||||
{navigatorConstants.map((route) => (
|
||||
<ClientLink
|
||||
to='cuesheet'
|
||||
current={location.pathname === '/cuesheet'}
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
current={location.pathname === `/${route.url}`}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
{route.label}
|
||||
</ClientLink>
|
||||
<ClientLink
|
||||
to='op'
|
||||
current={location.pathname === '/op'}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
<IoLockClosedOutline />
|
||||
Operator
|
||||
</ClientLink>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup label='Views'>
|
||||
{navigatorConstants.map((route) => (
|
||||
<ClientLink
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
current={location.pathname === `/${route.url}`}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
{route.label}
|
||||
</ClientLink>
|
||||
))}
|
||||
</MenuGroup>
|
||||
))}
|
||||
|
||||
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
||||
</div>
|
||||
|
||||
{isLocalhost && (
|
||||
<div className={style.footer}>
|
||||
<div>
|
||||
<OtherAddresses currentLocation={location.pathname} />
|
||||
</div>
|
||||
)}
|
||||
@@ -133,15 +121,6 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.group}>
|
||||
<div className={style.groupLabel}>{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||
const location = useLocation();
|
||||
const { data: urlPresets } = useUrlPresets();
|
||||
@@ -150,7 +129,8 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
if (navPresets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<MenuGroup label='Presets'>
|
||||
<>
|
||||
<hr className={style.separator} />
|
||||
{navPresets.map((preset) => (
|
||||
<ClientLink
|
||||
key={preset.alias}
|
||||
@@ -161,6 +141,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
{preset.alias}
|
||||
</ClientLink>
|
||||
))}
|
||||
</MenuGroup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-75
@@ -2,98 +2,35 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.625rem 1.25rem;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
|
||||
border-left: 3px solid transparent;
|
||||
color: $gray-200;
|
||||
font-size: $text-body-size;
|
||||
border-left: 4px solid transparent;
|
||||
color: $action-text-color;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition-property: color, background-color;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
// keep icons on a fixed box so labels align regardless of the glyph
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: $gray-500;
|
||||
transition: color $transition-time-action;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $white-3;
|
||||
color: $ui-white;
|
||||
|
||||
> svg {
|
||||
color: $gray-300;
|
||||
}
|
||||
color: $ontime-color;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $white-7;
|
||||
background-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:focus {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
background-color: $white-3;
|
||||
background-color: $gray-1350;
|
||||
}
|
||||
|
||||
&.current {
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
border-left-color: $blue-400;
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
|
||||
> svg {
|
||||
color: $blue-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational switch for toggle rows.
|
||||
* The row itself owns the interaction (role=switch), so this is aria-hidden:
|
||||
* nesting a real control inside a clickable row would double up the hit target.
|
||||
* Metrics and colours are kept in sync with the medium Switch component.
|
||||
*/
|
||||
.toggleTrack {
|
||||
flex-shrink: 0;
|
||||
box-sizing: content-box;
|
||||
width: 2.5rem;
|
||||
height: calc(1.5rem - 4px);
|
||||
padding: 2px;
|
||||
border-radius: $component-border-radius-full;
|
||||
background-color: $gray-1100;
|
||||
transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: calc(1.5rem - 4px);
|
||||
height: 100%;
|
||||
border-radius: $component-border-radius-full;
|
||||
background-color: $ui-white;
|
||||
transition: translate 150ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
[aria-checked='true'] > .toggleTrack {
|
||||
background-color: $blue-700;
|
||||
|
||||
&::after {
|
||||
translate: calc(2.5rem - (1.5rem - 4px)) 0;
|
||||
background-color: $gray-1300;
|
||||
border-left: 4px solid $action-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { isKeyEnter } from '../../../utils/keyEvent';
|
||||
|
||||
import style from './NavigationMenuItem.module.scss';
|
||||
|
||||
interface NavigationMenuToggleProps {
|
||||
checked: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A menu row which reflects an on/off state.
|
||||
* We keep the div + Enter handling of NavigationMenuItem instead of a button:
|
||||
* Space is a global hotkey for toggling the menu, and a button would react to it as well.
|
||||
*/
|
||||
export default function NavigationMenuToggle({ checked, icon, label, onClick }: NavigationMenuToggleProps) {
|
||||
return (
|
||||
<div
|
||||
className={style.link}
|
||||
tabIndex={0}
|
||||
role='switch'
|
||||
aria-checked={checked}
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event)) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={style.toggleTrack} aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-6
@@ -1,14 +1,11 @@
|
||||
.header {
|
||||
padding: 0 1.25rem 0.25rem;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.25rem 1.25rem 0;
|
||||
padding: 0.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
.drawerFooter {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -24,11 +34,11 @@
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem 1.5rem;
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
border-left: 1px solid $gray-1100;
|
||||
box-shadow: $box-shadow-l1;
|
||||
|
||||
&[data-open] {
|
||||
transform: translateX(0%);
|
||||
@@ -48,58 +58,36 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
height: 3.5rem;
|
||||
|
||||
.headerText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.target {
|
||||
font-size: $aux-text-size;
|
||||
color: $gray-600;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
padding-inline: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $gray-1000 transparent;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
padding: 0.75rem 1rem;
|
||||
padding-inline: 1rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
background-color: $gray-1250;
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 2rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -18,19 +18,6 @@ import ViewParamsSection from './ViewParamsSection';
|
||||
|
||||
import style from './ViewParamsEditor.module.scss';
|
||||
|
||||
/** User facing names for the views we can customise */
|
||||
const viewLabels: Record<OntimeView, string> = {
|
||||
[OntimeView.Editor]: 'Editor',
|
||||
[OntimeView.Cuesheet]: 'Cuesheet',
|
||||
[OntimeView.Operator]: 'Operator',
|
||||
[OntimeView.Timer]: 'Timer',
|
||||
[OntimeView.Backstage]: 'Backstage',
|
||||
[OntimeView.Timeline]: 'Timeline',
|
||||
[OntimeView.StudioClock]: 'Studio Clock',
|
||||
[OntimeView.Countdown]: 'Countdown',
|
||||
[OntimeView.ProjectInfo]: 'Project Info',
|
||||
};
|
||||
|
||||
interface EditFormDrawerProps {
|
||||
target: OntimeView;
|
||||
viewOptions: ViewOption[];
|
||||
@@ -84,17 +71,8 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
<Dialog.Backdrop className={style.backdrop} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<div className={style.headerText}>
|
||||
<Dialog.Title className={style.title}>Customise</Dialog.Title>
|
||||
<span className={style.target}>{viewLabels[target]}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='large'
|
||||
aria-label='Close'
|
||||
data-testid='close-view-params'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Dialog.Title>Customise</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,22 @@
|
||||
.presetSection {
|
||||
background-color: $white-2;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $panel-border-radius;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2.75rem;
|
||||
background-color: $gray-1350;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.presetList {
|
||||
padding-inline: 0.5rem;
|
||||
border-top: 1px solid $white-7;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $gray-1000 transparent;
|
||||
}
|
||||
|
||||
.preset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: $component-border-radius-md;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&.active {
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,25 +26,22 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||
|
||||
return (
|
||||
<div className={style.presetSection}>
|
||||
<div className={style.header}>Presets</div>
|
||||
<div className={style.presetList}>
|
||||
{viewPresets.map((preset) => {
|
||||
const active = searchParams.get('alias') === preset.alias;
|
||||
return (
|
||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||
<div>{preset.alias}</div>
|
||||
<Button
|
||||
variant={active ? 'ghosted' : 'subtle-white'}
|
||||
onClick={() => handleRecall(preset)}
|
||||
disabled={active}
|
||||
className={style.presetActions}
|
||||
>
|
||||
{active ? 'Applied' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{viewPresets.map((preset) => {
|
||||
const active = searchParams.get('alias') === preset.alias;
|
||||
return (
|
||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||
<div>{preset.alias}</div>
|
||||
<Button
|
||||
variant={active ? 'ghosted' : 'subtle-white'}
|
||||
onClick={() => handleRecall(preset)}
|
||||
disabled={active}
|
||||
className={style.presetActions}
|
||||
>
|
||||
{active ? 'Applied' : 'Apply'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-90
@@ -1,116 +1,48 @@
|
||||
.section {
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
background-color: $white-2;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $panel-border-radius;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: $gray-300;
|
||||
height: 2rem;
|
||||
|
||||
&.collapsible {
|
||||
cursor: pointer;
|
||||
border-radius: $panel-border-radius;
|
||||
transition-property: color, background-color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
&:hover {
|
||||
background-color: $white-3;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/** marks a section which holds values coming from the URL */
|
||||
.customised {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 50%;
|
||||
background-color: $active-indicator;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
rotate: 180deg;
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
|
||||
&.closed {
|
||||
rotate: 0deg;
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
padding-inline: 1rem;
|
||||
border-top: 1px solid $white-7;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 0.25rem;
|
||||
padding: 0.75rem 0;
|
||||
margin-top: $section-spacing;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact controls sit beside their label instead of below it.
|
||||
* Placement is done with grid areas so that the markup stays flat.
|
||||
*/
|
||||
.inline {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 1rem;
|
||||
|
||||
> .title {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
> .description {
|
||||
grid-area: 2 / 1;
|
||||
}
|
||||
|
||||
> *:last-child {
|
||||
grid-area: 1 / 2 / span 2;
|
||||
}
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $ui-white;
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-400;
|
||||
font-size: $aux-text-size;
|
||||
line-height: 1.4;
|
||||
max-width: 60ch;
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
color: $modal-note-color;
|
||||
}
|
||||
|
||||
.closed {
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 0deg;
|
||||
}
|
||||
|
||||
.open {
|
||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isKeyEnter } from '../../utils/keyEvent';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { OptionTitle } from './constants';
|
||||
import ParamInput from './ParamInput';
|
||||
@@ -16,19 +14,8 @@ interface ViewParamsSectionProps {
|
||||
options: ParamField[];
|
||||
}
|
||||
|
||||
/** Fields which are compact enough to share a row with their label */
|
||||
function isInlineField(option: ParamField): boolean {
|
||||
return option.type === 'boolean' || option.type === 'colour';
|
||||
}
|
||||
|
||||
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// hidden options are inputs without a UI, there is nothing to frame them with
|
||||
if (title === OptionTitle.Hidden) {
|
||||
return <HiddenContents options={options} />;
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
if (collapsible) {
|
||||
@@ -36,43 +23,34 @@ export default function ViewParamsSection({ title, collapsible, options }: ViewP
|
||||
}
|
||||
};
|
||||
|
||||
const isCollapsed = Boolean(collapsible && collapsed);
|
||||
const isCustomised = options.some((option) => searchParams.has(option.id));
|
||||
|
||||
return (
|
||||
<section className={style.section}>
|
||||
<div
|
||||
className={cx([style.sectionHeader, collapsible && style.collapsible])}
|
||||
onClick={handleCollapse}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event)) {
|
||||
handleCollapse();
|
||||
}
|
||||
}}
|
||||
role={collapsible ? 'button' : undefined}
|
||||
tabIndex={collapsible ? 0 : undefined}
|
||||
aria-expanded={collapsible ? !isCollapsed : undefined}
|
||||
>
|
||||
<span className={style.sectionTitle}>
|
||||
{title}
|
||||
{isCustomised && <span className={style.customised} title='Contains custom values' />}
|
||||
</span>
|
||||
{collapsible && <IoChevronDown className={cx([style.chevron, isCollapsed && style.closed])} />}
|
||||
</div>
|
||||
{/* collapsed options stay mounted: the form reads its values from the DOM */}
|
||||
<div className={cx([style.options, isCollapsed && style.hidden])}>
|
||||
<SectionContents options={options} />
|
||||
</div>
|
||||
{title === OptionTitle.Hidden ? (
|
||||
<HiddenContents options={options} />
|
||||
) : (
|
||||
<>
|
||||
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
|
||||
{title}
|
||||
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
|
||||
</div>
|
||||
<SectionContents options={options} collapsed={collapsed} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionContents({ options }: { options: ParamField[] }) {
|
||||
interface SectionContentsProps {
|
||||
options: ParamField[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
|
||||
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
|
||||
@@ -200,7 +200,8 @@ $card-padding: 2rem;
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: $zindex-backdrop;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.updateIndicator {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AppVersion.module.scss';
|
||||
|
||||
export default function AppVersion() {
|
||||
const { data, isError } = useAppVersion();
|
||||
|
||||
@@ -20,12 +18,7 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span className={style.updateIndicator} aria-hidden='true' />
|
||||
{`Ontime ${appVersion}`}
|
||||
</>
|
||||
}
|
||||
title={`Ontime ${appVersion}`}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
@@ -33,7 +26,7 @@ export default function AppVersion() {
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
);
|
||||
|
||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{data.pendingRestart && (
|
||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||
)}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "38.2.1",
|
||||
"electron-builder": "26.15.3",
|
||||
"wait-on": "^9.0.0"
|
||||
"electron-builder": "26.9.1",
|
||||
"wait-on": "^7.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
||||
submenu: [
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__create'),
|
||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
||||
},
|
||||
{
|
||||
label: 'Load...',
|
||||
@@ -202,18 +202,6 @@ function makeSettingsMenu(redirectWindow) {
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||
},
|
||||
{
|
||||
label: 'Custom views',
|
||||
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||
},
|
||||
{
|
||||
label: 'Server port',
|
||||
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"cookie": "1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.6",
|
||||
"dotenv": "^17.0.0",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "5.2.1",
|
||||
"express-static-gzip": "3.0.1",
|
||||
"express-validator": "7.3.2",
|
||||
@@ -31,11 +31,11 @@
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.5.10",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"server-timing": "^3.3.3",
|
||||
"ts-essentials": "catalog:",
|
||||
"tsx": "^4.23.12",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
|
||||
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
|
||||
|
||||
/** List of event properties which do not need the rundown to be regenerated */
|
||||
enum RegenerateWhitelist {
|
||||
id, // adding it for completeness, users cannot change ID
|
||||
type, // adding it for completeness, users cannot change ID
|
||||
cue,
|
||||
title,
|
||||
note,
|
||||
endAction,
|
||||
timerType,
|
||||
countToEnd,
|
||||
colour,
|
||||
timeWarning,
|
||||
timeDanger,
|
||||
custom,
|
||||
triggers,
|
||||
'id', // adding it for completeness, users cannot change ID
|
||||
'type', // adding it for completeness, users cannot change ID
|
||||
'cue',
|
||||
'title',
|
||||
'note',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'countToEnd',
|
||||
'colour',
|
||||
'timeWarning',
|
||||
'timeDanger',
|
||||
'custom',
|
||||
'triggers',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,9 +20,12 @@ export function createMcpServer(): Server {
|
||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
|
||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||
}));
|
||||
server.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (): Promise<ListToolsResult> => ({
|
||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||
}),
|
||||
);
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { EndAction, Instant, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, dayInMs, millisToString } from 'ontime-utils';
|
||||
|
||||
import * as timeCore from '../../lib/time-core/timeCore.js';
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
@@ -53,11 +54,12 @@ describe('getElapsed()', () => {
|
||||
it('uses the current pause start while paused', () => {
|
||||
const state = {
|
||||
clock: 10 * MILLIS_PER_MINUTE,
|
||||
_now: timeCore.toInstant((10 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
timer: {
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 7 * MILLIS_PER_MINUTE,
|
||||
pausedAt: timeCore.toInstant((7 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
} as RuntimeState;
|
||||
@@ -975,6 +977,40 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(25);
|
||||
});
|
||||
|
||||
it('paused time is delayed time when the pause spans midnight', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
|
||||
_now: timeCore.toInstant((3 * MILLIS_PER_MINUTE) as TimeOfDay, timeCore.now()),
|
||||
timer: {
|
||||
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
|
||||
current: 25, // still counting down
|
||||
addedTime: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: timeCore.toInstant(
|
||||
(23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE) as TimeOfDay,
|
||||
(timeCore.now() - dayInMs) as Instant,
|
||||
), // 23:58, before midnight
|
||||
pausedDuration: 0,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 23 * MILLIS_PER_HOUR,
|
||||
plannedStart: 23 * MILLIS_PER_HOUR,
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
|
||||
// paused from 23:58 to 00:03 -> so elapsed should still be 58 minutes
|
||||
expect(getElapsed(state)).toBe(58 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('offset doesnt exist if we havent started', () => {
|
||||
const state = {
|
||||
clock: 78480789,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import * as timeCore from '../lib/time-core/timeCore.js';
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
|
||||
/**
|
||||
@@ -96,17 +97,18 @@ export function getCurrent(state: RuntimeState): number {
|
||||
* Calculates active time elapsed since the timer started.
|
||||
*/
|
||||
export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||
const { clock } = state;
|
||||
const { clock, _now } = state;
|
||||
const { startedAt } = state.timer;
|
||||
const { pausedAt, pausedDuration } = state._timer;
|
||||
const { pausedDuration, pausedAt } = state._timer;
|
||||
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const referenceClock = pausedAt ?? clock;
|
||||
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration;
|
||||
const currentPauseDuration = pausedAt !== null ? timeCore.timeSince(_now, pausedAt) : 0;
|
||||
|
||||
const elapsedSinceStart = getTimeSinceStart(clock, startedAt);
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration - currentPauseDuration;
|
||||
|
||||
return Math.max(0, activeElapsed);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { Instant, OffsetMode, Playback, type TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../runtimeState.js';
|
||||
|
||||
const baseState: RuntimeState = {
|
||||
clock: 0 as TimeOfDay,
|
||||
_now: 0 as Instant,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
eventFlag: null,
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('mutation on runtimeState', () => {
|
||||
playback: Playback.Pause,
|
||||
addedTime: 0,
|
||||
});
|
||||
expect(newState._timer.pausedAt).toEqual(newState.clock);
|
||||
expect(newState._timer.pausedAt).toEqual(newState._now);
|
||||
|
||||
success = pause();
|
||||
expect(success).toBe(false);
|
||||
@@ -248,6 +248,59 @@ describe('mutation on runtimeState', () => {
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
test('elapsed excludes a pause that spans midnight', async () => {
|
||||
clearState();
|
||||
// an event that runs over midnight (23:00 -> 01:00)
|
||||
const event = {
|
||||
...mockEvent,
|
||||
id: 'elapsed-pause-midnight',
|
||||
timeStart: 23 * MILLIS_PER_HOUR,
|
||||
timeEnd: 1 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
};
|
||||
const mockRundown = makeRundown({
|
||||
entries: { [event.id]: event },
|
||||
order: [event.id],
|
||||
});
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start before midnight
|
||||
vi.setSystemTime('jan 1 23:50');
|
||||
load(event, rundown, metadata);
|
||||
start();
|
||||
|
||||
// 8 minutes of active running before we pause
|
||||
vi.setSystemTime('jan 1 23:58');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
pause();
|
||||
|
||||
// elapsed is active time since start, so it must not advance while paused,
|
||||
// not even when the pause itself crosses midnight
|
||||
vi.setSystemTime('jan 2 00:01');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
|
||||
vi.setSystemTime('jan 2 00:03');
|
||||
start();
|
||||
let state = getState();
|
||||
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
|
||||
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
|
||||
// and elapsed still reflects only the 8 active minutes
|
||||
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// 2 more active minutes after resume -> 10 minutes elapsed
|
||||
vi.setSystemTime('jan 2 00:05');
|
||||
update();
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime offset', async () => {
|
||||
|
||||
@@ -63,7 +63,9 @@ export type RuntimeState = {
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||
pausedAt: Maybe<TimeOfDay>;
|
||||
pausedAt: Maybe<Instant>;
|
||||
|
||||
/** Accumulate pause duration but dose not include the current pause */
|
||||
pausedDuration: number;
|
||||
secondaryTarget: Maybe<TimeOfDay>;
|
||||
hasFinished: boolean;
|
||||
@@ -76,10 +78,12 @@ export type RuntimeState = {
|
||||
_end: ExpectedMetadata;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
_startDayOffset: Maybe<Day>;
|
||||
_now: Instant;
|
||||
};
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
clock: timeCore.timeOfDayNow(),
|
||||
_now: timeCore.now(),
|
||||
groupNow: null,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
@@ -104,6 +108,12 @@ const runtimeState: RuntimeState = {
|
||||
_startDayOffset: null,
|
||||
};
|
||||
|
||||
/** set the current clock to ensure parity between _now and clock */
|
||||
function setClock(state: RuntimeState) {
|
||||
state._now = timeCore.now();
|
||||
state.clock = timeCore.toTimeOfDay(state._now);
|
||||
}
|
||||
|
||||
export function getState(): Readonly<RuntimeState> {
|
||||
// create a shallow copy of the state
|
||||
return {
|
||||
@@ -136,7 +146,7 @@ export function clearEventData() {
|
||||
runtimeState.rundown.selectedEventIndex = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
setClock(runtimeState);
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
@@ -169,7 +179,7 @@ export function clearState() {
|
||||
runtimeState._end = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
setClock(runtimeState);
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
@@ -422,15 +432,12 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
|
||||
state.clock = now;
|
||||
setClock(state);
|
||||
state.timer.secondaryTimer = null;
|
||||
|
||||
// add paused time if it exists
|
||||
if (state._timer.pausedAt) {
|
||||
const timeToAdd = state.clock - state._timer.pausedAt;
|
||||
const timeToAdd = state._now - state._timer.pausedAt;
|
||||
state.timer.addedTime += timeToAdd;
|
||||
state._timer.pausedDuration += timeToAdd;
|
||||
state._timer.pausedAt = null;
|
||||
@@ -447,7 +454,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
if (state.rundown.actualStart === null) {
|
||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||
state.rundown.currentDay = state._startDayOffset;
|
||||
state._startEpoch = epoch;
|
||||
state._startEpoch = state._now;
|
||||
state.rundown.actualStart = state.clock;
|
||||
}
|
||||
|
||||
@@ -481,8 +488,8 @@ export function pause(state: RuntimeState = runtimeState): boolean {
|
||||
}
|
||||
|
||||
state.timer.playback = Playback.Pause;
|
||||
state.clock = timeCore.timeOfDayNow();
|
||||
state._timer.pausedAt = state.clock;
|
||||
setClock(state);
|
||||
state._timer.pausedAt = state._now;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -547,9 +554,7 @@ export type UpdateResult = {
|
||||
export function update(): UpdateResult {
|
||||
// 0. there are some things we always do
|
||||
const previousClock = runtimeState.clock;
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
runtimeState.clock = now; // we update the clock on every update call
|
||||
setClock(runtimeState); // we update the clock on every update call
|
||||
|
||||
// 1. is playback idle?
|
||||
if (!isPlaybackActive(runtimeState.timer.playback)) {
|
||||
@@ -558,13 +563,13 @@ export function update(): UpdateResult {
|
||||
|
||||
// calculate currentDay from epoch (days elapsed since playback was started)
|
||||
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, runtimeState._now);
|
||||
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
|
||||
}
|
||||
|
||||
// 2. are we waiting to roll?
|
||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, runtimeState.clock);
|
||||
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -41,14 +41,14 @@
|
||||
"format:check": "oxfmt --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@types/node": "catalog:",
|
||||
"cross-env": "^7.0.3",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"oxfmt": "^0.42.0",
|
||||
"oxlint": "^1.57.0",
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"rimraf": "catalog:",
|
||||
"turbo": "2.10.10",
|
||||
"turbo": "2.8.20",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"nanoid": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"ontime-types": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
Generated
+2092
-1107
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -4,10 +4,10 @@ packages:
|
||||
|
||||
catalog:
|
||||
'@types/node': 22.19.11
|
||||
rimraf: 6.1.3
|
||||
ts-essentials: 10.2.1
|
||||
rimraf: 6.0.1
|
||||
ts-essentials: 10.1.1
|
||||
typescript: 7.0.2
|
||||
vitest: 4.1.10
|
||||
vitest: 4.0.17
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
|
||||
Reference in New Issue
Block a user