diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx index 747576f37..21550787e 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.tsx +++ b/apps/client/src/views/teleprompter/Teleprompter.tsx @@ -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 diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts index 83fd0e2fd..3ef856df4 100644 --- a/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts @@ -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 { 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', () => { 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 befc983b7..24cab6d1c 100644 --- a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts @@ -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); diff --git a/apps/client/src/views/teleprompter/teleprompter.keymap.ts b/apps/client/src/views/teleprompter/teleprompter.keymap.ts index d4bfa63f9..33b6407f2 100644 --- a/apps/client/src/views/teleprompter/teleprompter.keymap.ts +++ b/apps/client/src/views/teleprompter/teleprompter.keymap.ts @@ -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' }; } diff --git a/apps/client/src/views/teleprompter/teleprompter.options.ts b/apps/client/src/views/teleprompter/teleprompter.options.ts index 3cba2bb65..c03c69212 100644 --- a/apps/client/src/views/teleprompter/teleprompter.options.ts +++ b/apps/client/src/views/teleprompter/teleprompter.options.ts @@ -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], diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts index c2acad7d2..aa22c8fa6 100644 --- a/apps/client/src/views/teleprompter/teleprompter.scroll.ts +++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts @@ -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 diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts index fc468c393..fdfa5a64a 100644 --- a/apps/client/src/views/teleprompter/teleprompter.types.ts +++ b/apps/client/src/views/teleprompter/teleprompter.types.ts @@ -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' }; diff --git a/apps/client/src/views/teleprompter/useMirrorLiveParams.ts b/apps/client/src/views/teleprompter/useMirrorLiveParams.ts new file mode 100644 index 000000000..168c27853 --- /dev/null +++ b/apps/client/src/views/teleprompter/useMirrorLiveParams.ts @@ -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) { + 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)) { + 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]); +} diff --git a/apps/client/src/views/teleprompter/useTeleprompterControls.ts b/apps/client/src/views/teleprompter/useTeleprompterControls.ts index ccc784034..9fa602d7a 100644 --- a/apps/client/src/views/teleprompter/useTeleprompterControls.ts +++ b/apps/client/src/views/teleprompter/useTeleprompterControls.ts @@ -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': diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts index e612f223c..9190dee1c 100644 --- a/e2e/tests/features/215-teleprompter.spec.ts +++ b/e2e/tests/features/215-teleprompter.spec.ts @@ -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();