diff --git a/apps/client/src/views/teleprompter/Teleprompter.scss b/apps/client/src/views/teleprompter/Teleprompter.scss
index 433544e1c..3db72a64b 100644
--- a/apps/client/src/views/teleprompter/Teleprompter.scss
+++ b/apps/client/src/views/teleprompter/Teleprompter.scss
@@ -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;
diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx
index e33085968..990c0bf22 100644
--- a/apps/client/src/views/teleprompter/Teleprompter.tsx
+++ b/apps/client/src/views/teleprompter/Teleprompter.tsx
@@ -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}
/>
diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts
index 70e31ff71..33b53136e 100644
--- a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts
+++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts
@@ -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;
diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts
index 70bb0ff8b..ba6b7d5ac 100644
--- a/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts
+++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts
@@ -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')]);
diff --git a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx
index e19a5a1d7..0836ef71b 100644
--- a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx
+++ b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx
@@ -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({
lpm
+ {parkedAt === 'segment' && (
+
+ End of event
+
+ )}
+
controller.rewind())}
aria-label='Rewind to top'
diff --git a/apps/client/src/views/teleprompter/teleprompter.options.ts b/apps/client/src/views/teleprompter/teleprompter.options.ts
index 5841eedaa..790e1b6c5 100644
--- a/apps/client/src/views/teleprompter/teleprompter.options.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.options.ts
@@ -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),
diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts
index 20fe07fb2..541330c47 100644
--- a/apps/client/src/views/teleprompter/teleprompter.scroll.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts
@@ -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;
diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts
index 205cc6caa..3b82a60f9 100644
--- a/apps/client/src/views/teleprompter/teleprompter.types.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.types.ts
@@ -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 */
diff --git a/apps/client/src/views/teleprompter/teleprompter.utils.ts b/apps/client/src/views/teleprompter/teleprompter.utils.ts
index dbe3a1f18..e4183a2da 100644
--- a/apps/client/src/views/teleprompter/teleprompter.utils.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.utils.ts
@@ -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;
+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;
}
diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
index 04e55b451..f539dc6e7 100644
--- a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
+++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
@@ -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([]);
const anchorRef = useRef(null);
+ // the segment this run of playback stops at, chosen when it started
+ const playbackSegmentRef = useRef(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(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,
};
}
diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts
index 1ae10bcf7..689ad76e7 100644
--- a/e2e/tests/features/215-teleprompter.spec.ts
+++ b/e2e/tests/features/215-teleprompter.spec.ts
@@ -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('.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);