feat(teleprompter): record the live controls in the url

Speed, font size and the flips could each be set from two places: the view
params editor, which writes the query, and the prompter's own keys and
buttons, which wrote React state and nothing else. The URL therefore
described how the view was opened rather than how it had been tuned, so a
link copied after setting the prompter up handed the next person a
different prompter, and a reload threw the setup away.

The live controls now mirror into the same query the params editor writes,
so the existing features built on it — sharing a link, saving a URL preset,
redirecting a client — describe what is actually on screen without changes
of their own. Settings left at their default stay out of the query, so a
shared link carries only what was deliberately changed.

The write is debounced and replaces rather than pushes: the arrow keys
repeat while held, and neither a held key nor a foot pedal should fill the
address bar or the back button with one entry per frame.

Font size was a hidden multiplier on top of the option, which had no way to
be expressed as a param. It is now the size itself, stepped by a ratio so a
press is the same visual change at any size, sharing the option's own range.

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:42:01 +00:00
parent bb8f147575
commit 8ec12ae4d2
10 changed files with 193 additions and 43 deletions
@@ -12,9 +12,10 @@ import ControlOverlay from './control-overlay/ControlOverlay';
import HelpOverlay from './help-overlay/HelpOverlay';
import ReadingLine from './reading-line/ReadingLine';
import ScriptBlockView from './script-block/ScriptBlock';
import { getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
import { clampFontScale } from './teleprompter.scroll';
import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
import { stepFontSize } from './teleprompter.scroll';
import { buildScript, composeFlip } from './teleprompter.utils';
import { useMirrorLiveParams } from './useMirrorLiveParams';
import { useTeleprompterControls } from './useTeleprompterControls';
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
import { useTeleprompterScroll } from './useTeleprompterScroll';
@@ -45,25 +46,29 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
// the shared "Flip Screen" toggle from the navigation menu
const isMirrored = useViewOptionsStore((state) => state.mirror);
const [fontScale, setFontScale] = useState(1);
const [showHelp, setShowHelp] = useState(false);
/**
* The flip params seed the local state, which the F key then owns until the
* params change again. Without that the params would only apply on mount, so
* editing them in the view options, or redirecting this client to the same
* view with a different query, would silently do nothing.
* The params seed the live controls, which the keys then own until the params
* change again. Without that the params would only apply on mount, so editing
* them in the view options, or redirecting this client to the same view with a
* different query, would silently do nothing.
*
* The reset happens during render rather than in an effect so React can throw
* the stale render away before it reaches the screen. An effect would let a
* frame of the previous orientation paint first, which on a prompter reads as
* a flash. The speed option is kept live the same way, in useTeleprompterScroll.
*/
const [flip, setFlip] = useState({ h: options.flipH, v: options.flipV });
const [flipFromParams, setFlipFromParams] = useState({ h: options.flipH, v: options.flipV });
if (flipFromParams.h !== options.flipH || flipFromParams.v !== options.flipV) {
setFlipFromParams({ h: options.flipH, v: options.flipV });
setFlip({ h: options.flipH, v: options.flipV });
const [live, setLive] = useState({ flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize });
const [liveFromParams, setLiveFromParams] = useState(live);
if (
liveFromParams.flipH !== options.flipH ||
liveFromParams.flipV !== options.flipV ||
liveFromParams.fontSize !== options.fontSize
) {
const fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
setLiveFromParams(fromParams);
setLive(fromParams);
}
const viewOptions = getTeleprompterOptions(customFields);
@@ -93,12 +98,29 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
blocks,
});
const handleFlip = (axis: 'h' | 'v') => setFlip((current) => ({ ...current, [axis]: !current[axis] }));
const handleFlip = (axis: 'h' | 'v') =>
setLive((current) => {
const key = axis === 'h' ? 'flipH' : 'flipV';
return { ...current, [key]: !current[key] };
});
const handleFontSize = (delta: number) => setFontScale((current) => clampFontScale(current + delta));
const handleResetFontSize = () => setFontScale(1);
const handleFontSize = (steps: number) =>
setLive((current) => ({ ...current, fontSize: stepFontSize(current.fontSize, steps) }));
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
const handleToggleHelp = () => setShowHelp((current) => !current);
/**
* Everything the operator can change from the prompter itself is a view
* setting, and view settings live in the query. Recording them there is what
* makes a tuned prompter shareable, and what makes a reload keep the setup.
*/
useMirrorLiveParams({
speed: speed === defaults.speed ? null : String(speed),
fontSize: live.fontSize === defaults.fontSize ? null : String(live.fontSize),
flipH: live.flipH === defaults.flipH ? null : String(live.flipH),
flipV: live.flipV === defaults.flipV ? null : String(live.flipV),
});
useTeleprompterControls({
controller,
isHelpOpen: showHelp,
@@ -111,10 +133,10 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
const hasScriptSource = options.scriptSource !== 'none';
// Flip Screen is a flip on both axes, so it folds into the per view flips
const effectiveFlip = composeFlip(flip.h, flip.v, isMirrored);
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
const viewStyles = {
'--tp-font-size': `${options.fontSize * fontScale}px`,
'--tp-font-size': `${live.fontSize}px`,
'--tp-line-height': options.lineHeight,
'--tp-text-width': `${options.textWidth}%`,
// unitless, so the stylesheet can scale it by 1% against the view height for
@@ -1,5 +1,5 @@
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
import { FONT_SCALE_STEP, SPEED_STEP, SPEED_STEP_COARSE } from '../teleprompter.scroll';
import { SPEED_STEP, SPEED_STEP_COARSE } from '../teleprompter.scroll';
function makeEvent(overrides: Partial<TeleprompterKeyEvent>): TeleprompterKeyEvent {
return {
@@ -76,14 +76,8 @@ describe('resolveTeleprompterAction()', () => {
});
test('plus increases and minus decreases', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({
type: 'fontSize',
delta: FONT_SCALE_STEP,
});
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({
type: 'fontSize',
delta: -FONT_SCALE_STEP,
});
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({ type: 'fontSize', steps: 1 });
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({ type: 'fontSize', steps: -1 });
});
test('l re-engages the follow and ? shows the help', () => {
@@ -4,9 +4,12 @@ import {
easeCatchUp,
frameDeltaSeconds,
linesPerMinuteToPxPerSecond,
MAX_FONT_SIZE,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
MIN_FONT_SIZE,
MIN_SPEED,
stepFontSize,
} from '../teleprompter.scroll';
describe('linesPerMinuteToPxPerSecond()', () => {
@@ -16,6 +19,23 @@ describe('linesPerMinuteToPxPerSecond()', () => {
});
});
describe('stepFontSize()', () => {
test('steps by a ratio, so a press is the same visual change at any size', () => {
// a fixed pixel step would be a big jump at 20px and imperceptible at 200px
expect(stepFontSize(100, 1)).toBe(110);
expect(stepFontSize(20, 1)).toBe(22);
});
test('shrinking undoes growing', () => {
expect(stepFontSize(stepFontSize(50, 1), -1)).toBe(50);
});
test('stays inside the range the option accepts', () => {
expect(stepFontSize(MAX_FONT_SIZE, 5)).toBe(MAX_FONT_SIZE);
expect(stepFontSize(MIN_FONT_SIZE, -5)).toBe(MIN_FONT_SIZE);
});
});
describe('clampSpeed()', () => {
test('bounds the speed to the usable range', () => {
expect(clampSpeed(MIN_SPEED - 10)).toBe(MIN_SPEED);
@@ -1,4 +1,4 @@
import { FONT_SCALE_STEP, SPEED_STEP, SPEED_STEP_COARSE } from './teleprompter.scroll';
import { SPEED_STEP, SPEED_STEP_COARSE } from './teleprompter.scroll';
import type { TeleprompterAction } from './teleprompter.types';
/**
@@ -63,10 +63,10 @@ export function resolveTeleprompterAction(event: TeleprompterKeyEvent): Teleprom
return { type: 'toggleHelp' };
case '+':
case '=':
return { type: 'fontSize', delta: FONT_SCALE_STEP };
return { type: 'fontSize', steps: 1 };
case '-':
case '_':
return { type: 'fontSize', delta: -FONT_SCALE_STEP };
return { type: 'fontSize', steps: -1 };
case '0':
return { type: 'resetFontSize' };
}
@@ -7,7 +7,7 @@ import type { ViewOption } from '../../common/components/view-params-editor/view
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
import { clampSpeed, DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from './teleprompter.scroll';
import { clampSpeed, DEFAULT_SPEED, MAX_FONT_SIZE, MAX_SPEED, MIN_FONT_SIZE, MIN_SPEED } from './teleprompter.scroll';
import type { HeadingSource, TeleprompterOptions } from './teleprompter.types';
/**
@@ -31,7 +31,7 @@ const headingSources = headingOptions.map((option) => option.value);
* have to agree. Kept apart, they drift silently: the editor would show one
* value while the view used another, with nothing failing to say so.
*/
const defaults = {
export const defaults = {
script: 'none',
heading: 'title' as HeadingSource,
hideEmpty: true,
@@ -51,7 +51,7 @@ const defaults = {
/** ranges for the numeric options, applied when parsing */
const bounds = {
speed: [MIN_SPEED, MAX_SPEED],
fontSize: [12, 400],
fontSize: [MIN_FONT_SIZE, MAX_FONT_SIZE],
lineHeight: [1, 4],
textWidth: [20, 100],
readingLinePos: [0, 100],
@@ -24,10 +24,25 @@ export const DEFAULT_SPEED = 14;
export const SPEED_STEP = 1;
export const SPEED_STEP_COARSE = 5;
/** font size multiplier applied on top of the configured size by the +/- keys */
const MIN_FONT_SCALE = 0.4;
const MAX_FONT_SCALE = 3;
export const FONT_SCALE_STEP = 0.1;
/**
* Font size in pixels, the same range the view option accepts.
*
* The +/- keys move it by a ratio rather than a fixed number of pixels, so a
* press is the same visual step whether the talent is reading at 30px on a
* laptop or 200px through a beam splitter.
*/
export const MIN_FONT_SIZE = 12;
export const MAX_FONT_SIZE = 400;
const FONT_SIZE_STEP_RATIO = 1.1;
export function clampFontSize(value: number): number {
return clamp(value, MIN_FONT_SIZE, MAX_FONT_SIZE);
}
/** @param steps how many presses to apply, negative to shrink */
export function stepFontSize(current: number, steps: number): number {
return clampFontSize(Math.round(current * FONT_SIZE_STEP_RATIO ** steps));
}
/**
* requestAnimationFrame is suspended in background tabs, so the timestamp can
@@ -51,10 +66,6 @@ export function clampSpeed(value: number): number {
return clamp(value, MIN_SPEED, MAX_SPEED);
}
export function clampFontScale(value: number): number {
return clamp(value, MIN_FONT_SCALE, MAX_FONT_SCALE);
}
/**
* Converts a speed in lines per minute into pixels per second.
* Lines per minute is the unit prompter operators think in, and it is
@@ -49,7 +49,7 @@ export type TeleprompterAction =
| { type: 'rewindAndPause' }
| { type: 'jumpToEnd' }
| { type: 'flip'; axis: 'h' | 'v' }
| { type: 'fontSize'; delta: number }
| { type: 'fontSize'; steps: number }
| { type: 'resetFontSize' }
| { type: 'reengageFollow' }
| { type: 'toggleHelp' };
@@ -0,0 +1,70 @@
import { use, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
/**
* How long the controls have to settle before the URL is rewritten.
*
* The arrow keys repeat while held, and a foot pedal held down repeats too, so
* writing on every change would rewrite the URL dozens of times a second for a
* single gesture. The controls keep their own state and stay instant; this only
* records where they came to rest.
*/
const SETTLE_MS = 400;
/**
* Mirrors the live controls into the URL.
*
* Speed, font size and the flips can each be set in two places: the view params
* editor, which writes the query, and the prompter's own keys and buttons, which
* did not. The URL therefore described how the view was opened rather than how
* it had been tuned, so a link copied after setting the prompter up handed the
* next person a differently configured prompter. Reloading lost the setup for
* the same reason.
*
* Writing the same query the params editor writes means the existing features
* built on it — sharing a link, saving a URL preset, redirecting a client — all
* describe what is actually on screen, with no changes of their own.
*
* @param live values to record, keyed by param name. A null value means the
* control is at its default and the param is dropped, which keeps a shared link
* down to the settings which were actually changed.
*/
export function useMirrorLiveParams(live: Record<string, string | null>) {
const [, setSearchParams] = useSearchParams();
const isPreset = Boolean(use(PresetContext));
// the effect depends on the values rather than the object, which is new every render
const serialised = JSON.stringify(live);
useEffect(() => {
/**
* A preset's own search string wins over the query when the options are
* read, so a mirrored param would show a value the next load would ignore.
* Better to leave the address bar alone than to write a link which lies.
*/
if (isPreset) return;
const timeout = setTimeout(() => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(JSON.parse(serialised) as Record<string, string | null>)) {
if (value === null) {
next.delete(key);
} else {
next.set(key, value);
}
}
return next;
},
// a speed nudge is not a navigation: pushing would bury the operator's
// back button under one entry per keypress
{ replace: true },
);
}, SETTLE_MS);
return () => clearTimeout(timeout);
}, [serialised, isPreset, setSearchParams]);
}
@@ -9,7 +9,7 @@ interface UseTeleprompterControlsArgs {
/** the help dialog is modal, so the keymap stands down while it is open */
isHelpOpen: boolean;
onFlip: (axis: 'h' | 'v') => void;
onFontSize: (delta: number) => void;
onFontSize: (steps: number) => void;
onResetFontSize: () => void;
onToggleHelp: () => void;
}
@@ -56,7 +56,7 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
case 'flip':
return onFlip(action.axis);
case 'fontSize':
return onFontSize(action.delta);
return onFontSize(action.steps);
case 'resetFontSize':
return onResetFontSize();
case 'toggleHelp':
@@ -137,6 +137,39 @@ test.describe('teleprompter', () => {
await expect(follow).toBeVisible();
});
test('records the live controls in the url so a tuned view can be shared', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();
// a view left at its defaults should not litter the query
await expect(page).toHaveURL(/\?script=note$/);
await page.keyboard.press('ArrowRight');
await page.keyboard.press('f');
await expect(page).toHaveURL(/speed=/);
await expect(page).toHaveURL(/flipH=true/);
// the tuned url has to reproduce the prompter, which is the point of writing it
const shared = page.url();
const readSpeed = () =>
page
.getByTestId('teleprompter-speed')
.innerText()
.then((text) => text.replace(/\D/g, ''));
const tunedSpeed = await readSpeed();
await page.goto('/teleprompter');
await page.goto(shared);
await expect(scroller(page)).toBeVisible();
expect(await readSpeed()).toBe(tunedSpeed);
// and returning the controls to their defaults clears the query again
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('f');
await expect(page).toHaveURL(/\?script=note$/);
});
test('f mirrors the view for a beam splitter rig', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(scroller(page)).toBeVisible();