mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-02 21:09:09 +00:00
feat(teleprompter): add teleprompter view
Adds a teleprompter at /teleprompter which builds its script from the rundown rather than from an uploaded file, so the read follows the show. The script comes from a text custom field chosen per view, and the whole rundown renders as one continuous document with a heading per segment. That is how broadcast prompters work: the operator scrolls to the right section as the show moves, so a hard cut on every event change would take the tail of the line the talent is still reading. Following the loaded event is a soft jump which releases when the user scrolls by hand, the same interaction the operator view already uses. Controls are local, and match the convention shared by prompter software: space to run, arrows for speed and nudge, home to rewind, F to flip. That convention doubles as the hardware protocol, since foot pedals and hand controllers are USB HID devices emitting these keystrokes, so they work with no setup. Space is claimed back from the navigation menu for the lifetime of the view via a small store, since the router renders the menu generically for presets and a prop would not reach it. Scrolling uses native scrollTop on an overflow container, with the animation frame loop as its only writer. Position is kept as a float in a ref: at readable speeds the per frame movement is well under a pixel, so rounding every frame would stall the scroll, and holding it in state would re-render the document sixty times a second. Scroll anchoring and smooth scroll behaviour are both disabled because each would be a second writer. Notable details: - flip applies to the view root, so a beam splitter inverts the scroll direction along with the text - content padding is derived from the viewport height, not a percentage, which resolves against width and would strand the first line - image custom fields are refused even when typed into the URL - script text is rendered as a text node, never as markup Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Pure scroll arithmetic for the teleprompter.
|
||||
*
|
||||
* Everything here is side effect free so it can be unit tested without a DOM.
|
||||
* The hook that owns the requestAnimationFrame loop is the only caller.
|
||||
*/
|
||||
|
||||
/** lines per minute */
|
||||
export const MIN_SPEED = 2;
|
||||
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;
|
||||
export const FONT_SCALE_STEP = 0.1;
|
||||
|
||||
/**
|
||||
* requestAnimationFrame is suspended in background tabs, so the timestamp can
|
||||
* jump by minutes when the view becomes visible again.
|
||||
* We clamp the frame delta so a resume can never teleport the script.
|
||||
*/
|
||||
export const MAX_FRAME_DELTA_MS = 100;
|
||||
|
||||
/** how aggressively an eased jump converges on its target, per second */
|
||||
const CATCH_UP_RATE = 8;
|
||||
|
||||
/** below this distance an eased jump is considered arrived */
|
||||
export const CATCH_UP_EPSILON = 0.5;
|
||||
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
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
|
||||
* independent of font size, which is why it is what we persist.
|
||||
*/
|
||||
export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number {
|
||||
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
|
||||
*/
|
||||
export function frameDeltaSeconds(deltaMs: number): number {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0;
|
||||
return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the scroll position at a constant rate.
|
||||
*
|
||||
* The position is kept as a float by the caller: at readable prompter speeds the
|
||||
* per frame delta is well under a pixel, so rounding on every frame would stall
|
||||
* the scroll entirely. We return the exact float and let the DOM round on write.
|
||||
*/
|
||||
export function advance(
|
||||
position: number,
|
||||
pxPerSecond: number,
|
||||
deltaSeconds: number,
|
||||
maxScroll: number,
|
||||
): { position: number; atEnd: boolean } {
|
||||
const next = clamp(position + pxPerSecond * deltaSeconds, 0, Math.max(maxScroll, 0));
|
||||
// a document which does not overflow is never "at the end": it may simply not
|
||||
// have been measured yet, and stopping playback on that would be wrong
|
||||
return { position: next, atEnd: maxScroll > 0 && next >= maxScroll };
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves `current` towards `target` with an exponential ease.
|
||||
*
|
||||
* Framerate independent: the same wall clock duration produces the same curve
|
||||
* regardless of how many frames it was sampled over.
|
||||
*/
|
||||
export function easeCatchUp(current: number, target: number, deltaSeconds: number): number {
|
||||
if (deltaSeconds <= 0) return current;
|
||||
const next = target + (current - target) * Math.exp(-CATCH_UP_RATE * deltaSeconds);
|
||||
return Math.abs(next - target) < CATCH_UP_EPSILON ? target : next;
|
||||
}
|
||||
|
||||
export function hasArrived(current: number, target: number): boolean {
|
||||
return Math.abs(current - target) < CATCH_UP_EPSILON;
|
||||
}
|
||||
Reference in New Issue
Block a user