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