feat(teleprompter): stop playback at the end of the event

A user reading a podcast script found the prompter ran past the end of the
event and on into the next one. An event is a unit of time with a stop of
its own, but auto-scroll bounded against the end of the whole document, so
nothing held it at the boundary. At a conference that means the prompter
reads the next speaker's script while they are still walking on.

Playback now runs to the end of the segment being read and parks there,
saying so. Loading the next event releases it through the existing follow,
and pressing play again carries on into the next segment for a reader who
wants to keep going. Only playback is held: a jump, a page or a nudge is
the reader asking to leave the segment and still crosses freely.

The segment is chosen once when playback starts, and held by identity
rather than as a pixel bound. Re-deciding it against a position which is
moving let the bound outrun the reader and never arrive; holding the id
rather than the offset keeps the stop on the same words when an edit moves
the script underneath it.

Add an option to show only the event being played, for the case where the
rest of the script is a distraction. It falls back to the whole script
while nothing is playing, where narrowing would leave a blank screen. The
script of the event being played is now held out from the rest, which stays
readable so a reader who is ahead or behind can still find their place.

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-26 17:53:21 +00:00
parent 1d8ca453c9
commit 169714b3ae
11 changed files with 243 additions and 19 deletions
@@ -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<HTMLElement>('.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);