mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 21:54:09 +00:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user