diff --git a/apps/client/src/views/teleprompter/Teleprompter.scss b/apps/client/src/views/teleprompter/Teleprompter.scss index 433544e1c..3db72a64b 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.scss +++ b/apps/client/src/views/teleprompter/Teleprompter.scss @@ -61,6 +61,12 @@ &[data-loaded] .teleprompter__heading { color: $accent-color; } + + /* Holds the eye on the cued event without hiding what is around it, which is + what the reader needs when they are recovering from being ahead or behind. */ + .teleprompter--has-playing &:not([data-loaded]) { + opacity: 0.45; + } } .teleprompter__group { @@ -158,6 +164,14 @@ color: $viewer-label-color; } +.teleprompter__parked { + font-size: clamp(11px, 1vw, 15px); + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; + color: $viewer-label-color; +} + .teleprompter__help { position: fixed; inset: 0; diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx index e33085968..990c0bf22 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.tsx +++ b/apps/client/src/views/teleprompter/Teleprompter.tsx @@ -62,11 +62,12 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa const blocks = buildScript(rundown, rundownMetadata, customFields, { scriptSource: options.scriptSource, heading: options.heading, + onlyPlaying: options.onlyPlaying, hideEmpty: options.hideEmpty, showGroups: options.showGroups, }); - const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, atEnd } = + const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, parkedAt } = useTeleprompterScroll({ initialSpeed: options.speed, followLoaded: options.followLoaded, @@ -115,6 +116,8 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa 'teleprompter', effectiveFlip.flipH && 'teleprompter--flip-h', effectiveFlip.flipV && 'teleprompter--flip-v', + // nothing to hold back the eye from when no event is cued + blocks.some((block) => block.isLoaded) && 'teleprompter--has-playing', ])} style={viewStyles} data-testid='teleprompter-view' @@ -146,7 +149,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa isRunning={isRunning} speed={speed} canReengageFollow={canReengageFollow} - atEnd={atEnd} + parkedAt={parkedAt} controller={controller} onToggleHelp={handleToggleHelp} /> 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 70e31ff71..33b53136e 100644 --- a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts @@ -15,6 +15,8 @@ import { MIN_FONT_SIZE, MIN_SPEED, readPointForAnchor, + segmentAfter, + segmentEndFor, stepFontSize, } from '../teleprompter.scroll'; @@ -202,6 +204,50 @@ describe('the read anchor', () => { }); }); +describe('segmentAfter()', () => { + const readingOffset = 100; + const script: BlockGeometry[] = [ + { id: 'welcome', top: 0, height: 300 }, + { id: 'keynote', top: 300, height: 500 }, + ]; + + const endAfter = (position: number) => { + const block = segmentAfter(position, readingOffset, script); + return block === null ? null : segmentEndFor(block, readingOffset); + }; + + test('stops playback at the end of the segment being read', () => { + // an event has a stop of its own, and running on would read a segment + // nobody cued: at a conference, the next speaker's script + expect(endAfter(0)).toBe(200); + expect(endAfter(150)).toBe(200); + }); + + test('moves on to the next segment from a position already parked on a boundary', () => { + // otherwise playback would stop again where it already is, and the reader + // could never start the next event by pressing play + expect(endAfter(200)).toBe(700); + }); + + test('treats a position within measurement noise of a boundary as parked on it', () => { + // re-measuring can leave the parked position a fraction off the boundary, + // and a sub-pixel run to it would look like play doing nothing at all + expect(endAfter(199.5)).toBe(700); + expect(endAfter(190)).toBe(200); + }); + + test('gives up the bound past the last segment, leaving the end of the script', () => { + expect(endAfter(700)).toBeNull(); + }); + + test('has no boundary to stop at in a single segment script', () => { + // the plain use of pointing at some text and scrolling through it + const single: BlockGeometry[] = [{ id: 'only', top: 0, height: 5000 }]; + expect(segmentAfter(0, readingOffset, single)?.id).toBe('only'); + expect(segmentAfter(4900, readingOffset, single)).toBeNull(); + }); +}); + describe('easeCatchUp()', () => { test('approaches the target monotonically from either side', () => { let fromAbove = 500; 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 70bb0ff8b..ba6b7d5ac 100644 --- a/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts @@ -44,6 +44,7 @@ const customFields: CustomFields = { const defaultOptions = { scriptSource: 'custom-script', heading: 'title' as const, + onlyPlaying: false, hideEmpty: true, showGroups: true, }; @@ -106,6 +107,26 @@ describe('buildScript()', () => { expect(blocks.map((block) => block.id)).toEqual(['b']); }); + test('onlyPlaying narrows the script to the event being played', () => { + const rundown = makeRundown([makeEvent('a'), makeEvent('b')]); + const metadata = metadataFor(['a', 'b'], { b: { isLoaded: true } }); + + expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, onlyPlaying: true }).map((b) => b.id)) // + .toEqual(['b']); + }); + + test('onlyPlaying shows the whole script while nothing is playing', () => { + // narrowing to nothing would leave a blank screen, which is a worse answer + // than the script the reader asked to see + const rundown = makeRundown([makeEvent('a'), makeEvent('b')]); + + expect( + buildScript(rundown, metadataFor(['a', 'b']), customFields, { ...defaultOptions, onlyPlaying: true }).map( + (b) => b.id, + ), + ).toEqual(['a', 'b']); + }); + test('hideEmpty drops events with no script text', () => { const rundown = makeRundown([makeEvent('a', { custom: { script: ' ' } }), makeEvent('b')]); diff --git a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx index e19a5a1d7..0836ef71b 100644 --- a/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx +++ b/apps/client/src/views/teleprompter/control-overlay/ControlOverlay.tsx @@ -6,13 +6,13 @@ import Tooltip from '../../../common/components/tooltip/Tooltip'; import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity'; import { cx } from '../../../common/utils/styleUtils'; import { SPEED_STEP } from '../teleprompter.scroll'; -import type { TeleprompterController } from '../teleprompter.types'; +import type { ParkedAt, TeleprompterController } from '../teleprompter.types'; interface ControlOverlayProps { isRunning: boolean; speed: number; canReengageFollow: boolean; - atEnd: boolean; + parkedAt: ParkedAt; controller: TeleprompterController; onToggleHelp: () => void; } @@ -21,7 +21,7 @@ export default function ControlOverlay({ isRunning, speed, canReengageFollow, - atEnd, + parkedAt, controller, onToggleHelp, }: ControlOverlayProps) { @@ -71,6 +71,12 @@ export default function ControlOverlay({ lpm + {parkedAt === 'segment' && ( + + End of event + + )} + controller.rewind())} aria-label='Rewind to top' diff --git a/apps/client/src/views/teleprompter/teleprompter.options.ts b/apps/client/src/views/teleprompter/teleprompter.options.ts index 5841eedaa..790e1b6c5 100644 --- a/apps/client/src/views/teleprompter/teleprompter.options.ts +++ b/apps/client/src/views/teleprompter/teleprompter.options.ts @@ -21,6 +21,7 @@ const headingSources = headingOptions.map((option) => option.value); export const defaults = { script: 'none', heading: 'title' as HeadingSource, + onlyPlaying: false, hideEmpty: true, showGroups: true, speed: DEFAULT_SPEED, @@ -96,6 +97,14 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[] title: OptionTitle.ElementVisibility, collapsible: true, options: [ + { + id: 'onlyPlaying', + title: 'Show only the playing event', + description: + 'Hides the rest of the script, leaving only the event being played. Shows the whole script while nothing is playing', + type: 'boolean', + defaultValue: defaults.onlyPlaying, + }, { id: 'hideEmpty', title: 'Hide events without a script', @@ -196,6 +205,7 @@ export function getOptionsFromParams( scriptSource: getValue('script') ?? defaults.script, heading: toEnum(getValue('heading'), headingSources, defaults.heading), + onlyPlaying: toBoolean(getValue('onlyPlaying'), defaults.onlyPlaying), hideEmpty: toBoolean(getValue('hideEmpty'), defaults.hideEmpty), showGroups: toBoolean(getValue('showGroups'), defaults.showGroups), diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts index 20fe07fb2..541330c47 100644 --- a/apps/client/src/views/teleprompter/teleprompter.scroll.ts +++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts @@ -118,6 +118,36 @@ export function readPointForAnchor( return null; } +/** Keeps a position resting on a boundary from counting as still being before it. */ +const SEGMENT_BOUNDARY_EPSILON = 1; + +/** The position at which the reading line reaches the end of a segment. */ +export function segmentEndFor(block: BlockGeometry, readingOffset: number): number { + return block.top + block.height - readingOffset; +} + +/** + * The segment playback should run to, or null past the last one, where the end + * of the script is the bound. + * + * An event is a unit of time with a stop of its own, so running the script on + * into the next one reads a segment nobody has cued: at a conference that is + * the next speaker's script, while they are still walking on. + * + * Takes the first segment ending ahead rather than the one the position sits + * in, so that starting again from a position already parked on a boundary + * carries on into the next segment instead of stopping where it already is. + * Decide this once when playback starts: re-deciding it against a position + * which is moving lets the bound outrun the reader and never arrive. + */ +export function segmentAfter(position: number, readingOffset: number, blocks: BlockGeometry[]): BlockGeometry | null { + for (const block of blocks) { + if (segmentEndFor(block, readingOffset) > position + SEGMENT_BOUNDARY_EPSILON) return block; + } + + return null; +} + /** How far, in lines, the reader may move the script themselves before it counts as taking over. */ export const FOLLOW_BREAK_LINES = 1.5; diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts index 205cc6caa..3b82a60f9 100644 --- a/apps/client/src/views/teleprompter/teleprompter.types.ts +++ b/apps/client/src/views/teleprompter/teleprompter.types.ts @@ -10,9 +10,16 @@ export type ScriptBlock = { isLoaded: boolean; }; +/** + * Where playback stopped of its own accord: at the end of the segment being + * read, or at the end of the whole script. Null while it has somewhere to go. + */ +export type ParkedAt = 'segment' | 'script' | null; + export type TeleprompterOptions = { scriptSource: string; heading: HeadingSource; + onlyPlaying: boolean; hideEmpty: boolean; showGroups: boolean; /** lines per minute */ diff --git a/apps/client/src/views/teleprompter/teleprompter.utils.ts b/apps/client/src/views/teleprompter/teleprompter.utils.ts index dbe3a1f18..e4183a2da 100644 --- a/apps/client/src/views/teleprompter/teleprompter.utils.ts +++ b/apps/client/src/views/teleprompter/teleprompter.utils.ts @@ -4,7 +4,10 @@ import type { RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { getPropertyValue } from '../common/viewUtils'; import type { HeadingSource, ScriptBlock, TeleprompterOptions } from './teleprompter.types'; -type BuildScriptOptions = Pick; +type BuildScriptOptions = Pick< + TeleprompterOptions, + 'scriptSource' | 'heading' | 'onlyPlaying' | 'hideEmpty' | 'showGroups' +>; function makeHeading(source: HeadingSource, cue: string, title: string): string { switch (source) { @@ -34,7 +37,7 @@ export function buildScript( customFields: CustomFields, options: BuildScriptOptions, ): ScriptBlock[] { - const { scriptSource, heading, hideEmpty, showGroups } = options; + const { scriptSource, heading, onlyPlaying, hideEmpty, showGroups } = options; if (scriptSource === 'none' || !isReadableSource(scriptSource, customFields)) { return []; @@ -72,6 +75,15 @@ export function buildScript( }); } + if (onlyPlaying) { + const playing = blocks.filter((block) => block.isLoaded); + // With nothing loaded there is no event to narrow to, and a blank screen + // would be a worse answer than the script the reader asked to see. + if (playing.length > 0) { + return playing; + } + } + return blocks; } diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts index 04e55b451..f539dc6e7 100644 --- a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts +++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts @@ -13,8 +13,10 @@ import { linesPerMinuteToPxPerSecond, readPointForAnchor, type ScrollAnchor, + segmentAfter, + segmentEndFor, } from './teleprompter.scroll'; -import type { ScriptBlock, TeleprompterController } from './teleprompter.types'; +import type { ParkedAt, ScriptBlock, TeleprompterController } from './teleprompter.types'; const PAGE_FRACTION = 0.85; const EXTERNAL_SCROLL_EPSILON = 2; @@ -92,13 +94,15 @@ export function useTeleprompterScroll({ // the script's layout as of the last measure, and the reader's place in it const geometryRef = useRef([]); const anchorRef = useRef(null); + // the segment this run of playback stops at, chosen when it started + const playbackSegmentRef = useRef(null); const [isRunning, setIsRunning] = useState(false); const [speed, setSpeed] = useState(initialSpeed); // mirrors the operator view's lockAutoScroll: true once the reader has taken // the scroll over by hand, false while following is doing the driving const [autoScrollLocked, setAutoScrollLocked] = useState(false); - const [atEnd, setAtEnd] = useState(false); + const [parkedAt, setParkedAt] = useState(null); const isFollowingRef = useRef(false); useEffect(() => { @@ -155,13 +159,27 @@ export function useTeleprompterScroll({ catchUpTargetRef.current = null; } } else if (runningRef.current) { + // Only playback is held to the segment. A jump, a page or a nudge is + // the reader asking to leave it, and stays free to cross. + // + // Resolved from the segment's identity rather than the pixel bound + // taken when playback started, so an edit which moves the script keeps + // playback stopping on the same words. + const stopBlock = geometryRef.current.find((block) => block.id === playbackSegmentRef.current); + const stop = stopBlock + ? clamp(segmentEndFor(stopBlock, readingOffsetRef.current), 0, maxScrollRef.current) + : maxScrollRef.current; + const pxPerSecond = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current); - const result = advance(next, pxPerSecond, deltaSeconds, maxScrollRef.current); - next = result.position; + const result = advance(next, pxPerSecond, deltaSeconds, stop); + next = Math.min(result.position, stop); if (result.atEnd) { runningRef.current = false; setIsRunning(false); - setAtEnd(true); + // Past the last segment there is only the trailing padding, so + // stopping there is the end of the read rather than a wait for a cue. + const isLastSegment = stopBlock !== undefined && stopBlock.id === geometryRef.current.at(-1)?.id; + setParkedAt(isLastSegment || stop >= maxScrollRef.current ? 'script' : 'segment'); } } @@ -337,7 +355,7 @@ export function useTeleprompterScroll({ followTargetRef.current = target; catchUpTargetRef.current = target; readerDriftRef.current = 0; - setAtEnd(false); + setParkedAt(null); }, [selectedEventId, followLoaded, autoScrollLocked, readingLinePos, hasSelectedBlock, scrollTargetFor]); const registerBlock = useCallback((id: string, element: HTMLElement | null) => { @@ -353,9 +371,11 @@ export function useTeleprompterScroll({ if (maxScrollRef.current > 0 && posRef.current >= maxScrollRef.current) { return; } + const stopAt = segmentAfter(posRef.current, readingOffsetRef.current, geometryRef.current); + playbackSegmentRef.current = stopAt?.id ?? null; runningRef.current = true; setIsRunning(true); - setAtEnd(false); + setParkedAt(null); }; const pause = () => { @@ -375,7 +395,7 @@ export function useTeleprompterScroll({ const target = clamp(position, 0, maxScrollRef.current); addReaderDrift(target - destination()); catchUpTargetRef.current = target; - setAtEnd(false); + setParkedAt(null); }; return { @@ -384,7 +404,7 @@ export function useTeleprompterScroll({ const distance = lines * lineHeightRef.current; pendingDeltaRef.current += distance; addReaderDrift(distance); - setAtEnd(false); + setParkedAt(null); }, page: (direction: 1 | -1) => { const scroller = scrollerRef.current; @@ -410,7 +430,7 @@ export function useTeleprompterScroll({ if (maxScrollRef.current > 0) { runningRef.current = false; setIsRunning(false); - setAtEnd(true); + setParkedAt('script'); } }, reengageFollow: () => { @@ -430,6 +450,6 @@ export function useTeleprompterScroll({ // folds followLoaded in, so callers get one ready-to-use signal instead of // a runtime flag they must remember to AND with the option themselves canReengageFollow: followLoaded && autoScrollLocked, - atEnd, + parkedAt, }; } diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts index 1ae10bcf7..689ad76e7 100644 --- a/e2e/tests/features/215-teleprompter.spec.ts +++ b/e2e/tests/features/215-teleprompter.spec.ts @@ -117,6 +117,61 @@ test('shift and the vertical arrows walk the reader event by event', async ({ pa await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]); }); +test('playback stops at the end of the event instead of reading on into the next', async ({ page, request }) => { + const response = await request.post('/data/db/demo'); + expect(response.ok()).toBe(true); + const loadResponse = await request.get('/api/load/index/5'); + expect(loadResponse.ok()).toBe(true); + + await page.goto('/teleprompter?script=note&followLoaded=false&speed=40'); + + const scroller = page.getByTestId('teleprompter-scroller'); + await expect(scroller).toBeVisible(); + + // park the reading line just short of the first event's end, so the run to + // the boundary takes a moment rather than the length of the segment + const segmentEnd = await scroller.evaluate((element) => { + const block = element.querySelector('.teleprompter__block'); + if (!block) throw new Error('No script block found'); + const end = block.offsetTop + block.offsetHeight - element.clientHeight * 0.25; + element.scrollTop = end - 30; + return end; + }); + + await page.keyboard.press('Space'); + + await expect(page.getByTestId('teleprompter-parked')).toBeVisible(); + await expect + .poll(async () => Math.abs((await scroller.evaluate((element) => element.scrollTop)) - segmentEnd)) + .toBeLessThan(3); + + // and it stays there, rather than carrying on after a beat + await page.waitForTimeout(500); + expect(Math.abs((await scroller.evaluate((element) => element.scrollTop)) - segmentEnd)).toBeLessThan(3); + + // pressing play again is how the reader moves on to the next event + await page.keyboard.press('Space'); + await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(segmentEnd + 5); +}); + +test('onlyPlaying narrows the script to the event being played', async ({ page, request }) => { + const response = await request.post('/data/db/demo'); + expect(response.ok()).toBe(true); + const loadResponse = await request.get('/api/load/index/5'); + expect(loadResponse.ok()).toBe(true); + + await page.goto('/teleprompter?script=note'); + await expect(page.locator('.teleprompter__block').first()).toBeVisible(); + const whole = await page.locator('.teleprompter__block').count(); + expect(whole).toBeGreaterThan(1); + + await page.goto('/teleprompter?script=note&onlyPlaying=true'); + + const blocks = page.locator('.teleprompter__block'); + await expect(blocks).toHaveCount(1); + await expect(blocks.first()).toHaveAttribute('data-loaded', 'true'); +}); + test('follow tolerates a small scroll and breaks on a real one, like the operator view', async ({ page, request }) => { const response = await request.post('/data/db/demo'); expect(response.ok()).toBe(true);