From f4c699ba740e67cd4b6902d2360fe94e27b7300c Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:45:46 -0700 Subject: [PATCH 1/6] Move editor display toggles. (#5768) * Add display toggles to editor display header. * Remove display toggles from scene selector. * Only show display toggles in dual output mode. * Fixes for dual output tests. * Fixes for multistream and dual output tests. * Fix multistream go live tests. * Dual Output test fixes. * More fixes for dual output and multistream tests. * Fix dual output non-ultra test. --- .../editor/elements/SceneSelector.tsx | 14 -- app/components-react/root/StudioEditor.m.less | 52 ++-- app/components-react/root/StudioEditor.tsx | 62 +++-- app/i18n/en-US/dual-output.json | 4 + app/i18n/fallback.ts | 1 + test/helpers/modules/dual-output.ts | 62 ++++- test/regular/streaming/dual-output.ts | 235 ++++++++---------- 7 files changed, 213 insertions(+), 217 deletions(-) create mode 100644 app/i18n/en-US/dual-output.json diff --git a/app/components-react/editor/elements/SceneSelector.tsx b/app/components-react/editor/elements/SceneSelector.tsx index 016648ab8b63..f35d0f68cfd4 100644 --- a/app/components-react/editor/elements/SceneSelector.tsx +++ b/app/components-react/editor/elements/SceneSelector.tsx @@ -9,7 +9,6 @@ import { Services } from 'components-react/service-provider'; import { useVuex } from 'components-react/hooks'; import HelpTip from 'components-react/shared/HelpTip'; import Scrollable from 'components-react/shared/Scrollable'; -import { DisplayToggle } from 'components-react/shared/DisplayToggle'; import { useTree, IOnDropInfo } from 'components-react/hooks/useTree'; import { $t } from 'services/i18n'; import { EDismissable } from 'services/dismissables'; @@ -26,17 +25,8 @@ function SceneSelector() { SourceFiltersService, ProjectorService, EditorCommandsService, - StreamingService, - DualOutputService, } = Services; - const v = useVuex(() => ({ - studioMode: TransitionsService.views.studioMode, - isMidStreamMode: StreamingService.views.isMidStreamMode, - showDualOutput: DualOutputService.views.dualOutputMode, - selectiveRecording: StreamingService.state.selectiveRecording, - })); - const { treeSort } = useTree(true); const [showDropdown, setShowDropdown] = useState(false); @@ -184,10 +174,6 @@ function SceneSelector() { - {v.showDualOutput && ( - - )} - diff --git a/app/components-react/root/StudioEditor.m.less b/app/components-react/root/StudioEditor.m.less index 5ee2dff13b9b..c75ad1e181c2 100644 --- a/app/components-react/root/StudioEditor.m.less +++ b/app/components-react/root/StudioEditor.m.less @@ -190,10 +190,11 @@ .dual-output-header { display: flex; + justify-content: flex-end; align-items: center; - height: 60px; - color: var(--paragraph); + color: var(--icon-toggle); background-color: var(--background); + margin-bottom: 16px; &.stacked { flex-direction: column; @@ -201,49 +202,26 @@ height: initial; } - i { - margin: 0 10px; + &:hover { + cursor: pointer; } - .horizontal-header { - flex: 2; - justify-content: center; - align-content: center; - flex-grow: 2; - text-align: center; - font-size: 16px; - display: inline-flex; + .toggle-wrapper { + display: flex; align-items: center; - justify-content: center; + margin-right: 16px; } - .vertical-header { - flex: 2; - justify-content: center; - align-content: center; - flex-grow: 2; - text-align: center; - font-size: 16px; - display: inline-flex; - align-items: center; - justify-content: center; + i { + margin: 0 10px; } - .manage-link { - justify-self: flex-end; - align-self: flex-end; - color: var(--paragraph); - font-size: 11px; - font-weight: 400; - text-decoration: underline; - margin: 0 0 5px 10px; - white-space: nowrap; - position: absolute; - right: 10px; + .display-visible { + color: var(--icon-toggle-active); + } - &:hover { - text-decoration: none; - } + div:last-child { + margin-right: 0px !important; } } diff --git a/app/components-react/root/StudioEditor.tsx b/app/components-react/root/StudioEditor.tsx index fe6dc682ae2d..2da7ad0e5280 100644 --- a/app/components-react/root/StudioEditor.tsx +++ b/app/components-react/root/StudioEditor.tsx @@ -18,7 +18,6 @@ import { EAvailableFeatures } from 'services/incremental-rollout'; export default function StudioEditor() { const { - WindowsService, CustomizationService, EditorService, TransitionsService, @@ -34,13 +33,12 @@ export default function StudioEditor() { const v = useVuex(() => ({ cursor: EditorService.state.cursor, studioMode: TransitionsService.state.studioMode, - dualOutputMode: DualOutputService.views.dualOutputMode, showHorizontalDisplay: DualOutputService.views.showHorizontalDisplay, - showVerticalDisplay: - DualOutputService.views.showVerticalDisplay && !StreamingService.state.selectiveRecording, + showVerticalDisplay: DualOutputService.views.showVerticalDisplay, isRecording: StreamingService.views.isRecording, activeSceneId: ScenesService.views.activeSceneId, isLoading: DualOutputService.views.isLoading, + dualOutputMode: DualOutputService.views.dualOutputMode, })); const displayEnabled = !performanceMode && !v.isLoading; const placeholderRef = useRef(null); @@ -402,14 +400,10 @@ function StudioModeControls(p: { stacked: boolean }) { } function DualOutputControls(p: { stacked: boolean; isRecording: boolean }) { - function openSettingsWindow() { - Services.SettingsService.actions.showSettings('Video'); - } - const showHorizontal = Services.DualOutputService.views.showHorizontalDisplay; - const showVertical = - Services.DualOutputService.views.showVerticalDisplay && - !Services.StreamingService.state.selectiveRecording; + const showVertical = Services.DualOutputService.views.showVerticalDisplay; + + const v = useVuex(() => ({ toggleDisplay: Services.DualOutputService.actions.toggleDisplay })); const showRecordingIcons = useMemo(() => { return ( @@ -425,23 +419,37 @@ function DualOutputControls(p: { stacked: boolean; isRecording: boolean }) { id="dual-output-header" className={cx(styles.dualOutputHeader, { [styles.stacked]: p.stacked })} > - {showHorizontal && ( -
- - {$t('Horizontal Output')} - {showRecordingIcons && } -
- )} +
v.toggleDisplay(!showHorizontal, 'horizontal')} + > + {showRecordingIcons && } + {showHorizontal ? ( + + ) : ( + + )} + + {$t('Horizontal canvas')} + + {showRecordingIcons && } +
- {showVertical && ( -
- - {$t('Vertical Output')} - {showRecordingIcons && } -
- )} -
- {$t('Manage Dual Output')} +
v.toggleDisplay(!showVertical, 'vertical')} + > + {showRecordingIcons && } + {showVertical ? ( + + ) : ( + + )} + + {$t('Vertical canvas')} +
); diff --git a/app/i18n/en-US/dual-output.json b/app/i18n/en-US/dual-output.json new file mode 100644 index 000000000000..653c7026b978 --- /dev/null +++ b/app/i18n/en-US/dual-output.json @@ -0,0 +1,4 @@ +{ + "Horizontal canvas": "Horizontal canvas", + "Vertical canvas": "Vertical canvas" +} diff --git a/app/i18n/fallback.ts b/app/i18n/fallback.ts index c4e0d5d1b7be..df23f3dedf38 100644 --- a/app/i18n/fallback.ts +++ b/app/i18n/fallback.ts @@ -70,6 +70,7 @@ const fallbackDictionary = { ...require('./en-US/kick.json'), ...require('./en-US/stream-shift.json'), ...require('./en-US/developer.json'), + ...require('./en-US/dual-output.json'), }; export default fallbackDictionary; diff --git a/test/helpers/modules/dual-output.ts b/test/helpers/modules/dual-output.ts index c194c5c147aa..3535781ee247 100644 --- a/test/helpers/modules/dual-output.ts +++ b/test/helpers/modules/dual-output.ts @@ -1,3 +1,6 @@ +import { sleep } from '../sleep'; +import { skipCheckingErrorsInLog } from '../webdriver'; +import { addDummyAccount } from '../webdriver/user'; import { focusChild, click, @@ -6,8 +9,17 @@ import { clickIfDisplayed, focusMain, isDisplayed, + waitForDisplayed, } from './core'; +import { fillForm } from './forms'; import { showSettingsWindow } from './settings/settings'; +import { + chatIsVisible, + stopStream, + submit, + waitForSettingsWindowLoaded, + waitForStreamStop, +} from './streaming'; /** * Toggle dual output mode @@ -30,8 +42,54 @@ export async function toggleDualOutputMode(closeChildWindow: boolean = true) { */ export async function toggleDisplay(display: 'horizontal' | 'vertical', wait: boolean = false) { if (wait) { - await clickIfDisplayed(`i#${display}-display-toggle`); + await clickIfDisplayed(`div#${display}-display-toggle`); + } else { + await click(`div#${display}-display-toggle`); + } +} + +/** + * Toggle an account and assign it to a display + */ +export async function toggleAccountForDisplay(display: 'horizontal' | 'vertical') { + const platforms = ['trovo', 'youtube', 'instagram']; + + try { + for (const platform of platforms) { + if (platform === 'instagram') { + await addDummyAccount('instagram'); + } + + await focusChild(); + await fillForm({ [platform]: true, [`${platform}Display`]: display }); + await waitForSettingsWindowLoaded(); + + // If the settings form loads, then an account has successfully been toggled + if (await isDisplayed(`div[data-name="${platform}-settings"]`)) { + return platform; + } + } + } catch (e: unknown) { + console.error('Error toggling platforms.', e); + } + + return null; +} + +export async function waitForDualOutputStreamStart(platform: string) { + await waitForSettingsWindowLoaded(); + + await submit(); + await waitForDisplayed('span=Configure the Dual Output service', { timeout: 60000 }); + + // Dummy accounts won't go live + if (platform === 'instagram') { + await sleep(1000); + await chatIsVisible(); + await waitForStreamStop(); + skipCheckingErrorsInLog(); } else { - await click(`i#${display}-display-toggle`); + await chatIsVisible(true); + await stopStream(); } } diff --git a/test/regular/streaming/dual-output.ts b/test/regular/streaming/dual-output.ts index 993186bf4dc4..c56938852c4b 100644 --- a/test/regular/streaming/dual-output.ts +++ b/test/regular/streaming/dual-output.ts @@ -1,14 +1,10 @@ import { clickGoLive, prepareToGoLive, - stopStream, submit, waitForSettingsWindowLoaded, - waitForStreamStart, - waitForStreamStop, } from '../../helpers/modules/streaming'; import { - click, clickIfDisplayed, clickWhenDisplayed, closeWindow, @@ -18,7 +14,12 @@ import { waitForDisplayed, } from '../../helpers/modules/core'; import { logIn } from '../../helpers/modules/user'; -import { toggleDisplay, toggleDualOutputMode } from '../../helpers/modules/dual-output'; +import { + toggleAccountForDisplay, + toggleDisplay, + toggleDualOutputMode, + waitForDualOutputStreamStart, +} from '../../helpers/modules/dual-output'; import { skipCheckingErrorsInLog, test, @@ -31,8 +32,6 @@ import { getApiClient } from '../../helpers/api-client'; import { fillForm } from '../../helpers/modules/forms'; import { showSettingsWindow } from '../../helpers/modules/settings/settings'; import { sleep } from '../../helpers/sleep'; -// import { readFields, fillForm } from '../../helpers/modules/forms'; -// import { sleep } from '../../helpers/sleep'; // not a react hook // eslint-disable-next-line react-hooks/rules-of-hooks @@ -98,6 +97,7 @@ test('Dual Output', async (t: TExecutionContext) => { Item11: `, ), + 'Single Output scene collection built correctly', ); // toggle dual output on and convert dual output scene collection @@ -142,15 +142,12 @@ test('Dual Output', async (t: TExecutionContext) => { // toggling dual output shows/hides the vertical display await focusMain(); - t.true( - await isDisplayed('div#vertical-display'), - 'Toggling on dual output shows vertical display', - ); + t.true(await isDisplayed('#vertical-display'), 'Toggling on dual output shows vertical display'); await toggleDualOutputMode(); await focusMain(); t.false( - await isDisplayed('div#vertical-display'), + await isDisplayed('#vertical-display'), 'Toggling off dual output hides vertical display', ); @@ -158,40 +155,40 @@ test('Dual Output', async (t: TExecutionContext) => { await toggleDualOutputMode(); await focusMain(); - t.true(await isDisplayed('div#dual-output-header'), 'Dual output header exists'); + t.true(await isDisplayed('#dual-output-header'), 'Dual output header exists'); // check permutations of toggling on and off the displays - await clickIfDisplayed('i#horizontal-display-toggle'); - t.false(await isDisplayed('div#horizontal-display')); - t.true(await isDisplayed('div#vertical-display')); + await toggleDisplay('horizontal'); + t.false(await isDisplayed('#horizontal-display')); + t.true(await isDisplayed('#vertical-display')); await toggleDisplay('vertical', true); - t.false(await isDisplayed('div#horizontal-display')); - t.false(await isDisplayed('div#vertical-display')); + t.false(await isDisplayed('#horizontal-display')); + t.false(await isDisplayed('#vertical-display')); await toggleDisplay('horizontal'); - t.true(await isDisplayed('div#horizontal-display')); - t.false(await isDisplayed('div#vertical-display')); + t.true(await isDisplayed('#horizontal-display')); + t.false(await isDisplayed('#vertical-display')); await toggleDisplay('vertical'); - t.true(await isDisplayed('div#horizontal-display')); - t.true(await isDisplayed('div#vertical-display')); + t.true(await isDisplayed('#horizontal-display')); + t.true(await isDisplayed('#vertical-display')); await toggleDisplay('vertical'); - t.true(await isDisplayed('div#horizontal-display')); - t.false(await isDisplayed('div#vertical-display')); + t.true(await isDisplayed('#horizontal-display')); + t.false(await isDisplayed('#vertical-display')); await toggleDisplay('horizontal'); - t.false(await isDisplayed('div#horizontal-display')); - t.false(await isDisplayed('div#vertical-display')); + t.false(await isDisplayed('#horizontal-display')); + t.false(await isDisplayed('#vertical-display')); await toggleDisplay('vertical'); - t.false(await isDisplayed('div#horizontal-display')); - t.true(await isDisplayed('div#vertical-display')); + t.false(await isDisplayed('#horizontal-display')); + t.true(await isDisplayed('#vertical-display')); await toggleDisplay('horizontal'); - t.true(await isDisplayed('div#horizontal-display')); - t.true(await isDisplayed('div#vertical-display')); + t.true(await isDisplayed('#horizontal-display')); + t.true(await isDisplayed('#vertical-display')); await releaseUserInPool(user); @@ -206,7 +203,7 @@ test( await toggleDualOutputMode(); - // dual output cannot be toggled on in studio mode + // Studio Mode await focusMain(); await (await app.client.$('.side-nav .icon-studio-mode-3')).click(); t.true( @@ -214,36 +211,12 @@ test( 'Cannot toggle Studio Mode in Dual Output Mode.', ); - // selective recording in dual output mode is only available for the horizontal display - await toggleDualOutputMode(); - t.false(await isDisplayed('div#vertical-display'), 'Dual output mode is off'); - await (await app.client.$('[data-name=sourcesControls] .icon-smart-record')).click(); - - // Check that selective recording icon is active - await (await app.client.$('.icon-smart-record.active')).waitForExist(); - - await toggleDualOutputMode(); - - // dual output is active but the vertical display is not shown - await focusMain(); - await (await app.client.$('.icon-dual-output.active')).waitForExist(); - t.false( - await isDisplayed('div#vertical-display'), - 'Vertical display is not shown in dual output with selective recording', - ); - - // toggling selective recording off should show the vertical display - await (await app.client.$('.icon-smart-record.active')).click(); - t.true( - await isDisplayed('div#vertical-display'), - 'Toggling selective recording off shows vertical display in dual output mode', - ); - - // toggling selective recording back on should hide the vertical display + // Selective Recording await (await app.client.$('.icon-smart-record')).click(); + await waitForDisplayed('.icon-smart-record.active'); t.false( - await isDisplayed('div#vertical-display'), - 'Toggling selective recording back on hides vertical display in dual output mode', + await isDisplayed('#vertical-display'), + 'Toggling selective recording back hides the vertical display in dual output mode', ); // toggling selective recording on while in dual output mode opens a message box warning @@ -260,7 +233,7 @@ test( test('Dual Output Go Live Non-Ultra', async t => { await logIn('twitch', { prime: false }); - await toggleDualOutputMode(); + await toggleDualOutputMode(true); await prepareToGoLive(); await clickGoLive(); @@ -272,14 +245,13 @@ test('Dual Output Go Live Non-Ultra', async t => { timeout: 10000, }); await clickIfDisplayed('div.ant-message-notice-content'); - await sleep(1000); + await sleep(200); await closeWindow('child'); const dummy = await addDummyAccount('instagram'); try { await clickGoLive(); - await waitForSettingsWindowLoaded(); await submit(); // Cannot go live in dual output mode with all targets assigned to one display @@ -287,7 +259,7 @@ test('Dual Output Go Live Non-Ultra', async t => { timeout: 5000, }); await clickIfDisplayed('div.ant-message-notice-content'); - await sleep(1000); + await sleep(200); await fillForm({ instagram: true, @@ -303,88 +275,77 @@ test('Dual Output Go Live Non-Ultra', async t => { streamKey: dummy.streamKey, }); - await waitForSettingsWindowLoaded(); - // Dummy account will cause the stream to not go live - skipCheckingErrorsInLog(); - await submit(); - await waitForDisplayed('span=Configure the Dual Output service', { timeout: 60000 }); - await focusMain(); - await waitForDisplayed('div=Refresh Chat', { timeout: 60000 }); - await waitForStreamStop(); + await waitForDualOutputStreamStart('instagram'); } catch (e: unknown) { console.log('Error during Dual Output Go Live Non-Ultra test:', e); - } + t.fail('Error during Dual Output Go Live Non-Ultra test'); + } finally { + // Clean up the dummy account + await showSettingsWindow('Stream', async () => { + await waitForDisplayed('h2=Stream Destinations'); + await clickWhenDisplayed('[data-name="instagramUnlink"]'); + }); - // Clean up the dummy account - await showSettingsWindow('Stream', async () => { - await waitForDisplayed('h2=Stream Destinations'); - await clickWhenDisplayed('[data-name="instagramUnlink"]'); - }); + // Vertical display is hidden after logging out + await logOut(t); + t.false(await isDisplayed('div#vertical-display')); - // Vertical display is hidden after logging out - await logOut(t); - t.false(await isDisplayed('div#vertical-display')); - t.pass(); + t.pass(); + } }); -test( - 'Dual Output Go Live Ultra', - withUser('twitch', { prime: true, multistream: true }), - async (t: TExecutionContext) => { - try { - await toggleDualOutputMode(); - await prepareToGoLive(); - - await clickGoLive(); - await waitForSettingsWindowLoaded(); - await fillForm({ - trovo: true, - }); - await waitForSettingsWindowLoaded(); - await submit(); +test('Dual Output Go Live Ultra', async (t: TExecutionContext) => { + await logIn('twitch', { prime: true, multistream: true }); + await toggleDualOutputMode(); + await prepareToGoLive(); - // Cannot go live in dual output mode with all targets assigned to one display - await waitForDisplayed('div.ant-message-notice-content', { - timeout: 10000, - }); - await clickIfDisplayed('div.ant-message-notice-content'); - await sleep(500); + await clickGoLive(); + await waitForSettingsWindowLoaded(); + try { + const platform = await toggleAccountForDisplay('horizontal'); + if (!platform) { + t.fail('Could not toggle second platform.'); + } - // Dual output with one platform for each display - await fillForm({ - trovoDisplay: 'vertical', - primaryChat: 'Trovo', - }); - await waitForSettingsWindowLoaded(); - await submit(); - await waitForDisplayed('span=Configure the Dual Output service', { timeout: 60000 }); - await waitForStreamStart(); - await isDisplayed('span=Multistream'); - await stopStream(); - await waitForStreamStop(); - - await clickGoLive(); - await waitForSettingsWindowLoaded(); - await fillForm({ - trovoDisplay: 'horizontal', - twitchDisplay: 'vertical', - }); + await submit(); - await waitForSettingsWindowLoaded(); - await submit(); - await waitForDisplayed('span=Configure the Dual Output service', { timeout: 60000 }); - await waitForStreamStart(); - await isDisplayed('span=Multistream'); - await stopStream(); - await waitForStreamStop(); - - // Vertical display is hidden after logging out - await logOut(t); - t.false(await isDisplayed('div#vertical-display')); - } catch (e: unknown) { - console.log('Error during Dual Output Go Live Ultra test:', e); - } + // Cannot go live in dual output mode with all targets assigned to one display + await waitForDisplayed('div.ant-message-notice-content', { + timeout: 10000, + }); + await clickIfDisplayed('div.ant-message-notice-content'); + await sleep(200); + // Dual output with one platform for each display + await fillForm({ + [`${platform}Display`]: 'vertical', + }); + + await waitForDualOutputStreamStart(platform); + + await clickGoLive(); + await waitForSettingsWindowLoaded(); + await fillForm({ + [`${platform}Display`]: 'horizontal', + twitchDisplay: 'vertical', + }); + + await waitForDualOutputStreamStart(platform); + + if (platform === 'instagram') { + // Clean up the dummy account + await showSettingsWindow('Stream', async () => { + await waitForDisplayed('h2=Stream Destinations'); + await clickWhenDisplayed('[data-name="instagramUnlink"]'); + }); + } + } catch (e: unknown) { + console.log('Error during Dual Output Go Live Ultra test:', e); + t.fail('Error during Dual Output Go Live Ultra test'); + } finally { + // Vertical display is hidden after logging out + await logOut(t); + t.false(await isDisplayed('#vertical-display')); t.pass(); - }, -); + } +}); From 6fe61eda2f0e38741958f094fb1d911739062bf5 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:56:39 -0700 Subject: [PATCH 2/6] Enable/disable performance mode with display toggles. (#5826) * Enable/disable performance mode with display toggles. * Fix prettier error. --- app/components-react/sidebar/FeaturesNav.tsx | 209 ++++++++----------- app/services/customization.ts | 4 + app/services/dual-output/dual-output.ts | 40 +++- app/services/transitions.ts | 5 +- test/regular/streaming/dual-output.ts | 8 + 5 files changed, 140 insertions(+), 126 deletions(-) diff --git a/app/components-react/sidebar/FeaturesNav.tsx b/app/components-react/sidebar/FeaturesNav.tsx index d826f653b520..2507b3c7969c 100644 --- a/app/components-react/sidebar/FeaturesNav.tsx +++ b/app/components-react/sidebar/FeaturesNav.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback, memo } from 'react'; import { ENavName, EMenuItemKey, @@ -25,33 +25,36 @@ import Utils from 'services/utils'; import { useRealmObject } from 'components-react/hooks/realm'; export default function FeaturesNav() { - function toggleStudioMode() { + const toggleStudioMode = useCallback(() => { UsageStatisticsService.actions.recordClick('NavTools', 'studio-mode'); if (TransitionsService.views.studioMode) { TransitionsService.actions.disableStudioMode(); } else { TransitionsService.actions.enableStudioMode(); } - } + }, []); - function navigate(page: TAppPage, trackingTarget?: string, type?: TExternalLinkType | string) { - if (!UserService.views.isLoggedIn && !loggedOutMenuItemTargets.includes(page)) return; + const navigate = useCallback( + (page: TAppPage, trackingTarget?: string, type?: TExternalLinkType | string) => { + if (!UserService.views.isLoggedIn && !loggedOutMenuItemTargets.includes(page)) return; - if (trackingTarget) { - // NOTE: For themes, the submenu items are tracked instead of the menu item - // to distinguish between theme feature usage - const target = trackingTarget === 'themes' && type ? type : trackingTarget; - UsageStatisticsService.actions.recordClick('SideNav2', target); - } + if (trackingTarget) { + // NOTE: For themes, the submenu items are tracked instead of the menu item + // to distinguish between theme feature usage + const target = trackingTarget === 'themes' && type ? type : trackingTarget; + UsageStatisticsService.actions.recordClick('SideNav2', target); + } - if (type) { - NavigationService.actions.navigate(page, { type }); - } else { - NavigationService.actions.navigate(page); - } - } + if (type) { + NavigationService.actions.navigate(page, { type }); + } else { + NavigationService.actions.navigate(page); + } + }, + [], + ); - function handleNavigation(menuItem: IMenuItem, key?: string) { + const handleNavigation = useCallback((menuItem: IMenuItem, key?: string) => { if (menuItem.key === EMenuItemKey.StudioMode) { // if studio mode, toggle studio mode toggleStudioMode(); @@ -62,11 +65,11 @@ export default function FeaturesNav() { navigate(menuItem?.target as TAppPage, menuItem?.trackingTarget); } setCurrentMenuItem(key ?? menuItem.key); - } + }, []); - function isParentMenuItem(menuItem: IMenuItem): menuItem is IParentMenuItem { + const isParentMenuItem = useCallback((menuItem: IMenuItem): menuItem is IParentMenuItem => { return menuItem.hasOwnProperty('subMenuItems'); - } + }, []); const { IncrementalRolloutService, @@ -289,113 +292,71 @@ export default function FeaturesNav() { ); } -function FeaturesNavItem(p: { - isSubMenuItem?: boolean; - menuItem: IMenuItem | IParentMenuItem; - handleNavigation: (menuItem: IMenuItem, key?: string) => void; - badge?: string; - className?: string; -}) { - const { SideNavService, TransitionsService, DualOutputService } = Services; - const { isSubMenuItem, menuItem, badge, handleNavigation, className } = p; - - const { currentMenuItem, isOpen, studioMode, dualOutputMode } = useVuex(() => ({ - currentMenuItem: SideNavService.views.currentMenuItem, - isOpen: SideNavService.views.isOpen, - studioMode: TransitionsService.views.studioMode, - dualOutputMode: DualOutputService.views.dualOutputMode, - })); +const FeaturesNavItem = memo( + (p: { + isSubMenuItem?: boolean; + menuItem: IMenuItem | IParentMenuItem; + handleNavigation: (menuItem: IMenuItem, key?: string) => void; + badge?: string; + className?: string; + }) => { + const { SideNavService, TransitionsService, DualOutputService } = Services; + const { isSubMenuItem, menuItem, badge, handleNavigation, className } = p; - function setIcon() { - if (menuItem.key === EMenuItemKey.Highlighter) { - return ; - } else if (menuItem?.icon) { - return ; - } - } + const { currentMenuItem, isOpen, studioMode, dualOutputMode, showBothDisplays } = useVuex( + () => ({ + currentMenuItem: SideNavService.views.currentMenuItem, + isOpen: SideNavService.views.isOpen, + studioMode: TransitionsService.views.studioMode, + dualOutputMode: DualOutputService.views.dualOutputMode, + showBothDisplays: DualOutputService.views.showBothDisplays, + }), + ); - const title = useMemo(() => menuTitles(menuItem.key), [menuItem]); + const title = useMemo(() => menuTitles(menuItem.key), [menuItem]); - const disabled = dualOutputMode && menuItem.key === EMenuItemKey.StudioMode; + const disabled = useMemo(() => { + return ( + (menuItem.key === EMenuItemKey.StudioMode && dualOutputMode) || + (menuItem.key === EMenuItemKey.StudioMode && showBothDisplays) + ); + }, [menuItem, dualOutputMode, showBothDisplays]); - function showErrorMessage() { - message.error({ - content: $t('Cannot toggle Studio Mode in Dual Output Mode.'), - className: styles.toggleError, - }); - } + const handleClick = useCallback(() => { + if (disabled) { + message.error({ + content: $t('Cannot toggle Studio Mode in Dual Output Mode.'), + className: styles.toggleError, + }); + } else { + handleNavigation(menuItem); + } + }, [disabled, handleNavigation, menuItem]); - return ( - { - if (disabled) { - showErrorMessage(); - } else { - handleNavigation(menuItem); - } - }} - > -
- {title} - {badge && ( -
-

{badge}

-
+ return ( + - - ); -} - -// TODO: Replace with font icon once updated font is merged -const HighlighterIcon = () => ( - - - - - - - - - - - - - - - + title={title} + icon={menuItem?.icon ? : undefined} + onClick={handleClick} + > +
+ {title} + {badge && ( +
+

{badge}

+
+ )} +
+ + ); + }, ); diff --git a/app/services/customization.ts b/app/services/customization.ts index 2097bdf9dd51..e00d569f066b 100644 --- a/app/services/customization.ts +++ b/app/services/customization.ts @@ -243,6 +243,10 @@ export class CustomizationService extends Service { return this.state.isDarkTheme; } + get performanceMode() { + return this.state.performanceMode; + } + setUpdateStreamInfoOnLive(update: boolean) { this.setSettings({ updateStreamInfoOnLive: update }); } diff --git a/app/services/dual-output/dual-output.ts b/app/services/dual-output/dual-output.ts index a2857a7d8c35..189e652b1ab7 100644 --- a/app/services/dual-output/dual-output.ts +++ b/app/services/dual-output/dual-output.ts @@ -27,7 +27,8 @@ import invert from 'lodash/invert'; import forEachRight from 'lodash/forEachRight'; import { NotificationsService, ENotificationType } from 'services/notifications'; import { $t } from 'services/i18n'; -import { JsonrpcService } from 'app-services'; +import { JsonrpcService } from 'services/api/jsonrpc'; +import { CustomizationService, CustomizationState } from 'services/customization'; interface IDisplayVideoSettings { horizontal: IVideoInfo; @@ -173,6 +174,10 @@ class DualOutputViews extends ViewHandler { return this.showHorizontalDisplay && this.showVerticalDisplay; } + get hideBothDisplays() { + return !this.showHorizontalDisplay && !this.showVerticalDisplay; + } + get onlyVerticalDisplayActive() { return this.activeDisplays.vertical && !this.activeDisplays.horizontal; } @@ -296,6 +301,7 @@ export class DualOutputService extends PersistentStatefulService(); collectionHandled = new Subject<{ [sceneId: string]: Dictionary } | null>(); dualOutputModeChanged = new Subject(); + displayToggled = new Subject(); get views() { return new DualOutputViews(this.state); @@ -328,6 +335,31 @@ export class DualOutputService extends PersistentStatefulService { + if (this.views.hideBothDisplays && !this.customizationService.performanceMode) { + this.customizationService.actions.togglePerformanceMode(); + } + }); + + /** + * In dual output mode, when toggling off performance mode show both displays + */ + this.customizationService.settingsChanged.subscribe( + (settingsPatch: DeepPartial) => { + if ( + settingsPatch.performanceMode !== null && + settingsPatch.performanceMode === false && + this.views.dualOutputMode + ) { + this.toggleDisplay(true, 'horizontal'); + this.toggleDisplay(true, 'vertical'); + } + }, + ); + /** * Ensures that scene collection loads correctly for dual output * @remark This validates an existing scene collection, or converts a single output scene collection @@ -462,6 +494,10 @@ export class DualOutputService extends PersistentStatefulService { enableStudioMode() { if (this.state.studioMode) return; - if (this.dualOutputService.views.dualOutputMode) { + if ( + this.dualOutputService.views.dualOutputMode || + this.dualOutputService.views.showBothDisplays + ) { this.notificationsService.actions.push({ message: $t('Cannot toggle Studio Mode in Dual Output Mode.'), type: ENotificationType.WARNING, diff --git a/test/regular/streaming/dual-output.ts b/test/regular/streaming/dual-output.ts index c56938852c4b..f38b56439ee9 100644 --- a/test/regular/streaming/dual-output.ts +++ b/test/regular/streaming/dual-output.ts @@ -165,6 +165,7 @@ test('Dual Output', async (t: TExecutionContext) => { await toggleDisplay('vertical', true); t.false(await isDisplayed('#horizontal-display')); t.false(await isDisplayed('#vertical-display')); + t.true(await isDisplayed('div=Disable Performance Mode')); await toggleDisplay('horizontal'); t.true(await isDisplayed('#horizontal-display')); @@ -181,6 +182,7 @@ test('Dual Output', async (t: TExecutionContext) => { await toggleDisplay('horizontal'); t.false(await isDisplayed('#horizontal-display')); t.false(await isDisplayed('#vertical-display')); + t.true(await isDisplayed('div=Disable Performance Mode')); await toggleDisplay('vertical'); t.false(await isDisplayed('#horizontal-display')); @@ -190,6 +192,12 @@ test('Dual Output', async (t: TExecutionContext) => { t.true(await isDisplayed('#horizontal-display')); t.true(await isDisplayed('#vertical-display')); + await toggleDisplay('horizontal'); + await toggleDisplay('vertical'); + await clickWhenDisplayed('div=Disable Performance Mode'); + t.true(await isDisplayed('#horizontal-display')); + t.true(await isDisplayed('#vertical-display')); + await releaseUserInPool(user); t.pass(); From 105cd17ca92953c58ba5925b34ecb45cc13782b2 Mon Sep 17 00:00:00 2001 From: gettinToasty Date: Wed, 8 Apr 2026 15:37:16 -0700 Subject: [PATCH 3/6] Fix loading Ultra themes in old onboarding (#5835) Co-authored-by: gettinToasty --- app/components-react/pages/onboarding/ThemeSelector.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components-react/pages/onboarding/ThemeSelector.tsx b/app/components-react/pages/onboarding/ThemeSelector.tsx index c5446a1bcaf2..12e0b179b359 100644 --- a/app/components-react/pages/onboarding/ThemeSelector.tsx +++ b/app/components-react/pages/onboarding/ThemeSelector.tsx @@ -43,7 +43,12 @@ export function ThemeSelector() { function previewImages(theme: IThemeMetadata) { if (!theme?.data) return []; - return Object.values(theme.data.custom_images).slice(0, 3); + const customImages = Object.values(theme.data.custom_images).slice(0, 3); + if (customImages.length > 0) { + return customImages; + } else { + return theme.data.preview_images; + } } function focusTheme(theme: IThemeMetadata | null) { From 8e6526e96f72f547f4c49f7c849e5fbd7c1e7a46 Mon Sep 17 00:00:00 2001 From: Micheline Wu <69046953+michelinewu@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:07:24 -0700 Subject: [PATCH 4/6] Test fixes: dual output and remove encoder check from multistream tests. (#5839) * Remove default encoders checks in multistream tests. * Dual Output test fixes. --- test/helpers/modules/dual-output.ts | 31 ++++-- test/regular/streaming/dual-output.ts | 153 ++++++++++++-------------- test/regular/streaming/multistream.ts | 4 - 3 files changed, 97 insertions(+), 91 deletions(-) diff --git a/test/helpers/modules/dual-output.ts b/test/helpers/modules/dual-output.ts index 3535781ee247..2a49b0b6d73d 100644 --- a/test/helpers/modules/dual-output.ts +++ b/test/helpers/modules/dual-output.ts @@ -15,9 +15,11 @@ import { fillForm } from './forms'; import { showSettingsWindow } from './settings/settings'; import { chatIsVisible, - stopStream, + clickGoLive, submit, waitForSettingsWindowLoaded, + waitForStreamStart, + stopStream, waitForStreamStop, } from './streaming'; @@ -58,14 +60,27 @@ export async function toggleAccountForDisplay(display: 'horizontal' | 'vertical' for (const platform of platforms) { if (platform === 'instagram') { await addDummyAccount('instagram'); + await clickGoLive(); } - await focusChild(); - await fillForm({ [platform]: true, [`${platform}Display`]: display }); + await fillForm({ [platform]: true }); await waitForSettingsWindowLoaded(); + const formLoaded = await isDisplayed(`div[data-name="${platform}-settings"]`); // If the settings form loads, then an account has successfully been toggled - if (await isDisplayed(`div[data-name="${platform}-settings"]`)) { + if (formLoaded) { + if (platform === 'youtube') { + await fillForm({ + description: 'Test Description', + youtubeDisplay: display, + primaryChat: 'YouTube', + }); + } + + if (platform === 'trovo') { + await fillForm({ trovoGame: 'Doom', trovoDisplay: display, primaryChat: 'Trovo' }); + } + return platform; } } @@ -76,20 +91,22 @@ export async function toggleAccountForDisplay(display: 'horizontal' | 'vertical' return null; } -export async function waitForDualOutputStreamStart(platform: string) { +export async function goLiveWithDualOutput(platform: string) { await waitForSettingsWindowLoaded(); await submit(); await waitForDisplayed('span=Configure the Dual Output service', { timeout: 60000 }); - // Dummy accounts won't go live if (platform === 'instagram') { + // Dummy accounts won't go live await sleep(1000); await chatIsVisible(); await waitForStreamStop(); skipCheckingErrorsInLog(); } else { - await chatIsVisible(true); + await waitForDisplayed("h1=You're live!", { timeout: 60000 }); + await waitForStreamStart(); + await isDisplayed('span=Multistream'); await stopStream(); } } diff --git a/test/regular/streaming/dual-output.ts b/test/regular/streaming/dual-output.ts index f38b56439ee9..d5fc870933ce 100644 --- a/test/regular/streaming/dual-output.ts +++ b/test/regular/streaming/dual-output.ts @@ -5,6 +5,8 @@ import { waitForSettingsWindowLoaded, } from '../../helpers/modules/streaming'; import { + click, + clickButton, clickIfDisplayed, clickWhenDisplayed, closeWindow, @@ -15,10 +17,9 @@ import { } from '../../helpers/modules/core'; import { logIn } from '../../helpers/modules/user'; import { - toggleAccountForDisplay, toggleDisplay, toggleDualOutputMode, - waitForDualOutputStreamStart, + goLiveWithDualOutput, } from '../../helpers/modules/dual-output'; import { skipCheckingErrorsInLog, @@ -158,45 +159,42 @@ test('Dual Output', async (t: TExecutionContext) => { t.true(await isDisplayed('#dual-output-header'), 'Dual output header exists'); // check permutations of toggling on and off the displays - await toggleDisplay('horizontal'); + await toggleDisplay('horizontal', true); t.false(await isDisplayed('#horizontal-display')); - t.true(await isDisplayed('#vertical-display')); + t.true( + await isDisplayed('#vertical-display'), + 'Horizontal display toggled off, vertical display still on', + ); await toggleDisplay('vertical', true); t.false(await isDisplayed('#horizontal-display')); t.false(await isDisplayed('#vertical-display')); - t.true(await isDisplayed('div=Disable Performance Mode')); - - await toggleDisplay('horizontal'); - t.true(await isDisplayed('#horizontal-display')); - t.false(await isDisplayed('#vertical-display')); - - await toggleDisplay('vertical'); - t.true(await isDisplayed('#horizontal-display')); - t.true(await isDisplayed('#vertical-display')); + t.true( + await isDisplayed('div=Disable Performance Mode'), + 'Toggling off both displays by vertical display shows performance mode', + ); - await toggleDisplay('vertical'); + await click('div=Disable Performance Mode'); t.true(await isDisplayed('#horizontal-display')); - t.false(await isDisplayed('#vertical-display')); - - await toggleDisplay('horizontal'); - t.false(await isDisplayed('#horizontal-display')); - t.false(await isDisplayed('#vertical-display')); - t.true(await isDisplayed('div=Disable Performance Mode')); + t.true( + await isDisplayed('#vertical-display'), + 'Clicking performance mode button shows both displays, performance mode off', + ); - await toggleDisplay('vertical'); + await toggleDisplay('horizontal', true); t.false(await isDisplayed('#horizontal-display')); - t.true(await isDisplayed('#vertical-display')); - - await toggleDisplay('horizontal'); - t.true(await isDisplayed('#horizontal-display')); - t.true(await isDisplayed('#vertical-display')); + t.true( + await isDisplayed('#vertical-display'), + 'Horizontal display toggled off, vertical display still on, performance mode off', + ); - await toggleDisplay('horizontal'); - await toggleDisplay('vertical'); + await toggleDisplay('vertical', true); await clickWhenDisplayed('div=Disable Performance Mode'); t.true(await isDisplayed('#horizontal-display')); - t.true(await isDisplayed('#vertical-display')); + t.true( + await isDisplayed('#vertical-display'), + 'Clicking performance mode button shows both displays, performance mode off', + ); await releaseUserInPool(user); @@ -283,7 +281,7 @@ test('Dual Output Go Live Non-Ultra', async t => { streamKey: dummy.streamKey, }); - await waitForDualOutputStreamStart('instagram'); + await goLiveWithDualOutput('instagram'); } catch (e: unknown) { console.log('Error during Dual Output Go Live Non-Ultra test:', e); t.fail('Error during Dual Output Go Live Non-Ultra test'); @@ -292,6 +290,7 @@ test('Dual Output Go Live Non-Ultra', async t => { await showSettingsWindow('Stream', async () => { await waitForDisplayed('h2=Stream Destinations'); await clickWhenDisplayed('[data-name="instagramUnlink"]'); + await clickButton('Close'); }); // Vertical display is hidden after logging out @@ -302,58 +301,52 @@ test('Dual Output Go Live Non-Ultra', async t => { } }); -test('Dual Output Go Live Ultra', async (t: TExecutionContext) => { - await logIn('twitch', { prime: true, multistream: true }); - await toggleDualOutputMode(); - await prepareToGoLive(); - - await clickGoLive(); - await waitForSettingsWindowLoaded(); - try { - const platform = await toggleAccountForDisplay('horizontal'); - if (!platform) { - t.fail('Could not toggle second platform.'); - } - - await submit(); - - // Cannot go live in dual output mode with all targets assigned to one display - await waitForDisplayed('div.ant-message-notice-content', { - timeout: 10000, - }); - await clickIfDisplayed('div.ant-message-notice-content'); - await sleep(200); - - // Dual output with one platform for each display - await fillForm({ - [`${platform}Display`]: 'vertical', - }); - - await waitForDualOutputStreamStart(platform); - - await clickGoLive(); - await waitForSettingsWindowLoaded(); - await fillForm({ - [`${platform}Display`]: 'horizontal', - twitchDisplay: 'vertical', - }); +test( + 'Dual Output Go Live Ultra', + withUser('twitch', { prime: true, multistream: true }), + async (t: TExecutionContext) => { + try { + await toggleDualOutputMode(); + await prepareToGoLive(); + + await clickGoLive(); + await waitForSettingsWindowLoaded(); + await fillForm({ + trovo: true, + }); + await waitForSettingsWindowLoaded(); + await submit(); - await waitForDualOutputStreamStart(platform); + // Cannot go live in dual output mode with all targets assigned to one display + await waitForDisplayed('div.ant-message-notice-content', { + timeout: 10000, + }); + await clickIfDisplayed('div.ant-message-notice-content'); + await sleep(500); - if (platform === 'instagram') { - // Clean up the dummy account - await showSettingsWindow('Stream', async () => { - await waitForDisplayed('h2=Stream Destinations'); - await clickWhenDisplayed('[data-name="instagramUnlink"]'); + // Dual output with one platform for each display + await fillForm({ + trovoDisplay: 'vertical', + }); + await goLiveWithDualOutput('trovo'); + + await clickGoLive(); + await waitForSettingsWindowLoaded(); + await fillForm({ + trovoDisplay: 'horizontal', + twitchDisplay: 'vertical', + primaryChat: 'Trovo', }); + + await goLiveWithDualOutput('trovo'); + } catch (e: unknown) { + console.log('Error during Dual Output Go Live Ultra test:', e); + } finally { + // Vertical display is hidden after logging out + await logOut(t); + t.false(await isDisplayed('div#vertical-display')); } - } catch (e: unknown) { - console.log('Error during Dual Output Go Live Ultra test:', e); - t.fail('Error during Dual Output Go Live Ultra test'); - } finally { - // Vertical display is hidden after logging out - await logOut(t); - t.false(await isDisplayed('#vertical-display')); + t.pass(); - } -}); + }, +); diff --git a/test/regular/streaming/multistream.ts b/test/regular/streaming/multistream.ts index e6ea97722806..a6d3073ff673 100644 --- a/test/regular/streaming/multistream.ts +++ b/test/regular/streaming/multistream.ts @@ -168,8 +168,6 @@ test( await goLiveWithMultistream(); await stopStream(); - await goLiveWithDefaultCodec(); - t.pass(); }, ); @@ -310,7 +308,5 @@ test('Stream Shift', withUser('twitch', { prime: true, multistream: true }), asy // Multistream shift await goLiveWithStreamShift(t, true); - await goLiveWithDefaultCodec(); - t.pass(); }); From c56fd819297b98385dfe85e4900a18f16c3e593c Mon Sep 17 00:00:00 2001 From: Wes Rupert Date: Fri, 10 Apr 2026 07:07:42 -0700 Subject: [PATCH 5/6] chore(ai): add impression metric (#5830) --- app/components-react/pages/AILanding.tsx | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/components-react/pages/AILanding.tsx b/app/components-react/pages/AILanding.tsx index 9013eb1ae36a..496c3279bcbc 100644 --- a/app/components-react/pages/AILanding.tsx +++ b/app/components-react/pages/AILanding.tsx @@ -66,6 +66,14 @@ export default function AILanding() { UsageStatisticsService, VisionService, } = Services; + function trackEvent(type: string, data?: Record) { + UsageStatisticsService.actions.recordAnalyticsEvent('AiFeature', { + type, + source: 'AiLanding', + ...(data ?? {}), + }); + } + const visionActions = VisionService.actions; const visionState = useRealmObject(VisionService.state); @@ -76,17 +84,20 @@ export default function AILanding() { useEffect(() => { let active = true; let processing = false; + void loadProductionApps(); + trackEvent('impression'); + return () => { active = false; }; async function loadProductionApps() { + if (getOS() !== OS.Windows) return; if (processing) return; + processing = true; setIsAgentAppInstalled(false); - - if (getOS() !== OS.Windows) return; await PlatformAppsService.actions.return.loadProductionApps(); // Bail early if the component unmounted while we were loading. @@ -98,14 +109,6 @@ export default function AILanding() { } }, []); - function trackEvent(type: string, data?: Record) { - UsageStatisticsService.actions.recordAnalyticsEvent('AiFeature', { - type, - source: 'AiLanding', - ...(data ?? {}), - }); - } - function onToggleAiClick(isEnabled?: boolean) { const newIsEnabled = isEnabled ?? !enabled; trackEvent('enabled', { enabled: String(newIsEnabled) }); From 65e2ae95d56dd9251f5386aa6530974b00cbb2b9 Mon Sep 17 00:00:00 2001 From: Ava Creeth Date: Wed, 15 Apr 2026 13:31:18 -0700 Subject: [PATCH 6/6] make app sessions persistent (#5846) --- app/services/platform-apps/container-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/platform-apps/container-manager.ts b/app/services/platform-apps/container-manager.ts index 22366445fa55..d82b67303d06 100644 --- a/app/services/platform-apps/container-manager.ts +++ b/app/services/platform-apps/container-manager.ts @@ -313,7 +313,7 @@ export class PlatformContainerManager { */ private getAppPartition(app: ILoadedApp) { const userId = this.userService.platformId; - const partition = `platformApp-${app.id}-${userId}-${app.unpacked}`; + const partition = `persist:platformApp-${app.id}-${userId}-${app.unpacked}`; if (!this.sessionsInitialized[partition]) { const session = remote.session.fromPartition(partition);