Compare commits

..

1 Commits

Author SHA1 Message Date
Claude e9de1ec19f fix: prevent white background flash and overscroll on Safari iOS
- Set background-color: #101010 on html/body/.App so the Safari iOS
  rubber-band overscroll area is dark instead of white
- Always render body background style in ViewLoader (not just during
  Suspense fallback) so keyColour param and --background-color-override
  propagate to the body for the overscroll region
- Add viewport-fit=cover, apple-mobile-web-app meta tags for proper
  iOS notch/safe-area handling
- Fix manifest.json background_color from #ffffff to #101010
- Align theme_color across both manifests to #101010

https://claude.ai/code/session_016z5vU45pexTdkQV2eHJqh4
2026-05-09 16:35:53 +00:00
52 changed files with 910 additions and 1873 deletions
+2 -2
View File
@@ -20,13 +20,13 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+2 -2
View File
@@ -15,13 +15,13 @@ jobs:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+4 -4
View File
@@ -17,13 +17,13 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -74,7 +74,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -111,7 +111,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
+2 -2
View File
@@ -18,13 +18,13 @@ jobs:
node-version-file: '.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
+5 -5
View File
@@ -16,13 +16,13 @@ jobs:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -55,13 +55,13 @@ jobs:
- uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v4
- name: Get pnpm store directory
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
@@ -74,7 +74,7 @@ jobs:
- name: Get installed Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
+1 -1
View File
@@ -1 +1 @@
v22.22.2
v22.15.1
+1 -1
View File
@@ -1,4 +1,4 @@
ARG NODE_VERSION=22.22.2
ARG NODE_VERSION=22.15.1
FROM node:${NODE_VERSION}-alpine
# Set environment variables
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.8.0",
"version": "4.7.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -8
View File
@@ -6,8 +6,9 @@
<link rel="icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101010" />
<meta name="ontime" content="ontime - time keeping for live events" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="ontime" content="ontime - time keeping for live events" />
<link rel="apple-touch-icon" href="ontime-logo.png" />
<link rel="icon" type="image/png" href="ontime-logo.png" />
<link rel="manifest" href="site.webmanifest" />
@@ -18,13 +19,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script>
if (/\/timer\/?$/.test(window.location.pathname) && typeof Promise.any !== 'function') {
var b = document.querySelector('base');
var base = b ? (b.getAttribute('href') || '').replace(/\/$/, '') : '';
window.location.replace(base + '/timer-legacy' + window.location.search);
}
</script>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.8.0",
"version": "4.7.0",
"private": true,
"type": "module",
"dependencies": {
@@ -32,6 +32,7 @@
"react-icons": "5.5.0",
"react-router": "^7.11.0",
"react-virtuoso": "^4.17.0",
"web-vitals": "^5.1.0",
"zustand": "^5.0.9"
},
"scripts": {
@@ -50,8 +51,7 @@
"production": [
">0.2%",
"not dead",
"not op_mini all",
"safari >= 14.1"
"not op_mini all"
],
"development": [
"last 1 chrome version",
@@ -64,10 +64,8 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-legacy": "^8.0.1",
"@vitejs/plugin-react": "5.2.0",
"babel-plugin-react-compiler": "1.0.0",
"happy-dom": "^20.9.0",
"ontime-types": "workspace:*",
"ontime-utils": "workspace:*",
"sass": "^1.57.1",
@@ -75,6 +73,7 @@
"vite": "8.0.1",
"vite-plugin-compression2": "2.5.1",
"vite-plugin-svgr": "4.5.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "catalog:"
}
}
+1 -1
View File
@@ -14,6 +14,6 @@
"scope": "./",
"start_url": "./",
"display": "",
"theme_color": "#121212",
"theme_color": "#101010",
"background_color": "#101010"
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "",
"short_name": "",
"icons": [{ "src": "ontime-logo.png", "sizes": "295x295", "type": "image/png" }],
"theme_color": "#121212",
"theme_color": "#101010",
"background_color": "#101010",
"display": "standalone"
}
@@ -40,7 +40,6 @@ export default function useRundown() {
queryClient.setQueryData(getRundownQueryKey(data.id), data);
}, [data, loadedRundownId, queryClient]);
// Once we have the ID, drop the temporary current cache
useEffect(() => {
if (!loadedRundownId) return;
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
@@ -47,11 +47,6 @@ describe('getRouteFromPreset()', () => {
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('returns null when already on a preset path', () => {
const location = resolvePath('/preset/demopage');
expect(getRouteFromPreset(location, presets)).toBeNull();
});
it('returns null if the current location is the exact match of an unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
+28 -21
View File
@@ -3,7 +3,9 @@ import {
Log,
MaybeNumber,
MessageTag,
ProjectRundownsList,
RefetchKey,
Rundown,
RuntimeStore,
WsPacketToClient,
WsPacketToServer,
@@ -15,16 +17,15 @@ import {
CLIENT_LIST,
CSS_OVERRIDE,
CUSTOM_FIELDS,
CURRENT_RUNDOWN_QUERY_KEY,
PROJECT_DATA,
CURRENT_RUNDOWN_QUERY_KEY,
PROJECT_RUNDOWNS,
REPORT,
RUNDOWN,
RUNTIME,
TRANSLATION,
URL_PRESETS,
VIEW_SETTINGS,
getRundownQueryKey,
PROJECT_RUNDOWNS,
} from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
@@ -180,7 +181,7 @@ export const connectSocket = () => {
}
case MessageTag.Refetch: {
// the refetch message signals that the rundown has changed in the server side
const { target, revision, rundownId } = payload;
const { target, revision } = payload;
switch (target) {
case RefetchKey.All:
invalidateAllCaches();
@@ -195,7 +196,7 @@ export const connectSocket = () => {
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
break;
case RefetchKey.Rundown: {
maybeInvalidateRundownCache(revision, rundownId);
maybeInvalidateRundownCache(revision);
break;
}
case RefetchKey.UrlPresets:
@@ -231,28 +232,34 @@ export const connectSocket = () => {
};
};
export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: string) {
if (!rundownId) {
// we omit rundownId to signify invalidate all rundowns
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
/**
* When we receive a refetch message for the rundown
* check which rundown needs to be invalidated
*/
export function maybeInvalidateRundownCache(revision: MaybeNumber) {
const loadedRundownId: string | undefined = (ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS) as ProjectRundownsList)
?.loaded;
const activeRundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
const cachedRundown = ontimeQueryClient.getQueryData<Rundown>(activeRundownQueryKey);
if (revision === cachedRundown?.revision) {
return;
}
// skip if we dont recognise the ID the revision is lower
const queryKey = getRundownQueryKey(rundownId);
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
if (revision !== null && revision === cachedRundown?.revision) {
return;
}
ontimeQueryClient.invalidateQueries({ queryKey: activeRundownQueryKey, exact: true });
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
// keep current alias in sync with the ID-based cache
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
if (!loadedRundownId || loadedRundownId === rundownId) {
if (loadedRundownId) {
// Keep bootstrap alias in sync with the ID-based cache
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
} else {
// During bootstrap, loadedRundownId is not yet known.
// Invalidate any ID-based rundown caches that may have been seeded early.
ontimeQueryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'rundown' && query.queryKey[1] !== 'current',
});
}
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
}
export function sendSocket<T extends MessageTag | ApiActionTag>(
+3 -3
View File
@@ -204,7 +204,7 @@ function isPresettableView(view: string): view is OntimeViewPresettable {
/**
* Check if current location is a preset path
*/
function isPresetPath(location: Path): boolean {
const firstSegment = location.pathname.split('/').find(Boolean);
return firstSegment === 'preset';
export function isPresetPath(location: Path): boolean {
const segments = location.pathname.split('/').filter(Boolean);
return segments[0] === 'preset';
}
-1
View File
@@ -9,7 +9,6 @@ export const apiRepoLatest = 'https://api.github.com/repos/cpvalente/ontime/rele
export const websiteUrl = 'https://www.getontime.no';
export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const subredditUrl = 'https://www.reddit.com/r/ontimeapp/';
export const youtubeUrl = 'https://www.youtube.com/@ontimeapp';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
@@ -53,6 +53,10 @@ ul {
background-color: $red-400;
}
}
&.split {
margin-top: 1rem;
}
}
.secondary {
@@ -42,9 +42,16 @@ interface PanelListItemProps {
location?: string;
}
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
function PanelListItem(props: PanelListItemProps) {
const { panel, isSelected, location } = props;
const { setLocation } = useAppSettingsNavigation();
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
const classes = cx([
style.primary,
isSelected && style.active,
panel.split && style.split,
panel.highlight && style.highlight,
]);
return (
<Fragment key={panel.id}>
@@ -7,7 +7,6 @@ import {
githubUrl,
subredditUrl,
websiteUrl,
youtubeUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
@@ -39,7 +38,6 @@ export default function AboutPanel() {
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
<ExternalLink href={youtubeUrl}>YouTube channel</ExternalLink>
<ExternalLink href={subredditUrl}>Subreddit</ExternalLink>
</Panel.Section>
</>
@@ -89,9 +89,10 @@ export default function AutomationSettingsForm({
<Panel.Section>
<Info>
<span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<p>Control Ontime and share its data with external systems in your workflow.</p>
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
<br />
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
@@ -57,11 +57,11 @@ export default function CustomFieldSettings() {
<Panel.Divider />
<Panel.Section>
<Info>
<span>Custom fields allow for additional information to be added to an event.</span>
<span>
To use custom fields as a data source in an{' '}
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
</span>
Custom fields allow for additional information to be added to an event.
<br />
<br />
To use custom fields as a data source in an{' '}
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
</Info>
</Panel.Section>
@@ -52,9 +52,8 @@ export default function CustomViews() {
<Panel.Section>
<Info>
<span>
Upload one <strong>index.html</strong> per view to <strong>/external/&lt;name&gt;/</strong>.
</span>
Upload one <strong>index.html</strong> per view to <strong>/external/&lt;name&gt;/</strong>.
<br />
External imports are not allowed, include all assets inside the html file.
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
</Info>
@@ -20,5 +20,4 @@
border: 1px solid $gray-1100;
border-radius: 3px;
overflow: hidden;
height: 100%;
}
@@ -7,6 +7,7 @@ export type SettingsOption = {
id: string;
label: string;
secondary?: Readonly<SettingsOption[]>;
split?: boolean;
highlight?: string;
};
@@ -25,6 +26,7 @@ const staticOptions = [
{
id: 'project',
label: 'Project',
split: true,
secondary: [
{ id: 'project__create', label: 'Create...' },
{ id: 'project__list', label: 'Manage projects' },
@@ -44,6 +46,7 @@ const staticOptions = [
{
id: 'automation',
label: 'Automation',
split: true,
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__automations', label: 'Manage automations' },
@@ -53,6 +56,7 @@ const staticOptions = [
{
id: 'sharing',
label: 'Sharing and reporting',
split: true,
secondary: [
{ id: 'sharing__presets', label: 'URL Presets' },
{
@@ -65,6 +69,7 @@ const staticOptions = [
{
id: 'network',
label: 'Network',
split: true,
secondary: [
{
id: 'network__log',
@@ -79,10 +84,12 @@ const staticOptions = [
{
id: 'about',
label: 'About',
split: true,
},
{
id: 'shutdown',
label: 'Shutdown',
split: true,
},
] as const;
@@ -4,6 +4,18 @@
gap: 0.75rem;
}
.section,
.formSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sectionTitle {
font-size: $aux-text-size;
color: $label-gray;
}
.triggerList {
display: flex;
flex-direction: column;
@@ -13,13 +25,25 @@
overflow: hidden;
}
.triggerHeader {
display: grid;
grid-template-columns: 8rem 1fr 2rem;
.triggerForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
font-size: $aux-text-size;
color: $label-gray;
}
.formFields {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
align-items: end;
}
.formActions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
min-height: 2rem;
}
.trigger {
@@ -27,22 +51,40 @@
display: grid;
grid-template-columns: 8rem 1fr 2rem;
align-items: center;
gap: 0.5rem;
min-height: 2.5rem;
&:not(:first-child) {
border-top: 1px solid $white-10;
}
&[data-duplicate] {
> button:not(:last-child) {
border-color: $orange-500;
}
}
}
.duplicateMessage {
padding-left: 0.75rem;
.triggerMeta {
min-width: 0;
}
.metaLabel {
color: $label-gray;
font-size: $aux-text-size;
color: $orange-500;
line-height: 1.2;
margin-bottom: 0.125rem;
}
.automationTitle {
overflow-wrap: anywhere;
}
.validationError,
.validationSuccess {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: $aux-text-size;
}
.validationError {
color: $red-500;
}
.validationSuccess {
color: $green-500;
}
@@ -1,9 +1,11 @@
import { Trigger } from 'ontime-types';
import { NormalisedAutomation, TimerLifeCycle, Trigger, timerLifecycleValues } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { IoAdd, IoTrash } from 'react-icons/io5';
import { Fragment, useCallback, useMemo, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Info from '../../../../common/components/info/Info';
import Select from '../../../../common/components/select/Select';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
@@ -19,108 +21,174 @@ interface EventEditorTriggersProps {
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const { data: automationSettings, status: automationStatus } = useAutomationSettings();
const { updateEntry } = useEntryActionsContext();
const automationsEnabled = automationStatus === 'pending' ? undefined : automationSettings.enabledAutomations;
const allAutomationOptions = Object.values(automationSettings.automations).map(({ id, title }) => ({
value: id,
label: title,
}));
const hasAutomationOptions = allAutomationOptions.length > 0;
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
const duplicateIds = new Set<string>();
const seen = new Map<string, string>();
for (const trigger of triggers) {
const key = `${trigger.trigger}:${trigger.automationId}`;
if (seen.has(key)) {
duplicateIds.add(trigger.id);
} else {
seen.set(key, trigger.id);
}
}
const handleAdd = () => {
if (!hasAutomationOptions) return;
updateEntry({
id: eventId,
triggers: [
...triggers,
{ id: generateId(), title: '', trigger: eventTriggerOptions[0], automationId: allAutomationOptions[0].value },
],
});
};
const handleDelete = (triggerId: string) => {
updateEntry({ id: eventId, triggers: triggers.filter((t) => t.id !== triggerId) });
};
const handleChange = (triggerId: string, field: 'trigger' | 'automationId', value: string) => {
const newTriggers = triggers.map((t) => (t.id !== triggerId ? t : { ...t, [field]: value }));
updateEntry({ id: eventId, triggers: newTriggers });
};
const showTriggers = triggers.length > 0;
return (
<div className={style.triggers}>
{automationsEnabled === false && (
<Info>Automations are disabled. Event triggers stay configured, but they will not run until enabled.</Info>
)}
{automationsEnabled && !hasAutomationOptions && (
<Info>
<Info.Title>No automations available</Info.Title>
<Info.Body>Create an automation before attaching it to this event.</Info.Body>
</Info>
{showTriggers && (
<div className={style.section}>
<div className={style.sectionTitle}>Applied automations</div>
<ExistingEventTriggers triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</div>
)}
{triggers.length > 0 && (
<div className={style.triggerList}>
<div className={style.triggerHeader}>
<span>Lifecycle</span>
<span>Automation</span>
</div>
{triggers.map((trigger) => {
const isDuplicate = duplicateIds.has(trigger.id);
const lifecycleOptions = isDuplicate
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
: triggerOptions;
const automationOptions = isDuplicate
? allAutomationOptions.map((opt) =>
opt.value === trigger.automationId ? { ...opt, label: `${opt.label} *` } : opt,
)
: allAutomationOptions;
<Editor.Panel className={style.formSection}>
<div className={style.sectionTitle}>Add automation</div>
<EventTriggerForm triggers={triggers} eventId={eventId} automations={automationSettings.automations} />
</Editor.Panel>
</div>
);
}
interface EventTriggerFormProps {
eventId: string;
triggers?: Trigger[];
automations: NormalisedAutomation;
}
function EventTriggerForm({ eventId, triggers, automations }: EventTriggerFormProps) {
const { updateEntry } = useEntryActionsContext();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
const handleSubmit = (triggerLifeCycle: TimerLifeCycle, automationId: string) => {
const newTriggers = triggers ?? new Array<Trigger>();
const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
updateEntry({ id: eventId, triggers: newTriggers });
};
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
if (automationId === undefined) {
return 'Select an automation';
}
if (!Object.keys(automations).includes(automationId)) {
return 'This automation does not exist';
}
if (triggers === undefined) {
return;
}
return Object.values(triggers).some((t) => t.automationId === automationId && t.trigger === cycle)
? 'Automation can only be used once'
: undefined;
};
const validationError = getValidationError(cycleValue, automationId);
const validationLabel = validationError ?? 'Ready to add automation';
const triggerOptions = useMemo(
() => [
{ value: null, label: 'Select lifecycle' },
...eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })),
],
[], // eventTriggerOptions is a constant, no need for dependency
);
const automationOptions = useMemo(
() => [
{ value: null, label: 'Select Automation' },
...Object.values(automations).map(({ id, title }) => ({ value: id, label: title })),
],
[automations], // This needs to be a dependency as it can change
);
return (
<div className={style.triggerForm}>
<div className={style.formFields}>
<div>
<Editor.Label>Lifecycle</Editor.Label>
<Select
value={cycleValue}
onValueChange={(value) => {
if (value !== null) setCycleValue(value);
}}
options={triggerOptions}
/>
</div>
<div>
<Editor.Label>Automation</Editor.Label>
<Select
value={automationId ?? null}
onValueChange={(value) => {
if (value !== null) setAutomationId(value);
}}
options={automationOptions}
/>
</div>
</div>
<div className={style.formActions}>
<div className={validationError ? style.validationError : style.validationSuccess}>
{validationError ? <IoAlertCircle /> : <IoCheckmarkCircle />}
<span>{validationLabel}</span>
</div>
<Button
disabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add automation
</Button>
</div>
</div>
);
}
interface ExistingEventTriggersProps {
eventId: string;
triggers: Trigger[];
automations: NormalisedAutomation;
}
function ExistingEventTriggers({ eventId, triggers, automations }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActionsContext();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
updateEntry({ id: eventId, triggers: newTriggers });
},
[eventId, triggers, updateEntry],
);
const filteredTriggers: Record<string, Trigger[]> = {};
// sort triggers out into groups by the Lifecycle they are on
timerLifecycleValues.forEach((triggerType) => {
const thisTriggerType = triggers.filter((trigger) => trigger.trigger === triggerType);
if (thisTriggerType.length) {
Object.assign(filteredTriggers, { [triggerType]: thisTriggerType });
}
});
return (
<div className={style.triggerList}>
{Object.entries(filteredTriggers).map(([triggerLifeCycle, triggerGroup]) => (
<Fragment key={triggerLifeCycle}>
{triggerGroup.map((trigger) => {
const { id, automationId } = trigger;
const automationTitle = automations[automationId]?.title ?? '<MISSING AUTOMATION>';
return (
<div key={trigger.id} className={style.trigger} data-duplicate={isDuplicate || undefined}>
<Select
value={trigger.trigger}
onValueChange={(value) => {
if (value !== null) handleChange(trigger.id, 'trigger', value);
}}
options={lifecycleOptions}
/>
<Select
value={trigger.automationId}
onValueChange={(value) => {
if (value !== null) handleChange(trigger.id, 'automationId', value);
}}
options={automationOptions}
/>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
<div key={id} className={style.trigger}>
<div className={style.triggerMeta}>
<div className={style.metaLabel}>Lifecycle</div>
<div>{triggerLifeCycle}</div>
</div>
<div className={style.triggerMeta}>
<div className={style.metaLabel}>Automation</div>
<div className={style.automationTitle}>{automationTitle}</div>
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(id)}>
<IoTrash />
</IconButton>
</div>
);
})}
</div>
)}
{duplicateIds.size > 0 && (
<span className={style.duplicateMessage}>
* Duplicate combinations will only fire once per lifecycle event.
</span>
)}
{hasAutomationOptions && (
<Button variant='ghosted' onClick={handleAdd}>
<IoAdd /> Add automation
</Button>
)}
</Fragment>
))}
</div>
);
}
+2 -1
View File
@@ -81,7 +81,8 @@ html,
user-select: none;
-webkit-app-region: drag;
isolation: isolate;
background-color: $ui-black; // prevent the safari from showing white background
// prevents Safari iOS from showing white in the overscroll (rubber-band) area
background-color: $ui-black;
}
// reset button styles
+15 -13
View File
@@ -23,19 +23,21 @@ export default function ViewLoader({ children }: PropsWithChildren) {
const searchParams = new URLSearchParams(window.location.search);
const colourFromParams = searchParams.get('keyColour') ?? '#101010';
// always keep body background in sync so Safari iOS overscroll area matches the view
const bodyBgStyle = `body { background-color: var(--background-color-override, ${colourFromParams}); }`;
return (
<>
<style>{`body { background: var(--background-color-override, ${colourFromParams}); }`}</style>
<Suspense
fallback={
<>
<Loader />
</>
}
>
<OverrideStyles />
{children}
</Suspense>
</>
<Suspense
fallback={
<>
<style>{bodyBgStyle}</style>
<Loader />
</>
}
>
<style>{bodyBgStyle}</style>
<OverrideStyles />
{children}
</Suspense>
);
}
@@ -1,7 +1,7 @@
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react';
import { CSSProperties, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
@@ -30,8 +30,7 @@ interface EventRowProps {
hasCursor?: boolean;
}
export default memo(EventRow);
function EventRow({
export default function EventRow({
rowId,
id,
eventIndex,
@@ -56,25 +55,24 @@ function EventRow({
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
const { rowBgColour, backgroundColor, mutedText } = useMemo(() => {
const accessible = getAccessibleColour(colour);
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
const { color, backgroundColor } = getAccessibleColour(colour);
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
let rowBgColour: string | undefined;
const rowBgColour: string | undefined = useMemo(() => {
if (isLoaded) {
rowBgColour = '#087A27'; // $active-green
return '#087A27'; // $active-green
} else if (colour) {
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
if (accessibleBg !== null) {
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
return colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
return {
rowBgColour,
backgroundColor: accessible.backgroundColor,
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
};
return;
}, [colour, isLoaded]);
return (
@@ -1,6 +1,6 @@
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
import { CSSProperties, memo } from 'react';
import { CSSProperties } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
@@ -20,8 +20,7 @@ interface GroupRowProps {
hasCursor?: boolean;
}
export default memo(GroupRow);
function GroupRow({
export default function GroupRow({
groupId,
colour,
rowId,
@@ -1,7 +1,7 @@
import { Table, flexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react';
import { CSSProperties } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
@@ -25,8 +25,7 @@ interface MilestoneRowProps {
hasCursor?: boolean;
}
export default memo(MilestoneRow);
function MilestoneRow({
export default function MilestoneRow({
entryId,
isPast,
parentBgColour,
@@ -46,12 +45,17 @@ function MilestoneRow({
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
const rowBgColour = useMemo(() => {
if (!colour) return undefined;
const accessibleBg = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBg === null) return undefined;
return colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
}, [colour]);
let rowBgColour: string | undefined;
if (colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
if (accessibleBackgroundColor !== null) {
rowBgColour = colourToHex({
...accessibleBackgroundColor,
alpha: accessibleBackgroundColor.alpha * 0.25,
});
}
}
return (
<tr
@@ -45,9 +45,6 @@ $studio-idle: $red-1300;
top: 0;
top: 50%;
left: 50%;
margin-top: -0.5rem;
margin-left: -0.5rem;
}
.tick--active {
+1 -5
View File
@@ -1,7 +1,6 @@
import path from 'path';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import legacy from '@vitejs/plugin-legacy';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { compression } from 'vite-plugin-compression2';
@@ -22,9 +21,6 @@ export default defineConfig({
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
},
plugins: [
legacy({
targets: ['safari >= 14.1'],
}),
react({
babel: {
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
@@ -87,7 +83,7 @@ export default defineConfig({
},
test: {
globals: true,
dom: true,
environment: 'jsdom',
},
build: {
outDir: './build',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.8.0",
"version": "4.7.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.8.0",
"version": "4.7.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
-3
View File
@@ -14,9 +14,6 @@ export { TimerPhase, Playback, runtimeStorePlaceholder, OffsetMode } from 'ontim
export type { SimpleTimerState } from 'ontime-types';
export { SimplePlayback, SimpleDirection } from 'ontime-types';
// Client
export type { ClientList, Client } from 'ontime-types';
// entries
export type {
OntimeEntry,
+3 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.8.0",
"version": "4.7.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -29,10 +29,12 @@
"@types/express": "5.0.3",
"@types/multer": "2.1.0",
"@types/node": "catalog:",
"@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10",
"esbuild": "^0.24.0",
"ontime-types": "workspace:*",
"server-timing": "^3.3.3",
"shx": "^0.3.4",
"ts-essentials": "catalog:",
"tsx": "^4.19.2",
"typescript": "catalog:",
+2 -2
View File
@@ -262,6 +262,6 @@ export const socket = new SocketServer();
/**
* Utility function to notify clients that the REST data is stale
*/
export function sendRefetch(target: RefetchKey, revision: MaybeNumber = null, rundownId?: string) {
socket.sendAsJson(MessageTag.Refetch, { target, revision, rundownId });
export function sendRefetch(target: RefetchKey, revision: MaybeNumber = null) {
socket.sendAsJson(MessageTag.Refetch, { target, revision });
}
@@ -47,15 +47,7 @@ export function triggerAutomations(cycle: TimerLifeCycle) {
return;
}
// deduplicate — if the same automation appears multiple times on one lifecycle, fire it once
const seen = new Set<string>();
const uniqueTriggers = filteredTrigger.filter((t) => {
if (seen.has(t.automationId)) return false;
seen.add(t.automationId);
return true;
});
uniqueTriggers.forEach((trigger) => {
filteredTrigger.forEach((trigger) => {
const automation = automations[trigger.automationId];
if (!automation || automation.outputs.length === 0) {
return;
@@ -74,7 +74,6 @@ let rundownMetadata: RundownMetadata = {
let projectCustomFields: CustomFields = {};
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
export const getCurrentRundownId = (): string => cachedRundown.id;
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
@@ -27,7 +27,7 @@ import { updateRundownData } from '../../stores/runtimeState.js';
import {
createTransaction,
customFieldMutation,
getCurrentRundownId,
getCurrentRundown,
rundownCache,
rundownMutation,
updateBackgroundRundown,
@@ -74,7 +74,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
});
return newEntry;
@@ -118,10 +118,7 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, {
timer: didInvalidate ? true : [entry.id],
external: true,
});
notifyChanges(rundownMetadata, revision, { timer: didInvalidate ? true : [entry.id], external: true });
});
return entry;
@@ -184,10 +181,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, {
timer: batchDidInvalidate ? true : changedIds,
external: true,
});
notifyChanges(rundownMetadata, revision, { timer: batchDidInvalidate ? true : changedIds, external: true });
});
return rundownResult;
@@ -215,7 +209,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: entryIds, external: true });
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
});
return rundownResult;
@@ -237,7 +231,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
@@ -269,7 +263,7 @@ export async function reorderEntry(entryId: EntryId, destinationId: EntryId, ord
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
@@ -293,7 +287,7 @@ export function renumberEntries(ids: EntryId[], prefix: string, start: string, i
setImmediate(() => {
updateRuntimeOnChange(rundownMetadata);
notifyChanges(rundown.id, rundownMetadata, revision, { timer: ids, external: true });
notifyChanges(rundownMetadata, revision, { timer: ids, external: true });
});
return rundownResult;
@@ -324,7 +318,7 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
@@ -357,7 +351,7 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
updateRuntimeOnChange(rundownMetadata);
// notify timer and external services of change
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
@@ -386,11 +380,11 @@ export async function cloneEntry(entryId: EntryId, options: InsertOptions): Prom
// notify timer and external services of change
if (isOntimeGroup(newEntry)) {
notifyChanges(rundown.id, rundownMetadata, revision, { timer: newEntry.entries, external: true });
notifyChanges(rundownMetadata, revision, { timer: newEntry.entries, external: true });
} else if (isOntimeEvent(newEntry)) {
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
} else if (isOntimeDelay(newEntry)) {
notifyChanges(rundown.id, rundownMetadata, revision, { external: true });
notifyChanges(rundownMetadata, revision, { external: true });
}
});
@@ -412,7 +406,7 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
updateRuntimeOnChange(rundownMetadata);
// we need to notify the timer since we might be grouping a running event
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
notifyChanges(rundownMetadata, revision, { external: true, timer: true });
});
return rundownResult;
@@ -438,7 +432,7 @@ export async function ungroupEntries(groupId: EntryId): Promise<Rundown> {
updateRuntimeOnChange(rundownMetadata);
// we dont need to notify the timer since the grouping does not affect the runtime
notifyChanges(rundown.id, rundownMetadata, revision, { external: true });
notifyChanges(rundownMetadata, revision, { external: true });
});
return rundownResult;
@@ -528,7 +522,7 @@ export async function editCustomField(
// schedule the side effects
setImmediate(() => {
sendRefetch(RefetchKey.CustomFields);
notifyChanges(undefined, rundownMetadata, revision, { timer: true, external: true });
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return resultCustomFields;
@@ -567,7 +561,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
// schedule the side effects
setImmediate(() => {
sendRefetch(RefetchKey.CustomFields);
notifyChanges(undefined, rundownMetadata, revision, { timer: true, external: true });
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return resultCustomFields;
@@ -598,17 +592,10 @@ type NotifyChangesOptions = {
/**
* Notify services of changes in the rundown
* TODO: we could receive a runtime flag to call updateRuntimeOnChange
* instead of having it in every consumer
*/
function notifyChanges(
rundownId: string | undefined,
rundownMetadata: RundownMetadata,
revision: number,
options: NotifyChangesOptions,
) {
// notify timer service of changed event
if (options.timer && rundownId && isCurrentRundown(rundownId)) {
function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
// notify timer service of changed events
if (options.timer) {
runtimeService.notifyOfChangedEvents(rundownMetadata);
}
@@ -616,23 +603,18 @@ function notifyChanges(
if (options.reload) {
sendRefetch(RefetchKey.All);
} else if (options.external) {
sendRefetch(RefetchKey.Rundown, revision, rundownId);
sendRefetch(RefetchKey.Rundown, revision);
}
}
export function isCurrentRundown(id: string) {
return id === getCurrentRundownId();
}
/**
* @throws if the provided id does not exist
*/
export async function loadRundown(id: string) {
const dataProvider = getDataProvider();
if (isCurrentRundown(id)) {
if (id === getCurrentRundown().id) {
return dataProvider.getProjectRundowns();
}
const rundown = dataProvider.getRundown(id);
const customField = dataProvider.getCustomFields();
await initRundown(rundown, customField);
@@ -655,7 +637,7 @@ export async function initRundown(
updateRuntimeOnChange(rundownMetadata);
setImmediate(() => {
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload });
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload });
setLastLoadedRundown(rundown.id).catch((error) => {
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
});
@@ -12,7 +12,7 @@ import { DeepPartial } from 'ts-essentials';
import { socket } from '../adapters/WebsocketAdapter.js';
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
import { editEntry, loadRundown } from '../api-data/rundown/rundown.service.js';
import { editEntry } from '../api-data/rundown/rundown.service.js';
import { willCauseRegeneration } from '../api-data/rundown/rundown.utils.js';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
@@ -307,15 +307,6 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
runtimeService.setOffsetMode(mode);
return { payload: 'success' };
},
rundown: async (payload) => {
assert.isObject(payload);
if ('load' in payload) {
assert.isString(payload.load);
await loadRundown(payload.load);
return { payload: 'success' };
}
throw new Error('No matching method provided');
},
};
/**
+1 -6
View File
@@ -32,7 +32,7 @@ import { runtimeService } from './services/runtime-service/runtime.service.js';
import { timerConfig } from './setup/config.js';
import { environment, isProduction } from './setup/environment.js';
// import utils
import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { publicDir, srcDir } from './setup/index.js';
import { populateDemo } from './setup/loadDemo.js';
import { populateStyles } from './setup/loadStyles.js';
import { populateTranslation } from './setup/loadTranslations.js';
@@ -113,11 +113,6 @@ app.use(`${prefix}/external`, (req, res) => {
});
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
// Serve legacy timer for old browsers that don't support modern JS
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
res.sendFile(srcFiles.timerLegacy);
});
// Base route for static files
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
app.use(`${prefix}/*splat`, authenticateAndRedirect, compressedStatic);
-218
View File
@@ -1,218 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Timer view for legacy browsers" />
<title>Ontime Timer</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
background: #121212;
color: #ffffff;
font-family: sans-serif;
overflow: hidden;
}
#container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
-webkit-align-items: center;
align-items: center;
-webkit-justify-content: center;
justify-content: center;
}
#timer {
font-size: 20vmin;
font-weight: bold;
font-variant-numeric: tabular-nums;
letter-spacing: 0.025em;
line-height: 1;
color: #ffffff;
}
#timer.short {
font-size: 35vmin;
}
#timer.warning {
color: #ffa528;
}
#timer.danger {
color: #ff7300;
}
#timer.overtime {
color: #fa5656;
}
#title {
color: #999999;
font-size: 5vmin;
margin-top: 3vmin;
text-align: center;
padding-left: 4vmin;
padding-right: 4vmin;
display: none;
}
#progress-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4vmin;
background: rgba(255, 255, 255, 0.15);
display: none;
}
#progress-fill {
height: 100%;
width: 0%;
background: #ffffff;
}
</style>
</head>
<body>
<div id="container">
<div id="timer">--:--</div>
<div id="title"></div>
</div>
<div id="progress-container">
<div id="progress-fill"></div>
</div>
<script>
var timerEl = document.getElementById('timer');
var titleEl = document.getElementById('title');
var progressContainerEl = document.getElementById('progress-container');
var progressFillEl = document.getElementById('progress-fill');
var phaseColors = { warning: '#ffa528', danger: '#ff7300', overtime: '#fa5656' };
var state = { timerCurrent: null, timerPhase: 'none', timerDuration: null, eventTitle: null };
function pad(n) {
return n < 10 ? '0' + n : String(n);
}
function formatMs(ms) {
var sign = ms < 0 ? '-' : '';
var abs = Math.abs(ms);
var h = Math.floor(abs / 3600000);
var m = Math.floor((abs % 3600000) / 60000);
var s = Math.floor((abs % 60000) / 1000);
// remove the hours if there is nothing to show
if (h === 0) {
return sign + pad(m) + ':' + pad(s);
}
return sign + pad(h) + ':' + pad(m) + ':' + pad(s);
}
function render() {
var text = state.timerCurrent !== null ? formatMs(state.timerCurrent) : '--:--:--';
var hasHours = text.lastIndexOf(':') !== text.indexOf(':');
timerEl.textContent = text;
var phase = state.timerPhase;
var cls = phase === 'warning' || phase === 'danger' || phase === 'overtime' ? phase : '';
if (!hasHours) {
cls = cls ? cls + ' short' : 'short';
}
timerEl.className = cls;
if (state.eventTitle) {
titleEl.textContent = state.eventTitle;
titleEl.style.display = 'block';
} else {
titleEl.style.display = 'none';
}
if (state.timerDuration > 0 && state.timerCurrent !== null) {
var elapsed = state.timerDuration - Math.max(0, state.timerCurrent);
var pct = Math.min(100, Math.max(0, (elapsed / state.timerDuration) * 100));
progressFillEl.style.width = pct + '%';
progressFillEl.style.backgroundColor = phaseColors[phase] || '#ffffff';
progressContainerEl.style.display = 'block';
} else {
progressContainerEl.style.display = 'none';
}
}
function applyPatch(payload) {
if (payload.timer) {
if (payload.timer.current !== undefined) state.timerCurrent = payload.timer.current;
if (payload.timer.duration !== undefined) state.timerDuration = payload.timer.duration;
if (payload.timer.phase !== undefined) state.timerPhase = payload.timer.phase;
}
if ('eventNow' in payload) {
state.eventTitle = payload.eventNow ? payload.eventNow.title : null;
}
}
function resolveConnectionUrl() {
var isSecure = window.location.protocol === 'https:';
var stageHash = '';
var href = window.location.href;
if (href.indexOf('getontime.no') !== -1) {
var hash = href.split('/')[3];
stageHash = '/' + hash;
}
if (isSecure) {
return 'wss://' + window.location.host + stageHash + '/ws';
}
return 'ws://' + window.location.host + stageHash + '/ws';
}
var reconnectDelay = 1000;
function connect() {
var wsUrl = resolveConnectionUrl();
var ws = new WebSocket(wsUrl);
ws.onopen = function () {
reconnectDelay = 1000;
};
ws.onmessage = function (event) {
try {
var msg = JSON.parse(event.data);
if (msg.tag === 'runtime-data') {
applyPatch(msg.payload);
render();
}
} catch (_) {}
};
ws.onclose = function () {
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
};
ws.onerror = function () {
ws.close();
};
}
connect();
</script>
</body>
</html>
-2
View File
@@ -95,8 +95,6 @@ export const srcFiles = {
translationReadme: join(srcDir.root, config.user, config.translations.directory, 'README.md'),
/** Path to login */
login: join(srcDir.root, 'html/login.html'),
/** Path to legacy timer for old browsers */
timerLegacy: join(srcDir.root, 'html/timer-legacy.html'),
/** Path to ontime-logo */
logo: join(srcDir.root, config.user, config.logo, 'ontime-logo.png'),
};
+3 -9
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.8.0",
"version": "4.7.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -17,7 +17,7 @@
"license": "AGPL-3.0-only",
"engines": {
"node": "~22",
"pnpm": "~11"
"pnpm": "~10"
},
"type": "module",
"scripts": {
@@ -51,11 +51,5 @@
"turbo": "2.8.20",
"typescript": "catalog:"
},
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
"devEngines": {
"runtime": {
"name": "node",
"version": "22.22.2"
}
}
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.8.0",
"version": "4.7.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
+2 -16
View File
@@ -142,18 +142,6 @@ export type OffsetmodeResponse = {
payload: 'success';
};
export type RundownAction = {
tag: 'rundown';
payload: {
/** load a rundown by id */
load: string;
};
};
export type RundownLoadResponse = {
tag: 'rundown-load';
payload: 'success';
};
export type ApiAction =
| VersionAction
| PollAction
@@ -168,8 +156,7 @@ export type ApiAction =
| AddtimeAction
| AuxtimerAction
| ClientAction
| OffsetmodeAction
| RundownAction;
| OffsetmodeAction;
export type ApiResponse =
| VersionResponse
@@ -185,7 +172,6 @@ export type ApiResponse =
| AddtimeResponse
| AuxtimerResponse
| ClientResponse
| OffsetmodeResponse
| RundownLoadResponse;
| OffsetmodeResponse;
export type ApiActionTag = ApiAction['tag'];
@@ -41,7 +41,6 @@ type RefetchPacket = {
payload: {
target: RefetchKey;
revision: MaybeNumber;
rundownId?: string; // undefined means refetch all rundowns
};
};
+514 -1307
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -9,10 +9,8 @@ catalog:
typescript: 6.0.2
vitest: 4.0.17
allowBuilds:
'@parcel/watcher': true
'@sentry/cli': false
core-js: false
electron: true
electron-winstaller: false
esbuild: true
onlyBuiltDependencies:
- electron
- esbuild
useNodeVersion: 22.22.2