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.
*/