diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx
index 7a1df39cb..7d3675ac2 100644
--- a/apps/client/src/views/teleprompter/Teleprompter.tsx
+++ b/apps/client/src/views/teleprompter/Teleprompter.tsx
@@ -71,12 +71,6 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
],
);
- // a cheap identity for the rendered document, used to trigger a remeasure
- const contentKey = useMemo(
- () => `${blocks.length}-${options.fontSize}-${fontScale}-${options.textWidth}`,
- [blocks.length, options.fontSize, fontScale, options.textWidth],
- );
-
const {
scrollerRef,
contentRef,
@@ -93,7 +87,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
- contentKey,
+ blocks,
});
const handleFlip = useCallback((axis: 'h' | 'v') => {
@@ -162,7 +156,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
>
{blocks.map((block) => (
-
+
))}
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 640f2c628..befc983b7 100644
--- a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts
+++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts
@@ -1,10 +1,8 @@
import {
advance,
- clampFontScale,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
- hasArrived,
linesPerMinuteToPxPerSecond,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
@@ -16,10 +14,6 @@ describe('linesPerMinuteToPxPerSecond()', () => {
// 30 lines a minute over a 64px line is half a line a second
expect(linesPerMinuteToPxPerSecond(30, 64)).toBe(32);
});
-
- test('is zero before the line height has been measured', () => {
- expect(linesPerMinuteToPxPerSecond(30, 0)).toBe(0);
- });
});
describe('clampSpeed()', () => {
@@ -29,25 +23,16 @@ describe('clampSpeed()', () => {
expect(clampSpeed(30)).toBe(30);
});
- test('falls back to the minimum for a non number', () => {
+ test('falls back to the minimum for a non number, rather than stalling at zero', () => {
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
});
});
-describe('clampFontScale()', () => {
- test('bounds the font scale', () => {
- expect(clampFontScale(0.1)).toBe(0.4);
- expect(clampFontScale(10)).toBe(3);
- });
-});
-
describe('frameDeltaSeconds()', () => {
- test('converts milliseconds to seconds', () => {
- expect(frameDeltaSeconds(50)).toBe(0.05);
- expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
- });
-
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
+ // requestAnimationFrame is suspended while hidden, so the first timestamp
+ // back can be minutes stale
+ expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
expect(frameDeltaSeconds(60_000)).toBe(MAX_FRAME_DELTA_MS / 1000);
});
@@ -82,35 +67,34 @@ describe('advance()', () => {
});
test('never reports the end for a document which does not overflow', () => {
- // the document may simply not have been measured yet
+ // it may simply not have been measured yet, and stopping playback on an
+ // unmeasured document would look like the prompter refusing to run
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
});
});
describe('easeCatchUp()', () => {
- test('moves monotonically towards a target below', () => {
- let position = 0;
- let previous = -1;
+ test('approaches the target monotonically from either side', () => {
+ let fromAbove = 500;
+ let fromBelow = 0;
+ let previousAbove = 501;
+ let previousBelow = -1;
+
for (let i = 0; i < 20; i += 1) {
- position = easeCatchUp(position, 500, 1 / 60);
- expect(position).toBeGreaterThan(previous);
- expect(position).toBeLessThanOrEqual(500);
- previous = position;
+ fromBelow = easeCatchUp(fromBelow, 500, 1 / 60);
+ fromAbove = easeCatchUp(fromAbove, 0, 1 / 60);
+
+ expect(fromBelow).toBeGreaterThan(previousBelow);
+ expect(fromBelow).toBeLessThanOrEqual(500);
+ expect(fromAbove).toBeLessThan(previousAbove);
+ expect(fromAbove).toBeGreaterThanOrEqual(0);
+
+ previousBelow = fromBelow;
+ previousAbove = fromAbove;
}
});
- test('moves monotonically towards a target above', () => {
- let position = 500;
- let previous = 501;
- for (let i = 0; i < 20; i += 1) {
- position = easeCatchUp(position, 0, 1 / 60);
- expect(position).toBeLessThan(previous);
- expect(position).toBeGreaterThanOrEqual(0);
- previous = position;
- }
- });
-
- test('converges on the target within a bounded number of frames', () => {
+ test('settles exactly on the target instead of creeping forever', () => {
let position = 0;
for (let i = 0; i < 300; i += 1) {
position = easeCatchUp(position, 500, 1 / 60);
@@ -118,7 +102,7 @@ describe('easeCatchUp()', () => {
expect(position).toBe(500);
});
- test('is framerate independent', () => {
+ test('is framerate independent, so a jump takes the same time on any display', () => {
let atSixty = 0;
for (let i = 0; i < 60; i += 1) {
atSixty = easeCatchUp(atSixty, 1000, 1 / 60);
@@ -131,15 +115,4 @@ describe('easeCatchUp()', () => {
expect(atSixty).toBeCloseTo(atThirty, 3);
});
-
- test('does not move without time passing', () => {
- expect(easeCatchUp(100, 500, 0)).toBe(100);
- });
-});
-
-describe('hasArrived()', () => {
- test('treats a sub-pixel distance as arrived', () => {
- expect(hasArrived(100, 100.1)).toBe(true);
- expect(hasArrived(100, 105)).toBe(false);
- });
});
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 02b543f65..412266324 100644
--- a/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts
+++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts
@@ -150,6 +150,34 @@ describe('buildScript()', () => {
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null]);
});
+ test('emits each group title as the script moves between groups', () => {
+ const rundown = makeRundown(
+ [
+ makeGroup('g1', 'Morning session', ['a']),
+ makeEvent('a'),
+ makeGroup('g2', 'Afternoon session', ['b']),
+ makeEvent('b'),
+ ],
+ ['g1', 'a', 'g2', 'b'],
+ );
+ const metadata = metadataFor(['g1', 'a', 'g2', 'b'], { a: { groupId: 'g1' }, b: { groupId: 'g2' } });
+
+ const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
+ expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', 'Afternoon session']);
+ });
+
+ test('repeats a group title when the script returns to it after an ungrouped event', () => {
+ // the reader has lost the context by then, so naming the group again is right
+ const rundown = makeRundown(
+ [makeGroup('g', 'Morning session', ['a', 'c']), makeEvent('a'), makeEvent('b'), makeEvent('c')],
+ ['g', 'a', 'b', 'c'],
+ );
+ const metadata = metadataFor(['g', 'a', 'b', 'c'], { a: { groupId: 'g' }, c: { groupId: 'g' } });
+
+ const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
+ expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null, 'Morning session']);
+ });
+
test('does not emit group titles when they are turned off', () => {
const rundown = makeRundown([makeGroup('g', 'Morning session', ['a']), makeEvent('a')], ['g', 'a']);
const metadata = metadataFor(['g', 'a'], { a: { groupId: 'g' } });
@@ -172,6 +200,13 @@ describe('buildScript()', () => {
);
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'none' })[0].heading).toBe('');
});
+
+ test('leaves no dangling separator when an event has no cue', () => {
+ const noCue = makeRundown([makeEvent('a', { cue: '' })]);
+ expect(buildScript(noCue, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
+ 'Title a',
+ );
+ });
});
});
diff --git a/apps/client/src/views/teleprompter/script-block/ScriptBlock.tsx b/apps/client/src/views/teleprompter/script-block/ScriptBlock.tsx
index 562aaba6b..7d573a568 100644
--- a/apps/client/src/views/teleprompter/script-block/ScriptBlock.tsx
+++ b/apps/client/src/views/teleprompter/script-block/ScriptBlock.tsx
@@ -1,17 +1,22 @@
-import { memo } from 'react';
+import { memo, useCallback } from 'react';
import type { ScriptBlock } from '../teleprompter.types';
interface ScriptBlockProps {
block: ScriptBlock;
- registerRef: (element: HTMLElement | null) => void;
+ registerRef: (id: string, element: HTMLElement | null) => void;
}
export default memo(ScriptBlockView);
function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
+ // the id is bound here rather than by the parent so that the callback stays
+ // stable: React re-runs a ref callback whenever its identity changes, which
+ // would unregister and re-register every block on every parent render
+ const setRef = useCallback((element: HTMLElement | null) => registerRef(block.id, element), [block.id, registerRef]);
+
return (
-
+
{block.groupTitle && {block.groupTitle}
}
{block.heading && {block.heading}
}
{/* the script is user data, it is rendered as text and never as markup */}
diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts
index bd0064fd4..0a8bc92f1 100644
--- a/apps/client/src/views/teleprompter/teleprompter.scroll.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts
@@ -11,8 +11,8 @@ export const MAX_SPEED = 200;
export const DEFAULT_SPEED = 30;
/** font size multiplier applied on top of the configured size by the +/- keys */
-export const MIN_FONT_SCALE = 0.4;
-export const MAX_FONT_SCALE = 3;
+const MIN_FONT_SCALE = 0.4;
+const MAX_FONT_SCALE = 3;
export const FONT_SCALE_STEP = 0.1;
/**
@@ -26,7 +26,7 @@ export const MAX_FRAME_DELTA_MS = 100;
const CATCH_UP_RATE = 8;
/** below this distance an eased jump is considered arrived */
-export const CATCH_UP_EPSILON = 0.5;
+const CATCH_UP_EPSILON = 0.5;
export function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min;
@@ -50,11 +50,6 @@ export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx
return (linesPerMinute / 60) * lineHeightPx;
}
-/** Estimates a words per minute read rate, for display only */
-export function linesPerMinuteToWordsPerMinute(linesPerMinute: number, wordsPerLine: number): number {
- return Math.round(linesPerMinute * wordsPerLine);
-}
-
/**
* Clamps a raw frame delta and converts it to seconds.
* @param deltaMs milliseconds since the previous frame
diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts
index e2f06116c..9e76c8ff6 100644
--- a/apps/client/src/views/teleprompter/teleprompter.types.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.types.ts
@@ -66,8 +66,6 @@ export type TeleprompterAction =
/** The imperative surface the overlay and the key handler drive */
export type TeleprompterController = {
togglePlay: () => void;
- play: () => void;
- pause: () => void;
nudge: (lines: number) => void;
page: (direction: 1 | -1) => void;
changeSpeed: (delta: number) => void;
diff --git a/apps/client/src/views/teleprompter/teleprompter.utils.ts b/apps/client/src/views/teleprompter/teleprompter.utils.ts
index ef22b3459..86140754f 100644
--- a/apps/client/src/views/teleprompter/teleprompter.utils.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.utils.ts
@@ -111,23 +111,3 @@ export function buildScript(
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
}
-
-/**
- * Rough words per line, used only to show a words per minute estimate in the HUD.
- */
-export function estimateWordsPerLine(blocks: ScriptBlock[], charactersPerLine: number): number {
- if (blocks.length === 0 || charactersPerLine <= 0) {
- return 0;
- }
-
- let characters = 0;
- let words = 0;
- for (const block of blocks) {
- characters += block.text.length;
- words += block.text.split(/\s+/).filter(Boolean).length;
- }
-
- if (characters === 0) return 0;
- const averageWordLength = characters / words;
- return charactersPerLine / averageWordLength;
-}
diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
index 81a1ca54b..a8aee786e 100644
--- a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
+++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
@@ -10,7 +10,7 @@ import {
hasArrived,
linesPerMinuteToPxPerSecond,
} from './teleprompter.scroll';
-import type { TeleprompterController } from './teleprompter.types';
+import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
/** how much of a screen a page jump moves */
const PAGE_FRACTION = 0.85;
@@ -25,8 +25,8 @@ interface UseTeleprompterScrollArgs {
selectedEventId: string | null;
/** percentage from the top of the screen */
readingLinePos: number;
- /** changes whenever the rendered document changes, forcing a remeasure */
- contentKey: string;
+ /** the rendered document, watched so a follow can retarget once blocks remount */
+ blocks: ScriptBlock[];
}
/**
@@ -49,7 +49,7 @@ export function useTeleprompterScroll({
followLoaded,
selectedEventId,
readingLinePos,
- contentKey,
+ blocks,
}: UseTeleprompterScrollArgs) {
const scrollerRef = useRef(null);
const contentRef = useRef(null);
@@ -67,47 +67,19 @@ export function useTeleprompterScroll({
const catchUpTargetRef = useRef(null);
const pendingDeltaRef = useRef(0);
const adoptScrollRef = useRef(false);
- const tickRef = useRef<(ts: number) => void>(() => {});
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
const [followLocked, setFollowLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
- /** wakes the animation frame loop if it is not already running */
- const ensureLoop = useCallback(() => {
- if (frameRef.current !== null) return;
- lastTsRef.current = performance.now();
- frameRef.current = requestAnimationFrame((ts) => tickRef.current(ts));
- }, []);
-
- /** recomputes the scroll bounds and the line height the speed is derived from */
- const measure = useCallback(() => {
- const scroller = scrollerRef.current;
- const content = contentRef.current;
- if (!scroller || !content) return;
-
- maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
-
- const sample = content.querySelector('[data-prompter-body]');
- if (sample) {
- const computed = getComputedStyle(sample);
- const parsedLineHeight = Number.parseFloat(computed.lineHeight);
- if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
- lineHeightRef.current = parsedLineHeight;
- } else {
- // line-height resolves to the string 'normal' when it is not set explicitly
- const parsedFontSize = Number.parseFloat(computed.fontSize);
- if (Number.isFinite(parsedFontSize)) {
- lineHeightRef.current = parsedFontSize * 1.5;
- }
- }
- }
-
- speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
- }, []);
-
- tickRef.current = (ts: number) => {
+ /**
+ * Advances the scroll by one frame.
+ *
+ * Reads and writes only refs, so it never needs rebuilding: keeping it stable
+ * is what lets the loop reschedule itself without going through the render.
+ */
+ const tick = useCallback((timestamp: number) => {
const el = scrollerRef.current;
if (!el) {
frameRef.current = null;
@@ -123,8 +95,8 @@ export function useTeleprompterScroll({
adoptScrollRef.current = false;
}
- const deltaSeconds = frameDeltaSeconds(ts - lastTsRef.current);
- lastTsRef.current = ts;
+ const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
+ lastTsRef.current = timestamp;
let next = posRef.current;
@@ -161,10 +133,42 @@ export function useTeleprompterScroll({
catchUpTargetRef.current !== null ||
pendingDeltaRef.current !== 0 ||
adoptScrollRef.current;
- frameRef.current = hasWork ? requestAnimationFrame((next) => tickRef.current(next)) : null;
- };
+ frameRef.current = hasWork ? requestAnimationFrame(tick) : null;
+ }, []);
+
+ /** wakes the animation frame loop if it is not already running */
+ const ensureLoop = useCallback(() => {
+ if (frameRef.current !== null) return;
+ lastTsRef.current = performance.now();
+ frameRef.current = requestAnimationFrame(tick);
+ }, [tick]);
+
+ /** recomputes the scroll bounds and the line height the speed is derived from */
+ const measure = useCallback(() => {
+ const scroller = scrollerRef.current;
+ const content = contentRef.current;
+ if (!scroller || !content) return;
+
+ maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
+
+ const sample = content.querySelector('[data-prompter-body]');
+ if (sample) {
+ const computed = getComputedStyle(sample);
+ const parsedLineHeight = Number.parseFloat(computed.lineHeight);
+ if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
+ lineHeightRef.current = parsedLineHeight;
+ } else {
+ // line-height resolves to the string 'normal' when it is not set explicitly
+ const parsedFontSize = Number.parseFloat(computed.fontSize);
+ if (Number.isFinite(parsedFontSize)) {
+ lineHeightRef.current = parsedFontSize * 1.5;
+ }
+ }
+ }
+
+ speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
+ }, []);
- // stop the loop when the view goes away
useEffect(() => {
return () => {
if (frameRef.current !== null) {
@@ -185,7 +189,8 @@ export function useTeleprompterScroll({
setSpeed(clampSpeed(initialSpeed));
}, [initialSpeed]);
- // remeasure whenever the document or the viewport changes shape
+ // the observer covers every reflow that matters, script edits included, so
+ // nothing else needs to ask for a remeasure
useEffect(() => {
measure();
@@ -197,7 +202,7 @@ export function useTeleprompterScroll({
observer.observe(scroller);
observer.observe(content);
return () => observer.disconnect();
- }, [measure, contentKey]);
+ }, [measure]);
// webfonts land after first paint and reflow the whole document
useEffect(() => {
@@ -245,7 +250,7 @@ export function useTeleprompterScroll({
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
setAtEnd(false);
ensureLoop();
- }, [selectedEventId, followLoaded, followLocked, readingLinePos, contentKey, ensureLoop]);
+ }, [selectedEventId, followLoaded, followLocked, readingLinePos, blocks, ensureLoop]);
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);
@@ -262,18 +267,15 @@ export function useTeleprompterScroll({
}
}, [ensureLoop, followLoaded, lockFollow]);
- const registerBlock = useCallback(
- (id: string) => (element: HTMLElement | null) => {
- // entry ids are not guaranteed to be valid CSS selectors, so we keep a map
- // rather than reaching for querySelector
- if (element) {
- blockRefs.current.set(id, element);
- } else {
- blockRefs.current.delete(id);
- }
- },
- [],
- );
+ // entry ids are not guaranteed to be valid CSS selectors, so the follow target
+ // is looked up through this map rather than with querySelector
+ const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
+ if (element) {
+ blockRefs.current.set(id, element);
+ } else {
+ blockRefs.current.delete(id);
+ }
+ }, []);
const controller: TeleprompterController = useMemo(() => {
const play = () => {
@@ -293,8 +295,6 @@ export function useTeleprompterScroll({
};
return {
- play,
- pause,
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
pendingDeltaRef.current += lines * lineHeightRef.current;
diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts
index 821795b08..01aa71e42 100644
--- a/e2e/tests/features/215-teleprompter.spec.ts
+++ b/e2e/tests/features/215-teleprompter.spec.ts
@@ -38,13 +38,8 @@ test.describe('teleprompter', () => {
expect(await scrollTop(page)).toBe(0);
- // run it fast so the movement is unambiguous within the wait
- await page.keyboard.press('ArrowRight');
await page.keyboard.press('Space');
- await page.waitForTimeout(1000);
-
- const whileRunning = await scrollTop(page);
- expect(whileRunning).toBeGreaterThan(0);
+ await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
await page.keyboard.press('Space');
await page.waitForTimeout(200);
@@ -57,19 +52,18 @@ test.describe('teleprompter', () => {
test('space drives playback instead of opening the navigation menu', async ({ page }) => {
// the navigation menu binds Space globally, the teleprompter claims it back
await page.goto('/timer');
- await page.mouse.move(60, 60);
+ await expect(page.locator('data-testid=timer-view')).toBeVisible();
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toBeVisible();
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
- await page.mouse.move(60, 60);
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toHaveCount(0);
- // and the claim is released when the view goes away
+ // and the claim is released once the view goes away
await page.goto('/timer');
- await page.mouse.move(60, 60);
+ await expect(page.locator('data-testid=timer-view')).toBeVisible();
await page.keyboard.press('Space');
await expect(page.getByRole('dialog')).toBeVisible();
});
@@ -80,12 +74,11 @@ test.describe('teleprompter', () => {
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
- await page.waitForTimeout(300);
- expect(await scrollTop(page)).toBeGreaterThan(0);
+ await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
+ // the rewind eases rather than snapping, so poll rather than guess a duration
await page.keyboard.press('Home');
- await page.waitForTimeout(600);
- expect(await scrollTop(page)).toBe(0);
+ await expect.poll(() => scrollTop(page)).toBe(0);
});
test('arrow keys change the speed', async ({ page }) => {