refactor(teleprompter): build the control overlay from the shared pieces

The overlay is the on screen transport, for whoever is not at a keyboard, which
on a tablet prompter is everyone. It was written from scratch and three things
it needed already existed.

Its buttons are now IconButton in the subtle-white variant, the same as the
floating navigation uses over a dark viewer, which removes a block of bespoke
button styling that had reinvented sizing, centring, radius and hover. The help
overlay's close button likewise uses the shared Button.

Visibility now comes from useFadeOutOnInactivity, the hook the floating
navigation uses, so the overlay fades once the operator stops moving. It used
to fade on whether the script was rolling, which meant a paused prompter kept a
control bar in the talent's eyeline indefinitely.

Following the shared pattern turned up a bug in the old one. The floating
navigation stops taking clicks once faded, and this did not, so there was an
invisible transport across the foot of the script where a stray tap would pause
the read. It now matches.

The operator view's FollowButton is deliberately not reused. It is a labelled
pill fixed to the bottom centre, which is exactly where this group sits, so the
two would land on top of each other. Same icon and same job, folded into the
transport instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
Claude
2026-08-16 19:46:30 +00:00
parent 194fe0fb16
commit 8253f1c119
3 changed files with 51 additions and 60 deletions
@@ -178,35 +178,15 @@
opacity: 1;
transition: opacity $viewer-transition-time;
/* stay out of the eyeline while the script is rolling, until pointed at */
/**
* useFadeOutOnInactivity drives this, as it does the floating navigation, and
* the same rule applies: once faded the controls stop taking clicks. Leaving
* them live would put an invisible transport across the foot of the script,
* where a stray tap pauses the read.
*/
&--idle {
opacity: 0;
}
&:hover,
&:focus-within {
opacity: 1;
}
}
.teleprompter__control {
display: grid;
place-items: center;
width: 2em;
height: 2em;
font-size: clamp(14px, 1.4vw, 22px);
color: $viewer-color;
background: rgba(white, 8%);
border-radius: $element-border-radius;
cursor: pointer;
&:hover {
background: rgba(white, 16%);
}
&--attention {
color: $accent-color;
pointer-events: none;
}
}
@@ -270,9 +250,4 @@
.teleprompter__help-close {
margin-top: $view-element-gap;
padding: 0.4em 1em;
color: $viewer-color;
background: rgba(white, 8%);
border-radius: $element-border-radius;
cursor: pointer;
}
@@ -1,5 +1,7 @@
import { IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove, IoAdd } from 'react-icons/io5';
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../../common/utils/styleUtils';
import { SPEED_STEP } from '../teleprompter.scroll';
import type { TeleprompterController } from '../teleprompter.types';
@@ -14,8 +16,12 @@ interface ControlOverlayProps {
}
/**
* On screen transport, for when nobody is at a keyboard.
* It fades out while the script is rolling so it does not sit in the talent's eyeline.
* Transport for whoever is not at a keyboard, which on a tablet prompter is
* everyone. The keys remain the primary interface, and the way pedals reach it.
*
* Visibility follows the same rule as the rest of Ontime's floating chrome: it
* fades once the operator stops moving, so it leaves the talent's eyeline
* without needing to know whether the script happens to be rolling.
*/
export default function ControlOverlay({
isRunning,
@@ -25,65 +31,73 @@ export default function ControlOverlay({
controller,
onToggleHelp,
}: ControlOverlayProps) {
const isActive = useFadeOutOnInactivity(true);
return (
<div className={cx(['teleprompter__controls', isRunning && 'teleprompter__controls--idle'])}>
<button
type='button'
className='teleprompter__control'
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
<IconButton
variant='subtle-white'
size='large'
onClick={controller.togglePlay}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
>
{isRunning ? <IoPause /> : <IoPlay />}
</button>
</IconButton>
<button
type='button'
className='teleprompter__control'
<IconButton
variant='subtle-white'
size='large'
onClick={() => controller.changeSpeed(-SPEED_STEP)}
aria-label='Slow down'
>
<IoRemove />
</button>
</IconButton>
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed}
<span className='teleprompter__speed-unit'>lpm</span>
</div>
<button
type='button'
className='teleprompter__control'
<IconButton
variant='subtle-white'
size='large'
onClick={() => controller.changeSpeed(SPEED_STEP)}
aria-label='Speed up'
>
<IoAdd />
</button>
</IconButton>
<button
type='button'
className={cx(['teleprompter__control', atEnd && 'teleprompter__control--attention'])}
<IconButton
variant={atEnd ? 'primary' : 'subtle-white'}
size='large'
onClick={() => controller.rewind()}
aria-label='Rewind to top'
>
<IoArrowUp />
</button>
</IconButton>
{/*
The operator view solves this with its own FollowButton, a labelled pill
fixed to the bottom centre. That is exactly where this group sits, so the
two would land on top of each other. Same icon and same job, folded into
the transport rather than floating separately.
*/}
{followLocked && (
<button
type='button'
className='teleprompter__control teleprompter__control--attention'
<IconButton
variant='primary'
size='large'
onClick={controller.reengageFollow}
data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
>
<IoLocate />
</button>
</IconButton>
)}
<button type='button' className='teleprompter__control' onClick={onToggleHelp} aria-label='Keyboard shortcuts'>
<IconButton variant='subtle-white' size='large' onClick={onToggleHelp} aria-label='Keyboard shortcuts'>
<IoHelpCircleOutline />
</button>
</IconButton>
</div>
);
}
@@ -1,3 +1,5 @@
import Button from '../../../common/components/buttons/Button';
interface HelpOverlayProps {
onClose: () => void;
}
@@ -37,9 +39,9 @@ export default function HelpOverlay({ onClose }: HelpOverlayProps) {
<div className='teleprompter__help-note'>
Foot pedals and hand controllers which emit these keys work without any setup.
</div>
<button type='button' className='teleprompter__help-close' onClick={onClose}>
<Button variant='subtle-white' onClick={onClose} className='teleprompter__help-close'>
Close
</button>
</Button>
</div>
</div>
);