mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 18:48:53 +00:00
Compare commits
11 Commits
v4.11.0
...
countdown-v4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a95bc43a3 | |||
| 1604762f2b | |||
| 2f8f4ad129 | |||
| 0c7c90cae4 | |||
| 1148ac1924 | |||
| 100fbabfc6 | |||
| 42810a97ca | |||
| 05a9b2af1a | |||
| 1bac3a7fd0 | |||
| 8281314ede | |||
| 3e0b3f100e |
@@ -25,11 +25,10 @@ const Operator = React.lazy(() => import('./features/operator/OperatorExport'));
|
||||
const TimerView = React.lazy(() => import('./views/timer/Timer'));
|
||||
const MinimalTimerView = React.lazy(() => import('./features/viewers/minimal-timer/MinimalTimer'));
|
||||
const ClockView = React.lazy(() => import('./features/viewers/clock/Clock'));
|
||||
const Countdown = React.lazy(() => import('./features/viewers/countdown/Countdown'));
|
||||
const Countdown = React.lazy(() => import('./views/countdown/Countdown'));
|
||||
|
||||
const Backstage = React.lazy(() => import('./views/backstage/Backstage'));
|
||||
const Timeline = React.lazy(() => import('./views/timeline/TimelinePage'));
|
||||
const Public = React.lazy(() => import('./views/public/Public'));
|
||||
const Lower = React.lazy(() => import('./features/viewers/lower-thirds/LowerThird'));
|
||||
const StudioClock = React.lazy(() => import('./features/viewers/studio/StudioClock'));
|
||||
const ProjectInfo = React.lazy(() => import('./views/project-info/ProjectInfo'));
|
||||
@@ -40,7 +39,6 @@ const SClock = withPreset(withData(ClockView));
|
||||
const SCountdown = withPreset(withData(Countdown));
|
||||
const SBackstage = withPreset(withData(Backstage));
|
||||
const SProjectInfo = withPreset(withData(ProjectInfo));
|
||||
const SPublic = withPreset(withData(Public));
|
||||
const SLowerThird = withPreset(withData(Lower));
|
||||
const SStudio = withPreset(withData(StudioClock));
|
||||
const STimeline = withPreset(withData(Timeline));
|
||||
@@ -88,14 +86,6 @@ export default function AppRouter() {
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/public'
|
||||
element={
|
||||
<ViewLoader>
|
||||
<SPublic />
|
||||
</ViewLoader>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/minimal'
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.baseButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
|
||||
width: fit-content;
|
||||
padding-inline: 1em;
|
||||
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle {
|
||||
background: $gray-1050;
|
||||
color: $blue-400;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: $blue-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $blue-600;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $blue-800;
|
||||
border-color: $blue-900;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
.medium {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.large {
|
||||
height: 3.5rem;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ButtonHTMLAttributes } from 'react';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './Button.module.scss';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'subtle' | 'primary';
|
||||
size?: 'medium' | 'large';
|
||||
}
|
||||
|
||||
export default function Button(props: ButtonProps) {
|
||||
const { className, children, variant = 'subtle', size = 'medium', ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<button className={cx([style.baseButton, style[variant], style[size], className])} type='button' {...buttonProps}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
.subtle {
|
||||
@use '../../../theme/viewerDefs' as *;
|
||||
|
||||
.baseIconButton {
|
||||
aspect-ratio: 1;
|
||||
height: 2rem;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
background: $gray-1050;
|
||||
color: $blue-400;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
background: $gray-1050;
|
||||
color: $blue-400;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active {
|
||||
&:active:not(:disabled){
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
@@ -24,3 +31,37 @@
|
||||
background: $gray-1050;
|
||||
}
|
||||
}
|
||||
|
||||
.subtle-white {
|
||||
background: $gray-1050;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $gray-1050;
|
||||
}
|
||||
}
|
||||
|
||||
.destructive {
|
||||
background: $red-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $red-600;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $red-800;
|
||||
border-color: $red-900;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './IconButton.module.scss';
|
||||
|
||||
export default function IconButton(props: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...buttonProps } = props;
|
||||
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'subtle' | 'subtle-white' | 'destructive';
|
||||
}
|
||||
|
||||
export default function IconButton(props: IconButtonProps) {
|
||||
const { className, children, variant = 'subtle', ...buttonProps } = props;
|
||||
|
||||
return (
|
||||
<button className={cx([style.subtle, className])} {...buttonProps}>
|
||||
<button className={cx([style.baseIconButton, style[variant], className])} type='button' {...buttonProps}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IoSettingsOutline } from 'react-icons/io5';
|
||||
|
||||
import { useFadeOutOnInactivity } from '../../hooks/useFadeOutOnInactivity';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
|
||||
import style from './NavigationMenu.module.scss';
|
||||
|
||||
@@ -17,22 +18,24 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
|
||||
|
||||
return (
|
||||
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
|
||||
<button
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
className={style.navButton}
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
className={style.navButton}
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</button>
|
||||
<button
|
||||
className={style.button}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
className={style.navButton}
|
||||
onClick={toggleSettings}
|
||||
aria-label='toggle settings'
|
||||
data-testid='navigation__toggle-settings'
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
$menu-hover-bg: $gray-1350;
|
||||
$menu-focus-bg: $gray-1300;
|
||||
|
||||
$icon-color: $ui-white;
|
||||
$button-bg: $gray-1050;
|
||||
$button-size: 3rem;
|
||||
|
||||
.fadeable {
|
||||
@@ -15,6 +13,7 @@ $button-size: 3rem;
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,20 +40,10 @@ $button-size: 3rem;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.button {
|
||||
font-size: 1.5rem;
|
||||
color: $icon-color;
|
||||
background-color: $button-bg;
|
||||
width: $button-size;
|
||||
height: $button-size;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.navButton {
|
||||
@extend .button;
|
||||
z-index: 3;
|
||||
font-size: 1.5rem;
|
||||
height: $button-size;
|
||||
width: $button-size;;
|
||||
}
|
||||
|
||||
.link {
|
||||
|
||||
@@ -49,11 +49,11 @@ export function useFlatRundown() {
|
||||
// update data whenever the revision changes
|
||||
useEffect(() => {
|
||||
if (data.revision !== -1 && data.revision !== prevRevision) {
|
||||
const flatRundown = data.order.map((id) => data.entries[id]);
|
||||
const flatRundown = data.flatOrder.map((id) => data.entries[id]);
|
||||
setFlatRundown(flatRundown);
|
||||
setPrevRevision(data.revision);
|
||||
}
|
||||
}, [data.entries, data.order, data.revision, prevRevision]);
|
||||
}, [data.entries, data.flatOrder, data.revision, prevRevision]);
|
||||
|
||||
// TODO: should we have a project id field?
|
||||
// invalidate current version if project changes
|
||||
|
||||
@@ -2,31 +2,38 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { throttle } from '../utils/throttle';
|
||||
|
||||
const inactiveTime = 3000; // 3 seconds
|
||||
|
||||
export const useFadeOutOnInactivity = () => {
|
||||
const [isMouseMoved, setIsMouseMoved] = useState(false);
|
||||
/**
|
||||
* Provides whether there has been mouse movement in the page in the last <inactiveTime>
|
||||
*/
|
||||
export const useFadeOutOnInactivity = (initialState = false) => {
|
||||
const [isUserActive, setIsUserActive] = useState(initialState);
|
||||
|
||||
// show on mouse move
|
||||
useEffect(() => {
|
||||
let fadeOut: NodeJS.Timeout | null = null;
|
||||
const setShowMenuTrue = () => {
|
||||
setIsMouseMoved(true);
|
||||
setIsUserActive(true);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
fadeOut = setTimeout(() => setIsMouseMoved(false), 3000);
|
||||
fadeOut = setTimeout(() => setIsUserActive(false), inactiveTime);
|
||||
};
|
||||
|
||||
const throttledShowMenu = throttle(setShowMenuTrue, 1000);
|
||||
|
||||
document.addEventListener('mousemove', throttledShowMenu);
|
||||
document.addEventListener('keydown', throttledShowMenu);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', throttledShowMenu);
|
||||
document.removeEventListener('keydown', throttledShowMenu);
|
||||
if (fadeOut) {
|
||||
clearTimeout(fadeOut);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMouseMoved;
|
||||
return isUserActive;
|
||||
};
|
||||
|
||||
@@ -164,11 +164,15 @@ export const useTimeUntilData = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
offset: state.runtime.offsetMode === OffsetMode.Absolute ? state.runtime.offset : state.runtime.relativeOffset,
|
||||
offsetMode: state.runtime.offsetMode,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0, //The day of the currently running event
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
actualStart: state.runtime.actualStart,
|
||||
plannedStart: state.runtime.plannedStart,
|
||||
}));
|
||||
|
||||
export const useCurrentDay = createSelector((state: RuntimeStore) => ({
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
}));
|
||||
|
||||
export const useRuntimeOffset = createSelector((state: RuntimeStore) => ({
|
||||
offset: state.runtime.offset,
|
||||
}));
|
||||
|
||||
@@ -118,7 +118,8 @@
|
||||
padding-inline: 0.25rem;
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
white-space: pre;
|
||||
// allow multi-line text but trim before
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
<span className={style.mainField}>{main}</span>
|
||||
<span className={style.schedule}>
|
||||
<ClockTime value={timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
-
|
||||
→
|
||||
<ClockTime value={timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</span>
|
||||
|
||||
|
||||
@@ -36,8 +36,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
},
|
||||
{
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a custom field to highlight',
|
||||
title: 'Highlight Fields',
|
||||
description: 'Choose custom fields to highlight',
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
|
||||
import { getSubscriptionDisplayData, getTimerItems, TimerMessage } from '../../../views/countdown/countdown.utils';
|
||||
import { getCountdownOptions } from './countdown.options';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function Countdown(props: CountdownProps) {
|
||||
}
|
||||
}, [backstageEvents, searchParams]);
|
||||
|
||||
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
|
||||
const { message: runningMessage, timer: runningTimer } = getSubscriptionDisplayData(time, follow, selectedId, runtime.offset);
|
||||
|
||||
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
|
||||
@@ -4,8 +4,7 @@ import { OntimeEntry, OntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
import Empty from '../../../common/components/state/Empty';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
import { sanitiseTitle } from './countdown.helpers';
|
||||
import { sanitiseTitle } from '../../../views/countdown/countdown.utils';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { fetchTimerData, sanitiseTitle, TimerMessage } from '../countdown.helpers';
|
||||
import { getSubscriptionDisplayData, sanitiseTitle, TimerMessage } from '../../../../views/countdown/countdown.utils';
|
||||
|
||||
describe('sanitiseTitle() function', () => {
|
||||
it('should return a title when valid', () => {
|
||||
@@ -26,7 +26,7 @@ describe('fetchTimerData() function', () => {
|
||||
const follow = { id: followId };
|
||||
const time = { current: currentMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, followId);
|
||||
const { message, timer } = getSubscriptionDisplayData(time, follow, followId);
|
||||
expect(message).toBe(TimerMessage.running);
|
||||
expect(timer).toBe(currentMockValue);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ describe('fetchTimerData() function', () => {
|
||||
const follow = { id: 'anotherevent', timeStart: startMockValue };
|
||||
const time = { clock: timeNow };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.toStart);
|
||||
expect(timer).toBe(startMockValue - timeNow);
|
||||
});
|
||||
@@ -50,7 +50,7 @@ describe('fetchTimerData() function', () => {
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.waiting);
|
||||
expect(timer).toBe(endMockValue - startMockValue);
|
||||
});
|
||||
@@ -63,7 +63,7 @@ describe('fetchTimerData() function', () => {
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.ended);
|
||||
expect(timer).toBe(endMockValue);
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe('fetchTimerData() function', () => {
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.waiting);
|
||||
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
|
||||
});
|
||||
@@ -89,7 +89,7 @@ describe('fetchTimerData() function', () => {
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, followId);
|
||||
const { message, timer } = getSubscriptionDisplayData(time, follow, followId);
|
||||
expect(message).toBe(TimerMessage.running);
|
||||
expect(timer).toBe(dayInMs + endMockValue - startMockValue);
|
||||
});
|
||||
@@ -102,7 +102,7 @@ describe('fetchTimerData() function', () => {
|
||||
const follow = { id: followId, timeStart: startMockValue, timeEnd: endMockValue };
|
||||
const time = { clock: timeNow, current: dayInMs + endMockValue - startMockValue };
|
||||
|
||||
const { message, timer } = fetchTimerData(time, follow, 'notthesameevent');
|
||||
const { message, timer } = getSubscriptionDisplayData(time, follow, 'notthesameevent');
|
||||
expect(message).toBe(TimerMessage.toStart);
|
||||
expect(timer).toBe(startMockValue - timeNow);
|
||||
});
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { OntimeEvent, Playback } from 'ontime-types';
|
||||
|
||||
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
|
||||
import { formatTime } from '../../../common/utils/time';
|
||||
import { isStringBoolean } from '../common/viewUtils';
|
||||
|
||||
export enum TimerMessage {
|
||||
toStart = 'to_start',
|
||||
waiting = 'waiting',
|
||||
running = 'running',
|
||||
ended = 'ended',
|
||||
unhandled = '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses string as a title
|
||||
*/
|
||||
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
|
||||
|
||||
/**
|
||||
* Returns a parsed timer and relevant status message
|
||||
*/
|
||||
export const fetchTimerData = (
|
||||
time: ViewExtendedTimer,
|
||||
follow: OntimeEvent | null,
|
||||
selectedId: string | null,
|
||||
offset: number,
|
||||
): { message: TimerMessage; timer: number } => {
|
||||
if (follow === null) {
|
||||
return { message: TimerMessage.unhandled, timer: 0 };
|
||||
}
|
||||
|
||||
if (selectedId === follow.id) {
|
||||
// if it is selected, it may not be running
|
||||
return {
|
||||
message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
|
||||
timer: time.current ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const showProjected = getShouldShowProjected();
|
||||
const addedTime = showProjected ? offset : 0;
|
||||
if (time.clock < follow.timeStart) {
|
||||
// if it hasnt started, we count to start
|
||||
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
|
||||
}
|
||||
|
||||
if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
|
||||
// if it has started, we show running timer
|
||||
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
|
||||
}
|
||||
|
||||
// running timer timer is not the one we are following
|
||||
|
||||
// ends day after
|
||||
if (follow.timeStart > follow.timeEnd) {
|
||||
if (follow.timeStart > time.clock) {
|
||||
// if it hasnt started, we count to start
|
||||
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
|
||||
}
|
||||
if (follow.timeStart <= time.clock) {
|
||||
// if it has started, we show running timer
|
||||
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
|
||||
}
|
||||
// if it has ended, we show how long ago
|
||||
return { message: TimerMessage.ended, timer: follow.timeEnd };
|
||||
}
|
||||
|
||||
// if it has ended, we show how long ago
|
||||
return { message: TimerMessage.ended, timer: follow.timeEnd };
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets values for the timer items
|
||||
*/
|
||||
export function getTimerItems(start: number | undefined, end: number | undefined, delay: number, offset: number) {
|
||||
if (start == null || end == null) {
|
||||
return {
|
||||
scheduledStart: '',
|
||||
scheduledEnd: '',
|
||||
projectedStart: '',
|
||||
projectedEnd: '',
|
||||
};
|
||||
}
|
||||
|
||||
const showProjected = getShouldShowProjected();
|
||||
const scheduledStart = formatTime(start + delay);
|
||||
const scheduledEnd = formatTime(end + delay);
|
||||
const projectedStart = showProjected ? formatTime(start + delay - offset) : '';
|
||||
const projectedEnd = showProjected ? formatTime(end + delay - offset) : '';
|
||||
|
||||
return {
|
||||
scheduledStart,
|
||||
scheduledEnd,
|
||||
projectedStart,
|
||||
projectedEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets from the URL whether the showProjected option is active
|
||||
*/
|
||||
function getShouldShowProjected() {
|
||||
const params = new URL(document.location.href).searchParams;
|
||||
return isStringBoolean(params.get('showProjected'));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ $component-border-radius-sm: 2px;
|
||||
$component-border-radius-full: 99px;
|
||||
|
||||
// semantic colours
|
||||
$action-blue: #3182ce;
|
||||
$action-blue: $blue-500;
|
||||
$action-text-color: $blue-400;
|
||||
$ontime-color: #ff7597;
|
||||
$error-red: $red-500;
|
||||
|
||||
@@ -13,7 +13,7 @@ $timer-label-size: clamp(12px, 1.25vw, 20px);
|
||||
$base-font-size: clamp(15px, 1.5vw, 28px);
|
||||
$title-font-size: clamp(18px, 2.25vw, 42px);
|
||||
$timer-value-size: clamp(24px, 2.5vw, 48px);
|
||||
$header-font-size: clamp(28px, 3.5vw, 64px);
|
||||
$header-font-size: clamp(24px, 2.5vw, 48px);
|
||||
|
||||
// General styling
|
||||
$accent-color: $red-500; // --accent-color-override
|
||||
@@ -24,8 +24,8 @@ $viewer-label-color: rgba(white, 25%);
|
||||
$viewer-background-color: $ui-black; // --background-color-override
|
||||
$viewer-color: rgba(white, 80%); // --color-override
|
||||
$viewer-secondary-color: rgba(white, 45%); // --secondary-color-override
|
||||
$viewer-card-bg-color: rgba(white, 7%); // --card-background-color-override
|
||||
$element-border-radius: 8px;
|
||||
$viewer-card-bg-color: rgba(white, 5%); // --card-background-color-override
|
||||
$element-border-radius: 4px;
|
||||
|
||||
// Generic element sizes
|
||||
$view-element-gap: min(2vh, 16px);
|
||||
|
||||
@@ -10,12 +10,12 @@ export const ontimeCheckboxOnDark = {
|
||||
opacity: 0.6,
|
||||
},
|
||||
_checked: {
|
||||
borderColor: '#3182ce', // $action-blue
|
||||
backgroundColor: '#3182ce', //$action-blue
|
||||
borderColor: '#578AF4', // $blue-500
|
||||
backgroundColor: '#578AF4', // $blue-500
|
||||
_disabled: {
|
||||
color: 'white',
|
||||
borderColor: '#3182ce', // $action-blue
|
||||
backgroundColor: '#3182ce', //$action-blue
|
||||
borderColor: '#578AF4', // $blue-500
|
||||
backgroundColor: '#578AF4', // $blue-500
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,12 +25,12 @@ export const ontimeBlockRadio = {
|
||||
backgroundColor: '#262626', // $gray-1200
|
||||
_checked: {
|
||||
borderColor: '#262626', // $gray-1200
|
||||
color: '#3182ce', // $action-blue
|
||||
backgroundColor: '#3182ce', // $action-blue
|
||||
color: '#578AF4', // $blue-500
|
||||
backgroundColor: '#578AF4', // $blue-500
|
||||
},
|
||||
_hover: {
|
||||
color: '#3182ce', // $action-blue
|
||||
backgroundColor: '#3182ce', // $action-blue
|
||||
color: '#578AF4', // $blue-500
|
||||
backgroundColor: '#578AF4', // $blue-500
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,8 +28,10 @@ const translationsList = {
|
||||
zh: langZhCn,
|
||||
};
|
||||
|
||||
export type TranslationKey = keyof typeof langEn;
|
||||
|
||||
interface TranslationContextValue {
|
||||
getLocalizedString: (key: keyof typeof langEn, lang?: string) => string;
|
||||
getLocalizedString: (key: TranslationKey, lang?: string) => string;
|
||||
}
|
||||
|
||||
const TranslationContext = createContext<TranslationContextValue>({
|
||||
@@ -40,7 +42,7 @@ export const TranslationProvider = ({ children }: PropsWithChildren) => {
|
||||
const { data } = useSettings();
|
||||
|
||||
const getLocalizedString = useCallback(
|
||||
(key: keyof typeof langEn, lang = data?.language || 'en'): string => {
|
||||
(key: TranslationKey, lang = data?.language || 'en'): string => {
|
||||
if (lang in translationsList) {
|
||||
if (key in translationsList[lang as keyof typeof translationsList]) {
|
||||
return translationsList[lang as keyof typeof translationsList][key];
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<ViewParamsEditor viewOptions={backstageOptions} />
|
||||
<div className='project-header'>
|
||||
{general?.projectLogo ? <ViewLogo name={general.projectLogo} className='logo' /> : <div className='logo' />}
|
||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||
<div className='title'>{general.title}</div>
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
@@ -176,7 +176,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSchedule && <ScheduleExport selectedId={selectedId} isBackstage />}
|
||||
{showSchedule && <ScheduleExport selectedId={selectedId} />}
|
||||
|
||||
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
|
||||
{general.backstageUrl && <QRCode value={general.backstageUrl} size={qrSize} level='L' className='qr' />}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { cx } from '../../../common/utils/styleUtils';
|
||||
|
||||
import { getScheduledTimes } from './schedule.utils';
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
import './Schedule.scss';
|
||||
|
||||
interface ScheduleProps {
|
||||
isProduction?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||
const { events, isBackstage, containerRef } = useSchedule();
|
||||
export default function Schedule({ className }: ScheduleProps) {
|
||||
const { events, containerRef } = useSchedule();
|
||||
|
||||
if (events?.length < 1) {
|
||||
return null;
|
||||
@@ -21,18 +19,15 @@ export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||
return (
|
||||
<ul className={cx(['schedule', className])} ref={containerRef}>
|
||||
{events.map((event) => {
|
||||
const { timeStart, timeEnd, delay } = getScheduledTimes(event, isProduction);
|
||||
|
||||
return (
|
||||
<ScheduleItem
|
||||
key={event.id}
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
timeStart={event.timeStart}
|
||||
timeEnd={event.timeEnd}
|
||||
title={event.title}
|
||||
colour={isBackstage ? event.colour : undefined}
|
||||
backstageEvent={!event.isPublic}
|
||||
colour={event.colour}
|
||||
skip={event.skip}
|
||||
delay={delay}
|
||||
delay={event.skip ? 0 : event.delay}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,16 +7,15 @@ import ScheduleNav from './ScheduleNav';
|
||||
|
||||
interface ScheduleExportProps {
|
||||
selectedId: MaybeString;
|
||||
isBackstage?: boolean;
|
||||
}
|
||||
|
||||
export default memo(ScheduleExport);
|
||||
function ScheduleExport(props: ScheduleExportProps) {
|
||||
const { selectedId, isBackstage } = props;
|
||||
const { selectedId } = props;
|
||||
return (
|
||||
<ScheduleProvider selectedEventId={selectedId} isBackstage={isBackstage}>
|
||||
<ScheduleProvider selectedEventId={selectedId}>
|
||||
<ScheduleNav className='schedule-nav-container' />
|
||||
<Schedule isProduction={isBackstage} className='schedule-container' />
|
||||
<Schedule className='schedule-container' />
|
||||
</ScheduleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,13 @@ interface ScheduleItemProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
title: string;
|
||||
backstageEvent: boolean;
|
||||
colour?: string;
|
||||
skip?: boolean;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
|
||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
||||
const { showProjected } = useScheduleOptions();
|
||||
|
||||
if (showProjected) {
|
||||
@@ -33,7 +32,6 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
timeEnd={timeEnd}
|
||||
title={title}
|
||||
colour={colour}
|
||||
backstageEvent={backstageEvent}
|
||||
skip={skip}
|
||||
delay={delay}
|
||||
/>
|
||||
@@ -47,7 +45,6 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
timeEnd={timeEnd}
|
||||
title={title}
|
||||
colour={colour}
|
||||
backstageEvent={backstageEvent}
|
||||
skip={skip}
|
||||
delay={delay}
|
||||
/>
|
||||
@@ -63,7 +60,6 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
<SuperscriptTime time={start} />
|
||||
→
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent && '*'}
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
@@ -71,7 +67,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
|
||||
}
|
||||
|
||||
function DelayedScheduleItem(props: ScheduleItemProps) {
|
||||
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
|
||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
||||
|
||||
const start = formatTime(timeStart, formatOptions);
|
||||
const end = formatTime(timeEnd, formatOptions);
|
||||
@@ -86,13 +82,11 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
|
||||
<SuperscriptTime time={start} />
|
||||
→
|
||||
<SuperscriptTime time={end} />
|
||||
{backstageEvent && '*'}
|
||||
</span>
|
||||
<span className='entry-times--delay'>
|
||||
<SuperscriptTime time={delayedStart} />
|
||||
→
|
||||
<SuperscriptTime time={delayedEnd} />
|
||||
{backstageEvent && '*'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
@@ -101,7 +95,7 @@ function DelayedScheduleItem(props: ScheduleItemProps) {
|
||||
}
|
||||
|
||||
function ProjectedScheduleItem(props: ScheduleItemProps) {
|
||||
const { timeStart, timeEnd, title, backstageEvent, colour, skip, delay } = props;
|
||||
const { timeStart, timeEnd, title, colour, skip, delay } = props;
|
||||
|
||||
return (
|
||||
<li className={cx(['entry', skip && 'entry--skip'])}>
|
||||
@@ -110,7 +104,6 @@ function ProjectedScheduleItem(props: ScheduleItemProps) {
|
||||
<ProjectedTime time={timeStart} delay={delay} />
|
||||
→
|
||||
<ProjectedTime time={timeEnd} delay={delay} />
|
||||
{backstageEvent && '*'}
|
||||
</div>
|
||||
<div className='entry-title'>{title}</div>
|
||||
</li>
|
||||
|
||||
@@ -19,14 +19,14 @@ export const scheduleOptions: ViewOption = {
|
||||
{
|
||||
id: 'cycleInterval',
|
||||
title: 'Cycle interval',
|
||||
description: 'How long (in seconds) should each schedule page be shown.',
|
||||
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.',
|
||||
description: 'Whether scheduled times should account for runtime offset',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
@use '../../theme/viewerDefs' as *;
|
||||
|
||||
$item-height: 3.5rem;
|
||||
|
||||
.countdown {
|
||||
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);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $view-element-gap;
|
||||
padding: $view-outer-padding;
|
||||
font-size: $base-font-size;
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
.project-header {
|
||||
font-size: $header-font-size;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: min(200px, 20vw);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
||||
right: $view-inline-padding;
|
||||
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
gap: 2rem;
|
||||
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.fab-container--hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ========================= LIST ========================*/
|
||||
.empty-container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ====================== LIST-ITEM ======================*/
|
||||
.sub {
|
||||
margin: 2px;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1rem 1fr auto;
|
||||
grid-template-areas:
|
||||
'binder schedule status'
|
||||
'binder title timer'
|
||||
'binder secondary secondary';
|
||||
column-gap: 1rem;
|
||||
background: $viewer-card-bg-color;
|
||||
padding-right: 1rem;
|
||||
border-radius: $element-border-radius;
|
||||
overflow: clip;
|
||||
|
||||
&:hover {
|
||||
.sub__label {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub--selected {
|
||||
background: $blue-700;
|
||||
}
|
||||
|
||||
.sub--live {
|
||||
background-color: $green-700;
|
||||
}
|
||||
|
||||
.sub__binder {
|
||||
background: var(--user-color, $viewer-card-bg-color);
|
||||
grid-area: binder;
|
||||
}
|
||||
|
||||
.sub__schedule {
|
||||
grid-area: schedule;
|
||||
padding-top: 0.5rem;
|
||||
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
font-size: $timer-label-size;
|
||||
}
|
||||
|
||||
.sub__schedule--delayed {
|
||||
color: $delay-color;
|
||||
}
|
||||
|
||||
.sub__schedule--ahead {
|
||||
color: $green-500;
|
||||
}
|
||||
|
||||
.sub__schedule--behind {
|
||||
color: $orange-500;
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
grid-area: title;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
font-size: $title-font-size;
|
||||
line-height: 1.1em;
|
||||
}
|
||||
|
||||
.sub__secondary {
|
||||
grid-area: secondary;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
font-size: $base-font-size;
|
||||
line-height: 1.1em;
|
||||
// allow multi-line text but trim before
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.sub__status {
|
||||
grid-area: status;
|
||||
padding-top: 0.5rem;
|
||||
|
||||
font-size: $timer-label-size;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sub__label {
|
||||
grid-area: status;
|
||||
padding-top: 0.5rem;
|
||||
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sub__timer {
|
||||
grid-area: timer;
|
||||
|
||||
font-size: $timer-value-size;
|
||||
line-height: 1.1em;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ====================== MODIFIERS ======================*/
|
||||
.subdued {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
import {
|
||||
CustomFields,
|
||||
EntryId,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
OntimeEvent,
|
||||
ProjectData,
|
||||
Settings,
|
||||
} from 'ontime-types';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
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 { formatTime, getDefaultFormat } from '../../common/utils/time';
|
||||
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
import CountdownSubscriptions from './CountdownSubscriptions';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownProps {
|
||||
backstageEvents: OntimeEvent[];
|
||||
customFields: CustomFields;
|
||||
general: ProjectData;
|
||||
time: ViewExtendedTimer;
|
||||
isMirrored: boolean;
|
||||
selectedId: EntryId | null;
|
||||
settings: Settings | undefined;
|
||||
}
|
||||
|
||||
export default function Countdown(props: CountdownProps) {
|
||||
const { backstageEvents, customFields, general, time, isMirrored, selectedId, settings } = props;
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { subscriptions } = useCountdownOptions();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
useWindowTitle('Countdown');
|
||||
|
||||
// gather rundown data
|
||||
const playableEvents = backstageEvents.filter((event) => isOntimeEvent(event) && isPlayableEvent(event));
|
||||
|
||||
// gather timer data
|
||||
const clock = formatTime(time.clock);
|
||||
|
||||
// gather presentation data
|
||||
const hasEvents = playableEvents.length > 0;
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const countdownOptions = getCountdownOptions(defaultFormat, customFields);
|
||||
|
||||
return (
|
||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||
<ViewParamsEditor viewOptions={countdownOptions} />
|
||||
<div className='project-header'>
|
||||
{general?.projectLogo && <ViewLogo name={general.projectLogo} className='logo' />}
|
||||
<div className='title'>{general.title}</div>
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<SuperscriptTime time={clock} className='time' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
|
||||
|
||||
{hasEvents && editMode && (
|
||||
<CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
|
||||
)}
|
||||
|
||||
{hasEvents && !editMode && (
|
||||
<CountdownContents
|
||||
playableEvents={playableEvents}
|
||||
subscriptions={subscriptions}
|
||||
time={time}
|
||||
goToEditMode={() => setEditMode(true)}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CountdownContentsProps {
|
||||
playableEvents: OntimeEvent[];
|
||||
selectedId: EntryId | null;
|
||||
subscriptions: EntryId[];
|
||||
time: ViewExtendedTimer;
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
function CountdownContents(props: CountdownContentsProps) {
|
||||
const { playableEvents, selectedId, subscriptions, time, goToEditMode } = props;
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return (
|
||||
<div className='empty-container'>
|
||||
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
|
||||
<Button variant='primary' size='large' onClick={goToEditMode}>
|
||||
<IoAdd /> Add
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CountdownSubscriptions
|
||||
events={playableEvents}
|
||||
selectedId={selectedId}
|
||||
subscriptions={subscriptions}
|
||||
time={time}
|
||||
goToEditMode={goToEditMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { EntryId, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
||||
|
||||
import { makeSubscriptionsUrl } from './countdown.utils';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSelectProps {
|
||||
events: OntimeEvent[];
|
||||
subscriptions: EntryId[];
|
||||
disableEdit: () => void;
|
||||
}
|
||||
|
||||
export default function CountdownSelect(props: CountdownSelectProps) {
|
||||
const { events, subscriptions, disableEdit } = props;
|
||||
const [selected, setSelected] = useState<EntryId[]>(subscriptions);
|
||||
const navigate = useNavigate();
|
||||
|
||||
/**
|
||||
* Toggles an entry from the selected set
|
||||
*/
|
||||
const toggleSelect = (entryId: EntryId) => {
|
||||
setSelected((prev) => {
|
||||
if (prev.includes(entryId)) {
|
||||
// If the entry is already selected, remove it
|
||||
return prev.filter((id) => id !== entryId);
|
||||
}
|
||||
return [...prev, entryId];
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a URL with the selected subscriptions
|
||||
* and navigates to it
|
||||
*/
|
||||
const applySelection = () => {
|
||||
const url = makeSubscriptionsUrl(window.location.href, selected);
|
||||
disableEdit();
|
||||
navigate(url.search.toString());
|
||||
};
|
||||
|
||||
// make a copy of the selected array for quick lookup
|
||||
const selectedIds = new Set(selected);
|
||||
|
||||
return (
|
||||
<div className='list-container'>
|
||||
{events.map((event: OntimeEvent, index: number) => {
|
||||
const title = event.title || '{no title}';
|
||||
const isSelected = selectedIds.has(event.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => toggleSelect(event.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
toggleSelect(event.id);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className={cx(['sub', isSelected && 'sub--selected'])}
|
||||
>
|
||||
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
|
||||
<div className='sub__schedule'>
|
||||
<ClockTime value={event.timeStart} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
→
|
||||
<ClockTime value={event.timeEnd} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</div>
|
||||
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
|
||||
<div className='sub__title'>{title}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className='fab-container'>
|
||||
<Button variant='subtle' size='large' onClick={disableEdit}>
|
||||
<IoArrowBack /> Go back
|
||||
</Button>
|
||||
<Button variant='subtle' size='large' onClick={() => setSelected([])} disabled={selected.length === 0}>
|
||||
<IoClose /> Clear
|
||||
</Button>
|
||||
<Button variant='primary' size='large' disabled={events.length < 1} onClick={applySelection}>
|
||||
<IoSaveOutline /> Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { IoPencil } from 'react-icons/io5';
|
||||
import { EntryId, OntimeEvent, Playback } from 'ontime-types';
|
||||
|
||||
import Button from '../../common/components/buttons/Button';
|
||||
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
import { useCurrentDay, useRuntimeOffset } from '../../common/hooks/useSocket';
|
||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import FollowButton from '../../features/operator/follow-button/FollowButton';
|
||||
import ClockTime from '../../features/viewers/common/clock-time/ClockTime';
|
||||
import { getPropertyValue } from '../../features/viewers/common/viewUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { getOrderedSubscriptions, getSubscriptionDisplayData, sanitiseTitle, timerProgress } from './countdown.utils';
|
||||
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSubscriptionsProps {
|
||||
events: OntimeEvent[];
|
||||
selectedId: EntryId | null;
|
||||
subscriptions: EntryId[];
|
||||
time: ViewExtendedTimer;
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
export default function CountdownSubscriptions(props: CountdownSubscriptionsProps) {
|
||||
const { time, events, selectedId, goToEditMode } = props;
|
||||
const { followSelected, secondarySource, subscriptions, showProjected } = useCountdownOptions();
|
||||
const showFab = useFadeOutOnInactivity(true);
|
||||
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollToComponent = useFollowComponent({
|
||||
followRef: selectedRef,
|
||||
scrollRef,
|
||||
doFollow: !lockAutoScroll,
|
||||
topOffset: 0,
|
||||
});
|
||||
|
||||
// reset scroll if nothing is selected
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
if (!lockAutoScroll) {
|
||||
scrollRef.current?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
}, [selectedId, lockAutoScroll, scrollRef]);
|
||||
|
||||
// scroll to component if user clicks the Follow button
|
||||
const handleOffset = () => {
|
||||
if (selectedId) {
|
||||
scrollToComponent();
|
||||
}
|
||||
setLockAutoScroll(false);
|
||||
};
|
||||
|
||||
// gather data
|
||||
const subscribedEvents = getOrderedSubscriptions(subscriptions, events);
|
||||
|
||||
return (
|
||||
<div className='list-container'>
|
||||
{subscribedEvents.map((event) => {
|
||||
const secondaryData = getPropertyValue(event, secondarySource);
|
||||
const isLive = event.id === selectedId && time.playback !== Playback.Armed;
|
||||
|
||||
return (
|
||||
<div key={event.id} className={cx(['sub', isLive && 'sub--live'])}>
|
||||
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
||||
<div className={cx(['sub__schedule', event.delay > 0 && 'sub__schedule--delayed'])}>
|
||||
{showProjected ? (
|
||||
<ProjectedSchedule timeStart={event.timeStart} timeEnd={event.timeEnd} delay={event.delay} />
|
||||
) : (
|
||||
<>
|
||||
<ClockTime value={event.timeStart + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
→
|
||||
<ClockTime value={event.timeEnd + event.delay} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SubscriptionStatus key={event.id} event={event} selectedId={selectedId} time={time} />
|
||||
<div className={cx(['sub__title', !event.title && 'subdued'])}>{sanitiseTitle(event.title)}</div>
|
||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={cx(['fab-container', !showFab && 'fab-container--hidden'])}>
|
||||
<Button variant='primary' size='large' disabled={events.length < 1} onClick={goToEditMode}>
|
||||
<IoPencil /> Edit
|
||||
</Button>
|
||||
</div>
|
||||
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectedScheduleProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
delay: number;
|
||||
}
|
||||
function ProjectedSchedule(props: ProjectedScheduleProps) {
|
||||
const { timeStart, timeEnd, delay } = props;
|
||||
|
||||
const { offset } = useRuntimeOffset();
|
||||
|
||||
// offset is negative if we are ahead
|
||||
const projectedOffset = offset - delay;
|
||||
|
||||
const classes = cx([projectedOffset > 0 && 'sub__schedule--ahead', projectedOffset < 0 && 'sub__schedule--behind']);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClockTime
|
||||
value={timeStart - projectedOffset}
|
||||
className={classes}
|
||||
preferredFormat12='h:mm'
|
||||
preferredFormat24='HH:mm'
|
||||
/>
|
||||
→
|
||||
<ClockTime value={timeEnd - projectedOffset} preferredFormat12='h:mm' preferredFormat24='HH:mm' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
time: ViewExtendedTimer;
|
||||
event: OntimeEvent;
|
||||
selectedId: EntryId | null;
|
||||
}
|
||||
|
||||
function SubscriptionStatus(props: SubscriptionStatusProps) {
|
||||
const { time, event, selectedId } = props;
|
||||
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { currentDay } = useCurrentDay();
|
||||
const { offset } = useRuntimeOffset();
|
||||
const { showProjected } = useCountdownOptions();
|
||||
|
||||
// TODO: use reporter values as in the event block chip
|
||||
const { status, timer } = getSubscriptionDisplayData(
|
||||
time,
|
||||
event,
|
||||
selectedId,
|
||||
offset,
|
||||
currentDay,
|
||||
getLocalizedString('common.minutes'),
|
||||
showProjected,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='sub__status'>{getLocalizedString(timerProgress[status])}</div>
|
||||
<div className='sub__timer'>{timer}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomFields, EntryId, 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 { isStringBoolean } from '../../features/viewers/common/viewUtils';
|
||||
|
||||
export const getCountdownOptions = (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: 'sub',
|
||||
title: 'Event subscription',
|
||||
description: 'The events to follow',
|
||||
value: '',
|
||||
type: 'persist',
|
||||
},
|
||||
{
|
||||
// TODO: adding a secondary source is removing the subscriptions
|
||||
// this seems to be a bug with persist assuming that the property has a single entry
|
||||
id: 'secondary-src',
|
||||
title: 'Event secondary text',
|
||||
description: 'Select the data source for auxiliary text shown in the card',
|
||||
type: 'option',
|
||||
values: secondaryOptions,
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: OptionTitle.ElementVisibility,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'followSelected',
|
||||
title: 'Follow selected event',
|
||||
description: 'Whether the view should automatically scroll to the selected event',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide Past Events',
|
||||
description: 'Whether to hide events that have passed',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: OptionTitle.BehaviourOptions,
|
||||
collapsible: true,
|
||||
options: [
|
||||
{
|
||||
id: 'showProjected',
|
||||
title: 'Show projected time',
|
||||
description: 'Whether scheduled times should account for runtime offset',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
type CountdownOptions = {
|
||||
subscriptions: EntryId[];
|
||||
secondarySource: keyof OntimeEvent | null;
|
||||
showProjected: boolean;
|
||||
followSelected: boolean;
|
||||
hidePast: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility extract the view options from URL Params
|
||||
* the names and fallback are manually matched with timerOptions
|
||||
*/
|
||||
function getOptionsFromParams(searchParams: URLSearchParams): CountdownOptions {
|
||||
// we manually make an object that matches the key above
|
||||
return {
|
||||
subscriptions: searchParams.getAll('sub') as EntryId[],
|
||||
secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null,
|
||||
showProjected: isStringBoolean(searchParams.get('showProjected')),
|
||||
followSelected: isStringBoolean(searchParams.get('followSelected')),
|
||||
hidePast: isStringBoolean(searchParams.get('hidePast')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook exposes the backstage view options
|
||||
*/
|
||||
export function useCountdownOptions(): CountdownOptions {
|
||||
const [searchParams] = useSearchParams();
|
||||
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
|
||||
return options;
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { EntryId, OntimeEvent, Playback, TimerType } from 'ontime-types';
|
||||
|
||||
import { ViewExtendedTimer } from '../../common/models/TimeManager.type';
|
||||
import { getFormattedTimer } from '../../features/viewers/common/viewUtils';
|
||||
import type { TranslationKey } from '../../translation/TranslationProvider';
|
||||
|
||||
/**
|
||||
* Parses string as a title
|
||||
*/
|
||||
export const sanitiseTitle = (title: string | null) => (title ? title : '{no title}');
|
||||
|
||||
const subscriptionTimerDisplayOptions = {
|
||||
removeSeconds: true,
|
||||
removeLeadingZero: true,
|
||||
} as const;
|
||||
|
||||
type TimerMessage = Record<string, TranslationKey>;
|
||||
export type ProgressStatus = 'future' | 'due' | 'live' | 'done';
|
||||
|
||||
export const timerProgress: TimerMessage = {
|
||||
future: 'countdown.to_start',
|
||||
due: 'timeline.due',
|
||||
live: 'timeline.live',
|
||||
done: 'countdown.ended',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a parsed timer and relevant status message
|
||||
* Handles events in different days but disregards whether an event has actually played
|
||||
* TODO: get data from reporter and check if the event has played
|
||||
* TODO: get timer data granularly
|
||||
*/
|
||||
export function getSubscriptionDisplayData(
|
||||
time: ViewExtendedTimer,
|
||||
subscribedEvent: OntimeEvent,
|
||||
selectedId: EntryId | null,
|
||||
offset: number,
|
||||
currentDay: number,
|
||||
minutesString: string,
|
||||
showProjected = false,
|
||||
): { status: ProgressStatus; timer: string } {
|
||||
const addedTime = showProjected ? offset : 0;
|
||||
|
||||
if (selectedId === subscribedEvent.id) {
|
||||
// 1. An event that is loaded but not running is {'due': <countdown | overtime>}
|
||||
if (time.playback === Playback.Armed) {
|
||||
// if we are following the event, but it is not running, we show the armed timer
|
||||
return {
|
||||
status: 'due',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + addedTime,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 2. An event with a time-to-start lower than 0 is {'due': <countdown | scheduledStart>}
|
||||
return {
|
||||
status: 'live',
|
||||
timer: getFormattedTimer(time.current, TimerType.CountDown, minutesString, subscriptionTimerDisplayOptions),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the running timer is not the one we are following
|
||||
* we can be in future, due or have ended
|
||||
*/
|
||||
|
||||
// 3. event is the day after
|
||||
if (subscribedEvent.dayOffset > currentDay) {
|
||||
return {
|
||||
status: 'future',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart - time.clock - addedTime,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 4. event is the before after, show the scheduled end
|
||||
if (subscribedEvent.dayOffset < currentDay) {
|
||||
return {
|
||||
status: 'done',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeEnd,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. if event is in future, we count to the scheduled start
|
||||
if (time.clock < subscribedEvent.timeStart) {
|
||||
return {
|
||||
status: 'future',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart - time.clock - addedTime,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 6. if event has ended, we count to the scheduled end
|
||||
if (time.clock > subscribedEvent.timeEnd) {
|
||||
return {
|
||||
status: 'done',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeEnd,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// the event here has to be due
|
||||
return {
|
||||
status: 'due',
|
||||
timer: getFormattedTimer(
|
||||
subscribedEvent.timeStart + addedTime,
|
||||
TimerType.CountDown,
|
||||
minutesString,
|
||||
subscriptionTimerDisplayOptions,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a set of subscriptions to the URL parameters
|
||||
*/
|
||||
export function makeSubscriptionsUrl(urlRef: string, subscriptions: EntryId[]) {
|
||||
const url = new URL(urlRef);
|
||||
const newParams = new URLSearchParams();
|
||||
|
||||
// copy existing parameters except for 'sub'
|
||||
for (const [key, value] of url.searchParams.entries()) {
|
||||
if (key !== 'sub') {
|
||||
newParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// add new subscriptions
|
||||
subscriptions.forEach((id) => {
|
||||
newParams.append('sub', id);
|
||||
});
|
||||
|
||||
url.search = newParams.toString();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of events subscribed events ordered by scheduled
|
||||
* Since the original array is already ordered, we simply filter out the events
|
||||
* which are not in the subscriptions list.
|
||||
*/
|
||||
export function getOrderedSubscriptions(subscriptions: EntryId[], playableEvents: OntimeEvent[]): OntimeEvent[] {
|
||||
return playableEvents.filter((event) => subscriptions.includes(event.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks through the rundown whether the current event is linked to the loaded event
|
||||
*/
|
||||
export function isLinkedToLoadedEvent(events: OntimeEvent[], loadedId: EntryId | null, currentId: EntryId): boolean {
|
||||
// if nothing is loaded, we return true to simplify the logic
|
||||
if (!loadedId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const loadedIndex = events.findIndex((event) => event.id === loadedId);
|
||||
if (loadedIndex === -1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = loadedIndex; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
if (event.id === currentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.linkStart === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
|
||||
.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 img{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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 = (() => {
|
||||
if (showNow) return undefined;
|
||||
if (!hasEvents) return undefined;
|
||||
return 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
|
||||
const showPending = scheduledStart !== undefined;
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const publicOptions = getPublicOptions(defaultFormat, customFields);
|
||||
|
||||
return (
|
||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||
<ViewParamsEditor viewOptions={publicOptions} />
|
||||
<div className='project-header'>
|
||||
{general?.projectLogo ? <ViewLogo name={general.projectLogo} className='logo' /> : <div className='logo' />}
|
||||
<div className='title'>{general.title}</div>
|
||||
<div className='clock-container'>
|
||||
<div className='label'>{getLocalizedString('common.time_now')}</div>
|
||||
<SuperscriptTime time={clock} className='time' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasEvents && <Empty text={getLocalizedString('countdown.waiting')} className='empty-container' />}
|
||||
|
||||
<div className='card-container'>
|
||||
{showNow && hasEvents && (
|
||||
<TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />
|
||||
)}
|
||||
{showPending && (
|
||||
<div className='event'>
|
||||
<div className='title-card__placeholder'>{getLocalizedString('countdown.waiting')}</div>
|
||||
<div className='timer-group'>
|
||||
<div className='time-entry'>
|
||||
<div className={cx(['time-entry__label', isPendingStart && 'time-entry--pending'])}>
|
||||
{getLocalizedString('common.scheduled_start')}
|
||||
</div>
|
||||
<SuperscriptTime time={formatTime(scheduledStart)} className='time-entry__value' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showNext && hasEvents && (
|
||||
<TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSchedule && <ScheduleExport selectedId={publicSelectedId} />}
|
||||
|
||||
<div className={cx(['info', !showSchedule && 'info--stretch'])}>
|
||||
{general.publicUrl && <QRCode value={general.publicUrl} size={qrSize} level='L' className='qr' />}
|
||||
{general.publicInfo && <div className='info__message'>{general.publicInfo}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user