diff --git a/apps/client/src/common/components/view-logo/ViewLogo.scss b/apps/client/src/common/components/view-logo/ViewLogo.scss
new file mode 100644
index 000000000..f2b2491a9
--- /dev/null
+++ b/apps/client/src/common/components/view-logo/ViewLogo.scss
@@ -0,0 +1,5 @@
+.viewLogo {
+ max-width: 100%;
+ max-height: 100%;
+ display: block;
+}
diff --git a/apps/client/src/common/components/view-logo/ViewLogo.tsx b/apps/client/src/common/components/view-logo/ViewLogo.tsx
index 6ddde4593..6ed80ae1f 100644
--- a/apps/client/src/common/components/view-logo/ViewLogo.tsx
+++ b/apps/client/src/common/components/view-logo/ViewLogo.tsx
@@ -1,5 +1,7 @@
import { projectLogoPath } from '../../api/constants';
+import './ViewLogo.scss';
+
interface ViewLogoProps {
name: string;
className: string;
@@ -7,5 +9,11 @@ interface ViewLogoProps {
export default function ViewLogo(props: ViewLogoProps) {
const { name, className } = props;
- return
;
+
+ // we wrap the image in a div to help maintain the aspect ratio
+ return (
+
+
{general.backstageUrl &&
}
{general.backstageInfo &&
{general.backstageInfo}
}
diff --git a/apps/client/src/views/backstage/backstage.options.ts b/apps/client/src/views/backstage/backstage.options.ts
index ccf4f94e3..a0b2b414b 100644
--- a/apps/client/src/views/backstage/backstage.options.ts
+++ b/apps/client/src/views/backstage/backstage.options.ts
@@ -8,7 +8,7 @@ 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';
+import { scheduleOptions } from '../common/schedule/schedule.options';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
@@ -29,26 +29,7 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel
},
],
},
- {
- 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,
- },
- ],
- },
+ scheduleOptions,
];
};
@@ -75,24 +56,3 @@ export function useBackstageOptions(): BackstageOptions {
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
-
-type ScheduleOptions = {
- cycleInterval: number;
- stopCycle: boolean;
-};
-
-function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
- return {
- cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
- stopCycle: isStringBoolean(searchParams.get('stopCycle')),
- };
-}
-
-/**
- * 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/backstage/backstage.utils.ts b/apps/client/src/views/backstage/backstage.utils.ts
index 03086b3c5..7e5e5992a 100644
--- a/apps/client/src/views/backstage/backstage.utils.ts
+++ b/apps/client/src/views/backstage/backstage.utils.ts
@@ -1,5 +1,6 @@
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
+import { enDash } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
/**
@@ -45,16 +46,16 @@ export function getCardData(
}
// if we are loaded, we show the upcoming event as next
- const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
+ const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
const nowSecondary = getPropertyValue(eventNow, secondarySource);
- const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
+ const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
const nextSecondary = getPropertyValue(eventNext, secondarySource);
return {
- showNow: Boolean(nowMain) || Boolean(nowSecondary),
+ showNow: eventNow !== null,
nowMain,
nowSecondary,
- showNext: Boolean(nextMain) || Boolean(nextSecondary),
+ showNext: eventNext !== null,
nextMain,
nextSecondary,
};
diff --git a/apps/client/src/views/common/schedule/ScheduleContext.tsx b/apps/client/src/views/common/schedule/ScheduleContext.tsx
index a690c3d76..b8a79eb80 100644
--- a/apps/client/src/views/common/schedule/ScheduleContext.tsx
+++ b/apps/client/src/views/common/schedule/ScheduleContext.tsx
@@ -11,7 +11,8 @@ import {
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
-import { useScheduleOptions } from '../../backstage/backstage.options';
+
+import { useScheduleOptions } from './schedule.options';
interface ScheduleContextState {
events: OntimeEvent[];
diff --git a/apps/client/src/views/common/schedule/ScheduleItem.tsx b/apps/client/src/views/common/schedule/ScheduleItem.tsx
index b67c06a99..f2ffb8b2f 100644
--- a/apps/client/src/views/common/schedule/ScheduleItem.tsx
+++ b/apps/client/src/views/common/schedule/ScheduleItem.tsx
@@ -35,13 +35,13 @@ export default function ScheduleItem(props: ScheduleItemProps) {
- {' → '}
+ →
{backstageEvent && '*'}
- {' → '}
+ →
{backstageEvent && '*'}
@@ -56,7 +56,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
- {' → '}
+ →
{backstageEvent && '*'}
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 000000000..b70fc15b9
--- /dev/null
+++ b/apps/client/src/views/common/schedule/schedule.options.ts
@@ -0,0 +1,48 @@
+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,
+ },
+ ],
+};
+
+type ScheduleOptions = {
+ cycleInterval: number;
+ stopCycle: boolean;
+};
+
+function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions {
+ return {
+ cycleInterval: Number(searchParams.get('cycleInterval')) || 10,
+ stopCycle: isStringBoolean(searchParams.get('stopCycle')),
+ };
+}
+
+/**
+ * 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/public/Public.scss b/apps/client/src/views/public/Public.scss
index e4c577677..9c306b604 100644
--- a/apps/client/src/views/public/Public.scss
+++ b/apps/client/src/views/public/Public.scss
@@ -10,23 +10,30 @@
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, $viewer-background-color);
color: var(--color-override, $viewer-color);
- gap: min(2vh, 16px);
- padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
+ gap: $view-element-gap;
+ padding: $view-outer-padding;
display: grid;
- grid-template-columns: 1fr 1fr 40vw;
+ grid-template-columns: 1fr 40vw;
grid-template-rows: auto 12px 1fr auto;
grid-template-areas:
- ' header header header'
- ' progress progress schedule-nav'
- ' now now schedule'
- ' info info schedule';
+ 'header header'
+ 'now schedule-nav'
+ 'info schedule';
+
+ .empty-container {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin-top: 25vh;
+ }
/* =================== HEADER + EXTRAS ===================*/
.project-header {
grid-area: header;
- font-size: clamp(32px, 4.5vw, 64px);
+ font-size: $header-font-size;
font-weight: 600;
display: flex;
gap: 1rem;
@@ -37,18 +44,22 @@
max-height: min(100px, 20vh);
}
+ .title {
+ line-height: 1.1em;
+ }
+
.clock-container {
margin-left: auto;
+ font-weight: 600;
.label {
- font-size: clamp(16px, 1.5vw, 24px);
- font-weight: 600;
+ font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
- font-size: clamp(32px, 3.5vw, 50px);
+ font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
@@ -57,50 +68,136 @@
/* =================== MAIN - NOW ===================*/
- .now-container {
+ .card-container {
grid-area: now;
display: flex;
flex-direction: column;
- gap: min(2vh, 16px);
+ gap: $view-element-gap;
}
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
- padding: 16px 24px;
- border-radius: 8px;
+ 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;
+ }
+
+ .aux-timers {
+ &__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%;
- margin-left: clamp(16px, 5vw, 64px);
+ padding-left: $schedule-left-spacing;
}
.schedule-nav-container {
grid-area: schedule-nav;
- align-self: center;
+ 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: clamp(16px, 1.5vw, 24px);
- line-height: 1.3em;
+ font-size: $base-font-size;
+ line-height: 1.2em;
white-space: pre-line;
overflow: hidden;
flex: 1;
}
.qr {
- padding: 4px;
- background-color: white;
+ 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
index 3f174100a..d7e4b59a4 100644
--- a/apps/client/src/views/public/Public.tsx
+++ b/apps/client/src/views/public/Public.tsx
@@ -1,28 +1,28 @@
import QRCode from 'react-qr-code';
-import { useSearchParams } from 'react-router-dom';
-import { AnimatePresence, motion } from 'framer-motion';
+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 { getPropertyValue } from '../../features/viewers/common/viewUtils';
import { useTranslation } from '../../translation/TranslationProvider';
+import { getIsPendingStart } from '../backstage/backstage.utils';
import PublicSchedule from '../common/schedule/PublicSchedule';
-import { titleVariants } from '../timer/timer.animations';
-import { getPublicOptions } from './public.options';
+import { getPublicOptions, usePublicOptions } from './public.options';
+import { getCardData, getFirstStartTime } from './public.utils';
import './Public.scss';
-export const MotionTitleCard = motion(TitleCard);
-
interface BackstageProps {
customFields: CustomFields;
+ events: OntimeEvent[];
general: ProjectData;
isMirrored: boolean;
publicEventNow: OntimeEvent | null;
@@ -33,73 +33,85 @@ interface BackstageProps {
}
export default function Public(props: BackstageProps) {
- const { customFields, general, isMirrored, publicEventNow, publicEventNext, time, publicSelectedId, settings } =
- props;
+ const {
+ customFields,
+ events,
+ general,
+ isMirrored,
+ publicEventNow,
+ publicEventNext,
+ time,
+ publicSelectedId,
+ settings,
+ } = props;
const { getLocalizedString } = useTranslation();
- const [searchParams] = useSearchParams();
+ const { secondarySource } = usePublicOptions();
+ const { height: screenHeight } = useViewportSize();
useWindowTitle('Public Schedule');
- const clock = formatTime(time.clock);
- const qrSize = Math.max(window.innerWidth / 15, 128);
+ // gather card data
+ 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 = showNow ? '' : getFirstStartTime(events[0]);
+
+ // gather presentation styles
+ const qrSize = Math.max(window.innerWidth / 15, 72);
+ const showSchedule = screenHeight > 700; // in vertical screens we may not have space
+
+ // gather option data
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const publicOptions = getPublicOptions(defaultFormat, customFields);
- const secondarySource = searchParams.get('secondary-src');
- const secondaryTextNext = getPropertyValue(publicEventNext, secondarySource);
- const secondaryTextNow = getPropertyValue(publicEventNow, secondarySource);
-
return (
- {general?.projectLogo &&
}
- {general.title}
+ {general?.projectLogo ?
:
}
+
{general.title}
{getLocalizedString('common.time_now')}
-
-
- {publicEventNow && (
-
- )}
-
+ {events.length === 0 && (
+
+
+
+ )}
-
- {publicEventNext && (
-
- )}
-
+
+ {showNow &&
}
+ {!showNow && scheduledStart && (
+
+
{getLocalizedString('countdown.waiting')}
+
+
+
+ {getLocalizedString('common.scheduled_start')}
+
+
+
+
+
+ )}
+ {showNext &&
}
-
+ {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
index 210abf12e..821de86c1 100644
--- a/apps/client/src/views/public/public.options.ts
+++ b/apps/client/src/views/public/public.options.ts
@@ -1,4 +1,6 @@
-import { CustomFields } from 'ontime-types';
+import { useMemo } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { CustomFields, OntimeEvent } from 'ontime-types';
import {
getTimeOption,
@@ -6,6 +8,7 @@ import {
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' });
@@ -26,32 +29,30 @@ export const getPublicOptions = (timeFormat: string, customFields: CustomFields)
},
],
},
- {
- title: OptionTitle.Schedule,
- collapsible: true,
- options: [
- {
- id: 'eventsPerPage',
- title: 'Events per page',
- description: 'Sets the number of events on the page, can cause overflow',
- type: 'number',
- placeholder: '8 (default)',
- },
- {
- id: 'hidePast',
- title: 'Hide past events',
- description: 'Scheduler will only show upcoming events',
- type: 'boolean',
- defaultValue: false,
- },
- {
- id: 'stopCycle',
- title: 'Stop cycling through event pages',
- description: 'Schedule will not auto-cycle through events',
- type: 'boolean',
- defaultValue: false,
- },
- ],
- },
+ 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 000000000..a0b463b8b
--- /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;
+}