mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 08:39:34 +00:00
feat(teleprompter): add teleprompter view
This commit is contained in:
committed by
Carlos Valente
parent
a84b8b5530
commit
150e0c83e9
@@ -9,45 +9,52 @@ test.describe('test view navigation feature', () => {
|
||||
test('Timeline', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Timeline' }).click();
|
||||
page.locator('data-testid=timeline-view');
|
||||
await expect(page.getByTestId('timeline-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/timeline');
|
||||
});
|
||||
|
||||
test('Backstage', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Backstage' }).click();
|
||||
page.locator('data-testid=backstage-view');
|
||||
await expect(page.getByTestId('backstage-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/backstage');
|
||||
});
|
||||
|
||||
test('Studio Clock', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Studio Clock' }).click();
|
||||
page.locator('data-testid=studio-view');
|
||||
await expect(page.getByTestId('studio-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/studio');
|
||||
});
|
||||
|
||||
test('Countdown', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Countdown' }).click();
|
||||
page.locator('data-testid=countdown-view');
|
||||
await expect(page.getByTestId('countdown-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/countdown');
|
||||
});
|
||||
|
||||
test('Project Info', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Project Info' }).click();
|
||||
page.locator('data-testid=project-view');
|
||||
await expect(page.getByTestId('project-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/info');
|
||||
});
|
||||
|
||||
test('Timer', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Timer', exact: true }).click();
|
||||
page.locator('data-testid=timer-view');
|
||||
await expect(page.getByTestId('timer-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/timer');
|
||||
});
|
||||
|
||||
test('Teleprompter', async ({ page }) => {
|
||||
await openNavigationMenu(page);
|
||||
await page.getByRole('button', { name: 'Teleprompter' }).click();
|
||||
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
||||
await expect(page).toHaveURL('/teleprompter');
|
||||
});
|
||||
|
||||
/**
|
||||
* The shortcut has to read the current open state, not the one captured when it was
|
||||
* registered, otherwise it only ever opens the menu.
|
||||
|
||||
@@ -38,3 +38,25 @@ test('View params keeps the values of collapsed sections', async ({ page }) => {
|
||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||
});
|
||||
|
||||
test('View params configures teleprompter view', async ({ page, request }) => {
|
||||
const response = await request.post('/data/db/demo');
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note');
|
||||
|
||||
const readingMarker = page.locator('.teleprompter__reading-marker');
|
||||
await expect(readingMarker).toBeVisible();
|
||||
|
||||
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||
await page.getByTestId('navigation__toggle-settings').click();
|
||||
|
||||
const readingLineSwitch = page.locator('label:has(input[name="readingLine"]) [role="switch"]');
|
||||
await expect(readingLineSwitch).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await readingLineSwitch.click();
|
||||
await page.getByTestId('apply-view-params').click();
|
||||
|
||||
await expect(page).toHaveURL(/.*readingLine=false/);
|
||||
await expect(readingMarker).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
/** The heading of the event the reading line is currently over. */
|
||||
function eventUnderReadingLine(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const line = document.querySelector('.teleprompter__reading-line')?.getBoundingClientRect();
|
||||
if (!line) throw new Error('Reading line not found');
|
||||
const element = document.elementFromPoint(window.innerWidth / 2, line.top + line.height / 2);
|
||||
return element?.closest('.teleprompter__block')?.querySelector('.teleprompter__heading')?.textContent ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
test('teleprompter renders and responds to its primary controls', async ({ page, request }) => {
|
||||
// Earlier feature specs edit the loaded rundown. Restore the real demo project
|
||||
// instead of manufacturing test-only events: its notes are the script fixture.
|
||||
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&flipV=true');
|
||||
|
||||
const view = page.getByTestId('teleprompter-view');
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
const speed = page.getByTestId('teleprompter-speed');
|
||||
|
||||
await expect(view).toBeVisible();
|
||||
await expect(scroller).toBeVisible();
|
||||
await expect(page.getByText('Music plays, holding slide on screens')).toBeVisible();
|
||||
await expect(speed).toContainText('14');
|
||||
await expect(view).toHaveCSS('transform', /^matrix\(1, 0, 0, -1/);
|
||||
|
||||
// Following uses layout coordinates, which must remain stable when the view's
|
||||
// visual coordinates are inverted by a vertical transform.
|
||||
const loadedBlock = page.locator('.teleprompter__block[data-loaded]');
|
||||
await expect(loadedBlock).toBeAttached();
|
||||
const expectedScrollTop = await loadedBlock.evaluate((element) => {
|
||||
const scroller = element.closest('.teleprompter')?.querySelector<HTMLElement>('.teleprompter__scroller');
|
||||
if (!scroller) throw new Error('Teleprompter scroller not found');
|
||||
return Math.max(0, (element as HTMLElement).offsetTop - scroller.clientHeight * 0.25);
|
||||
});
|
||||
await expect
|
||||
.poll(async () => Math.abs((await scroller.evaluate((element) => element.scrollTop)) - expectedScrollTop))
|
||||
.toBeLessThan(2);
|
||||
|
||||
await page.goto('/teleprompter?script=note');
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('f');
|
||||
await expect(speed).toContainText('15');
|
||||
await expect(page).toHaveURL(/speed=15/);
|
||||
await expect(page).toHaveURL(/flipH=true/);
|
||||
await expect(view).toHaveCSS('transform', /^matrix\(-1/);
|
||||
});
|
||||
|
||||
test('an edit above the reader leaves the same text under the reading line', 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);
|
||||
|
||||
// following moves the reader for its own reasons; this is about the document
|
||||
// changing underneath a position the reader chose
|
||||
await page.goto('/teleprompter?script=note&followLoaded=false');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
// park the reading line inside a block rather than at a fraction of the
|
||||
// document, whose tail is a screen of padding below the last event
|
||||
await scroller.evaluate((element) => {
|
||||
const blocks = element.querySelectorAll<HTMLElement>('.teleprompter__block');
|
||||
const target = blocks[Math.floor(blocks.length / 2)];
|
||||
element.scrollTop = target.offsetTop + 10 - element.clientHeight * 0.25;
|
||||
});
|
||||
const before = await eventUnderReadingLine(page);
|
||||
expect(before).not.toBeNull();
|
||||
const scrollBefore = await scroller.evaluate((element) => element.scrollTop);
|
||||
|
||||
// grow the first event's script, which sits above wherever we scrolled to
|
||||
const edit = await request.put('/data/rundowns/default/entry', {
|
||||
data: { id: '9bf60f', note: `Music plays, holding slide on screens\n${'Another line of script. '.repeat(120)}` },
|
||||
});
|
||||
expect(edit.ok()).toBe(true);
|
||||
|
||||
// the document grew, so holding position means the offset had to change
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(scrollBefore);
|
||||
expect(await eventUnderReadingLine(page)).toBe(before);
|
||||
});
|
||||
|
||||
test('shift and the vertical arrows walk the reader event by event', 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');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
await expect(scroller).toBeVisible();
|
||||
|
||||
const headings = await page.locator('.teleprompter__heading').allTextContents();
|
||||
expect(headings.length).toBeGreaterThan(2);
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[0]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowDown');
|
||||
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[2]);
|
||||
|
||||
await page.keyboard.press('Shift+ArrowUp');
|
||||
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);
|
||||
const loadResponse = await request.get('/api/load/index/5');
|
||||
expect(loadResponse.ok()).toBe(true);
|
||||
|
||||
await page.goto('/teleprompter?script=note');
|
||||
|
||||
const scroller = page.getByTestId('teleprompter-scroller');
|
||||
const follow = page.getByTestId('teleprompter-follow');
|
||||
await expect(scroller).toBeVisible();
|
||||
await expect(follow).toBeDisabled();
|
||||
|
||||
await page.mouse.move(960, 500);
|
||||
await page.mouse.wheel(0, 15);
|
||||
await expect(follow).toBeDisabled();
|
||||
|
||||
await page.mouse.wheel(0, 400);
|
||||
await expect(follow).toBeEnabled();
|
||||
|
||||
await page.keyboard.press('l');
|
||||
await expect(follow).toBeDisabled();
|
||||
});
|
||||
Reference in New Issue
Block a user