diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx index 030540423..7a1df39cb 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.tsx +++ b/apps/client/src/views/teleprompter/Teleprompter.tsx @@ -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 (
diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts index 3c9a87e14..02b543f65 100644 --- a/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.utils.test.ts @@ -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 { 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 }); + }); +}); diff --git a/apps/client/src/views/teleprompter/teleprompter.options.ts b/apps/client/src/views/teleprompter/teleprompter.options.ts index 533320b99..859692c18 100644 --- a/apps/client/src/views/teleprompter/teleprompter.options.ts +++ b/apps/client/src/views/teleprompter/teleprompter.options.ts @@ -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, }, diff --git a/apps/client/src/views/teleprompter/teleprompter.utils.ts b/apps/client/src/views/teleprompter/teleprompter.utils.ts index fe4c30576..ef22b3459 100644 --- a/apps/client/src/views/teleprompter/teleprompter.utils.ts +++ b/apps/client/src/views/teleprompter/teleprompter.utils.ts @@ -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. */