+
+
+
+
+
+
5 && visiblePage < numPages - 4 && 'schedule-nav__item--selected',
+ ])}
+ />
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {numPages > 1 &&
+ [...Array(numPages).keys()].map((i) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/client/src/views/common/schedule/schedule.options.ts b/apps/client/src/views/common/schedule/schedule.options.ts
new file mode 100644
index 0000000000..58b3b531de
--- /dev/null
+++ b/apps/client/src/views/common/schedule/schedule.options.ts
@@ -0,0 +1,57 @@
+import { useMemo } from 'react';
+import { useSearchParams } from 'react-router-dom';
+
+import { OptionTitle } from '../../../common/components/view-params-editor/constants';
+import { ViewOption } from '../../../common/components/view-params-editor/types';
+import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
+
+export const scheduleOptions: ViewOption = {
+ title: OptionTitle.Schedule,
+ collapsible: true,
+ options: [
+ {
+ id: 'stopCycle',
+ title: 'Stop cycling through event pages',
+ description: 'Schedule will not auto-cycle through events',
+ type: 'boolean',
+ defaultValue: false,
+ },
+ {
+ id: 'cycleInterval',
+ title: 'Cycle interval',
+ description: 'How long (in seconds) should each schedule page be shown.',
+ type: 'number',
+ defaultValue: 10,
+ },
+ {
+ id: 'showProjected',
+ title: 'Show projected time',
+ description: 'Whether scheduled times should account for runtime offset.',
+ type: 'boolean',
+ defaultValue: false,
+ },
+ ],
+};
+
+type ScheduleOptions = {
+ cycleInterval: number;
+ stopCycle: boolean;
+ showProjected: boolean;
+};
+
+function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
+ return {
+ cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
+ stopCycle: isStringBoolean(searchParams.get('stopCycle')),
+ showProjected: isStringBoolean(searchParams.get('showProjected')),
+ };
+}
+
+/**
+ * Hook exposes the schedule component options
+ */
+export function useScheduleOptions() {
+ const [searchParams] = useSearchParams();
+ const options = useMemo(() => getScheduleOptionsFromParams(searchParams), [searchParams]);
+ return options;
+}
diff --git a/apps/client/src/views/common/schedule/schedule.utils.ts b/apps/client/src/views/common/schedule/schedule.utils.ts
new file mode 100644
index 0000000000..934cb156cd
--- /dev/null
+++ b/apps/client/src/views/common/schedule/schedule.utils.ts
@@ -0,0 +1,19 @@
+import { OntimeEvent } from 'ontime-types';
+
+/**
+ * Gather rules for how to present scheduled times
+ */
+export function getScheduledTimes(event: OntimeEvent, isProduction?: boolean) {
+ if (isProduction) {
+ return {
+ timeStart: event.timeStart,
+ timeEnd: event.timeEnd,
+ delay: event.skip ? 0 : event.delay,
+ };
+ }
+ return {
+ timeStart: event.timeStart,
+ timeEnd: event.timeEnd,
+ delay: 0,
+ };
+}
diff --git a/apps/client/src/views/public/Public.scss b/apps/client/src/views/public/Public.scss
new file mode 100644
index 0000000000..7a020b7a67
--- /dev/null
+++ b/apps/client/src/views/public/Public.scss
@@ -0,0 +1,205 @@
+@use '../../theme/viewerDefs' as *;
+
+.public-screen {
+ margin: 0;
+ box-sizing: border-box; /* reset */
+ overflow: hidden;
+ width: 100%; /* restrict the page width to viewport */
+ height: 100vh;
+
+ font-family: var(--font-family-override, $viewer-font-family);
+ background: var(--background-color-override, $viewer-background-color);
+ color: var(--color-override, $viewer-color);
+ gap: $view-element-gap;
+ padding: $view-outer-padding;
+ font-size: $base-font-size;
+
+ display: grid;
+ grid-template-columns: 1fr 40vw;
+ grid-template-rows: auto 12px 1fr auto;
+ grid-template-areas:
+ 'header header'
+ 'now schedule-nav'
+ 'info schedule';
+
+ .empty-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin-top: 25vh;
+ color: var(--label-color-override, $viewer-label-color);
+ }
+
+ /* =================== HEADER + EXTRAS ===================*/
+
+ .project-header {
+ grid-area: header;
+ font-size: $header-font-size;
+ font-weight: 600;
+ display: flex;
+ gap: 1rem;
+ }
+
+ .logo {
+ max-width: min(200px, 20vw);
+ max-height: min(100px, 20vh);
+ }
+
+ .title {
+ line-height: 1.1em;
+ }
+
+ .clock-container {
+ margin-left: auto;
+ font-weight: 600;
+
+ .label {
+ font-size: $timer-label-size;
+ color: var(--label-color-override, $viewer-label-color);
+ text-transform: uppercase;
+ }
+
+ .time {
+ font-size: $timer-value-size;
+ color: var(--secondary-color-override, $viewer-secondary-color);
+ letter-spacing: 0.05em;
+ line-height: 0.95em;
+ }
+ }
+
+ /* =================== MAIN - NOW ===================*/
+
+ .card-container {
+ grid-area: now;
+ display: flex;
+ flex-direction: column;
+ gap: $view-element-gap;
+ }
+
+ .event {
+ background-color: var(--card-background-color-override, $viewer-card-bg-color);
+ padding: $view-card-padding;
+ border-radius: $element-border-radius;
+ }
+
+ .timer-group {
+ border-top: 2px solid var(--background-color-override, $viewer-background-color);
+ margin-top: max(1vh, 16px);
+ padding-top: max(1vh, 16px);
+ display: flex;
+ row-gap: 0.5em;
+ }
+
+ .time-entry {
+ &__label {
+ font-size: $timer-label-size;
+ color: var(--label-color-override, $viewer-label-color);
+ font-weight: 600;
+ text-transform: uppercase;
+ }
+
+ &__value {
+ font-size: $base-font-size;
+ color: var(--secondary-color-override, $viewer-secondary-color);
+ letter-spacing: 0.05em;
+ line-height: 0.95em;
+ }
+
+ &--pending {
+ color: var(--timer-pending-color-override, $ontime-roll);
+ }
+ }
+
+ /* =================== MAIN - SCHEDULE ===================*/
+
+ $schedule-left-spacing: clamp(16px, 4vw, 64px);
+ .schedule-container {
+ grid-area: schedule;
+ overflow: hidden;
+ height: 100%;
+ padding-left: $schedule-left-spacing;
+ }
+
+ .schedule-nav-container {
+ grid-area: schedule-nav;
+ padding-left: $schedule-left-spacing;
+ }
+
+ /* =================== MAIN - INFO ===================*/
+
+ .info {
+ grid-area: info;
+ display: flex;
+ gap: max(1vw, 16px);
+ align-self: flex-end;
+ overflow: hidden;
+ align-items: end;
+
+ &__message {
+ font-size: $base-font-size;
+ line-height: 1.2em;
+ white-space: pre-line;
+ overflow: hidden;
+ flex: 1;
+ }
+
+ .qr {
+ padding: 0.5rem;
+ background-color: $ui-white;
+ border-radius: 2px;
+ }
+ }
+}
+
+/* =================== MOBILE ===================*/
+@media screen and (max-width: 768px) {
+ .public-screen {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+
+ .project-header {
+ flex-direction: column;
+ position: relative;
+ gap: 0.5rem;
+ }
+
+ .logo {
+ height: min(50px, 10vh);
+ }
+
+ .timer-group {
+ flex-wrap: wrap;
+ }
+
+ .clock-container {
+ position: absolute;
+ top: 0;
+ right: 0;
+ }
+
+ .schedule-nav-container {
+ padding-left: 0;
+ justify-content: center;
+ }
+
+ .schedule-container {
+ padding-left: 0;
+ flex: 1;
+ }
+
+ .info {
+ width: 100%;
+ text-align: right;
+ }
+
+ .qr {
+ display: none;
+ }
+
+ .info--stretch {
+ flex: 1;
+ }
+ }
+}
diff --git a/apps/client/src/views/public/Public.tsx b/apps/client/src/views/public/Public.tsx
new file mode 100644
index 0000000000..aae5dd7b93
--- /dev/null
+++ b/apps/client/src/views/public/Public.tsx
@@ -0,0 +1,121 @@
+import QRCode from 'react-qr-code';
+import { useViewportSize } from '@mantine/hooks';
+import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types';
+
+import Empty from '../../common/components/state/Empty';
+import TitleCard from '../../common/components/title-card/TitleCard';
+import ViewLogo from '../../common/components/view-logo/ViewLogo';
+import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
+import { useWindowTitle } from '../../common/hooks/useWindowTitle';
+import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
+import { cx } from '../../common/utils/styleUtils';
+import { formatTime, getDefaultFormat } from '../../common/utils/time';
+import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
+import { useTranslation } from '../../translation/TranslationProvider';
+import { getIsPendingStart } from '../backstage/backstage.utils';
+import ScheduleExport from '../common/schedule/ScheduleExport';
+
+import { getPublicOptions, usePublicOptions } from './public.options';
+import { getCardData, getFirstStartTime } from './public.utils';
+
+import './Public.scss';
+
+interface BackstageProps {
+ customFields: CustomFields;
+ events: OntimeEvent[];
+ general: ProjectData;
+ isMirrored: boolean;
+ publicEventNow: OntimeEvent | null;
+ publicEventNext: OntimeEvent | null;
+ time: ViewExtendedTimer;
+ publicSelectedId: string | null;
+ settings: Settings | undefined;
+}
+
+export default function Public(props: BackstageProps) {
+ const {
+ customFields,
+ events,
+ general,
+ isMirrored,
+ publicEventNow,
+ publicEventNext,
+ time,
+ publicSelectedId,
+ settings,
+ } = props;
+
+ const { getLocalizedString } = useTranslation();
+ const { secondarySource } = usePublicOptions();
+ const { height: screenHeight } = useViewportSize();
+
+ useWindowTitle('Public Schedule');
+
+ // gather card data
+ const hasEvents = events.length > 0;
+ const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData(
+ publicEventNow,
+ publicEventNext,
+ 'title',
+ secondarySource,
+ time.playback,
+ );
+
+ // gather timer data
+ const clock = formatTime(time.clock);
+ const isPendingStart = getIsPendingStart(time.playback, time.phase);
+ const scheduledStart = hasEvents && showNow ? '' : getFirstStartTime(events[0]);
+
+ // gather presentation styles
+ const qrSize = Math.max(window.innerWidth / 15, 72);
+ const showSchedule = hasEvents && screenHeight > 700; // in vertical screens we may not have space
+
+ // gather option data
+ const defaultFormat = getDefaultFormat(settings?.timeFormat);
+ const publicOptions = getPublicOptions(defaultFormat, customFields);
+
+ return (
+
+
+
+ {general?.projectLogo ?
:
}
+
{general.title}
+
+
{getLocalizedString('common.time_now')}
+
+
+
+
+ {!hasEvents &&
}
+
+
+ {showNow && hasEvents && (
+
+ )}
+ {!showNow && scheduledStart && (
+
+
{getLocalizedString('countdown.waiting')}
+
+
+
+ {getLocalizedString('common.scheduled_start')}
+
+
+
+
+
+ )}
+ {showNext && hasEvents && (
+
+ )}
+
+
+ {showSchedule &&
}
+
+
+ {general.publicUrl &&
}
+ {general.publicInfo &&
{general.publicInfo}
}
+
+
+ );
+}
diff --git a/apps/client/src/views/public/public.options.ts b/apps/client/src/views/public/public.options.ts
new file mode 100644
index 0000000000..821de86c10
--- /dev/null
+++ b/apps/client/src/views/public/public.options.ts
@@ -0,0 +1,58 @@
+import { useMemo } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { CustomFields, OntimeEvent } from 'ontime-types';
+
+import {
+ getTimeOption,
+ makeOptionsFromCustomFields,
+ OptionTitle,
+} from '../../common/components/view-params-editor/constants';
+import { ViewOption } from '../../common/components/view-params-editor/types';
+import { scheduleOptions } from '../common/schedule/schedule.options';
+
+export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
+ const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
+
+ return [
+ { title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
+ {
+ title: OptionTitle.DataSources,
+ collapsible: true,
+ options: [
+ {
+ id: 'secondary-src',
+ title: 'Event secondary text',
+ description: 'Select the data source for auxiliary text shown in now and next cards',
+ type: 'option',
+ values: secondaryOptions,
+ defaultValue: '',
+ },
+ ],
+ },
+ scheduleOptions,
+ ];
+};
+
+type PublicOptions = {
+ secondarySource: keyof OntimeEvent | null;
+};
+
+/**
+ * Utility extract the view options from URL Params
+ * the names and fallback are manually matched with timerOptions
+ */
+function getOptionsFromParams(searchParams: URLSearchParams): PublicOptions {
+ // we manually make an object that matches the key above
+ return {
+ secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
+ };
+}
+
+/**
+ * Hook exposes the backstage view options
+ */
+export function usePublicOptions(): PublicOptions {
+ const [searchParams] = useSearchParams();
+ const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
+ return options;
+}
diff --git a/apps/client/src/views/public/public.utils.ts b/apps/client/src/views/public/public.utils.ts
new file mode 100644
index 0000000000..a0b463b8bb
--- /dev/null
+++ b/apps/client/src/views/public/public.utils.ts
@@ -0,0 +1,45 @@
+import { OntimeEvent, Playback } from 'ontime-types';
+
+import { enDash } from '../../common/utils/styleUtils';
+import { getPropertyValue } from '../../features/viewers/common/viewUtils';
+
+/**
+ * What should we be showing in the cards?
+ */
+export function getCardData(
+ eventNow: OntimeEvent | null,
+ eventNext: OntimeEvent | null,
+ mainSource: keyof OntimeEvent | null,
+ secondarySource: keyof OntimeEvent | null,
+ playback: Playback,
+) {
+ if (playback === Playback.Stop) {
+ return {
+ showNow: false,
+ nowMain: undefined,
+ nowSecondary: undefined,
+ showNext: false,
+ nextMain: undefined,
+ nextSecondary: undefined,
+ };
+ }
+
+ // if we are loaded, we show the upcoming event as next
+ const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
+ const nowSecondary = getPropertyValue(eventNow, secondarySource);
+ const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
+ const nextSecondary = getPropertyValue(eventNext, secondarySource);
+
+ return {
+ showNow: eventNow !== null,
+ nowMain,
+ nowSecondary,
+ showNext: eventNext !== null,
+ nextMain,
+ nextSecondary,
+ };
+}
+
+export function getFirstStartTime(firstPublicEvent: OntimeEvent | null): number | undefined {
+ return firstPublicEvent?.timeStart;
+}
diff --git a/apps/client/src/views/timer/Timer.scss b/apps/client/src/views/timer/Timer.scss
index 95e9409f4b..7358d4d2c5 100644
--- a/apps/client/src/views/timer/Timer.scss
+++ b/apps/client/src/views/timer/Timer.scss
@@ -198,8 +198,8 @@
/* =================== LOGO ===================*/
.logo {
position: absolute;
- top: min(2vh, 16px);
- left: min(2vw, 16px);
+ top: $view-block-padding;
+ left: $view-inline-padding;
max-width: min(200px, 20vw);
max-height: min(100px, 20vh);
}
diff --git a/apps/server/src/user/styles/override.css b/apps/server/src/user/styles/override.css
index e1ab8bf836..5fffb6c72d 100644
--- a/apps/server/src/user/styles/override.css
+++ b/apps/server/src/user/styles/override.css
@@ -25,6 +25,7 @@
--timer-warning-color-override: #ffbc56;
--timer-danger-color-override: #e69000;
--timer-overtime-color-override: #fa5656;
+ --timer-pending-color-override: #578AF4;
/** Background for card elements on background */
--card-background-color-override: #fff;
diff --git a/e2e/tests/features/203-delay-block.spec.ts b/e2e/tests/features/203-delay-block.spec.ts
index 40af8d9b93..6b74df6f0c 100644
--- a/e2e/tests/features/203-delay-block.spec.ts
+++ b/e2e/tests/features/203-delay-block.spec.ts
@@ -84,9 +84,9 @@ test('delays are show correctly', async ({ page }) => {
// delay is NOT shown in the public view
await page.goto('http://localhost:4001/public');
- await page.getByText('00:10 → 00:20').click();
+ await page.getByText('00:10→00:20').click();
// delay is shown in the backstage view
await page.goto('http://localhost:4001/backstage');
- await page.getByText('00:11 → 00:21').click();
+ await page.getByText('00:11→00:21').click();
});