fix(teleprompter): keep the transport row fixed and label every control

The follow control was rendered only while the follow could be re-engaged,
so it appeared and vanished mid show and moved every control after it. An
operator reaching for pause would press whatever had slid under their
finger. It is now always present and goes disabled when there is nothing to
re-engage, which is how the rest of the row already behaved.

The controls also could not explain themselves: the follow target in
particular is not a guessable icon. Each one now carries a tooltip naming
the action and its key, using the shared Tooltip the editor uses.

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-18 18:24:05 +00:00
parent 6ef04d9a92
commit bb8f147575
3 changed files with 105 additions and 41 deletions
@@ -161,7 +161,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
<ControlOverlay <ControlOverlay
isRunning={isRunning} isRunning={isRunning}
speed={speed} speed={speed}
followLocked={followLocked && options.followLoaded} canReengageFollow={followLocked && options.followLoaded}
atEnd={atEnd} atEnd={atEnd}
controller={controller} controller={controller}
onToggleHelp={handleToggleHelp} onToggleHelp={handleToggleHelp}
@@ -2,6 +2,7 @@ import type { MouseEvent } from 'react';
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5'; import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity'; import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { SPEED_STEP } from '../teleprompter.scroll'; import { SPEED_STEP } from '../teleprompter.scroll';
@@ -10,7 +11,8 @@ import type { TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps { interface ControlOverlayProps {
isRunning: boolean; isRunning: boolean;
speed: number; speed: number;
followLocked: boolean; /** whether the follow can be re-engaged: it is off while already following */
canReengageFollow: boolean;
atEnd: boolean; atEnd: boolean;
controller: TeleprompterController; controller: TeleprompterController;
onToggleHelp: () => void; onToggleHelp: () => void;
@@ -23,11 +25,16 @@ interface ControlOverlayProps {
* Visibility follows the same rule as the rest of Ontime's floating chrome: 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 * fades once the operator stops moving, so it leaves the talent's eyeline
* without needing to know whether the script happens to be rolling. * without needing to know whether the script happens to be rolling.
*
* Every control is present for the life of the view and goes disabled when it
* has nothing to do. A control which comes and goes moves every control after
* it, so the operator reaches for pause during a show and presses whatever slid
* under their finger instead.
*/ */
export default function ControlOverlay({ export default function ControlOverlay({
isRunning, isRunning,
speed, speed,
followLocked, canReengageFollow,
atEnd, atEnd,
controller, controller,
onToggleHelp, onToggleHelp,
@@ -50,47 +57,67 @@ export default function ControlOverlay({
return ( return (
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}> <div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
<IconButton <Tooltip
variant='subtle-white' text={isRunning ? 'Pause (Space)' : 'Play (Space)'}
size='large' render={
onClick={press(controller.togglePlay)} <IconButton
data-testid='teleprompter-play' variant='subtle-white'
aria-label={isRunning ? 'Pause' : 'Play'} size='large'
onClick={press(controller.togglePlay)}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
/>
}
> >
{isRunning ? <IoPause /> : <IoPlay />} {isRunning ? <IoPause /> : <IoPlay />}
</IconButton> </Tooltip>
<IconButton <Tooltip
variant='subtle-white' text='Slow down (Left arrow)'
size='large' render={
onClick={press(() => controller.changeSpeed(-SPEED_STEP))} <IconButton
aria-label='Slow down' variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(-SPEED_STEP))}
aria-label='Slow down'
/>
}
> >
<IoRemove /> <IoRemove />
</IconButton> </Tooltip>
<div className='teleprompter__speed' data-testid='teleprompter-speed'> <div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed} {speed}
<span className='teleprompter__speed-unit'>lpm</span> <span className='teleprompter__speed-unit'>lpm</span>
</div> </div>
<IconButton <Tooltip
variant='subtle-white' text='Speed up (Right arrow)'
size='large' render={
onClick={press(() => controller.changeSpeed(SPEED_STEP))} <IconButton
aria-label='Speed up' variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(SPEED_STEP))}
aria-label='Speed up'
/>
}
> >
<IoAdd /> <IoAdd />
</IconButton> </Tooltip>
<IconButton <Tooltip
variant={atEnd ? 'primary' : 'subtle-white'} text='Rewind to the top (Home)'
size='large' render={
onClick={press(() => controller.rewind())} <IconButton
aria-label='Rewind to top' variant={atEnd ? 'primary' : 'subtle-white'}
size='large'
onClick={press(() => controller.rewind())}
aria-label='Rewind to top'
/>
}
> >
<IoArrowUp /> <IoArrowUp />
</IconButton> </Tooltip>
{/* {/*
The operator view solves this with its own FollowButton, a labelled pill The operator view solves this with its own FollowButton, a labelled pill
@@ -98,21 +125,37 @@ export default function ControlOverlay({
two would land on top of each other. Same icon and same job, folded into two would land on top of each other. Same icon and same job, folded into
the transport rather than floating separately. the transport rather than floating separately.
*/} */}
{followLocked && ( <Tooltip
<IconButton text={
variant='primary' canReengageFollow ? 'Jump back to the loaded event and follow it again (L)' : 'Following the loaded event'
size='large' }
onClick={press(controller.reengageFollow)} render={
data-testid='teleprompter-follow' <IconButton
aria-label='Follow the loaded event' variant={canReengageFollow ? 'primary' : 'subtle-white'}
> size='large'
<IoLocate /> disabled={!canReengageFollow}
</IconButton> onClick={press(controller.reengageFollow)}
)} data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
/>
}
>
<IoLocate />
</Tooltip>
<IconButton variant='subtle-white' size='large' onClick={press(onToggleHelp)} aria-label='Keyboard shortcuts'> <Tooltip
text='Keyboard shortcuts (?)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(onToggleHelp)}
aria-label='Keyboard shortcuts'
/>
}
>
<IoHelpCircleOutline /> <IoHelpCircleOutline />
</IconButton> </Tooltip>
</div> </div>
); );
} }
@@ -116,6 +116,27 @@ test.describe('teleprompter', () => {
expect(slower).toBe(before); expect(slower).toBe(before);
}); });
test('keeps every transport control in place', async ({ page }) => {
// a control which appears mid show moves the ones after it, and the operator
// reaches for pause and presses whatever slid under their finger instead
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
const controls = page.locator('.teleprompter__controls button');
const follow = page.getByTestId('teleprompter-follow');
await expect(controls).toHaveCount(6);
await expect(follow).toBeVisible();
await expect(follow).toBeDisabled();
// taking over by hand offers the follow back, without restacking the row
await page.mouse.move(640, 300);
await page.mouse.wheel(0, 200);
await expect(controls).toHaveCount(6);
await expect(follow).toBeVisible();
});
test('f mirrors the view for a beam splitter rig', async ({ page }) => { test('f mirrors the view for a beam splitter rig', async ({ page }) => {
await page.goto(teleprompterUrl); await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible(); await expect(scroller(page)).toBeVisible();