feat(teleprompter): stop playback at the end of the event

A user reading a podcast script found the prompter ran past the end of the
event and on into the next one. An event is a unit of time with a stop of
its own, but auto-scroll bounded against the end of the whole document, so
nothing held it at the boundary. At a conference that means the prompter
reads the next speaker's script while they are still walking on.

Playback now runs to the end of the segment being read and parks there,
saying so. Loading the next event releases it through the existing follow,
and pressing play again carries on into the next segment for a reader who
wants to keep going. Only playback is held: a jump, a page or a nudge is
the reader asking to leave the segment and still crosses freely.

The segment is chosen once when playback starts, and held by identity
rather than as a pixel bound. Re-deciding it against a position which is
moving let the bound outrun the reader and never arrive; holding the id
rather than the offset keeps the stop on the same words when an edit moves
the script underneath it.

Add an option to show only the event being played, for the case where the
rest of the script is a distraction. It falls back to the whole script
while nothing is playing, where narrowing would leave a blank screen. The
script of the event being played is now held out from the rest, which stays
readable so a reader who is ahead or behind can still find their place.

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-26 17:53:21 +00:00
parent 1d8ca453c9
commit 169714b3ae
11 changed files with 243 additions and 19 deletions
@@ -61,6 +61,12 @@
&[data-loaded] .teleprompter__heading {
color: $accent-color;
}
/* Holds the eye on the cued event without hiding what is around it, which is
what the reader needs when they are recovering from being ahead or behind. */
.teleprompter--has-playing &:not([data-loaded]) {
opacity: 0.45;
}
}
.teleprompter__group {
@@ -158,6 +164,14 @@
color: $viewer-label-color;
}
.teleprompter__parked {
font-size: clamp(11px, 1vw, 15px);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
color: $viewer-label-color;
}
.teleprompter__help {
position: fixed;
inset: 0;
@@ -62,11 +62,12 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
const blocks = buildScript(rundown, rundownMetadata, customFields, {
scriptSource: options.scriptSource,
heading: options.heading,
onlyPlaying: options.onlyPlaying,
hideEmpty: options.hideEmpty,
showGroups: options.showGroups,
});
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, atEnd } =
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, parkedAt } =
useTeleprompterScroll({
initialSpeed: options.speed,
followLoaded: options.followLoaded,
@@ -115,6 +116,8 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
'teleprompter',
effectiveFlip.flipH && 'teleprompter--flip-h',
effectiveFlip.flipV && 'teleprompter--flip-v',
// nothing to hold back the eye from when no event is cued
blocks.some((block) => block.isLoaded) && 'teleprompter--has-playing',
])}
style={viewStyles}
data-testid='teleprompter-view'
@@ -146,7 +149,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
isRunning={isRunning}
speed={speed}
canReengageFollow={canReengageFollow}
atEnd={atEnd}
parkedAt={parkedAt}
controller={controller}
onToggleHelp={handleToggleHelp}
/>
@@ -15,6 +15,8 @@ import {
MIN_FONT_SIZE,
MIN_SPEED,
readPointForAnchor,
segmentAfter,
segmentEndFor,
stepFontSize,
} from '../teleprompter.scroll';
@@ -202,6 +204,50 @@ describe('the read anchor', () => {
});
});
describe('segmentAfter()', () => {
const readingOffset = 100;
const script: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 300 },
{ id: 'keynote', top: 300, height: 500 },
];
const endAfter = (position: number) => {
const block = segmentAfter(position, readingOffset, script);
return block === null ? null : segmentEndFor(block, readingOffset);
};
test('stops playback at the end of the segment being read', () => {
// an event has a stop of its own, and running on would read a segment
// nobody cued: at a conference, the next speaker's script
expect(endAfter(0)).toBe(200);
expect(endAfter(150)).toBe(200);
});
test('moves on to the next segment from a position already parked on a boundary', () => {
// otherwise playback would stop again where it already is, and the reader
// could never start the next event by pressing play
expect(endAfter(200)).toBe(700);
});
test('treats a position within measurement noise of a boundary as parked on it', () => {
// re-measuring can leave the parked position a fraction off the boundary,
// and a sub-pixel run to it would look like play doing nothing at all
expect(endAfter(199.5)).toBe(700);
expect(endAfter(190)).toBe(200);
});
test('gives up the bound past the last segment, leaving the end of the script', () => {
expect(endAfter(700)).toBeNull();
});
test('has no boundary to stop at in a single segment script', () => {
// the plain use of pointing at some text and scrolling through it
const single: BlockGeometry[] = [{ id: 'only', top: 0, height: 5000 }];
expect(segmentAfter(0, readingOffset, single)?.id).toBe('only');
expect(segmentAfter(4900, readingOffset, single)).toBeNull();
});
});
describe('easeCatchUp()', () => {
test('approaches the target monotonically from either side', () => {
let fromAbove = 500;
@@ -44,6 +44,7 @@ const customFields: CustomFields = {
const defaultOptions = {
scriptSource: 'custom-script',
heading: 'title' as const,
onlyPlaying: false,
hideEmpty: true,
showGroups: true,
};
@@ -106,6 +107,26 @@ describe('buildScript()', () => {
expect(blocks.map((block) => block.id)).toEqual(['b']);
});
test('onlyPlaying narrows the script to the event being played', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const metadata = metadataFor(['a', 'b'], { b: { isLoaded: true } });
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, onlyPlaying: true }).map((b) => b.id)) //
.toEqual(['b']);
});
test('onlyPlaying shows the whole script while nothing is playing', () => {
// narrowing to nothing would leave a blank screen, which is a worse answer
// than the script the reader asked to see
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
expect(
buildScript(rundown, metadataFor(['a', 'b']), customFields, { ...defaultOptions, onlyPlaying: true }).map(
(b) => b.id,
),
).toEqual(['a', 'b']);
});
test('hideEmpty drops events with no script text', () => {
const rundown = makeRundown([makeEvent('a', { custom: { script: ' ' } }), makeEvent('b')]);
@@ -6,13 +6,13 @@ import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../../common/utils/styleUtils';
import { SPEED_STEP } from '../teleprompter.scroll';
import type { TeleprompterController } from '../teleprompter.types';
import type { ParkedAt, TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps {
isRunning: boolean;
speed: number;
canReengageFollow: boolean;
atEnd: boolean;
parkedAt: ParkedAt;
controller: TeleprompterController;
onToggleHelp: () => void;
}
@@ -21,7 +21,7 @@ export default function ControlOverlay({
isRunning,
speed,
canReengageFollow,
atEnd,
parkedAt,
controller,
onToggleHelp,
}: ControlOverlayProps) {
@@ -71,6 +71,12 @@ export default function ControlOverlay({
<span className='teleprompter__speed-unit'>lpm</span>
</div>
{parkedAt === 'segment' && (
<span className='teleprompter__parked' data-testid='teleprompter-parked'>
End of event
</span>
)}
<Tooltip
text='Speed up (Right arrow)'
render={
@@ -89,7 +95,7 @@ export default function ControlOverlay({
text='Rewind to the top (Home)'
render={
<IconButton
variant={atEnd ? 'primary' : 'subtle-white'}
variant={parkedAt === 'script' ? 'primary' : 'subtle-white'}
size='large'
onClick={press(() => controller.rewind())}
aria-label='Rewind to top'
@@ -21,6 +21,7 @@ const headingSources = headingOptions.map((option) => option.value);
export const defaults = {
script: 'none',
heading: 'title' as HeadingSource,
onlyPlaying: false,
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
@@ -96,6 +97,14 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[]
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'onlyPlaying',
title: 'Show only the playing event',
description:
'Hides the rest of the script, leaving only the event being played. Shows the whole script while nothing is playing',
type: 'boolean',
defaultValue: defaults.onlyPlaying,
},
{
id: 'hideEmpty',
title: 'Hide events without a script',
@@ -196,6 +205,7 @@ export function getOptionsFromParams(
scriptSource: getValue('script') ?? defaults.script,
heading: toEnum(getValue('heading'), headingSources, defaults.heading),
onlyPlaying: toBoolean(getValue('onlyPlaying'), defaults.onlyPlaying),
hideEmpty: toBoolean(getValue('hideEmpty'), defaults.hideEmpty),
showGroups: toBoolean(getValue('showGroups'), defaults.showGroups),
@@ -118,6 +118,36 @@ export function readPointForAnchor(
return null;
}
/** Keeps a position resting on a boundary from counting as still being before it. */
const SEGMENT_BOUNDARY_EPSILON = 1;
/** The position at which the reading line reaches the end of a segment. */
export function segmentEndFor(block: BlockGeometry, readingOffset: number): number {
return block.top + block.height - readingOffset;
}
/**
* The segment playback should run to, or null past the last one, where the end
* of the script is the bound.
*
* An event is a unit of time with a stop of its own, so running the script on
* into the next one reads a segment nobody has cued: at a conference that is
* the next speaker's script, while they are still walking on.
*
* Takes the first segment ending ahead rather than the one the position sits
* in, so that starting again from a position already parked on a boundary
* carries on into the next segment instead of stopping where it already is.
* Decide this once when playback starts: re-deciding it against a position
* which is moving lets the bound outrun the reader and never arrive.
*/
export function segmentAfter(position: number, readingOffset: number, blocks: BlockGeometry[]): BlockGeometry | null {
for (const block of blocks) {
if (segmentEndFor(block, readingOffset) > position + SEGMENT_BOUNDARY_EPSILON) return block;
}
return null;
}
/** How far, in lines, the reader may move the script themselves before it counts as taking over. */
export const FOLLOW_BREAK_LINES = 1.5;
@@ -10,9 +10,16 @@ export type ScriptBlock = {
isLoaded: boolean;
};
/**
* Where playback stopped of its own accord: at the end of the segment being
* read, or at the end of the whole script. Null while it has somewhere to go.
*/
export type ParkedAt = 'segment' | 'script' | null;
export type TeleprompterOptions = {
scriptSource: string;
heading: HeadingSource;
onlyPlaying: boolean;
hideEmpty: boolean;
showGroups: boolean;
/** lines per minute */
@@ -4,7 +4,10 @@ import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { getPropertyValue } from '../common/viewUtils';
import type { HeadingSource, ScriptBlock, TeleprompterOptions } from './teleprompter.types';
type BuildScriptOptions = Pick<TeleprompterOptions, 'scriptSource' | 'heading' | 'hideEmpty' | 'showGroups'>;
type BuildScriptOptions = Pick<
TeleprompterOptions,
'scriptSource' | 'heading' | 'onlyPlaying' | 'hideEmpty' | 'showGroups'
>;
function makeHeading(source: HeadingSource, cue: string, title: string): string {
switch (source) {
@@ -34,7 +37,7 @@ export function buildScript(
customFields: CustomFields,
options: BuildScriptOptions,
): ScriptBlock[] {
const { scriptSource, heading, hideEmpty, showGroups } = options;
const { scriptSource, heading, onlyPlaying, hideEmpty, showGroups } = options;
if (scriptSource === 'none' || !isReadableSource(scriptSource, customFields)) {
return [];
@@ -72,6 +75,15 @@ export function buildScript(
});
}
if (onlyPlaying) {
const playing = blocks.filter((block) => block.isLoaded);
// With nothing loaded there is no event to narrow to, and a blank screen
// would be a worse answer than the script the reader asked to see.
if (playing.length > 0) {
return playing;
}
}
return blocks;
}
@@ -13,8 +13,10 @@ import {
linesPerMinuteToPxPerSecond,
readPointForAnchor,
type ScrollAnchor,
segmentAfter,
segmentEndFor,
} from './teleprompter.scroll';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
import type { ParkedAt, ScriptBlock, TeleprompterController } from './teleprompter.types';
const PAGE_FRACTION = 0.85;
const EXTERNAL_SCROLL_EPSILON = 2;
@@ -92,13 +94,15 @@ export function useTeleprompterScroll({
// the script's layout as of the last measure, and the reader's place in it
const geometryRef = useRef<BlockGeometry[]>([]);
const anchorRef = useRef<ScrollAnchor | null>(null);
// the segment this run of playback stops at, chosen when it started
const playbackSegmentRef = useRef<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
// mirrors the operator view's lockAutoScroll: true once the reader has taken
// the scroll over by hand, false while following is doing the driving
const [autoScrollLocked, setAutoScrollLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
const [parkedAt, setParkedAt] = useState<ParkedAt>(null);
const isFollowingRef = useRef(false);
useEffect(() => {
@@ -155,13 +159,27 @@ export function useTeleprompterScroll({
catchUpTargetRef.current = null;
}
} else if (runningRef.current) {
// Only playback is held to the segment. A jump, a page or a nudge is
// the reader asking to leave it, and stays free to cross.
//
// Resolved from the segment's identity rather than the pixel bound
// taken when playback started, so an edit which moves the script keeps
// playback stopping on the same words.
const stopBlock = geometryRef.current.find((block) => block.id === playbackSegmentRef.current);
const stop = stopBlock
? clamp(segmentEndFor(stopBlock, readingOffsetRef.current), 0, maxScrollRef.current)
: maxScrollRef.current;
const pxPerSecond = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
const result = advance(next, pxPerSecond, deltaSeconds, maxScrollRef.current);
next = result.position;
const result = advance(next, pxPerSecond, deltaSeconds, stop);
next = Math.min(result.position, stop);
if (result.atEnd) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
// Past the last segment there is only the trailing padding, so
// stopping there is the end of the read rather than a wait for a cue.
const isLastSegment = stopBlock !== undefined && stopBlock.id === geometryRef.current.at(-1)?.id;
setParkedAt(isLastSegment || stop >= maxScrollRef.current ? 'script' : 'segment');
}
}
@@ -337,7 +355,7 @@ export function useTeleprompterScroll({
followTargetRef.current = target;
catchUpTargetRef.current = target;
readerDriftRef.current = 0;
setAtEnd(false);
setParkedAt(null);
}, [selectedEventId, followLoaded, autoScrollLocked, readingLinePos, hasSelectedBlock, scrollTargetFor]);
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
@@ -353,9 +371,11 @@ export function useTeleprompterScroll({
if (maxScrollRef.current > 0 && posRef.current >= maxScrollRef.current) {
return;
}
const stopAt = segmentAfter(posRef.current, readingOffsetRef.current, geometryRef.current);
playbackSegmentRef.current = stopAt?.id ?? null;
runningRef.current = true;
setIsRunning(true);
setAtEnd(false);
setParkedAt(null);
};
const pause = () => {
@@ -375,7 +395,7 @@ export function useTeleprompterScroll({
const target = clamp(position, 0, maxScrollRef.current);
addReaderDrift(target - destination());
catchUpTargetRef.current = target;
setAtEnd(false);
setParkedAt(null);
};
return {
@@ -384,7 +404,7 @@ export function useTeleprompterScroll({
const distance = lines * lineHeightRef.current;
pendingDeltaRef.current += distance;
addReaderDrift(distance);
setAtEnd(false);
setParkedAt(null);
},
page: (direction: 1 | -1) => {
const scroller = scrollerRef.current;
@@ -410,7 +430,7 @@ export function useTeleprompterScroll({
if (maxScrollRef.current > 0) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
setParkedAt('script');
}
},
reengageFollow: () => {
@@ -430,6 +450,6 @@ export function useTeleprompterScroll({
// folds followLoaded in, so callers get one ready-to-use signal instead of
// a runtime flag they must remember to AND with the option themselves
canReengageFollow: followLoaded && autoScrollLocked,
atEnd,
parkedAt,
};
}
@@ -117,6 +117,61 @@ test('shift and the vertical arrows walk the reader event by event', async ({ pa
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
});
test('playback stops at the end of the event instead of reading on into the next', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note&followLoaded=false&speed=40');
const scroller = page.getByTestId('teleprompter-scroller');
await expect(scroller).toBeVisible();
// park the reading line just short of the first event's end, so the run to
// the boundary takes a moment rather than the length of the segment
const segmentEnd = await scroller.evaluate((element) => {
const block = element.querySelector<HTMLElement>('.teleprompter__block');
if (!block) throw new Error('No script block found');
const end = block.offsetTop + block.offsetHeight - element.clientHeight * 0.25;
element.scrollTop = end - 30;
return end;
});
await page.keyboard.press('Space');
await expect(page.getByTestId('teleprompter-parked')).toBeVisible();
await expect
.poll(async () => Math.abs((await scroller.evaluate((element) => element.scrollTop)) - segmentEnd))
.toBeLessThan(3);
// and it stays there, rather than carrying on after a beat
await page.waitForTimeout(500);
expect(Math.abs((await scroller.evaluate((element) => element.scrollTop)) - segmentEnd)).toBeLessThan(3);
// pressing play again is how the reader moves on to the next event
await page.keyboard.press('Space');
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(segmentEnd + 5);
});
test('onlyPlaying narrows the script to the event being played', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note');
await expect(page.locator('.teleprompter__block').first()).toBeVisible();
const whole = await page.locator('.teleprompter__block').count();
expect(whole).toBeGreaterThan(1);
await page.goto('/teleprompter?script=note&onlyPlaying=true');
const blocks = page.locator('.teleprompter__block');
await expect(blocks).toHaveCount(1);
await expect(blocks.first()).toHaveAttribute('data-loaded', 'true');
});
test('follow tolerates a small scroll and breaks on a real one, like the operator view', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);