mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-27 18:09:10 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7089abbb53 | |||
| 99f8f4885c | |||
| c4ac9df8cf | |||
| 0d5228f1e5 | |||
| fae9e030a3 | |||
| 03fa62f3a1 | |||
| f208f148b7 | |||
| 60cdf45c36 | |||
| 2ef966fc14 |
@@ -22,10 +22,14 @@ export const useRundownEditor = createSelector((state: RuntimeStore) => ({
|
|||||||
nextEventId: state.eventNext?.id ?? null,
|
nextEventId: state.eventNext?.id ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
|
export const useScreenControl = createSelector((state: RuntimeStore) => ({
|
||||||
blackout: state.message.timer.blackout,
|
blackout: state.message.timer.blackout,
|
||||||
blink: state.message.timer.blink,
|
blink: state.message.timer.blink,
|
||||||
secondarySource: state.message.timer.secondarySource,
|
isScreenModified:
|
||||||
|
state.message.timer.visible ||
|
||||||
|
state.message.timer.blink ||
|
||||||
|
state.message.timer.blackout ||
|
||||||
|
state.message.timer.secondarySource !== null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
|
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
|
||||||
@@ -33,17 +37,12 @@ export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
|
|||||||
visible: state.message.timer.visible,
|
visible: state.message.timer.visible,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useExternalMessageInput = createSelector((state: RuntimeStore) => ({
|
export const useSecondaryMessageInput = createSelector((state: RuntimeStore) => ({
|
||||||
text: state.message.secondary,
|
text: state.message.secondary,
|
||||||
visible: state.message.timer.secondarySource === 'secondary',
|
source: state.message.timer.secondarySource,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
export const useTimerStatus = createSelector((state: RuntimeStore) => ({
|
||||||
blink: state.message.timer.blink,
|
|
||||||
blackout: state.message.timer.blackout,
|
|
||||||
phase: state.timer.phase,
|
|
||||||
secondarySource: state.message.timer.secondarySource,
|
|
||||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
|
||||||
timerType: state.eventNow?.timerType ?? null,
|
timerType: state.eventNow?.timerType ?? null,
|
||||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||||
}));
|
}));
|
||||||
@@ -52,10 +51,16 @@ export const setMessage = {
|
|||||||
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
|
timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }),
|
||||||
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
|
timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }),
|
||||||
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
|
secondaryMessage: (payload: string) => sendSocket('message', { secondary: payload }),
|
||||||
timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }),
|
// blink and blackout are mutually exclusive stage states, so turning one on turns the other off
|
||||||
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
|
timerBlink: (payload: boolean) =>
|
||||||
|
sendSocket('message', payload ? { timer: { blink: true, blackout: false } } : { timer: { blink: false } }),
|
||||||
|
timerBlackout: (payload: boolean) =>
|
||||||
|
sendSocket('message', payload ? { timer: { blackout: true, blink: false } } : { timer: { blackout: false } }),
|
||||||
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
|
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
|
||||||
sendSocket('message', { timer: { secondarySource: payload } }),
|
sendSocket('message', { timer: { secondarySource: payload } }),
|
||||||
|
/** returns the stage to a plain timer, keeping whatever the operator has typed */
|
||||||
|
clearScreen: () =>
|
||||||
|
sendSocket('message', { timer: { visible: false, blink: false, blackout: false, secondarySource: null } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: $element-spacing;
|
gap: $element-spacing;
|
||||||
margin-top: $element-inner-spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
&.withSource {
|
||||||
font-size: $inner-section-text-size;
|
grid-template-columns: auto 1fr auto;
|
||||||
color: $label-gray;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: $action-text-color;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label.active {
|
||||||
|
color: $action-text-color;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
import { PropsWithChildren, ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import Input from '../../../common/components/input/input/Input';
|
import Input from '../../../common/components/input/input/Input';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
|
|
||||||
@@ -9,12 +10,15 @@ interface InputRowProps {
|
|||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
/** whether this text is currently on the audience screen */
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
changeHandler: (newValue: string) => void;
|
changeHandler: (newValue: string) => void;
|
||||||
|
/** control which picks where the text is shown, rendered before the input */
|
||||||
|
sourcePicker?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
|
export default function InputRow(props: PropsWithChildren<InputRowProps>) {
|
||||||
const { label, placeholder, text, visible, changeHandler, children } = props;
|
const { label, placeholder, text, visible, changeHandler, sourcePicker, children } = props;
|
||||||
|
|
||||||
const [value, setValue] = useState(text);
|
const [value, setValue] = useState(text);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -43,10 +47,11 @@ export default function InputRow(props: PropsWithChildren<InputRowProps>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className={cx([style.label, visible ?? style.active])} htmlFor={label}>
|
<Editor.Label className={cx([style.label, visible && style.active])} htmlFor={label}>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</Editor.Label>
|
||||||
<div className={style.inputItems}>
|
<div className={cx([style.inputItems, sourcePicker && style.withSource])}>
|
||||||
|
{sourcePicker}
|
||||||
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
|
<Input id={label} ref={inputRef} value={value} onChange={handleInputChange} placeholder={placeholder} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/* the screen state buttons belong with the preview they act on, not with the message inputs.
|
||||||
|
buttons sit in a column to the right of the stage by default; once the container narrows
|
||||||
|
too far for that (e.g. an extracted window resized narrow), they drop into a row below it.
|
||||||
|
the group is sized by its content, not stretched - the preview keeps a fixed shape rather
|
||||||
|
than growing to whatever height the panel happens to have free */
|
||||||
|
.screenGroup {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: $element-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 22rem) {
|
||||||
|
.screenGroup {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
|
import { SecondarySource } from 'ontime-types';
|
||||||
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
import { IoEye, IoEyeOffOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
import IconButton from '../../../common/components/buttons/IconButton';
|
import IconButton from '../../../common/components/buttons/IconButton';
|
||||||
import {
|
import Select from '../../../common/components/select/Select';
|
||||||
setMessage,
|
import { setMessage, useSecondaryMessageInput, useTimerMessageInput } from '../../../common/hooks/useSocket';
|
||||||
useExternalMessageInput as useSecondaryMessageInput,
|
|
||||||
useTimerMessageInput,
|
|
||||||
} from '../../../common/hooks/useSocket';
|
|
||||||
import InputRow from './InputRow';
|
import InputRow from './InputRow';
|
||||||
import TimerControlsPreview from './TimerViewControl';
|
import ScreenControl from './ScreenControl';
|
||||||
|
import TimerPreview from './TimerPreview';
|
||||||
|
|
||||||
|
import style from './MessageControl.module.scss';
|
||||||
|
|
||||||
export default function MessageControl() {
|
export default function MessageControl() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TimerControlsPreview />
|
<div className={style.screenGroup}>
|
||||||
|
<TimerPreview />
|
||||||
|
<ScreenControl />
|
||||||
|
</div>
|
||||||
<TimerMessageInput />
|
<TimerMessageInput />
|
||||||
<SecondaryInput />
|
<SecondaryInput />
|
||||||
</>
|
</>
|
||||||
@@ -24,7 +28,7 @@ function TimerMessageInput() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InputRow
|
<InputRow
|
||||||
label='Timer Message'
|
label='Timer message'
|
||||||
placeholder='Message shown fullscreen in stage timer'
|
placeholder='Message shown fullscreen in stage timer'
|
||||||
text={text}
|
text={text}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
@@ -32,6 +36,7 @@ function TimerMessageInput() {
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='Toggle timer message visibility'
|
aria-label='Toggle timer message visibility'
|
||||||
|
aria-pressed={visible}
|
||||||
onClick={() => setMessage.timerVisible(!visible)}
|
onClick={() => setMessage.timerVisible(!visible)}
|
||||||
variant={visible ? 'primary' : 'subtle'}
|
variant={visible ? 'primary' : 'subtle'}
|
||||||
>
|
>
|
||||||
@@ -41,31 +46,46 @@ function TimerMessageInput() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The secondary line of the stage timer shows one of the aux timers or the secondary message.
|
||||||
|
* The select owns which one, the eye owns whether the line is shown at all.
|
||||||
|
*/
|
||||||
function SecondaryInput() {
|
function SecondaryInput() {
|
||||||
const { text, visible } = useSecondaryMessageInput();
|
const { text, source } = useSecondaryMessageInput();
|
||||||
|
const isShowingSecondaryLine = source !== null;
|
||||||
const toggleSecondary = () => {
|
const selectedSource = source ?? 'aux1';
|
||||||
if (visible) {
|
|
||||||
setMessage.timerSecondarySource(null);
|
|
||||||
} else {
|
|
||||||
setMessage.timerSecondarySource('secondary');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputRow
|
<InputRow
|
||||||
label='Secondary Message'
|
label='Secondary'
|
||||||
placeholder='Message shown as secondary text in stage timer'
|
placeholder='Message shown as secondary text in stage timer'
|
||||||
text={text}
|
text={text}
|
||||||
visible={visible}
|
visible={source === 'secondary'}
|
||||||
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
|
changeHandler={(newValue) => setMessage.secondaryMessage(newValue)}
|
||||||
|
sourcePicker={
|
||||||
|
<Select
|
||||||
|
value={selectedSource}
|
||||||
|
options={[
|
||||||
|
{ value: 'aux1', label: 'Aux 1' },
|
||||||
|
{ value: 'aux2', label: 'Aux 2' },
|
||||||
|
{ value: 'aux3', label: 'Aux 3' },
|
||||||
|
{ value: 'secondary', label: 'Message' },
|
||||||
|
]}
|
||||||
|
onValueChange={(value: SecondarySource | null) => {
|
||||||
|
if (value === null) return;
|
||||||
|
setMessage.timerSecondarySource(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label='Toggle secondary message visibility'
|
aria-label='Toggle secondary visibility'
|
||||||
onClick={toggleSecondary}
|
aria-pressed={isShowingSecondaryLine}
|
||||||
variant={visible ? 'primary' : 'subtle'}
|
onClick={() => setMessage.timerSecondarySource(isShowingSecondaryLine ? null : selectedSource)}
|
||||||
|
variant={isShowingSecondaryLine ? 'primary' : 'subtle'}
|
||||||
|
data-testid='toggle secondary'
|
||||||
>
|
>
|
||||||
{visible ? <IoEye /> : <IoEyeOffOutline />}
|
{isShowingSecondaryLine ? <IoEye /> : <IoEyeOffOutline />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputRow>
|
</InputRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
.growPanel {
|
.growPanel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the extracted route has no flex parent to grow into */
|
||||||
|
.extractedPanel {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contentLayout {
|
.contentLayout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
container-type: inline-size;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $section-spacing;
|
gap: $section-spacing;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../../common/components/error-boundary/ErrorBounda
|
|||||||
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
import ViewNavigationMenu from '../../../common/components/navigation-menu/ViewNavigationMenu';
|
||||||
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
import ProtectRoute from '../../../common/components/protect-route/ProtectRoute';
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { getIsNavigationLocked } from '../../../externals';
|
import { getIsNavigationLocked } from '../../../externals';
|
||||||
import MessageControl from './MessageControl';
|
import MessageControl from './MessageControl';
|
||||||
|
|
||||||
@@ -16,7 +17,10 @@ function MessageControlExport() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProtectRoute permission='editor'>
|
<ProtectRoute permission='editor'>
|
||||||
<Editor.Panel className={style.growPanel} data-testid='panel-messages-control'>
|
<Editor.Panel
|
||||||
|
className={cx([style.growPanel, isExtracted && style.extractedPanel])}
|
||||||
|
data-testid='panel-messages-control'
|
||||||
|
>
|
||||||
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
|
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('messagecontrol', event)} />}
|
||||||
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.screenControl {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
gap: $element-spacing;
|
||||||
|
width: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 22rem) {
|
||||||
|
.screenControl {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: 1fr;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Button from '../../../common/components/buttons/Button';
|
||||||
|
import { setMessage, useScreenControl } from '../../../common/hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './ScreenControl.module.scss';
|
||||||
|
|
||||||
|
export default function ScreenControl() {
|
||||||
|
const { blackout, blink, isScreenModified } = useScreenControl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.screenControl}>
|
||||||
|
<Button
|
||||||
|
variant={blink ? 'primary' : 'subtle'}
|
||||||
|
aria-pressed={blink}
|
||||||
|
fluid
|
||||||
|
onClick={() => setMessage.timerBlink(!blink)}
|
||||||
|
data-testid='toggle timer blink'
|
||||||
|
>
|
||||||
|
Blink
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={blackout ? 'destructive' : 'subtle'}
|
||||||
|
aria-pressed={blackout}
|
||||||
|
fluid
|
||||||
|
onClick={() => setMessage.timerBlackout(!blackout)}
|
||||||
|
data-testid='toggle timer blackout'
|
||||||
|
>
|
||||||
|
Blackout
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='subtle'
|
||||||
|
fluid
|
||||||
|
disabled={!isScreenModified}
|
||||||
|
onClick={() => setMessage.clearScreen()}
|
||||||
|
data-testid='clear screen'
|
||||||
|
>
|
||||||
|
Clear screen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +1,26 @@
|
|||||||
.preview {
|
/* fixed shape, not stretched to whatever height the panel has free - it fills the
|
||||||
background-color: $ui-black;
|
available width and keeps a 16:9 proportion, which is also what the font size
|
||||||
display: grid;
|
estimate in the embedded timer assumes */
|
||||||
place-content: center;
|
.stage {
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mainContent {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: var(--override-colour, $ui-white);
|
aspect-ratio: 16 / 9;
|
||||||
|
min-width: 0;
|
||||||
&[data-phase='pending'] {
|
position: relative;
|
||||||
color: $ontime-roll;
|
/* a query container so that the embedded timer scales to the preview, not to the viewport */
|
||||||
}
|
container-type: size;
|
||||||
&[data-phase='overtime'] {
|
contain: paint;
|
||||||
color: $playback-negative;
|
overflow: hidden;
|
||||||
}
|
border-radius: $component-border-radius-md;
|
||||||
&[data-phase='none'] {
|
|
||||||
opacity: $opacity-disabled;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondaryContent {
|
/* editor chrome sits above the blackout and message overlays, so it stays reachable and readable */
|
||||||
border-top: 1px solid $white-7;
|
.stageChrome {
|
||||||
}
|
|
||||||
|
|
||||||
.blackout {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eventStatus {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
inset: 0;
|
||||||
margin: 0.5rem 0.25rem;
|
z-index: calc($zindex-floating + 2);
|
||||||
display: flex;
|
pointer-events: none;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusIcon {
|
> * {
|
||||||
color: $gray-1000;
|
pointer-events: auto;
|
||||||
|
|
||||||
&[data-active='true'] {
|
|
||||||
color: $active-indicator;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,21 @@
|
|||||||
import { TimerPhase, TimerType } from 'ontime-types';
|
|
||||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
|
||||||
|
|
||||||
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
|
||||||
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../../common/hooks-query/useViewSettings';
|
||||||
import { useMessagePreview } from '../../../common/hooks/useSocket';
|
|
||||||
import { handleLinks } from '../../../common/utils/linkUtils';
|
import { handleLinks } from '../../../common/utils/linkUtils';
|
||||||
import { cx, timerPlaceholder } from '../../../common/utils/styleUtils';
|
|
||||||
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
import PipRoot from '../../../views/editor/pip-timer/PipRoot';
|
||||||
|
import { PipTimer } from '../../../views/editor/pip-timer/PipTimer';
|
||||||
|
import TimerStatus from './TimerStatus';
|
||||||
|
|
||||||
import style from './TimerPreview.module.scss';
|
import style from './TimerPreview.module.scss';
|
||||||
|
|
||||||
const secondarySourceLabels: Record<string, string> = {
|
|
||||||
aux1: 'Aux 1',
|
|
||||||
aux2: 'Aux 2',
|
|
||||||
aux3: 'Aux 3',
|
|
||||||
secondary: 'Secondary message',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TimerPreview() {
|
export default function TimerPreview() {
|
||||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
|
||||||
const { data } = useViewSettings();
|
const { data } = useViewSettings();
|
||||||
|
|
||||||
const main = (() => {
|
|
||||||
if (showTimerMessage) return 'Message';
|
|
||||||
if (timerType === TimerType.None) return timerPlaceholder;
|
|
||||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
|
||||||
if (phase === TimerPhase.Overtime) return 'Timer Overtime';
|
|
||||||
if (timerType === TimerType.Clock) return 'Clock';
|
|
||||||
if (countToEnd) return 'Count to End';
|
|
||||||
return 'Timer';
|
|
||||||
})();
|
|
||||||
|
|
||||||
const secondary = (() => {
|
|
||||||
// message is a fullscreen overlay or secondary is not active
|
|
||||||
if (showTimerMessage || !secondarySource) return null;
|
|
||||||
|
|
||||||
// we need to check aux first since it takes priority
|
|
||||||
return secondarySourceLabels[secondarySource];
|
|
||||||
})();
|
|
||||||
|
|
||||||
const overrideColour = (() => {
|
|
||||||
// override fallback colours from starter project
|
|
||||||
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
|
|
||||||
if (phase === TimerPhase.Danger) return data.dangerColor ?? '#ff7300';
|
|
||||||
return data.normalColor ?? '#FFFC';
|
|
||||||
})();
|
|
||||||
|
|
||||||
const showColourOverride = main == 'Timer';
|
|
||||||
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.preview}>
|
<div className={style.stage}>
|
||||||
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
<PipTimer viewSettings={data} />
|
||||||
<div className={contentClasses}>
|
<div className={style.stageChrome}>
|
||||||
<div
|
<TimerStatus />
|
||||||
className={style.mainContent}
|
<CornerWithPip onExtractClick={(event) => handleLinks('timer', event)} pipElement={<PipRoot />} />
|
||||||
data-phase={showColourOverride && phase}
|
|
||||||
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
|
||||||
>
|
|
||||||
{main}
|
|
||||||
</div>
|
|
||||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
|
||||||
</div>
|
|
||||||
<div className={style.eventStatus}>
|
|
||||||
<Tooltip
|
|
||||||
text='Time type: Count down'
|
|
||||||
render={<span />}
|
|
||||||
className={style.statusIcon}
|
|
||||||
data-active={timerType === TimerType.CountDown}
|
|
||||||
>
|
|
||||||
<IoArrowDown />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
text='Time type: Count up'
|
|
||||||
render={<span />}
|
|
||||||
className={style.statusIcon}
|
|
||||||
data-active={timerType === TimerType.CountUp}
|
|
||||||
>
|
|
||||||
<IoArrowUp />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
text='Time type: Clock'
|
|
||||||
render={<span />}
|
|
||||||
className={style.statusIcon}
|
|
||||||
data-active={timerType === TimerType.Clock}
|
|
||||||
>
|
|
||||||
<IoTime />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
text='Time type: None'
|
|
||||||
render={<span />}
|
|
||||||
className={style.statusIcon}
|
|
||||||
data-active={timerType === TimerType.None}
|
|
||||||
>
|
|
||||||
<IoBan />
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip
|
|
||||||
text={countToEnd ? 'Count to end' : 'Count duration'}
|
|
||||||
render={<span />}
|
|
||||||
className={style.statusIcon}
|
|
||||||
data-active={countToEnd}
|
|
||||||
>
|
|
||||||
<LuArrowDownToLine />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.timerStatus {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
margin: 0.5rem 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusIcon {
|
||||||
|
color: $gray-1000;
|
||||||
|
|
||||||
|
&[data-active='true'] {
|
||||||
|
color: $active-indicator;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { TimerType } from 'ontime-types';
|
||||||
|
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
||||||
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
|
import { useTimerStatus } from '../../../common/hooks/useSocket';
|
||||||
|
|
||||||
|
import style from './TimerStatus.module.scss';
|
||||||
|
|
||||||
|
/** Read only summary of how the loaded event drives the stage timer */
|
||||||
|
export default function TimerStatus() {
|
||||||
|
const { countToEnd, timerType } = useTimerStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.timerStatus}>
|
||||||
|
<Tooltip
|
||||||
|
text='Time type: Count down'
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={timerType === TimerType.CountDown}
|
||||||
|
>
|
||||||
|
<IoArrowDown />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text='Time type: Count up'
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={timerType === TimerType.CountUp}
|
||||||
|
>
|
||||||
|
<IoArrowUp />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text='Time type: Clock'
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={timerType === TimerType.Clock}
|
||||||
|
>
|
||||||
|
<IoTime />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text='Time type: None'
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={timerType === TimerType.None}
|
||||||
|
>
|
||||||
|
<IoBan />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text={countToEnd ? 'Count to end' : 'Count duration'}
|
||||||
|
render={<span />}
|
||||||
|
className={style.statusIcon}
|
||||||
|
data-active={countToEnd}
|
||||||
|
>
|
||||||
|
<LuArrowDownToLine />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
.previewContainer {
|
|
||||||
display: grid;
|
|
||||||
gap: $element-spacing;
|
|
||||||
grid-template-columns: 3fr 2fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $element-spacing;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { SecondarySource } from 'ontime-types';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
|
||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
|
||||||
import Select from '../../../common/components/select/Select';
|
|
||||||
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
|
|
||||||
import TimerPreview from './TimerPreview';
|
|
||||||
|
|
||||||
import style from './TimerViewControl.module.scss';
|
|
||||||
|
|
||||||
export default function TimerControlsPreview() {
|
|
||||||
const { blackout, blink } = useTimerViewControl();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={style.previewContainer}>
|
|
||||||
<TimerPreview />
|
|
||||||
<div className={style.options}>
|
|
||||||
<SecondarySourceControl />
|
|
||||||
|
|
||||||
<Editor.Separator orientation='horizontal' />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={blink ? 'primary' : 'subtle'}
|
|
||||||
fluid
|
|
||||||
onClick={() => setMessage.timerBlink(!blink)}
|
|
||||||
data-testid='toggle timer blink'
|
|
||||||
>
|
|
||||||
Blink
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={blackout ? 'primary' : 'subtle'}
|
|
||||||
fluid
|
|
||||||
onClick={() => setMessage.timerBlackout(!blackout)}
|
|
||||||
data-testid='toggle timer blackout'
|
|
||||||
>
|
|
||||||
Blackout screen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SecondarySourceControl() {
|
|
||||||
const { secondarySource } = useTimerViewControl();
|
|
||||||
const [value, setValue] = useState<SecondarySource>('aux1');
|
|
||||||
|
|
||||||
// sync secondary source with external changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (secondarySource !== null) {
|
|
||||||
setValue(secondarySource);
|
|
||||||
}
|
|
||||||
}, [secondarySource]);
|
|
||||||
|
|
||||||
const toggleSecondary = () => {
|
|
||||||
if (secondarySource === value) {
|
|
||||||
setMessage.timerSecondarySource(null);
|
|
||||||
} else {
|
|
||||||
setMessage.timerSecondarySource(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
value={value}
|
|
||||||
options={[
|
|
||||||
{ value: 'aux1', label: 'Aux 1' },
|
|
||||||
{ value: 'aux2', label: 'Aux 2' },
|
|
||||||
{ value: 'aux3', label: 'Aux 3' },
|
|
||||||
{ value: 'secondary', label: 'Secondary message' },
|
|
||||||
]}
|
|
||||||
onValueChange={(value: SecondarySource | null) => {
|
|
||||||
if (value === null) return;
|
|
||||||
// we can only update the remote if it is enabled
|
|
||||||
if (secondarySource !== null) {
|
|
||||||
setMessage.timerSecondarySource(value);
|
|
||||||
}
|
|
||||||
setValue(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant={secondarySource !== null ? 'primary' : 'subtle'}
|
|
||||||
fluid
|
|
||||||
onClick={toggleSecondary}
|
|
||||||
data-testid='toggle secondary'
|
|
||||||
>
|
|
||||||
Show secondary
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box; /* reset */
|
box-sizing: border-box; /* reset */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 100%; /* restrict the page width to viewport */
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100%; /* fill the pip window or the editor preview frame */
|
||||||
|
position: relative; /* anchor for the blackout and message overlays */
|
||||||
transition: opacity 0.5s ease-in-out;
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
|
||||||
font-family: $viewer-font-family;
|
font-family: $viewer-font-family;
|
||||||
@@ -19,8 +20,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&--finished {
|
&--finished {
|
||||||
outline: clamp(4px, 1vw, 16px) solid $timer-finished-color;
|
outline: clamp(4px, 1cqw, 16px) solid $timer-finished-color;
|
||||||
outline-offset: calc(clamp(4px, 1vw, 16px) * -1);
|
outline-offset: calc(clamp(4px, 1cqw, 16px) * -1);
|
||||||
transition: $viewer-transition-time;
|
transition: $viewer-transition-time;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +108,24 @@
|
|||||||
|
|
||||||
/* =================== OVERLAY ===================*/
|
/* =================== OVERLAY ===================*/
|
||||||
|
|
||||||
.message-overlay {
|
.blackout {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
padding: 2vw;
|
z-index: 0;
|
||||||
|
background-color: #000;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity $viewer-transition-time;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
z-index: calc($zindex-floating + 1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 2cqw;
|
||||||
background: $viewer-background-color;
|
background: $viewer-background-color;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity $viewer-transition-time;
|
transition: opacity $viewer-transition-time;
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
// gather timer data
|
// gather timer data
|
||||||
const totalTime = getTotalTime(time.duration, time.addedTime);
|
const totalTime = getTotalTime(time.duration, time.addedTime);
|
||||||
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
|
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
|
||||||
|
// match the defaults of the timer view, which is what the preview is standing in for
|
||||||
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
|
||||||
removeSeconds: false,
|
removeSeconds: false,
|
||||||
removeLeadingZero: false,
|
removeLeadingZero: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentAux = (() => {
|
const currentAux = (() => {
|
||||||
@@ -63,23 +64,27 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
|
|||||||
|
|
||||||
// gather presentation styles
|
// gather presentation styles
|
||||||
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
|
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
|
||||||
|
// the estimate is tuned for a 16:9 screen, so cap it by height for containers wider than that
|
||||||
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
|
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
|
||||||
|
const timerFontRule = `min(${timerFontSize}cqw, ${((timerFontSize * 16) / 9).toFixed(2)}cqh)`;
|
||||||
const userStyles = {
|
const userStyles = {
|
||||||
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
|
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
|
<div className={cx(['pip-timer', showFinished && 'pip-timer--finished'])} style={userStyles}>
|
||||||
|
<div className={cx(['blackout', message.timer.blackout && 'blackout--active'])} />
|
||||||
|
|
||||||
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
|
<div className={cx(['message-overlay', showOverlay && 'message-overlay--active'])}>
|
||||||
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
|
<FitText mode='multi' min={12} max={256} className={cx(['message', message.timer.blink && 'blink'])}>
|
||||||
{message.timer.text}
|
{message.timer.text}
|
||||||
</FitText>
|
</FitText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='timer-container'>
|
<div className={cx(['timer-container', message.timer.blink && !showOverlay && 'blink'])}>
|
||||||
<div
|
<div
|
||||||
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
className={cx(['timer', !isPlaying && 'timer--paused', showFinished && 'timer--finished'])}
|
||||||
style={{ fontSize: `${timerFontSize}vw` }}
|
style={{ fontSize: timerFontRule }}
|
||||||
data-phase={time.phase}
|
data-phase={time.phase}
|
||||||
>
|
>
|
||||||
{display}
|
{display}
|
||||||
|
|||||||
@@ -19,3 +19,29 @@ test('message control sends messages to screens', async ({ context }) => {
|
|||||||
|
|
||||||
await expect(featurePage.getByText('TIME NOW')).toBeVisible();
|
await expect(featurePage.getByText('TIME NOW')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('message control drives the stage screen state', async ({ context }) => {
|
||||||
|
const editorPage = await context.newPage();
|
||||||
|
const featurePage = await context.newPage();
|
||||||
|
|
||||||
|
await editorPage.goto('/messagecontrol');
|
||||||
|
await featurePage.goto('/timer');
|
||||||
|
await featurePage.waitForLoadState('load', { timeout: 5000 });
|
||||||
|
|
||||||
|
// the secondary line defaults to an aux timer, the select is what puts our text on screen
|
||||||
|
await editorPage.getByPlaceholder('Message shown as secondary text in stage timer').fill('testing secondary');
|
||||||
|
await editorPage.getByRole('combobox').click();
|
||||||
|
await editorPage.getByRole('option', { name: 'Message' }).click();
|
||||||
|
await expect(featurePage.getByText('testing secondary')).toBeVisible();
|
||||||
|
|
||||||
|
await editorPage.getByTestId('toggle timer blackout').click();
|
||||||
|
await expect(featurePage.locator('.blackout')).toHaveClass(/blackout--active/);
|
||||||
|
|
||||||
|
// clearing returns the screen to normal, but keeps what the operator typed
|
||||||
|
await editorPage.getByTestId('clear screen').click();
|
||||||
|
await expect(featurePage.locator('.blackout')).not.toHaveClass(/blackout--active/);
|
||||||
|
await expect(featurePage.getByText('testing secondary')).toHaveCount(0);
|
||||||
|
await expect(editorPage.getByPlaceholder('Message shown as secondary text in stage timer')).toHaveValue(
|
||||||
|
'testing secondary',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user