- Foot pedals and hand controllers which emit these keys work without any setup.
+
@@ -64,3 +103,35 @@ export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
);
}
+
+function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
+ return (
+
+ );
+}
+
+function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
+ return (
+
;
+}
diff --git a/apps/client/src/views/teleprompter/reading-line/ReadingLine.tsx b/apps/client/src/views/teleprompter/reading-line/ReadingLine.tsx
index 9b231489b..e4ecb18f1 100644
--- a/apps/client/src/views/teleprompter/reading-line/ReadingLine.tsx
+++ b/apps/client/src/views/teleprompter/reading-line/ReadingLine.tsx
@@ -1,31 +1,29 @@
-import { cx } from '../../../common/utils/styleUtils';
-import type { ReadingLineVariant } from '../teleprompter.types';
-
interface ReadingLineProps {
- variant: ReadingLineVariant;
+ showReadingLine: boolean;
dimPast: boolean;
}
/**
* The eye-line indicator: it marks where on the screen the talent should read,
* which keeps their eyeline near the lens instead of tracking down the page.
+ *
+ * It is a marker beside the text rather than a rule across it. A full width line
+ * cuts through the words at the one place the eye is trying to rest, and every
+ * prompter which ships a cue indicator keeps it out of the reading path for the
+ * same reason. One line tall, so it frames the line being read rather than
+ * pointing at a position between two of them.
*/
-export default function ReadingLine({ variant, dimPast }: ReadingLineProps) {
- if (variant === 'none' && !dimPast) {
+export default function ReadingLine({ showReadingLine, dimPast }: ReadingLineProps) {
+ if (!showReadingLine && !dimPast) {
return null;
}
return (
<>
{dimPast &&
- {variant === 'arrows' && (
- <>
-
-
- >
- )}
+ {showReadingLine && (
+
+
)}
>
diff --git a/apps/client/src/views/teleprompter/teleprompter.options.ts b/apps/client/src/views/teleprompter/teleprompter.options.ts
index c807f5ada..44dc0038e 100644
--- a/apps/client/src/views/teleprompter/teleprompter.options.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.options.ts
@@ -8,7 +8,7 @@ import { makeOptionsFromCustomFields } from '../../common/components/view-params
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
import { clampSpeed, DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from './teleprompter.scroll';
-import type { HeadingSource, ReadingLineVariant, TeleprompterOptions } from './teleprompter.types';
+import type { HeadingSource, TeleprompterOptions } from './teleprompter.types';
/**
* The values the editor offers are also the values the parser accepts, so the
@@ -24,13 +24,6 @@ const headingOptions: { value: HeadingSource; label: string }[] = [
];
const headingSources = headingOptions.map((option) => option.value);
-const readingLineOptions: { value: ReadingLineVariant; label: string }[] = [
- { value: 'line', label: 'Line' },
- { value: 'arrows', label: 'Arrows' },
- { value: 'none', label: 'None' },
-];
-const readingLineVariants = readingLineOptions.map((option) => option.value);
-
/**
* Defaults and bounds for every option, in one place.
*
@@ -45,10 +38,10 @@ const defaults = {
showGroups: true,
speed: DEFAULT_SPEED,
followLoaded: true,
- fontSize: 64,
- lineHeight: 1.5,
- textWidth: 90,
- readingLine: 'line' as ReadingLineVariant,
+ fontSize: 40,
+ lineHeight: 1.3,
+ textWidth: 80,
+ readingLine: true,
readingLinePos: 40,
dimPast: true,
flipH: false,
@@ -162,9 +155,8 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[]
{
id: 'readingLine',
title: 'Reading line',
- description: 'Style of the eye-line indicator which marks where to read',
- type: 'option',
- values: readingLineOptions,
+ description: 'Shows a marker beside the line which should be read',
+ type: 'boolean',
defaultValue: defaults.readingLine,
},
{
@@ -245,7 +237,7 @@ export function getOptionsFromParams(
lineHeight: toNumber(getValue('lineHeight'), bounds.lineHeight, defaults.lineHeight),
textWidth: toNumber(getValue('textWidth'), bounds.textWidth, defaults.textWidth),
dimPast: toBoolean(getValue('dimPast'), defaults.dimPast),
- readingLine: toEnum(getValue('readingLine'), readingLineVariants, defaults.readingLine),
+ readingLine: toBoolean(getValue('readingLine'), defaults.readingLine),
readingLinePos: toNumber(getValue('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
flipH: toBoolean(getValue('flipH'), defaults.flipH),
flipV: toBoolean(getValue('flipV'), defaults.flipV),
diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts
index d8f6906b6..e6013640e 100644
--- a/apps/client/src/views/teleprompter/teleprompter.scroll.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts
@@ -5,14 +5,23 @@
* 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;
+/**
+ * Lines per minute.
+ *
+ * The default is calibrated against the reading rate rather than picked for
+ * feel: broadcast presenters read at 140-160 words per minute and conference
+ * talent slower still, and at the default column width a line carries a dozen
+ * or so words. Twelve lines per minute lands in that band. The ceiling is set
+ * where the text stops being readable at all, not at the fastest the loop can
+ * physically scroll, so the arrow keys stay useful across their whole range.
+ */
+export const MIN_SPEED = 1;
+export const MAX_SPEED = 40;
+export const DEFAULT_SPEED = 12;
/** how much one speed adjustment moves, shared by the keymap and the overlay */
-export const SPEED_STEP = 2;
-export const SPEED_STEP_COARSE = 10;
+export const SPEED_STEP = 1;
+export const SPEED_STEP_COARSE = 5;
/** font size multiplier applied on top of the configured size by the +/- keys */
const MIN_FONT_SCALE = 0.4;
diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts
index 72a9bf725..fc468c393 100644
--- a/apps/client/src/views/teleprompter/teleprompter.types.ts
+++ b/apps/client/src/views/teleprompter/teleprompter.types.ts
@@ -3,13 +3,6 @@ import type { MaybeString } from 'ontime-types';
/** What the per-event heading shows above each script block */
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
-/**
- * Style of the eye-line indicator.
- * Darkening what has already been read is the separate dimPast option, so there
- * is deliberately no shade variant here: it would be the same element twice.
- */
-export type ReadingLineVariant = 'none' | 'line' | 'arrows';
-
/** A single readable segment of the prompter document */
export type ScriptBlock = {
/** entry id, used as the follow target and the react key */
@@ -37,7 +30,7 @@ export type TeleprompterOptions = {
lineHeight: number;
textWidth: number;
dimPast: boolean;
- readingLine: ReadingLineVariant;
+ readingLine: boolean;
readingLinePos: number;
flipH: boolean;
flipV: boolean;
diff --git a/apps/client/src/views/teleprompter/useTeleprompterControls.ts b/apps/client/src/views/teleprompter/useTeleprompterControls.ts
index 23b9ad0ac..ccc784034 100644
--- a/apps/client/src/views/teleprompter/useTeleprompterControls.ts
+++ b/apps/client/src/views/teleprompter/useTeleprompterControls.ts
@@ -75,12 +75,13 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
}
/**
- * A focused button is activated by Space and Enter. Resolving those into
- * prompter actions here, and calling preventDefault, would stop the button
- * doing its own job: tabbing to the help control and pressing Space would
- * start the script rather than open the help.
+ * Enter is left to a focused control so the overlay stays operable from
+ * the keyboard. Space is not: it is the play/pause pedal, and a prompter
+ * which stops rolling because the operator last touched a button would be
+ * broken in the one moment it matters. The overlay drops focus after a
+ * click so the two never compete for the same press.
*/
- if ((event.code === 'Space' || event.key === 'Enter') && target?.closest('button, a, [role="button"]')) {
+ if (event.key === 'Enter' && target?.closest('button, a, [role="button"]')) {
return;
}
diff --git a/e2e/tests/features/207-view-params.spec.ts b/e2e/tests/features/207-view-params.spec.ts
index 8179f6ac3..0d4f5339b 100644
--- a/e2e/tests/features/207-view-params.spec.ts
+++ b/e2e/tests/features/207-view-params.spec.ts
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
+import { seedScript } from '../utils/seedScript';
+
test('View params configures timer view', async ({ page }) => {
await page.goto('/timer');
@@ -13,3 +15,29 @@ test('View params configures timer view', async ({ page }) => {
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
await expect(page).toHaveURL(/.*hideClock=true/);
});
+
+/**
+ * An option which defaults to true is the case which breaks: the switch has to
+ * open on, and switching it off has to reach the URL. A checkbox which is off
+ * sends nothing at all, so an option in that state is only representable by
+ * writing it out explicitly.
+ */
+test('View params can switch off an option which defaults to on', async ({ page }) => {
+ await seedScript(page);
+ 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);
+});
diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts
index d6f68aa40..73a877e25 100644
--- a/e2e/tests/features/215-teleprompter.spec.ts
+++ b/e2e/tests/features/215-teleprompter.spec.ts
@@ -1,15 +1,13 @@
import { type Page, expect, test } from '@playwright/test';
+import { scriptMarker, seedScript } from '../utils/seedScript';
+
/**
* The note field is the script source throughout, so that these tests do not
* depend on how custom field keys happen to be spelled.
*/
const teleprompterUrl = '/teleprompter?script=note';
-const scriptMarker = 'E2E prompter script';
-/** long enough that the document scrolls well past a screen */
-const scriptText = `${scriptMarker}. `.repeat(40);
-
function scroller(page: Page) {
return page.getByTestId('teleprompter-scroller');
}
@@ -18,25 +16,6 @@ function scrollTop(page: Page) {
return scroller(page).evaluate((element) => element.scrollTop);
}
-/**
- * Puts a known script into whichever rundown happens to be loaded.
- *
- * These tests used to read the notes of the uploaded fixture, which made them
- * depend on every spec that runs before them: 214 creates a fresh rundown and
- * leaves it loaded, so by the time this file ran there were no notes anywhere
- * and the view was showing its empty state. Seeding is idempotent, so the
- * rundown gains one event no matter how many tests run.
- */
-async function seedScript(page: Page) {
- const rundown = await (await page.request.get('/data/rundowns/current')).json();
- const alreadySeeded = rundown.flatOrder.some((id: string) => rundown.entries[id]?.note?.startsWith(scriptMarker));
- if (alreadySeeded) return;
-
- await page.request.post(`/data/rundowns/${rundown.id}/entry`, {
- data: { type: 'event', title: 'Teleprompter e2e', note: scriptText },
- });
-}
-
test.describe('teleprompter', () => {
test.beforeEach(async ({ page }) => {
await seedScript(page);
@@ -150,11 +129,14 @@ test.describe('teleprompter', () => {
});
test('honours the flip and reading line params', async ({ page }) => {
- await page.goto('/teleprompter?script=note&flipV=true&readingLine=arrows');
+ await page.goto('/teleprompter?script=note&flipV=true&readingLine=false');
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/);
+
+ // a boolean which defaults to true has to be switchable off from the url
+ await expect(page.locator('.teleprompter__reading-marker')).toHaveCount(0);
});
});
diff --git a/e2e/tests/utils/seedScript.ts b/e2e/tests/utils/seedScript.ts
new file mode 100644
index 000000000..1b99249e5
--- /dev/null
+++ b/e2e/tests/utils/seedScript.ts
@@ -0,0 +1,24 @@
+import type { Page } from '@playwright/test';
+
+export const scriptMarker = 'E2E prompter script';
+
+/** long enough that the document scrolls well past a screen */
+export const scriptText = `${scriptMarker}. `.repeat(40);
+
+/**
+ * Puts a known script into whichever rundown happens to be loaded.
+ *
+ * Specs which read the rundown cannot rely on the uploaded fixture surviving:
+ * the suite runs serially and earlier specs add, edit and delete entries, while
+ * 214 creates a fresh rundown and leaves it loaded. Seeding is idempotent, so
+ * the rundown gains one event no matter how many tests have run before.
+ */
+export async function seedScript(page: Page) {
+ const rundown = await (await page.request.get('/data/rundowns/current')).json();
+ const alreadySeeded = rundown.flatOrder.some((id: string) => rundown.entries[id]?.note?.startsWith(scriptMarker));
+ if (alreadySeeded) return;
+
+ await page.request.post(`/data/rundowns/${rundown.id}/entry`, {
+ data: { type: 'event', title: 'Teleprompter e2e', note: scriptText },
+ });
+}