mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-18 13:44:12 +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:
@@ -48,6 +48,16 @@ test.describe('test view navigation feature', () => {
|
||||
await expect(page).toHaveURL('/timer');
|
||||
});
|
||||
|
||||
test('Teleprompter', async ({ page }) => {
|
||||
// note: openNavigationMenu presses Space, which the teleprompter claims for
|
||||
// playback. Every test starts from the timer view, so reaching it is fine,
|
||||
// but a test which navigates *away* from it must click the nav button.
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Teleprompter' }).click();
|
||||
page.locator('data-testid=teleprompter-view');
|
||||
await expect(page).toHaveURL('/teleprompter');
|
||||
});
|
||||
|
||||
test('not-found', async ({ page }) => {
|
||||
await page.goto('/not-found');
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The note field is used as the script source throughout: every event in the
|
||||
* test fixture has one, which keeps these tests independent of how custom field
|
||||
* keys happen to be spelled in the fixture.
|
||||
*/
|
||||
const teleprompterUrl = '/teleprompter?script=note';
|
||||
|
||||
function scroller(page: Page) {
|
||||
return page.getByTestId('teleprompter-scroller');
|
||||
}
|
||||
|
||||
function scrollTop(page: Page) {
|
||||
return scroller(page).evaluate((element) => element.scrollTop);
|
||||
}
|
||||
|
||||
test.describe('teleprompter', () => {
|
||||
test('shows the script from the selected source', async ({ page }) => {
|
||||
await page.goto(teleprompterUrl);
|
||||
|
||||
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
||||
await expect(scroller(page)).toBeVisible();
|
||||
// the fixture uses cue style notes, the first event is Albania
|
||||
await expect(page.getByText('SF1.01', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('asks for a script source when none is selected', async ({ page }) => {
|
||||
await page.goto('/teleprompter');
|
||||
|
||||
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
||||
await expect(page.getByText('Select which field holds the script in the view options')).toBeVisible();
|
||||
});
|
||||
|
||||
test('space starts and stops the scroll', async ({ page }) => {
|
||||
await page.goto(teleprompterUrl);
|
||||
await expect(scroller(page)).toBeVisible();
|
||||
|
||||
expect(await scrollTop(page)).toBe(0);
|
||||
|
||||
// run it fast so the movement is unambiguous within the wait
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const whileRunning = await scrollTop(page);
|
||||
expect(whileRunning).toBeGreaterThan(0);
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForTimeout(200);
|
||||
const afterPause = await scrollTop(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
expect(await scrollTop(page)).toBe(afterPause);
|
||||
});
|
||||
|
||||
test('space drives playback instead of opening the navigation menu', async ({ page }) => {
|
||||
// the navigation menu binds Space globally, the teleprompter claims it back
|
||||
await page.goto('/timer');
|
||||
await page.mouse.move(60, 60);
|
||||
await page.keyboard.press('Space');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
await page.goto(teleprompterUrl);
|
||||
await expect(scroller(page)).toBeVisible();
|
||||
await page.mouse.move(60, 60);
|
||||
await page.keyboard.press('Space');
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
|
||||
// and the claim is released when the view goes away
|
||||
await page.goto('/timer');
|
||||
await page.mouse.move(60, 60);
|
||||
await page.keyboard.press('Space');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
|
||||
test('arrow keys nudge and home rewinds', async ({ page }) => {
|
||||
await page.goto(teleprompterUrl);
|
||||
await expect(scroller(page)).toBeVisible();
|
||||
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.waitForTimeout(300);
|
||||
expect(await scrollTop(page)).toBeGreaterThan(0);
|
||||
|
||||
await page.keyboard.press('Home');
|
||||
await page.waitForTimeout(600);
|
||||
expect(await scrollTop(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('arrow keys change the speed', async ({ page }) => {
|
||||
await page.goto(teleprompterUrl);
|
||||
await expect(scroller(page)).toBeVisible();
|
||||
|
||||
const readout = page.getByTestId('teleprompter-speed');
|
||||
const before = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
const faster = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
|
||||
expect(faster).toBeGreaterThan(before);
|
||||
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
const slower = Number(await readout.innerText().then((text) => text.replace(/\D/g, '')));
|
||||
expect(slower).toBe(before);
|
||||
});
|
||||
|
||||
test('f mirrors the view for a beam splitter rig', async ({ page }) => {
|
||||
await page.goto(teleprompterUrl);
|
||||
await expect(scroller(page)).toBeVisible();
|
||||
|
||||
const view = page.getByTestId('teleprompter-view');
|
||||
await page.keyboard.press('f');
|
||||
|
||||
// a horizontal flip is a negative x scale in the computed matrix
|
||||
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
|
||||
expect(transform.startsWith('matrix(-1')).toBe(true);
|
||||
});
|
||||
|
||||
test('honours the flip and reading line params', async ({ page }) => {
|
||||
await page.goto('/teleprompter?script=note&flipV=true&readingLine=arrows');
|
||||
|
||||
const view = page.getByTestId('teleprompter-view');
|
||||
const transform = await view.evaluate((element) => getComputedStyle(element).transform);
|
||||
// a vertical flip leaves x positive and makes the y scale negative
|
||||
expect(transform).toMatch(/^matrix\(1, 0, 0, -1/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user