From edc27c887ad027347d04043956298b4c24bf7f70 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Thu, 10 Sep 2026 15:11:08 +1200 Subject: [PATCH 1/3] Fix source selection after disabling dual output --- .../editor/elements/SourceSelector.tsx | 2 + app/services/dual-output/dual-output.ts | 2 +- app/services/scenes/scene.ts | 4 +- test/regular/api/dual-output.ts | 82 +++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/app/components-react/editor/elements/SourceSelector.tsx b/app/components-react/editor/elements/SourceSelector.tsx index 0ece169946d2..6fc0f22f2876 100644 --- a/app/components-react/editor/elements/SourceSelector.tsx +++ b/app/components-react/editor/elements/SourceSelector.tsx @@ -387,6 +387,7 @@ class SourceSelectorController { * clicking on the source selector selects both sources */ if ( + this.isDualOutputActive && this.dualOutputService.views.hasNodeMap(this.scene.id) && this.dualOutputService.views.activeDisplays.horizontal && this.dualOutputService.views.activeDisplays.vertical @@ -504,6 +505,7 @@ class SourceSelectorController { * in the vertical display, convert the vertical node id to the horizontal node id. */ if ( + this.isDualOutputActive && this.dualOutputService.views.activeDisplays.horizontal && this.dualOutputService.views.activeDisplays.vertical ) { diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index c4e82eaa577d..aa4ad22a0ab6 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -174,7 +174,7 @@ class DualOutputViews extends ViewHandler { } get onlyVerticalDisplayActive() { - return this.activeDisplays.vertical && !this.activeDisplays.horizontal; + return this.dualOutputMode && this.activeDisplays.vertical && !this.activeDisplays.horizontal; } get platformsDualStreaming() { diff --git a/app/services/scenes/scene.ts b/app/services/scenes/scene.ts index 7ced6b012b73..eaa62ee47a5b 100644 --- a/app/services/scenes/scene.ts +++ b/app/services/scenes/scene.ts @@ -151,9 +151,7 @@ export class Scene { getSourceSelectorNodes(): TSceneNode[] { let nodes = this.getNodes(); - const populateWithVerticalNodes = - !this.dualOutputService.views.activeDisplays.horizontal && - this.dualOutputService.views.activeDisplays.vertical; + const populateWithVerticalNodes = this.dualOutputService.views.onlyVerticalDisplayActive; nodes = nodes.filter(node => { // if only the vertical display is active diff --git a/test/regular/api/dual-output.ts b/test/regular/api/dual-output.ts index 7b439778f8df..952b4fd63db4 100644 --- a/test/regular/api/dual-output.ts +++ b/test/regular/api/dual-output.ts @@ -3,11 +3,93 @@ import { getApiClient } from '../../helpers/api-client'; import { test, useWebdriver, TExecutionContext } from '../../helpers/webdriver'; import { ScenesService, Scene, SceneItem } from 'services/scenes'; import { VideoSettingsService } from 'services/settings-v2/video'; +import { SelectionService } from 'services/api/external-api/selection'; +import { click, focusMain, focusWindow, waitForDisplayed } from '../../helpers/modules/core'; // not a react hook // eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); +async function setDualOutputMode(t: TExecutionContext, status: boolean) { + try { + t.true(await focusWindow('worker'), 'Worker window is available'); + // Exercise the normal mode transition without requiring a provider login. + await t.context.app.client.execute(` + window.servicesManager.getResource('DualOutputService').setDualOutputMode(${status}, true); + 0; + `); + await t.context.app.client.waitUntil( + () => + t.context.app.client.execute(` + const dualOutput = window.servicesManager.getResource('DualOutputService'); + return dualOutput.views.dualOutputMode === ${status} && !dualOutput.views.isLoading; + `), + { timeout: 10000, timeoutMsg: 'Dual output mode transition did not complete' }, + ); + } finally { + await focusMain(); + } +} + +for (const horizontalVisible of [false, true]) { + test(`Selection after disabling dual output with horizontal ${ + horizontalVisible ? 'visible' : 'hidden' + }`, async t => { + const client = await getApiClient(); + const scenesService = client.getResource('ScenesService'); + const dualOutputService = client.getResource('DualOutputService'); + const selection = client.getResource('SelectionService'); + const scene = scenesService.createScene('Selection transition'); + scenesService.makeSceneActive(scene.id); + const horizontalItem = scene.createAndAddSource('Selection target', 'color_source'); + horizontalItem.fitToScreen(); + + await setDualOutputMode(t, true); + dualOutputService.toggleDisplay(horizontalVisible, 'horizontal'); + const verticalItem = scene.getItems().find(item => item.display === 'vertical'); + t.truthy(verticalItem, 'Dual output created a vertical partner'); + t.deepEqual( + scene.getSourceSelectorNodes().map(node => node.id), + [horizontalVisible ? horizontalItem.id : verticalItem.id], + 'Dual output source rows follow the visible displays', + ); + + await setDualOutputMode(t, false); + await waitForDisplayed('#horizontal-display'); + t.deepEqual( + scene.getSourceSelectorNodes().map(node => node.id), + [horizontalItem.id], + 'Single output source rows always use horizontal items', + ); + + await click('[data-name="Selection target"]'); + t.deepEqual(selection.getIds(), [horizontalItem.id], 'List selects only the visible item'); + + selection.reset(); + await t.context.app.client.waitUntil(async () => { + const selectedRows = await t.context.app.client.$$( + '.ant-tree-node-selected [data-name="Selection target"]', + ); + return selectedRows.length === 0; + }); + await click('#horizontal-display'); + await waitForDisplayed('.ant-tree-node-selected [data-name="Selection target"]'); + t.deepEqual(selection.getIds(), [horizontalItem.id], 'Canvas selection highlights the list'); + + await setDualOutputMode(t, true); + t.is( + dualOutputService.state.videoSettings.activeDisplays.horizontal, + horizontalVisible, + 'The saved horizontal display preference is preserved', + ); + t.deepEqual( + scene.getSourceSelectorNodes().map(node => node.id), + [horizontalVisible ? horizontalItem.id : verticalItem.id], + 'Re-enabling dual output restores the corresponding source rows', + ); + }); +} + function confirmDualOutputSources(t: TExecutionContext, scene: Scene) { const numSceneItems = scene .getItems() From c5b134342a5f1d763a06f3e8fae93754b736ade0 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Thu, 10 Sep 2026 15:25:53 +1200 Subject: [PATCH 2/3] Tests refactoring --- test/regular/api/dual-output.ts | 37 +++++++++++++-------------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/test/regular/api/dual-output.ts b/test/regular/api/dual-output.ts index 952b4fd63db4..22834fbcd117 100644 --- a/test/regular/api/dual-output.ts +++ b/test/regular/api/dual-output.ts @@ -4,31 +4,22 @@ import { test, useWebdriver, TExecutionContext } from '../../helpers/webdriver'; import { ScenesService, Scene, SceneItem } from 'services/scenes'; import { VideoSettingsService } from 'services/settings-v2/video'; import { SelectionService } from 'services/api/external-api/selection'; -import { click, focusMain, focusWindow, waitForDisplayed } from '../../helpers/modules/core'; +import { click, focusMain, waitForDisplayed } from '../../helpers/modules/core'; // not a react hook // eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); -async function setDualOutputMode(t: TExecutionContext, status: boolean) { - try { - t.true(await focusWindow('worker'), 'Worker window is available'); - // Exercise the normal mode transition without requiring a provider login. - await t.context.app.client.execute(` - window.servicesManager.getResource('DualOutputService').setDualOutputMode(${status}, true); - 0; - `); - await t.context.app.client.waitUntil( - () => - t.context.app.client.execute(` - const dualOutput = window.servicesManager.getResource('DualOutputService'); - return dualOutput.views.dualOutputMode === ${status} && !dualOutput.views.isLoading; - `), - { timeout: 10000, timeoutMsg: 'Dual output mode transition did not complete' }, - ); - } finally { - await focusMain(); - } +async function setDualOutputMode(status: boolean) { + const client = await getApiClient(); + // The RPC fallback exposes this private method; its loading-mode decorator returns a promise. + const dualOutput = client.getResource<{ + setDualOutputMode(status: boolean, skipShowVideoSettings: boolean): Promise; + }>('DualOutputService'); + + // Exercise the normal mode transition without requiring a provider login. + await dualOutput.setDualOutputMode(status, true); + await focusMain(); } for (const horizontalVisible of [false, true]) { @@ -44,7 +35,7 @@ for (const horizontalVisible of [false, true]) { const horizontalItem = scene.createAndAddSource('Selection target', 'color_source'); horizontalItem.fitToScreen(); - await setDualOutputMode(t, true); + await setDualOutputMode(true); dualOutputService.toggleDisplay(horizontalVisible, 'horizontal'); const verticalItem = scene.getItems().find(item => item.display === 'vertical'); t.truthy(verticalItem, 'Dual output created a vertical partner'); @@ -54,7 +45,7 @@ for (const horizontalVisible of [false, true]) { 'Dual output source rows follow the visible displays', ); - await setDualOutputMode(t, false); + await setDualOutputMode(false); await waitForDisplayed('#horizontal-display'); t.deepEqual( scene.getSourceSelectorNodes().map(node => node.id), @@ -76,7 +67,7 @@ for (const horizontalVisible of [false, true]) { await waitForDisplayed('.ant-tree-node-selected [data-name="Selection target"]'); t.deepEqual(selection.getIds(), [horizontalItem.id], 'Canvas selection highlights the list'); - await setDualOutputMode(t, true); + await setDualOutputMode(true); t.is( dualOutputService.state.videoSettings.activeDisplays.horizontal, horizontalVisible, From 74389be766c9c57b8988f20d7df39871ab15caa9 Mon Sep 17 00:00:00 2001 From: Aleksandr Voitenko Date: Fri, 11 Sep 2026 11:13:18 +1200 Subject: [PATCH 3/3] Refactor dual output selection test variants --- test/regular/api/dual-output.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/regular/api/dual-output.ts b/test/regular/api/dual-output.ts index 22834fbcd117..664e368041e6 100644 --- a/test/regular/api/dual-output.ts +++ b/test/regular/api/dual-output.ts @@ -10,6 +10,9 @@ import { click, focusMain, waitForDisplayed } from '../../helpers/modules/core'; // eslint-disable-next-line react-hooks/rules-of-hooks useWebdriver(); +testSelectionAfterDisablingDualOutput(false); +testSelectionAfterDisablingDualOutput(true); + async function setDualOutputMode(status: boolean) { const client = await getApiClient(); // The RPC fallback exposes this private method; its loading-mode decorator returns a promise. @@ -22,7 +25,7 @@ async function setDualOutputMode(status: boolean) { await focusMain(); } -for (const horizontalVisible of [false, true]) { +function testSelectionAfterDisablingDualOutput(horizontalVisible: boolean) { test(`Selection after disabling dual output with horizontal ${ horizontalVisible ? 'visible' : 'hidden' }`, async t => {