mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 11:23:50 +00:00
feat(teleprompter): honour the shared Flip Screen toggle
The teleprompter was the only view ignoring the global Flip Screen option, so it now composes with the per view flips. The two turn out to be the same mechanism: the shared `.mirror` class is rotate(180deg), which is the same matrix as scale(-1, -1), so Flip Screen is exactly a flip on both axes at once. Folding it in with XOR means the teleprompter matches every other view when Flip Screen is on, and there is still only one transform on the element. It cannot replace the per view flips. A rotation preserves handedness, so it never produces the mirror image a beam splitter reflection needs, and it cannot address one axis on its own. Those remain URL params, which also lets a shared link to the talent screen carry the rig's setup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
@@ -5,6 +5,7 @@ import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import Loader from '../common/loader/Loader';
|
||||
import ControlOverlay from './control-overlay/ControlOverlay';
|
||||
@@ -13,7 +14,7 @@ import ReadingLine from './reading-line/ReadingLine';
|
||||
import ScriptBlockView from './script-block/ScriptBlock';
|
||||
import { getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
|
||||
import { clampFontScale } from './teleprompter.scroll';
|
||||
import { buildScript } from './teleprompter.utils';
|
||||
import { buildScript, composeFlip } from './teleprompter.utils';
|
||||
import { useTeleprompterControls } from './useTeleprompterControls';
|
||||
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
|
||||
import { useTeleprompterScroll } from './useTeleprompterScroll';
|
||||
@@ -39,6 +40,8 @@ export default function TeleprompterLoader() {
|
||||
function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) {
|
||||
const options = useTeleprompterOptions();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
// the shared "Flip Screen" toggle from the navigation menu
|
||||
const isMirrored = useViewOptionsStore((state) => state.mirror);
|
||||
|
||||
const [fontScale, setFontScale] = useState(1);
|
||||
const [flipH, setFlipH] = useState(options.flipH);
|
||||
@@ -118,6 +121,9 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
||||
|
||||
const hasScriptSource = Boolean(options.scriptSource) && options.scriptSource !== 'none';
|
||||
|
||||
// Flip Screen is a flip on both axes, so it folds into the per view flips
|
||||
const flip = composeFlip(flipH, flipV, isMirrored);
|
||||
|
||||
const viewStyles = {
|
||||
'--tp-font-size': `${options.fontSize * fontScale}px`,
|
||||
'--tp-line-height': options.lineHeight,
|
||||
@@ -134,7 +140,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(['teleprompter', flipH && 'teleprompter--flip-h', flipV && 'teleprompter--flip-v'])}
|
||||
className={cx(['teleprompter', flip.flipH && 'teleprompter--flip-h', flip.flipV && 'teleprompter--flip-v'])}
|
||||
style={viewStyles}
|
||||
data-testid='teleprompter-view'
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type CustomFields, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import type { RundownMetadata, RundownMetadataObject } from '../../../common/utils/rundownMetadata';
|
||||
import { buildScript } from '../teleprompter.utils';
|
||||
import { buildScript, composeFlip } from '../teleprompter.utils';
|
||||
|
||||
function makeEvent(id: string, overrides: Partial<OntimeEntry> = {}): OntimeEntry {
|
||||
return {
|
||||
@@ -174,3 +174,24 @@ describe('buildScript()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeFlip()', () => {
|
||||
test('passes the per view flips through when Flip Screen is off', () => {
|
||||
expect(composeFlip(false, false, false)).toEqual({ flipH: false, flipV: false });
|
||||
expect(composeFlip(true, false, false)).toEqual({ flipH: true, flipV: false });
|
||||
expect(composeFlip(false, true, false)).toEqual({ flipH: false, flipV: true });
|
||||
});
|
||||
|
||||
test('Flip Screen alone flips both axes, matching rotate(180deg) in every other view', () => {
|
||||
expect(composeFlip(false, false, true)).toEqual({ flipH: true, flipV: true });
|
||||
});
|
||||
|
||||
test('a horizontal flip and Flip Screen leave only the vertical axis flipped', () => {
|
||||
// scale(-1, 1) composed with scale(-1, -1) is scale(1, -1)
|
||||
expect(composeFlip(true, false, true)).toEqual({ flipH: false, flipV: true });
|
||||
});
|
||||
|
||||
test('both flips cancel Flip Screen out', () => {
|
||||
expect(composeFlip(true, true, true)).toEqual({ flipH: false, flipV: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +171,8 @@ export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[]
|
||||
{
|
||||
id: 'flipH',
|
||||
title: 'Flip horizontally',
|
||||
description: 'Mirrors the view horizontally, for use with a beam splitter rig. Toggled live with F',
|
||||
description:
|
||||
'Mirrors the view horizontally, which is what a beam splitter rig needs. Toggled live with F. Flip Screen in the navigation menu flips both axes at once, which is a rotation rather than a mirror',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
@@ -95,6 +95,23 @@ export function buildScript(
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds Ontime's global "Flip Screen" toggle into the per view flips.
|
||||
*
|
||||
* The shared `.mirror` class is `rotate(180deg)`, which is the same matrix as
|
||||
* `scale(-1, -1)`: a flip on both axes at once. So the global toggle is exactly
|
||||
* the pair of flips this view already has, and composing them with XOR keeps the
|
||||
* teleprompter behaving like every other view without two transforms competing
|
||||
* for the same property.
|
||||
*
|
||||
* It cannot replace the per view flips, though. A rotation preserves handedness,
|
||||
* so it never yields the mirror image a beam splitter reflection needs; only a
|
||||
* single axis flip does. That is why both exist.
|
||||
*/
|
||||
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
|
||||
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rough words per line, used only to show a words per minute estimate in the HUD.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user