mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 21:24:11 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a55aab400 | |||
| 7b4ebbf7f5 | |||
| daa032e92c | |||
| ae65ec97d6 | |||
| e7e4302100 | |||
| 7e7d35980a | |||
| 5001dda284 | |||
| a75d35bdc0 | |||
| 2e631194e1 |
@@ -0,0 +1,14 @@
|
||||
.kbd {
|
||||
display: inline-block;
|
||||
min-width: 1.5rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 2px;
|
||||
background-color: $gray-1100;
|
||||
color: $ui-white;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
|
||||
font-family: monospace;
|
||||
font-size: calc(1rem - 4px);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import style from './Kbd.module.scss';
|
||||
|
||||
export default function Kbd({ children }: PropsWithChildren) {
|
||||
return <kbd className={style.kbd}>{children}</kbd>;
|
||||
}
|
||||
@@ -38,8 +38,6 @@ $progress-bar-br: 3px;
|
||||
.multiprogress-bar__indicator-bar {
|
||||
background-color: var(--background-color-override, $ui-black);
|
||||
opacity: 0.8;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
|
||||
.multiprogress-bar--ignore-css-override & {
|
||||
background-color: $ui-black;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
@@ -34,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
className = '',
|
||||
} = props;
|
||||
|
||||
const percentRemaining = 100 - getProgress(now, complete);
|
||||
const percentRemaining = 100 - useAnimatedProgress(now, complete);
|
||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||
const isOvertime = now !== null && now < 0;
|
||||
|
||||
@@ -14,6 +14,4 @@ $progress-bar-br: 3px;
|
||||
.progress-bar__indicator {
|
||||
height: $progress-bar-size;
|
||||
background-color: var(--timer-progress-override, $accent-color);
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
|
||||
|
||||
import './ProgressBar.scss';
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ProgressBarProps {
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { current, duration, className } = props;
|
||||
const progress = getProgress(current, duration);
|
||||
const progress = useAnimatedProgress(current, duration);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${className}`}>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MaybeNumber, Playback } from 'ontime-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getProgress } from '../utils/getProgress';
|
||||
import { usePlayback } from './useSocket';
|
||||
|
||||
/**
|
||||
* Returns the live completion percentage (0–100) of a countdown, interpolated locally.
|
||||
*/
|
||||
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
|
||||
const playback = usePlayback();
|
||||
const isRunning = playback === Playback.Play || playback === Playback.Roll;
|
||||
|
||||
const baseline = useRef({ current, at: performance.now() });
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// there is only something to animate while a running timer is counting down towards 0
|
||||
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
|
||||
|
||||
// re-anchor to the authoritative value whenever the server pushes a new timer update
|
||||
useEffect(() => {
|
||||
baseline.current = { current, at: performance.now() };
|
||||
}, [current, duration, playback]);
|
||||
|
||||
// while counting down, re-render every animation frame so the derived progress stays smooth
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
return;
|
||||
}
|
||||
let frame = requestAnimationFrame(function tick() {
|
||||
setTick((value) => value + 1);
|
||||
frame = requestAnimationFrame(tick);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [shouldAnimate]);
|
||||
|
||||
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
|
||||
const anchored = baseline.current.current;
|
||||
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
|
||||
return getProgress(value, duration);
|
||||
}
|
||||
@@ -180,7 +180,13 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorGroup key={entry.id} title={entry.title} />
|
||||
<OperatorGroup
|
||||
key={entry.id}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
count={entry.entries.length}
|
||||
duration={entry.duration}
|
||||
/>
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
@@ -217,6 +223,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
groupColour={entry.colour}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
|
||||
@@ -22,17 +22,39 @@
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
&.grouped {
|
||||
position: relative;
|
||||
padding-left: 0.35rem;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
|
||||
$viewer-card-bg-color;
|
||||
}
|
||||
|
||||
&.running {
|
||||
border-top: 1px solid $gray-1300;
|
||||
background-color: var(--operator-running-bg-override, $active-green);
|
||||
}
|
||||
|
||||
&.grouped.running {
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
|
||||
var(--operator-running-bg-override, $active-green);
|
||||
}
|
||||
|
||||
&.past {
|
||||
border-top: 1px solid transparent;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.groupRail {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 0.35rem;
|
||||
background-color: var(--group-colour, $gray-500);
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
color: $section-white;
|
||||
@@ -74,6 +96,9 @@
|
||||
.plannedStart,
|
||||
.timeUntil,
|
||||
.runningTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: $component-border-radius-md;
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: 1;
|
||||
@@ -95,6 +120,21 @@
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.live {
|
||||
color: $ui-black;
|
||||
background-color: $ui-white;
|
||||
}
|
||||
|
||||
.due {
|
||||
color: $ui-black;
|
||||
background-color: $orange-500;
|
||||
}
|
||||
|
||||
.done {
|
||||
color: $white-60;
|
||||
background-color: $white-7;
|
||||
}
|
||||
|
||||
.runningTime {
|
||||
grid-area: running;
|
||||
font-size: 1.25rem;
|
||||
|
||||
@@ -25,6 +25,7 @@ interface OperatorEventProps {
|
||||
isLinkedToLoaded: boolean;
|
||||
isSelected: boolean;
|
||||
isPast: boolean;
|
||||
groupColour?: string;
|
||||
selectedRef?: RefObject<HTMLDivElement | null>;
|
||||
showStart: boolean;
|
||||
subscribed: Subscribed;
|
||||
@@ -46,6 +47,7 @@ function OperatorEvent({
|
||||
isLinkedToLoaded,
|
||||
isSelected,
|
||||
isPast,
|
||||
groupColour,
|
||||
selectedRef,
|
||||
showStart,
|
||||
subscribed,
|
||||
@@ -68,7 +70,12 @@ function OperatorEvent({
|
||||
const mouseHandlers = useLongPress(handleLongPress);
|
||||
const cueColours = colour && getAccessibleColour(colour);
|
||||
|
||||
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
|
||||
const operatorClasses = cx([
|
||||
style.event,
|
||||
groupColour && style.grouped,
|
||||
isSelected && style.running,
|
||||
isPast && style.past,
|
||||
]);
|
||||
|
||||
const hasFields = subscribed.some((field) => field.value);
|
||||
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
|
||||
@@ -85,8 +92,10 @@ function OperatorEvent({
|
||||
data-testid={cue}
|
||||
ref={selectedRef}
|
||||
onContextMenu={handleLongPress}
|
||||
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
|
||||
{...mouseHandlers}
|
||||
>
|
||||
{groupColour && <div className={style.groupRail} />}
|
||||
<div className={style.binder} style={{ ...cueColours }}>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
@@ -153,11 +162,11 @@ function OperatorEventSchedule({
|
||||
isLinkedToLoaded,
|
||||
}: OperatorEventScheduleProps) {
|
||||
if (isPast) {
|
||||
return <span className={style.timeUntil}>DONE</span>;
|
||||
return <span className={cx([style.timeUntil, style.done])}>DONE</span>;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return <span className={style.timeUntil}>LIVE</span>;
|
||||
return <span className={cx([style.timeUntil, style.live])}>LIVE</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -186,7 +195,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||
|
||||
return (
|
||||
<span className={style.timeUntil} data-testid='time-until'>
|
||||
<span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'>
|
||||
{timeUntilString}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
.group {
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
||||
background-color: $gray-1350;
|
||||
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.group {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: color-mix(in srgb, currentColor 60%, transparent);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { memo } from 'react';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
|
||||
import style from './OperatorGroup.module.scss';
|
||||
|
||||
interface OperatorGroup {
|
||||
title: string;
|
||||
colour: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export default memo(OperatorGroup);
|
||||
function OperatorGroup({ title }: OperatorGroup) {
|
||||
return <div className={style.group}>{title}</div>;
|
||||
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
||||
const groupColour = colour || '#929292';
|
||||
const groupColours = getAccessibleColour(groupColour);
|
||||
|
||||
return (
|
||||
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
||||
<span className={style.title}>{title}</span>
|
||||
<span className={style.meta}>
|
||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,58 +3,94 @@
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.shortcutSection {
|
||||
flex: 1;
|
||||
margin-top: 15vh;
|
||||
width: min(100%, 48rem);
|
||||
margin-top: clamp(1.5rem, 8vh, 5rem);
|
||||
margin-inline: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
font-size: calc(1rem - 3px);
|
||||
border-collapse: separate;
|
||||
border-spacing: 4rem 0;
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
|
||||
tr {
|
||||
white-space: nowrap;
|
||||
td:nth-child(odd) {
|
||||
text-align: left;
|
||||
}
|
||||
td:nth-child(even) {
|
||||
text-align: right;
|
||||
}
|
||||
.shortcutGroup {
|
||||
h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
color: $ui-white;
|
||||
font-size: calc(1rem - 3px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 1rem;
|
||||
.shortcutList {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.shortcutRow {
|
||||
min-height: 1.625rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.shortcutLabel {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.shortcutKeys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyCombo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.25rem 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $gray-500;
|
||||
font-size: calc(1rem - 5px);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
font-size: calc(1rem - 2px);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: $gray-1200;
|
||||
color: $ui-white;
|
||||
border-radius: 2px;
|
||||
font-weight: 400;
|
||||
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.entryEditor {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.shortcutSection {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.shortcutRow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.shortcutKeys {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PropsWithChildren, memo } from 'react';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import Kbd from '../../../common/components/kbd/Kbd';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
|
||||
import style from './EventEditorEmpty.module.scss';
|
||||
@@ -12,216 +13,124 @@ function EventEditorEmpty() {
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<div className={style.shortcutSection}>
|
||||
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
|
||||
<table className={style.shortcuts}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Find in rundown</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>F</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Open Settings</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>,</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Select entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Select group</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Jump to top / bottom</td>
|
||||
<td>
|
||||
<Kbd>Home</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>End</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Page up / down</td>
|
||||
<td>
|
||||
<Kbd>PgUp</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>PgDn</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Deselect entry</td>
|
||||
<td>
|
||||
<Kbd>Esc</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Reorder selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>C</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cut selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>X</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Paste above</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Paste below</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Clone selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Backspace</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Add event below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>E</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add event above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>E</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add group below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>G</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add group above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>G</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add milestone below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>M</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add milestone above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>M</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add delay below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add delay above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={style.shortcuts}>
|
||||
<ShortcutGroup title='Search'>
|
||||
<Shortcut label='Find in rundown'>
|
||||
<Combo keys={[deviceMod, 'F']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Open settings'>
|
||||
<Combo keys={[deviceMod, ',']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Navigation'>
|
||||
<Shortcut label='Select entry'>
|
||||
<Combo keys={[deviceAlt, '↑']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, '↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Select group'>
|
||||
<Combo keys={[deviceAlt, 'Shift', '↑']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', '↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Jump to top / bottom'>
|
||||
<Combo keys={['Home']} />
|
||||
<Separator />
|
||||
<Combo keys={['End']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Page up / down'>
|
||||
<Combo keys={['PgUp']} />
|
||||
<Separator />
|
||||
<Combo keys={['PgDn']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Deselect entry'>
|
||||
<Combo keys={['Esc']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Editing'>
|
||||
<Shortcut label='Reorder selected entry'>
|
||||
<Combo keys={[deviceAlt, deviceMod, '↑']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, deviceMod, '↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Copy selected entry'>
|
||||
<Combo keys={[deviceMod, 'C']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Cut selected entry'>
|
||||
<Combo keys={[deviceMod, 'X']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Paste below'>
|
||||
<Combo keys={[deviceMod, 'V']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Paste above'>
|
||||
<Combo keys={[deviceMod, 'Shift', 'V']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Clone selected entry'>
|
||||
<Combo keys={[deviceMod, 'D']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Delete selected entry'>
|
||||
<Combo keys={[deviceAlt, 'Backspace']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Insert'>
|
||||
<Shortcut label='Add event below / above'>
|
||||
<Combo keys={[deviceAlt, 'E']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'E']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Add group below / above'>
|
||||
<Combo keys={[deviceAlt, 'G']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'G']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Add milestone below / above'>
|
||||
<Combo keys={[deviceAlt, 'M']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'M']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Add delay below / above'>
|
||||
<Combo keys={[deviceAlt, 'D']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'D']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuxKey({ children }: PropsWithChildren) {
|
||||
return <span className={style.divider}>{children}</span>;
|
||||
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
|
||||
return (
|
||||
<section className={style.shortcutGroup}>
|
||||
<h3>{title}</h3>
|
||||
<div className={style.shortcutList}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Kbd({ children }: PropsWithChildren) {
|
||||
return <span className={style.kbd}>{children}</span>;
|
||||
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.shortcutRow}>
|
||||
<span className={style.shortcutLabel}>{label}</span>
|
||||
<span className={style.shortcutKeys}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Combo({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<span className={style.keyCombo}>
|
||||
{keys.map((key) => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <span className={style.separator}>/</span>;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
@@ -156,7 +156,7 @@ export default function RundownEvent({
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
onClick: () => {
|
||||
clearSelectedEvents();
|
||||
deleteEntry(Array.from(selectedEvents));
|
||||
@@ -202,7 +202,7 @@ export default function RundownEvent({
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
onClick: () => {
|
||||
deleteEntry([eventId]);
|
||||
unselect(eventId);
|
||||
|
||||
-2
@@ -3,7 +3,5 @@
|
||||
width: 0;
|
||||
border-radius: 1px 0 0 1px;
|
||||
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
background-color: $gray-200;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../../../common/utils/getProgress';
|
||||
|
||||
import style from './RundownEventProgressBar.module.scss';
|
||||
|
||||
export default function RundownEventProgressBar() {
|
||||
const timer = useTimer();
|
||||
|
||||
const progress = getProgress(timer.current, timer.duration);
|
||||
const progress = useAnimatedProgress(timer.current, timer.duration);
|
||||
|
||||
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import Tag from '../../../common/components/tag/Tag';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
@@ -66,7 +66,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
type: 'item',
|
||||
label: 'Delete Group',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
onClick: () => deleteEntry([data.id]),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { deviceAlt } from '../../../common/utils/deviceUtils';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
onClick: () => deleteEntry([entryId]),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -54,17 +54,51 @@
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.filterHint {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.em {
|
||||
color: $ui-white;
|
||||
margin-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.hintItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterHint {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SupportedEntry } from 'ontime-types';
|
||||
import { KeyboardEvent, useState } from 'react';
|
||||
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import Kbd from '../../../common/components/kbd/Kbd';
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import useFinder from './useFinder';
|
||||
|
||||
@@ -96,8 +97,25 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
}
|
||||
footerElements={
|
||||
<div className={style.footer}>
|
||||
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
|
||||
<span className={style.em}>title</span> to filter search.
|
||||
<div className={style.hints}>
|
||||
<span className={style.hintItem}>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className={style.hintItem}>
|
||||
<Kbd>Enter</Kbd>
|
||||
Go
|
||||
</span>
|
||||
<span className={style.hintItem}>
|
||||
<Kbd>Esc</Kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.filterHint}>
|
||||
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
|
||||
<span className={style.em}>title</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Day } from 'ontime-types';
|
||||
import { CSSProperties, RefObject } from 'react';
|
||||
|
||||
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
|
||||
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../common/utils/getProgress';
|
||||
import { alpha, cx } from '../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -169,7 +169,7 @@ function TimelineEntryStatus({
|
||||
/** Generates a block level progress bar */
|
||||
function ActiveBlock() {
|
||||
const { current, duration } = useTimer();
|
||||
const progress = getProgress(current, duration);
|
||||
const progress = useAnimatedProgress(current, duration);
|
||||
return (
|
||||
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
|
||||
);
|
||||
|
||||
@@ -975,6 +975,36 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(25);
|
||||
});
|
||||
|
||||
it('paused time is delayed time when the pause spans midnight', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 3 * MILLIS_PER_MINUTE, // 00:03 (after midnight)
|
||||
timer: {
|
||||
startedAt: 23 * MILLIS_PER_HOUR, // started on time at 23:00
|
||||
current: 25, // still counting down
|
||||
addedTime: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 23 * MILLIS_PER_HOUR + 58 * MILLIS_PER_MINUTE, // 23:58, before midnight
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 23 * MILLIS_PER_HOUR,
|
||||
plannedStart: 23 * MILLIS_PER_HOUR,
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
} as RuntimeState;
|
||||
|
||||
// paused from 23:58 to 00:03 -> 5 minutes, regardless of the midnight wrap
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(5 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('offset doesnt exist if we havent started', () => {
|
||||
const state = {
|
||||
clock: 78480789,
|
||||
|
||||
@@ -248,6 +248,59 @@ describe('mutation on runtimeState', () => {
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
test('elapsed excludes a pause that spans midnight', async () => {
|
||||
clearState();
|
||||
// an event that runs over midnight (23:00 -> 01:00)
|
||||
const event = {
|
||||
...mockEvent,
|
||||
id: 'elapsed-pause-midnight',
|
||||
timeStart: 23 * MILLIS_PER_HOUR,
|
||||
timeEnd: 1 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
};
|
||||
const mockRundown = makeRundown({
|
||||
entries: { [event.id]: event },
|
||||
order: [event.id],
|
||||
});
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start before midnight
|
||||
vi.setSystemTime('jan 1 23:50');
|
||||
load(event, rundown, metadata);
|
||||
start();
|
||||
|
||||
// 8 minutes of active running before we pause
|
||||
vi.setSystemTime('jan 1 23:58');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
pause();
|
||||
|
||||
// elapsed is active time since start, so it must not advance while paused,
|
||||
// not even when the pause itself crosses midnight
|
||||
vi.setSystemTime('jan 2 00:01');
|
||||
update();
|
||||
expect(getState().timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// resume 5 minutes after pausing, having crossed midnight (23:58 -> 00:03)
|
||||
vi.setSystemTime('jan 2 00:03');
|
||||
start();
|
||||
let state = getState();
|
||||
// the accumulated pause count is 5 minutes, regardless of the midnight wrap
|
||||
expect(state._timer.pausedDuration).toBe(5 * MILLIS_PER_MINUTE);
|
||||
// and elapsed still reflects only the 8 active minutes
|
||||
expect(state.timer.elapsed).toBe(8 * MILLIS_PER_MINUTE);
|
||||
|
||||
// 2 more active minutes after resume -> 10 minutes elapsed
|
||||
vi.setSystemTime('jan 2 00:05');
|
||||
update();
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(10 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime offset', async () => {
|
||||
|
||||
Reference in New Issue
Block a user