mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-11 00:59:35 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1190b1ec7 | |||
| 150e0c83e9 | |||
| a84b8b5530 | |||
| c19aa4ca28 | |||
| 805663e2ea | |||
| 0f0b32d444 | |||
| 703dee35a4 | |||
| 2a7f5b7872 | |||
| c6eccec30e | |||
| 5220c2c374 | |||
| 4eeeb294f7 |
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@base-ui/react": "1.7.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -13,12 +13,12 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@mantine/hooks": "^9.5.1",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-table": "^9.1.2",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.18.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.6.0",
|
||||
"react-icons": "5.7.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
@@ -60,7 +60,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "5.1.1",
|
||||
"@sentry/vite-plugin": "5.4.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
@@ -72,8 +73,8 @@
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "catalog:",
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const Backstage = lazy(() => import('./views/backstage/Backstage'));
|
||||
const StudioClock = lazy(() => import('./views/studio/Studio'));
|
||||
const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
const Teleprompter = lazy(() => import('./views/teleprompter/Teleprompter'));
|
||||
|
||||
const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
|
||||
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
|
||||
@@ -95,6 +96,15 @@ export default function AppRouter() {
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='teleprompter'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSpaceHotkey />
|
||||
<Teleprompter />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
{/*/!* Protected Routes *!/*/}
|
||||
<Route path='editor' element={<Editor />} />
|
||||
<Route path='cuesheet' element={<Cuesheet />} />
|
||||
@@ -165,6 +175,7 @@ const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
|
||||
[OntimeView.StudioClock]: StudioClock,
|
||||
[OntimeView.Countdown]: Countdown,
|
||||
[OntimeView.ProjectInfo]: ProjectInfo,
|
||||
[OntimeView.Teleprompter]: Teleprompter,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -214,8 +225,14 @@ function PresetView() {
|
||||
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||
return (
|
||||
<PresetContext value={preset}>
|
||||
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
|
||||
{Component ? <Component /> : <NotFound />}
|
||||
<ViewLoader>
|
||||
<ViewNavigationMenu
|
||||
isNavigationLocked={getIsNavigationLocked()}
|
||||
suppressSettings
|
||||
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
|
||||
/>
|
||||
{Component ? <Component /> : <NotFound />}
|
||||
</ViewLoader>
|
||||
</PresetContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.eyebrow {
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Eyebrow.module.scss';
|
||||
|
||||
interface EyebrowProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
|
||||
return <span className={cx([style.eyebrow, className])}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.groups {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.group {
|
||||
h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
color: var(--shortcut-title-color, currentColor);
|
||||
font-size: calc(1rem - 3px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
min-height: 1.625rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
color: var(--shortcut-label-color, currentColor);
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.combo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.25rem 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--shortcut-separator-color, currentColor);
|
||||
font-size: calc(1rem - 5px);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.keys {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Kbd from '../kbd/Kbd';
|
||||
|
||||
import style from './KeyboardShortcuts.module.scss';
|
||||
|
||||
export function ShortcutGroups({ className, children }: PropsWithChildren<{ className?: string }>) {
|
||||
return <div className={cx([style.groups, className])}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
|
||||
return (
|
||||
<section className={style.group}>
|
||||
<h3>{title}</h3>
|
||||
<div className={style.list}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.row}>
|
||||
<span className={style.label}>{label}</span>
|
||||
<span className={style.keys}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Combo({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<span className={style.combo}>
|
||||
{keys.map((key) => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Separator() {
|
||||
return <span className={style.separator}>/</span>;
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
.separator {
|
||||
border-color: $border-color-ondark;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -23,14 +19,15 @@
|
||||
|
||||
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%);
|
||||
@@ -50,22 +47,51 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3.5rem;
|
||||
padding-inline: 1.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.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;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-block: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-left: auto;
|
||||
color: $white-20;
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
padding: 0.5rem 1.25rem 0.25rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
|
||||
import { LuCoffee } from 'react-icons/lu';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
@@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
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';
|
||||
@@ -33,7 +35,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||
const { fullscreen, toggle } = useFullscreen();
|
||||
const { fullscreen, toggle } = useFullscreenDocument();
|
||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||
const location = useLocation();
|
||||
@@ -52,75 +54,93 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<Dialog.Title>Ontime</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={onClose}>
|
||||
<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}>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.body}>
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
||||
Toggle Fullscreen
|
||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
||||
<MenuGroup label='This screen'>
|
||||
{supportsFullscreen && (
|
||||
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
|
||||
)}
|
||||
<NavigationMenuToggle
|
||||
checked={mirror}
|
||||
icon={<IoSwapVertical />}
|
||||
label='Flip Screen'
|
||||
onToggle={() => toggleMirror()}
|
||||
/>
|
||||
{canUseWakeLock && (
|
||||
<NavigationMenuToggle
|
||||
checked={keepAwake}
|
||||
icon={<LuCoffee />}
|
||||
label='Keep Awake'
|
||||
onToggle={toggleKeepAwake}
|
||||
/>
|
||||
)}
|
||||
<NavigationMenuItem onClick={handlers.open}>
|
||||
<IoPencilOutline />
|
||||
Rename Client
|
||||
</NavigationMenuItem>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<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) => (
|
||||
<MenuGroup label='Ontime app'>
|
||||
<EditorNavigation />
|
||||
<ClientLink
|
||||
key={route.url}
|
||||
to={route.url}
|
||||
current={location.pathname === `/${route.url}`}
|
||||
to='cuesheet'
|
||||
current={location.pathname === '/cuesheet'}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
{route.label}
|
||||
<IoLockClosedOutline />
|
||||
Cuesheet
|
||||
</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>
|
||||
<OtherAddresses currentLocation={location.pathname} />
|
||||
</div>
|
||||
)}
|
||||
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||
</Dialog.Popup>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.group}>
|
||||
<div className={style.groupLabel}>
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||
const location = useLocation();
|
||||
const { data: urlPresets } = useUrlPresets();
|
||||
@@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
if (navPresets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr className={style.separator} />
|
||||
<MenuGroup label='Presets'>
|
||||
{navPresets.map((preset) => (
|
||||
<ClientLink
|
||||
key={preset.alias}
|
||||
@@ -141,6 +160,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
||||
{preset.alias}
|
||||
</ClientLink>
|
||||
))}
|
||||
</>
|
||||
</MenuGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { useHotkeys } from '@mantine/hooks';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { hasCustomParams } from '../../stores/savedViewParams';
|
||||
@@ -13,23 +13,41 @@ interface ViewNavigationMenuProps {
|
||||
isNavigationLocked?: boolean;
|
||||
/** prevent showing settings */
|
||||
suppressSettings?: boolean;
|
||||
/** leave Space to the view, which needs it for its own transport */
|
||||
suppressSpaceHotkey?: boolean;
|
||||
}
|
||||
|
||||
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
target.closest(
|
||||
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
|
||||
) !== null
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ViewNavigationMenu);
|
||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpaceHotkey }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const hasSavedChanges = hasCustomParams(searchParams);
|
||||
|
||||
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
|
||||
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
'Space',
|
||||
() => {
|
||||
if (isNavigationLocked) return;
|
||||
menuHandler.toggle();
|
||||
(event) => {
|
||||
if (suppressSpaceHotkey || isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
|
||||
event.preventDefault();
|
||||
toggleMenu();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
{ preventDefault: false },
|
||||
],
|
||||
[
|
||||
'mod + ,',
|
||||
@@ -48,11 +66,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
||||
return (
|
||||
<>
|
||||
<FloatingNavigation
|
||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
|
||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||
hasSavedChanges={hasSavedChanges}
|
||||
/>
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+45
-12
@@ -2,35 +2,68 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 0.75rem 1.5rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
|
||||
border-left: 4px solid transparent;
|
||||
color: $action-text-color;
|
||||
border-left: 3px solid transparent;
|
||||
color: $gray-200;
|
||||
font-size: $text-body-size;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition-property: color;
|
||||
transition-property: color, background-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 {
|
||||
color: $ontime-color;
|
||||
background-color: $gray-1350;
|
||||
background-color: $white-3;
|
||||
color: $ui-white;
|
||||
|
||||
> svg {
|
||||
color: $gray-300;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $border-color-ondark;
|
||||
background-color: $white-7;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
background-color: $gray-1350;
|
||||
outline-offset: -2px;
|
||||
background-color: $white-3;
|
||||
}
|
||||
|
||||
// toggle rows are labels, the focusable control sits inside them
|
||||
&:focus-within {
|
||||
background-color: $white-3;
|
||||
}
|
||||
|
||||
&.current {
|
||||
background-color: $gray-1300;
|
||||
border-left: 4px solid $action-text-color;
|
||||
background-color: $bg-active;
|
||||
border-left-color: $blue-400;
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
|
||||
> svg {
|
||||
color: $blue-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@include ellipsis-text;
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { isKeyEnter } from '../../../utils/keyEvent';
|
||||
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './NavigationMenuItem.module.scss';
|
||||
@@ -11,6 +11,7 @@ interface NavigationMenuItemProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
|
||||
export default function NavigationMenuItem({
|
||||
active,
|
||||
className,
|
||||
@@ -24,7 +25,9 @@ export default function NavigationMenuItem({
|
||||
role='button'
|
||||
onClick={onClick}
|
||||
onKeyDown={(event) => {
|
||||
if (isKeyEnter(event)) {
|
||||
if (isKeyEnter(event) || isKeySpace(event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import Switch from '../../switch/Switch';
|
||||
|
||||
import style from './NavigationMenuItem.module.scss';
|
||||
|
||||
interface NavigationMenuToggleProps {
|
||||
checked: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** A menu row which reflects, and toggles, an on/off state */
|
||||
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
|
||||
return (
|
||||
<label className={style.link}>
|
||||
{icon}
|
||||
<span className={style.label}>{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
+34
-4
@@ -1,22 +1,52 @@
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-left: 1rem;
|
||||
padding: 0 1.25rem 0.25rem;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.interfaces {
|
||||
padding: 0.5rem 0.5rem;
|
||||
padding: 0.25rem 1.25rem 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.interface {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
|
||||
> div {
|
||||
max-width: 100%;
|
||||
|
||||
> button:first-child {
|
||||
min-width: 0;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
justify-content: flex-start;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.interfaceCopy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.address {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.goIcon {
|
||||
flex: 0 0 auto;
|
||||
@include rotate-fourty-five;
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
+18
-15
@@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5';
|
||||
import useInfo from '../../../hooks-query/useInfo';
|
||||
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
|
||||
import CopyTag from '../../copy-tag/CopyTag';
|
||||
import Eyebrow from '../../eyebrow/Eyebrow';
|
||||
import { getExternalInterfaces } from './otherAddresses.utils';
|
||||
|
||||
import style from './OtherAddresses.module.scss';
|
||||
|
||||
@@ -12,32 +14,33 @@ interface OtherAddressesProps {
|
||||
|
||||
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
|
||||
const { data } = useInfo();
|
||||
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
|
||||
|
||||
// there is no point showing this if we only have one interface
|
||||
if (data.networkInterfaces.length < 2) {
|
||||
if (externalInterfaces.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={style.header}>Accessible on external networks</div>
|
||||
<div className={style.footer}>
|
||||
<div className={style.header}>
|
||||
<Eyebrow>Also available at</Eyebrow>
|
||||
</div>
|
||||
<div className={style.interfaces}>
|
||||
{data?.networkInterfaces?.map((nif) => {
|
||||
if (nif.name === 'localhost') {
|
||||
return null;
|
||||
}
|
||||
|
||||
{externalInterfaces.map((nif) => {
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
<div key={nif.name} className={style.interface}>
|
||||
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||
<span className={style.interfaceCopy}>
|
||||
<span className={style.address}>{nif.address}</span>
|
||||
<IoArrowUp className={style.goIcon} />
|
||||
</span>
|
||||
</CopyTag>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { getExternalInterfaces } from '../otherAddresses.utils';
|
||||
|
||||
describe('getExternalInterfaces', () => {
|
||||
test('returns no alternatives when localhost is the only interface', () => {
|
||||
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not treat an interface name as deployment information', () => {
|
||||
expect(
|
||||
getExternalInterfaces([
|
||||
{ name: 'localhost', address: '127.0.0.1' },
|
||||
{ name: 'cloud', address: '192.168.1.42' },
|
||||
]),
|
||||
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
|
||||
});
|
||||
|
||||
test('returns non-local interfaces when local and external interfaces are available', () => {
|
||||
expect(
|
||||
getExternalInterfaces([
|
||||
{ name: 'localhost', address: '127.0.0.1' },
|
||||
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type NetworkInterface } from 'ontime-types';
|
||||
|
||||
/** Returns network addresses other than the localhost address injected by the info endpoint. */
|
||||
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
|
||||
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
> span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
// attempt to match with ontimeTextInputs
|
||||
|
||||
@@ -1,37 +1,77 @@
|
||||
.inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
.multiOptionList {
|
||||
overflow-y: auto;
|
||||
max-height: 12rem;
|
||||
background: $gray-1200;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
}
|
||||
|
||||
// styles from subtle button
|
||||
.toggleSelect {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.5rem;
|
||||
height: 2.5rem;
|
||||
background: $gray-1050;
|
||||
color: $ui-white;
|
||||
gap: 0.75rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: $gray-300;
|
||||
line-height: 1em;
|
||||
border-radius: $component-border-radius-md;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color $transition-time-action,
|
||||
border-color $transition-time-action,
|
||||
color $transition-time-action;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
&:hover {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
&:active {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: $bg-active;
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
|
||||
> [role='checkbox'] {
|
||||
flex: 0 0 auto;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border: 1px solid $gray-400;
|
||||
|
||||
&[data-unchecked] {
|
||||
background: $gray-1100;
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
border-color: $blue-400;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
margin-left: 0.25rem;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
background: var(--user-bg, $gray-900);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { isStringBoolean } from '../../../views/common/viewUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
import Input from '../input/input/Input';
|
||||
import Select, { SelectOption } from '../select/Select';
|
||||
@@ -49,7 +50,13 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
|
||||
const paramValue = searchParams.get(id);
|
||||
return (
|
||||
<ControlledSwitch
|
||||
id={id}
|
||||
initialValue={paramValue === null ? Boolean(defaultValue) : isStringBoolean(paramValue)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
@@ -107,18 +114,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState.join(',')} />
|
||||
<div className={style.inline}>
|
||||
<div className={style.multiOptionList}>
|
||||
{values.map((option) => {
|
||||
const selected = paramState.includes(option.value);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={style.toggleSelect}
|
||||
className={cx([style.toggleSelect, selected && style.selected])}
|
||||
style={{
|
||||
'--user-bg': option.colour,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={paramState.includes(option.value)}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
||||
/>
|
||||
{option.label}
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
.drawerFooter {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: $section-spacing;
|
||||
|
||||
button {
|
||||
padding: 0 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -34,11 +24,11 @@
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-block: 1rem 1.5rem;
|
||||
|
||||
background-color: $gray-1250;
|
||||
background-color: $ui-black;
|
||||
color: $ui-white;
|
||||
border-left: 1px solid $gray-1100;
|
||||
box-shadow: $box-shadow-l1;
|
||||
|
||||
&[data-open] {
|
||||
transform: translateX(0%);
|
||||
@@ -58,36 +48,54 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-inline: 1rem;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 3.5rem;
|
||||
gap: 1rem;
|
||||
min-height: 4rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.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-inline: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
padding-inline: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
background-color: $ui-black;
|
||||
border-top: 1px solid $white-10;
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding-right: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FormEvent, memo } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { viewLabels } from '../../../viewerConfig';
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
||||
@@ -71,8 +72,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
<Dialog.Backdrop className={style.backdrop} />
|
||||
<Dialog.Popup className={style.drawer}>
|
||||
<div className={style.header}>
|
||||
<Dialog.Title>Customise</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
||||
<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}
|
||||
>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
.presetSection {
|
||||
background-color: $gray-1350;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: $component-border-radius-md;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
|
||||
color: $gray-300;
|
||||
}
|
||||
|
||||
.presetList {
|
||||
border-top: 1px solid $white-7;
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
@@ -14,12 +25,39 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: $inner-section-text-size;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $white-7;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: $blue-500;
|
||||
background-color: $bg-active;
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
color: $ui-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.presetName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.presetActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
color: $blue-400;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
|
||||
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
|
||||
import style from './ViewParamsPresets.module.scss';
|
||||
|
||||
@@ -26,22 +27,31 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
||||
|
||||
return (
|
||||
<div className={style.presetSection}>
|
||||
{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 className={style.header}>
|
||||
<Eyebrow>Saved presets</Eyebrow>
|
||||
</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])}>
|
||||
<span className={style.presetName}>{preset.alias}</span>
|
||||
{active ? (
|
||||
<span className={style.current}>Current</span>
|
||||
) : (
|
||||
<Button
|
||||
variant='ghosted'
|
||||
size='small'
|
||||
onClick={() => handleRecall(preset)}
|
||||
className={style.presetActions}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+92
-22
@@ -1,48 +1,118 @@
|
||||
.section {
|
||||
overflow: hidden;
|
||||
|
||||
color: $ui-white;
|
||||
font-size: 1rem;
|
||||
background-color: $white-3;
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
|
||||
color: $gray-300;
|
||||
|
||||
&.collapsible {
|
||||
cursor: pointer;
|
||||
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 {
|
||||
margin-top: $section-spacing;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 0.25rem;
|
||||
padding: 0.75rem 0;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.description {
|
||||
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;
|
||||
color: $gray-400;
|
||||
font-size: $aux-text-size;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Eyebrow from '../eyebrow/Eyebrow';
|
||||
import { OptionTitle } from './constants';
|
||||
import ParamInput from './ParamInput';
|
||||
import { type ParamField } from './viewParams.types';
|
||||
@@ -14,50 +16,74 @@ 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();
|
||||
|
||||
const handleCollapse = () => {
|
||||
if (collapsible) {
|
||||
setCollapsed((prev) => !prev);
|
||||
}
|
||||
};
|
||||
// hidden options are inputs without a UI, there is nothing to frame them with
|
||||
if (title === OptionTitle.Hidden) {
|
||||
return <HiddenContents options={options} />;
|
||||
}
|
||||
|
||||
const isCollapsed = Boolean(collapsible && collapsed);
|
||||
|
||||
return (
|
||||
<section className={style.section}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionContentsProps {
|
||||
options: ParamField[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
||||
return (
|
||||
<>
|
||||
{options.map((option) => {
|
||||
return (
|
||||
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
isCustomised={options.some((option) => searchParams.has(option.id))}
|
||||
collapsed={isCollapsed}
|
||||
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
|
||||
/>
|
||||
{/* collapsed options stay mounted: the form reads its values from the DOM */}
|
||||
<div className={cx([style.options, isCollapsed && style.hidden])}>
|
||||
{options.map((option) => (
|
||||
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
|
||||
<span className={style.title}>{option.title}</span>
|
||||
<span className={style.description}>{option.description}</span>
|
||||
<ParamInput paramField={option} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
isCustomised: boolean;
|
||||
collapsed: boolean;
|
||||
/** a header without a toggle belongs to a section which cannot collapse */
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
|
||||
const label = (
|
||||
<Eyebrow className={style.sectionTitle}>
|
||||
{title}
|
||||
{isCustomised && <span className={style.customised} title='Contains custom values' />}
|
||||
</Eyebrow>
|
||||
);
|
||||
|
||||
if (!onToggle) {
|
||||
return <div className={style.sectionHeader}>{label}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={cx([style.sectionHeader, style.collapsible])}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{label}
|
||||
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+31
-1
@@ -228,11 +228,41 @@ describe('getURLSearchParamsFromObj()', () => {
|
||||
},
|
||||
];
|
||||
const params = {
|
||||
bool1: 'off',
|
||||
bool2: 'on',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
|
||||
expect(result.get('bool1')).toBe('false');
|
||||
expect(result.get('bool2')).toBe('true');
|
||||
});
|
||||
|
||||
it('omits booleans which match their default', () => {
|
||||
const mockOptionsWithBooleans: ViewOption[] = [
|
||||
{
|
||||
title: OptionTitle.StyleOverride,
|
||||
options: [
|
||||
{
|
||||
id: 'onByDefault',
|
||||
title: 'onByDefault',
|
||||
description: 'On by default',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
id: 'offByDefault',
|
||||
title: 'offByDefault',
|
||||
description: 'Off by default',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const untouched = getURLSearchParamsFromObj({ onByDefault: 'on' }, mockOptionsWithBooleans);
|
||||
expect(untouched.toString()).toBe('');
|
||||
|
||||
const flipped = getURLSearchParamsFromObj({ offByDefault: 'on' }, mockOptionsWithBooleans);
|
||||
expect(flipped.get('onByDefault')).toBe('false');
|
||||
expect(flipped.get('offByDefault')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +155,14 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
|
||||
});
|
||||
});
|
||||
|
||||
// Unchecked checkboxes are absent from FormData. Persist false when true is the default.
|
||||
metadata.booleanFields.forEach((id) => {
|
||||
if (id in paramsObj) return;
|
||||
if (metadata.defaultValues[id] !== 'false') {
|
||||
addUniqueParam(id, 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Then process user-provided values
|
||||
Object.entries(paramsObj).forEach(([id, value]) => {
|
||||
if (typeof value === 'string' && value.length) {
|
||||
|
||||
@@ -4,6 +4,10 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Enter';
|
||||
}
|
||||
|
||||
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === ' ' || event.code === 'Space';
|
||||
}
|
||||
|
||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Escape';
|
||||
}
|
||||
|
||||
@@ -42,6 +42,27 @@ import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
|
||||
|
||||
let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: NodeJS.Timeout | null = null;
|
||||
type SocketListener = (payload: unknown) => void;
|
||||
|
||||
const socketListeners = new Map<WsPacketToClient['tag'], Set<SocketListener>>();
|
||||
|
||||
export function subscribeSocket<T extends WsPacketToClient['tag']>(
|
||||
tag: T,
|
||||
listener: (payload: Extract<WsPacketToClient, { tag: T }>['payload']) => void,
|
||||
) {
|
||||
const listeners = socketListeners.get(tag) ?? new Set();
|
||||
const socketListener = listener as SocketListener;
|
||||
listeners.add(socketListener);
|
||||
socketListeners.set(tag, listeners);
|
||||
return () => {
|
||||
listeners.delete(socketListener);
|
||||
};
|
||||
}
|
||||
|
||||
function notifySocketListeners(tag: WsPacketToClient['tag'], payload: unknown) {
|
||||
socketListeners.get(tag)?.forEach((listener) => listener(payload));
|
||||
}
|
||||
|
||||
const socketConfig = {
|
||||
reconnectBaseInterval: 1000, // 1 second
|
||||
reconnectMaxInterval: 30000, // 30 seconds
|
||||
@@ -124,6 +145,10 @@ export const connectSocket = () => {
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case MessageTag.TeleprompterCommand: {
|
||||
notifySocketListeners(tag, payload);
|
||||
break;
|
||||
}
|
||||
case MessageTag.Pong: {
|
||||
const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5;
|
||||
patchRuntimeProperty('ping', offset);
|
||||
|
||||
-35
@@ -1,5 +1,3 @@
|
||||
import { AppMode } from '../ontimeConfig';
|
||||
|
||||
declare module '*.scss' {
|
||||
const content: Record<string, string>;
|
||||
export default content;
|
||||
@@ -32,39 +30,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*
|
||||
* And metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
declare module '@tanstack/react-table' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface TableMeta<TData extends RowData> {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow passing CSS Properties
|
||||
*/
|
||||
|
||||
@@ -53,7 +53,7 @@ ul {
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
background-color: $bg-active;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ ul {
|
||||
&.active {
|
||||
color: $ui-white;
|
||||
border-left-color: $blue-400;
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
background-color: $bg-active;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ $card-padding: 2rem;
|
||||
// active rows use a panel-local class today; keep the treatment here so all
|
||||
// settings tables share the same state without each panel owning its colours
|
||||
tr[class*='current'] {
|
||||
background-color: rgba($blue-500, 0.16);
|
||||
background-color: $bg-active;
|
||||
|
||||
> td:first-child {
|
||||
box-shadow: inset 3px 0 $blue-400;
|
||||
@@ -200,8 +200,7 @@ $card-padding: 2rem;
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: $zindex-backdrop;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.updateIndicator {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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();
|
||||
|
||||
@@ -18,7 +20,12 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={`Ontime ${appVersion}`}
|
||||
title={
|
||||
<>
|
||||
<span className={style.updateIndicator} aria-hidden='true' />
|
||||
{`Ontime ${appVersion}`}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
@@ -26,7 +33,7 @@ export default function AppVersion() {
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
);
|
||||
|
||||
+10
-8
@@ -12,20 +12,22 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
|
||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
import { viewLabels } from '../../../../../viewerConfig';
|
||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
|
||||
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Backstage, label: 'Backstage' },
|
||||
{ value: OntimeView.Timeline, label: 'Timeline' },
|
||||
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
|
||||
{ value: OntimeView.Countdown, label: 'Countdown' },
|
||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
||||
{ value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
|
||||
{ value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
|
||||
{ value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
|
||||
{ value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
|
||||
{ value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
|
||||
{ value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
|
||||
{ value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
|
||||
{ value: OntimeView.Teleprompter, label: viewLabels[OntimeView.Teleprompter] },
|
||||
{ value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
|
||||
];
|
||||
|
||||
const formId = 'url-preset-form';
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -17,59 +17,10 @@
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
.shortcutGroup {
|
||||
h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
color: $ui-white;
|
||||
font-size: calc(1rem - 3px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcutList {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.shortcutRow {
|
||||
min-height: 1.625rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.shortcutLabel {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.shortcutKeys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyCombo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.25rem 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $gray-500;
|
||||
font-size: calc(1rem - 5px);
|
||||
--shortcut-title-color: #{$ui-white};
|
||||
--shortcut-separator-color: #{$gray-500};
|
||||
}
|
||||
|
||||
.prompt {
|
||||
@@ -84,13 +35,4 @@
|
||||
.shortcutSection {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.shortcutRow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.shortcutKeys {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { PropsWithChildren, memo } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import Kbd from '../../../common/components/kbd/Kbd';
|
||||
import {
|
||||
Combo,
|
||||
Separator,
|
||||
Shortcut,
|
||||
ShortcutGroup,
|
||||
ShortcutGroups,
|
||||
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
|
||||
import style from './EventEditorEmpty.module.scss';
|
||||
@@ -13,7 +19,7 @@ function EventEditorEmpty() {
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<div className={style.shortcutSection}>
|
||||
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
|
||||
<div className={style.shortcuts}>
|
||||
<ShortcutGroups className={style.shortcuts}>
|
||||
<ShortcutGroup title='Search'>
|
||||
<Shortcut label='Find in rundown'>
|
||||
<Combo keys={[deviceMod, 'F']} />
|
||||
@@ -100,40 +106,8 @@ function EventEditorEmpty() {
|
||||
<Combo keys={[deviceAlt, 'Shift', 'D']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
</div>
|
||||
</ShortcutGroups>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
|
||||
return (
|
||||
<section className={style.shortcutGroup}>
|
||||
<h3>{title}</h3>
|
||||
<div className={style.shortcutList}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.shortcutRow}>
|
||||
<span className={style.shortcutLabel}>{label}</span>
|
||||
<span className={style.shortcutKeys}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Combo({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<span className={style.keyCombo}>
|
||||
{keys.map((key) => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <span className={style.separator}>/</span>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
|
||||
|
||||
/**
|
||||
* Creates column definitions for the rundown table
|
||||
* Reuses cuesheetColsFactory with preset=undefined for full access
|
||||
*/
|
||||
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
|
||||
export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
|
||||
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
|
||||
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
|
||||
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
||||
{ value: OntimeView.Timer, label: 'Timer' },
|
||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
||||
{ value: OntimeView.Operator, label: 'Operator' },
|
||||
{ value: OntimeView.Teleprompter, label: 'Teleprompter' },
|
||||
{ value: '<<companion>>', label: 'Companion' },
|
||||
...urlPresetData.map((preset) => ({
|
||||
value: `preset-${preset.alias}`,
|
||||
|
||||
@@ -48,6 +48,7 @@ $playback-under: $green-500;
|
||||
// interface panels
|
||||
$bg-container-l1: $gray-1350;
|
||||
$bg-container-l2: $gray-1300;
|
||||
$bg-active: rgba($action-blue, 0.16);
|
||||
|
||||
$backdrop-color: rgba(0, 0, 0, 0.7);
|
||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
export const navigatorConstants = [
|
||||
{ url: 'timer', label: 'Timer' },
|
||||
{ url: 'backstage', label: 'Backstage' },
|
||||
{ url: 'timeline', label: 'Timeline' },
|
||||
{ url: 'studio', label: 'Studio Clock' },
|
||||
{ url: 'countdown', label: 'Countdown' },
|
||||
{ url: 'info', label: 'Project Info' },
|
||||
import { OntimeView } from 'ontime-types';
|
||||
|
||||
/** User facing name of each view, the single source for view naming in the UI */
|
||||
export 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.Teleprompter]: 'Teleprompter',
|
||||
[OntimeView.ProjectInfo]: 'Project Info',
|
||||
};
|
||||
|
||||
/** Views offered in the navigation menu, in the order they are shown. Their value is also their route */
|
||||
const navigatorViews = [
|
||||
OntimeView.Timer,
|
||||
OntimeView.Backstage,
|
||||
OntimeView.Timeline,
|
||||
OntimeView.StudioClock,
|
||||
OntimeView.Countdown,
|
||||
OntimeView.Teleprompter,
|
||||
OntimeView.ProjectInfo,
|
||||
];
|
||||
|
||||
export const navigatorConstants = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
|
||||
|
||||
// default time format to use for users in 12 hour clocks
|
||||
export const FORMAT_12 = 'h:mm:ss a';
|
||||
// default time format to use for users in 24 hour clocks
|
||||
|
||||
@@ -7,14 +7,13 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
|
||||
import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
||||
|
||||
interface CuesheetDndProps {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
columns: CuesheetColumnDef[];
|
||||
tableRoot?: 'editor' | 'cuesheet';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
@@ -29,12 +29,17 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||
import {
|
||||
CuesheetColumnDef,
|
||||
CuesheetTable as CuesheetTableInstance,
|
||||
cuesheetTableFeatures,
|
||||
} from './cuesheetTable.features';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
columns: CuesheetColumnDef[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
@@ -120,7 +125,8 @@ export default function CuesheetTable({
|
||||
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
|
||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||
|
||||
const table = useReactTable({
|
||||
const table = useTable({
|
||||
features: cuesheetTableFeatures,
|
||||
data: flatRundown,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
@@ -131,7 +137,6 @@ export default function CuesheetTable({
|
||||
},
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
meta,
|
||||
});
|
||||
|
||||
@@ -195,7 +200,7 @@ export default function CuesheetTable({
|
||||
return colSizes;
|
||||
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
|
||||
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||
}, [columns, table.state.columnResizing, table.state.columnSizing]);
|
||||
|
||||
const allLeafColumns = table.getAllLeafColumns();
|
||||
const { rows } = table.getRowModel();
|
||||
@@ -214,9 +219,7 @@ export default function CuesheetTable({
|
||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||
const fixedHeaderContent = useCallback(() => {
|
||||
return table.getHeaderGroups().map((headerGroup) => {
|
||||
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn
|
||||
? CuesheetHeader
|
||||
: SortableCuesheetHeader;
|
||||
const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
|
||||
|
||||
// if the table is being resized, we render non-sortable headers to avoid performance issues
|
||||
return (
|
||||
@@ -279,8 +282,8 @@ interface CuesheetVirtuosoContext {
|
||||
columnSizeVars: { [key: string]: number };
|
||||
cursor: string | null;
|
||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||
table: Table<ExtendedEntry>;
|
||||
rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
|
||||
table: CuesheetTableInstance;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { HeaderGroup, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
|
||||
import { Draggable, SortableCell, TableCell } from './SortableCell';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroup: HeaderGroup<ExtendedEntry>;
|
||||
headerGroup: CuesheetHeaderGroup;
|
||||
cuesheetMode: AppMode;
|
||||
hideIndexColumn: boolean;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
</SortableCell>
|
||||
);
|
||||
})}
|
||||
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
|
||||
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
|
||||
draggable={<Draggable header={header} />}
|
||||
>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.isPlaceholder ? null : <FlexRender header={header} />}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './EventRow.module.scss';
|
||||
|
||||
@@ -25,7 +25,7 @@ interface EventRowProps {
|
||||
skip: boolean;
|
||||
parent: EntryId | null;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry<OntimeEntry>>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -133,7 +133,7 @@ function EventRow({
|
||||
data-testid={`cuesheet-cell-${cell.column.id}`}
|
||||
data-column-id={cell.column.id}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<FlexRender cell={cell} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './GroupRow.module.scss';
|
||||
|
||||
@@ -15,7 +15,7 @@ interface GroupRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ function GroupRow({
|
||||
}}
|
||||
role='cell'
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
<FlexRender cell={cell} />
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+4
-4
@@ -1,14 +1,14 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { FlexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||
import type { CuesheetTable } from '../cuesheetTable.features';
|
||||
|
||||
import style from './MilestoneRow.module.scss';
|
||||
|
||||
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
|
||||
colour: string;
|
||||
rowId: string;
|
||||
rowIndex: number;
|
||||
table: Table<ExtendedEntry>;
|
||||
table: CuesheetTable;
|
||||
injectedStyles?: CSSProperties;
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function MilestoneRow({
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{canRender && <FlexRender cell={cell} />}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
|
||||
+2
-3
@@ -1,9 +1,8 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Header } from '@tanstack/react-table';
|
||||
import { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetHeaderCell } from '../cuesheetTable.features';
|
||||
|
||||
import style from '../CuesheetTable.module.scss';
|
||||
|
||||
@@ -48,7 +47,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
|
||||
}
|
||||
|
||||
interface DraggableProps {
|
||||
header: Header<ExtendedEntry, unknown>;
|
||||
header: CuesheetHeaderCell;
|
||||
}
|
||||
|
||||
export function Draggable({ header }: DraggableProps) {
|
||||
|
||||
+12
-12
@@ -1,4 +1,3 @@
|
||||
import { CellContext, ColumnDef } from '@tanstack/react-table';
|
||||
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback } from 'react';
|
||||
@@ -8,6 +7,7 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
import FlagCell from './FlagCell';
|
||||
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
|
||||
import SingleLineCell from './SingleLineCell';
|
||||
import TimeInput from './TimeInput';
|
||||
|
||||
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
|
||||
function getColumnLabel(column: CuesheetCellContext['column']): string {
|
||||
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
|
||||
}
|
||||
|
||||
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
|
||||
);
|
||||
}
|
||||
|
||||
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
|
||||
);
|
||||
}
|
||||
|
||||
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
|
||||
if (!table.options.meta) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntr
|
||||
);
|
||||
}
|
||||
|
||||
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
|
||||
);
|
||||
}
|
||||
|
||||
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function LazyImage({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
|
||||
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
|
||||
}
|
||||
|
||||
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
|
||||
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
|
||||
);
|
||||
}
|
||||
|
||||
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeFlagField({ row }: CuesheetCellContext) {
|
||||
const event = row.original;
|
||||
if (!isOntimeEvent(event) || !event.flag) {
|
||||
return null;
|
||||
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
|
||||
return <FlagCell />;
|
||||
}
|
||||
|
||||
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) {
|
||||
function MakeCustomField({ row, column, table }: CuesheetCellContext) {
|
||||
const update = useCallback(
|
||||
(newValue: string) => {
|
||||
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
|
||||
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
|
||||
customFields: CustomFields,
|
||||
cuesheetMode: AppMode,
|
||||
preset: URLPreset | undefined,
|
||||
): ColumnDef<ExtendedEntry>[] {
|
||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||
): CuesheetColumnDef[] {
|
||||
const columnsDef: CuesheetColumnDef[] = [];
|
||||
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||
|
||||
if (canRead('flag')) {
|
||||
|
||||
+4
-5
@@ -2,7 +2,6 @@ import { Popover } from '@base-ui/react/popover';
|
||||
import { Toggle } from '@base-ui/react/toggle';
|
||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
import { ReactNode } from 'react';
|
||||
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
||||
|
||||
@@ -10,9 +9,9 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
import type { CuesheetColumn } from '../cuesheetTable.features';
|
||||
import CuesheetShareModal from './CuesheetShareModal';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
@@ -37,7 +36,7 @@ type TableModeControls = {
|
||||
};
|
||||
|
||||
interface CuesheetTableHeaderToolbarProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
columns: CuesheetColumn[];
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
@@ -111,7 +110,7 @@ interface ViewSettingsProps {
|
||||
}
|
||||
|
||||
interface ColumnSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
columns: CuesheetColumn[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
@@ -203,7 +202,7 @@ function ColumnSettings({
|
||||
|
||||
return (
|
||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
|
||||
{columnHeader as ReactNode}
|
||||
</Editor.Label>
|
||||
);
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&[data-background-rundown='true'] {
|
||||
background-color: rgba($ontime-color, 0.5);
|
||||
background-color: $bg-active;
|
||||
|
||||
.apart::before {
|
||||
content: 'BACKGROUND EDIT';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
columnOrderingFeature,
|
||||
columnResizingFeature,
|
||||
columnSizingFeature,
|
||||
columnVisibilityFeature,
|
||||
metaHelper,
|
||||
tableFeatures,
|
||||
} from '@tanstack/react-table';
|
||||
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
|
||||
import type { TimeField } from 'ontime-types';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { AppMode } from '../../../ontimeConfig';
|
||||
|
||||
/**
|
||||
* Custom data we pass to the table
|
||||
* - `handleUpdate` callback to update the entry when the user edits a cell
|
||||
* - `handleUpdateTimer` callback to update the timer for a specific event
|
||||
* - `options-showDelayedTimes` whether to show or hide delayed times
|
||||
* - `options-hideTableSeconds` whether to hide seconds in the table
|
||||
* - `options-hideIndexColumn` whether to hide the index column
|
||||
* - `options-cuesheetMode` run or edit mode
|
||||
*/
|
||||
export interface CuesheetTableMeta {
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
|
||||
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
|
||||
options: {
|
||||
showDelayedTimes: boolean;
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
cuesheetMode: AppMode;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata specific for each column
|
||||
* - `canWrite` whether the user can write to this column
|
||||
* - `colour` background colour associated with a custom field
|
||||
*/
|
||||
export interface CuesheetColumnMeta {
|
||||
canWrite: boolean;
|
||||
colour?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Features registered in the cuesheet and rundown tables.
|
||||
* In v9 an API only exists once its feature is registered, so this list is the
|
||||
* source of truth for what the table can do:
|
||||
* - `columnOrderingFeature`: user reorders columns by dragging the headers
|
||||
* - `columnVisibilityFeature`: user toggles columns in the table settings
|
||||
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
|
||||
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
|
||||
*
|
||||
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
|
||||
* they scope our meta types to this table instead of every table in the app.
|
||||
*/
|
||||
export const cuesheetTableFeatures = tableFeatures({
|
||||
columnOrderingFeature,
|
||||
columnVisibilityFeature,
|
||||
columnSizingFeature,
|
||||
columnResizingFeature,
|
||||
tableMeta: metaHelper<CuesheetTableMeta>(),
|
||||
columnMeta: metaHelper<CuesheetColumnMeta>(),
|
||||
});
|
||||
|
||||
export type CuesheetFeatures = typeof cuesheetTableFeatures;
|
||||
|
||||
/** Convenience aliases so consumers do not need to repeat the feature generic */
|
||||
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
|
||||
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { ColumnSizingState, Updater } from '@tanstack/react-table';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { debounce } from '../../../common/utils/debounce';
|
||||
import { makeStageKey } from '../../../common/utils/localStorage';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import type { CuesheetColumnDef } from './cuesheetTable.features';
|
||||
|
||||
type TableRoot = 'editor' | 'cuesheet';
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
|
||||
};
|
||||
}
|
||||
|
||||
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') {
|
||||
export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
|
||||
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
|
||||
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
.teleprompter {
|
||||
--tp-flip-x: 1;
|
||||
--tp-flip-y: 1;
|
||||
|
||||
/* Keep shared stage-screen settings usable on smaller clients. */
|
||||
--tp-font-size: min(var(--tp-configured-font-size), 8vw);
|
||||
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, #000000);
|
||||
color: var(--color-override, #ffffff);
|
||||
|
||||
transform: scale(var(--tp-flip-x), var(--tp-flip-y));
|
||||
transform-origin: center center;
|
||||
|
||||
&--flip-h {
|
||||
--tp-flip-x: -1;
|
||||
}
|
||||
|
||||
&--flip-v {
|
||||
--tp-flip-y: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.teleprompter__scroller {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
/* The animation loop is the only writer of scrollTop. */
|
||||
overflow-anchor: none;
|
||||
scroll-behavior: auto;
|
||||
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.teleprompter--controlled .teleprompter__scroller {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.teleprompter__content {
|
||||
width: var(--tp-text-width);
|
||||
margin-inline: auto;
|
||||
|
||||
font-size: var(--tp-font-size);
|
||||
line-height: var(--tp-line-height);
|
||||
|
||||
/* Percent padding resolves against width, so use viewport-height units. */
|
||||
padding-top: calc(var(--tp-reading-line) * 1dvh);
|
||||
padding-bottom: calc(100dvh - var(--tp-reading-line) * 1dvh);
|
||||
}
|
||||
|
||||
.teleprompter__block {
|
||||
margin-bottom: 1em;
|
||||
|
||||
&[data-loaded] .teleprompter__heading {
|
||||
color: $accent-color;
|
||||
}
|
||||
|
||||
/* Holds the eye on the cued event without hiding what is around it, which is
|
||||
what the reader needs when they are recovering from being ahead or behind. */
|
||||
.teleprompter--has-playing &:not([data-loaded]) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
.teleprompter__group {
|
||||
font-size: 0.34em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: $viewer-label-color;
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.teleprompter__heading {
|
||||
font-size: 0.38em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $viewer-secondary-color;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.teleprompter__body {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.teleprompter__dim {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: calc(var(--tp-reading-line) * 1%);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
.teleprompter__reading-line {
|
||||
position: absolute;
|
||||
top: calc(var(--tp-reading-line) * 1%);
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: var(--tp-text-width);
|
||||
margin-inline: auto;
|
||||
height: calc(var(--tp-font-size) * var(--tp-line-height));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.teleprompter__reading-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: $accent-color;
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
font-size: var(--tp-font-size);
|
||||
|
||||
--tp-gutter: max(0px, calc((100vw - 100%) / 2));
|
||||
width: min(1.2em, calc(var(--tp-gutter) * 0.6));
|
||||
right: calc(100% + var(--tp-gutter) * 0.2);
|
||||
}
|
||||
|
||||
.teleprompter__controls {
|
||||
position: absolute;
|
||||
bottom: min(2vh, 16px);
|
||||
left: 50%;
|
||||
|
||||
/* Cancel the view flip so controls remain readable to the operator. */
|
||||
transform: translateX(-50%) scale(var(--tp-flip-x), var(--tp-flip-y));
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: min(1vh, 8px);
|
||||
padding: min(1vh, 8px) clamp(8px, 1vw, 16px);
|
||||
|
||||
background: rgba(white, 8%);
|
||||
border-radius: $element-border-radius;
|
||||
backdrop-filter: blur(6px);
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity $viewer-transition-time;
|
||||
|
||||
&--idle {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.teleprompter__speed {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2em;
|
||||
min-width: 4em;
|
||||
justify-content: center;
|
||||
font-size: clamp(14px, 1.4vw, 22px);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.teleprompter__speed-unit {
|
||||
font-size: 0.6em;
|
||||
color: $viewer-label-color;
|
||||
}
|
||||
|
||||
.teleprompter__parked {
|
||||
font-size: clamp(11px, 1vw, 15px);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
white-space: nowrap;
|
||||
color: $viewer-label-color;
|
||||
}
|
||||
|
||||
.teleprompter__help {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.teleprompter__help-card {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: min(92vw, 32rem);
|
||||
max-height: 85dvh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
|
||||
background: $viewer-background-color;
|
||||
color: $viewer-color;
|
||||
border-radius: $element-border-radius;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.teleprompter__help-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.teleprompter__help-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.teleprompter__help-groups {
|
||||
--shortcut-title-color: #{$viewer-label-color};
|
||||
--shortcut-label-color: #{$viewer-secondary-color};
|
||||
--shortcut-separator-color: #{$viewer-label-color};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { MessageTag, OntimeView } from 'ontime-types';
|
||||
import { type CSSProperties, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
import { sendSocket, subscribeSocket } from '../../common/utils/socket';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import Loader from '../common/loader/Loader';
|
||||
import ControlOverlay from './control-overlay/ControlOverlay';
|
||||
import HelpOverlay from './help-overlay/HelpOverlay';
|
||||
import ScriptBlockView from './script-block/ScriptBlock';
|
||||
import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
|
||||
import { stepFontSize } from './teleprompter.scroll';
|
||||
import { buildScript, composeFlip } from './teleprompter.utils';
|
||||
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
|
||||
import { applyTeleprompterCommand, useTeleprompterControls } from './useTeleprompterControls';
|
||||
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
|
||||
import { useTeleprompterScroll } from './useTeleprompterScroll';
|
||||
|
||||
import './Teleprompter.scss';
|
||||
|
||||
const commandHistoryLimit = 100;
|
||||
|
||||
function rememberCommandId(receivedIds: Set<string>, commandId: string): boolean {
|
||||
if (receivedIds.has(commandId)) return false;
|
||||
|
||||
receivedIds.add(commandId);
|
||||
if (receivedIds.size > commandHistoryLimit) {
|
||||
for (const oldestCommandId of receivedIds) {
|
||||
receivedIds.delete(oldestCommandId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getEmptyMessage(scriptSource: string, blockCount: number): string | null {
|
||||
if (scriptSource === 'none') return 'Select which field holds the script in the view options';
|
||||
if (blockCount === 0) return 'There is no script text in the selected field';
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TeleprompterLoader() {
|
||||
const { data, status } = useTeleprompterData();
|
||||
|
||||
useWindowTitle('Teleprompter');
|
||||
|
||||
if (status === 'pending') {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
||||
}
|
||||
|
||||
return <Teleprompter {...data} />;
|
||||
}
|
||||
|
||||
function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) {
|
||||
'use memo';
|
||||
|
||||
const options = useTeleprompterOptions();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [controlMode, setControlMode] = useState(options.controlMode);
|
||||
const receivedCommandIds = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => setControlMode(options.controlMode), [options.controlMode]);
|
||||
|
||||
const fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
|
||||
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
|
||||
|
||||
const [live, setLive] = useState(fromParams);
|
||||
const [seededFrom, setSeededFrom] = useState(paramsKey);
|
||||
// Reset live controls before commit when the URL configuration changes.
|
||||
if (seededFrom !== paramsKey) {
|
||||
setSeededFrom(paramsKey);
|
||||
setLive(fromParams);
|
||||
}
|
||||
|
||||
const viewOptions = getTeleprompterOptions(customFields);
|
||||
|
||||
const blocks = buildScript(rundown, rundownMetadata, customFields, {
|
||||
scriptSource: options.scriptSource,
|
||||
heading: options.heading,
|
||||
onlyPlaying: options.onlyPlaying,
|
||||
hideEmpty: options.hideEmpty,
|
||||
showGroups: options.showGroups,
|
||||
});
|
||||
|
||||
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, isFollowingLoadedEvent, parkedAt } =
|
||||
useTeleprompterScroll({
|
||||
initialSpeed: options.speed,
|
||||
followLoaded: options.followLoaded,
|
||||
selectedEventId,
|
||||
readingLinePos: options.readingLinePos,
|
||||
blocks,
|
||||
});
|
||||
|
||||
const handleFlip = (axis: 'h' | 'v') =>
|
||||
setLive((current) => {
|
||||
const key = axis === 'h' ? 'flipH' : 'flipV';
|
||||
return { ...current, [key]: !current[key] };
|
||||
});
|
||||
|
||||
const handleFontSize = (steps: number) =>
|
||||
setLive((current) => ({ ...current, fontSize: stepFontSize(current.fontSize, steps) }));
|
||||
|
||||
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
|
||||
const handleToggleHelp = () => setShowHelp((current) => !current);
|
||||
const handleToggleControlMode = () => setControlMode((current) => (current === 'free' ? 'controlled' : 'free'));
|
||||
const isControlled = controlMode === 'controlled';
|
||||
|
||||
useSyncTeleprompterParams({ speed, fontSize: live.fontSize, flipH: live.flipH, flipV: live.flipV });
|
||||
|
||||
useTeleprompterControls({
|
||||
controller,
|
||||
isHelpOpen: showHelp,
|
||||
onFlip: handleFlip,
|
||||
onFontSize: handleFontSize,
|
||||
onResetFontSize: handleResetFontSize,
|
||||
onToggleHelp: handleToggleHelp,
|
||||
enabled: !isControlled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeSocket(MessageTag.TeleprompterCommand, ({ commandId, command }) => {
|
||||
if (!isControlled || !rememberCommandId(receivedCommandIds.current, commandId)) return;
|
||||
applyTeleprompterCommand(command, controller);
|
||||
});
|
||||
}, [controller, isControlled]);
|
||||
|
||||
useEffect(() => {
|
||||
sendSocket(MessageTag.ClientSet, {
|
||||
teleprompter: {
|
||||
mode: controlMode,
|
||||
playback: isRunning ? 'playing' : 'paused',
|
||||
speed,
|
||||
isFollowingLoadedEvent,
|
||||
parkedAt,
|
||||
},
|
||||
});
|
||||
}, [controlMode, isFollowingLoadedEvent, isRunning, parkedAt, speed]);
|
||||
|
||||
const emptyMessage = getEmptyMessage(options.scriptSource, blocks.length);
|
||||
|
||||
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
|
||||
|
||||
const viewStyles = {
|
||||
'--tp-configured-font-size': `${live.fontSize}px`,
|
||||
'--tp-line-height': options.lineHeight,
|
||||
'--tp-text-width': `${options.textWidth}%`,
|
||||
'--tp-reading-line': options.readingLinePos,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx([
|
||||
'teleprompter',
|
||||
isControlled && 'teleprompter--controlled',
|
||||
effectiveFlip.flipH && 'teleprompter--flip-h',
|
||||
effectiveFlip.flipV && 'teleprompter--flip-v',
|
||||
// nothing to hold back the eye from when no event is cued
|
||||
blocks.some((block) => block.isLoaded) && 'teleprompter--has-playing',
|
||||
])}
|
||||
style={viewStyles}
|
||||
data-testid='teleprompter-view'
|
||||
>
|
||||
<ViewParamsEditor target={OntimeView.Teleprompter} viewOptions={viewOptions} />
|
||||
|
||||
{emptyMessage ? (
|
||||
<EmptyPage text={emptyMessage} />
|
||||
) : (
|
||||
<>
|
||||
<div className='teleprompter__scroller' data-testid='teleprompter-scroller' ref={scrollerRef}>
|
||||
<div className='teleprompter__content' ref={contentRef}>
|
||||
{blocks.map((block) => (
|
||||
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='teleprompter__dim' />
|
||||
{options.readingLine && (
|
||||
<div className='teleprompter__reading-line'>
|
||||
<span className='teleprompter__reading-marker' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ControlOverlay
|
||||
isRunning={isRunning}
|
||||
speed={speed}
|
||||
parkedAt={parkedAt}
|
||||
controller={controller}
|
||||
onToggleHelp={handleToggleHelp}
|
||||
controlMode={controlMode}
|
||||
onToggleControlMode={handleToggleControlMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<HelpOverlay isOpen={showHelp} onClose={handleToggleHelp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { TeleprompterController } from '../teleprompter.types';
|
||||
import { applyTeleprompterAction } from '../useTeleprompterControls';
|
||||
|
||||
function makeController() {
|
||||
const state = {
|
||||
isRunning: false,
|
||||
position: 10,
|
||||
speed: 14,
|
||||
};
|
||||
|
||||
const controller: TeleprompterController = {
|
||||
play: () => {
|
||||
state.isRunning = true;
|
||||
},
|
||||
pause: () => {
|
||||
state.isRunning = false;
|
||||
},
|
||||
togglePlay: () => {
|
||||
state.isRunning = !state.isRunning;
|
||||
},
|
||||
nudge: (lines) => {
|
||||
state.position += lines;
|
||||
},
|
||||
page: () => undefined,
|
||||
jumpEvent: () => undefined,
|
||||
setSpeed: (speed) => {
|
||||
state.speed = speed;
|
||||
},
|
||||
changeSpeed: (delta) => {
|
||||
state.speed += delta;
|
||||
},
|
||||
rewind: () => {
|
||||
state.position = 0;
|
||||
},
|
||||
jumpToEnd: () => undefined,
|
||||
reengageFollow: () => undefined,
|
||||
};
|
||||
|
||||
return { controller, state };
|
||||
}
|
||||
|
||||
const callbacks = {
|
||||
onFlip: () => undefined,
|
||||
onFontSize: () => undefined,
|
||||
onResetFontSize: () => undefined,
|
||||
onToggleHelp: () => undefined,
|
||||
};
|
||||
|
||||
describe('applyTeleprompterAction()', () => {
|
||||
test('preserves consecutive local playback toggles', () => {
|
||||
const { controller, state } = makeController();
|
||||
|
||||
applyTeleprompterAction({ type: 'togglePlay' }, { controller, ...callbacks });
|
||||
expect(state.isRunning).toBe(true);
|
||||
|
||||
applyTeleprompterAction({ type: 'togglePlay' }, { controller, ...callbacks });
|
||||
expect(state.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
test('preserves consecutive local speed changes', () => {
|
||||
const { controller, state } = makeController();
|
||||
|
||||
applyTeleprompterAction({ type: 'speed', delta: 5 }, { controller, ...callbacks });
|
||||
applyTeleprompterAction({ type: 'speed', delta: 5 }, { controller, ...callbacks });
|
||||
|
||||
expect(state.speed).toBe(24);
|
||||
});
|
||||
|
||||
test('rewinds and pauses as two base operations', () => {
|
||||
const { controller, state } = makeController();
|
||||
state.isRunning = true;
|
||||
|
||||
applyTeleprompterAction({ type: 'rewindAndPause' }, { controller, ...callbacks });
|
||||
|
||||
expect(state.position).toBe(0);
|
||||
expect(state.isRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
|
||||
import { SPEED_STEP, SPEED_STEP_COARSE } from '../teleprompter.scroll';
|
||||
|
||||
function makeEvent(overrides: Partial<TeleprompterKeyEvent>): TeleprompterKeyEvent {
|
||||
return {
|
||||
code: '',
|
||||
key: '',
|
||||
shiftKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
repeat: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveTeleprompterAction()', () => {
|
||||
test('space toggles playback', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'Space' }))).toEqual({ type: 'togglePlay' });
|
||||
});
|
||||
|
||||
test('ignores a repeating space', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', repeat: true }))).toBeNull();
|
||||
});
|
||||
|
||||
test('vertical arrows nudge by a line', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown' }))).toEqual({ type: 'nudge', lines: 1 });
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowUp' }))).toEqual({ type: 'nudge', lines: -1 });
|
||||
});
|
||||
|
||||
test('a repeating arrow still nudges, so the key can be held', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown', repeat: true }))).toEqual({
|
||||
type: 'nudge',
|
||||
lines: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('shift makes the vertical arrows jump a whole event', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown', shiftKey: true }))).toEqual({
|
||||
type: 'jumpEvent',
|
||||
direction: 1,
|
||||
});
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowUp', shiftKey: true }))).toEqual({
|
||||
type: 'jumpEvent',
|
||||
direction: -1,
|
||||
});
|
||||
});
|
||||
|
||||
test('page keys jump a screen', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'PageDown' }))).toEqual({ type: 'page', direction: 1 });
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'PageUp' }))).toEqual({ type: 'page', direction: -1 });
|
||||
});
|
||||
|
||||
test('horizontal arrows change speed', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight' }))).toEqual({ type: 'speed', delta: SPEED_STEP });
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -SPEED_STEP });
|
||||
});
|
||||
|
||||
test('shift makes the speed step coarse', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({
|
||||
type: 'speed',
|
||||
delta: SPEED_STEP_COARSE,
|
||||
});
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({
|
||||
type: 'speed',
|
||||
delta: -SPEED_STEP_COARSE,
|
||||
});
|
||||
});
|
||||
|
||||
test('home rewinds and escape rewinds and stops', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'Home' }))).toEqual({ type: 'rewind' });
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'Escape' }))).toEqual({ type: 'rewindAndPause' });
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'End' }))).toEqual({ type: 'jumpToEnd' });
|
||||
});
|
||||
|
||||
test('f flips, shift+f flips the other axis', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: 'f' }))).toEqual({ type: 'flip', axis: 'h' });
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: 'F', shiftKey: true }))).toEqual({ type: 'flip', axis: 'v' });
|
||||
});
|
||||
|
||||
test('changes and resets the font size', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({ type: 'fontSize', steps: 1 });
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: '=' }))).toEqual({ type: 'fontSize', steps: 1 });
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({ type: 'fontSize', steps: -1 });
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: '_' }))).toEqual({ type: 'fontSize', steps: -1 });
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: '0' }))).toEqual({ type: 'resetFontSize' });
|
||||
});
|
||||
|
||||
test('l re-engages the follow and ? shows the help', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: 'l' }))).toEqual({ type: 'reengageFollow' });
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: '?', shiftKey: true }))).toEqual({ type: 'toggleHelp' });
|
||||
});
|
||||
|
||||
test('never shadows a shortcut which carries a modifier', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ key: ',', metaKey: true }))).toBeNull();
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', ctrlKey: true }))).toBeNull();
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', altKey: true }))).toBeNull();
|
||||
});
|
||||
|
||||
test('ignores keys it does not bind', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'KeyQ', key: 'q' }))).toBeNull();
|
||||
});
|
||||
|
||||
test('leaves Enter alone, so a focused transport button can still be pressed', () => {
|
||||
expect(resolveTeleprompterAction(makeEvent({ code: 'Enter', key: 'Enter' }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { getOptionsFromParams, getTeleprompterOptions } from '../teleprompter.options';
|
||||
import { DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from '../teleprompter.scroll';
|
||||
|
||||
describe('getOptionsFromParams()', () => {
|
||||
test('provides sensible defaults with no params', () => {
|
||||
const options = getOptionsFromParams(new URLSearchParams());
|
||||
|
||||
expect(options).toMatchObject({
|
||||
scriptSource: 'none',
|
||||
heading: 'title',
|
||||
hideEmpty: true,
|
||||
showGroups: true,
|
||||
speed: DEFAULT_SPEED,
|
||||
followLoaded: true,
|
||||
fontSize: 52,
|
||||
lineHeight: 1.3,
|
||||
textWidth: 80,
|
||||
readingLine: true,
|
||||
readingLinePos: 25,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('reads the script source verbatim so it can be handed to getPropertyValue', () => {
|
||||
const options = getOptionsFromParams(new URLSearchParams('script=custom-prompter'));
|
||||
expect(options.scriptSource).toBe('custom-prompter');
|
||||
});
|
||||
|
||||
test('booleans which default to true can be turned off', () => {
|
||||
const options = getOptionsFromParams(
|
||||
new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&readingLine=false'),
|
||||
);
|
||||
|
||||
expect(options).toMatchObject({
|
||||
hideEmpty: false,
|
||||
showGroups: false,
|
||||
followLoaded: false,
|
||||
readingLine: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('booleans which default to false can be turned on', () => {
|
||||
const options = getOptionsFromParams(new URLSearchParams('flipH=true&flipV=true'));
|
||||
|
||||
expect(options).toMatchObject({
|
||||
flipH: true,
|
||||
flipV: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps the speed to the usable range', () => {
|
||||
expect(getOptionsFromParams(new URLSearchParams('speed=1000')).speed).toBe(MAX_SPEED);
|
||||
expect(getOptionsFromParams(new URLSearchParams('speed=0')).speed).toBe(MIN_SPEED);
|
||||
});
|
||||
|
||||
test('falls back to the default for a non numeric value', () => {
|
||||
expect(getOptionsFromParams(new URLSearchParams('speed=fast')).speed).toBe(DEFAULT_SPEED);
|
||||
expect(getOptionsFromParams(new URLSearchParams('speed=')).speed).toBe(DEFAULT_SPEED);
|
||||
expect(getOptionsFromParams(new URLSearchParams('fontSize=huge')).fontSize).toBe(52);
|
||||
});
|
||||
|
||||
test('rejects an unknown value for an enumerated option', () => {
|
||||
expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title');
|
||||
});
|
||||
|
||||
test('preset values take precedence over the search params', () => {
|
||||
const options = getOptionsFromParams(
|
||||
new URLSearchParams('speed=10&script=custom-a'),
|
||||
new URLSearchParams('speed=20&script=custom-b'),
|
||||
);
|
||||
|
||||
expect(options.speed).toBe(20);
|
||||
expect(options.scriptSource).toBe('custom-b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeleprompterOptions()', () => {
|
||||
test('every declared default is what parsing an empty query produces', () => {
|
||||
const parsed = getOptionsFromParams(new URLSearchParams()) as Record<string, unknown>;
|
||||
const parsedByParamId: Record<string, unknown> = { ...parsed, script: parsed.scriptSource };
|
||||
|
||||
const declared = getTeleprompterOptions({}).flatMap((section) => section.options);
|
||||
expect(declared.length).toBeGreaterThan(0);
|
||||
|
||||
for (const field of declared) {
|
||||
if (!('defaultValue' in field) || field.defaultValue === undefined) continue;
|
||||
const expected = field.defaultValue;
|
||||
expect({ id: field.id, value: parsedByParamId[field.id] }).toEqual({ id: field.id, value: expected });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('option value round trip', () => {
|
||||
test('every value the editor offers survives parsing', () => {
|
||||
const selects = getTeleprompterOptions({})
|
||||
.flatMap((section) => section.options)
|
||||
.filter((field) => field.type === 'option' && field.id !== 'script');
|
||||
|
||||
expect(selects.length).toBeGreaterThan(0);
|
||||
|
||||
for (const field of selects) {
|
||||
for (const { value } of field.values) {
|
||||
const parsed = getOptionsFromParams(new URLSearchParams(`${field.id}=${value}`)) as Record<string, unknown>;
|
||||
expect({ id: field.id, value, parsed: parsed[field.id] }).toEqual({ id: field.id, value, parsed: value });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
advance,
|
||||
anchorAtReadPoint,
|
||||
type BlockGeometry,
|
||||
clampSpeed,
|
||||
easeCatchUp,
|
||||
FOLLOW_BREAK_LINES,
|
||||
frameDeltaSeconds,
|
||||
hasBrokenFollow,
|
||||
indexAtReadPoint,
|
||||
linesPerMinuteToPxPerSecond,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FRAME_DELTA_MS,
|
||||
MAX_SPEED,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_SPEED,
|
||||
readPointForAnchor,
|
||||
segmentAfter,
|
||||
segmentEndFor,
|
||||
stepFontSize,
|
||||
} from '../teleprompter.scroll';
|
||||
|
||||
describe('linesPerMinuteToPxPerSecond()', () => {
|
||||
test('converts a read rate into a pixel rate', () => {
|
||||
// 30 lines a minute over a 64px line is half a line a second
|
||||
expect(linesPerMinuteToPxPerSecond(30, 64)).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepFontSize()', () => {
|
||||
test('steps by a ratio, so a press is the same visual change at any size', () => {
|
||||
// a fixed pixel step would be a big jump at 20px and imperceptible at 200px
|
||||
expect(stepFontSize(100, 1)).toBe(110);
|
||||
expect(stepFontSize(20, 1)).toBe(22);
|
||||
});
|
||||
|
||||
test('shrinking undoes growing', () => {
|
||||
expect(stepFontSize(stepFontSize(50, 1), -1)).toBe(50);
|
||||
});
|
||||
|
||||
test('stays inside the range the option accepts', () => {
|
||||
expect(stepFontSize(MAX_FONT_SIZE, 5)).toBe(MAX_FONT_SIZE);
|
||||
expect(stepFontSize(MIN_FONT_SIZE, -5)).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampSpeed()', () => {
|
||||
test('bounds the speed to the usable range', () => {
|
||||
expect(clampSpeed(MIN_SPEED - 10)).toBe(MIN_SPEED);
|
||||
expect(clampSpeed(MAX_SPEED + 10)).toBe(MAX_SPEED);
|
||||
expect(clampSpeed(30)).toBe(30);
|
||||
});
|
||||
|
||||
test('falls back to the minimum for a non number, rather than stalling at zero', () => {
|
||||
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frameDeltaSeconds()', () => {
|
||||
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
|
||||
// requestAnimationFrame is suspended while hidden, so the first timestamp
|
||||
// back can be minutes stale
|
||||
expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
|
||||
expect(frameDeltaSeconds(60_000)).toBe(MAX_FRAME_DELTA_MS / 1000);
|
||||
});
|
||||
|
||||
test('ignores nonsense deltas', () => {
|
||||
expect(frameDeltaSeconds(-5)).toBe(0);
|
||||
expect(frameDeltaSeconds(Number.NaN)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advance()', () => {
|
||||
test('accumulates sub-pixel movement without losing any to rounding', () => {
|
||||
// 32px/s sampled at 60fps is 0.53px a frame: rounding each frame would stall
|
||||
const pxPerSecond = 32;
|
||||
const frame = 1 / 60;
|
||||
let position = 0;
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
position = advance(position, pxPerSecond, frame, 10_000).position;
|
||||
}
|
||||
|
||||
expect(position).toBeCloseTo((pxPerSecond * 100) / 60, 5);
|
||||
});
|
||||
|
||||
test('reports the end of the script once the bottom is reached', () => {
|
||||
expect(advance(499, 100, 1, 500).atEnd).toBe(true);
|
||||
expect(advance(100, 100, 1, 500).atEnd).toBe(false);
|
||||
});
|
||||
|
||||
test('reports the end without bounding the position, which the caller owns', () => {
|
||||
// the loop has to bound the position anyway, for nudges and for a document
|
||||
// which shrank, so this does not do it a second time
|
||||
expect(advance(490, 100, 1, 500)).toEqual({ position: 590, atEnd: true });
|
||||
});
|
||||
|
||||
test('never reports the end for a document which does not overflow', () => {
|
||||
// it may simply not have been measured yet, and stopping playback on an
|
||||
// unmeasured document would look like the prompter refusing to run
|
||||
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBrokenFollow()', () => {
|
||||
const lineHeight = 40;
|
||||
|
||||
test('tolerates drift under the threshold, so momentum or a stray touch does not break it', () => {
|
||||
const underThreshold = lineHeight * FOLLOW_BREAK_LINES - 1;
|
||||
expect(hasBrokenFollow(underThreshold, lineHeight)).toBe(false);
|
||||
expect(hasBrokenFollow(-underThreshold, lineHeight)).toBe(false);
|
||||
});
|
||||
|
||||
test('counts a deliberate scroll past the threshold, in either direction', () => {
|
||||
const overThreshold = lineHeight * FOLLOW_BREAK_LINES + 1;
|
||||
expect(hasBrokenFollow(overThreshold, lineHeight)).toBe(true);
|
||||
expect(hasBrokenFollow(-overThreshold, lineHeight)).toBe(true);
|
||||
});
|
||||
|
||||
test('never breaks follow before the document has been measured', () => {
|
||||
// an unmeasured line height would make any distance look like a break
|
||||
expect(hasBrokenFollow(10_000, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the read anchor', () => {
|
||||
const script: BlockGeometry[] = [
|
||||
{ id: 'welcome', top: 0, height: 100 },
|
||||
{ id: 'keynote', top: 100, height: 300 },
|
||||
{ id: 'lunch', top: 400, height: 100 },
|
||||
];
|
||||
const ids = script.map((block) => block.id);
|
||||
|
||||
describe('indexAtReadPoint()', () => {
|
||||
test('finds the block the reading line is over', () => {
|
||||
expect(indexAtReadPoint(150, script)).toBe(1);
|
||||
expect(indexAtReadPoint(400, script)).toBe(2);
|
||||
});
|
||||
|
||||
test('clamps past either end, so a jump from there still lands on a block', () => {
|
||||
expect(indexAtReadPoint(-50, script)).toBe(0);
|
||||
expect(indexAtReadPoint(10_000, script)).toBe(2);
|
||||
});
|
||||
|
||||
test('reports no block for an empty script', () => {
|
||||
expect(indexAtReadPoint(0, [])).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPointForAnchor()', () => {
|
||||
test('round trips an unchanged document', () => {
|
||||
const anchor = anchorAtReadPoint(250, script);
|
||||
expect(anchor).toEqual({ blockId: 'keynote', offset: 150 });
|
||||
expect(readPointForAnchor(anchor!, script, ids)).toBe(250);
|
||||
});
|
||||
|
||||
test('holds the same words under the reading line when an event above grows', () => {
|
||||
// the whole point: the rundown is edited while it is being read, and an
|
||||
// edit above the reader moves every pixel below it
|
||||
const anchor = anchorAtReadPoint(250, script);
|
||||
const grown: BlockGeometry[] = [
|
||||
{ id: 'welcome', top: 0, height: 180 },
|
||||
{ id: 'keynote', top: 180, height: 300 },
|
||||
{ id: 'lunch', top: 480, height: 100 },
|
||||
];
|
||||
expect(readPointForAnchor(anchor!, grown, ids)).toBe(330);
|
||||
});
|
||||
|
||||
test('follows the anchored event when the rundown is reordered', () => {
|
||||
const anchor = anchorAtReadPoint(250, script);
|
||||
const reordered: BlockGeometry[] = [
|
||||
{ id: 'lunch', top: 0, height: 100 },
|
||||
{ id: 'welcome', top: 100, height: 100 },
|
||||
{ id: 'keynote', top: 200, height: 300 },
|
||||
];
|
||||
expect(readPointForAnchor(anchor!, reordered, ids)).toBe(350);
|
||||
});
|
||||
|
||||
test('stays inside an event which was edited shorter than the read offset', () => {
|
||||
const anchor = anchorAtReadPoint(250, script);
|
||||
const trimmed: BlockGeometry[] = [
|
||||
{ id: 'welcome', top: 0, height: 100 },
|
||||
{ id: 'keynote', top: 100, height: 40 },
|
||||
{ id: 'lunch', top: 140, height: 100 },
|
||||
];
|
||||
expect(readPointForAnchor(anchor!, trimmed, ids)).toBe(140);
|
||||
});
|
||||
|
||||
test('falls back to the end of the nearest surviving event when the anchored one is deleted', () => {
|
||||
// where the deleted text used to begin, rather than wherever its pixels
|
||||
// now happen to point
|
||||
const anchor = anchorAtReadPoint(250, script);
|
||||
const deleted: BlockGeometry[] = [
|
||||
{ id: 'welcome', top: 0, height: 100 },
|
||||
{ id: 'lunch', top: 100, height: 100 },
|
||||
];
|
||||
expect(readPointForAnchor(anchor!, deleted, ids)).toBe(100);
|
||||
});
|
||||
|
||||
test('gives up rather than guessing when nothing before the anchor survives', () => {
|
||||
const anchor = anchorAtReadPoint(250, script);
|
||||
expect(readPointForAnchor(anchor!, [{ id: 'lunch', top: 0, height: 100 }], ids)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('segmentAfter()', () => {
|
||||
const readingOffset = 100;
|
||||
const script: BlockGeometry[] = [
|
||||
{ id: 'welcome', top: 0, height: 300 },
|
||||
{ id: 'keynote', top: 300, height: 500 },
|
||||
];
|
||||
|
||||
const endAfter = (position: number) => {
|
||||
const block = segmentAfter(position, readingOffset, script);
|
||||
return block === null ? null : segmentEndFor(block, readingOffset);
|
||||
};
|
||||
|
||||
test('stops playback at the end of the segment being read', () => {
|
||||
// an event has a stop of its own, and running on would read a segment
|
||||
// nobody cued: at a conference, the next speaker's script
|
||||
expect(endAfter(0)).toBe(200);
|
||||
expect(endAfter(150)).toBe(200);
|
||||
});
|
||||
|
||||
test('moves on to the next segment from a position already parked on a boundary', () => {
|
||||
// otherwise playback would stop again where it already is, and the reader
|
||||
// could never start the next event by pressing play
|
||||
expect(endAfter(200)).toBe(700);
|
||||
});
|
||||
|
||||
test('treats a position within measurement noise of a boundary as parked on it', () => {
|
||||
// re-measuring can leave the parked position a fraction off the boundary,
|
||||
// and a sub-pixel run to it would look like play doing nothing at all
|
||||
expect(endAfter(199.5)).toBe(700);
|
||||
expect(endAfter(190)).toBe(200);
|
||||
});
|
||||
|
||||
test('gives up the bound past the last segment, leaving the end of the script', () => {
|
||||
expect(endAfter(700)).toBeNull();
|
||||
});
|
||||
|
||||
test('has no boundary to stop at in a single segment script', () => {
|
||||
// the plain use of pointing at some text and scrolling through it
|
||||
const single: BlockGeometry[] = [{ id: 'only', top: 0, height: 5000 }];
|
||||
expect(segmentAfter(0, readingOffset, single)?.id).toBe('only');
|
||||
expect(segmentAfter(4900, readingOffset, single)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('easeCatchUp()', () => {
|
||||
test('approaches the target monotonically from either side', () => {
|
||||
let fromAbove = 500;
|
||||
let fromBelow = 0;
|
||||
let previousAbove = 501;
|
||||
let previousBelow = -1;
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
fromBelow = easeCatchUp(fromBelow, 500, 1 / 60);
|
||||
fromAbove = easeCatchUp(fromAbove, 0, 1 / 60);
|
||||
|
||||
expect(fromBelow).toBeGreaterThan(previousBelow);
|
||||
expect(fromBelow).toBeLessThanOrEqual(500);
|
||||
expect(fromAbove).toBeLessThan(previousAbove);
|
||||
expect(fromAbove).toBeGreaterThanOrEqual(0);
|
||||
|
||||
previousBelow = fromBelow;
|
||||
previousAbove = fromAbove;
|
||||
}
|
||||
});
|
||||
|
||||
test('settles exactly on the target instead of creeping forever', () => {
|
||||
let position = 0;
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
position = easeCatchUp(position, 500, 1 / 60);
|
||||
}
|
||||
expect(position).toBe(500);
|
||||
});
|
||||
|
||||
test('is framerate independent, so a jump takes the same time on any display', () => {
|
||||
let atSixty = 0;
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
atSixty = easeCatchUp(atSixty, 1000, 1 / 60);
|
||||
}
|
||||
|
||||
let atThirty = 0;
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
atThirty = easeCatchUp(atThirty, 1000, 1 / 30);
|
||||
}
|
||||
|
||||
expect(atSixty).toBeCloseTo(atThirty, 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { type CustomFields, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import type { RundownMetadata, RundownMetadataObject } from '../../../common/utils/rundownMetadata';
|
||||
import { buildScript, composeFlip } from '../teleprompter.utils';
|
||||
|
||||
function makeEvent(id: string, overrides: Partial<OntimeEntry> = {}): OntimeEntry {
|
||||
return {
|
||||
type: SupportedEntry.Event,
|
||||
id,
|
||||
cue: id.toUpperCase(),
|
||||
title: `Title ${id}`,
|
||||
note: `Note ${id}`,
|
||||
skip: false,
|
||||
custom: { script: `Script ${id}` },
|
||||
parent: null,
|
||||
...overrides,
|
||||
} as OntimeEntry;
|
||||
}
|
||||
|
||||
function makeGroup(id: string, title: string, entries: string[]): OntimeEntry {
|
||||
return { type: SupportedEntry.Group, id, title, entries } as OntimeEntry;
|
||||
}
|
||||
|
||||
function makeMetadata(overrides: Partial<RundownMetadata> = {}): RundownMetadata {
|
||||
return { isPast: false, isLoaded: false, groupId: null, ...overrides } as RundownMetadata;
|
||||
}
|
||||
|
||||
function makeRundown(entries: OntimeEntry[], flatOrder?: string[]): Rundown {
|
||||
return {
|
||||
id: 'default',
|
||||
title: 'test',
|
||||
order: flatOrder ?? entries.map((entry) => entry.id),
|
||||
flatOrder: flatOrder ?? entries.map((entry) => entry.id),
|
||||
entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
|
||||
revision: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const customFields: CustomFields = {
|
||||
script: { type: 'text', colour: '', label: 'Script' },
|
||||
poster: { type: 'image', colour: '', label: 'Poster' },
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
scriptSource: 'custom-script',
|
||||
heading: 'title' as const,
|
||||
onlyPlaying: false,
|
||||
hideEmpty: true,
|
||||
showGroups: true,
|
||||
};
|
||||
|
||||
function metadataFor(ids: string[], overrides: Record<string, Partial<RundownMetadata>> = {}): RundownMetadataObject {
|
||||
return Object.fromEntries(ids.map((id) => [id, makeMetadata(overrides[id])]));
|
||||
}
|
||||
|
||||
describe('buildScript()', () => {
|
||||
test('resolves the script from the selected custom field, in rundown order', () => {
|
||||
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
|
||||
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
|
||||
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks.map((block) => block.id)).toEqual(['a', 'b']);
|
||||
expect(blocks[0].text).toBe('Script a');
|
||||
});
|
||||
|
||||
test('returns nothing when no script source is selected', () => {
|
||||
const rundown = makeRundown([makeEvent('a')]);
|
||||
expect(buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'none' })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses an image custom field, which the select filters but the URL does not', () => {
|
||||
const rundown = makeRundown([makeEvent('a', { custom: { poster: 'https://example.com/a.png' } })]);
|
||||
const blocks = buildScript(rundown, metadataFor(['a']), customFields, {
|
||||
...defaultOptions,
|
||||
scriptSource: 'custom-poster',
|
||||
});
|
||||
|
||||
expect(blocks).toEqual([]);
|
||||
});
|
||||
|
||||
test('reads the note and the title as script sources', () => {
|
||||
const rundown = makeRundown([makeEvent('a')]);
|
||||
expect(
|
||||
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'note' })[0].text,
|
||||
).toBe('Note a');
|
||||
expect(
|
||||
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'title' })[0].text,
|
||||
).toBe('Title a');
|
||||
});
|
||||
|
||||
test('skips entries which are not events', () => {
|
||||
const rundown = makeRundown([
|
||||
makeEvent('a'),
|
||||
{ type: SupportedEntry.Delay, id: 'd', duration: 10 } as OntimeEntry,
|
||||
{ type: SupportedEntry.Milestone, id: 'm', title: 'milestone' } as OntimeEntry,
|
||||
]);
|
||||
|
||||
const blocks = buildScript(rundown, metadataFor(['a', 'd', 'm']), customFields, defaultOptions);
|
||||
expect(blocks.map((block) => block.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
test('skips events flagged as skipped', () => {
|
||||
const rundown = makeRundown([makeEvent('a', { skip: true }), makeEvent('b')]);
|
||||
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
|
||||
expect(blocks.map((block) => block.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
test('onlyPlaying narrows the script to the event being played', () => {
|
||||
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
|
||||
const metadata = metadataFor(['a', 'b'], { b: { isLoaded: true } });
|
||||
|
||||
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, onlyPlaying: true }).map((b) => b.id)) //
|
||||
.toEqual(['b']);
|
||||
});
|
||||
|
||||
test('onlyPlaying shows the whole script while nothing is playing', () => {
|
||||
// narrowing to nothing would leave a blank screen, which is a worse answer
|
||||
// than the script the reader asked to see
|
||||
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
|
||||
|
||||
expect(
|
||||
buildScript(rundown, metadataFor(['a', 'b']), customFields, { ...defaultOptions, onlyPlaying: true }).map(
|
||||
(b) => b.id,
|
||||
),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('hideEmpty drops events with no script text', () => {
|
||||
const rundown = makeRundown([makeEvent('a', { custom: { script: ' ' } }), makeEvent('b')]);
|
||||
|
||||
expect(buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions).map((b) => b.id)).toEqual(['b']);
|
||||
expect(
|
||||
buildScript(rundown, metadataFor(['a', 'b']), customFields, { ...defaultOptions, hideEmpty: false }).map(
|
||||
(b) => b.id,
|
||||
),
|
||||
).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('marks the block belonging to the loaded event', () => {
|
||||
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
|
||||
const metadata = metadataFor(['a', 'b'], { b: { isLoaded: true } });
|
||||
|
||||
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
|
||||
expect(blocks.map((block) => block.isLoaded)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
test('emits a group title once, on the first block of the group', () => {
|
||||
const rundown = makeRundown(
|
||||
[makeGroup('g', 'Morning session', ['a', 'b']), makeEvent('a'), makeEvent('b')],
|
||||
['g', 'a', 'b'],
|
||||
);
|
||||
const metadata = metadataFor(['g', 'a', 'b'], { a: { groupId: 'g' }, b: { groupId: 'g' } });
|
||||
|
||||
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
|
||||
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null]);
|
||||
});
|
||||
|
||||
test('emits each group title as the script moves between groups', () => {
|
||||
const rundown = makeRundown(
|
||||
[
|
||||
makeGroup('g1', 'Morning session', ['a']),
|
||||
makeEvent('a'),
|
||||
makeGroup('g2', 'Afternoon session', ['b']),
|
||||
makeEvent('b'),
|
||||
],
|
||||
['g1', 'a', 'g2', 'b'],
|
||||
);
|
||||
const metadata = metadataFor(['g1', 'a', 'g2', 'b'], { a: { groupId: 'g1' }, b: { groupId: 'g2' } });
|
||||
|
||||
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
|
||||
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', 'Afternoon session']);
|
||||
});
|
||||
|
||||
test('repeats a group title when the script returns to it after an ungrouped event', () => {
|
||||
// the reader has lost the context by then, so naming the group again is right
|
||||
const rundown = makeRundown(
|
||||
[makeGroup('g', 'Morning session', ['a', 'c']), makeEvent('a'), makeEvent('b'), makeEvent('c')],
|
||||
['g', 'a', 'b', 'c'],
|
||||
);
|
||||
const metadata = metadataFor(['g', 'a', 'b', 'c'], { a: { groupId: 'g' }, c: { groupId: 'g' } });
|
||||
|
||||
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
|
||||
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null, 'Morning session']);
|
||||
});
|
||||
|
||||
test('does not emit group titles when they are turned off', () => {
|
||||
const rundown = makeRundown([makeGroup('g', 'Morning session', ['a']), makeEvent('a')], ['g', 'a']);
|
||||
const metadata = metadataFor(['g', 'a'], { a: { groupId: 'g' } });
|
||||
|
||||
const blocks = buildScript(rundown, metadata, customFields, { ...defaultOptions, showGroups: false });
|
||||
expect(blocks[0].groupTitle).toBeNull();
|
||||
});
|
||||
|
||||
describe('headings', () => {
|
||||
const rundown = makeRundown([makeEvent('a')]);
|
||||
const metadata = metadataFor(['a']);
|
||||
|
||||
test('shows the title, the cue, both, or nothing', () => {
|
||||
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'title' })[0].heading).toBe(
|
||||
'Title a',
|
||||
);
|
||||
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'cue' })[0].heading).toBe('A');
|
||||
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
|
||||
'A · Title a',
|
||||
);
|
||||
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'none' })[0].heading).toBe('');
|
||||
});
|
||||
|
||||
test('leaves no dangling separator when an event has no cue', () => {
|
||||
const noCue = makeRundown([makeEvent('a', { cue: '' })]);
|
||||
expect(buildScript(noCue, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
|
||||
'Title a',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeFlip()', () => {
|
||||
test('passes the per view flips through when Flip Screen is off', () => {
|
||||
expect(composeFlip(false, false, false)).toEqual({ flipH: false, flipV: false });
|
||||
expect(composeFlip(true, false, false)).toEqual({ flipH: true, flipV: false });
|
||||
expect(composeFlip(false, true, false)).toEqual({ flipH: false, flipV: true });
|
||||
});
|
||||
|
||||
test('Flip Screen alone flips both axes, matching rotate(180deg) in every other view', () => {
|
||||
expect(composeFlip(false, false, true)).toEqual({ flipH: true, flipV: true });
|
||||
});
|
||||
|
||||
test('a horizontal flip and Flip Screen leave only the vertical axis flipped', () => {
|
||||
// scale(-1, 1) composed with scale(-1, -1) is scale(1, -1)
|
||||
expect(composeFlip(true, false, true)).toEqual({ flipH: false, flipV: true });
|
||||
});
|
||||
|
||||
test('both flips cancel Flip Screen out', () => {
|
||||
expect(composeFlip(true, true, true)).toEqual({ flipH: false, flipV: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { MouseEvent, PropsWithChildren } from 'react';
|
||||
import {
|
||||
IoAdd,
|
||||
IoArrowDown,
|
||||
IoArrowUp,
|
||||
IoHelpCircleOutline,
|
||||
IoLockClosed,
|
||||
IoLockOpen,
|
||||
IoPause,
|
||||
IoPlay,
|
||||
IoRemove,
|
||||
} from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
import { SPEED_STEP } from '../teleprompter.scroll';
|
||||
import type { ParkedAt, TeleprompterControlMode, TeleprompterController } from '../teleprompter.types';
|
||||
|
||||
interface ControlOverlayProps {
|
||||
isRunning: boolean;
|
||||
speed: number;
|
||||
parkedAt: ParkedAt;
|
||||
controller: TeleprompterController;
|
||||
onToggleHelp: () => void;
|
||||
controlMode: TeleprompterControlMode;
|
||||
onToggleControlMode: () => void;
|
||||
}
|
||||
|
||||
interface ControlButtonProps {
|
||||
label: string;
|
||||
accessibleLabel: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isActive?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
label,
|
||||
accessibleLabel,
|
||||
onPress,
|
||||
disabled,
|
||||
isActive,
|
||||
testId,
|
||||
children,
|
||||
}: PropsWithChildren<ControlButtonProps>) {
|
||||
// Pointer activation yields focus so the next Space reaches the transport.
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (event.detail > 0) {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
onPress();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={label}
|
||||
render={
|
||||
<IconButton
|
||||
variant={isActive ? 'primary' : 'subtle-white'}
|
||||
size='large'
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
aria-label={accessibleLabel}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ControlOverlay({
|
||||
isRunning,
|
||||
speed,
|
||||
parkedAt,
|
||||
controller,
|
||||
onToggleHelp,
|
||||
controlMode,
|
||||
onToggleControlMode,
|
||||
}: ControlOverlayProps) {
|
||||
const isActive = useFadeOutOnInactivity(true);
|
||||
const isControlled = controlMode === 'controlled';
|
||||
|
||||
return (
|
||||
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
|
||||
<ControlButton
|
||||
label={isControlled ? 'Switch to free mode' : 'Switch to controlled mode'}
|
||||
accessibleLabel='Toggle control mode'
|
||||
onPress={onToggleControlMode}
|
||||
isActive={isControlled}
|
||||
>
|
||||
{isControlled ? <IoLockClosed /> : <IoLockOpen />}
|
||||
</ControlButton>
|
||||
|
||||
<ControlButton
|
||||
label={isRunning ? 'Pause (Space)' : 'Play (Space)'}
|
||||
accessibleLabel={isRunning ? 'Pause' : 'Play'}
|
||||
onPress={controller.togglePlay}
|
||||
disabled={isControlled}
|
||||
testId='teleprompter-play'
|
||||
>
|
||||
{isRunning ? <IoPause /> : <IoPlay />}
|
||||
</ControlButton>
|
||||
|
||||
<ControlButton
|
||||
label='Slow down (Left arrow)'
|
||||
accessibleLabel='Slow down'
|
||||
onPress={() => controller.changeSpeed(-SPEED_STEP)}
|
||||
disabled={isControlled}
|
||||
>
|
||||
<IoRemove />
|
||||
</ControlButton>
|
||||
|
||||
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
|
||||
{speed}
|
||||
<span className='teleprompter__speed-unit'>lpm</span>
|
||||
</div>
|
||||
|
||||
{parkedAt === 'segment' && (
|
||||
<span className='teleprompter__parked' data-testid='teleprompter-parked'>
|
||||
End of event
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ControlButton
|
||||
label='Speed up (Right arrow)'
|
||||
accessibleLabel='Speed up'
|
||||
onPress={() => controller.changeSpeed(SPEED_STEP)}
|
||||
disabled={isControlled}
|
||||
>
|
||||
<IoAdd />
|
||||
</ControlButton>
|
||||
|
||||
<ControlButton
|
||||
label='Nudge up one line (Up arrow)'
|
||||
accessibleLabel='Nudge up one line'
|
||||
onPress={() => controller.nudge(-1)}
|
||||
disabled={isControlled}
|
||||
>
|
||||
<IoArrowUp />
|
||||
</ControlButton>
|
||||
|
||||
<ControlButton
|
||||
label='Nudge down one line (Down arrow)'
|
||||
accessibleLabel='Nudge down one line'
|
||||
onPress={() => controller.nudge(1)}
|
||||
disabled={isControlled}
|
||||
>
|
||||
<IoArrowDown />
|
||||
</ControlButton>
|
||||
|
||||
<ControlButton label='Keyboard shortcuts (?)' accessibleLabel='Keyboard shortcuts' onPress={onToggleHelp}>
|
||||
<IoHelpCircleOutline />
|
||||
</ControlButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import {
|
||||
Combo,
|
||||
Separator,
|
||||
Shortcut,
|
||||
ShortcutGroup,
|
||||
ShortcutGroups,
|
||||
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
|
||||
|
||||
interface HelpOverlayProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Backdrop className='teleprompter__help' />
|
||||
<Dialog.Popup className='teleprompter__help-card'>
|
||||
<div className='teleprompter__help-header'>
|
||||
<Dialog.Title className='teleprompter__help-title'>Prompter shortcuts</Dialog.Title>
|
||||
<IconButton variant='subtle-white' size='large' onClick={onClose} aria-label='Close'>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<ShortcutGroups className='teleprompter__help-groups'>
|
||||
<ShortcutGroup title='Transport'>
|
||||
<Shortcut label='Start / stop scrolling'>
|
||||
<Combo keys={['Space']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Slower / faster'>
|
||||
<Combo keys={['←']} />
|
||||
<Separator />
|
||||
<Combo keys={['→']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Larger speed steps'>
|
||||
<Combo keys={['Shift', '←']} />
|
||||
<Separator />
|
||||
<Combo keys={['Shift', '→']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Navigation'>
|
||||
<Shortcut label='Nudge one line'>
|
||||
<Combo keys={['↑']} />
|
||||
<Separator />
|
||||
<Combo keys={['↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Jump a screen'>
|
||||
<Combo keys={['PgUp']} />
|
||||
<Separator />
|
||||
<Combo keys={['PgDn']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Jump to previous / next event'>
|
||||
<Combo keys={['Shift', '↑']} />
|
||||
<Separator />
|
||||
<Combo keys={['Shift', '↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Jump to top / end'>
|
||||
<Combo keys={['Home']} />
|
||||
<Separator />
|
||||
<Combo keys={['End']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Rewind and stop'>
|
||||
<Combo keys={['Esc']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Follow the loaded event again'>
|
||||
<Combo keys={['L']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Display'>
|
||||
<Shortcut label='Font size'>
|
||||
<Combo keys={['+']} />
|
||||
<Separator />
|
||||
<Combo keys={['-']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Reset font size'>
|
||||
<Combo keys={['0']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Flip horizontally / vertically'>
|
||||
<Combo keys={['F']} />
|
||||
<Separator />
|
||||
<Combo keys={['Shift', 'F']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Show this list'>
|
||||
<Combo keys={['?']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
</ShortcutGroups>
|
||||
</Dialog.Popup>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ScriptBlock } from '../teleprompter.types';
|
||||
|
||||
interface ScriptBlockProps {
|
||||
block: ScriptBlock;
|
||||
registerRef: (id: string, element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
export default function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
|
||||
'use memo';
|
||||
|
||||
return (
|
||||
<section
|
||||
className='teleprompter__block'
|
||||
ref={(element) => registerRef(block.id, element)}
|
||||
data-loaded={block.isLoaded || undefined}
|
||||
>
|
||||
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
|
||||
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
|
||||
<p className='teleprompter__body'>{block.text}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { SPEED_STEP, SPEED_STEP_COARSE } from './teleprompter.scroll';
|
||||
import type { TeleprompterAction } from './teleprompter.types';
|
||||
|
||||
export type TeleprompterKeyEvent = {
|
||||
code: string;
|
||||
key: string;
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
repeat: boolean;
|
||||
};
|
||||
|
||||
export function resolveTeleprompterAction(event: TeleprompterKeyEvent): TeleprompterAction | null {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (event.code) {
|
||||
case 'Space':
|
||||
return event.repeat ? null : { type: 'togglePlay' };
|
||||
// Shift is the coarser step on both axes: a bigger speed change sideways,
|
||||
// a whole event rather than a line vertically.
|
||||
case 'ArrowDown':
|
||||
return event.shiftKey ? { type: 'jumpEvent', direction: 1 } : { type: 'nudge', lines: 1 };
|
||||
case 'ArrowUp':
|
||||
return event.shiftKey ? { type: 'jumpEvent', direction: -1 } : { type: 'nudge', lines: -1 };
|
||||
case 'PageDown':
|
||||
return { type: 'page', direction: 1 };
|
||||
case 'PageUp':
|
||||
return { type: 'page', direction: -1 };
|
||||
case 'ArrowRight':
|
||||
return { type: 'speed', delta: event.shiftKey ? SPEED_STEP_COARSE : SPEED_STEP };
|
||||
case 'ArrowLeft':
|
||||
return { type: 'speed', delta: event.shiftKey ? -SPEED_STEP_COARSE : -SPEED_STEP };
|
||||
case 'Home':
|
||||
return { type: 'rewind' };
|
||||
case 'End':
|
||||
return { type: 'jumpToEnd' };
|
||||
case 'Escape':
|
||||
return { type: 'rewindAndPause' };
|
||||
}
|
||||
|
||||
// Character bindings use key so they work across keyboard layouts.
|
||||
switch (event.key) {
|
||||
case '?':
|
||||
return { type: 'toggleHelp' };
|
||||
case '+':
|
||||
case '=':
|
||||
return { type: 'fontSize', steps: 1 };
|
||||
case '-':
|
||||
case '_':
|
||||
return { type: 'fontSize', steps: -1 };
|
||||
case '0':
|
||||
return { type: 'resetFontSize' };
|
||||
}
|
||||
|
||||
const lowerKey = event.key.toLowerCase();
|
||||
if (lowerKey === 'f') {
|
||||
return { type: 'flip', axis: event.shiftKey ? 'v' : 'h' };
|
||||
}
|
||||
if (lowerKey === 'l') {
|
||||
return { type: 'reengageFollow' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
import { use, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { OptionTitle } from '../../common/components/view-params-editor/constants';
|
||||
import type { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
|
||||
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import { isStringBoolean } from '../common/viewUtils';
|
||||
import { clamp, DEFAULT_SPEED, MAX_FONT_SIZE, MAX_SPEED, MIN_FONT_SIZE, MIN_SPEED } from './teleprompter.scroll';
|
||||
import type { HeadingSource, TeleprompterOptions } from './teleprompter.types';
|
||||
|
||||
const headingOptions: { value: HeadingSource; label: string }[] = [
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'cue', label: 'Cue' },
|
||||
{ value: 'both', label: 'Cue and title' },
|
||||
{ value: 'none', label: 'None' },
|
||||
];
|
||||
const headingSources = headingOptions.map((option) => option.value);
|
||||
|
||||
export const defaults = {
|
||||
script: 'none',
|
||||
heading: 'title' as HeadingSource,
|
||||
onlyPlaying: false,
|
||||
hideEmpty: true,
|
||||
showGroups: true,
|
||||
speed: DEFAULT_SPEED,
|
||||
followLoaded: true,
|
||||
fontSize: 52,
|
||||
lineHeight: 1.3,
|
||||
textWidth: 80,
|
||||
readingLine: true,
|
||||
readingLinePos: 25,
|
||||
flipH: false,
|
||||
flipV: false,
|
||||
controlMode: 'free' as const,
|
||||
};
|
||||
|
||||
const bounds = {
|
||||
speed: [MIN_SPEED, MAX_SPEED],
|
||||
fontSize: [MIN_FONT_SIZE, MAX_FONT_SIZE],
|
||||
lineHeight: [1, 4],
|
||||
textWidth: [20, 100],
|
||||
readingLinePos: [0, 100],
|
||||
} as const;
|
||||
|
||||
export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[] => {
|
||||
const scriptOptions = makeOptionsFromCustomFields(customFields, [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'note', label: 'Note' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
]);
|
||||
|
||||
return [
|
||||
{
|
||||
title: OptionTitle.DataSources,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'controlMode',
|
||||
title: 'Control mode',
|
||||
description: 'Free mode accepts local controls. Controlled mode accepts teleprompter API commands.',
|
||||
type: 'option',
|
||||
values: [
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'controlled', label: 'Controlled' },
|
||||
],
|
||||
defaultValue: defaults.controlMode,
|
||||
},
|
||||
{
|
||||
id: 'script',
|
||||
title: 'Script',
|
||||
description: 'Select the data source which holds the script to read',
|
||||
type: 'option',
|
||||
values: scriptOptions,
|
||||
defaultValue: defaults.script,
|
||||
},
|
||||
{
|
||||
id: 'heading',
|
||||
title: 'Segment heading',
|
||||
description: 'What to show above each segment of the script',
|
||||
type: 'option',
|
||||
values: headingOptions,
|
||||
defaultValue: defaults.heading,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: OptionTitle.BehaviourOptions,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'speed',
|
||||
title: 'Speed',
|
||||
description: `Scroll speed in lines per minute (${MIN_SPEED}-${MAX_SPEED}). Adjustable live with the arrow keys`,
|
||||
type: 'number',
|
||||
defaultValue: defaults.speed,
|
||||
},
|
||||
{
|
||||
id: 'followLoaded',
|
||||
title: 'Follow loaded event',
|
||||
description: 'Scroll to the segment of the loaded event. Scrolling by hand releases the follow',
|
||||
type: 'boolean',
|
||||
defaultValue: defaults.followLoaded,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: OptionTitle.ElementVisibility,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'onlyPlaying',
|
||||
title: 'Show only the playing event',
|
||||
description:
|
||||
'Hides the rest of the script, leaving only the event being played. Shows the whole script while nothing is playing',
|
||||
type: 'boolean',
|
||||
defaultValue: defaults.onlyPlaying,
|
||||
},
|
||||
{
|
||||
id: 'hideEmpty',
|
||||
title: 'Hide events without a script',
|
||||
description: 'Prevents showing headings for events which have no script text',
|
||||
type: 'boolean',
|
||||
defaultValue: defaults.hideEmpty,
|
||||
},
|
||||
{
|
||||
id: 'showGroups',
|
||||
title: 'Show group names',
|
||||
description: 'Shows the group name when the script moves into a new group',
|
||||
type: 'boolean',
|
||||
defaultValue: defaults.showGroups,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: OptionTitle.StyleOverride,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'fontSize',
|
||||
title: 'Font size',
|
||||
description: 'Base font size in pixels. Adjustable live with the + and - keys',
|
||||
type: 'number',
|
||||
defaultValue: defaults.fontSize,
|
||||
},
|
||||
{
|
||||
id: 'lineHeight',
|
||||
title: 'Line height',
|
||||
description: 'Spacing between lines, as a multiple of the font size',
|
||||
type: 'number',
|
||||
defaultValue: defaults.lineHeight,
|
||||
},
|
||||
{
|
||||
id: 'textWidth',
|
||||
title: 'Text width',
|
||||
description: 'Width of the text column as a percentage of the screen. Narrower means less eye movement',
|
||||
type: 'number',
|
||||
defaultValue: defaults.textWidth,
|
||||
},
|
||||
{
|
||||
id: 'readingLine',
|
||||
title: 'Reading line',
|
||||
description: 'Shows a marker beside the line which should be read',
|
||||
type: 'boolean',
|
||||
defaultValue: defaults.readingLine,
|
||||
},
|
||||
{
|
||||
id: 'readingLinePos',
|
||||
title: 'Reading line position',
|
||||
description: 'Position of the reading line as a percentage from the top of the screen',
|
||||
type: 'number',
|
||||
defaultValue: defaults.readingLinePos,
|
||||
},
|
||||
{
|
||||
id: 'flipH',
|
||||
title: 'Flip horizontally',
|
||||
description:
|
||||
'Mirrors the view horizontally, which is what a beam splitter rig needs. Toggled live with F. Flip Screen in the navigation menu flips both axes at once, which is a rotation rather than a mirror',
|
||||
type: 'boolean',
|
||||
defaultValue: defaults.flipH,
|
||||
},
|
||||
{
|
||||
id: 'flipV',
|
||||
title: 'Flip vertically',
|
||||
description: 'Mirrors the view vertically. Note this also moves the reading line. Toggled live with Shift+F',
|
||||
type: 'boolean',
|
||||
defaultValue: defaults.flipV,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
function toNumber(value: string | null, [min, max]: readonly [number, number], fallback: number): number {
|
||||
if (value === null || value === '') return fallback;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return clamp(parsed, min, max);
|
||||
}
|
||||
|
||||
function toBoolean(value: string | null, fallback: boolean): boolean {
|
||||
return value === null ? fallback : isStringBoolean(value);
|
||||
}
|
||||
|
||||
function toEnum<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
|
||||
return allowed.find((candidate) => candidate === value) ?? fallback;
|
||||
}
|
||||
|
||||
export function getOptionsFromParams(
|
||||
searchParams: URLSearchParams,
|
||||
defaultValues?: URLSearchParams,
|
||||
): TeleprompterOptions {
|
||||
const getParam = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
|
||||
|
||||
return {
|
||||
scriptSource: getParam('script') ?? defaults.script,
|
||||
heading: toEnum(getParam('heading'), headingSources, defaults.heading),
|
||||
|
||||
onlyPlaying: toBoolean(getParam('onlyPlaying'), defaults.onlyPlaying),
|
||||
hideEmpty: toBoolean(getParam('hideEmpty'), defaults.hideEmpty),
|
||||
showGroups: toBoolean(getParam('showGroups'), defaults.showGroups),
|
||||
|
||||
speed: toNumber(getParam('speed'), bounds.speed, defaults.speed),
|
||||
followLoaded: toBoolean(getParam('followLoaded'), defaults.followLoaded),
|
||||
|
||||
fontSize: toNumber(getParam('fontSize'), bounds.fontSize, defaults.fontSize),
|
||||
lineHeight: toNumber(getParam('lineHeight'), bounds.lineHeight, defaults.lineHeight),
|
||||
textWidth: toNumber(getParam('textWidth'), bounds.textWidth, defaults.textWidth),
|
||||
readingLine: toBoolean(getParam('readingLine'), defaults.readingLine),
|
||||
readingLinePos: toNumber(getParam('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
|
||||
flipH: toBoolean(getParam('flipH'), defaults.flipH),
|
||||
flipV: toBoolean(getParam('flipV'), defaults.flipV),
|
||||
controlMode: toEnum(getParam('controlMode'), ['free', 'controlled'], defaults.controlMode),
|
||||
};
|
||||
}
|
||||
|
||||
export function useTeleprompterOptions(): TeleprompterOptions {
|
||||
const [searchParams] = useSearchParams();
|
||||
const maybePreset = use(PresetContext);
|
||||
|
||||
return useMemo(() => {
|
||||
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
|
||||
return getOptionsFromParams(searchParams, defaultValues);
|
||||
}, [maybePreset, searchParams]);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
export const MIN_SPEED = 1;
|
||||
export const MAX_SPEED = 40;
|
||||
export const DEFAULT_SPEED = 14;
|
||||
|
||||
export const SPEED_STEP = 1;
|
||||
export const SPEED_STEP_COARSE = 5;
|
||||
|
||||
export const MIN_FONT_SIZE = 12;
|
||||
export const MAX_FONT_SIZE = 400;
|
||||
const FONT_SIZE_STEP_RATIO = 1.1;
|
||||
|
||||
export function stepFontSize(current: number, steps: number): number {
|
||||
return clamp(Math.round(current * FONT_SIZE_STEP_RATIO ** steps), MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
}
|
||||
|
||||
export const MAX_FRAME_DELTA_MS = 100;
|
||||
|
||||
const CATCH_UP_RATE = 8;
|
||||
const CATCH_UP_EPSILON = 0.5;
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function clampSpeed(value: number): number {
|
||||
return clamp(value, MIN_SPEED, MAX_SPEED);
|
||||
}
|
||||
|
||||
export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number {
|
||||
return (linesPerMinute / 60) * lineHeightPx;
|
||||
}
|
||||
|
||||
export function frameDeltaSeconds(deltaMs: number): number {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0;
|
||||
return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000;
|
||||
}
|
||||
|
||||
export function advance(
|
||||
position: number,
|
||||
pxPerSecond: number,
|
||||
deltaSeconds: number,
|
||||
maxScroll: number,
|
||||
): { position: number; atEnd: boolean } {
|
||||
const next = position + pxPerSecond * deltaSeconds;
|
||||
return { position: next, atEnd: maxScroll > 0 && next >= maxScroll };
|
||||
}
|
||||
|
||||
export function easeCatchUp(current: number, target: number, deltaSeconds: number): number {
|
||||
if (deltaSeconds <= 0) return current;
|
||||
const next = target + (current - target) * Math.exp(-CATCH_UP_RATE * deltaSeconds);
|
||||
return Math.abs(next - target) < CATCH_UP_EPSILON ? target : next;
|
||||
}
|
||||
|
||||
/** Where a block sits inside the scrolled content, in layout pixels, ordered top to bottom. */
|
||||
export type BlockGeometry = { id: string; top: number; height: number };
|
||||
|
||||
/**
|
||||
* The read position expressed as a place in the script rather than an offset
|
||||
* into the document.
|
||||
*
|
||||
* A rundown is edited while it is being read, and an edit above the reader
|
||||
* moves every pixel below it. Professional prompters cue a story plus an
|
||||
* offset into it for exactly this reason: the text under the reading line is
|
||||
* the position, the scroll offset is only how it is currently drawn.
|
||||
*/
|
||||
export type ScrollAnchor = { blockId: string; offset: number };
|
||||
|
||||
/**
|
||||
* Index of the block the read point falls in, clamped to the ends of the script.
|
||||
*
|
||||
* Scans rather than bisects: this runs once a frame over a show's worth of
|
||||
* events, and a scan makes no assumption the caller has to keep true.
|
||||
*/
|
||||
export function indexAtReadPoint(readPoint: number, blocks: BlockGeometry[]): number {
|
||||
if (blocks.length === 0) return -1;
|
||||
|
||||
// starts on the first block, which is where a read point above the script lands
|
||||
let index = 0;
|
||||
for (let i = 1; i < blocks.length; i += 1) {
|
||||
if (blocks[i].top > readPoint) break;
|
||||
index = i;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function anchorAtReadPoint(readPoint: number, blocks: BlockGeometry[]): ScrollAnchor | null {
|
||||
const index = indexAtReadPoint(readPoint, blocks);
|
||||
if (index === -1) return null;
|
||||
return { blockId: blocks[index].id, offset: readPoint - blocks[index].top };
|
||||
}
|
||||
|
||||
/**
|
||||
* The read point which puts an anchored place in the script back under the
|
||||
* reading line, or null when it cannot be found at all.
|
||||
*/
|
||||
export function readPointForAnchor(
|
||||
anchor: ScrollAnchor,
|
||||
blocks: BlockGeometry[],
|
||||
previousOrder: string[],
|
||||
): number | null {
|
||||
const match = blocks.find((block) => block.id === anchor.blockId);
|
||||
if (match) {
|
||||
// The block may have been edited shorter than the offset into it.
|
||||
return match.top + Math.min(anchor.offset, match.height);
|
||||
}
|
||||
|
||||
// The anchored event was deleted mid-read. Land on the end of the nearest
|
||||
// event which preceded it and survives, which is where the deleted text
|
||||
// used to begin, rather than wherever its pixels now happen to point.
|
||||
const previousIndex = previousOrder.indexOf(anchor.blockId);
|
||||
for (let i = previousIndex - 1; i >= 0; i -= 1) {
|
||||
const survivor = blocks.find((block) => block.id === previousOrder[i]);
|
||||
if (survivor) return survivor.top + survivor.height;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Keeps a position resting on a boundary from counting as still being before it. */
|
||||
const SEGMENT_BOUNDARY_EPSILON = 1;
|
||||
|
||||
/** The position at which the reading line reaches the end of a segment. */
|
||||
export function segmentEndFor(block: BlockGeometry, readingOffset: number): number {
|
||||
return block.top + block.height - readingOffset;
|
||||
}
|
||||
|
||||
/** Applies a signed nudge within the section currently under the reading line. */
|
||||
export function nudgeTargetFor(
|
||||
position: number,
|
||||
distance: number,
|
||||
readingOffset: number,
|
||||
blocks: BlockGeometry[],
|
||||
maxScroll: number,
|
||||
): number {
|
||||
const index = indexAtReadPoint(position + readingOffset, blocks);
|
||||
if (index === -1) return clamp(position + distance, 0, maxScroll);
|
||||
const block = blocks[index];
|
||||
return clamp(
|
||||
position + distance,
|
||||
Math.max(0, block.top - readingOffset),
|
||||
Math.min(maxScroll, segmentEndFor(block, readingOffset)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The segment playback should run to, or null past the last one, where the end
|
||||
* of the script is the bound.
|
||||
*
|
||||
* An event is a unit of time with a stop of its own, so running the script on
|
||||
* into the next one reads a segment nobody has cued: at a conference that is
|
||||
* the next speaker's script, while they are still walking on.
|
||||
*
|
||||
* Takes the first segment ending ahead rather than the one the position sits
|
||||
* in, so that starting again from a position already parked on a boundary
|
||||
* carries on into the next segment instead of stopping where it already is.
|
||||
* Decide this once when playback starts: re-deciding it against a position
|
||||
* which is moving lets the bound outrun the reader and never arrive.
|
||||
*/
|
||||
export function segmentAfter(position: number, readingOffset: number, blocks: BlockGeometry[]): BlockGeometry | null {
|
||||
for (const block of blocks) {
|
||||
if (segmentEndFor(block, readingOffset) > position + SEGMENT_BOUNDARY_EPSILON) return block;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** How far, in lines, the reader may move the script themselves before it counts as taking over. */
|
||||
export const FOLLOW_BREAK_LINES = 1.5;
|
||||
|
||||
/**
|
||||
* Distinguishes a deliberate scroll away from the read position from momentum
|
||||
* or a stray touch.
|
||||
*
|
||||
* Takes what the reader moved rather than where the script ended up: while
|
||||
* following eases towards a newly loaded event, and while playback carries the
|
||||
* script along, the distance to the target is the prompter's own doing.
|
||||
*/
|
||||
export function hasBrokenFollow(readerDriftPx: number, lineHeightPx: number): boolean {
|
||||
if (lineHeightPx <= 0) return false;
|
||||
return Math.abs(readerDriftPx) > lineHeightPx * FOLLOW_BREAK_LINES;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { MaybeString, TeleprompterCommand, TeleprompterControlMode } from 'ontime-types';
|
||||
|
||||
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
|
||||
|
||||
export type ScriptBlock = {
|
||||
id: string;
|
||||
heading: string;
|
||||
text: string;
|
||||
groupTitle: MaybeString;
|
||||
isLoaded: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where playback stopped of its own accord: at the end of the segment being
|
||||
* read, or at the end of the whole script. Null while it has somewhere to go.
|
||||
*/
|
||||
export type ParkedAt = 'segment' | 'script' | null;
|
||||
|
||||
export type TeleprompterOptions = {
|
||||
scriptSource: string;
|
||||
heading: HeadingSource;
|
||||
onlyPlaying: boolean;
|
||||
hideEmpty: boolean;
|
||||
showGroups: boolean;
|
||||
/** lines per minute */
|
||||
speed: number;
|
||||
followLoaded: boolean;
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
textWidth: number;
|
||||
readingLine: boolean;
|
||||
readingLinePos: number;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
controlMode: TeleprompterControlMode;
|
||||
};
|
||||
|
||||
export type { TeleprompterCommand, TeleprompterControlMode };
|
||||
|
||||
export type TeleprompterAction =
|
||||
| { type: 'togglePlay' }
|
||||
| { type: 'nudge'; lines: number }
|
||||
| { type: 'page'; direction: 1 | -1 }
|
||||
| { type: 'jumpEvent'; direction: 1 | -1 }
|
||||
| { type: 'speed'; delta: number }
|
||||
| { type: 'rewind' }
|
||||
| { type: 'rewindAndPause' }
|
||||
| { type: 'jumpToEnd' }
|
||||
| { type: 'flip'; axis: 'h' | 'v' }
|
||||
| { type: 'fontSize'; steps: number }
|
||||
| { type: 'resetFontSize' }
|
||||
| { type: 'reengageFollow' }
|
||||
| { type: 'toggleHelp' };
|
||||
|
||||
export type TeleprompterController = {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
/** Local input convenience; remote control should use play or pause. */
|
||||
togglePlay: () => void;
|
||||
nudge: (lines: number, options?: { preserveFollow?: boolean }) => void;
|
||||
page: (direction: 1 | -1) => void;
|
||||
jumpEvent: (direction: 1 | -1) => void;
|
||||
setSpeed: (speed: number) => void;
|
||||
/** Local input convenience; remote control should set an absolute speed. */
|
||||
changeSpeed: (delta: number) => void;
|
||||
rewind: () => void;
|
||||
jumpToEnd: () => void;
|
||||
reengageFollow: () => void;
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type CustomFields, isOntimeEvent, isOntimeGroup, type MaybeString, type Rundown } from 'ontime-types';
|
||||
|
||||
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
import type { HeadingSource, ScriptBlock, TeleprompterOptions } from './teleprompter.types';
|
||||
|
||||
type BuildScriptOptions = Pick<
|
||||
TeleprompterOptions,
|
||||
'scriptSource' | 'heading' | 'onlyPlaying' | 'hideEmpty' | 'showGroups'
|
||||
>;
|
||||
|
||||
function makeHeading(source: HeadingSource, cue: string, title: string): string {
|
||||
switch (source) {
|
||||
case 'cue':
|
||||
return cue;
|
||||
case 'title':
|
||||
return title;
|
||||
case 'both':
|
||||
return [cue, title].filter(Boolean).join(' · ');
|
||||
case 'none':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isReadableSource(scriptSource: string, customFields: CustomFields): boolean {
|
||||
if (!scriptSource.startsWith('custom-')) {
|
||||
return true;
|
||||
}
|
||||
const key = scriptSource.slice('custom-'.length);
|
||||
return customFields[key]?.type === 'text';
|
||||
}
|
||||
|
||||
/** Builds the continuous script in rundown order. */
|
||||
export function buildScript(
|
||||
rundown: Rundown,
|
||||
rundownMetadata: RundownMetadataObject,
|
||||
customFields: CustomFields,
|
||||
options: BuildScriptOptions,
|
||||
): ScriptBlock[] {
|
||||
const { scriptSource, heading, onlyPlaying, hideEmpty, showGroups } = options;
|
||||
|
||||
if (scriptSource === 'none' || !isReadableSource(scriptSource, customFields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks: ScriptBlock[] = [];
|
||||
let lastGroupId: MaybeString = null;
|
||||
|
||||
for (const id of rundown.flatOrder) {
|
||||
const entry = rundown.entries[id];
|
||||
if (!isOntimeEvent(entry) || entry.skip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = rundownMetadata[id];
|
||||
const text = getPropertyValue(entry, scriptSource, rundown.entries)?.trim() ?? '';
|
||||
if (hideEmpty && !text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupId = metadata?.groupId ?? null;
|
||||
let groupTitle: MaybeString = null;
|
||||
if (showGroups && groupId && groupId !== lastGroupId) {
|
||||
const group = rundown.entries[groupId];
|
||||
groupTitle = isOntimeGroup(group) ? group.title : null;
|
||||
}
|
||||
lastGroupId = groupId;
|
||||
|
||||
blocks.push({
|
||||
id,
|
||||
heading: makeHeading(heading, entry.cue, entry.title),
|
||||
text,
|
||||
groupTitle,
|
||||
isLoaded: Boolean(metadata?.isLoaded),
|
||||
});
|
||||
}
|
||||
|
||||
if (onlyPlaying) {
|
||||
const playing = blocks.filter((block) => block.isLoaded);
|
||||
// With nothing loaded there is no event to narrow to, and a blank screen
|
||||
// would be a worse answer than the script the reader asked to see.
|
||||
if (playing.length > 0) {
|
||||
return playing;
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
|
||||
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { use, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import { defaults } from './teleprompter.options';
|
||||
|
||||
const SETTLE_MS = 400;
|
||||
|
||||
interface LiveParams {
|
||||
speed: number;
|
||||
fontSize: number;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
}
|
||||
|
||||
/** Keeps keyboard and overlay changes shareable through the URL. */
|
||||
export function useSyncTeleprompterParams({ speed, fontSize, flipH, flipV }: LiveParams) {
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const isPreset = Boolean(use(PresetContext));
|
||||
|
||||
useEffect(() => {
|
||||
if (isPreset) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
const values = {
|
||||
speed: speed === defaults.speed ? null : String(speed),
|
||||
fontSize: fontSize === defaults.fontSize ? null : String(fontSize),
|
||||
flipH: flipH === defaults.flipH ? null : String(flipH),
|
||||
flipV: flipV === defaults.flipV ? null : String(flipV),
|
||||
};
|
||||
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === null) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, value);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, SETTLE_MS);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [flipH, flipV, fontSize, isPreset, setSearchParams, speed]);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useViewParamsEditorStore } from '../../common/components/view-params-editor/viewParamsEditor.store';
|
||||
import { resolveTeleprompterAction } from './teleprompter.keymap';
|
||||
import type { TeleprompterAction, TeleprompterCommand, TeleprompterController } from './teleprompter.types';
|
||||
|
||||
interface TeleprompterActionContext {
|
||||
controller: TeleprompterController;
|
||||
onFlip: (axis: 'h' | 'v') => void;
|
||||
onFontSize: (steps: number) => void;
|
||||
onResetFontSize: () => void;
|
||||
onToggleHelp: () => void;
|
||||
}
|
||||
|
||||
interface UseTeleprompterControlsArgs extends TeleprompterActionContext {
|
||||
isHelpOpen: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
export function applyTeleprompterAction(action: TeleprompterAction, context: TeleprompterActionContext) {
|
||||
const { controller, onFlip, onFontSize, onResetFontSize, onToggleHelp } = context;
|
||||
switch (action.type) {
|
||||
case 'togglePlay':
|
||||
return controller.togglePlay();
|
||||
case 'nudge':
|
||||
return controller.nudge(action.lines);
|
||||
case 'page':
|
||||
return controller.page(action.direction);
|
||||
case 'jumpEvent':
|
||||
return controller.jumpEvent(action.direction);
|
||||
case 'speed':
|
||||
return controller.changeSpeed(action.delta);
|
||||
case 'rewind':
|
||||
return controller.rewind();
|
||||
case 'rewindAndPause':
|
||||
controller.rewind();
|
||||
return controller.pause();
|
||||
case 'jumpToEnd':
|
||||
return controller.jumpToEnd();
|
||||
case 'reengageFollow':
|
||||
return controller.reengageFollow();
|
||||
case 'flip':
|
||||
return onFlip(action.axis);
|
||||
case 'fontSize':
|
||||
return onFontSize(action.steps);
|
||||
case 'resetFontSize':
|
||||
return onResetFontSize();
|
||||
case 'toggleHelp':
|
||||
return onToggleHelp();
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTeleprompterCommand(command: TeleprompterCommand, controller: TeleprompterController) {
|
||||
switch (command.type) {
|
||||
case 'play':
|
||||
return controller.play();
|
||||
case 'pause':
|
||||
return controller.pause();
|
||||
case 'setSpeed':
|
||||
return controller.setSpeed(command.linesPerMinute);
|
||||
case 'nudge':
|
||||
return controller.nudge(command.lines, { preserveFollow: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
|
||||
const argsRef = useRef(args);
|
||||
useEffect(() => {
|
||||
argsRef.current = args;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
|
||||
return;
|
||||
}
|
||||
if (argsRef.current.enabled === false) return;
|
||||
|
||||
const action = resolveTeleprompterAction(event);
|
||||
if (!action) return;
|
||||
|
||||
event.preventDefault();
|
||||
applyTeleprompterAction(action, argsRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { CustomFields, Rundown } from 'ontime-types';
|
||||
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
|
||||
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||
import { type ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
|
||||
|
||||
export interface TeleprompterData {
|
||||
rundown: Rundown;
|
||||
rundownMetadata: RundownMetadataObject;
|
||||
customFields: CustomFields;
|
||||
}
|
||||
|
||||
export function useTeleprompterData(): ViewData<TeleprompterData> {
|
||||
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
|
||||
return {
|
||||
data: {
|
||||
rundown,
|
||||
rundownMetadata,
|
||||
customFields,
|
||||
},
|
||||
status: aggregateQueryStatus([rundownStatus, customFieldStatus]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
advance,
|
||||
anchorAtReadPoint,
|
||||
type BlockGeometry,
|
||||
clamp,
|
||||
clampSpeed,
|
||||
easeCatchUp,
|
||||
frameDeltaSeconds,
|
||||
hasBrokenFollow,
|
||||
indexAtReadPoint,
|
||||
linesPerMinuteToPxPerSecond,
|
||||
nudgeTargetFor,
|
||||
readPointForAnchor,
|
||||
type ScrollAnchor,
|
||||
segmentAfter,
|
||||
segmentEndFor,
|
||||
} from './teleprompter.scroll';
|
||||
import type { ParkedAt, ScriptBlock, TeleprompterController } from './teleprompter.types';
|
||||
|
||||
const PAGE_FRACTION = 0.85;
|
||||
const EXTERNAL_SCROLL_EPSILON = 2;
|
||||
/** Below this the correction would be invisible and only add jitter. */
|
||||
const ANCHOR_CORRECTION_EPSILON = 1;
|
||||
|
||||
/**
|
||||
* Uses layout coordinates because client rects include the teleprompter's CSS
|
||||
* transform while scrollTop does not.
|
||||
*/
|
||||
function getLayoutTop(element: HTMLElement): number {
|
||||
let top = 0;
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current) {
|
||||
top += current.offsetTop;
|
||||
current = current.offsetParent instanceof HTMLElement ? current.offsetParent : null;
|
||||
}
|
||||
|
||||
return top;
|
||||
}
|
||||
|
||||
function getLineHeight(element: HTMLElement): number {
|
||||
const styles = getComputedStyle(element);
|
||||
const lineHeight = Number.parseFloat(styles.lineHeight);
|
||||
if (Number.isFinite(lineHeight) && lineHeight > 0) {
|
||||
return lineHeight;
|
||||
}
|
||||
|
||||
const fontSize = Number.parseFloat(styles.fontSize);
|
||||
return Number.isFinite(fontSize) ? fontSize * 1.2 : 0;
|
||||
}
|
||||
|
||||
function measureBlockGeometry(scroller: HTMLElement, blocks: Map<string, HTMLElement>): BlockGeometry[] {
|
||||
const scrollerTop = getLayoutTop(scroller);
|
||||
return Array.from(blocks, ([id, element]) => ({
|
||||
id,
|
||||
top: getLayoutTop(element) - scrollerTop,
|
||||
height: element.offsetHeight,
|
||||
})).sort((a, b) => a.top - b.top);
|
||||
}
|
||||
|
||||
interface UseTeleprompterScrollArgs {
|
||||
initialSpeed: number;
|
||||
followLoaded: boolean;
|
||||
selectedEventId: string | null;
|
||||
readingLinePos: number;
|
||||
blocks: ScriptBlock[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the scroll position of the teleprompter.
|
||||
*
|
||||
* The animation frame is the only writer of scrollTop. Controls update refs so
|
||||
* smooth scrolling and playback cannot compete for the DOM position.
|
||||
*/
|
||||
export function useTeleprompterScroll({
|
||||
initialSpeed,
|
||||
followLoaded,
|
||||
selectedEventId,
|
||||
readingLinePos,
|
||||
blocks,
|
||||
}: UseTeleprompterScrollArgs) {
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const blockRefs = useRef(new Map<string, HTMLElement>());
|
||||
|
||||
const [isScrollerMounted, setIsScrollerMounted] = useState(false);
|
||||
const attachScroller = useCallback((element: HTMLDivElement | null) => {
|
||||
scrollerRef.current = element;
|
||||
setIsScrollerMounted(Boolean(element && contentRef.current));
|
||||
}, []);
|
||||
const attachContent = useCallback((element: HTMLDivElement | null) => {
|
||||
contentRef.current = element;
|
||||
setIsScrollerMounted(Boolean(element && scrollerRef.current));
|
||||
}, []);
|
||||
|
||||
// authoritative, sub-pixel scroll position
|
||||
const posRef = useRef(0);
|
||||
const lastTsRef = useRef(0);
|
||||
const runningRef = useRef(false);
|
||||
const speedRef = useRef(initialSpeed);
|
||||
const lineHeightRef = useRef(0);
|
||||
const maxScrollRef = useRef(0);
|
||||
const catchUpTargetRef = useRef<number | null>(null);
|
||||
const pendingDeltaRef = useRef(0);
|
||||
// where following last put (or is easing towards putting) the reader
|
||||
const followTargetRef = useRef(0);
|
||||
// how far the reader has moved the script themselves since following last placed it
|
||||
const readerDriftRef = useRef(0);
|
||||
// distance from the top of the viewport to the reading line
|
||||
const readingOffsetRef = useRef(0);
|
||||
const readingLinePosRef = useRef(readingLinePos);
|
||||
const selectedEventIdRef = useRef(selectedEventId);
|
||||
// the script's layout as of the last measure, and the reader's place in it
|
||||
const geometryRef = useRef<BlockGeometry[]>([]);
|
||||
const anchorRef = useRef<ScrollAnchor | null>(null);
|
||||
// the segment this run of playback stops at, chosen when it started
|
||||
const playbackSegmentRef = useRef<string | null>(null);
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [speed, setSpeed] = useState(initialSpeed);
|
||||
// mirrors the operator view's lockAutoScroll: true once the reader has taken
|
||||
// the scroll over by hand, false while following is doing the driving
|
||||
const [autoScrollLocked, setAutoScrollLocked] = useState(false);
|
||||
const [parkedAt, setParkedAt] = useState<ParkedAt>(null);
|
||||
|
||||
const setPlaybackRunning = useCallback((nextIsRunning: boolean) => {
|
||||
runningRef.current = nextIsRunning;
|
||||
setIsRunning(nextIsRunning);
|
||||
}, []);
|
||||
|
||||
const isFollowingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
isFollowingRef.current = followLoaded && !autoScrollLocked;
|
||||
}, [followLoaded, autoScrollLocked]);
|
||||
|
||||
/**
|
||||
* One rule for every input, wheel and keyboard alike: once the reader has
|
||||
* moved the script far enough themselves, they are driving.
|
||||
*
|
||||
* Accumulates, so a gesture the browser spreads over many frames adds up to
|
||||
* the move it was, and so does a slow drag. Scrolling back where you came
|
||||
* from cancels out, which is what keeps momentum and a stray touch from
|
||||
* taking the scroll over.
|
||||
*/
|
||||
const addReaderDrift = useCallback((delta: number) => {
|
||||
if (!isFollowingRef.current) return;
|
||||
readerDriftRef.current += delta;
|
||||
if (hasBrokenFollow(readerDriftRef.current, lineHeightRef.current)) {
|
||||
setAutoScrollLocked(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const tick = useCallback(
|
||||
(timestamp: number) => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
// Adopt wheel, touch, scrollbar, browser-clamp and find-in-page changes.
|
||||
// Whatever the position is that the frame loop did not put there is the
|
||||
// reader's own doing, which makes this the one place a scroll by hand can
|
||||
// be measured, however the browser chose to deliver it.
|
||||
const external = scroller.scrollTop - posRef.current;
|
||||
if (Math.abs(external) > EXTERNAL_SCROLL_EPSILON) {
|
||||
addReaderDrift(external);
|
||||
posRef.current = scroller.scrollTop;
|
||||
catchUpTargetRef.current = null;
|
||||
}
|
||||
|
||||
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
|
||||
lastTsRef.current = timestamp;
|
||||
|
||||
let next = posRef.current;
|
||||
|
||||
if (pendingDeltaRef.current !== 0) {
|
||||
next += pendingDeltaRef.current;
|
||||
pendingDeltaRef.current = 0;
|
||||
catchUpTargetRef.current = null;
|
||||
}
|
||||
|
||||
if (catchUpTargetRef.current !== null) {
|
||||
next = easeCatchUp(next, catchUpTargetRef.current, deltaSeconds);
|
||||
if (next === catchUpTargetRef.current) {
|
||||
catchUpTargetRef.current = null;
|
||||
}
|
||||
} else if (runningRef.current) {
|
||||
// Only playback is held to the segment. A jump, a page or a nudge is
|
||||
// the reader asking to leave it, and stays free to cross.
|
||||
//
|
||||
// Resolved from the segment's identity rather than the pixel bound
|
||||
// taken when playback started, so an edit which moves the script keeps
|
||||
// playback stopping on the same words.
|
||||
const stopBlock = geometryRef.current.find((block) => block.id === playbackSegmentRef.current);
|
||||
const stop = stopBlock
|
||||
? clamp(segmentEndFor(stopBlock, readingOffsetRef.current), 0, maxScrollRef.current)
|
||||
: maxScrollRef.current;
|
||||
|
||||
const pxPerSecond = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
|
||||
const result = advance(next, pxPerSecond, deltaSeconds, stop);
|
||||
next = Math.min(result.position, stop);
|
||||
if (result.atEnd) {
|
||||
setPlaybackRunning(false);
|
||||
// Past the last segment there is only the trailing padding, so
|
||||
// stopping there is the end of the read rather than a wait for a cue.
|
||||
const isLastSegment = stopBlock !== undefined && stopBlock.id === geometryRef.current.at(-1)?.id;
|
||||
setParkedAt(isLastSegment || stop >= maxScrollRef.current ? 'script' : 'segment');
|
||||
}
|
||||
}
|
||||
|
||||
const clamped = clamp(next, 0, maxScrollRef.current);
|
||||
if (clamped !== posRef.current) {
|
||||
posRef.current = clamped;
|
||||
scroller.scrollTop = clamped;
|
||||
}
|
||||
|
||||
// Remember the reader's place in the script, not just in the document, so
|
||||
// the next measure can put them back if the rundown changed underneath
|
||||
// them. Reads no layout: the geometry is the one taken at that measure.
|
||||
anchorRef.current = anchorAtReadPoint(clamped + readingOffsetRef.current, geometryRef.current);
|
||||
},
|
||||
[addReaderDrift, setPlaybackRunning],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
lastTsRef.current = performance.now();
|
||||
let frame = requestAnimationFrame(function loop(timestamp) {
|
||||
tick(timestamp);
|
||||
frame = requestAnimationFrame(loop);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [tick]);
|
||||
|
||||
/** Where following would put the reader for a block, against the last measure. */
|
||||
const scrollTargetFor = useCallback((blockId: string): number | null => {
|
||||
const block = geometryRef.current.find((entry) => entry.id === blockId);
|
||||
if (!block) return null;
|
||||
return clamp(block.top - readingOffsetRef.current, 0, maxScrollRef.current);
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!scroller || !content) return;
|
||||
|
||||
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
readingOffsetRef.current = (scroller.clientHeight * readingLinePosRef.current) / 100;
|
||||
|
||||
lineHeightRef.current = getLineHeight(content);
|
||||
|
||||
const wasEasingToLoadedEvent = catchUpTargetRef.current === followTargetRef.current;
|
||||
|
||||
const previousGeometry = geometryRef.current;
|
||||
// Registration follows mounting rather than rundown order, so trust layout.
|
||||
const geometry = measureBlockGeometry(scroller, blockRefs.current);
|
||||
geometryRef.current = geometry;
|
||||
|
||||
// Put the reader back on the words they were on. Nothing to restore on the
|
||||
// first measure, when there is no earlier document to have moved.
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor && previousGeometry.length > 0) {
|
||||
const readPoint = readPointForAnchor(
|
||||
anchor,
|
||||
geometry,
|
||||
previousGeometry.map((block) => block.id),
|
||||
);
|
||||
if (readPoint !== null) {
|
||||
const target = clamp(readPoint - readingOffsetRef.current, 0, maxScrollRef.current);
|
||||
const delta = target - posRef.current;
|
||||
if (Math.abs(delta) > ANCHOR_CORRECTION_EPSILON) {
|
||||
posRef.current = target;
|
||||
scroller.scrollTop = target;
|
||||
if (catchUpTargetRef.current !== null) {
|
||||
catchUpTargetRef.current = clamp(catchUpTargetRef.current + delta, 0, maxScrollRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The follow target is a position in a document which may have just moved,
|
||||
// so take it from the new geometry rather than ageing the old value.
|
||||
const selectedId = selectedEventIdRef.current;
|
||||
if (selectedId !== null) {
|
||||
const followTarget = scrollTargetFor(selectedId);
|
||||
if (followTarget !== null) {
|
||||
followTargetRef.current = followTarget;
|
||||
}
|
||||
}
|
||||
|
||||
// Shifting an in-flight ease by the correction is only an estimate. When it
|
||||
// was heading for the loaded event we know better: send it to where that
|
||||
// event is now, so following still lands exactly on the reading line.
|
||||
if (wasEasingToLoadedEvent) {
|
||||
catchUpTargetRef.current = followTargetRef.current;
|
||||
}
|
||||
}, [scrollTargetFor]);
|
||||
|
||||
useEffect(() => {
|
||||
speedRef.current = speed;
|
||||
}, [speed]);
|
||||
|
||||
const [speedFromOption, setSpeedFromOption] = useState(initialSpeed);
|
||||
// Keep the live speed in sync with view-param and remote redirect changes.
|
||||
if (speedFromOption !== initialSpeed) {
|
||||
setSpeedFromOption(initialSpeed);
|
||||
setSpeed(initialSpeed);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
|
||||
const scroller = scrollerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!scroller || !content) return;
|
||||
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(scroller);
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, isScrollerMounted]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void document.fonts?.ready.then(() => {
|
||||
if (!cancelled) measure();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [measure]);
|
||||
|
||||
useEffect(() => {
|
||||
readingLinePosRef.current = readingLinePos;
|
||||
measure();
|
||||
}, [readingLinePos, measure]);
|
||||
|
||||
/**
|
||||
* A resize observer sees the document get taller or shorter, but not a
|
||||
* reorder or a rewrite which happens to leave the height alone, and both
|
||||
* move the reader's place in the script.
|
||||
*/
|
||||
const contentSignature = blocks.map((block) => `${block.id}:${block.text.length}`).join();
|
||||
useEffect(() => {
|
||||
measure();
|
||||
}, [contentSignature, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVisibilityChange = () => {
|
||||
lastTsRef.current = performance.now();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
selectedEventIdRef.current = selectedEventId;
|
||||
}, [selectedEventId]);
|
||||
|
||||
// Avoid re-following when a new blocks array contains the same selected event.
|
||||
const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!followLoaded || autoScrollLocked || !selectedEventId) return;
|
||||
|
||||
const target = scrollTargetFor(selectedEventId);
|
||||
if (target === null) return;
|
||||
|
||||
followTargetRef.current = target;
|
||||
catchUpTargetRef.current = target;
|
||||
readerDriftRef.current = 0;
|
||||
setParkedAt(null);
|
||||
}, [selectedEventId, followLoaded, autoScrollLocked, readingLinePos, hasSelectedBlock, scrollTargetFor]);
|
||||
|
||||
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
blockRefs.current.set(id, element);
|
||||
} else {
|
||||
blockRefs.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const controller: TeleprompterController = useMemo(() => {
|
||||
const play = () => {
|
||||
if (runningRef.current) return;
|
||||
if (maxScrollRef.current > 0 && posRef.current >= maxScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
const stopAt = segmentAfter(posRef.current, readingOffsetRef.current, geometryRef.current);
|
||||
playbackSegmentRef.current = stopAt?.id ?? null;
|
||||
setPlaybackRunning(true);
|
||||
setParkedAt(null);
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (!runningRef.current) return;
|
||||
setPlaybackRunning(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Where the scroll is headed rather than where it currently sits, so
|
||||
* pressing a step key again before the ease settles moves on by another
|
||||
* step instead of re-aiming at the one already in flight.
|
||||
*/
|
||||
const destination = () => catchUpTargetRef.current ?? posRef.current;
|
||||
|
||||
/** Eases to a position the reader asked for, by hand. */
|
||||
const goTo = (position: number) => {
|
||||
const target = clamp(position, 0, maxScrollRef.current);
|
||||
addReaderDrift(target - destination());
|
||||
catchUpTargetRef.current = target;
|
||||
setParkedAt(null);
|
||||
};
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlay: () => (runningRef.current ? pause() : play()),
|
||||
nudge: (lines: number, options) => {
|
||||
const distance = lines * lineHeightRef.current;
|
||||
const from = posRef.current + pendingDeltaRef.current;
|
||||
const target = nudgeTargetFor(
|
||||
from,
|
||||
distance,
|
||||
readingOffsetRef.current,
|
||||
geometryRef.current,
|
||||
maxScrollRef.current,
|
||||
);
|
||||
const delta = target - from;
|
||||
pendingDeltaRef.current += delta;
|
||||
if (!options?.preserveFollow) addReaderDrift(delta);
|
||||
setParkedAt(null);
|
||||
},
|
||||
page: (direction: 1 | -1) => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
goTo(destination() + scroller.clientHeight * PAGE_FRACTION * direction);
|
||||
},
|
||||
jumpEvent: (direction: 1 | -1) => {
|
||||
const geometry = geometryRef.current;
|
||||
const current = indexAtReadPoint(destination() + readingOffsetRef.current, geometry);
|
||||
if (current === -1) return;
|
||||
|
||||
const next = clamp(current + direction, 0, geometry.length - 1);
|
||||
goTo(geometry[next].top - readingOffsetRef.current);
|
||||
},
|
||||
setSpeed: (nextSpeed: number) => setSpeed(clampSpeed(nextSpeed)),
|
||||
changeSpeed: (delta: number) => setSpeed((current) => clampSpeed(current + delta)),
|
||||
rewind: () => goTo(0),
|
||||
jumpToEnd: () => {
|
||||
addReaderDrift(maxScrollRef.current - destination());
|
||||
catchUpTargetRef.current = maxScrollRef.current;
|
||||
if (maxScrollRef.current > 0) {
|
||||
setPlaybackRunning(false);
|
||||
setParkedAt('script');
|
||||
}
|
||||
},
|
||||
reengageFollow: () => {
|
||||
readerDriftRef.current = 0;
|
||||
setAutoScrollLocked(false);
|
||||
},
|
||||
};
|
||||
}, [addReaderDrift, scrollTargetFor, setPlaybackRunning]);
|
||||
|
||||
return {
|
||||
scrollerRef: attachScroller,
|
||||
contentRef: attachContent,
|
||||
registerBlock,
|
||||
controller,
|
||||
isRunning,
|
||||
speed,
|
||||
isFollowingLoadedEvent: followLoaded && !autoScrollLocked,
|
||||
parkedAt,
|
||||
};
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
{
|
||||
id: 'timerColour',
|
||||
title: 'Timer Colour',
|
||||
description: 'Timer colour. Default: #f6f6f6',
|
||||
description: 'Timer colour. Default: #F6F6F6',
|
||||
type: 'colour',
|
||||
defaultValue: 'f6f6f6',
|
||||
},
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"main": "src/main.js",
|
||||
"devDependencies": {
|
||||
"electron": "38.2.1",
|
||||
"electron-builder": "26.9.1",
|
||||
"wait-on": "^7.2.0"
|
||||
"electron-builder": "26.15.3",
|
||||
"wait-on": "^9.0.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__manage&new=true'),
|
||||
click: () => redirectWindow('/editor?settings=project__create'),
|
||||
},
|
||||
{
|
||||
label: 'Load...',
|
||||
@@ -151,6 +151,7 @@ function makeViewMenu(clientUrl) {
|
||||
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
|
||||
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
|
||||
makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
|
||||
makeItemOpenInBrowser('Teleprompter', `${clientUrl}/teleprompter`),
|
||||
{ type: 'separator' },
|
||||
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
|
||||
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
|
||||
@@ -202,6 +203,18 @@ 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": "^16.0.1",
|
||||
"dotenv": "^17.0.0",
|
||||
"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.24.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"server-timing": "^3.3.3",
|
||||
"ts-essentials": "catalog:",
|
||||
"tsx": "^4.19.2",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { authenticateSocket } from '../middleware/authenticate.js';
|
||||
import { teleprompterService } from '../services/teleprompter-service/TeleprompterService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import getRandomName from '../utils/getRandomName.js';
|
||||
import type { IAdapter } from './IAdapter.js';
|
||||
@@ -43,6 +44,7 @@ class SocketServer implements IAdapter {
|
||||
|
||||
private wss: WebSocketServer | null;
|
||||
private readonly clients: Map<ClientId, Client>;
|
||||
private readonly connections: Map<ClientId, WebSocket>;
|
||||
private lastConnection: Date | null = null;
|
||||
private shouldShowWelcome = true;
|
||||
|
||||
@@ -54,6 +56,7 @@ class SocketServer implements IAdapter {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
||||
instance = this;
|
||||
this.clients = new Map<ClientId, Client>();
|
||||
this.connections = new Map<ClientId, WebSocket>();
|
||||
this.wss = null;
|
||||
}
|
||||
|
||||
@@ -83,6 +86,8 @@ class SocketServer implements IAdapter {
|
||||
origin: '',
|
||||
path: '',
|
||||
});
|
||||
this.connections.set(clientId, ws);
|
||||
teleprompterService.register(clientId);
|
||||
|
||||
this.lastConnection = new Date();
|
||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientName}`);
|
||||
@@ -98,6 +103,8 @@ class SocketServer implements IAdapter {
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(clientId);
|
||||
this.connections.delete(clientId);
|
||||
teleprompterService.remove(clientId);
|
||||
logger.info(LogOrigin.Client, `${this.clients.size} Connections with disconnected: ${clientName}`);
|
||||
this.sendClientList();
|
||||
});
|
||||
@@ -114,7 +121,9 @@ class SocketServer implements IAdapter {
|
||||
}
|
||||
case MessageTag.ClientSet: {
|
||||
const previousData = this.getOrCreateClient(clientId);
|
||||
const updatedClient = { ...previousData, ...payload };
|
||||
const { teleprompter, ...clientPatch } = payload;
|
||||
if (teleprompter) teleprompterService.report(clientId, teleprompter);
|
||||
const updatedClient = { ...previousData, ...clientPatch };
|
||||
this.clients.set(clientId, updatedClient);
|
||||
if (this.shouldShowWelcome && updatedClient.path?.toLowerCase().includes('editor')) {
|
||||
this.shouldShowWelcome = false;
|
||||
@@ -185,7 +194,11 @@ class SocketServer implements IAdapter {
|
||||
}
|
||||
|
||||
private sendClientList(): void {
|
||||
const payload = Object.fromEntries(this.clients.entries());
|
||||
const payload = Object.fromEntries(
|
||||
this.clients
|
||||
.entries()
|
||||
.map(([clientId, client]) => [clientId, { ...client, teleprompter: teleprompterService.getState(clientId) }]),
|
||||
);
|
||||
this.sendAsJson(MessageTag.ClientList, payload);
|
||||
}
|
||||
|
||||
@@ -193,6 +206,16 @@ class SocketServer implements IAdapter {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
|
||||
public sendToClient<T extends MessageTag>(
|
||||
target: ClientId,
|
||||
tag: T,
|
||||
payload: Extract<WsPacketToClient, { tag: T }>['payload'],
|
||||
) {
|
||||
const client = this.connections.get(target);
|
||||
if (!client || client.readyState !== WebSocket.OPEN) throw new Error(`Client "${target}" not found`);
|
||||
client.send(JSON.stringify({ tag, payload }));
|
||||
}
|
||||
|
||||
public renameClient(target: ClientId, name: string) {
|
||||
const previousData = this.clients.get(target);
|
||||
if (!previousData) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ApiActionTag,
|
||||
MessageTag,
|
||||
MessageState,
|
||||
OffsetMode,
|
||||
OntimeEvent,
|
||||
@@ -19,6 +20,7 @@ import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.j
|
||||
import * as messageService from '../services/message-service/message.service.js';
|
||||
import { validateMessage, validateTimerMessage } from '../services/message-service/message.utils.js';
|
||||
import { runtimeService } from '../services/runtime-service/runtime.service.js';
|
||||
import { teleprompterService } from '../services/teleprompter-service/TeleprompterService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import * as assert from '../utils/assert.js';
|
||||
import { coerceEnum } from '../utils/coerceType.js';
|
||||
@@ -307,8 +309,50 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
|
||||
runtimeService.setOffsetMode(mode);
|
||||
return { payload: 'success' };
|
||||
},
|
||||
teleprompter: (payload) => {
|
||||
assert.isObject(payload);
|
||||
const request = payload as Record<string, unknown>;
|
||||
if (
|
||||
typeof request.target !== 'string' ||
|
||||
typeof request.commandId !== 'string' ||
|
||||
request.commandId.trim().length === 0 ||
|
||||
!isTeleprompterCommand(request.command)
|
||||
) {
|
||||
throw new Error('Invalid teleprompter command');
|
||||
}
|
||||
teleprompterService.deliver(request.target, request.commandId, request.command, (target, commandId, command) => {
|
||||
socket.sendToClient(target, MessageTag.TeleprompterCommand, { commandId, command });
|
||||
});
|
||||
return { payload: { commandId: request.commandId, status: 'delivered' } };
|
||||
},
|
||||
};
|
||||
|
||||
export function isTeleprompterCommand(command: unknown): command is import('ontime-types').TeleprompterCommand {
|
||||
if (!command || typeof command !== 'object' || !('type' in command)) return false;
|
||||
switch (command.type) {
|
||||
case 'play':
|
||||
case 'pause':
|
||||
return true;
|
||||
case 'setSpeed':
|
||||
return (
|
||||
'linesPerMinute' in command &&
|
||||
typeof command.linesPerMinute === 'number' &&
|
||||
Number.isInteger(command.linesPerMinute) &&
|
||||
command.linesPerMinute >= 1 &&
|
||||
command.linesPerMinute <= 40
|
||||
);
|
||||
case 'nudge':
|
||||
return (
|
||||
'lines' in command &&
|
||||
typeof command.lines === 'number' &&
|
||||
Number.isInteger(command.lines) &&
|
||||
Math.abs(command.lines) <= 1000
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a value of type number, converting if necessary
|
||||
* Otherwise throws
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
||||
|
||||
/**
|
||||
* Wraps the app authenticate middleware with support for the Authorization header.
|
||||
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
||||
* rather than cookies or query params; any other request falls through to the
|
||||
* app middleware, keeping the behaviour of the shared middleware untouched.
|
||||
*/
|
||||
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
||||
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (hasPassword) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
return fallback(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -20,12 +20,9 @@ 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;
|
||||
|
||||
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
import { integrationRouter } from './api-integration/integration.router.js';
|
||||
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
// Services
|
||||
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
||||
|
||||
// serve static external files
|
||||
app.use(
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
|
||||
import { isPublicAssetRequest } from '../authenticate.js';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../api-data/session/session.service.js', () => ({
|
||||
hasPassword: true,
|
||||
hashedPassword: 'valid-token',
|
||||
}));
|
||||
|
||||
import { authenticateSocket, isPublicAssetRequest, makeAuthenticateMiddleware } from '../authenticate.js';
|
||||
|
||||
function makeResponse() {
|
||||
return {
|
||||
redirect: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function makeHeadersWithFailingAuthorization(cookie?: string) {
|
||||
return {
|
||||
cookie,
|
||||
get authorization(): never {
|
||||
throw new Error('Authorization header should not be read');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('isPublicAssetRequest()', () => {
|
||||
it('allows root public assets without a prefix', () => {
|
||||
@@ -18,3 +43,144 @@ describe('isPublicAssetRequest()', () => {
|
||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bearer authentication', () => {
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
next.mockClear();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for API requests', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||
headers: makeHeadersWithFailingAuthorization(),
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for redirecting routes', () => {
|
||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||
headers: makeHeadersWithFailingAuthorization(),
|
||||
originalUrl: '/external/image.png',
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('prioritises cookie authentication for WebSocket handshakes', () => {
|
||||
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
|
||||
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
|
||||
|
||||
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('authenticates API requests with a bearer token', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts case-insensitive bearer schemes and extra whitespace', () => {
|
||||
const { authenticate } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'bearer valid-token ' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
|
||||
authenticate(req, makeResponse(), next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('authenticates redirecting routes with a bearer token', () => {
|
||||
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
originalUrl: '/stage/external/image.png',
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticateAndRedirect(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authenticates WebSocket handshakes with a bearer token', () => {
|
||||
const req = {
|
||||
headers: { authorization: 'Bearer valid-token' },
|
||||
} as IncomingMessage;
|
||||
|
||||
authenticateSocket({} as never, req, next);
|
||||
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('rejects an invalid bearer token', () => {
|
||||
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: 'Bearer invalid-token' },
|
||||
query: {},
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
|
||||
authenticate(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||
|
||||
const redirectReq = { ...req, originalUrl: '/external/image.png' } as Request;
|
||||
const redirectRes = makeResponse();
|
||||
authenticateAndRedirect(redirectReq, redirectRes, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(redirectRes.redirect).toHaveBeenCalledWith('/login?redirect=/external/image.png');
|
||||
|
||||
const socketNext = vi.fn();
|
||||
authenticateSocket(
|
||||
{} as never,
|
||||
{ headers: { authorization: 'Bearer invalid-token' } } as IncomingMessage,
|
||||
socketNext,
|
||||
);
|
||||
|
||||
expect(socketNext).toHaveBeenCalledOnce();
|
||||
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
||||
});
|
||||
|
||||
it.each(['/socket?not_token=valid-token', '/socket?token=valid-token-suffix'])(
|
||||
'rejects lookalike WebSocket query tokens in %s',
|
||||
(url) => {
|
||||
const socketNext = vi.fn();
|
||||
|
||||
authenticateSocket({} as never, { headers: { host: 'localhost' }, url } as IncomingMessage, socketNext);
|
||||
|
||||
expect(socketNext).toHaveBeenCalledOnce();
|
||||
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,17 +77,16 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
||||
const loginRedirectBase = `${prefix}/login?redirect=`;
|
||||
|
||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.query.token) {
|
||||
if (req.query.token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.cookies?.token) {
|
||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||
if (tokenFromCookie === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(401).send('Unauthorized');
|
||||
@@ -105,17 +104,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// we expect the token to be in the cookies
|
||||
if (req.cookies?.token) {
|
||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
||||
if (tokenFromCookie === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// we use query params for generating authenticated URLs and for clients like the companion module
|
||||
// if the user gives is a token in the query params, we set the cookie to be used in further requests
|
||||
if (req.query.token === hashedPassword) {
|
||||
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||
if (hashedPassword !== undefined) {
|
||||
setSessionCookie(res, hashedPassword, prefix);
|
||||
}
|
||||
@@ -136,33 +135,16 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
||||
return next();
|
||||
}
|
||||
|
||||
// check if the token is in the cookie
|
||||
const cookieString = req.headers.cookie;
|
||||
if (typeof cookieString === 'string') {
|
||||
const cookies = parseCookie(cookieString);
|
||||
if (cookies.token) {
|
||||
const token = getTokenFromCookie(cookies.token);
|
||||
if (token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if token is in the params - simple string check first
|
||||
const urlString = req.url || '';
|
||||
if (urlString.includes(`token=${hashedPassword}`)) {
|
||||
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// fallback to full URL parsing for other formats
|
||||
try {
|
||||
const url = new URL(urlString, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
if (token === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore URL parsing errors
|
||||
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return next(new Error('Unauthorized'));
|
||||
@@ -181,19 +163,18 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When calling this function we already know a cookie called 'token' exists
|
||||
* And want to extract its value
|
||||
*/
|
||||
function getTokenFromCookie(cookieContents: string): string | undefined {
|
||||
// Fast path: check if the hashed password is directly in the cookie string
|
||||
// This avoids JSON parsing for the common case
|
||||
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
||||
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
|
||||
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
|
||||
if (typeof cookieContents !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fast path: avoid JSON parsing when the expected token can be found directly
|
||||
const cookieTokenString = '"token":"' + hashedPassword + '"';
|
||||
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
// Fallback to JSON parsing for other cases or validation
|
||||
try {
|
||||
const cookie = JSON.parse(cookieContents);
|
||||
if (cookie && typeof cookie.token === 'string') {
|
||||
@@ -203,3 +184,19 @@ function getTokenFromCookie(cookieContents: string): string | undefined {
|
||||
// no error handling to do here
|
||||
}
|
||||
}
|
||||
|
||||
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
|
||||
return authorization?.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
|
||||
}
|
||||
|
||||
function getTokenFromParams(params: string | Record<string, unknown> | undefined, host?: string): string | undefined {
|
||||
if (typeof params !== 'string') {
|
||||
return typeof params?.token === 'string' ? params.token : undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
|
||||
} catch (_) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { TeleprompterCommand, TeleprompterControlState } from 'ontime-types';
|
||||
|
||||
type ClientId = string;
|
||||
type DeliverCommand = (target: ClientId, commandId: string, command: TeleprompterCommand) => void;
|
||||
|
||||
/**
|
||||
* Ephemeral state for connected teleprompter views. The view reports observed
|
||||
* transport state; this service never attempts to own document geometry or
|
||||
* replay a relative nudge after a reconnect.
|
||||
*/
|
||||
export class TeleprompterService {
|
||||
private readonly states = new Map<ClientId, TeleprompterControlState>();
|
||||
private readonly commandReceipts = new Map<ClientId, Map<string, string>>();
|
||||
|
||||
register(clientId: ClientId) {
|
||||
this.commandReceipts.set(clientId, new Map());
|
||||
}
|
||||
|
||||
remove(clientId: ClientId) {
|
||||
this.states.delete(clientId);
|
||||
this.commandReceipts.delete(clientId);
|
||||
}
|
||||
|
||||
report(clientId: ClientId, state: TeleprompterControlState) {
|
||||
this.states.set(clientId, state);
|
||||
}
|
||||
|
||||
getState(clientId: ClientId) {
|
||||
return this.states.get(clientId);
|
||||
}
|
||||
|
||||
deliver(clientId: ClientId, commandId: string, command: TeleprompterCommand, deliver: DeliverCommand) {
|
||||
const receipts = this.commandReceipts.get(clientId);
|
||||
if (!receipts) throw new Error(`Client "${clientId}" not found`);
|
||||
|
||||
const serialized = JSON.stringify(command);
|
||||
const previous = receipts.get(commandId);
|
||||
if (previous === serialized) return;
|
||||
if (previous !== undefined)
|
||||
throw new Error(`Teleprompter command id "${commandId}" was reused with different data`);
|
||||
|
||||
deliver(clientId, commandId, command);
|
||||
receipts.set(commandId, serialized);
|
||||
if (receipts.size > 100) receipts.delete(receipts.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
|
||||
export const teleprompterService = new TeleprompterService();
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TeleprompterService } from '../TeleprompterService.js';
|
||||
|
||||
describe('TeleprompterService', () => {
|
||||
test('keeps reported state and makes retries idempotent', () => {
|
||||
const service = new TeleprompterService();
|
||||
const deliver = vi.fn();
|
||||
service.register('view');
|
||||
service.report('view', {
|
||||
mode: 'controlled',
|
||||
playback: 'paused',
|
||||
speed: 14,
|
||||
isFollowingLoadedEvent: true,
|
||||
parkedAt: null,
|
||||
});
|
||||
|
||||
service.deliver('view', 'command-1', { type: 'nudge', lines: 1 }, deliver);
|
||||
service.deliver('view', 'command-1', { type: 'nudge', lines: 1 }, deliver);
|
||||
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
expect(service.getState('view')?.mode).toBe('controlled');
|
||||
expect(() => service.deliver('view', 'command-1', { type: 'nudge', lines: 2 }, deliver)).toThrow('reused');
|
||||
});
|
||||
});
|
||||
@@ -9,45 +9,93 @@ test.describe('test view navigation feature', () => {
|
||||
test('Timeline', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Timeline' }).click();
|
||||
page.locator('data-testid=timeline-view');
|
||||
await expect(page.getByTestId('timeline-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/timeline');
|
||||
});
|
||||
|
||||
test('Backstage', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Backstage' }).click();
|
||||
page.locator('data-testid=backstage-view');
|
||||
await expect(page.getByTestId('backstage-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/backstage');
|
||||
});
|
||||
|
||||
test('Studio Clock', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Studio Clock' }).click();
|
||||
page.locator('data-testid=studio-view');
|
||||
await expect(page.getByTestId('studio-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/studio');
|
||||
});
|
||||
|
||||
test('Countdown', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Countdown' }).click();
|
||||
page.locator('data-testid=countdown-view');
|
||||
await expect(page.getByTestId('countdown-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/countdown');
|
||||
});
|
||||
|
||||
test('Project Info', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Project Info' }).click();
|
||||
page.locator('data-testid=project-view');
|
||||
await expect(page.getByTestId('project-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/info');
|
||||
});
|
||||
|
||||
test('Timer', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Timer', exact: true }).click();
|
||||
page.locator('data-testid=timer-view');
|
||||
await expect(page.getByTestId('timer-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/timer');
|
||||
});
|
||||
|
||||
test('Teleprompter', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Teleprompter' }).click();
|
||||
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/teleprompter');
|
||||
});
|
||||
|
||||
/**
|
||||
* The shortcut has to read the current open state, not the one captured when it was
|
||||
* registered, otherwise it only ever opens the menu.
|
||||
*/
|
||||
test('Space closes the menu as well as opening it', async ({ page }) => {
|
||||
const menu = page.getByRole('dialog');
|
||||
await expect(menu).toBeHidden();
|
||||
|
||||
await openNavigationMenu(page);
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('Space closes a menu opened with the button', async ({ page }) => {
|
||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||
await page.getByTestId('navigation__toggle-menu').click();
|
||||
|
||||
const menu = page.getByRole('dialog');
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await expect(menu).toBeHidden();
|
||||
});
|
||||
|
||||
test('Space toggles a focused menu switch without closing the menu', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
|
||||
const menu = page.getByRole('dialog');
|
||||
const flipScreen = page.getByRole('switch', { name: 'Flip Screen' });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const initiallyChecked = await flipScreen.getAttribute('aria-checked');
|
||||
await flipScreen.focus();
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
await expect(flipScreen).toHaveAttribute('aria-checked', initiallyChecked === 'true' ? 'false' : 'true');
|
||||
await expect(menu).toBeVisible();
|
||||
});
|
||||
|
||||
test('not-found', async ({ page }) => {
|
||||
await page.goto('/not-found');
|
||||
|
||||
|
||||
@@ -6,6 +6,25 @@ test('cuesheet displays events', async ({ page }) => {
|
||||
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('cuesheet persists column visibility', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
const noteHeader = page.getByRole('columnheader', { name: 'Note', exact: true });
|
||||
const noteCell = page.getByTestId('cuesheet-event').first().getByTestId('cuesheet-cell-note');
|
||||
await expect(noteHeader).toBeVisible();
|
||||
await expect(noteCell).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Columns' }).click();
|
||||
await page.getByRole('checkbox', { name: 'Note', exact: true }).click();
|
||||
|
||||
await expect(noteHeader).toBeHidden();
|
||||
await expect(noteCell).toBeHidden();
|
||||
|
||||
await page.reload();
|
||||
await expect(noteHeader).toBeHidden();
|
||||
await expect(noteCell).toBeHidden();
|
||||
});
|
||||
|
||||
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ test.describe('URL Preset', () => {
|
||||
.filter({ hasText: /^testingApply$/ })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
await expect(page.getByRole('button', { name: 'Applied' })).toBeVisible();
|
||||
await expect(page.getByText('Current')).toBeVisible();
|
||||
|
||||
// 2. the URL contains the preset
|
||||
expect(page.url().includes('hideTimerSeconds=true')).toBeTruthy();
|
||||
|
||||
@@ -13,3 +13,50 @@ test('View params configures timer view', async ({ page }) => {
|
||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||
});
|
||||
|
||||
/**
|
||||
* The form gathers its values from the DOM, so a collapsed section has to stay mounted.
|
||||
* Unmounting it would quietly drop everything the user set in it on the next apply.
|
||||
*/
|
||||
test('View params keeps the values of collapsed sections', async ({ page }) => {
|
||||
// hideClock lives in the section we are about to collapse
|
||||
await page.goto('/timer?hideClock=true');
|
||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||
|
||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||
await page.getByTestId('navigation__toggle-settings').click();
|
||||
|
||||
const section = page.getByRole('button', { name: /Element visibility/ });
|
||||
await expect(section).toHaveAttribute('aria-expanded', 'true');
|
||||
await section.focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(section).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(page.getByRole('dialog', { name: 'Ontime' })).toBeHidden();
|
||||
|
||||
await page.getByTestId('apply-view-params').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||
});
|
||||
|
||||
test('View params configures teleprompter view', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note');
|
||||
|
||||
const readingMarker = page.locator('.teleprompter__reading-marker');
|
||||
await expect(readingMarker).toBeVisible();
|
||||
|
||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||
await page.getByTestId('navigation__toggle-settings').click();
|
||||
|
||||
const readingLineSwitch = page.locator('label:has(input[name="readingLine"]) [role="switch"]');
|
||||
await expect(readingLineSwitch).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await readingLineSwitch.click();
|
||||
await page.getByTestId('apply-view-params').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*readingLine=false/);
|
||||
await expect(readingMarker).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
/** The heading of the event the reading line is currently over. */
|
||||
function eventUnderReadingLine(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const line = document.querySelector('.teleprompter__reading-line')?.getBoundingClientRect();
|
||||
if (!line) throw new Error('Reading line not found');
|
||||
const element = document.elementFromPoint(window.innerWidth / 2, line.top + line.height / 2);
|
||||
return element?.closest('.teleprompter__block')?.querySelector('.teleprompter__heading')?.textContent ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
test('teleprompter renders and responds to its primary controls', async ({ page, request }) => {
|
||||
// Earlier feature specs edit the loaded rundown. Restore the real demo project
|
||||
// instead of manufacturing test-only events: its notes are the script fixture.
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note&flipV=true');
|
||||
|
||||
const view = page.getByTestId('teleprompter-view');
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
const speed = page.getByTestId('teleprompter-speed');
|
||||
|
||||
await expect(view).toBeVisible();
|
||||
await expect(scroller).toBeVisible();
|
||||
await expect(page.getByText('Music plays, holding slide on screens')).toBeVisible();
|
||||
await expect(speed).toContainText('14');
|
||||
await expect(view).toHaveCSS('transform', /^matrix\(1, 0, 0, -1/);
|
||||
|
||||
// Following uses layout coordinates, which must remain stable when the view's
|
||||
// visual coordinates are inverted by a vertical transform.
|
||||
const loadedBlock = page.locator('.teleprompter__block[data-loaded]');
|
||||
await expect(loadedBlock).toBeAttached();
|
||||
const expectedScrollTop = await loadedBlock.evaluate((element) => {
|
||||
const scroller = element.closest('.teleprompter')?.querySelector<HTMLElement>('.teleprompter__scroller');
|
||||
if (!scroller) throw new Error('Teleprompter scroller not found');
|
||||
return Math.max(0, (element as HTMLElement).offsetTop - scroller.clientHeight * 0.25);
|
||||
});
|
||||
await expect
|
||||
.poll(async () => Math.abs((await scroller.evaluate((element) => element.scrollTop)) - expectedScrollTop))
|
||||
.toBeLessThan(2);
|
||||
|
||||
await page.goto('/teleprompter?script=note');
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('f');
|
||||
await expect(speed).toContainText('15');
|
||||
await expect(page).toHaveURL(/speed=15/);
|
||||
await expect(page).toHaveURL(/flipH=true/);
|
||||
await expect(view).toHaveCSS('transform', /^matrix\(-1/);
|
||||
});
|
||||
|
||||
test('an edit above the reader leaves the same text under the reading line', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
// following moves the reader for its own reasons; this is about the document
|
||||
// changing underneath a position the reader chose
|
||||
await page.goto('/teleprompter?script=note&followLoaded=false');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
// park the reading line inside a block rather than at a fraction of the
|
||||
// document, whose tail is a screen of padding below the last event
|
||||
await scroller.evaluate((element) => {
|
||||
const blocks = element.querySelectorAll<HTMLElement>('.teleprompter__block');
|
||||
const target = blocks[Math.floor(blocks.length / 2)];
|
||||
element.scrollTop = target.offsetTop + 10 - element.clientHeight * 0.25;
|
||||
});
|
||||
const before = await eventUnderReadingLine(page);
|
||||
expect(before).not.toBeNull();
|
||||
const scrollBefore = await scroller.evaluate((element) => element.scrollTop);
|
||||
|
||||
// grow the first event's script, which sits above wherever we scrolled to
|
||||
const edit = await request.put('/data/rundowns/default/entry', {
|
||||
data: { id: '9bf60f', note: `Music plays, holding slide on screens\n${'Another line of script. '.repeat(120)}` },
|
||||
});
|
||||
expect(edit.ok()).toBe(true);
|
||||
|
||||
// the document grew, so holding position means the offset had to change
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(scrollBefore);
|
||||
expect(await eventUnderReadingLine(page)).toBe(before);
|
||||
});
|
||||
|
||||
test('shift and the vertical arrows walk the reader event by event', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note&followLoaded=false');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
const headings = await page.locator('.teleprompter__heading').allTextContents();
|
||||
expect(headings.length).toBeGreaterThan(2);
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[0]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[2]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowUp');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
|
||||
});
|
||||
|
||||
test('playback stops at the end of the event instead of reading on into the next', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note&followLoaded=false&speed=40');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
// park the reading line just short of the first event's end, so the run to
|
||||
// the boundary takes a moment rather than the length of the segment
|
||||
const segmentEnd = await scroller.evaluate((element) => {
|
||||
const block = element.querySelector<HTMLElement>('.teleprompter__block');
|
||||
if (!block) throw new Error('No script block found');
|
||||
const end = block.offsetTop + block.offsetHeight - element.clientHeight * 0.25;
|
||||
element.scrollTop = end - 30;
|
||||
return end;
|
||||
});
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
await expect(page.getByTestId('teleprompter-parked')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => Math.abs((await scroller.evaluate((element) => element.scrollTop)) - segmentEnd))
|
||||
.toBeLessThan(3);
|
||||
|
||||
// and it stays there, rather than carrying on after a beat
|
||||
await page.waitForTimeout(500);
|
||||
expect(Math.abs((await scroller.evaluate((element) => element.scrollTop)) - segmentEnd)).toBeLessThan(3);
|
||||
|
||||
// pressing play again is how the reader moves on to the next event
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(segmentEnd + 5);
|
||||
});
|
||||
|
||||
test('onlyPlaying narrows the script to the event being played', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note');
|
||||
await expect(page.locator('.teleprompter__block').first()).toBeVisible();
|
||||
const whole = await page.locator('.teleprompter__block').count();
|
||||
expect(whole).toBeGreaterThan(1);
|
||||
|
||||
await page.goto('/teleprompter?script=note&onlyPlaying=true');
|
||||
|
||||
const blocks = page.locator('.teleprompter__block');
|
||||
await expect(blocks).toHaveCount(1);
|
||||
await expect(blocks.first()).toHaveAttribute('data-loaded', 'true');
|
||||
});
|
||||
|
||||
test('follow tolerates a small scroll and breaks on a real one, like the operator view', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
const follow = page.getByTestId('teleprompter-follow');
|
||||
await expect(scroller).toBeVisible();
|
||||
await expect(follow).toBeDisabled();
|
||||
|
||||
await page.mouse.move(960, 500);
|
||||
await page.mouse.wheel(0, 15);
|
||||
await expect(follow).toBeDisabled();
|
||||
|
||||
await page.mouse.wheel(0, 400);
|
||||
await expect(follow).toBeEnabled();
|
||||
|
||||
await page.keyboard.press('l');
|
||||
await expect(follow).toBeDisabled();
|
||||
});
|
||||
+5
-5
@@ -41,14 +41,14 @@
|
||||
"format:check": "oxfmt --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.60.0",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/node": "catalog:",
|
||||
"cross-env": "^7.0.3",
|
||||
"oxfmt": "^0.42.0",
|
||||
"oxlint": "^1.57.0",
|
||||
"oxlint-tsgolint": "^0.17.4",
|
||||
"oxfmt": "^0.63.0",
|
||||
"oxlint": "^1.78.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"rimraf": "catalog:",
|
||||
"turbo": "2.8.20",
|
||||
"turbo": "2.10.10",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||
|
||||
@@ -6,6 +6,17 @@ import type { MessageState } from '../../definitions/runtime/MessageControl.type
|
||||
import type { OffsetMode } from '../../definitions/runtime/Offset.type.js';
|
||||
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
|
||||
|
||||
export type TeleprompterCommand =
|
||||
| { type: 'play' }
|
||||
| { type: 'pause' }
|
||||
| { type: 'setSpeed'; linesPerMinute: number }
|
||||
| { type: 'nudge'; lines: number };
|
||||
export type TeleprompterAction = {
|
||||
tag: 'teleprompter';
|
||||
payload: { target: string; commandId: string; command: TeleprompterCommand };
|
||||
};
|
||||
export type TeleprompterResponse = { tag: 'teleprompter'; payload: { commandId: string; status: 'delivered' } };
|
||||
|
||||
export type VersionAction = {
|
||||
tag: 'version';
|
||||
payload: undefined;
|
||||
@@ -156,7 +167,8 @@ export type ApiAction =
|
||||
| AddtimeAction
|
||||
| AuxtimerAction
|
||||
| ClientAction
|
||||
| OffsetmodeAction;
|
||||
| OffsetmodeAction
|
||||
| TeleprompterAction;
|
||||
|
||||
export type ApiResponse =
|
||||
| VersionResponse
|
||||
@@ -172,6 +184,7 @@ export type ApiResponse =
|
||||
| AddtimeResponse
|
||||
| AuxtimerResponse
|
||||
| ClientResponse
|
||||
| OffsetmodeResponse;
|
||||
| OffsetmodeResponse
|
||||
| TeleprompterResponse;
|
||||
|
||||
export type ApiActionTag = ApiAction['tag'];
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Client } from '../../definitions/Clients.type.js';
|
||||
import type { Log } from '../../definitions/runtime/Logger.type.js';
|
||||
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
|
||||
import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||
import type { TeleprompterCommand } from './api.type.js';
|
||||
import type { RefetchKey } from './refetch.type.js';
|
||||
|
||||
export enum MessageTag {
|
||||
@@ -17,6 +18,7 @@ export enum MessageTag {
|
||||
Log = 'log',
|
||||
RuntimeData = 'runtime-data',
|
||||
Refetch = 'refetch',
|
||||
TeleprompterCommand = 'teleprompter-command',
|
||||
}
|
||||
|
||||
// CLIENT TO SERVER
|
||||
@@ -35,6 +37,10 @@ type ListClientPacket = {
|
||||
payload: Record<string, Client>;
|
||||
};
|
||||
type RuntimePacket = { tag: MessageTag.RuntimeData; payload: Partial<RuntimeStore> };
|
||||
type TeleprompterCommandPacket = {
|
||||
tag: MessageTag.TeleprompterCommand;
|
||||
payload: { commandId: string; command: TeleprompterCommand };
|
||||
};
|
||||
|
||||
type RefetchPacket = {
|
||||
tag: MessageTag.Refetch;
|
||||
@@ -58,4 +64,5 @@ export type WsPacketToClient =
|
||||
| LogPacket
|
||||
| ListClientPacket
|
||||
| RuntimePacket
|
||||
| RefetchPacket;
|
||||
| RefetchPacket
|
||||
| TeleprompterCommandPacket;
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
export type ClientType = 'unknown' | 'ontime' | string;
|
||||
|
||||
export type TeleprompterControlMode = 'free' | 'controlled';
|
||||
export type TeleprompterPlayback = 'playing' | 'paused';
|
||||
export type TeleprompterParkedAt = 'segment' | 'script' | null;
|
||||
export type TeleprompterControlState = {
|
||||
mode: TeleprompterControlMode;
|
||||
playback: TeleprompterPlayback;
|
||||
speed: number;
|
||||
isFollowingLoadedEvent: boolean;
|
||||
parkedAt: TeleprompterParkedAt;
|
||||
};
|
||||
|
||||
export type Client = {
|
||||
name: string;
|
||||
type: ClientType;
|
||||
identify: boolean;
|
||||
origin: string;
|
||||
path: string;
|
||||
teleprompter?: TeleprompterControlState;
|
||||
};
|
||||
|
||||
export type ClientList = Record<string, Client>;
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum OntimeView {
|
||||
StudioClock = 'studio',
|
||||
Countdown = 'countdown',
|
||||
ProjectInfo = 'info',
|
||||
Teleprompter = 'teleprompter',
|
||||
}
|
||||
|
||||
export type OntimeViewPresettable = Exclude<OntimeView, OntimeView.Editor>;
|
||||
|
||||
@@ -107,7 +107,7 @@ export type {
|
||||
export { MessageTag } from './api/websocket/data.type.js';
|
||||
export type { WsPacketToServer, WsPacketToClient } from './api/websocket/data.type.js';
|
||||
export { RefetchKey } from './api/websocket/refetch.type.js';
|
||||
export type { ApiAction, ApiActionTag, ApiResponse } from './api/websocket/api.type.js';
|
||||
export type { ApiAction, ApiActionTag, ApiResponse, TeleprompterCommand } from './api/websocket/api.type.js';
|
||||
// SERVER RUNTIME
|
||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||
export { Playback } from './definitions/runtime/Playback.type.js';
|
||||
@@ -125,7 +125,15 @@ export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.ty
|
||||
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
||||
|
||||
// CLIENT
|
||||
export type { Client, ClientList, ClientType } from './definitions/Clients.type.js';
|
||||
export type {
|
||||
Client,
|
||||
ClientList,
|
||||
ClientType,
|
||||
TeleprompterControlMode,
|
||||
TeleprompterControlState,
|
||||
TeleprompterParkedAt,
|
||||
TeleprompterPlayback,
|
||||
} from './definitions/Clients.type.js';
|
||||
|
||||
// TYPE UTILITIES
|
||||
export {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"nanoid": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"ontime-types": "workspace:*",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
Generated
+1152
-2116
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user