mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-02 05:57:59 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b13d5792f3 | |||
| d0027d4016 | |||
| ae1639802c | |||
| 9167a50eda | |||
| 8adca908b3 | |||
| bf99646ad5 | |||
| 308285ce3e |
@@ -1,12 +0,0 @@
|
||||
@use "../../../theme/mixins" as *;
|
||||
|
||||
.link {
|
||||
@include action-link;
|
||||
font-size: $inner-section-text-size;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
margin-left: $element-inner-spacing;
|
||||
display: inline-block;
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
|
||||
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function AppLink(props: AppLinkProps) {
|
||||
const { href, children } = props;
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
openLink(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<a href='#!' target='_blank' rel='noreferrer' className={style.link} onClick={handleClick}>
|
||||
{children} <IoArrowUp className={style.linkIcon} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,3 @@
|
||||
.label {
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: $label-gray;
|
||||
font-size: $text-body-size;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { navigatorConstants } from '../../../viewerConfig';
|
||||
import { setClientRemote } from '../../hooks/useSocket';
|
||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||
import Info from '../info/Info';
|
||||
import AppLink from '../link/app-link/AppLink';
|
||||
|
||||
import style from './RedirectClientModal.module.scss';
|
||||
|
||||
@@ -58,9 +59,7 @@ export function RedirectClientModal(props: RedirectClientModalProps) {
|
||||
Either by selecting a URL Preset or entering a custom path.
|
||||
<br />
|
||||
<br />
|
||||
<a href='/editor?settings=feature_settings__urlpresets' target='_blank' className={style.link}>
|
||||
Manage URL Presets
|
||||
</a>
|
||||
<AppLink search='settings=feature_settings__urlpresets'>Manage URL Presets</AppLink>
|
||||
</Info>
|
||||
<div>
|
||||
<span className={style.label}>Select View or URL Preset</span>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.link {
|
||||
color: $label-gray;
|
||||
font-size: $text-body-size;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
color: $blue-400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './AppLink.module.scss';
|
||||
|
||||
interface AppLinkProps {
|
||||
className?: string;
|
||||
search: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component used to navigate to an editor link inside the same window
|
||||
* Handles the path to respect Ontime Clouds base URL
|
||||
*/
|
||||
export default function AppLink(props: PropsWithChildren<AppLinkProps>) {
|
||||
const { className, search, children } = props;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => navigate({ search });
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={cx([style.link, className])}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { IoOpenOutline } from '@react-icons/all-files/io5/IoOpenOutline';
|
||||
|
||||
import { openLink } from '../../utils/linkUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import { openLink } from '../../../utils/linkUtils';
|
||||
import { cx } from '../../../utils/styleUtils';
|
||||
|
||||
import style from './ExternalLink.module.scss';
|
||||
|
||||
@@ -41,6 +41,17 @@ export const useExternalMessageInput = createSelector((state: RuntimeStore) => (
|
||||
visible: state.message.timer.secondarySource === 'external',
|
||||
}));
|
||||
|
||||
export const useTimerSchedule = createSelector((state: RuntimeStore) => ({
|
||||
startedAt: state.timer.startedAt,
|
||||
expectedFinish: state.timer.expectedFinish,
|
||||
phase: state.timer.phase,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useTimerCurrent = createSelector((state: RuntimeStore) => ({
|
||||
current: state.timer.current,
|
||||
}));
|
||||
|
||||
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
||||
blink: state.message.timer.blink,
|
||||
blackout: state.message.timer.blackout,
|
||||
@@ -114,6 +125,15 @@ export const setAuxTimer = {
|
||||
setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }),
|
||||
};
|
||||
|
||||
export const useTimerSpeed = createSelector((state: RuntimeStore) => ({
|
||||
speed: state.timer.speed,
|
||||
}));
|
||||
|
||||
export const setTimerSpeed = {
|
||||
getSpeed: () => socketSendJson('get-speed'),
|
||||
setSpeed: (speed: number) => socketSendJson('set-speed', speed),
|
||||
};
|
||||
|
||||
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({
|
||||
selectedEventId: state.eventNow?.id ?? null,
|
||||
}));
|
||||
|
||||
@@ -6,7 +6,6 @@ import { baseURI, serverURL } from '../../externals';
|
||||
* Open an external URLs: specifically for a electron / browser case
|
||||
* If electron: ask main process to call a new browser window
|
||||
* If browser: open in new tab
|
||||
* @param url
|
||||
*/
|
||||
export function openLink(url: string) {
|
||||
if (window.process?.type === 'renderer') {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import {
|
||||
buyMeACoffeeUrl,
|
||||
discordUrl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
|
||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
|
||||
+1
-1
@@ -3,8 +3,8 @@ import { Button, Input, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { editAutomationSettings } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
|
||||
+1
-1
@@ -9,8 +9,8 @@ import { URLPreset } from 'ontime-types';
|
||||
import { postUrlPresets } from '../../../../common/api/urlPresets';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { CustomField, CustomFieldLabel } from 'ontime-types';
|
||||
|
||||
import { deleteCustomField, editCustomField, postCustomField } from '../../../../../common/api/customFields';
|
||||
import ExternalLink from '../../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { customFieldsDocsUrl } from '../../../../../externals';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
@@ -5,9 +5,9 @@ import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import { postViewSettings } from '../../../../common/api/viewSettings';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Button, Select, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
|
||||
import copyToClipboard from '../../../../common/utils/copyToClipboard';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
|
||||
const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadsheet-gsheet/';
|
||||
|
||||
export default function GSheetInfo() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import AddTime from './add-time/AddTime';
|
||||
import { AuxTimer } from './aux-timer/AuxTimer';
|
||||
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
||||
import PlaybackTimer from './playback-timer/PlaybackTimer';
|
||||
import TimerSpeed from './timer-speed/TimerSpeed';
|
||||
|
||||
import style from './PlaybackControl.module.scss';
|
||||
|
||||
@@ -14,7 +15,7 @@ export default function PlaybackControl() {
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
<PlaybackTimer playback={data.playback as Playback}>
|
||||
<PlaybackTimer playback={data.playback}>
|
||||
<AddTime playback={data.playback} />
|
||||
</PlaybackTimer>
|
||||
<PlaybackButtons
|
||||
@@ -23,6 +24,7 @@ export default function PlaybackControl() {
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
timerPhase={data.timerPhase}
|
||||
/>
|
||||
<TimerSpeed isPlaying={data.playback === Playback.Play} eventIndex={data.selectedEventIndex} />
|
||||
<AuxTimer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
@@ -16,7 +17,9 @@ interface AddTimeProps {
|
||||
playback: Playback;
|
||||
}
|
||||
|
||||
export default function AddTime(props: AddTimeProps) {
|
||||
export default memo(AddTime);
|
||||
|
||||
function AddTime(props: AddTimeProps) {
|
||||
const { playback } = props;
|
||||
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
.timeContainer {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'indicators timer addtime'
|
||||
'status status addtime';
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas: 'indicators timer addtime';
|
||||
grid-template-columns: 1.25rem 1fr 6.5rem;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
@@ -50,36 +47,3 @@
|
||||
background-color: $playback-negative;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------> LABELS
|
||||
|
||||
.status {
|
||||
grid-area: status;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
gap: $section-spacing;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.rolltag {
|
||||
color: $ontime-roll;
|
||||
font-size: $text-body-size;
|
||||
}
|
||||
|
||||
.reportLink {
|
||||
color: $label-gray;
|
||||
font-size: $text-body-size;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { formatDuration } from '../../../../common/utils/time';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
@@ -35,7 +33,16 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
const rollLabel = (() => {
|
||||
if (!isRolling) {
|
||||
return '';
|
||||
}
|
||||
if (isWaiting) {
|
||||
return 'Roll: Countdown to start';
|
||||
}
|
||||
|
||||
return 'Roll mode active';
|
||||
})();
|
||||
|
||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||
|
||||
@@ -51,59 +58,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
||||
<div className={style.status}>
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
) : (
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={playback} />
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RunningStatusProps {
|
||||
startedAt: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
playback: Playback;
|
||||
}
|
||||
function RunningStatus(props: RunningStatusProps) {
|
||||
const { startedAt, expectedFinish, playback } = props;
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(finishedMs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={style.start}>
|
||||
<span className={style.tag}>Started at</span>
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StoppedStatus() {
|
||||
const { data } = useReport();
|
||||
const hasReport = Object.keys(data).length > 0;
|
||||
|
||||
if (hasReport) {
|
||||
return (
|
||||
<a className={style.reportLink} href='/editor?settings=feature_settings__report'>
|
||||
Go to report management
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
color: $timer-color;
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
font-size: 3.5rem;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import { useClock, useTimerCurrent, useTimerSchedule } from '../../../../common/hooks/useSocket';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatTime } from '../../../../common/utils/time';
|
||||
|
||||
import style from './TimerSpeed.module.scss';
|
||||
|
||||
interface MeetScheduleProps {
|
||||
speed: number;
|
||||
newSpeed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* We isolate this component since it has the potential to re-render on every second
|
||||
*/
|
||||
export default function TimerSchedule(props: MeetScheduleProps) {
|
||||
const { speed, newSpeed } = props;
|
||||
const { startedAt, expectedFinish, phase, playback } = useTimerSchedule();
|
||||
|
||||
const started = formatTime(startedAt);
|
||||
const normalisedExpectedEnd = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const endTime = formatTime(normalisedExpectedEnd);
|
||||
|
||||
const isWaiting = phase === TimerPhase.Pending;
|
||||
const hasNewSpeed = speed !== newSpeed;
|
||||
|
||||
if (isWaiting) {
|
||||
return <div className={cx([style.entry, style.roll])}>Roll: Countdown to start</div>;
|
||||
}
|
||||
|
||||
if (playback === Playback.Stop) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={style.timers}>
|
||||
<div className={style.entry}>
|
||||
<div className={style.timerLabel}>Started at</div>
|
||||
<div>{started}</div>
|
||||
</div>
|
||||
<div className={style.entry}>
|
||||
<div className={style.timerLabel}>Expected end</div>
|
||||
{hasNewSpeed ? <SpeedFinish newSpeed={newSpeed} /> : <div>{endTime}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: extract and test
|
||||
// calculate the new finish time
|
||||
function useExpectedTime(remainingTimeMs: number, speedFactor: number): number {
|
||||
const { clock } = useClock();
|
||||
const adjustedRemainingTimeMs = remainingTimeMs / speedFactor;
|
||||
const newFinishTimeMs = clock + adjustedRemainingTimeMs;
|
||||
return newFinishTimeMs;
|
||||
}
|
||||
|
||||
function StoppedStatus() {
|
||||
const { data } = useReport();
|
||||
const hasReport = Object.keys(data).length > 0;
|
||||
|
||||
if (hasReport) {
|
||||
return (
|
||||
<AppLink className={style.entry} search='settings=feature_settings__report'>
|
||||
Go to report management
|
||||
</AppLink>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={style.entry}>No running playback</div>;
|
||||
}
|
||||
|
||||
interface SpeedFinishProps {
|
||||
newSpeed: number;
|
||||
}
|
||||
|
||||
function SpeedFinish(props: SpeedFinishProps) {
|
||||
const { newSpeed } = props;
|
||||
const { current } = useTimerCurrent();
|
||||
|
||||
const newFinish = formatTime(useExpectedTime(current ?? 0, newSpeed));
|
||||
|
||||
return <div className={style.highlight}>{newFinish}</div>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.panelContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.inlineApart {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.inlineSiblings {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
height: 1.2rem;
|
||||
align-items: end;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.timerLabel {
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.roll {
|
||||
color: $ontime-roll;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: $highlight-orange;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: $opacity-disabled;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Button, Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack } from '@chakra-ui/react';
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { setTimerSpeed, useTimerSpeed } from '../../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
|
||||
import TimerSchedule from './TimerSchedule';
|
||||
|
||||
import style from './TimerSpeed.module.scss';
|
||||
|
||||
interface TimerSpeedProps {
|
||||
isPlaying: boolean;
|
||||
eventIndex: MaybeNumber;
|
||||
}
|
||||
|
||||
export default memo(TimerSpeed);
|
||||
|
||||
function TimerSpeed(props: TimerSpeedProps) {
|
||||
const { isPlaying, eventIndex } = props;
|
||||
const { speed } = useTimerSpeed();
|
||||
const [newSpeed, setNewSpeed] = useState(1);
|
||||
const { setSpeed } = setTimerSpeed;
|
||||
|
||||
// when a new timer is set, we want to reset the speed
|
||||
useEffect(() => {
|
||||
setNewSpeed(1);
|
||||
}, [eventIndex]);
|
||||
|
||||
const handleApply = () => setSpeed(newSpeed);
|
||||
const handleReset = () => {
|
||||
setNewSpeed(1.0);
|
||||
setSpeed(1.0);
|
||||
};
|
||||
|
||||
const canReset = isPlaying && speed === newSpeed && newSpeed !== 1;
|
||||
const canApply = isPlaying && speed !== newSpeed;
|
||||
const willChangeSpeed = speed === 1 && !canApply;
|
||||
|
||||
return (
|
||||
<div className={style.panelContainer}>
|
||||
<TimerSchedule speed={speed} newSpeed={newSpeed} />
|
||||
<div className={style.inlineApart}>
|
||||
<div className={style.entry}>
|
||||
<span className={cx([willChangeSpeed && style.disabled])}>{`${speed}x`}</span>
|
||||
{newSpeed !== speed && <span className={style.highlight}>{` ⇢ ${newSpeed}x`}</span>}
|
||||
</div>
|
||||
<div className={style.inlineSiblings}>
|
||||
<Button size='sm' variant='ontime-subtle-white' onClick={handleReset} isDisabled={!canReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size='sm' variant='ontime-subtle-white' onClick={handleApply} isDisabled={!canApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
variant={newSpeed === 1 ? 'ontime' : 'ontime-highlight'}
|
||||
defaultValue={newSpeed}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.01}
|
||||
onChange={(v) => setNewSpeed(v)}
|
||||
value={newSpeed}
|
||||
isDisabled={!isPlaying}
|
||||
>
|
||||
<SliderMark value={0.5}>0.5x</SliderMark>
|
||||
<SliderMark value={1.0}>1.0x</SliderMark>
|
||||
<SliderMark value={1.5}>1.5x</SliderMark>
|
||||
<SliderMark value={2.0}>2.0x</SliderMark>
|
||||
<SliderTrack>
|
||||
<SliderFilledTrack />
|
||||
</SliderTrack>
|
||||
<SliderThumb />
|
||||
</Slider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, Mod
|
||||
import { loadDemo, loadProject } from '../../../common/api/db';
|
||||
import { postShowWelcomeDialog } from '../../../common/api/settings';
|
||||
import { invalidateAllCaches } from '../../../common/api/utils';
|
||||
import ExternalLink from '../../../common/components/external-link/ExternalLink';
|
||||
import ExternalLink from '../../../common/components/link/external-link/ExternalLink';
|
||||
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
|
||||
import * as Editor from '../editor-utils/EditorUtils';
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CSSProperties, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import AppLink from '../../../common/components/link/app-link/AppLink';
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
@@ -29,7 +28,6 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
const { event } = props;
|
||||
const { data: customFields } = useCustomFields();
|
||||
const { updateEvent } = useEventAction();
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const isEditor = window.location.pathname.includes('editor');
|
||||
|
||||
@@ -45,10 +43,6 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
[event?.id, updateEvent],
|
||||
);
|
||||
|
||||
const handleOpenCustomManager = () => {
|
||||
setSearchParams({ settings: 'feature_settings__custom' });
|
||||
};
|
||||
|
||||
if (!event) {
|
||||
return <EventEditorEmpty />;
|
||||
}
|
||||
@@ -83,11 +77,7 @@ export default function EventEditor(props: EventEditorProps) {
|
||||
<div className={style.column}>
|
||||
<Editor.Title>
|
||||
Custom Fields
|
||||
{isEditor && (
|
||||
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage</AppLink>}
|
||||
</Editor.Title>
|
||||
|
||||
{Object.keys(customFields).map((fieldKey) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ $warning-orange: $orange-500;
|
||||
$info-blue: $blue-500;
|
||||
$opacity-disabled: 0.4;
|
||||
$active-red: $red-700;
|
||||
$highlight-orange: $orange-600;
|
||||
|
||||
// playback colours
|
||||
$playback-start: $green-600;
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ontimeButtonFilled = {
|
||||
|
||||
export const ontimeButtonOutlined = {
|
||||
backgroundColor: '#2d2d2d', // $gray-1100
|
||||
color: '#e2e2e2', // $blue-400
|
||||
color: '#e2e2e2', // $gray-200
|
||||
border: '1px solid rgba(255, 255, 255, 0.10)', // white-10
|
||||
_hover: {
|
||||
backgroundColor: '#404040', // $gray-1000
|
||||
@@ -74,5 +74,4 @@ export const ontimeButtonGhosted = {
|
||||
export const ontimeButtonSubtleWhite = {
|
||||
...ontimeButtonSubtle,
|
||||
color: '#f6f6f6', // $gray-50
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export const ontimeSlider = {
|
||||
thumb: {
|
||||
background: '#f6f6f6', // $ui-white
|
||||
width: '0.25rem',
|
||||
},
|
||||
filledTrack: {
|
||||
background: '#779BE7', // $blue-400
|
||||
},
|
||||
track: {
|
||||
background: '#303030', // $gray-1050
|
||||
},
|
||||
mark: {
|
||||
color: '#b1b1b1', // $label-gray
|
||||
mt: '2',
|
||||
ml: '-2.5',
|
||||
fontSize: 'calc(1rem - 3px)',
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeHighlightSlider = {
|
||||
...ontimeSlider,
|
||||
filledTrack: {
|
||||
background: '#FFAB33', // $highlight-orange
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ import { ontimeMenuOnDark } from './ontimeMenu';
|
||||
import { ontimeModal } from './ontimeModal';
|
||||
import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio';
|
||||
import { ontimeSelect } from './ontimeSelect';
|
||||
import { ontimeHighlightSlider, ontimeSlider } from './ontimeSlider';
|
||||
import { ontimeSwitch } from './ontimeSwitch';
|
||||
import { ontimeTab } from './ontimeTab';
|
||||
import {
|
||||
@@ -140,6 +141,12 @@ const theme = extendTheme({
|
||||
ontime: { ...ontimeSelect },
|
||||
},
|
||||
},
|
||||
Slider: {
|
||||
variants: {
|
||||
ontime: { ...ontimeSlider },
|
||||
'ontime-highlight': { ...ontimeHighlightSlider },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langDe: TranslationObject = {
|
||||
'timeline.done': 'Beendet',
|
||||
'timeline.due': 'fällig',
|
||||
'timeline.followedby': 'Gefolgt von',
|
||||
'project.title': 'Titel',
|
||||
'project.description': 'Beschreibung',
|
||||
'project.backstage_info': 'Backstage-Informationen',
|
||||
'project.backstage_url': 'Backstage-URL',
|
||||
'project.public_info': 'Öffentliche Informationen',
|
||||
'project.public_url': 'Öffentliche URL',
|
||||
};
|
||||
|
||||
@@ -22,6 +22,12 @@ export const langEn = {
|
||||
'timeline.done': 'done',
|
||||
'timeline.due': 'due',
|
||||
'timeline.followedby': 'Followed by',
|
||||
'project.title': 'Title',
|
||||
'project.description': 'Description',
|
||||
'project.backstage_info': 'Backstage Info',
|
||||
'project.backstage_url': 'Backstage URL',
|
||||
'project.public_info': 'Public Info',
|
||||
'project.public_url': 'Public URL',
|
||||
};
|
||||
|
||||
export type TranslationObject = Record<keyof typeof langEn, string>;
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langEs: TranslationObject = {
|
||||
'timeline.done': 'Terminado',
|
||||
'timeline.due': 'pendiente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
'project.title': 'Título',
|
||||
'project.description': 'Descripción',
|
||||
'project.backstage_info': 'Información de backstage',
|
||||
'project.backstage_url': 'URL de backstage',
|
||||
'project.public_info': 'Información pública',
|
||||
'project.public_url': 'URL pública',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langFr: TranslationObject = {
|
||||
'timeline.done': 'Terminé',
|
||||
'timeline.due': 'dû',
|
||||
'timeline.followedby': 'Suivi de',
|
||||
'project.title': 'Titre',
|
||||
'project.description': 'Description',
|
||||
'project.backstage_info': 'Informations des coulisses',
|
||||
'project.backstage_url': 'URL des coulisses',
|
||||
'project.public_info': 'Informations publiques',
|
||||
'project.public_url': 'URL publique',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langHu: TranslationObject = {
|
||||
'timeline.done': 'kész',
|
||||
'timeline.due': 'esedékes',
|
||||
'timeline.followedby': 'Követi',
|
||||
'project.title': 'Cím',
|
||||
'project.description': 'Leírás',
|
||||
'project.backstage_info': 'Kulisszák mögötti információ',
|
||||
'project.backstage_url': 'Kulisszák mögötti URL',
|
||||
'project.public_info': 'Nyilvános információ',
|
||||
'project.public_url': 'Nyilvános URL',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langIt: TranslationObject = {
|
||||
'timeline.done': 'Terminato',
|
||||
'timeline.due': 'previsto',
|
||||
'timeline.followedby': 'Seguito da',
|
||||
'project.title': 'Titolo',
|
||||
'project.description': 'Descrizione',
|
||||
'project.backstage_info': 'Informazioni di backstage',
|
||||
'project.backstage_url': 'URL di backstage',
|
||||
'project.public_info': 'Informazioni pubbliche',
|
||||
'project.public_url': 'URL pubblico',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langNo: TranslationObject = {
|
||||
'timeline.done': 'Ferdig',
|
||||
'timeline.due': 'Venter',
|
||||
'timeline.followedby': 'Etterfulgt av',
|
||||
'project.title': 'Tittel',
|
||||
'project.description': 'Beskrivelse',
|
||||
'project.backstage_info': 'Backstage-informasjon',
|
||||
'project.backstage_url': 'Backstage-URL',
|
||||
'project.public_info': 'Offentlig informasjon',
|
||||
'project.public_url': 'Offentlig URL',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langPl: TranslationObject = {
|
||||
'timeline.done': 'Zakończony',
|
||||
'timeline.due': 'termin',
|
||||
'timeline.followedby': 'Następnie',
|
||||
'project.title': 'Tytuł',
|
||||
'project.description': 'Opis',
|
||||
'project.backstage_info': 'Informacje zaplecza',
|
||||
'project.backstage_url': 'URL zaplecza',
|
||||
'project.public_info': 'Informacje publiczne',
|
||||
'project.public_url': 'URL publiczny',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langPt: TranslationObject = {
|
||||
'timeline.done': 'Concluído',
|
||||
'timeline.due': 'Pendente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
'project.title': 'Título',
|
||||
'project.description': 'Descrição',
|
||||
'project.backstage_info': 'Informações de bastidores',
|
||||
'project.backstage_url': 'URL de bastidores',
|
||||
'project.public_info': 'Informações públicas',
|
||||
'project.public_url': 'URL pública',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langSv: TranslationObject = {
|
||||
'timeline.done': 'Avslutad',
|
||||
'timeline.due': 'Väntande',
|
||||
'timeline.followedby': 'Följt av',
|
||||
'project.title': 'Titel',
|
||||
'project.description': 'Beskrivning',
|
||||
'project.backstage_info': 'Backstageinformation',
|
||||
'project.backstage_url': 'Backstage-URL',
|
||||
'project.public_info': 'Offentlig information',
|
||||
'project.public_url': 'Offentlig URL',
|
||||
};
|
||||
|
||||
@@ -24,4 +24,10 @@ export const langZhCn: TranslationObject = {
|
||||
'timeline.done': '已结束',
|
||||
'timeline.due': '即将开始',
|
||||
'timeline.followedby': '随后是',
|
||||
'project.title': '标题',
|
||||
'project.description': '描述',
|
||||
'project.backstage_info': '后台信息',
|
||||
'project.backstage_url': '后台网址',
|
||||
'project.public_info': '公开信息',
|
||||
'project.public_url': '公开网址',
|
||||
};
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
background: var(--background-color-override, $viewer-background-color);
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
|
||||
padding: $view-outer-padding;
|
||||
font-size: $base-font-size;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -25,18 +26,15 @@
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
width: min(calc(100vw - 4rem), 800px);
|
||||
font-size: clamp(16px, 1.5vw, 24px);
|
||||
}
|
||||
|
||||
.info__label {
|
||||
font-weight: 600;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-transform: uppercase;
|
||||
margin-top: $view-element-gap;
|
||||
white-space: pre;
|
||||
}
|
||||
.info__label {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
|
||||
a.info__value {
|
||||
color: $action-text-color;
|
||||
|
||||
@@ -5,6 +5,7 @@ import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewLogo from '../../common/components/view-logo/ViewLogo';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
|
||||
import BackstageInfo from './backstage-info/BackstageInfo';
|
||||
import PublicInfo from './public-info/PublicInfo';
|
||||
@@ -19,18 +20,19 @@ interface ProjectInfoProps {
|
||||
|
||||
export default function ProjectInfo(props: ProjectInfoProps) {
|
||||
const { general, isMirrored } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
useWindowTitle('Project info');
|
||||
|
||||
if (!general) {
|
||||
return <Empty text='No data found' />;
|
||||
return <Empty text={getLocalizedString('common.no_data')} />;
|
||||
}
|
||||
|
||||
if (!general) {
|
||||
return (
|
||||
<>
|
||||
<ViewParamsEditor viewOptions={projectInfoOptions} />
|
||||
return <EmptyPage text='No data found' />;
|
||||
<EmptyPage text={getLocalizedString('common.no_data')} />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +42,7 @@ export default function ProjectInfo(props: ProjectInfoProps) {
|
||||
return (
|
||||
<>
|
||||
<ViewParamsEditor viewOptions={projectInfoOptions} />
|
||||
<EmptyPage text='The project has no data yet' />;
|
||||
<EmptyPage text={getLocalizedString('common.no_data')} />;
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -52,13 +54,13 @@ export default function ProjectInfo(props: ProjectInfoProps) {
|
||||
<div className='info'>
|
||||
{general.title && (
|
||||
<>
|
||||
<div className='info__label'>Title</div>
|
||||
<div className='info__label'>{getLocalizedString('project.title')}</div>
|
||||
<div className='info__value'>{general.title}</div>
|
||||
</>
|
||||
)}
|
||||
{general.description && (
|
||||
<>
|
||||
<div className='info__label'>Description</div>
|
||||
<div className='info__label'>{getLocalizedString('project.description')}</div>
|
||||
<div className='info__value'>{general.description}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
interface BackstageInfoProps {
|
||||
general: ProjectData;
|
||||
@@ -10,6 +11,7 @@ interface BackstageInfoProps {
|
||||
export default function BackstageInfo(props: BackstageInfoProps) {
|
||||
const { general } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const showBackstage = isStringBoolean(searchParams.get('showBackstage'));
|
||||
|
||||
@@ -21,13 +23,13 @@ export default function BackstageInfo(props: BackstageInfoProps) {
|
||||
<>
|
||||
{general.backstageInfo && (
|
||||
<>
|
||||
<div className='info__label'>Backstage info</div>
|
||||
<div className='info__label'>{getLocalizedString('project.backstage_info')}</div>
|
||||
<div className='info__value'>{general.backstageInfo}</div>
|
||||
</>
|
||||
)}
|
||||
{general.backstageUrl && (
|
||||
<>
|
||||
<div className='info__label'>Backstage URL</div>
|
||||
<div className='info__label'>{getLocalizedString('project.backstage_url')}</div>
|
||||
<a href={general.backstageUrl} target='_blank' rel='noreferrer' className='info__value'>
|
||||
{general.backstageUrl}
|
||||
</a>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
|
||||
interface PublicInfoProps {
|
||||
general: ProjectData;
|
||||
@@ -10,6 +11,7 @@ interface PublicInfoProps {
|
||||
export default function PublicInfo(props: PublicInfoProps) {
|
||||
const { general } = props;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const showPublic = isStringBoolean(searchParams.get('showPublic'));
|
||||
|
||||
@@ -21,13 +23,13 @@ export default function PublicInfo(props: PublicInfoProps) {
|
||||
<>
|
||||
{general.publicInfo && (
|
||||
<>
|
||||
<div className='info__label'>Public info</div>
|
||||
<div className='info__label'>{getLocalizedString('project.public_info')}</div>
|
||||
<div className='info__value'>{general.publicInfo}</div>
|
||||
</>
|
||||
)}
|
||||
{general.publicUrl && (
|
||||
<>
|
||||
<div className='info__label'>Public URL</div>
|
||||
<div className='info__label'>{getLocalizedString('project.public_url')}</div>
|
||||
<a href={general.publicUrl} target='_blank' rel='noreferrer' className='info__value'>
|
||||
{general.publicUrl}
|
||||
</a>
|
||||
|
||||
@@ -259,6 +259,20 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
}
|
||||
throw new Error('No matching method provided');
|
||||
},
|
||||
/* Speed */
|
||||
'get-speed': () => {
|
||||
const factor = runtimeService.getSpeed();
|
||||
return { payload: factor };
|
||||
},
|
||||
'set-speed': (payload) => {
|
||||
const speedToSet = numberOrError(payload);
|
||||
const speedSet = runtimeService.setSpeed(speedToSet);
|
||||
return { payload: speedSet };
|
||||
},
|
||||
'reset-speed': () => {
|
||||
const factor = runtimeService.resetSpeed();
|
||||
return { payload: factor };
|
||||
},
|
||||
/* Client */
|
||||
client: (payload) => {
|
||||
assert.isObject(payload);
|
||||
|
||||
@@ -666,6 +666,43 @@ class RuntimeService {
|
||||
logger.info(LogOrigin.Playback, `${time > 0 ? 'Added' : 'Removed'} ${millisToString(time)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number} speed factor currently applied
|
||||
*/
|
||||
public getSpeed(): number {
|
||||
return runtimeState.getSpeed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a speed factor to current timer
|
||||
* @param {number} speed - speed factor
|
||||
* @returns {number} applied speed factor
|
||||
*/
|
||||
public setSpeed(speed: number): number {
|
||||
if (speed < 0.5 || speed > 2.0) {
|
||||
logger.warning(LogOrigin.Server, `Speed out of bounds: ${speed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = runtimeState.getState();
|
||||
if (currentState.eventNow === null) {
|
||||
logger.warning(LogOrigin.Server, 'No event running to set speed to');
|
||||
}
|
||||
|
||||
const newSpeed = runtimeState.setSpeed(speed);
|
||||
logger.info(LogOrigin.Server, `Speed set to ${newSpeed}`);
|
||||
return newSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the speed of the current timer
|
||||
*/
|
||||
public resetSpeed(): number {
|
||||
const speed = runtimeState.resetSpeed();
|
||||
logger.info(LogOrigin.Server, `Speed set to ${speed}`);
|
||||
return speed;
|
||||
}
|
||||
}
|
||||
|
||||
// calculate at 30fps, refresh at 1fps
|
||||
|
||||
@@ -37,6 +37,23 @@ export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
return timeEnd + addedTime + pausedTime;
|
||||
}
|
||||
|
||||
// TODO: need to calculate the expected end taking the speed into account
|
||||
/**
|
||||
* // For normal timer events:
|
||||
// 1. Compute how much real time (excluding paused time) has elapsed.
|
||||
const realElapsed = clock - startedAt - pausedTime;
|
||||
// 2. Translate real elapsed time to virtual elapsed time using the speed factor.
|
||||
const virtualElapsed = realElapsed * speed;
|
||||
// 3. The virtual total time is the base duration plus any added time.
|
||||
const virtualTotal = duration! + addedTime;
|
||||
// 4. The remaining virtual time is the total virtual time minus what’s already elapsed.
|
||||
const virtualRemaining = virtualTotal - virtualElapsed;
|
||||
// 5. Convert the remaining virtual time back to real time.
|
||||
const remainingReal = virtualRemaining / speed;
|
||||
// 6. The new expected finish time is the current clock plus the adjusted remaining time.
|
||||
const adjustedFinish = clock + remainingReal;
|
||||
*/
|
||||
|
||||
// handle events that finish the day after
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
|
||||
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
|
||||
|
||||
@@ -32,6 +32,7 @@ const baseState: RuntimeState = {
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
speed: 1,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
|
||||
@@ -350,6 +350,26 @@ export function updateAll(rundown: OntimeRundown) {
|
||||
loadBlock(rundown);
|
||||
}
|
||||
|
||||
export function setSpeed(speed: number): number {
|
||||
const remainingMs = runtimeState.timer.current;
|
||||
const adjustedRemainingTimeMs = remainingMs / speed;
|
||||
const newFinishTimeMs = runtimeState.clock + adjustedRemainingTimeMs;
|
||||
|
||||
runtimeState.timer.speed = speed;
|
||||
runtimeState.timer.expectedFinish = newFinishTimeMs;
|
||||
|
||||
return runtimeState.timer.speed;
|
||||
}
|
||||
|
||||
export function resetSpeed() {
|
||||
runtimeState.timer.speed = 1.0;
|
||||
return runtimeState.timer.speed;
|
||||
}
|
||||
|
||||
export function getSpeed() {
|
||||
return runtimeState.timer.speed;
|
||||
}
|
||||
|
||||
export function start(state: RuntimeState = runtimeState): boolean {
|
||||
if (state.eventNow === null) {
|
||||
return false;
|
||||
@@ -464,6 +484,7 @@ export type UpdateResult = {
|
||||
};
|
||||
|
||||
export function update(): UpdateResult {
|
||||
const timeSinceLastUpdate = clock.timeNow() - runtimeState.clock;
|
||||
// 0. there are some things we always do
|
||||
const previousClock = runtimeState.clock;
|
||||
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
|
||||
@@ -490,6 +511,12 @@ export function update(): UpdateResult {
|
||||
}
|
||||
}
|
||||
|
||||
const catchUpMultiplier = 1 - runtimeState.timer.speed;
|
||||
|
||||
if (runtimeState.timer.playback === Playback.Play) {
|
||||
runtimeState.timer.addedTime += timeSinceLastUpdate * catchUpMultiplier;
|
||||
}
|
||||
|
||||
// update timer state
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
|
||||
@@ -47,6 +47,10 @@
|
||||
/** ---- Colour used for progress bar progress */
|
||||
--timer-progress-override: #202020;
|
||||
|
||||
/** View specific features: /op */
|
||||
--operator-customfield-font-size-override: 1.25rem;
|
||||
--operator-running-bg-override: #339e4e;
|
||||
|
||||
/** View specific features: /studio */
|
||||
--studio-active: #101010;
|
||||
--studio-idle: #cfcfcf;
|
||||
|
||||
@@ -16,6 +16,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
playback: Playback.Stop, // change initiated by user
|
||||
secondaryTimer: null, // change on every update
|
||||
startedAt: null, // change can only be initiated by user
|
||||
speed: 1.0, // change initiated by user
|
||||
},
|
||||
onAir: false,
|
||||
message: {
|
||||
|
||||
@@ -30,4 +30,6 @@ export type TimerState = {
|
||||
secondaryTimer: MaybeNumber;
|
||||
/** only if timer has already started */
|
||||
startedAt: MaybeNumber;
|
||||
/** the speed of the current timer 1.0 = realtime, 2.0 = double time */
|
||||
speed: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user