From 88defd877682addf5e6284cdb5c2ceddfe0bff77 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:51:33 -0700
Subject: [PATCH 01/18] Add highlighter banner dismissable.
---
.../go-live/AiHighlighterToggle.m.less | 8 ++-
.../windows/go-live/AiHighlighterToggle.tsx | 49 +++++++++++++------
app/services/dismissables.ts | 5 +-
3 files changed, 44 insertions(+), 18 deletions(-)
diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.m.less b/app/components-react/windows/go-live/AiHighlighterToggle.m.less
index 5821a52da82e..b90a3921f00e 100644
--- a/app/components-react/windows/go-live/AiHighlighterToggle.m.less
+++ b/app/components-react/windows/go-live/AiHighlighterToggle.m.less
@@ -223,4 +223,10 @@
border-radius: 8px;
}
-// .highlighter-banner___36EFl .ant-switch-handle::before
+.dismissable {
+ display: flex;
+ justify-content: flex-end;
+ width: 100%;
+ padding: 10px;
+ text-decoration: underline;
+}
diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.tsx b/app/components-react/windows/go-live/AiHighlighterToggle.tsx
index bb8ba8bc24df..d1e968f800c8 100644
--- a/app/components-react/windows/go-live/AiHighlighterToggle.tsx
+++ b/app/components-react/windows/go-live/AiHighlighterToggle.tsx
@@ -2,7 +2,6 @@ import { SwitchInput } from 'components-react/shared/inputs/SwitchInput';
import React, { useEffect, useState, memo } from 'react';
import styles from './AiHighlighterToggle.m.less';
import { Services } from 'components-react/service-provider';
-import * as remote from '@electron/remote';
import { useDebounce, useVuex } from 'components-react/hooks';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { Alert, Button } from 'antd';
@@ -15,10 +14,22 @@ import { EAvailableFeatures } from 'services/incremental-rollout';
import { promptAction } from 'components-react/modals';
import InputWrapper from 'components-react/shared/inputs/InputWrapper';
import Translate from 'components-react/shared/Translate';
+import { EDismissable } from 'services/dismissables';
-export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded: boolean }) {
+export default function AiHighlighterToggle({
+ cardIsExpanded,
+ isUpdateMode,
+}: {
+ cardIsExpanded: boolean;
+ isUpdateMode?: boolean;
+}) {
//TODO M: Probably good way to integrate the highlighter in to GoLiveSettings
- const { HighlighterService, StreamingService, IncrementalRolloutService } = Services;
+ const {
+ HighlighterService,
+ StreamingService,
+ IncrementalRolloutService,
+ DismissablesService,
+ } = Services;
const {
useHighlighter,
highlighterVersion,
@@ -26,6 +37,7 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded
isVerticalReplayBuffer,
outputDisplay,
gameName,
+ shouldShow,
} = useVuex(() => {
return {
useHighlighter: HighlighterService.views.useAiHighlighter,
@@ -34,6 +46,7 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded
isVerticalReplayBuffer: StreamingService.views.isVerticalReplayBuffer,
outputDisplay: StreamingService.views.outputDisplay,
gameName: StreamingService.views.gameName,
+ shouldShow: DismissablesService.views.shouldShow(EDismissable.HighlighterBanner),
};
});
@@ -47,8 +60,8 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded
const supportedGame = isGameSupported(gameName);
setGameIsSupported(!!supportedGame);
if (supportedGame) {
- setIsExpanded(true);
setGameConfig(getConfigByGame(supportedGame));
+ if (!isUpdateMode) setIsExpanded(true);
} else {
setGameConfig(null);
}
@@ -83,19 +96,16 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded
}
function getInitialExpandedState() {
- if (gameIsSupported) {
- return true;
- } else {
- if (useHighlighter) {
- return true;
- } else {
- return cardIsExpanded;
- }
- }
+ if (isUpdateMode) return false;
+ if (gameIsSupported) return true;
+ if (useHighlighter) return true;
+ return cardIsExpanded;
}
const initialExpandedState = getInitialExpandedState();
const [isExpanded, setIsExpanded] = useState(initialExpandedState);
+ const showHighlighterBanner = shouldShow || !isUpdateMode;
+
const toggleHighlighter = useDebounce(300, handleToggleHighlighter);
function handleToggleHighlighter() {
@@ -160,7 +170,7 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded
return (
- {gameIsSupported ? (
+ {gameIsSupported && showHighlighterBanner ? (
)}
+ {isUpdateMode && (
+
+ )}
) : (
diff --git a/app/services/dismissables.ts b/app/services/dismissables.ts
index 66db1e18e0c0..da8ec677e0f3 100644
--- a/app/services/dismissables.ts
+++ b/app/services/dismissables.ts
@@ -20,6 +20,7 @@ export enum EDismissable {
TikTokReapply = 'tiktok_reapply',
EnhancedBroadcasting = 'enhanced_broadcasting',
StreamAvatarAutomationsWelcome = 'stream_avatar_automations_welcome',
+ HighlighterBanner = 'highlighter_banner',
}
interface IDismissablesServiceState {
@@ -60,9 +61,7 @@ export class DismissablesService extends PersistentStatefulService
- this.dismiss(EDismissable[key]),
- );
+ Object.values(EDismissable).forEach((key: EDismissable) => this.dismiss(key));
}
/**
From 61cfba9658a5c8bcfa56d8b908353d7ec3c16253 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:53:55 -0700
Subject: [PATCH 02/18] Minor component changes.
---
app/components-react/root/LiveDock.tsx | 6 +++++-
app/components-react/shared/inputs/RadioInput.m.less | 4 ++++
app/components-react/windows/go-live/GoLiveWindow.tsx | 3 +--
3 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx
index 150c2f0470c9..558ef31eb619 100644
--- a/app/components-react/root/LiveDock.tsx
+++ b/app/components-react/root/LiveDock.tsx
@@ -430,7 +430,11 @@ function LiveDock() {
placement="right"
autoAdjustOverflow={false}
>
- ctrl.showEditStreamInfo()} className="icon-edit" />
+ ctrl.showEditStreamInfo()}
+ className="icon-edit"
+ />
)}
{hasLiveDockFeature('view-stream') && isStreaming && (
diff --git a/app/components-react/shared/inputs/RadioInput.m.less b/app/components-react/shared/inputs/RadioInput.m.less
index 1db85791b4ec..16fcb5964aea 100644
--- a/app/components-react/shared/inputs/RadioInput.m.less
+++ b/app/components-react/shared/inputs/RadioInput.m.less
@@ -25,6 +25,10 @@
color: var(--icon-toggle-active);
transition: color 0.3s ease-in-out;
}
+
+ i.disabled {
+ opacity: 0.7;
+ }
}
:global(.ant-radio) {
diff --git a/app/components-react/windows/go-live/GoLiveWindow.tsx b/app/components-react/windows/go-live/GoLiveWindow.tsx
index 42b9b785a0f0..fde048341d00 100644
--- a/app/components-react/windows/go-live/GoLiveWindow.tsx
+++ b/app/components-react/windows/go-live/GoLiveWindow.tsx
@@ -224,9 +224,9 @@ function ModalFooter() {
]);
// When the streaming service detects an active stream on another device, show the prompt
+ // Note: `promptStreamShift` is intentionally left out of the dependency array to avoid multiple prompts on window load
useEffect(() => {
// Prompt the user to switch to Streamlabs Desktop if a stream is detected on another device
-
if (Services.RestreamService.views.streamShiftStatus === 'pending') {
promptStreamShift();
}
@@ -238,7 +238,6 @@ function ModalFooter() {
});
return () => isLive.unsubscribe();
- // Note : `promptStreamShift` is intentionally left out of the dependency array to avoid multiple prompts on window load
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
From 02d27728bddd9db575c2efd26141df8e8e549c59 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:56:01 -0700
Subject: [PATCH 03/18] Disable enhanced broadcasting with live output editing.
---
.../windows/go-live/LiveOutputEditingCard.tsx | 74 +++++++++++
.../windows/go-live/StreamShiftCard.tsx | 115 ++++++++++++++++++
app/services/platforms/twitch.ts | 4 +
3 files changed, 193 insertions(+)
create mode 100644 app/components-react/windows/go-live/LiveOutputEditingCard.tsx
create mode 100644 app/components-react/windows/go-live/StreamShiftCard.tsx
diff --git a/app/components-react/windows/go-live/LiveOutputEditingCard.tsx b/app/components-react/windows/go-live/LiveOutputEditingCard.tsx
new file mode 100644
index 000000000000..ac038072dafd
--- /dev/null
+++ b/app/components-react/windows/go-live/LiveOutputEditingCard.tsx
@@ -0,0 +1,74 @@
+import React, { useCallback, useMemo } from 'react';
+import { useGoLiveSettings } from './useGoLiveSettings';
+import { Services } from 'components-react/service-provider';
+import { SwitcherCard } from './SwitcherCard';
+import UltraIcon from 'components-react/shared/UltraIcon';
+import { $t } from 'services/i18n';
+import styles from './GoLive.m.less';
+
+export default function LiveOutputEditingCard() {
+ const {
+ isLiveOutputEditingEnabled,
+ isLiveOutputEditingDisabled,
+ isPrime,
+ isStreamShiftMode,
+ setLiveOutputEditingEnabled,
+ } = useGoLiveSettings();
+
+ const liveOutputTooltip = useMemo(() => {
+ if (!isPrime) {
+ return $t('Upgrade to Ultra to manage live outputs mid-stream');
+ }
+
+ if (isStreamShiftMode) {
+ return $t('Live Output Editing cannot be used with Stream Shift');
+ }
+
+ return $t('Update your live outputs mid-stream');
+ }, [isPrime, isStreamShiftMode]);
+
+ const tooltipDisabled = useMemo(() => {
+ return isPrime && !isStreamShiftMode;
+ }, [isPrime, isStreamShiftMode]);
+
+ const handleToggleLiveOutputEditing = useCallback(
+ (status?: boolean) => {
+ if (!isPrime) {
+ Services.MagicLinkService.actions.linkToPrime('slobs-live-output-editing', {
+ event: 'LiveOutputEditing',
+ });
+ return;
+ }
+
+ // A disabled card still receives the click, so stop here rather than switching on a feature
+ // that is mutually exclusive with stream shift
+ if (isLiveOutputEditingDisabled) return;
+
+ setLiveOutputEditingEnabled(status ?? !isLiveOutputEditingEnabled);
+ Services.UsageStatisticsService.actions.recordAnalyticsEvent('LiveOutputEditing', {
+ toggle: status ?? !isLiveOutputEditingEnabled,
+ });
+ },
+ [setLiveOutputEditingEnabled, isLiveOutputEditingEnabled, isLiveOutputEditingDisabled],
+ );
+
+ return (
+ handleToggleLiveOutputEditing()}
+ value={isLiveOutputEditingEnabled}
+ title={
+ <>
+ {$t('Live output editing')}
+ {!isPrime && }
+ >
+ }
+ name="liveOutput"
+ description={$t('Manage output destinations mid-stream.')}
+ icon="icon-output"
+ disabled={isLiveOutputEditingDisabled}
+ switchTooltip={liveOutputTooltip}
+ switchTooltipDisabled={tooltipDisabled}
+ iconClassName={!isPrime ? styles.ultraIcon : undefined}
+ />
+ );
+}
diff --git a/app/components-react/windows/go-live/StreamShiftCard.tsx b/app/components-react/windows/go-live/StreamShiftCard.tsx
new file mode 100644
index 000000000000..7699f127b7d3
--- /dev/null
+++ b/app/components-react/windows/go-live/StreamShiftCard.tsx
@@ -0,0 +1,115 @@
+import React, { useCallback, useMemo } from 'react';
+import { useGoLiveSettings } from './useGoLiveSettings';
+import { Services } from 'components-react/service-provider';
+import { SwitcherCard } from './SwitcherCard';
+import UltraIcon from 'components-react/shared/UltraIcon';
+import { $t } from 'services/i18n/i18n';
+import styles from './GoLive.m.less';
+import { shell } from '@electron/remote';
+
+export default function StreamShiftCard() {
+ const { isStreamShiftMode, isPrime, setStreamShift, isStreamShiftDisabled } = useGoLiveSettings();
+
+ const tooltipDisabled = !isStreamShiftDisabled;
+
+ const handleToggleStreamShift = useCallback(
+ (status?: boolean) => {
+ if (!isPrime) {
+ Services.MagicLinkService.actions.linkToPrime('slobs-streamswitcher', {
+ event: 'StreamShift',
+ });
+ return;
+ }
+
+ // A disabled card still receives the click, so stop here rather than switching on a feature
+ // that is mutually exclusive with live output editing
+ if (isStreamShiftDisabled) return;
+
+ setStreamShift(status ?? !isStreamShiftMode);
+ Services.UsageStatisticsService.actions.recordAnalyticsEvent('StreamShift', {
+ toggle: status ?? !isStreamShiftMode,
+ });
+ },
+ [setStreamShift, isStreamShiftMode, isStreamShiftDisabled, isPrime],
+ );
+
+ return (
+ handleToggleStreamShift()}
+ value={isStreamShiftDisabled ? false : isStreamShiftMode}
+ title={
+ <>
+ {$t('Stream Shift')}
+ {!isPrime && }
+ >
+ }
+ name="streamShift"
+ description={$t('Switch between devices while live.')}
+ icon="icon-repeat-2"
+ iconClassName={!isPrime ? styles.ultraIcon : undefined}
+ disabled={isStreamShiftDisabled}
+ switchTooltip={ }
+ switchTooltipDisabled={tooltipDisabled}
+ />
+ );
+}
+
+function StreamShiftTooltip() {
+ const {
+ isPrime,
+ isDualOutputMode,
+ isPatreonEnabled,
+ isStreamShiftMode,
+ isLiveOutputEditingEnabled,
+ showTooltip,
+ } = useGoLiveSettings().extend(module => ({
+ get showTooltip() {
+ if (module.isPatreonEnabled) return true;
+ if (!module.isPrime) return true;
+ if (module.isStreamShiftMode) return false;
+ if (module.isLiveOutputEditingEnabled) return true;
+ if (module.isDualOutputMode) return true;
+ return false;
+ },
+ }));
+
+ const tooltipText = useMemo(() => {
+ if (!isPrime) {
+ return { name: 'non-ultra', text: $t('Upgrade to Ultra to switch streams between devices.') };
+ }
+
+ if (isDualOutputMode) {
+ return { name: 'dual-output', text: $t('Stream Shift cannot be used with Dual Output') };
+ }
+
+ if (isPatreonEnabled) {
+ return { name: 'patreon', text: $t('Stream Shift cannot be used with Patreon') };
+ }
+
+ if (isLiveOutputEditingEnabled) {
+ return {
+ name: 'live-output',
+ text: $t('Stream Shift cannot be used with Live Output Editing'),
+ };
+ }
+
+ return { name: 'default', text: '' };
+ }, [isPrime, isPatreonEnabled, isDualOutputMode, isStreamShiftMode, isLiveOutputEditingEnabled]);
+
+ function handleTooltipClick() {
+ shell.openExternal(
+ 'https://streamlabs.com/content-hub/post/how-to-use-streamlabs-stream-shift',
+ );
+ }
+
+ return showTooltip ? (
+ {tooltipText.text}
+ ) : (
+
+ {$t(
+ 'Stay uninterrupted by switching between devices mid stream. Works between Desktop and Mobile App.',
+ )}
+ {$t('Learn More')}
+
+ );
+}
diff --git a/app/services/platforms/twitch.ts b/app/services/platforms/twitch.ts
index e7308ff03ec7..81aa3b36dda0 100644
--- a/app/services/platforms/twitch.ts
+++ b/app/services/platforms/twitch.ts
@@ -275,6 +275,10 @@ export class TwitchService
} catch (e: unknown) {
console.error('Error setting up dual stream:', e);
}
+ } else if (this.streamingService.views.isLiveOutputEditingEnabled) {
+ // When live output editing is enabled enhanced broadcasting won't work because it
+ // uses restream, which is incompatible with enhanced broadcasting.
+ this.settingsService.setEnhancedBroadcasting(false);
} else {
// Update enhanced broadcasting setting based on go live settings
this.settingsService.setEnhancedBroadcasting(channelInfo.isEnhancedBroadcasting);
From adb2645fb74937483e8f9c04116f140cd9211c70 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:56:34 -0700
Subject: [PATCH 04/18] Update translations files.
---
app/i18n/en-US/highlighter.json | 3 +-
app/i18n/en-US/live-output-editing.json | 42 +++++++++++++++++++++++++
app/i18n/en-US/live-outputs.json | 4 ---
app/i18n/en-US/streaming.json | 1 -
app/i18n/en-US/twitch.json | 3 +-
app/i18n/fallback.ts | 2 +-
6 files changed, 47 insertions(+), 8 deletions(-)
create mode 100644 app/i18n/en-US/live-output-editing.json
delete mode 100644 app/i18n/en-US/live-outputs.json
diff --git a/app/i18n/en-US/highlighter.json b/app/i18n/en-US/highlighter.json
index 8e60affd5198..20af7216fac7 100644
--- a/app/i18n/en-US/highlighter.json
+++ b/app/i18n/en-US/highlighter.json
@@ -227,5 +227,6 @@
"Vertical replay buffer is active. Would you like to stop the replay buffer to enable AI Highlighter?": "Vertical replay buffer is active. Would you like to stop the replay buffer to enable AI Highlighter?",
"All Supported Games": "All Supported Games",
"Deletion info": "Deletion info",
- "At least one clip could not be deleted from your system. Please delete it manually.": "At least one clip could not be deleted from your system. Please delete it manually."
+ "At least one clip could not be deleted from your system. Please delete it manually.": "At least one clip could not be deleted from your system. Please delete it manually.",
+ "Do not ask again": "Do not ask again"
}
diff --git a/app/i18n/en-US/live-output-editing.json b/app/i18n/en-US/live-output-editing.json
new file mode 100644
index 000000000000..1c974038f906
--- /dev/null
+++ b/app/i18n/en-US/live-output-editing.json
@@ -0,0 +1,42 @@
+{
+ "Upgrade to Ultra to manage live outputs mid-stream": "Upgrade to Ultra to manage live outputs mid-stream",
+ "Configure the Live Output Editing service": "Configure the Live Output Editing service",
+ "Update takes up to 10 seconds": "Update takes up to 10 seconds",
+ "Go offline to change orientation, then select a new resolution and go live again": "Go offline to change orientation, then select a new resolution and go live again",
+ "Error updating stream settings. Please check your settings and try again.": "Error updating stream settings. Please check your settings and try again.",
+ "Live Output Editing cannot be used with Stream Shift": "Live Output Editing cannot be used with Stream Shift",
+ "Update your live outputs mid-stream": "Update your live outputs mid-stream",
+ "Update Destinations & Outputs:": "Update Destinations & Outputs:",
+ "Updating": "Updating",
+ "Start streaming to %{target}": "Start streaming to %{target}",
+ "Stop streaming to %{target}": "Stop streaming to %{target}",
+ "Start streaming to Custom Destination": "Start streaming to Custom Destination",
+ "Stop streaming to Custom Destination": "Stop streaming to Custom Destination",
+ "Continue streaming to Custom Destination": "Continue streaming to Custom Destination",
+ "Dual Stream is not available while live output editing is enabled": "Dual Stream is not available while live output editing is enabled",
+ "Manage Stream": "Manage Stream",
+ "Multistream settings are invalid, please check your platforms and destinations and try again": "Multistream settings are invalid, please check your platforms and destinations and try again",
+ "confirm the user has Ultra and confirm the settings for enabled platforms and destinations": "confirm the user has Ultra and confirm the settings for enabled platforms and destinations",
+ "Multistream stream key does not exist": "Multistream stream key does not exist",
+ "there was no Multistream session key, ask the user to end the stream and go live again": "there was no Multistream session key, ask the user to end the stream and go live again",
+ "Stream key missing for %{display} display": "Stream key missing for %{display} display",
+ "Unable to add targets for %{display}": "Unable to add targets for %{display}",
+ "Unable to create targets for %{display}": "Unable to create targets for %{display}",
+ "Unable to fetch user stream key for %{mode}": "Unable to fetch user stream key for %{mode}",
+ "Unable to match %{numTargets} target(s) to remove against the active stream.": "Unable to match %{numTargets} target(s) to remove against the active stream.",
+ "Failed to add the destination to your live stream": "Failed to add the destination to your live stream",
+ "No active Multistream destinations were found for your live stream": "No active Multistream destinations were found for your live stream",
+ "Failed to find the destination to remove on your live stream": "Failed to find the destination to remove on your live stream",
+ "Failed to remove the destination from your live stream": "Failed to remove the destination from your live stream",
+ "Failed to add new targets to the stream": "Failed to add new targets to the stream",
+ "Stream Shift cannot be used with Live Output Editing": "Stream Shift cannot be used with Live Output Editing",
+ "Cannot add targets in live output editing mode because the stream key is missing": "Cannot add targets in live output editing mode because the stream key is missing",
+ "Failed to multistream because Enhanced Broadcasting is enabled": "Failed to multistream because Enhanced Broadcasting is enabled",
+ "Failed to start Multistreaming for one of the displays": "Failed to start Multistreaming for one of the displays",
+ "failed to remove the platform while live, confirm the stream is still active": "failed to remove the platform while live, confirm the stream is still active",
+ "one of the platforms requesting removal does not exist": "one of the platforms requesting removal does not exist",
+ "ask the user to restart the stream and go live again. If in dual output mode, ask the user to stream in single output mode": "ask the user to restart the stream and go live again. If in dual output mode, ask the user to stream in single output mode",
+ "confirm if the user is in dual output mode and which displays are currently streaming": "confirm if the user is in dual output mode and which displays are currently streaming",
+ "confirm the platform settings for the platform, then try updating the stream again": "confirm the platform settings for the platform, then try updating the stream again",
+ "no live destinations so there was nothing to remove. The stream may have already ended on the server": "no live destinations so there was nothing to remove. The stream may have already ended on the server"
+}
diff --git a/app/i18n/en-US/live-outputs.json b/app/i18n/en-US/live-outputs.json
deleted file mode 100644
index a9edb7e04bcf..000000000000
--- a/app/i18n/en-US/live-outputs.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "Upgrade to Ultra to manage live outputs mid-stream.": "Upgrade to Ultra to manage live outputs mid-stream.",
- "Manage Stream": "Manage Stream"
-}
diff --git a/app/i18n/en-US/streaming.json b/app/i18n/en-US/streaming.json
index 0da45b14dbd4..7b84e66c5abe 100644
--- a/app/i18n/en-US/streaming.json
+++ b/app/i18n/en-US/streaming.json
@@ -92,7 +92,6 @@
"Failed to update platform settings": "Failed to update platform settings",
"The Multistream server is temporarily unavailable": "The Multistream server is temporarily unavailable",
"Failed to configure the Multistream server": "Failed to configure the Multistream server",
- "Failed to configure the Multistream server for Enhanced Broadcasting": "Failed to configure the Multistream server for Enhanced Broadcasting",
"disable Enhanced Broadcasting for Twitch and try again": "disable Enhanced Broadcasting for Twitch and try again",
"Failed to configure the Dual Output service": "Failed to configure the Dual Output service",
"The Multistream server is temporarily unavailable for Dual Output": "The Multistream server is temporarily unavailable for Dual Output",
diff --git a/app/i18n/en-US/twitch.json b/app/i18n/en-US/twitch.json
index da6ebe938470..98c64b5f3943 100644
--- a/app/i18n/en-US/twitch.json
+++ b/app/i18n/en-US/twitch.json
@@ -14,5 +14,6 @@
"Import your scenes and sources from Twitch Studio.": "Import your scenes and sources from Twitch Studio.",
"Importing from Twitch Studio is an experimental feature under active development. Some source types are unable to be imported, and not all settings will be carried over.": "Importing from Twitch Studio is an experimental feature under active development. Some source types are unable to be imported, and not all settings will be carried over.",
"Stream Shift Error: Twitch is not live": "Stream Shift Error: Twitch is not live",
- "Importing Your Existing Settings From Twitch Studio": "Importing Your Existing Settings From Twitch Studio"
+ "Importing Your Existing Settings From Twitch Studio": "Importing Your Existing Settings From Twitch Studio",
+ "Enhanced broadcasting is not available for live output editing": "Enhanced broadcasting is not available for live output editing"
}
diff --git a/app/i18n/fallback.ts b/app/i18n/fallback.ts
index 9897319d06a6..731b9d7370c8 100644
--- a/app/i18n/fallback.ts
+++ b/app/i18n/fallback.ts
@@ -22,7 +22,7 @@ const fallbackDictionary = {
...require('./en-US/filters.json'),
...require('./en-US/game-overlay.json'),
...require('./en-US/hotkeys.json'),
- ...require('./en-US/live-outputs.json'),
+ ...require('./en-US/live-output-editing.json'),
...require('./en-US/media-gallery.json'),
...require('./en-US/notifications.json'),
...require('./en-US/onboarding.json'),
From 1db8ae55a7f7edcb70368393c9ffa31f30026166 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:57:41 -0700
Subject: [PATCH 05/18] Add restream errors.
---
.../windows/go-live/GoLiveError.tsx | 25 ++--
app/services/streaming/stream-error.ts | 113 +++++++++++++++++-
2 files changed, 130 insertions(+), 8 deletions(-)
diff --git a/app/components-react/windows/go-live/GoLiveError.tsx b/app/components-react/windows/go-live/GoLiveError.tsx
index af80464edbe7..6cb6b2b513da 100644
--- a/app/components-react/windows/go-live/GoLiveError.tsx
+++ b/app/components-react/windows/go-live/GoLiveError.tsx
@@ -58,6 +58,15 @@ export default function GoLiveError() {
return renderSettingsUpdateError(error);
case 'RESTREAM_DISABLED':
case 'RESTREAM_SETUP_FAILED':
+ case 'RESTREAM_UPDATE_FAILED':
+ case 'RESTREAM_INVALID_CONFIG':
+ case 'RESTREAM_STREAM_KEY_MISSING':
+ case 'RESTREAM_STREAM_KEY_FETCH_FAILED':
+ case 'RESTREAM_DISPLAY_SETUP_FAILED':
+ case 'RESTREAM_ADD_TARGETS_FAILED':
+ case 'RESTREAM_NO_ACTIVE_TARGETS':
+ case 'RESTREAM_REMOVE_TARGET_NOT_FOUND':
+ case 'RESTREAM_REMOVE_TARGETS_FAILED':
return renderRestreamError(error);
case 'DUAL_OUTPUT_RESTREAM_DISABLED':
case 'DUAL_OUTPUT_SETUP_FAILED':
@@ -268,14 +277,16 @@ export default function GoLiveError() {
]
: error.details.split('\n');
+ // Leave the message to `MessageLayout`, which falls back to the error's own message. Each
+ // restream failure has its own error type, so the headline names what actually went wrong
+ // instead of repeating the same generic line for every one of them.
return (
-
+
+
+ {$t(
+ 'Please try again. If the issue persists, you can stream directly to a single platform instead or click the button below to bypass and go live.',
+ )}
+
{`${$t('Issues')}:`}
{details.map((detail: string, index: number) => (
diff --git a/app/services/streaming/stream-error.ts b/app/services/streaming/stream-error.ts
index ff66fb8c61f8..520d51219452 100644
--- a/app/services/streaming/stream-error.ts
+++ b/app/services/streaming/stream-error.ts
@@ -51,9 +51,87 @@ export const errorTypes = {
return $t('Failed to update Multistream platforms and destinations while live');
},
},
+ RESTREAM_INVALID_CONFIG: {
+ get message() {
+ return $t(
+ 'Multistream settings are invalid, please check your platforms and destinations and try again',
+ );
+ },
+ get action() {
+ return $t(
+ 'confirm the user has Ultra and confirm the settings for enabled platforms and destinations',
+ );
+ },
+ },
+ RESTREAM_STREAM_KEY_MISSING: {
+ get message() {
+ return $t('Multistream stream key does not exist');
+ },
+ get action() {
+ return $t(
+ 'there was no Multistream session key, ask the user to end the stream and go live again',
+ );
+ },
+ },
+ RESTREAM_STREAM_KEY_FETCH_FAILED: {
+ get message() {
+ return $t('Cannot add targets in live output editing mode because the stream key is missing');
+ },
+ get action() {
+ return $t(
+ 'ask the user to restart the stream and go live again. If in dual output mode, ask the user to stream in single output mode',
+ );
+ },
+ },
+ RESTREAM_DISPLAY_SETUP_FAILED: {
+ get message() {
+ return $t('Failed to start Multistreaming for one of the displays');
+ },
+ get action() {
+ return $t(
+ 'confirm if the user is in dual output mode and which displays are currently streaming',
+ );
+ },
+ },
+ RESTREAM_ADD_TARGETS_FAILED: {
+ get message() {
+ return $t('Failed to add the destination to your live stream');
+ },
+ get action() {
+ return $t(
+ 'confirm the platform settings for the platform, then try updating the stream again',
+ );
+ },
+ },
+ RESTREAM_NO_ACTIVE_TARGETS: {
+ get message() {
+ return $t('No active Multistream destinations were found for your live stream');
+ },
+ get action() {
+ return $t(
+ 'no live destinations so there was nothing to remove. The stream may have already ended on the server',
+ );
+ },
+ },
+ RESTREAM_REMOVE_TARGET_NOT_FOUND: {
+ get message() {
+ return $t('Failed to find the destination to remove on your live stream');
+ },
+ get action() {
+ return $t('one of the platforms requesting removal does not exist');
+ },
+ },
+ RESTREAM_REMOVE_TARGETS_FAILED: {
+ get message() {
+ return $t('Failed to remove the destination from your live stream');
+ },
+ get action() {
+ return $t('failed to remove the platform while live, confirm the stream is still active');
+ },
+ },
RESTREAM_ENHANCED_BROADCASTING_FAILED: {
get message() {
- return $t('Failed to configure the Multistream server for Enhanced Broadcasting');
+ return $t('Failed to multistream because Enhanced Broadcasting is enabled');
},
get action() {
return $t('disable Enhanced Broadcasting for Twitch and try again');
@@ -573,6 +651,39 @@ export function formatUnknownErrorMessage(
details,
};
}
+export function throwRestreamError(e: unknown, errorType?: TStreamErrorType, message?: string) {
+ console.error('Restream error:', e);
+
+ const error =
+ e instanceof StreamError
+ ? e
+ : {
+ status: 400,
+ statusText:
+ message ?? $t('Failed to update Multistream platforms and destinations while live'),
+ };
+
+ const type = getRestreamErrorType(e, errorType);
+ const details = formatRestreamErrorMessage(e, message);
+
+ throwStreamError(type, error, details);
+}
+
+function getRestreamErrorType(e: unknown, errorType?: TStreamErrorType): TStreamErrorType {
+ if (e instanceof StreamError) {
+ return e.type;
+ }
+
+ return errorType ?? ('RESTREAM_UPDATE_FAILED' as TStreamErrorType);
+}
+
+function formatRestreamErrorMessage(e: unknown, message?: string) {
+ if (e instanceof StreamError) {
+ return e.details ?? e.statusText;
+ }
+
+ return message ?? $t('Failed to update Multistream platforms and destinations while live');
+}
function obsStringErrorAsMessages(info: { error: string; code: number }) {
const error = { message: info.error, code: info.code };
From b5565c62ca558e6c2e31d87e9d28bc4c32f47db4 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 15:58:12 -0700
Subject: [PATCH 06/18] Fix common title handling in go live and edit stream
windows.
---
.../windows/go-live/CommonPlatformFields.tsx | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/app/components-react/windows/go-live/CommonPlatformFields.tsx b/app/components-react/windows/go-live/CommonPlatformFields.tsx
index 061712ce642f..350946d8fec5 100644
--- a/app/components-react/windows/go-live/CommonPlatformFields.tsx
+++ b/app/components-react/windows/go-live/CommonPlatformFields.tsx
@@ -60,6 +60,15 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => {
? view.supports('description', [p.platform as TPlatform])
: view.supports('description');
+ // Only the shared instance can run out of platforms to write to, and only while live, where
+ // `updateCommonFields` skips any platform using its own title. Once every enabled platform has
+ // opted out, editing the shared title changes nothing.
+ const titleDisabled =
+ !p.platform &&
+ view.isMidStreamMode &&
+ view.enabledPlatforms.length > 0 &&
+ !view.platformsWithoutCustomFields.length;
+
const fields = p.value;
const height = useMemo(() => {
@@ -121,7 +130,10 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => {
$t('Title')
)
}
- required={true}
+ // A disabled input cannot be corrected, so it must not be able to fail validation. Each
+ // platform using its own title validates that title in its own section.
+ required={!titleDisabled}
+ disabled={titleDisabled}
max={maxCharacters}
min={minCharacters}
layout={p.layout}
From 816b7fe3bd1a3a44fd97f1b09ed98b5a77927ccb Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:01:50 -0700
Subject: [PATCH 07/18] Prep for custom destination in update checklist.
---
app/services/settings/streaming/stream-settings.ts | 7 +++++++
app/services/streaming/streaming-api.ts | 1 +
2 files changed, 8 insertions(+)
diff --git a/app/services/settings/streaming/stream-settings.ts b/app/services/settings/streaming/stream-settings.ts
index 8236af65f2ba..89c89797adbd 100644
--- a/app/services/settings/streaming/stream-settings.ts
+++ b/app/services/settings/streaming/stream-settings.ts
@@ -42,6 +42,13 @@ export interface ICustomStreamDestination {
dualStream?: boolean;
}
+// Used for uniquely identifying custom destinations
+export type TDestinationId = `${string}/${string}`;
+
+export function getDestinationId(dest: ICustomStreamDestination): TDestinationId {
+ return `${dest.url}/${dest.streamKey}` as TDestinationId;
+}
+
/**
* settings that we keep in the localStorage
*/
diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts
index 51dcdf409280..87749e30918e 100644
--- a/app/services/streaming/streaming-api.ts
+++ b/app/services/streaming/streaming-api.ts
@@ -60,6 +60,7 @@ export interface IStreamInfo {
facebook: TGoLiveChecklistItemState;
twitter: TGoLiveChecklistItemState;
instagram: TGoLiveChecklistItemState;
+ destination: TGoLiveChecklistItemState;
setupMultistream: TGoLiveChecklistItemState;
setupDualOutput: TGoLiveChecklistItemState;
startVideoTransmission: TGoLiveChecklistItemState;
From a0033874b27f4e8c93d2efb73734c479132a5fc7 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:02:41 -0700
Subject: [PATCH 08/18] Twitch form disable enhanced broadcasting with live
output editing.
---
.../platforms/TwitchEditStreamInfo.tsx | 40 ++++++++++++++-----
1 file changed, 29 insertions(+), 11 deletions(-)
diff --git a/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx
index 7857d1f9b363..a2a1ad9109b9 100644
--- a/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx
+++ b/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx
@@ -59,7 +59,9 @@ const TwitchRequiredFields = memo((p: IPlatformComponentParams<'twitch'>) => {
- {p.isAiHighlighterEnabled && }
+ {p.isAiHighlighterEnabled && (
+
+ )}
>
);
});
@@ -75,20 +77,36 @@ const TwitchOptionalFields = memo((p: IPlatformComponentParams<'twitch'>) => {
}, [twSettings?.display]);
const enhancedBroadcastingTooltipText = useMemo(() => {
- return p.isDualOutputMode
- ? $t(
- 'Enhanced broadcasting in dual output mode is only available when streaming to both the horizontal and vertical displays in Twitch',
- )
- : $t(
- 'Enhanced broadcasting automatically optimizes your settings to encode and send multiple video qualities to Twitch. Selecting this option will send basic information about your computer and software setup.',
- );
- }, [p.isDualOutputMode]);
+ if (p.isLiveOutputEditingEnabled) {
+ return $t('Enhanced broadcasting is not available for live output editing');
+ }
+
+ if (p.isDualOutputMode) {
+ return $t(
+ 'Enhanced broadcasting in dual output mode is only available when streaming to both the horizontal and vertical displays in Twitch',
+ );
+ }
+
+ return $t(
+ 'Enhanced broadcasting automatically optimizes your settings to encode and send multiple video qualities to Twitch. Selecting this option will send basic information about your computer and software setup.',
+ );
+ }, [p.isDualOutputMode, p.isLiveOutputEditingEnabled]);
const enhancedBroadcastingEnabled = useMemo(() => {
+ if (p.isLiveOutputEditingEnabled) return false;
if (isDualStream) return true;
if (p.isStreamShiftMode) return false;
return twSettings?.isEnhancedBroadcasting;
- }, [isDualStream, twSettings?.isEnhancedBroadcasting, p.isStreamShiftMode]);
+ }, [
+ isDualStream,
+ twSettings?.isEnhancedBroadcasting,
+ p.isStreamShiftMode,
+ p.isLiveOutputEditingEnabled,
+ ]);
+
+ const disableEnhancedBroadcasting = useMemo(() => {
+ return isDualStream || p.isStreamShiftMode || p.isLiveOutputEditingEnabled || p.isUpdateMode;
+ }, [isDualStream, p.isStreamShiftMode, p.isLiveOutputEditingEnabled, p.isUpdateMode]);
return (
<>
@@ -109,7 +127,7 @@ const TwitchOptionalFields = memo((p: IPlatformComponentParams<'twitch'>) => {
label={$t('Enhanced broadcasting')}
tooltip={enhancedBroadcastingTooltipText}
{...bind.isEnhancedBroadcasting}
- disabled={isDualStream || p.isStreamShiftMode}
+ disabled={disableEnhancedBroadcasting}
value={enhancedBroadcastingEnabled}
tooltipIcon={
Date: Tue, 8 Sep 2026 16:04:04 -0700
Subject: [PATCH 09/18] Prep for go live window changes.
---
.../windows/go-live/GoLive.m.less | 58 ++++++++++++++-----
.../platforms/PlatformSettingsLayout.tsx | 1 +
2 files changed, 44 insertions(+), 15 deletions(-)
diff --git a/app/components-react/windows/go-live/GoLive.m.less b/app/components-react/windows/go-live/GoLive.m.less
index 8c6d97f26e2b..4ff5edd5569f 100644
--- a/app/components-react/windows/go-live/GoLive.m.less
+++ b/app/components-react/windows/go-live/GoLive.m.less
@@ -116,32 +116,19 @@ button.bottom {
text-align: right;
}
-.banner-wrapper {
+.info-banner-wrapper {
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
}
-.banner {
+.info-banner {
margin-right: 5px;
height: 32px !important;
width: unset !important;
}
-.info-banner-wrapper {
- flex: 1;
- display: flex;
- flex-direction: row;
- align-items: flex-start;
-
- :global(.info-banner) {
- margin-right: 5px;
- height: 32px;
- width: unset;
- }
-}
-
.primary-chat {
border: 0px;
padding-bottom: 5px;
@@ -206,6 +193,12 @@ button.bottom {
.confirm-btn {
width: 141.25px;
+
+ && {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
}
.footer-content {
@@ -294,3 +287,38 @@ button.bottom {
flex-direction: row;
align-items: center;
}
+
+.update-btn-tooltip {
+ margin-left: 8px;
+ display: flex;
+}
+
+.spinner {
+ height: 20px;
+ width: 20px;
+}
+
+.update-btn {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ line-height: 1;
+}
+
+.ultra-icon {
+ background: linear-gradient(
+ 123.53deg,
+ #2de8b0 25.56%,
+ #cbe953 60.27%,
+ #ffab48 79.52%,
+ #ff5151 96.69%
+ ) !important;
+ background-clip: text !important;
+ color: transparent !important;
+}
+
+.section-title {
+ margin-top: 15px;
+}
diff --git a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx
index b6c0f0ddea94..4875b00c7656 100644
--- a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx
+++ b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx
@@ -53,4 +53,5 @@ export interface IPlatformComponentParams {
isAiHighlighterEnabled?: boolean;
isStreamShiftMode?: boolean;
isMidStreamMode?: boolean;
+ isLiveOutputEditingEnabled?: boolean;
}
From c791ef2ead672c866a7482502af0493c8cdd9ca4 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:06:24 -0700
Subject: [PATCH 10/18] Go live settings (left col) changes.
---
.../windows/go-live/GoLiveSettings.tsx | 33 ++++++++++++-------
1 file changed, 22 insertions(+), 11 deletions(-)
diff --git a/app/components-react/windows/go-live/GoLiveSettings.tsx b/app/components-react/windows/go-live/GoLiveSettings.tsx
index 8a1be19eb278..7fa9c7afc8c0 100644
--- a/app/components-react/windows/go-live/GoLiveSettings.tsx
+++ b/app/components-react/windows/go-live/GoLiveSettings.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React from 'react';
import styles from './GoLive.m.less';
import Scrollable from 'components-react/shared/Scrollable';
import { useGoLiveSettings } from './useGoLiveSettings';
@@ -14,13 +14,14 @@ import ColorSpaceWarnings from './ColorSpaceWarnings';
import { DestinationSwitchers } from './DestinationSwitchers';
import AddDestinationButton from 'components-react/shared/AddDestinationButton';
import cx from 'classnames';
-import StreamShiftToggle from 'components-react/shared/StreamShiftToggle';
import { CaretDownOutlined } from '@ant-design/icons';
import * as remote from '@electron/remote';
import { inject } from 'slap';
import { VideoEncodingOptimizationService } from 'services/video-encoding-optimizations';
import { MagicLinkService } from 'services/magic-link';
import { SettingsService } from 'services/settings';
+import { EAvailableFeatures, IncrementalRolloutService } from 'services/incremental-rollout';
+import StreamShiftToggle from 'components-react/shared/StreamShiftToggle';
/**
* Renders settings for starting the stream
@@ -39,7 +40,7 @@ export default function GoLiveSettings() {
isPrime,
shouldShowLeftCol,
isStreamShiftDisabled,
- isUpdateMode,
+ canEditLiveOutputs,
addDestination,
showTopAddDestination,
showBottomAddDestination,
@@ -50,6 +51,7 @@ export default function GoLiveSettings() {
videoEncodingOptimizationService: inject(VideoEncodingOptimizationService),
settingsService: inject(SettingsService),
magicLinkService: inject(MagicLinkService),
+ incrementalRolloutService: inject(IncrementalRolloutService),
addDestination() {
this.settingsService.actions.showSettings('Stream');
@@ -69,10 +71,15 @@ export default function GoLiveSettings() {
},
get shouldShowLeftCol() {
- if (module.isUpdateMode) return false;
return module.isStreamShiftMode ? true : module.protectedModeEnabled;
},
+ get canEditLiveOutputs() {
+ return this.incrementalRolloutService.views.featureIsEnabled(
+ EAvailableFeatures.liveOutputEditing,
+ );
+ },
+
async openPlatformSettings() {
try {
const link = await this.magicLinkService.getDashboardMagicLink(
@@ -131,10 +138,15 @@ export default function GoLiveSettings() {
border={false}
disabled={!hasMultiplePlatforms}
/>
-
+
+ {/* STREAM SHIFT TOGGLE */}
+ {/* Remove after feature flag removed */}
+ {!canEditLiveOutputs && (
+
+ )}
@@ -144,8 +156,7 @@ export default function GoLiveSettings() {
@@ -158,7 +169,7 @@ export default function GoLiveSettings() {
{/*PLATFORM SETTINGS*/}
{/*EXTRAS*/}
- {!!canUseOptimizedProfile && !isUpdateMode && (
+ {!!canUseOptimizedProfile && (
From 944007ed62275ef8d4794f21451568669014f37f Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 16:16:40 -0700
Subject: [PATCH 11/18] Update stream display selectors only show live display.
---
.../shared/DisplaySelector.tsx | 37 ++++++++++++++++++-
1 file changed, 36 insertions(+), 1 deletion(-)
diff --git a/app/components-react/shared/DisplaySelector.tsx b/app/components-react/shared/DisplaySelector.tsx
index 800c6ae87ab5..5810a0279441 100644
--- a/app/components-react/shared/DisplaySelector.tsx
+++ b/app/components-react/shared/DisplaySelector.tsx
@@ -25,12 +25,24 @@ export default function DisplaySelector(p: IDisplaySelectorProps) {
canDualStream,
updateCustomDestinationDisplayAndSaveSettings,
updatePlatformDisplayAndSaveSettings,
+ isLiveOutputEditingEnabled,
+ isUpdateMode,
+ isLive,
} = useGoLiveSettings().extend(module => ({
get canDualStream() {
if (!p.platform) return false;
if (module.isLiveOutputEditingEnabled) return false;
return module.getCanDualStream(p.platform);
},
+
+ get isLive(): boolean {
+ return (
+ module.isUpdateMode &&
+ module.isLiveOutputEditingEnabled &&
+ !!module.isTargetLive(p.platform ?? p.index)
+ );
+ },
+
get display(): TDisplayOutput {
const defaultDisplay = p.platform
? module.settings.platforms[p.platform]?.display
@@ -58,6 +70,29 @@ export default function DisplaySelector(p: IDisplaySelectorProps) {
},
];
+ if (isLive) {
+ // A live target cannot change display without restarting its stream, so offer only the
+ // display it is already using and explain how to change it
+ const activeDisplay =
+ defaultDisplays.find(option => option.value === display) ?? defaultDisplays[0];
+
+ return [
+ {
+ ...activeDisplay,
+ disabled: true,
+ tooltip: $t(
+ 'Go offline to change orientation, then select a new resolution and go live again',
+ ),
+ },
+ ];
+ }
+
+ if (isUpdateMode) {
+ // Don't show Dual stream option in the Edit Stream window because it is not compatible with
+ // live output editing, which is the only time the display toggles are shown in the update window
+ return defaultDisplays;
+ }
+
if (canDualStream) {
const tooltip = p?.platform
? $t('Stream both horizontally and vertically to %{platform}', {
@@ -77,7 +112,7 @@ export default function DisplaySelector(p: IDisplaySelectorProps) {
}
return defaultDisplays;
- }, [canDualStream]);
+ }, [canDualStream, isLiveOutputEditingEnabled, isUpdateMode, isLive, display, p.platform]);
const onChange = useCallback(
(val: string) => {
From fd64f074f8118d474c1be6c0c5f8cf7eebca1630 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Tue, 8 Sep 2026 17:50:53 -0700
Subject: [PATCH 12/18] Restoresilent drops from merge.
---
.../root/StartStreamingButton.tsx | 122 +++++-------------
app/components-react/root/StudioFooter.tsx | 20 +++
.../shared/DisplaySelector.tsx | 1 +
app/components-react/shared/Spinner.m.less | 21 +++
app/components-react/shared/Spinner.tsx | 18 ++-
.../shared/inputs/RadioInput.m.less | 9 ++
.../shared/inputs/RadioInput.tsx | 15 ++-
.../go-live/DestinationSwitchers.m.less | 5 +
.../windows/go-live/GameSelector.tsx | 9 ++
.../windows/go-live/GoLive.m.less | 11 ++
.../windows/go-live/GoLiveInfoBanner.tsx | 26 ++++
.../windows/go-live/GoLiveWindow.tsx | 29 +----
.../windows/go-live/SwitcherCard.tsx | 64 +++++++--
app/i18n/en-US/stream-shift.json | 1 +
app/services/diagnostics.ts | 4 +
app/services/platforms/kick.ts | 29 ++++-
app/styles/loader.less | 6 +-
17 files changed, 255 insertions(+), 135 deletions(-)
create mode 100644 app/components-react/windows/go-live/GoLiveInfoBanner.tsx
diff --git a/app/components-react/root/StartStreamingButton.tsx b/app/components-react/root/StartStreamingButton.tsx
index eeca006d2178..4120cc7be4c6 100644
--- a/app/components-react/root/StartStreamingButton.tsx
+++ b/app/components-react/root/StartStreamingButton.tsx
@@ -3,7 +3,7 @@ import cx from 'classnames';
import { EStreamingState } from 'services/streaming';
import { EGlobalSyncStatus } from 'services/media-backup';
import { $t } from 'services/i18n';
-import { useVuex } from '../hooks';
+import { useDebounce, useVuex } from '../hooks';
import { Services } from '../service-provider';
import * as remote from '@electron/remote';
import { TStreamShiftStatus } from 'services/restream';
@@ -11,7 +11,6 @@ import { promptAction } from 'components-react/modals';
import { TSocketEvent } from 'services/websocket';
import { useRealmObject } from 'components-react/hooks/realm';
import debounce from 'lodash/debounce';
-import Utils from 'services/utils';
function StartStreamingButton(p: { disabled?: boolean }) {
const {
@@ -22,7 +21,6 @@ function StartStreamingButton(p: { disabled?: boolean }) {
MediaBackupService,
SourcesService,
RestreamService,
- UsageStatisticsService,
} = Services;
const {
@@ -30,7 +28,6 @@ function StartStreamingButton(p: { disabled?: boolean }) {
delayEnabled,
delaySeconds,
streamShiftStatus,
- streamShiftForceGoLive,
isDualOutputMode,
isLoggedIn,
isPrime,
@@ -42,7 +39,6 @@ function StartStreamingButton(p: { disabled?: boolean }) {
delayEnabled: StreamingService.views.delayEnabled,
delaySeconds: StreamingService.views.delaySeconds,
streamShiftStatus: RestreamService.state.streamShiftStatus,
- streamShiftForceGoLive: RestreamService.state.streamShiftForceGoLive,
isDualOutputMode: StreamingService.views.isDualOutputMode,
isLoggedIn: UserService.isLoggedIn,
isPrime: UserService.state.isPrime,
@@ -79,88 +75,35 @@ function StartStreamingButton(p: { disabled?: boolean }) {
useEffect(() => {
// Check for stream shift status on mount. This will happen on app launch because the main window is always active
if (isPrime && streamingStatus === EStreamingState.Offline) {
- fetchStreamShiftStatus().catch((e: unknown) => {
- console.error('Error fetching stream shift status:', e);
- });
+ checkIsLive();
}
- const streamShiftEvent = StreamingService.streamShiftEvent.subscribe((event: TSocketEvent) => {
- if (streamShiftForceGoLive) return;
- if (event.type !== 'streamSwitchRequest' && event.type !== 'switchActionComplete') {
- return;
- }
-
- const { streamShiftStreamId } = RestreamService.state;
- console.debug('Event ID: ' + event.data.identifier, '\n Stream ID: ' + streamShiftStreamId);
- const isIncomingStream: boolean =
- (streamShiftStreamId && event.data.identifier === streamShiftStreamId) || false;
-
- if (event.type === 'streamSwitchRequest') {
- if (isIncomingStream) {
- // Don't record the request from this device because the other device will record it
- RestreamService.actions.confirmStreamShift('approved');
- } else {
- recordStreamShiftAnalytics('request', event.data.identifier);
- }
- }
-
- if (event.type === 'switchActionComplete') {
- // End the stream on this device if switching the stream to another device
- // Only record analytics if the stream was switched from this device to a different one
- if (!isIncomingStream) {
- Services.RestreamService.actions.endStreamShiftStream(event.data.identifier);
-
- recordStreamShiftAnalytics('complete', event.data.identifier);
- }
-
+ const streamShiftEvent = StreamingService.streamShiftEvent.subscribe(
+ async (event: TSocketEvent) => {
// Notify the user
- const message = formatStreamShiftMessage(isIncomingStream, event.data.identifier);
-
- promptAction({
- title: $t('Stream successfully switched'),
- message,
- btnText: $t('Close'),
- btnType: 'default',
- cancelBtnPosition: 'none',
- });
- }
- });
+ const message = await RestreamService.actions.return.handleStreamShiftEvent(event);
+
+ // An empty message means the handler declined to notify (e.g. a forced go live),
+ // so don't show an alert with an empty body
+ if (event.type === 'switchActionComplete' && message) {
+ promptAction({
+ title: $t('Stream successfully switched'),
+ message,
+ btnText: $t('Close'),
+ btnType: 'default',
+ cancelBtnPosition: 'none',
+ });
+ }
+ },
+ );
return () => {
toggleStreaming.cancel();
+ checkIsLive.cancel();
streamShiftEvent.unsubscribe();
};
}, []);
- const recordStreamShiftAnalytics = useCallback((action: 'request' | 'complete', id: string) => {
- // Prevent recording analytics event in test mode
- if (Utils.isTestMode()) return;
-
- // Note: because the event's stream id is from the device that requested the switch,
- // it is not possible to know what type of device the stream will be switching from.
- // We can only identify the type of device the stream is switching to.
- const remoteDeviceType = /[A-Z]/.test(id) ? 'mobile' : 'desktop';
- const switchType = `desktop-${remoteDeviceType}`;
-
- UsageStatisticsService.recordAnalyticsEvent('StreamShift', {
- stream: switchType,
- action,
- });
- }, []);
-
- const formatStreamShiftMessage = useCallback((isFromOtherDevice: boolean, id: string) => {
- if (isFromOtherDevice) {
- return $t(
- 'Your stream has been switched to Streamlabs Desktop from another device. Enjoy your stream!',
- );
- }
-
- const remoteDeviceType = /[A-Z]/.test(id) ? 'mobile' : 'desktop';
- return remoteDeviceType === 'mobile'
- ? $t('Your stream has been successfully switched to Streamlabs Mobile. Enjoy your stream!')
- : $t('Your stream has been successfully switched to Streamlabs Desktop. Enjoy your stream!');
- }, []);
-
const handleToggleStreaming = useCallback(async () => {
if (StreamingService.isStreaming) {
StreamingService.toggleStreaming();
@@ -220,10 +163,14 @@ function StartStreamingButton(p: { disabled?: boolean }) {
// Wrap the toggleStreaming function in a debounce to prevent multiple rapid clicks
// and also to cancel the action on unmount to prevent memory leaks and state updates on unmounted components
+ // Don't use the useDebounce hook here to maintain stateful callbacks
const toggleStreaming = useMemo(() => debounce(handleToggleStreaming, 500), [
handleToggleStreaming,
]);
+ // Debounce checking for the live status of the stream and enable canceling on unmount
+ const checkIsLive = useDebounce(0, RestreamService.actions.checkIsLive);
+
const getIsRedButton = useMemo(() => {
return streamingStatus !== EStreamingState.Offline && streamShiftStatus !== 'pending';
}, [streamingStatus, streamShiftStatus]);
@@ -236,17 +183,6 @@ function StartStreamingButton(p: { disabled?: boolean }) {
);
}, [p.disabled, streamingStatus, delaySecondsRemaining]);
- const fetchStreamShiftStatus = useCallback(async () => {
- try {
- const isLive = await RestreamService.actions.return.checkIsLive();
- return isLive;
- } catch (e: unknown) {
- console.log('Error checking stream shift status', e);
- setIsLoading(false);
- return false;
- }
- }, []);
-
const shouldShowGoLiveWindow = useCallback(() => {
if (!UserService.isLoggedIn) return false;
const primaryPlatform = UserService.state.auth?.primaryPlatform;
@@ -254,13 +190,17 @@ function StartStreamingButton(p: { disabled?: boolean }) {
if (!primaryPlatform) return false;
+ if (streamShiftStatus === 'pending') {
+ return true;
+ }
+
if (StreamingService.views.isDualOutputMode) {
return true;
}
if (
!!UserService.state.auth?.platforms &&
- StreamingService.views.isMultiplatformMode &&
+ isMultiplatformMode &&
Object.keys(UserService.state.auth?.platforms).length > 1
) {
return true;
@@ -269,14 +209,14 @@ function StartStreamingButton(p: { disabled?: boolean }) {
if (primaryPlatform === 'twitch') {
// For Twitch, we can show the Go Live window even with protected mode off
// This is mainly for legacy reasons.
- return StreamingService.views.isMultiplatformMode || updateStreamInfoOnLive;
+ return isMultiplatformMode || updateStreamInfoOnLive;
} else {
return (
StreamSettingsService.state.protectedModeEnabled &&
StreamSettingsService.isSafeToModifyStreamKey()
);
}
- }, [primaryPlatform, isMultiplatformMode, updateStreamInfoOnLive]);
+ }, [primaryPlatform, isMultiplatformMode, updateStreamInfoOnLive, streamShiftStatus]);
return (
({
streamingStatus: StreamingService.views.streamingStatus,
@@ -46,6 +47,7 @@ function StudioFooterComponent() {
replayBufferEnabled: SettingsService.views.values.Output.RecRB,
replayBufferStatus: StreamingService.views.replayBufferStatus,
isReplayBufferActive: StreamingService.views.isReplayBufferActive,
+ isLiveOutputEditingEnabled: StreamingService.views.isLiveOutputEditingEnabled,
}),
false,
);
@@ -110,6 +112,12 @@ function StudioFooterComponent() {
StreamingService.actions.saveReplay();
}, [replayBufferSaving, replayBufferStopping]);
+ const openEditStream = useCallback(() => {
+ if (streamingStatus === EStreamingState.Live) {
+ StreamingService.actions.showEditStream();
+ }
+ }, [streamingStatus]);
+
const showRecordingModeDisableModal = useCallback(async () => {
const result = await confirmAsync({
title: $t('Enable Live Streaming?'),
@@ -197,6 +205,18 @@ function StudioFooterComponent() {
)}
+ {isLiveOutputEditingEnabled && streamingStatus === EStreamingState.Live && (
+
+
+ {$t('Manage Stream')}
+
+
+ )}
{!recordingModeEnabled && (
diff --git a/app/components-react/shared/DisplaySelector.tsx b/app/components-react/shared/DisplaySelector.tsx
index db0c9c702fc5..5810a0279441 100644
--- a/app/components-react/shared/DisplaySelector.tsx
+++ b/app/components-react/shared/DisplaySelector.tsx
@@ -31,6 +31,7 @@ export default function DisplaySelector(p: IDisplaySelectorProps) {
} = useGoLiveSettings().extend(module => ({
get canDualStream() {
if (!p.platform) return false;
+ if (module.isLiveOutputEditingEnabled) return false;
return module.getCanDualStream(p.platform);
},
diff --git a/app/components-react/shared/Spinner.m.less b/app/components-react/shared/Spinner.m.less
index 91394bf48564..c483a423b3e4 100644
--- a/app/components-react/shared/Spinner.m.less
+++ b/app/components-react/shared/Spinner.m.less
@@ -21,6 +21,27 @@
visibility: visible;
opacity: 1;
}
+
+ &.inline {
+ position: static;
+ display: inline-flex;
+ align-items: center;
+ width: auto;
+ height: auto;
+ background-color: transparent;
+
+ :global(.s-spinner) {
+ width: auto;
+ height: auto;
+ padding: 0;
+ }
+
+ :global(.s-bars) {
+ display: flex;
+ align-items: center;
+ min-width: 0 !important;
+ }
+ }
}
.spinner-relative:extend(.container) {
diff --git a/app/components-react/shared/Spinner.tsx b/app/components-react/shared/Spinner.tsx
index 4051a641cdc0..f376aed3f8d1 100644
--- a/app/components-react/shared/Spinner.tsx
+++ b/app/components-react/shared/Spinner.tsx
@@ -12,9 +12,18 @@ export default function Spinner(
delay?: number;
relative?: boolean;
pageLoader?: boolean;
+ inline?: boolean;
+ width?: string;
+ height?: string;
} & HTMLAttributes
= {},
) {
- const defaultProps = { visible: false, delay: 0, relative: false, pageLoader: false };
+ const defaultProps = {
+ visible: false,
+ delay: 0,
+ relative: false,
+ pageLoader: false,
+ inline: false,
+ };
const p = { ...defaultProps, ...props };
const timeoutRef = useRef(0);
@@ -50,12 +59,17 @@ export default function Spinner(
[css.hasVisibleSpinner]: visibility.isSpinnerVisible,
[css.spinnerRelative]: p.relative,
[css.pageLoader]: p.pageLoader,
+ [css.inline]: p.inline,
});
return (
{visibility.isContainerVisible && (
-
+
)}
diff --git a/app/components-react/shared/inputs/RadioInput.m.less b/app/components-react/shared/inputs/RadioInput.m.less
index 115c8ef8ab04..16fcb5964aea 100644
--- a/app/components-react/shared/inputs/RadioInput.m.less
+++ b/app/components-react/shared/inputs/RadioInput.m.less
@@ -34,6 +34,10 @@
:global(.ant-radio) {
display: none;
}
+
+ i.disabled {
+ opacity: 0.7;
+ }
}
.icon-default:extend(.icon-radio) {
@@ -79,6 +83,11 @@
border-bottom-right-radius: 4px;
}
+ // Must be last to override the above two rules
+ :global(.ant-radio-wrapper):only-child {
+ border-radius: 4px;
+ }
+
:global(.ant-radio-wrapper-checked) {
background-color: var(--button);
diff --git a/app/components-react/shared/inputs/RadioInput.tsx b/app/components-react/shared/inputs/RadioInput.tsx
index aca02707b2eb..09bf35ef56ea 100644
--- a/app/components-react/shared/inputs/RadioInput.tsx
+++ b/app/components-react/shared/inputs/RadioInput.tsx
@@ -13,6 +13,7 @@ export interface ICustomRadioOption {
defaultValue?: string;
icon?: string;
tooltip?: string;
+ disabled?: boolean;
children?: React.ReactNode;
}
@@ -95,14 +96,22 @@ export const RadioInput = InputComponent((p: TRadioInputProps) => {
-
+
) : (
-
+
)
}
/>
diff --git a/app/components-react/windows/go-live/DestinationSwitchers.m.less b/app/components-react/windows/go-live/DestinationSwitchers.m.less
index 79d110e4d485..780a44ac44bc 100644
--- a/app/components-react/windows/go-live/DestinationSwitchers.m.less
+++ b/app/components-react/windows/go-live/DestinationSwitchers.m.less
@@ -60,6 +60,10 @@
overflow: hidden;
width: 100%;
+ &.card-disabled {
+ background-color: var(--card-disabled);
+ }
+
.destination-info {
display: flex;
flex-direction: row;
@@ -174,6 +178,7 @@
:global(div.ant-tooltip-inner) {
box-shadow: 0 2px 16px -4px rgba(211, 211, 211, 0.274), 0 2px 15px 0 rgba(211, 211, 211, 0.32),
0 2px 28px 6px rgba(211, 211, 211, 0.2) !important;
+ white-space: normal;
}
:global(.ant-radio-wrapper:last-child::after) {
display: none;
diff --git a/app/components-react/windows/go-live/GameSelector.tsx b/app/components-react/windows/go-live/GameSelector.tsx
index b7457104618f..5e70596ead1d 100644
--- a/app/components-react/windows/go-live/GameSelector.tsx
+++ b/app/components-react/windows/go-live/GameSelector.tsx
@@ -123,6 +123,15 @@ export default function GameSelector(p: TProps) {
});
}
+ if (isKick) {
+ // Kick's API requires the category id, but this component renders the name,
+ // so the service has to track both
+ Services.KickService.actions.setGameInfo({
+ gameId: game?.value ?? '',
+ gameName: game?.label ?? '',
+ });
+ }
+
if (!game) return;
setGames([game]);
}
diff --git a/app/components-react/windows/go-live/GoLive.m.less b/app/components-react/windows/go-live/GoLive.m.less
index 572d537597db..4ff5edd5569f 100644
--- a/app/components-react/windows/go-live/GoLive.m.less
+++ b/app/components-react/windows/go-live/GoLive.m.less
@@ -52,6 +52,17 @@
.destination-mode {
padding-right: 25px !important;
padding-left: 25px !important;
+
+ // Without a left column this padding is the only gutter, so the children's own right margins
+ // sit on top of it and inset the right edge further than the left. Those margins exist to
+ // separate the two columns, which is not this layout.
+ > *:not(:first-child) {
+ margin-right: 0;
+ }
+
+ .right-column-scroll {
+ margin-right: 0 !important;
+ }
}
.update-mode {
diff --git a/app/components-react/windows/go-live/GoLiveInfoBanner.tsx b/app/components-react/windows/go-live/GoLiveInfoBanner.tsx
new file mode 100644
index 000000000000..e05bb1abe387
--- /dev/null
+++ b/app/components-react/windows/go-live/GoLiveInfoBanner.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import styles from './GoLive.m.less';
+import InfoBanner from 'components-react/shared/InfoBanner';
+import { EDismissable } from 'services/dismissables';
+
+interface IGoLiveInfoBannerProps {
+ message: string | JSX.Element;
+ onClick?: () => void;
+ dismissableKey?: EDismissable;
+}
+
+export function GoLiveInfoBanner(p: IGoLiveInfoBannerProps) {
+ return (
+
+
+
+ );
+}
+
+export default GoLiveInfoBanner;
diff --git a/app/components-react/windows/go-live/GoLiveWindow.tsx b/app/components-react/windows/go-live/GoLiveWindow.tsx
index a71087adf57b..fde048341d00 100644
--- a/app/components-react/windows/go-live/GoLiveWindow.tsx
+++ b/app/components-react/windows/go-live/GoLiveWindow.tsx
@@ -66,9 +66,7 @@ function ModalFooter() {
isPrime,
isStreamShiftMode,
hasIncompatibleCodec,
- streamShiftStatus,
codec,
- checkIsLive,
forceStreamShiftGoLive,
goLiveWithDefaultCodec,
showSettings,
@@ -95,10 +93,6 @@ function ModalFooter() {
return module.streamShiftStatus;
},
- async checkIsLive() {
- return this.restreamService.actions.return.checkIsLive();
- },
-
async forceStreamShiftGoLive() {
this.restreamService.actions.forceStreamShiftGoLive();
},
@@ -146,14 +140,6 @@ function ModalFooter() {
const [isCoolingDown, setIsCoolingDown] = useState(false);
const isStreamShiftPromptShown = useRef(false);
- // Check stream shift status on mount for Prime users
- useEffect(() => {
- if (!isPrime) return;
- checkIsLive().catch((e: unknown) => {
- console.error('Error checking stream shift status on mount:', e);
- });
- }, []);
-
const promptUseDefaultCodec = useCallback(async () => {
// If the user is not live but has an incompatible codec, prompt to change codec
let message = $t(
@@ -193,15 +179,6 @@ function ModalFooter() {
});
}, [isStreamShiftMode, isDualOutputMode, codec, goLiveWithDefaultCodec, showSettings]);
- const startStreamShift = useCallback(() => {
- if (isDualOutputMode) {
- Services.DualOutputService.actions.toggleDisplay(false, 'vertical');
- }
-
- setStreamShift(true);
- goLive();
- }, [isDualOutputMode, goLive, setStreamShift]);
-
const promptStreamShift = useCallback(async () => {
isStreamShiftPromptShown.current = true;
await promptAction({
@@ -215,7 +192,8 @@ function ModalFooter() {
if (hasIncompatibleCodec) {
promptUseDefaultCodec();
} else {
- startStreamShift();
+ setStreamShift(true);
+ goLive();
close();
}
},
@@ -236,8 +214,9 @@ function ModalFooter() {
maskClosable: false,
});
}, [
+ isStreamShiftPromptShown,
hasIncompatibleCodec,
- startStreamShift,
+ setStreamShift,
close,
forceStreamShiftGoLive,
promptUseDefaultCodec,
diff --git a/app/components-react/windows/go-live/SwitcherCard.tsx b/app/components-react/windows/go-live/SwitcherCard.tsx
index 56e8f58f6a55..ea22274732a6 100644
--- a/app/components-react/windows/go-live/SwitcherCard.tsx
+++ b/app/components-react/windows/go-live/SwitcherCard.tsx
@@ -28,11 +28,15 @@ interface ISwitcherCardProps {
description: string;
value: boolean;
onClick: (e: MouseEvent) => boolean | void | unknown;
- tooltip?: string;
+ tooltip?: string | ReactNode;
tooltipDisabled?: boolean;
+ switchTooltip?: string | ReactNode;
+ switchTooltipDisabled?: boolean;
className?: string;
switchClassName?: string;
tooltipClassName?: string;
+ switchTooltipClassName?: string;
+ iconClassName?: string;
disabled?: boolean;
switchDisabled?: boolean;
}
@@ -40,6 +44,7 @@ interface ISwitcherCardProps {
interface ISwitcherCardContentsProps {
className?: string;
switchClassName?: string;
+ iconClassName?: string;
onClick: (e: MouseEvent) => void;
onTransitionEnd: (e: React.TransitionEvent) => void;
value: boolean;
@@ -51,6 +56,9 @@ interface ISwitcherCardContentsProps {
description: string;
children?: ReactNode;
switchDisabled?: boolean;
+ switchTooltip?: string | ReactNode;
+ switchTooltipDisabled?: boolean;
+ switchTooltipClassName?: string;
}
/**
@@ -128,7 +136,11 @@ export const SwitcherCard = forwardRef(
label={p.label}
title={p.title}
icon={p.icon}
+ iconClassName={p.iconClassName}
description={p.description}
+ switchTooltip={p.switchTooltip}
+ switchTooltipDisabled={p.switchTooltipDisabled}
+ switchTooltipClassName={p.switchTooltipClassName}
>
{p.children}
@@ -138,25 +150,53 @@ export const SwitcherCard = forwardRef(
function SwitcherCardContents(p: ISwitcherCardContentsProps) {
return (
-
+
-
+ {p.switchTooltip ? (
+
+
+
+ ) : (
+
+ )}
{/* PLATFORM LOGO AND NAME*/}
{typeof p.icon === 'string' ? (
-
+
) : (
p.icon
)}
diff --git a/app/i18n/en-US/stream-shift.json b/app/i18n/en-US/stream-shift.json
index c8f15c92dca4..3b3800e55571 100644
--- a/app/i18n/en-US/stream-shift.json
+++ b/app/i18n/en-US/stream-shift.json
@@ -11,5 +11,6 @@
"Switch to Streamlabs Desktop": "Switch to Streamlabs Desktop",
"Upgrade to Ultra to switch streams between devices.": "Upgrade to Ultra to switch streams between devices.",
"Force Start": "Force Start",
+ "Switch Stream": "Switch Stream",
"A stream on another device has been detected. Would you like to switch your stream to Streamlabs Desktop? If you do not wish to continue this stream, please end it from the current streaming source. If you're sure you're not live and it has been incorrectly detected, choose \"Force Start\" below.": "A stream on another device has been detected. Would you like to switch your stream to Streamlabs Desktop? If you do not wish to continue this stream, please end it from the current streaming source. If you're sure you're not live and it has been incorrectly detected, choose \"Force Start\" below."
}
diff --git a/app/services/diagnostics.ts b/app/services/diagnostics.ts
index 16491afd9020..d6fd45e0179b 100644
--- a/app/services/diagnostics.ts
+++ b/app/services/diagnostics.ts
@@ -193,6 +193,10 @@ export class DiagnosticsService extends PersistentStatefulService
4;
}
+ get lastStream(): IStreamDiagnosticInfo | undefined {
+ return this.state.streams[this.state.streams.length - 1];
+ }
+
static defaultState: IDiagnosticsServiceState = {
streams: [],
};
diff --git a/app/services/platforms/kick.ts b/app/services/platforms/kick.ts
index 1a8eae6a67a9..991f84793eef 100644
--- a/app/services/platforms/kick.ts
+++ b/app/services/platforms/kick.ts
@@ -84,6 +84,7 @@ interface IKickUpdateStreamResponse {
interface IKickStartStreamSettings {
title: string;
game: string;
+ gameName?: string;
video?: IVideo;
mode?: TOutputOrientation;
}
@@ -91,6 +92,7 @@ interface IKickStartStreamSettings {
export interface IKickStartStreamOptions {
title: string;
game: string;
+ gameName?: string;
}
interface IKickRequestHeaders extends Dictionary {
@@ -109,6 +111,7 @@ export class KickService
title: '',
mode: 'landscape',
game: '',
+ gameName: '',
},
ingest: '',
chatUrl: '',
@@ -421,13 +424,25 @@ export class KickService
* show live approval status.
*/
async searchGames(searchString: string): Promise {
+ if (!searchString || searchString === '') {
+ console.debug('Kick search string is empty.');
+ return [] as IGame[];
+ }
+
const host = this.hostsService.streamlabs;
- const url = `https://${host}/api/v5/slobs/kick/info?category=${searchString}`;
+ const params = new URLSearchParams({ category: searchString });
+ const url = `https://${host}/api/v5/slobs/kick/info?${params.toString()}`;
const headers = authorizedHeaders(this.userService.apiToken);
const request = new Request(url, { headers });
return jfetch(request)
.then(async res => {
+ // To prevent errors when the response is not valid return an empty array
+ if (typeof res !== 'object' || res === null) {
+ console.error('Received a non-JSON response fetching Kick categories info.');
+ return [] as IGame[];
+ }
+
const data = res as IKickStreamInfoResponse;
if (data.categories && data.categories.length > 0) {
@@ -452,9 +467,18 @@ export class KickService
}
async fetchGame(name: string): Promise {
+ // Don't attempt to search for an empty game name
+ // Note: on app start, there will not be a game selected yet
+ if (!name || name === '') return Promise.resolve({ id: '', name: '', image: '' } as IGame);
+
return (await this.searchGames(name))[0];
}
+ setGameInfo({ gameId, gameName }: { gameId: string; gameName: string }) {
+ this.UPDATE_STREAM_SETTINGS({ game: gameId, gameName });
+ this.SET_GAME_NAME(gameName);
+ }
+
/**
* prepopulate channel info and save it to the store
*/
@@ -593,5 +617,8 @@ export class KickService
@mutation()
SET_GAME_NAME(gameName: string) {
this.state.gameName = gameName;
+ // also mirror into settings so it survives into savedSettings, which clones
+ // state.settings rather than reading the top-level state
+ this.state.settings = { ...this.state.settings, gameName };
}
}
diff --git a/app/styles/loader.less b/app/styles/loader.less
index cd16b3a67a44..67ad7a1d10b9 100644
--- a/app/styles/loader.less
+++ b/app/styles/loader.less
@@ -1,4 +1,4 @@
-@import "./mixins.less";
+@import './mixins.less';
.s-loader__bg {
position: relative;
@@ -51,6 +51,10 @@
height: 80px;
width: 56px;
}
+.s-spinner--small {
+ height: 15px;
+ width: 15px;
+}
.s-spinner__bar {
fill: var(--title);
}
From 2646eca7d054d920e9854afac9e9467777f0748e Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:02:08 -0700
Subject: [PATCH 13/18] Service changes and update call error handling.
---
app/services/restream.ts | 563 ++++++++++++++----
app/services/streaming/streaming-view.ts | 183 +++++-
app/services/streaming/streaming.ts | 695 ++++++++++++++++++-----
3 files changed, 1175 insertions(+), 266 deletions(-)
diff --git a/app/services/restream.ts b/app/services/restream.ts
index 8ecca8f7a565..97051e2d3591 100644
--- a/app/services/restream.ts
+++ b/app/services/restream.ts
@@ -11,7 +11,7 @@ import {
} from 'services/customization';
import { authorizedHeaders, jfetch } from 'util/requests';
import electron from 'electron';
-import { StreamingService } from './streaming';
+import { StreamingService, EStreamingState } from './streaming';
import { FacebookService } from './platforms/facebook';
import { TikTokService } from './platforms/tiktok';
import { KickService } from './platforms/kick';
@@ -23,13 +23,16 @@ import { InstagramService } from './platforms/instagram';
import { PlatformAppsService } from './platform-apps';
import { DualOutputService } from 'services/dual-output';
import { SettingsService } from 'services/settings';
-import { throwStreamError } from './streaming/stream-error';
+import { UsageStatisticsService } from 'services/usage-statistics';
+import { DiagnosticsService } from './diagnostics';
+import { StreamError, throwRestreamError } from './streaming/stream-error';
import { Subject } from 'rxjs';
import uuid from 'uuid';
import Utils from './utils';
import { $t } from './i18n';
import { RealmObject } from './realm';
import { ObjectSchema } from 'realm';
+import { TSocketEvent } from './websocket';
interface IIngestServer {
name: string;
@@ -160,6 +163,8 @@ export class RestreamService extends StatefulService {
@Inject() platformAppsService: PlatformAppsService;
@Inject() dualOutputService: DualOutputService;
@Inject() settingsService: SettingsService;
+ @Inject() usageStatisticsService: UsageStatisticsService;
+ @Inject() diagnosticsService: DiagnosticsService;
settings: IUserSettingsResponse;
@@ -367,9 +372,10 @@ export class RestreamService extends StatefulService {
new Headers({ 'Content-Type': 'application/json' }),
);
const url = `https://${this.host}/api/v1/rst/targets/runtime`;
+ const body = JSON.stringify({ streamKey, targets });
const request = new Request(url, {
headers,
- body: JSON.stringify({ streamKey, targets }),
+ body,
method: 'POST',
});
@@ -417,7 +423,9 @@ export class RestreamService extends StatefulService {
// Returning one only defers the failure to the runtime endpoint, which rejects the request
// with an error that does not say which display was missing a key.
if (!sessionKey) {
- throwStreamError('RESTREAM_UPDATE_FAILED', {}, `No stream key for ${orientation}.`);
+ const display = orientation === 'landscape' ? 'horizontal' : 'vertical';
+ const details = $t('Stream key missing for %{display} display', { display });
+ throwRestreamError({}, 'RESTREAM_STREAM_KEY_MISSING', details);
}
console.error(
@@ -497,11 +505,13 @@ export class RestreamService extends StatefulService {
streamKey = await this.resolveStreamKey(mode);
} catch (e: unknown) {
console.error('Restream Error: Unable to fetch user stream key for', mode, e);
- throwStreamError(
- 'RESTREAM_UPDATE_FAILED',
- e,
- `Unable to fetch user stream key for ${mode}.`,
- );
+
+ const details =
+ e instanceof StreamError
+ ? e.details
+ : $t('Unable to fetch user stream key for %{mode}', { mode });
+
+ throwRestreamError(e, 'RESTREAM_STREAM_KEY_FETCH_FAILED', details);
}
if (displaysToSetup.includes(display)) {
@@ -513,8 +523,12 @@ export class RestreamService extends StatefulService {
try {
await this.setupDisplayTargets(platforms, customDestinations, display);
} catch (e: unknown) {
- console.error('Restream Error: Unable to create targets for', display, e);
- throwStreamError('RESTREAM_UPDATE_FAILED', e, `Unable to create targets for ${display}.`);
+ const details =
+ e instanceof StreamError
+ ? e.details
+ : $t('Unable to create targets for %{display}', { display });
+
+ throwRestreamError(e, 'RESTREAM_DISPLAY_SETUP_FAILED', details);
}
} else {
// This display already has a running restream session, so add the targets to it.
@@ -523,7 +537,12 @@ export class RestreamService extends StatefulService {
await this.addRuntimeTargets(streamKey, targetsByMode[mode] as IRestreamRuntimeTarget[]);
} catch (e: unknown) {
console.error('Restream Error: Unable to add targets for', display, e);
- throwStreamError('RESTREAM_UPDATE_FAILED', e, `Unable to add targets for ${display}.`);
+
+ throwRestreamError(
+ e,
+ 'RESTREAM_ADD_TARGETS_FAILED',
+ $t('Unable to add targets for %{display}', { display }),
+ );
}
}
}
@@ -549,7 +568,7 @@ export class RestreamService extends StatefulService {
if (!remoteTargets.length) {
console.debug('No active restream targets.');
- throwStreamError('RESTREAM_UPDATE_FAILED', {}, 'No active restream targets.');
+ throwRestreamError({}, 'RESTREAM_NO_ACTIVE_TARGETS', 'No active restream targets.');
}
// Match the targets to remove against the remote targets by stream key. When removing all
@@ -563,6 +582,24 @@ export class RestreamService extends StatefulService {
),
]);
+ // Every requested key must correspond to a live target. The keys are re-derived from platform
+ // state here rather than recorded when the target was created, so a key that has since changed
+ // matches nothing, and without this the filter below would quietly drop it, no request would
+ // be sent, and the caller would report the target as removed while it is still streaming.
+ if (streamKeysToRemove) {
+ const liveStreamKeys = new Set(remoteTargets.map(target => target.streamKey));
+ const unmatched = [...streamKeysToRemove].filter(key => !liveStreamKeys.has(key));
+
+ if (unmatched.length) {
+ console.error('Restream Error: No live restream target matches', unmatched);
+ const details = $t(
+ 'Unable to match %{numTargets} target(s) to remove against the active stream.',
+ { numTargets: unmatched.length },
+ );
+ throwRestreamError({}, 'RESTREAM_REMOVE_TARGET_NOT_FOUND', details);
+ }
+ }
+
// Group by the mode reported by the server. It is the only reliable record of which stream a
// target is running on, the locally derived mode can be stale.
const targetsByMode = this.filterRemoveTargetsByMode(remoteTargets, streamKeysToRemove);
@@ -571,17 +608,52 @@ export class RestreamService extends StatefulService {
const stopTargets = targetsByMode[mode];
if (!stopTargets.length) continue;
+ const streamKey = await this.resolveStreamKey(mode);
+
try {
// Fetch the key for this mode rather than deriving it, the same way `addTargets` does, so
// that targets are removed from the stream they were added to
- await this.removeRuntimeTargets(await this.resolveStreamKey(mode), stopTargets);
+ await this.removeRuntimeTargets(streamKey, stopTargets);
} catch (e: unknown) {
- console.error('Restream Error: Error removing restream targets for', mode, e);
- throwStreamError('RESTREAM_UPDATE_FAILED', e, `Unable to remove targets for ${mode}.`);
+ const display = mode === 'landscape' ? 'horizontal' : 'vertical';
+ throwRestreamError(
+ e,
+ 'RESTREAM_REMOVE_TARGETS_FAILED',
+ `Unable to remove targets for ${display}.`,
+ );
}
}
}
+ /**
+ * Determine which of the given targets are actually streaming
+ * @remark Used to reconcile the Go Live settings after a runtime target update fails. Adding and
+ * removing targets is done one display at a time, so an update can fail partway with some
+ * targets already changed. The server's target list is the only record of what really happened,
+ * which is why this compares against it rather than rolling back the attempted change.
+ * Targets are matched by stream key, not platform, because relayed platforms are all reported by
+ * the server as `relay`.
+ * @param platforms - The platforms to check
+ * @param customDestinations - The custom destinations to check
+ * @returns The subset of each that the server currently has a target for
+ */
+ async getLiveTargets(
+ platforms: TPlatform[],
+ customDestinations: ICustomStreamDestination[],
+ ): Promise<{ platforms: TPlatform[]; customDestinations: ICustomStreamDestination[] }> {
+ const remoteTargets: IRestreamTarget[] = await this.fetchTargets();
+ const liveStreamKeys = new Set(remoteTargets.map(target => target.streamKey));
+
+ return {
+ platforms: platforms.filter(platform =>
+ liveStreamKeys.has(this.formatRuntimePlatformData(platform).streamKey),
+ ),
+ customDestinations: customDestinations.filter(dest =>
+ liveStreamKeys.has(this.formatRuntimeCustomDestinationData(dest).streamKey),
+ ),
+ };
+ }
+
/**
* Filter targets by their mode (landscape or portrait)
* @remark Used for updating a stream while live. Needed for dual output mode to separate targets
@@ -596,7 +668,8 @@ export class RestreamService extends StatefulService {
): { landscape: TRestreamTarget[]; portrait: TRestreamTarget[] } {
return targets.reduce(
(acc, target) => {
- const mode = target.mode === 'portrait' ? 'portrait' : 'landscape';
+ const mode =
+ this.streamInfo.isDualOutputMode && target.mode === 'portrait' ? 'portrait' : 'landscape';
acc[mode].push(target);
return acc;
},
@@ -624,9 +697,12 @@ export class RestreamService extends StatefulService {
(acc: Record, target) => {
if (streamKeysToRemove && !streamKeysToRemove.has(target.streamKey)) return acc;
- const mode: TOutputOrientation = /^(portrait|landscape)$/.test(target.mode ?? '')
- ? (target.mode as TOutputOrientation)
- : 'landscape';
+ // Only trust a reported `portrait` in dual output mode, the same way `filterAddTargetsByMode`
+ // does. Outside it there is no portrait stream to remove from, so a stale `portrait` on the
+ // server would resolve a portrait stream key and send the removal to a stream that is not
+ // running, leaving the target live.
+ const mode: TOutputOrientation =
+ this.streamInfo.isDualOutputMode && target.mode === 'portrait' ? 'portrait' : 'landscape';
acc[mode].push({ id: target.id });
return acc;
},
@@ -634,6 +710,90 @@ export class RestreamService extends StatefulService {
);
}
+ /**
+ * Derive the stream key for an orientation from the landscape (default) stream key
+ * @remark Only use this when the key was fetched without a `mode`. If the key was fetched
+ * with `fetchUserSettings(mode)` it is already resolved for that orientation and applying
+ * this again would transform it a second time.
+ * TODO: This is an unverified assumption about the shape of the backend's stream keys.
+ * Replace it with `fetchUserSettings('portrait').streamKey` once the stream shift flow,
+ * which depends on the modeless key, can also fetch per-mode keys.
+ * @param streamKey - The landscape stream key for the restream session
+ * @param orientation - The orientation to resolve the key for
+ */
+ private async getModeStreamKey(
+ orientation: TOutputOrientation,
+ streamKey?: string,
+ ): Promise {
+ const key = streamKey ?? (await this.fetchUserSettings(orientation).then(s => s.streamKey));
+
+ return this.formatOrientationKey(key, orientation);
+ }
+
+ /**
+ * Update targets in the restream session and handle errors
+ * @remark This is a wrapper that handles any errors that occur when updating. Passing all update calls through
+ * a single function simplifies error handling, which makes debugging easier.
+ * @param targets - The updated targets for the stream, should already have data correctly formatted
+ * @param streamKey - The stream key for the restream session, already resolved for `orientation`
+ * (see `getModeStreamKey`)
+ * @param orientation - The display to apply the updates to, defaults to landscape. In dual output mode,
+ * under the hood there are two separate streams, one for each display, so the targets need to be updated
+ * for each display separately.
+ */
+ async updateTargetsAndValidate(
+ targets: TRestreamTarget[],
+ streamKey: string,
+ orientation: TOutputOrientation = 'landscape',
+ ) {
+ if (!targets.length) return;
+
+ try {
+ await this.addRuntimeTargets(streamKey, targets as IRestreamRuntimeTarget[]);
+ } catch (e: unknown) {
+ console.error('Restream Error: Error updating restream targets for', orientation, e);
+ throwRestreamError(
+ e,
+ 'RESTREAM_ADD_TARGETS_FAILED',
+ `Unable to update targets for ${orientation}.`,
+ );
+ }
+ }
+
+ /**
+ * Filter targets by their mode (landscape or portrait)
+ * @remark Used for updating a stream while live. Needed for dual output mode to separate targets
+ * for each display so that each stream is updated correctly. In dual output mode, under the hood
+ * there are two separate streams, one for each display, so the targets need to be updated for each
+ * display separately.
+ * @param targets - The targets in the stream
+ * @returns An object containing the targets grouped by their mode (landscape or portrait)
+ */
+ filterTargetsByMode(targets: TRestreamTarget[]) {
+ return targets.reduce(
+ (acc, target) => {
+ if (target.mode === 'landscape') {
+ acc.landscape.push(target);
+ } else if (target.mode === 'portrait') {
+ acc.portrait.push(target);
+ }
+ return acc;
+ },
+ {
+ landscape: [] as TRestreamTarget[],
+ portrait: [] as TRestreamTarget[],
+ },
+ );
+ }
+
+ getActiveModes(targets: TRestreamTarget[]) {
+ const targetsByMode = this.filterTargetsByMode(targets);
+ const modes: TOutputOrientation[] = [];
+ if (targetsByMode.landscape.length > 0) modes.push('landscape');
+ if (targetsByMode.portrait.length > 0) modes.push('portrait');
+ return modes;
+ }
+
/**
* Type guard for platforms
* @param target - The target to check
@@ -724,8 +884,11 @@ export class RestreamService extends StatefulService {
async beforeGoLive() {
if (!this.streamInfo.getIsValidRestreamConfig()) {
- console.log('Invalid restream config, cannot go live with restream');
- throwStreamError('RESTREAM_SETUP_FAILED');
+ throwRestreamError(
+ {},
+ 'RESTREAM_INVALID_CONFIG',
+ 'Invalid restream config, cannot go live with restream',
+ );
}
const shouldSwitchStreams = this.state.streamShiftTargets.length > 0;
@@ -746,59 +909,50 @@ export class RestreamService extends StatefulService {
* @param context - Optional, display to stream
* @param mode - Optional, mode which denotes which context to stream
*/
- async setupIngest() {
+ async setupIngest(display?: TDisplayType) {
const ingest = await this.getIngestServer();
- if (this.streamInfo.isStreamShiftMode) {
- // in single output mode, we just set the ingest for the default display
- this.streamSettingsService.setSettings({
- streamType: 'rtmp_custom',
- });
+ const shouldSetupStreamShift =
+ this.streamInfo.isStreamShiftMode || this.state.streamShiftStatus === 'pending';
+ if (shouldSetupStreamShift) {
+ // in single output mode, we just set the ingest for the default display
const streamId = uuid();
this.SET_STREAM_SWITCHER_STREAM_ID(streamId);
- // for the stream switcher, the stream needs a unique identifier
+ // For the stream switcher, the stream needs a unique identifier
+ // Note: if there is a bug with stream shift, start by checking for an sid parameter in the stream key
const streamKey = `${this.settings.streamKey}&sid=${streamId}`;
- this.streamSettingsService.setSettings({
- streamType: 'rtmp_custom',
- key: streamKey,
- server: ingest,
- });
- } else if (this.streamingService.views.isDualOutputMode) {
- // in dual output mode, we need to set the ingest for each display
- const displays = this.streamInfo.displaysToRestream;
-
- displays.forEach(async display => {
- const mode = this.getMode(display);
- const settings = await this.fetchUserSettings(mode);
-
- this.streamSettingsService.setSettings(
- {
- streamType: 'rtmp_custom',
- },
- display,
- );
-
- this.streamSettingsService.setSettings(
- {
- key: settings.streamKey,
- server: ingest,
- },
- display,
- );
- });
+ this.setStreamSettingsForDisplay('horizontal', streamKey, ingest);
+ } else if (display) {
+ // Setup ingest for the display if provided, otherwise setup ingest for the entire stream
+
+ const mode = this.getMode(display);
+ const settings = await this.fetchUserSettings(mode);
+
+ this.setStreamSettingsForDisplay(display, settings.streamKey, ingest);
+ return;
+ } else if (this.streamInfo.isLiveOutputEditingEnabled || this.streamInfo.isDualOutputMode) {
+ // Set the ingest for each display being restreamed.
+ // In live output editing mode, every display must use the restream servers so that a target
+ // can switch between displays mid-stream, so use every display with a target.
+ const displays = this.streamInfo.isLiveOutputEditingEnabled
+ ? this.streamInfo.liveOutputDisplays
+ : this.streamInfo.displaysToRestream;
+
+ // Await the settings for every display. Otherwise `beforeGoLive` resolves before the
+ // stream settings have been written and `createStreaming` reads stale values.
+ await Promise.allSettled(
+ displays.map(async display => {
+ const mode = this.getMode(display);
+ const settings = await this.fetchUserSettings(mode);
+
+ this.setStreamSettingsForDisplay(display, settings.streamKey, ingest);
+ }),
+ );
} else {
- // in single output mode, we just set the ingest for the default display
- this.streamSettingsService.setSettings({
- streamType: 'rtmp_custom',
- });
-
- this.streamSettingsService.setSettings({
- streamType: 'rtmp_custom',
- key: this.settings.streamKey,
- server: ingest,
- });
+ // In single output mode, we just set the ingest for the horizontal (default) display
+ this.setStreamSettingsForDisplay('horizontal', this.settings.streamKey, ingest);
}
}
@@ -851,21 +1005,28 @@ export class RestreamService extends StatefulService {
// Setup new targets
const newTargets = [...this.setupPlatforms(), ...this.setupCustomDestinations()];
-
await this.createTargets(newTargets);
}
- setupPlatforms() {
+ setupPlatforms(updatedPlatforms?: TPlatform[], display?: TDisplayType) {
const isEnhancedBroadcasting = this.settingsService.isEnhancedBroadcasting();
- const isDualOutputMode = this.streamingService.views.isDualOutputMode;
- const modesToRestream = this.streamInfo.displaysToRestream.map(display =>
- this.getMode(display),
- );
+ const modesToRestream = this.getModesToRestream();
+
+ const targetPlatforms = updatedPlatforms ?? this.streamInfo.enabledPlatforms;
- return this.streamInfo.enabledPlatforms.reduce((platforms, platform) => {
+ return targetPlatforms.reduce((platforms, platform) => {
// Enhanced broacasting when multistreaming uses its own video context and stream
// so skip setting up Twitch as a target here
if (isEnhancedBroadcasting && platform === 'twitch') {
+ if (updatedPlatforms) {
+ // Enhanced broadcasting is disabled while live output editing is enabled, so reaching
+ // this while adding targets means no Twitch target will be created for the display.
+ console.warn(
+ 'RESTREAM Skipping Twitch target for display',
+ display,
+ 'because enhanced broadcasting is enabled',
+ );
+ }
return platforms;
}
@@ -906,12 +1067,22 @@ export class RestreamService extends StatefulService {
targetInfo.streamKey = `${this.patreonService.state.ingest}/${this.patreonService.state.streamKey}`;
}
- // `getPlatformMode` handles the logic for determi
- const mode = this.streamingService.views.getPlatformMode(platform);
+ if (updatedPlatforms) {
+ const mode = display ? this.getMode(display) : this.getPlatformMode(platform);
+ platforms.push({ ...targetInfo, mode });
+ return platforms;
+ }
+
+ // `getPlatformMode` resolves the platform's assigned display in dual output and live output
+ // editing modes, and falls back to landscape in single output mode
+ const mode = this.getPlatformMode(platform);
- // In single output mode, always add the platform as a target
- // In dual output mode, only add the platform as a target if its display (aka mode) is being restreamed
- if (!isDualOutputMode || modesToRestream.includes(mode)) {
+ // In single output mode every platform is a target. In dual output and live output editing
+ // modes a platform is only a target when its display is one of the displays being restreamed.
+ const usesDisplays =
+ this.streamInfo.isDualOutputMode || this.streamInfo.isLiveOutputEditingEnabled;
+
+ if (!usesDisplays || modesToRestream.includes(mode)) {
platforms.push({ ...targetInfo, mode });
}
@@ -919,13 +1090,15 @@ export class RestreamService extends StatefulService {
}, []);
}
- setupCustomDestinations() {
+ setupCustomDestinations(customDestinations?: ICustomStreamDestination[], display?: TDisplayType) {
const isDualOutputMode = this.streamingService.views.isDualOutputMode;
- const modesToRestream = this.streamInfo.displaysToRestream.map(display =>
- this.getMode(display),
- );
+ const modesToRestream = this.getModesToRestream();
- return this.streamInfo.customDestinations.reduce((dests, dest) => {
+ // When an explicit list is passed, only create targets for that list. Otherwise this is the
+ // go live flow, which creates targets for every enabled destination on the stream.
+ const targetDestinations = customDestinations ?? this.streamInfo.customDestinations;
+
+ return targetDestinations.reduce((dests, dest) => {
if (!dest.enabled) return dests;
const targetInfo = {
@@ -933,6 +1106,12 @@ export class RestreamService extends StatefulService {
streamKey: `${this.formatUrl(dest.url)}${dest.streamKey}`,
};
+ if (customDestinations) {
+ const mode = display ? this.getMode(display) : this.getMode(dest.display);
+ dests.push({ ...targetInfo, mode });
+ return dests;
+ }
+
if (isDualOutputMode) {
const mode = this.getMode(dest.display);
if (modesToRestream.includes(mode)) {
@@ -946,6 +1125,14 @@ export class RestreamService extends StatefulService {
}, []);
}
+ getModesToRestream() {
+ if (!this.streamInfo.isDualOutputMode) return ['landscape'] as TOutputOrientation[];
+ if (this.streamInfo.isLiveOutputEditingEnabled) {
+ return this.streamInfo.liveOutputDisplays.map(display => this.getMode(display));
+ }
+ return this.streamInfo.displaysToRestream.map(display => this.getMode(display));
+ }
+
formatUrl(url: string): string {
return url.replace(/^\s+|\/+$/g, '') + '/';
}
@@ -961,7 +1148,7 @@ export class RestreamService extends StatefulService {
platform: platform as TPlatform | 'relay',
streamKey: getPlatformService(platform).state.streamKey,
label: `${platform} target`,
- mode: this.getPlatformMode(platform),
+ mode: this.streamInfo.isDualOutputMode ? this.getPlatformMode(platform) : 'landscape',
dcProtection: true,
enabled: true,
};
@@ -995,10 +1182,11 @@ export class RestreamService extends StatefulService {
streamKey: `${this.kickService.state.ingest}/${this.kickService.state.streamKey}`,
};
}
+ // Patreon is a special relay case because while it is technically a relay, the server expects the platform value `patreon`
case 'patreon': {
- // Patreon is a special relay case because while it is technically a relay, the server expects the platform value `patreon`
return {
...platformData,
+ platform: 'patreon' as 'patreon',
streamKey: `${this.patreonService.state.ingest}/${this.patreonService.state.streamKey}`,
};
}
@@ -1017,14 +1205,10 @@ export class RestreamService extends StatefulService {
formatRuntimeCustomDestinationData(
destination: ICustomStreamDestination,
): IRestreamRuntimeTarget {
- const useSavedMode =
- this.streamingService.views.isDualOutputMode ||
- this.streamingService.views.isLiveOutputEditingEnabled;
-
return {
platform: 'relay' as 'relay',
streamKey: `${this.formatUrl(destination.url)}${destination.streamKey}`,
- mode: useSavedMode ? this.getMode(destination.display) : 'landscape',
+ mode: this.streamInfo.isDualOutputMode ? this.getMode(destination.display) : 'landscape',
dcProtection: true,
enabled: true,
label: `${destination.name} target`,
@@ -1048,20 +1232,18 @@ export class RestreamService extends StatefulService {
) {
const mode = this.getMode(display);
- // Only create targets for the platforms and destinations assigned to this display
+ // Only create targets for the platforms and destinations assigned to this display. Compare
+ // resolved orientations rather than the raw saved display so that a destination still holding
+ // a `vertical` display from a dual output session is not dropped in single output mode.
const displayPlatforms = platforms.filter(platform => this.getPlatformMode(platform) === mode);
const displayDestinations = customDestinations.filter(
- dest => dest.enabled && (dest.display ?? 'horizontal') === display,
+ dest => dest.enabled && this.getMode(dest.display ?? 'horizontal') === mode,
);
- // TODO: Comment in when UI merged
- // const updatedTargets = [
- // ...this.setupPlatforms(displayPlatforms, display),
- // ...this.setupCustomDestinations(displayDestinations, display),
- // ];
-
- // TODO: Remove when UI merged
- const updatedTargets: IRestreamRuntimeTarget[] = [];
+ const updatedTargets = [
+ ...this.setupPlatforms(displayPlatforms, display),
+ ...this.setupCustomDestinations(displayDestinations, display),
+ ];
if (!updatedTargets.length) return;
@@ -1077,11 +1259,52 @@ export class RestreamService extends StatefulService {
);
}
+ /**
+ * Check if the user is already live via stream shift
+ * @remark This also validates and resets the stream shift state for non-ultra users.
+ * @returns - Promise with stream shift live status
+ */
async checkIsLive(): Promise {
+ // Stream Shift is ultra-only. Reset if the user is not prime
+ if (!this.userService.views.isPrime) {
+ if (this.state.streamShiftStatus === 'pending') {
+ this.SET_STREAM_SWITCHER_STATUS('inactive');
+ this.SET_STREAM_SWITCHER_TARGETS([]);
+ }
+
+ if (this.streamInfo.settings.streamShift) {
+ this.streamSettingsService.setGoLiveSettings({ streamShift: false });
+ }
+ return false;
+ }
+
+ // Don't check stream shift status while the stream status isn't `Offline`.
+ // While the stream is active, starting, or tearing down, the is live status will be reported
+ // as true from Desktop's own stream, while the intent is to check for a stream on another device.
+ if (this.streamInfo.streamingStatus !== EStreamingState.Offline) {
+ return false;
+ }
+
const status = await this.fetchLiveStatus();
console.debug('Stream Shift Status', status);
if (status.isLive) {
+ // If the last stream had live output editing enabled, it may still be in the cooldown period
+ // and show as a new live stream immediately after the previous one ended. To prevent it from
+ // accidentally being identified as a stream shift stream, force the stream to go live if the
+ // app recently went live with live output editing enabled.
+ if (this.streamInfo.isLiveOutputEditingEnabled) {
+ // If the last stream ended within the last minute, assume it is still in the cooldown period
+ const streamEndedRecently =
+ this.diagnosticsService.lastStream &&
+ Date.now() - new Date(this.diagnosticsService.lastStream.endTime).getTime() < 60 * 1000;
+
+ if (streamEndedRecently) {
+ this.SET_STREAM_SWITCHER_FORCE_GO_LIVE(true);
+ return false;
+ }
+ }
+
this.streamSettingsService.setGoLiveSettings({ streamShift: true });
this.SET_STREAM_SWITCHER_STATUS('pending');
this.SET_STREAM_SWITCHER_TARGETS(status.targets);
@@ -1090,6 +1313,8 @@ export class RestreamService extends StatefulService {
this.SET_STREAM_SWITCHER_TARGETS([]);
}
+ this.SET_STREAM_SWITCHER_FORCE_GO_LIVE(false);
+
this.isLive.next(status.isLive);
return status.isLive;
}
@@ -1119,18 +1344,21 @@ export class RestreamService extends StatefulService {
return jfetch<{ [key: string]: ITargetLiveData[] }>(request)
.then(res => {
- const targets = this.state.streamShiftTargets.reduce((targetData: ITargetLiveData[], t) => {
- const platform = t.platform as string;
- if (t.platform !== 'relay') {
- const data = res[platform]?.[0];
-
- if (data) {
- targetData.push({ ...t, ...data });
- }
- }
-
- return targetData;
- }, []);
+ // Preserve targets the status endpoint returned no data for. Dropping them removes the
+ // platform from the switch entirely, and drops the relay target on every fetch.
+ const targets = this.state.streamShiftTargets.map((t: ITargetLiveData) => {
+ console.debug('Stream Shift target data', t, res[t.platform as string]);
+ if (t.platform === 'relay') return t;
+
+ const data = res[t.platform as string]?.[0];
+ // A default value is needed here because the status endpoint does not return a value
+ // for `is_live` when the stream is not live and its absence should be treated as false.
+ // Needed to prevent the relay target from being dropped when the status endpoint returns
+ // no data for a platform.
+ const isLive = data?.is_live ?? false;
+
+ return data ? { ...t, ...data, is_live: isLive } : t;
+ });
console.debug('Stream Shift target data', targets);
@@ -1171,13 +1399,15 @@ export class RestreamService extends StatefulService {
new Headers({ 'Content-Type': 'application/json' }),
);
const url = `https://${this.host}/api/v1/rst/targets`;
+ const dcProtection =
+ this.streamInfo.isStreamShiftMode || this.streamInfo.isLiveOutputEditingEnabled;
const body = JSON.stringify(
targets.map(target => {
return {
platform: target.platform,
streamKey: target.streamKey,
enabled: true,
- dcProtection: false,
+ dcProtection,
idleTimeout: 30,
label: target?.label ?? `${target.platform} target`,
mode: target?.mode,
@@ -1241,12 +1471,22 @@ export class RestreamService extends StatefulService {
if (action === 'rejected') {
this.SET_STREAM_SWITCHER_STATUS('pending');
} else {
+ this.streamSettingsService.setGoLiveSettings({ streamShift: true });
+
+ // Dual output mode is not compatible with stream shift
if (this.streamInfo.isDualOutputMode) {
- this.dualOutputService.toggleDisplay(false, 'vertical');
+ this.dualOutputService.setDualOutputModeIfPossible(false, true, false, true);
}
+ // Live output editing mode is not compatible with stream shift
+ if (this.streamInfo.isLiveOutputEditingEnabled) {
+ this.streamSettingsService.setGoLiveSettings({ liveOutputEditing: false });
+ }
+
+ this.updateStreamShift('approved').catch((e: unknown) => {
+ console.error('Stream Shift Error: failed to approve the switch', e);
+ });
this.SET_STREAM_SWITCHER_STATUS('inactive');
- this.updateStreamShift('approved');
}
}
@@ -1301,6 +1541,86 @@ export class RestreamService extends StatefulService {
this.SET_STREAM_SWITCHER_FORCE_GO_LIVE(true);
}
+ /**
+ * Infer the type of the remote device from its stream identifier
+ * @remarks Mobile identifiers contain uppercase characters, desktop identifiers do not.
+ * Note: because the event's stream id is from the device that requested the switch, it is not
+ * possible to know what type of device the stream will be switching from. We can only identify
+ * the type of device the stream is switching to.
+ */
+ private getStreamShiftDeviceType(id: string): 'mobile' | 'desktop' {
+ return /[A-Z]/.test(id) ? 'mobile' : 'desktop';
+ }
+
+ /**
+ * Handle an incoming stream shift socket event
+ * @returns A message to show the user, or an empty string when no alert should be shown
+ */
+ async handleStreamShiftEvent(event: TSocketEvent): Promise {
+ if (this.state.streamShiftForceGoLive) return '';
+ if (event.type !== 'streamSwitchRequest' && event.type !== 'switchActionComplete') {
+ return '';
+ }
+
+ const streamShiftStreamId = this.state.streamShiftStreamId;
+ console.debug('Event ID: ' + event.data.identifier, '\n Stream ID: ' + streamShiftStreamId);
+ const isIncomingStream: boolean =
+ (streamShiftStreamId && event.data.identifier === streamShiftStreamId) || false;
+
+ // Handle stream shift request events
+ if (event.type === 'streamSwitchRequest') {
+ if (isIncomingStream) {
+ // Don't record the request from this device because the other device will record it
+ this.confirmStreamShift('approved');
+ } else {
+ this.recordStreamShiftAnalytics('request', event.data.identifier);
+ }
+
+ // Currently no alert is shown for stream shift requests, so this is a placeholder message
+ return $t('Switch Stream');
+ }
+
+ // Handle stream shift completed events
+ if (event.type === 'switchActionComplete') {
+ // End the stream on this device if switching the stream to another device
+ // Only record analytics if the stream was switched from this device to a different one
+
+ if (!isIncomingStream) {
+ this.endStreamShiftStream(event.data.identifier);
+ this.recordStreamShiftAnalytics('complete', event.data.identifier);
+ }
+
+ // Notify the user
+ if (isIncomingStream) {
+ // close go live window
+ return $t(
+ 'Your stream has been switched to Streamlabs Desktop from another device. Enjoy your stream!',
+ );
+ }
+
+ return this.getStreamShiftDeviceType(event.data.identifier) === 'mobile'
+ ? $t('Your stream has been successfully switched to Streamlabs Mobile. Enjoy your stream!')
+ : $t(
+ 'Your stream has been successfully switched to Streamlabs Desktop. Enjoy your stream!',
+ );
+ }
+
+ // Placeholder for a default return value when no stream shift event is handled
+ return '';
+ }
+
+ /**
+ * @param id - The stream identifier of the device the stream is switching to
+ */
+ recordStreamShiftAnalytics(action: 'request' | 'complete', id: string) {
+ if (Utils.isTestMode()) return;
+
+ this.usageStatisticsService.recordAnalyticsEvent('StreamShift', {
+ stream: `desktop-${this.getStreamShiftDeviceType(id)}`,
+ action,
+ });
+ }
+
/**
* Test helper to emit isLive for testing purposes
* @param isLive - Whether the stream is live or not
@@ -1404,11 +1724,19 @@ export class RestreamService extends StatefulService {
}
private getPlatformMode(platform: TPlatform): TOutputOrientation {
- const display = this.streamingService.views.getPlatformMode(platform);
return this.streamingService.views.getPlatformMode(platform);
}
+ /**
+ * Resolve the output orientation for a display
+ * @remark Outside dual output mode there is only the horizontal display, so every target belongs
+ * to the landscape stream regardless of the display it is nominally assigned to. A target keeps
+ * its saved `vertical` display when the user leaves dual output mode, and without this guard that
+ * stale value routes the target to a portrait stream that is not running.
+ * @param display - The display to resolve the orientation for
+ */
getMode(display: TDisplayType): TOutputOrientation {
+ if (!this.streamInfo.isDualOutputMode) return 'landscape';
if (!display) return 'landscape';
return display === 'horizontal' ? 'landscape' : 'portrait';
}
@@ -1428,7 +1756,6 @@ class RestreamView extends ViewHandler {
get isTikTokGrandfathered() {
return this.state.tiktokGrandfathered;
}
-
/**
* This determines whether the user can enable restream
* Requirements:
diff --git a/app/services/streaming/streaming-view.ts b/app/services/streaming/streaming-view.ts
index 4b1a28ab8800..e448a1cf1287 100644
--- a/app/services/streaming/streaming-view.ts
+++ b/app/services/streaming/streaming-view.ts
@@ -105,6 +105,7 @@ export class StreamInfoView extends ViewHandler {
return (
(this.platforms.twitch?.enabled && this.platforms.twitch.game) ||
(this.platforms.facebook?.enabled && this.platforms.facebook.game) ||
+ (this.platforms.kick?.enabled && this.platforms.kick.game) ||
''
);
}
@@ -113,6 +114,7 @@ export class StreamInfoView extends ViewHandler {
return (
(this.platforms.twitch?.enabled && this.platforms.twitch.gameName) ||
(this.platforms.facebook?.enabled && this.platforms.facebook.game) ||
+ (this.platforms.kick?.enabled && this.platforms.kick.gameName) ||
''
);
}
@@ -194,6 +196,12 @@ export class StreamInfoView extends ViewHandler {
}
get isTwitchDualStreamEnabled() {
+ // Twitch dual stream requires enhanced broadcasting, which is not available with live output editing
+ // because enhanced broadcasting cannot use restream service due to api requirements
+ if (this.isLiveOutputEditingEnabled) {
+ return false;
+ }
+
if (!this.twitchView.hasTwitchDualStreamAccess) {
return false;
}
@@ -287,9 +295,15 @@ export class StreamInfoView extends ViewHandler {
/**
* Returns if the user can or should use the restream service
+ * @remark Order matters here when checking for which features are enabled. Stream shift mode and live output editing
+ * take precedence over dual output mode.
*/
get isMultiplatformMode(): boolean {
+ // Order matters here when checking for which features are enabled.
+ // Stream shift mode and live output editing take precedence over
+ // dual output mode.
if (this.isStreamShiftMode) return true;
+ if (this.isLiveOutputEditingEnabled) return true;
if (this.isDualOutputMode) return false;
return this.hasMultipleTargetsEnabled;
}
@@ -334,9 +348,27 @@ export class StreamInfoView extends ViewHandler {
* Returns if the user can edit live outputs mid-stream.
*/
get isLiveOutputEditingEnabled(): boolean {
+ if (!this.incrementalRolloutView.featureIsEnabled(EAvailableFeatures.liveOutputEditing)) {
+ return false;
+ }
return this.settings.liveOutputEditing ?? false;
}
+ /**
+ * The persisted live output editing setting, gated by the feature flag
+ * @remark Reads `goLiveSettings` from state directly instead of `this.settings` to avoid the
+ * circular dependency: settings → savedSettings → getSavedPlatformSettings → settings. Use this
+ * wherever the persisted setting is read outside of `settings`, so that a setting persisted
+ * while the flag was granted cannot keep switching on live output editing behavior after it is
+ * revoked.
+ */
+ private get savedLiveOutputEditing(): boolean {
+ if (!this.incrementalRolloutView.featureIsEnabled(EAvailableFeatures.liveOutputEditing)) {
+ return false;
+ }
+ return this.streamSettingsView.state.goLiveSettings?.liveOutputEditing ?? false;
+ }
+
/**
* Returns if the restream service should be set up when going live
*/
@@ -345,23 +377,23 @@ export class StreamInfoView extends ViewHandler {
if (this.isStreamShiftMode) return true;
// Live output editing uses the restream service
- if (this.isLiveOutputEditingEnabled) {
- return this.incrementalRolloutView.featureIsEnabled(EAvailableFeatures.liveOutputEditing);
- }
+ if (this.isLiveOutputEditingEnabled) return true;
// In dual output mode, if a display has more than one target that display uses the restream service
const restreamDualOutputMode =
this.isDualOutputMode && (this.horizontalStream.length > 1 || this.verticalStream.length > 1);
return this.isMultiplatformMode || restreamDualOutputMode;
}
-
/**
* Returns the displays that should use restream
* @remark In dual output mode, only displays that have multiple targets enabled should use restream
*/
get displaysToRestream(): TDisplayType[] {
const displays = [] as TDisplayType[];
- if (!this.isDualOutputMode) return displays;
+
+ // In single output mode, only the horizontal stream is streamed
+ if (!this.isDualOutputMode && !this.isLiveOutputEditingEnabled) return displays;
+
if (this.horizontalStream.length > 1) {
displays.push('horizontal' as TDisplayType);
}
@@ -393,10 +425,21 @@ export class StreamInfoView extends ViewHandler {
*/
get isDualOutputMode(): boolean {
if (!this.userView.isLoggedIn || !this.info) return false;
+ if (!this.dualOutputView.dualOutputMode) return false;
return this.shouldSetupDualOutput;
}
+ /**
+ * Returns the output orientation for a given platform.
+ * @remark Expects to return the following per feature:
+ * - Stream Shift - always returns 'landscape'
+ * - Single Output Mode - always returns 'landscape'
+ * - Dual Output Mode - returns assigned displays: 'landscape' for horizontal displays and 'portrait' for vertical displays
+ * - Live Output Editing - returns 'landscape' in single output mode, and assigned displays in dual output mode
+ * @param platform - The platform to resolve the orientation for
+ */
getPlatformMode(platform: TPlatform): TOutputOrientation {
+ if (this.isStreamShiftMode) return 'landscape';
if (!this.isDualOutputMode) return 'landscape';
const display = this.getPlatformDisplayType(platform);
return display === 'vertical' ? 'portrait' : 'landscape';
@@ -455,6 +498,59 @@ export class StreamInfoView extends ViewHandler {
);
}
+ /**
+ * Returns the passed in targets according to their assigned display
+ * @remark Currently unused, but could be used for a future refactor to unify logic for filtering
+ * targets by display
+ * @param platforms - The platforms to be sorted by display
+ * @param customDestinations - The custom destinations to be sorted by display
+ * @param settings - The go live settings containing platform display assignments
+ * @returns targets sorted by assigned display
+ */
+ getActiveDisplayTargets(
+ platforms: TPlatform[],
+ customDestinations: ICustomStreamDestination[],
+ settings: IGoLiveSettings,
+ ): TDisplayDestinations {
+ const parsedPlatforms = platforms.reduce(
+ (displayPlatforms: TDisplayPlatforms, platform: TPlatform) => {
+ const display =
+ settings.platforms[platform]?.display && settings.platforms[platform]?.display !== 'both'
+ ? settings.platforms[platform]?.display
+ : 'horizontal';
+ displayPlatforms[display].push(platform);
+
+ // if the platform is set to 'both' display, add it to both horizontal and vertical
+ // for analytics purposes
+ if (settings.platforms[platform]?.display === 'both') {
+ displayPlatforms.vertical.push(platform);
+ }
+
+ return displayPlatforms;
+ },
+ { horizontal: [], vertical: [] },
+ );
+
+ /**
+ * Returns the enabled destinations according to their assigned display
+ */
+
+ const parsedDestinations = customDestinations.reduce(
+ (displayDestinations: TDisplayDestinations, destination: ICustomStreamDestination) => {
+ if (destination.enabled && !destination.dualStream) {
+ displayDestinations[destination.display ?? 'horizontal'].push(destination.url);
+ }
+ return displayDestinations;
+ },
+ { horizontal: [], vertical: [] },
+ );
+
+ return {
+ horizontal: (parsedPlatforms.horizontal as string[]).concat(parsedDestinations.horizontal),
+ vertical: (parsedPlatforms.vertical as string[]).concat(parsedDestinations.vertical),
+ };
+ }
+
get horizontalStream() {
return this.activeDisplayDestinations.horizontal.concat(
this.activeDisplayPlatforms.horizontal as string[],
@@ -486,6 +582,26 @@ export class StreamInfoView extends ViewHandler {
);
}
+ /**
+ * Validate the display when live output editing is enabled
+ * @remark Used to ensure a platform with the `both` display, used for dual streaming, uses the
+ * default display instead. Reads `savedLiveOutputEditing` instead of `isLiveOutputEditingEnabled`
+ * to avoid the circular dependency: settings → savedSettings → getSavedPlatformSettings → settings
+ * @param display - The display saved for the platform
+ * @remark Use the dual output mode service state to prevent circular references
+ * @warning The `get` prefix is required. This class is passed to `injectState` in
+ * `useGoLiveSettings`, and slap registers any method not named `get*`/`is*`/`should*` as a
+ * mutation. Calling a mutation from a getter dispatches it during the component snapshot,
+ * which re-enters `updateUI` and recurses until the stack overflows.
+ */
+ private getValidatedDisplay(display?: TDisplayOutput): TDisplayType {
+ if (!display || display === 'both' || !this.dualOutputView.dualOutputMode) {
+ return 'horizontal';
+ }
+
+ return display as TDisplayType;
+ }
+
get shouldSetupDualOutput(): boolean {
if (this.dualOutputView.dualOutputMode) return true;
// Read from state to avoid circular dependency:
@@ -502,10 +618,10 @@ export class StreamInfoView extends ViewHandler {
const p = platforms[platform as TPlatform];
if (!p?.enabled || !this.isPlatformLinked(platform as TPlatform)) continue;
- const display = p.display ?? 'horizontal';
-
- // Any enabled platform with 'both' display automatically enables dual output mode
- if (display === 'both') return true;
+ // Note: this is to prevent an error where the platform doesn't go live because the display is set to 'both'
+ // in dual output mode when live output editing is enabled. It should never happen but to prevent errors indexing
+ // `platformDisplays`, default a platform without a display to horizontal
+ const display = this.getValidatedDisplay(p.display);
platformDisplays[display].push(platform as TPlatform);
}
@@ -549,6 +665,10 @@ export class StreamInfoView extends ViewHandler {
* Check for multistreaming with Twitch enhanced broadcasting
*/
isEnhancedBroadcastingMultistream(): boolean {
+ // Enhanced broadcasting is not available while live output editing is enabled because it uses
+ // its own video context and stream, which cannot be edited mid-stream
+ if (this.isLiveOutputEditingEnabled) return false;
+
// As a failsafe, ensure Twitch is one of the enabled platforms
if (!this.enabledPlatforms.includes('twitch')) return false;
@@ -672,6 +792,7 @@ export class StreamInfoView extends ViewHandler {
customDestinations: savedGoLiveSettings?.customDestinations || [],
recording: savedGoLiveSettings?.recording || 'horizontal',
streamShift: savedGoLiveSettings?.streamShift || false,
+ liveOutputEditing: this.savedLiveOutputEditing,
};
}
@@ -721,16 +842,39 @@ export class StreamInfoView extends ViewHandler {
return commonFields;
}
+ /**
+ * Apply the common title and description to each platform
+ * @remark While live, the common title wins over whatever title a platform is holding, unless that
+ * platform uses custom fields. Deliberately scoped to mid-stream, for the Edit Stream window, the
+ * Go Live window keeps the original backfill. The description uses the backfill in both cases.
+ * @param platforms - The platform settings to apply the common fields to
+ * @return The updated platform settings with common fields applied
+ */
applyCommonFields(platforms: IGoLiveSettings['platforms']): IGoLiveSettings['platforms'] {
const commonFields = this.getCommonFields(platforms);
const result = {} as IGoLiveSettings['platforms'];
+ const useCommonTitle = this.isMidStreamMode;
+
Object.keys(platforms).forEach(platform => {
// TODO: index
// @ts-ignore
result[platform] = platforms[platform];
+
+ // TODO: index
+ // @ts-ignore
+ const usesCustomFields = platforms[platform].useCustomFields;
+
// TODO: index
// @ts-ignore
- result[platform].title = platforms[platform].title || commonFields.title;
+ result[platform].title =
+ useCommonTitle && !usesCustomFields
+ ? // TODO: index
+ // @ts-ignore
+ commonFields.title || platforms[platform].title
+ : // TODO: index
+ // @ts-ignore
+ platforms[platform].title || commonFields.title;
+
// TODO: index
// @ts-ignore
result[platform].description = platforms[platform].description || commonFields.description;
@@ -877,10 +1021,12 @@ export class StreamInfoView extends ViewHandler {
settings['liveVideoId'] = '';
}
- // make sure platforms assigned to the vertical display in dual output mode still go live in single output mode
+ // Make sure platforms assigned to the vertical display in dual output mode still go live in single output mode
+ // Note: This is a check to ensure that the display is valid when live output editing is enabled. If the display
+ // is set to 'both', it will be defaulted to 'horizontal' for single output mode.
const display =
- this.isDualOutputMode && savedDestinations
- ? savedDestinations[platform]?.display
+ this.isDualOutputMode && savedDestinations && savedDestinations[platform]?.display
+ ? this.getValidatedDisplay(savedDestinations[platform]?.display)
: 'horizontal';
return {
@@ -988,11 +1134,10 @@ export class StreamInfoView extends ViewHandler {
return this.streamingState.selectiveRecording;
}
- get canEditLiveOutputs() {
- return false;
- // return (
- // !this.isMidStreamMode &&
- // this.incrementalRolloutView.featureIsEnabled(EAvailableFeatures.liveOutputEditing)
- // );
+ get showFeatureToggleCards() {
+ if (!this.incrementalRolloutView.featureIsEnabled(EAvailableFeatures.liveOutputEditing)) {
+ return false;
+ }
+ return !this.isMidStreamMode;
}
}
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index 7963383d345d..c6ecc9dde39c 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -65,7 +65,12 @@ import {
} from 'services/notifications';
import { VideoEncodingOptimizationService } from 'services/video-encoding-optimizations';
import { VideoSettingsService, TDisplayType } from 'services/settings-v2/video';
-import { ICustomStreamDestination, StreamSettingsService } from '../settings/streaming';
+import {
+ getDestinationId,
+ ICustomStreamDestination,
+ StreamSettingsService,
+ TDestinationId,
+} from '../settings/streaming';
import { IStreamShiftTarget, RestreamService } from 'services/restream';
import Utils from 'services/utils';
import cloneDeep from 'lodash/cloneDeep';
@@ -95,9 +100,10 @@ import { EOBSOutputType, EOBSOutputSignal, IOBSOutputSignalInfo } from 'services
import { SignalsService } from 'services/signals-manager';
import { TSocketEvent } from 'services/websocket';
import { HighlighterService } from 'services/highlighter';
+import { EAvailableFeatures, IncrementalRolloutService } from 'services/incremental-rollout';
type TOBSOutputType = 'streaming' | 'recording' | 'replayBuffer';
-type TOutputContext = TDisplayType | 'enhancedBroadcasting' | 'stream' | 'streamSecond';
+type TOutputContext = TDisplayType | 'enhancedBroadcasting';
interface IOutputContext {
streaming:
@@ -174,6 +180,7 @@ export class StreamingService
@Inject() private settingsService: SettingsService;
@Inject() private signalsService: SignalsService;
@Inject() private highlighterService: HighlighterService;
+ @Inject() private incrementalRolloutService: IncrementalRolloutService;
streamingStatusChange = new Subject();
recordingStatusChange = new Subject();
@@ -189,8 +196,18 @@ export class StreamingService
streamingStateChange = new Subject();
powerSaveId: number;
- private isUpdatingStreamTarget: boolean = false;
- private isUpdatingStreamSecondTarget: boolean = false;
+
+ /**
+ * For live output editing, prevent teardown of live streaming contexts when one of the displays
+ * is being added or removed mid-stream
+ */
+ private isUpdatingHorizontalStream: boolean = false;
+ private isUpdatingVerticalStream: boolean = false;
+ /**
+ * For live output editing, track displays whose streaming instance is being created mid-stream
+ * to prevent triggering the full start streaming flow while the user is already live
+ */
+ private addingDisplayTargets = new Set();
private numInstances: number = 0;
private resolveStartStreaming: Function = () => {};
@@ -200,8 +217,6 @@ export class StreamingService
horizontal: IOutputContext;
vertical: IOutputContext;
enhancedBroadcasting: Partial;
- stream: Partial;
- streamSecond: Partial;
} = {
horizontal: {
streaming: null,
@@ -216,12 +231,6 @@ export class StreamingService
enhancedBroadcasting: {
streaming: null,
},
- stream: {
- streaming: null,
- },
- streamSecond: {
- streaming: null,
- },
};
static initialState: IStreamingServiceState = {
@@ -267,6 +276,7 @@ export class StreamingService
facebook: 'not-started',
twitter: 'not-started',
instagram: 'not-started',
+ destination: 'not-started',
setupMultistream: 'not-started',
setupDualOutput: 'not-started',
startVideoTransmission: 'not-started',
@@ -840,10 +850,13 @@ export class StreamingService
// Twitch dual stream, which requires enhanced broadcasting to be enabled. The setting
// in osn is what actually determines if the stream will use enhanced broadcasting.
if (platform === 'twitch') {
+ // Enhanced broadcasting is unavailable while live output editing is enabled because it
+ // uses its own video context and stream, which cannot be edited mid-stream
const isEnhancedBroadcasting =
- this.views.isTwitchDualStreamEnabled ||
- settings.platforms.twitch?.isEnhancedBroadcasting ||
- false;
+ !this.views.isLiveOutputEditingEnabled &&
+ (this.views.isTwitchDualStreamEnabled ||
+ settings.platforms.twitch?.isEnhancedBroadcasting ||
+ false);
this.SET_ENHANCED_BROADCASTING(isEnhancedBroadcasting);
}
@@ -1010,82 +1023,55 @@ export class StreamingService
activeDestinations,
);
- // If there is a difference in the active platforms/destinations vs the ones in the go live window,
- // update the restream targets
+ // Note: a target cannot change display while it is live. Each display is a separate restream
+ // stream and a separate output instance, so moving a target would mean restarting it. The
+ // display selector only offers the display a live target is already using.
const shouldUpdateRestream =
updatePlatforms.start.length > 0 ||
updatePlatforms.stop.length > 0 ||
updateDestinations.start.length > 0 ||
updateDestinations.stop.length > 0;
- if (this.userService.isPrime && shouldUpdateRestream) {
- updatePlatforms.stop.forEach(platform => {
- this.UPDATE_STREAM_INFO({
- checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
- });
- });
-
- updatePlatforms.start.forEach(platform => {
- this.UPDATE_STREAM_INFO({
- checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
- });
- });
-
- updatePlatforms.continue.forEach(platform => {
- this.UPDATE_STREAM_INFO({
- checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
- });
- });
-
- if (shouldUpdateRestream) {
- this.UPDATE_STREAM_INFO({
- checklist: { ...this.state.info.checklist, ['setupMultistream']: 'not-started' },
- });
- }
-
- // Run checklist
- this.UPDATE_STREAM_INFO({ lifecycle: 'runChecklist' });
-
- // Remove targets from restream in a single request
- if (updatePlatforms.stop.length > 0 || updateDestinations.stop.length > 0) {
- await this.removeTargetsFromStream(updatePlatforms.stop, updateDestinations.stop);
- }
-
- // Update checklist for added platforms and run `beforeGoLive` to set up the new platforms.
- // Fail on error so that a platform that could not be set up is never added as a target.
- for (const platform of updatePlatforms.start) {
- await this.setPlatformSettings(platform, settings, false, true);
- }
-
- // Save any settings updated during the `beforeGoLive` process for the platforms.
- // This is important for dual streaming and multistreaming.
- this.SET_GO_LIVE_SETTINGS(this.views.savedSettings);
+ try {
+ await this.runUpdateStreamSettings(
+ settings,
+ platforms,
+ updatePlatforms,
+ updateDestinations,
+ shouldUpdateRestream,
+ );
+ } catch (e: unknown) {
+ console.error('Error updating stream settings', e);
- // Update settings for the persisted targets
- for (const platform of updatePlatforms.continue) {
- await this.updatePlatformSettings(platform, settings);
- }
+ // `handleTypedStreamError` builds the message the user sees. Prefer the reason the
+ // platform gave, because it is the only part that tells the user what to do about it.
+ // A generic message here would replace it, since the `RESTREAM` branch rebuilds the
+ // details from whatever is passed in.
+ const platformError = e instanceof StreamError ? e : undefined;
- // Add targets to restream in a single request
- if (updatePlatforms.start.length > 0 || updateDestinations.start.length > 0) {
- await this.addTargetsToStream(updatePlatforms.start, updateDestinations.start);
- }
- } else {
- // If not a prime user or not adding/removing targets, just update settings for enabled platforms
- platforms.forEach(platform => {
- this.UPDATE_STREAM_INFO({
- checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
- });
- });
+ this.handleTypedStreamError(
+ e,
+ platformError?.type || 'RESTREAM_UPDATE_FAILED',
+ platformError?.details || platformError?.message || 'Failed to update restream settings',
+ platformError?.platform,
+ );
- // Run checklist
- this.UPDATE_STREAM_INFO({ lifecycle: 'runChecklist' });
+ // The Edit Stream window persists a toggle as soon as it is switched, so the saved
+ // settings now claim targets that never started, or claim a target was removed when it is
+ // still streaming. Correct them against what the server actually has.
+ await this.syncTargetsToLive(settings);
- // Update settings for all enabled platforms
- for (const platform of platforms) {
- await this.updatePlatformSettings(platform, settings);
- }
+ // Report the failure so the caller does not tell the user the update succeeded, and does
+ // not record the targets that failed to start as active
+ return false;
+ } finally {
+ // Finish the 'runChecklist' step
+ this.UPDATE_STREAM_INFO({ lifecycle });
}
+
+ // Save updated settings locally
+ this.streamSettingsService.setSettings({ goLiveSettings: settings });
+ return true;
} else {
this.UPDATE_STREAM_INFO({ lifecycle: 'runChecklist' });
@@ -1117,16 +1103,310 @@ export class StreamingService
}
/**
- * Adds restream targets while live
- * @remark Adds targets through the update window checklist
- * @param platforms - Updated list of platforms for the stream
- * @param destinations - Updated list of custom destinations for the stream
+ * Correct the saved Go Live settings to match the targets that are actually streaming
+ * @remark Called when updating targets mid-stream fails. The Edit Stream window persists a
+ * toggle as soon as the user switches it, before the update is applied, so a failure leaves the
+ * saved settings claiming targets that never started. Targets are added and removed one display
+ * at a time, so an update can also fail partway with some targets already changed, which is why
+ * this reconciles against the server rather than rolling back the attempted change.
+ * @param settings - The settings the failed update was applied with
*/
- async addTargetsToStream(platforms: TPlatform[], destinations: ICustomStreamDestination[]) {
+ private async syncTargetsToLive(settings: IGoLiveSettings) {
+ try {
+ const enabledPlatforms = this.views.getEnabledPlatforms(settings.platforms);
+ const enabledDestinations = settings.customDestinations.filter(dest => dest.enabled);
+
+ const live = await this.restreamService.getLiveTargets(enabledPlatforms, enabledDestinations);
+
+ const livePlatforms = new Set(live.platforms);
+ const liveDestinations = new Set(live.customDestinations.map(d => getDestinationId(d)));
+
+ const platforms = cloneDeep(settings.platforms);
+ enabledPlatforms.forEach(platform => {
+ const platformSettings = platforms[platform];
+ if (!platformSettings) return;
+ platformSettings.enabled = livePlatforms.has(platform);
+ });
+
+ const customDestinations = settings.customDestinations.map(dest => ({
+ ...dest,
+ enabled: liveDestinations.has(getDestinationId(dest)),
+ }));
+
+ this.streamSettingsService.setGoLiveSettings({ platforms, customDestinations });
+ } catch (e: unknown) {
+ // Never let this replace the error the update actually failed with, which is the one that
+ // tells the user what to do. A failing API is often why the update failed in the first
+ // place, so `getLiveTargets` throwing here is expected rather than exceptional.
+ console.error('Unable to sync targets to the live stream, leaving saved settings as is', e);
+ }
+ }
+
+ /**
+ * Apply the settings update for a live stream
+ * @remark Extracted from `updateStreamSettings` so that the checklist lifecycle can be restored
+ * and failed targets reverted from a single place regardless of where the update fails.
+ */
+ private async runUpdateStreamSettings(
+ settings: IGoLiveSettings,
+ platforms: TPlatform[],
+ updatePlatforms: { continue: TPlatform[]; stop: TPlatform[]; start: TPlatform[] },
+ updateDestinations: {
+ continue: ICustomStreamDestination[];
+ stop: ICustomStreamDestination[];
+ start: ICustomStreamDestination[];
+ },
+ shouldUpdateRestream: boolean,
+ ) {
+ if (this.userService.isPrime && shouldUpdateRestream) {
+ updatePlatforms.stop.forEach(platform => {
+ this.UPDATE_STREAM_INFO({
+ checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
+ });
+ });
+
+ updatePlatforms.start.forEach(platform => {
+ this.UPDATE_STREAM_INFO({
+ checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
+ });
+ });
+
+ updatePlatforms.continue.forEach(platform => {
+ this.UPDATE_STREAM_INFO({
+ checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
+ });
+ });
+
+ this.UPDATE_STREAM_INFO({
+ checklist: { ...this.state.info.checklist, ['setupMultistream']: 'not-started' },
+ });
+
+ // Run checklist
+ this.UPDATE_STREAM_INFO({ lifecycle: 'runChecklist' });
+
+ const willRemoveTargets =
+ updatePlatforms.stop.length > 0 || updateDestinations.stop.length > 0;
+ const willAddTargets =
+ updatePlatforms.start.length > 0 || updateDestinations.start.length > 0;
+ const keepsAtLeastOneTarget =
+ updatePlatforms.continue.length > 0 || updateDestinations.continue.length > 0;
+
+ // Removing all targets from the stream will end the stream. Ordinarily, we remove the
+ // targets before adding targets but if the user is removing all currently active targets
+ // then we need to switch the order and add targets before removing the old targets to
+ // ensure that the stream does not end.
+ const deferRemoval = willRemoveTargets && willAddTargets && !keepsAtLeastOneTarget;
+
+ const removeStoppedTargets = async () => {
+ await this.removeTargetsFromStream(updatePlatforms.stop, updateDestinations.stop);
+ };
+
+ // Remove targets from restream in a single request
+ if (willRemoveTargets && !deferRemoval) {
+ await removeStoppedTargets();
+ }
+
+ // Update checklist for added platforms and run `beforeGoLive` to set up the new platforms.
+ // Fail on error so that a platform that could not be set up is never added as a target.
+ for (const platform of updatePlatforms.start) {
+ await this.setPlatformSettings(platform, settings, false, true);
+ }
+
+ // Save any settings updated during the `beforeGoLive` process for the platforms.
+ // This is important for dual streaming and multistreaming.
+ this.SET_GO_LIVE_SETTINGS(this.views.savedSettings);
+
+ // Update settings for the persisted targets
+ for (const platform of updatePlatforms.continue) {
+ await this.updatePlatformSettings(platform, settings);
+ }
+
+ // Filter out dual stream custom destinations (right now this is just YouTube)
+ const dualStreamDestinations = this.views.savedSettings.customDestinations.filter(
+ dest => dest.dualStream && dest.enabled,
+ );
+ const allStartDestinations = [...updateDestinations.start, ...dualStreamDestinations];
+
+ // Add targets to restream in a single request
+ if (willAddTargets || allStartDestinations.length > 0) {
+ // Targets can be added for a display that is not live yet, which means that display needs to
+ // go through the full go live flow to create the streaming instance and restream session.
+ const displaysToSetup = this.getDisplaysToSetup(
+ updatePlatforms.start,
+ allStartDestinations,
+ );
+
+ try {
+ await this.addTargetsToStream(
+ updatePlatforms.start,
+ allStartDestinations,
+ displaysToSetup,
+ );
+ } catch (e: unknown) {
+ // Cleanup platforms if there was an error adding the new platforms to the stream
+ updatePlatforms.start.forEach(platform => {
+ const service = getPlatformService(platform);
+ if (service.afterStopStream) service.afterStopStream();
+ });
+
+ const errorType = this.handleTypedStreamError(
+ e,
+ 'RESTREAM_ADD_TARGETS_FAILED',
+ $t('Failed to add new targets to the stream'),
+ );
+ throwStreamError(errorType);
+ }
+
+ for (const display of displaysToSetup) {
+ await this.createLiveOutputEditingContext(display);
+ }
+ }
+
+ // The new targets are on the stream now, so the old ones can go without the session ever
+ // being empty. Deliberately after the add: if the add threw, it rethrew above and the old
+ // targets stay, which is what keeps the stream alive.
+ if (deferRemoval) {
+ await removeStoppedTargets();
+ }
+ } else {
+ // If not a prime user or not adding/removing targets, just update settings for enabled platforms
+ platforms.forEach(platform => {
+ this.UPDATE_STREAM_INFO({
+ checklist: { ...this.state.info.checklist, [platform]: 'not-started' },
+ });
+ });
+
+ // Run checklist
+ this.UPDATE_STREAM_INFO({ lifecycle: 'runChecklist' });
+
+ // Update settings for all enabled platforms
+ for (const platform of platforms) {
+ await this.updatePlatformSettings(platform, settings);
+ }
+ }
+ }
+
+ /**
+ * Displays that will receive newly added targets but are not streaming yet
+ * @remark These displays have no restream session and no streaming instance, so they need the
+ * same setup the go live flow does rather than a runtime target update. Call this after
+ * `setPlatformSettings` because a platform's assigned display is refreshed during `beforeGoLive`.
+ * @param platforms - The platforms being added
+ * @param destinations - The custom destinations being added
+ */
+ private getDisplaysToSetup(
+ platforms: TPlatform[],
+ destinations: ICustomStreamDestination[],
+ ): TDisplayType[] {
+ // Outside dual output mode only the horizontal display streams, so the saved display on a
+ // target is not meaningful. Reading it here would schedule a vertical output instance for a
+ // target that is about to be added to the landscape stream.
+ if (!this.views.isDualOutputMode) {
+ return this.views.isHorizontalStreaming ? [] : ['horizontal'];
+ }
+
+ const targetedDisplays = new Set();
+ platforms.forEach(platform =>
+ targetedDisplays.add(this.views.getPlatformDisplayType(platform)),
+ );
+ destinations.forEach(dest => targetedDisplays.add(dest.display ?? 'horizontal'));
+
+ return (['horizontal', 'vertical'] as TDisplayType[]).filter(display => {
+ if (!targetedDisplays.has(display)) return false;
+
+ return display === 'horizontal'
+ ? !this.views.isHorizontalStreaming
+ : !this.views.isVerticalStreaming;
+ });
+ }
+
+ /**
+ * Create and start the streaming instance for a display that was not streaming
+ * @remark The restream targets and stream settings for the display must already be in place.
+ * @param display - The display to start streaming
+ */
+ private async createLiveOutputEditingContext(display: TDisplayType) {
+ // Ensure the streaming instance that the display will use is not using a stale restream session.
+ // `handleDestroyOutputContexts` is a no-op when the display has no instance, and it leaves the
+ // instance alone while a recording or replay buffer is still running on the display, so it's a
+ // safe call to make here.
+ await this.handleDestroyOutputContexts(display);
+
+ // Flag the display that is being added so the streaming signal handler can identify it
+ this.addingDisplayTargets.add(display);
+
+ try {
+ await this.validateOrCreateOutputInstance({
+ display,
+ type: 'streaming',
+ audioTrack: this.getStreamingAudioTrack(),
+ context: display,
+ start: true,
+ isEnhancedBroadcasting: false,
+ });
+
+ if (!this.contexts[display].streaming) {
+ throwStreamError('RESTREAM_UPDATE_FAILED');
+ }
+ } catch (e: unknown) {
+ this.addingDisplayTargets.delete(display);
+
+ const errorType = this.handleTypedStreamError(
+ e,
+ 'RESTREAM_UPDATE_FAILED',
+ $t('Failed to start the new output. Your existing stream is still live.'),
+ );
+ this.rethrowStreamError(e, errorType);
+ }
+ }
+
+ /**
+ * Revert targets from a failed attempt to add them to the stream
+ * @remark The Go Live window persists targets as enabled as soon as they are toggled on, before
+ * the update is applied. When the update fails the stream is left as it was, so the saved
+ * settings need to be reverted to match what is currently live so the user is not misled.
+ * @param platforms - The platforms that failed to start
+ * @param destinations - The custom destinations that failed to start
+ */
+ private restoreFailedTargets(platforms: TPlatform[], destinations: ICustomStreamDestination[]) {
+ if (!platforms.length && !destinations.length) return;
+
+ const savedSettings = this.views.savedSettings;
+ const failedDestinations = new Set(destinations.map(d => getDestinationId(d)));
+
+ // Work with a copy of the saved settings so that they are not updated until all changes are made
+ const revertedPlatforms = cloneDeep(savedSettings.platforms);
+ platforms.forEach(platform => {
+ const platformSettings = revertedPlatforms[platform];
+ if (!platformSettings) return;
+ platformSettings.enabled = false;
+ });
+
+ const revertedDestinations = savedSettings.customDestinations.map(dest =>
+ failedDestinations.has(getDestinationId(dest)) ? { ...dest, enabled: false } : dest,
+ );
+
+ this.streamSettingsService.setGoLiveSettings({
+ platforms: revertedPlatforms,
+ customDestinations: revertedDestinations,
+ });
+ }
+
+ /**
+ * Add targets to the stream while live
+ * @param platforms - The platforms to add
+ * @param destinations - The custom destinations to add
+ * @param displaysToSetup - Displays that have no restream session yet
+ */
+ async addTargetsToStream(
+ platforms: TPlatform[],
+ destinations: ICustomStreamDestination[],
+ displaysToSetup: TDisplayType[] = [],
+ ) {
// Regular multistreaming via restream service
try {
await this.runCheck('setupMultistream', async () => {
- await this.restreamService.addTargets(platforms, destinations);
+ await this.restreamService.addTargets(platforms, destinations, displaysToSetup);
});
} catch (e: unknown) {
const errorType = this.handleTypedStreamError(
@@ -1134,7 +1414,20 @@ export class StreamingService
'RESTREAM_UPDATE_FAILED',
'Failed to add restream targets while live',
);
- throwStreamError(errorType);
+
+ // The displays that were already live keep streaming. Don't remove any targets that were successfully added.
+ // This is in case the update partially succeeded and the user wants to try again.
+ this.rethrowStreamError(e, errorType);
+ }
+
+ // Update checklist for added custom destinations
+ // Note: Custom destinations show as a single checklist item for all custom destinations
+ // because there is nothing to set up for these destinations
+ if (destinations.length > 0) {
+ await this.runCheck('destination', async () => {
+ // Delay for UI animation
+ await new Promise(resolve => setTimeout(resolve, 300));
+ });
}
}
@@ -1167,7 +1460,42 @@ export class StreamingService
'Failed to remove restream targets while live',
);
- throwStreamError(errorType);
+ // The displays that were already live keep streaming. The Edit Stream window has already
+ // persisted the new targets as enabled, so revert them here. Otherwise the saved settings
+ // claim targets that are not streaming, which breaks the checks that decide whether a
+ // display still has targets when the user next removes one.
+ this.restoreFailedTargets(platforms, destinations);
+
+ this.rethrowStreamError(e, errorType);
+ }
+
+ // Update checklist for removed custom destinations
+ // Note: Custom destinations show as a single checklist item for all custom destinations
+ // because there is nothing to set up for these destinations
+ if (destinations.length > 0) {
+ await this.runCheck('destination', async () => {
+ // Delay for UI animation
+ await new Promise(resolve => setTimeout(resolve, 300));
+ });
+ }
+
+ // Stop streaming displays that no longer have any targets
+ if (
+ !this.views.horizontalStream.length &&
+ this.contexts.horizontal.streaming &&
+ this.state.status.horizontal.streaming !== EStreamingState.Offline
+ ) {
+ this.isUpdatingHorizontalStream = true;
+ this.contexts.horizontal.streaming.stop(true);
+ }
+
+ if (
+ !this.views.verticalStream.length &&
+ this.contexts.vertical.streaming &&
+ this.state.status.vertical.streaming !== EStreamingState.Offline
+ ) {
+ this.isUpdatingVerticalStream = true;
+ this.contexts.vertical.streaming.stop(true);
}
}
@@ -1220,26 +1548,23 @@ export class StreamingService
}
/**
- * Diff enabled custom destinations against currently active custom destinations
- *
- * @remark Uses `url + streamKey` as a composite key to uniquely identify destinations.
- * Categorizes into the same three buckets as {@link parseUpdatePlatforms}.
- *
+ * Compare enabled and active custom destinations
+ * @remark Primarily used to update custom destinations while live
* @param enabledDestinations - Custom destinations enabled in the Go Live window
- * @param activeDestinations - Custom destinations currently streaming
- * @returns Destinations grouped by action: `continue`, `stop`, and `start`
+ * @param activeDestinations - Custom destinations that are currently live
+ * @returns Custom destinations to continue, stop, and start
*/
parseUpdateCustomDestinations(
enabledDestinations: ICustomStreamDestination[],
activeDestinations: ICustomStreamDestination[],
) {
- const active = new Set(activeDestinations.map(dest => `${dest.url}${dest.streamKey}`));
- const enabled = new Set(enabledDestinations.map(dest => `${dest.url}${dest.streamKey}`));
+ const active = new Set(activeDestinations.map(d => getDestinationId(d)));
+ const enabled = new Set(enabledDestinations.map(d => getDestinationId(d)));
const destinations = enabledDestinations.reduce(
(acc, dest) => {
- const url = `${dest.url}${dest.streamKey}`;
- if (active.has(url)) {
+ const id: TDestinationId = getDestinationId(dest);
+ if (active.has(id)) {
acc.continue.push(dest);
} else {
acc.start.push(dest);
@@ -1254,8 +1579,8 @@ export class StreamingService
);
destinations.stop = activeDestinations.reduce((acc, dest) => {
- const url = `${dest.url}${dest.streamKey}`;
- if (!enabled.has(url)) {
+ const id: TDestinationId = getDestinationId(dest);
+ if (!enabled.has(id)) {
acc.push(dest);
}
return acc;
@@ -1290,22 +1615,44 @@ export class StreamingService
(e.type as TStreamErrorType) === 'PLATFORM_REQUEST_FAILED'
? 'SETTINGS_UPDATE_FAILED'
: e.type || 'UNKNOWN_ERROR';
- return this.handleTypedStreamError(e, type, message, platform);
+ // `type` is already derived from `e.type` here, including the deliberate cast above, so it
+ // must win over the error's own type
+ return this.handleTypedStreamError(e, type, message, platform, true);
} else {
return this.handleTypedStreamError(e, 'SETTINGS_UPDATE_FAILED', message, platform);
}
}
+ /**
+ * Set the error state from a caught error and resolve the type to report
+ * @param e - The caught error
+ * @param type - The type to fall back to when `e` does not carry one of its own
+ * @param message - The message to report
+ * @param platform - The platform the error belongs to, if any
+ * @param forceType - Use `type` even when `e` already has one. Only for callers that
+ * deliberately remap a type, such as `handleUpdatePlatformError`.
+ */
handleTypedStreamError(
e: StreamError | unknown,
type: TStreamErrorType,
message: string,
platform?: TPlatform,
+ forceType = false,
): TStreamErrorType {
+ // A `StreamError` thrown further down already names what failed, so `type` is only a fallback
+ // for errors that carry no type of their own. Using it unconditionally collapsed every
+ // restream failure back to the caller's generic type before it reached the user.
+ const resolvedType =
+ !forceType && e instanceof StreamError && e.type ? (e.type as TStreamErrorType) : type;
+
// restream errors returns an object with key value pairs for error details
const messages: string[] = [message];
const details: string[] = [];
+ // What the thrower said about this specific failure. It is the only part that names the
+ // target or display that failed, so prefer it over the generic fallback below.
+ const errorDetails = e instanceof StreamError && e.details ? e.details : undefined;
+
const defaultMessage =
this.state.info.error?.message ??
$t(
@@ -1313,7 +1660,7 @@ export class StreamingService
);
// Format the error message for restream errors to show the details to the user in the bypass error modal
- if (e && typeof e === 'object' && type.split('_').includes('RESTREAM')) {
+ if (e && typeof e === 'object' && resolvedType.split('_').includes('RESTREAM')) {
const platformName = platform || this.state.info.error?.platform;
const errorPlatform = platformName ? platformLabels(platformName) : undefined;
// If the error has a platform associated with it, specify the platform in the error message
@@ -1321,10 +1668,20 @@ export class StreamingService
const platformLabel = $t('%{platform} Error', { platform: errorPlatform });
details.push([platformLabel, message].join('. '));
} else {
- details.push(defaultMessage);
+ details.push(errorDetails ?? defaultMessage);
}
- Object.entries(e).forEach(([key, value]: [string, string]) => {
+ // Report a `StreamError` through its own serializable model. `getModel` is an instance
+ // property rather than a prototype method, so iterating the error listed it as a field and
+ // printed its function source, and it skipped `message`, which is non-enumerable on an
+ // `Error`. The model carries the message and drops the fields already reported separately.
+ const model: Record =
+ e instanceof StreamError ? e.getModel() : (e as Record);
+
+ Object.entries(model).forEach(([key, value]) => {
+ // Unset fields say nothing, and a function has no readable value
+ if (value == null || value === '' || typeof value === 'function') return;
+
const name = capitalize(key.replace(/([A-Z])/g, ' $1'));
// Never show the actual stream key and server url to the user for security purposes
if (['streamKey', 'serverUrl'].includes(key)) {
@@ -1337,7 +1694,7 @@ export class StreamingService
const status = this.state.info.error?.status ?? 400;
const streamError = createStreamError(
- type,
+ resolvedType,
{ status, statusText: $t('Multistream Error') + messages.join('. '), platform },
details.join('\n'),
);
@@ -1347,12 +1704,26 @@ export class StreamingService
}
if (e instanceof StreamError) {
- this.setError({ ...e, type });
- return e.type;
+ this.setError({ ...e, type: resolvedType });
+ return resolvedType;
}
- this.setError(type);
- return type;
+ this.setError(resolvedType);
+ return resolvedType;
+ }
+
+ /**
+ * Rethrow an error that has already been handled
+ * @remark `handleTypedStreamError` has set the error state by this point, so this only
+ * propagates the failure to the caller. Building a new error from the type alone drops the
+ * details naming the target or display that failed, and the caller shows those details to the
+ * user in place of its own generic message.
+ * @param e - The error that was caught and handled
+ * @param type - The type to throw with when `e` is not a `StreamError`
+ */
+ private rethrowStreamError(e: unknown, type: TStreamErrorType): never {
+ if (e instanceof StreamError) throw e;
+ throwStreamError(type);
}
/**
@@ -2032,9 +2403,9 @@ export class StreamingService
if (context === 'horizontal') {
await this.handleStartStreaming(code, context);
- }
-
- if (context === 'vertical') {
+ } else if (context === 'vertical' && this.views.isLiveOutputEditingEnabled) {
+ this.handleStartLiveOutputEditingStreamContext('vertical');
+ } else if (context === 'vertical') {
// This should not happen because the vertical stream is only created in dual output mode so reject the promise
this.handleCleanupStreamingInstances({ skipHorizontal: false });
@@ -2063,6 +2434,12 @@ export class StreamingService
}
}
+ private handleStartLiveOutputEditingStreamContext(display: TDisplayType) {
+ this.SET_STREAMING_STATUS(EStreamingState.Live, display, new Date().toISOString());
+ this.streamingStatusChange.next(EStreamingState.Live);
+ return;
+ }
+
/**
* Handle stopping the stream
* @remark Allows for consistency when handling stopping the stream in
@@ -2071,6 +2448,10 @@ export class StreamingService
* @param force - boolean, whether to force stop the stream
*/
private async handleStopStreaming(force?: boolean) {
+ if (this.views.isLiveOutputEditingEnabled) {
+ this.resetLiveOutputEditing();
+ }
+
// Twitch dual streaming uses the `enhancedBroadcasting` instance but most of the
// streaming signal handling work with the `horizontal` instance. Because the `horizontal`
// instance is not streaming, but may exist to be used with the recording and replay buffer,
@@ -2164,13 +2545,7 @@ export class StreamingService
private stopActiveStreamingInstances(force?: boolean): boolean {
let stopped = false;
- const contextNames: TOutputContext[] = [
- 'vertical',
- 'horizontal',
- 'enhancedBroadcasting',
- 'stream',
- 'streamSecond',
- ];
+ const contextNames: TOutputContext[] = ['vertical', 'horizontal', 'enhancedBroadcasting'];
contextNames.forEach(contextName => {
const streaming = this.contexts[contextName].streaming;
@@ -2979,6 +3354,18 @@ export class StreamingService
const time = new Date().toISOString();
if (info.signal === EOBSOutputSignal.Start) {
+ if (this.views.isLiveOutputEditingEnabled) {
+ // Only send the `Start` signal for the new streaming context if creating mid-stream.
+ // Running the full start streaming flow would restart any streaming, recording, and
+ // replay buffer instances that are already running, interrupting them.
+ if (this.isDisplayContext(context) && this.addingDisplayTargets.has(context)) {
+ this.addingDisplayTargets.delete(context);
+ this.handleStartLiveOutputEditingStreamContext(context);
+ this.numInstances++;
+ return;
+ }
+ }
+
if (this.views.isDualOutputMode) {
await this.handleStartDualOutputStream(info.signal, context, time);
} else {
@@ -3001,7 +3388,7 @@ export class StreamingService
// which happens below
} else if (info.signal === EOBSOutputSignal.Stopping) {
// Ignore stopping signals when updating stream targets mid-stream
- if (this.isUpdatingStreamTarget || this.isUpdatingStreamSecondTarget) return;
+ if (this.isUpdatingHorizontalStream || this.isUpdatingVerticalStream) return;
const isEnhancedBroadcastDualOutputStopping =
this.views.isDualOutputMode &&
@@ -3026,12 +3413,47 @@ export class StreamingService
} else if (info.signal === EOBSOutputSignal.Deactivate) {
// The `deactivate` signal is sent after the `stop` signal
- // Reset mid-stream update flags
- if (this.isUpdatingStreamTarget && context === 'horizontal') {
- this.isUpdatingStreamTarget = false;
- }
- if (this.isUpdatingStreamSecondTarget && context === 'vertical') {
- this.isUpdatingStreamSecondTarget = false;
+ // Even though this flag is checked with `isLiveOutputEditingEnabled`, check for it here to preserve the
+ // existing behavior of what is currently live. This is to preserve testing the if/else logic for only
+ // users with the flag. In other places, the `isLiveOutputEditingEnabled` check is enough to gate the logic.
+ if (
+ this.incrementalRolloutService.views.featureIsEnabled(EAvailableFeatures.liveOutputEditing)
+ ) {
+ if (this.views.isLiveOutputEditingEnabled) {
+ // When live output editing, if a display has no targets left the streaming context
+ // and it is not being used for recording or replay buffer, that display's streaming instance
+ // should be cleaned up
+ const isUpdatingTarget =
+ (this.isUpdatingHorizontalStream && context === 'horizontal') ||
+ (this.isUpdatingVerticalStream && context === 'vertical');
+
+ if (this.isUpdatingHorizontalStream && context === 'horizontal') {
+ this.isUpdatingHorizontalStream = false;
+ }
+ if (this.isUpdatingVerticalStream && context === 'vertical') {
+ this.isUpdatingVerticalStream = false;
+ }
+
+ if (isUpdatingTarget) {
+ // This display lost its last target while the other display keeps streaming, so only
+ // destroy this display's contexts. `handleCleanupStreamingInstances` below would stop
+ // every other streaming context, which would end the stream on the display that is
+ // still live. Set the status before destroying so that `handleDestroyOutputContexts`
+ // the display status is `Offline` so the streaming instance is destroyed correctly.
+ this.SET_STREAMING_STATUS(nextState, context, time);
+ await this.handleDestroyOutputContexts(context);
+ this.streamingStatusChange.next(nextState);
+ return;
+ }
+ } else {
+ // Reset mid-stream update flags
+ if (this.isUpdatingHorizontalStream && context === 'horizontal') {
+ this.isUpdatingHorizontalStream = false;
+ }
+ if (this.isUpdatingVerticalStream && context === 'vertical') {
+ this.isUpdatingVerticalStream = false;
+ }
+ }
}
// Handle stopping recording and replay buffer started by AI Highlighter.
@@ -3620,8 +4042,6 @@ export class StreamingService
return (
this.contexts.horizontal?.streaming ??
this.contexts.vertical?.streaming ??
- this.contexts.stream?.streaming ??
- this.contexts.streamSecond?.streaming ??
this.contexts.enhancedBroadcasting?.streaming ??
null
);
@@ -4326,6 +4746,10 @@ export class StreamingService
}
private handleCleanupStreamingInstances({ skipHorizontal = false }) {
+ if (this.views.isLiveOutputEditingEnabled) {
+ this.resetLiveOutputEditing();
+ }
+
for (const contextName of Object.keys(this.contexts) as TOutputContext[]) {
if (
(contextName === 'horizontal' && skipHorizontal) ||
@@ -4513,6 +4937,19 @@ export class StreamingService
}
}
+ /**
+ * Clear every flag tracking a display that is mid-transition from a target update
+ * @remark Call this anywhere the stream is torn down. These flags change how the `start` and
+ * `stopping` signals are handled, so one left set after its display is gone would misroute the
+ * next legitimate signal. `isUpdatingHorizontalStream` and `isUpdatingVerticalStream` are
+ * otherwise only cleared on the `deactivate` signal, which never arrives if the stop fails.
+ */
+ private resetLiveOutputEditing() {
+ this.addingDisplayTargets.clear();
+ this.isUpdatingHorizontalStream = false;
+ this.isUpdatingVerticalStream = false;
+ }
+
/**
* Log the current state of the streaming contexts
* @remark Used for debugging purposes
From c26f45a52f5f309841914affc4397a39b46bf30a Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:02:32 -0700
Subject: [PATCH 14/18] Go live module changes.
---
.../windows/go-live/useGoLiveSettings.ts | 200 ++++++++++++++----
1 file changed, 163 insertions(+), 37 deletions(-)
diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts
index 66d35d77ddf2..c76adab55c0c 100644
--- a/app/components-react/windows/go-live/useGoLiveSettings.ts
+++ b/app/components-react/windows/go-live/useGoLiveSettings.ts
@@ -5,7 +5,7 @@ import {
platformList,
TPlatform,
} from '../../../services/platforms';
-import { ICustomStreamDestination } from 'services/settings/streaming';
+import { getDestinationId, ICustomStreamDestination } from 'services/settings/streaming';
import { Services } from '../../service-provider';
import cloneDeep from 'lodash/cloneDeep';
import { FormInstance } from 'antd/lib/form';
@@ -138,11 +138,6 @@ class GoLiveSettingsState extends StreamInfoView {
id: otherEnabledTargetIndex.toString(),
};
- console.log(
- 'updating ',
- this.state.customDestinations[otherEnabledTargetIndex]?.name,
- ' to disabled',
- );
alertInfo({
name: 'both-display-info-alert',
text: $t(
@@ -263,9 +258,11 @@ class GoLiveSettingsState extends StreamInfoView {
(Object.keys(fields) as TCommonFieldName[]).forEach((fieldName: TCommonFieldName) => {
const view = this.getView();
const value = fields[fieldName];
- const platforms = shouldChangeAllPlatforms
- ? view.platformsWithoutCustomFields
- : view.enabledPlatforms;
+ // In the Edit Stream window, skip platforms using custom fields
+ const platforms =
+ shouldChangeAllPlatforms || this.state.isUpdateMode
+ ? view.platformsWithoutCustomFields
+ : view.enabledPlatforms;
platforms.forEach(platform => {
if (!view.supports(fieldName, [platform])) return;
const platformSettings = getDefined(this.state.platforms[platform]);
@@ -298,6 +295,12 @@ export class GoLiveSettingsModule {
cooldownTimer = new Subject();
+ /**
+ * Whether the targets have already been restored on teardown
+ * @remark `destroy()` fires more than once as slap disposes nested scopes
+ */
+ private targetsRestored = false;
+
constructor(
public form: FormInstance,
public isUpdateMode: boolean,
@@ -315,9 +318,18 @@ export class GoLiveSettingsModule {
);
}
- // determine if TikTok apply notification should be shown
+ // Determine if TikTok apply notification should be shown
Services.TikTokService.actions.handleApplyPrompt();
+ // Determine if Stream Shift prompt should be shown
+ // Always check is live because checking also resets the stream shift state for non-ultra users
+ // This is not awaited because it only decides whether the prompt appears and nothing else
+ // that is rendered depends on the result. Also don't check in update mode because any stream
+ // using restream will show as live, because it is live, just not with stream shift.
+ if (!this.isUpdateMode && Services.RestreamService.views.streamShiftStatus !== 'pending') {
+ Services.RestreamService.actions.checkIsLive();
+ }
+
await this.prepopulate();
}
@@ -376,6 +388,15 @@ export class GoLiveSettingsModule {
settings.streamShift = false;
}
+ /**
+ * The two features are mutually exclusive, so a persisted pair with both switched on would
+ * disable both cards and leave the user unable to switch either off. Live output editing wins,
+ * matching `isStreamShiftDisabled`. Already gated by the feature flag via `savedLiveOutputEditing`.
+ */
+ if (settings.liveOutputEditing && settings.streamShift) {
+ settings.streamShift = false;
+ }
+
this.state.updateSettings(settings);
/* If the user was in dual output before but doesn't have restream
@@ -546,12 +567,18 @@ export class GoLiveSettingsModule {
Services.UserService.actions.setPrimaryPlatform(platform);
}
+ /**
+ * Whether any target has been added to or removed from the stream
+ * @remark Mirrors `shouldUpdateRestream` in the streaming service, so it also answers whether the
+ * restream step will run. Custom destinations are keyed by url and stream key together, the same
+ * way `parseUpdateCustomDestinations` identifies them — the stream key alone is not unique.
+ */
get isUpdatingTargets() {
return (
xorWith(this.activePlatforms, this.state.enabledPlatforms, isEqual).length > 0 ||
xorWith(
- this.activeDestinations?.map(dest => dest.streamKey),
- this.state.customDestinations.filter(dest => dest.enabled).map(dest => dest.streamKey),
+ this.activeDestinations?.map(d => getDestinationId(d)),
+ this.state.customDestinations.filter(dest => dest.enabled).map(d => getDestinationId(d)),
isEqual,
).length > 0
);
@@ -565,9 +592,7 @@ export class GoLiveSettingsModule {
isTargetLive(target: TPlatform | number) {
if (typeof target === 'number') {
const dest = this.state.customDestinations[target];
- return this.activeDestinations?.some(
- d => `{${d.url}${d.streamKey}` === `{${dest.url}${dest.streamKey}`,
- );
+ return this.activeDestinations?.some(d => getDestinationId(d) === getDestinationId(dest));
} else {
return this.activePlatforms?.includes(target);
}
@@ -575,6 +600,15 @@ export class GoLiveSettingsModule {
setStreamShift(status: boolean) {
this.state.toggleStreamShift(status);
+
+ // The two features are mutually exclusive. `isLiveOutputEditingDisabled` stops live output
+ // editing being switched on while stream shift is active, so turn it off here to close the
+ // other direction — including when accepting a detected switch, which enables stream shift
+ // without the user touching either toggle.
+ if (status && this.state.isLiveOutputEditingEnabled) {
+ this.state.toggleLiveOutputEditing(false);
+ }
+
this.save(this.state.settings);
}
@@ -609,6 +643,13 @@ export class GoLiveSettingsModule {
* Validate the form and show an error message
*/
async validate() {
+ if (
+ Services.RestreamService.views.streamShiftStatus === 'pending' &&
+ !Services.RestreamService.views.streamShiftForceGoLive
+ ) {
+ return true;
+ }
+
if (this.getIsInvalidDualStream()) {
alertInfo({
name: 'ultra-required-alert',
@@ -618,7 +659,7 @@ export class GoLiveSettingsModule {
return;
}
- if (!this.isPrime && this.state.isDualOutputMode) {
+ if (!this.state.settings.streamShift && !this.isPrime && this.state.isDualOutputMode) {
const totalEnabled =
this.state.enabledPlatforms.length +
this.state.customDestinations.filter(d => d.enabled).length;
@@ -667,14 +708,24 @@ export class GoLiveSettingsModule {
* Validate the form and send new settings for each eligible platform
*/
async updateStream() {
- if (
- (await this.validate()) &&
- (await Services.StreamingService.actions.return.updateStreamSettings(
+ if (!(await this.validate())) return;
+
+ let updated = false;
+ try {
+ updated = await Services.StreamingService.actions.return.updateStreamSettings(
this.state.settings,
this.activePlatforms,
this.activeDestinations,
- ))
- ) {
+ );
+ } catch (e: unknown) {
+ // The error is surfaced by the streaming service through the Go Live checklist, so just
+ // stop here. Any stream that was already live is unaffected.
+ console.error('Error updating stream settings', e);
+ this.syncToLiveTargets();
+ return;
+ }
+
+ if (updated) {
message.success($t('Successfully updated'));
// Handle add/remove targets when updating a stream while live
@@ -686,9 +737,92 @@ export class GoLiveSettingsModule {
this.activePlatforms = this.state.enabledPlatforms;
this.activeDestinations = this.state.customDestinations.filter(dest => dest.enabled);
}
+ } else {
+ message.error(
+ $t('Error updating stream settings. Please check your settings and try again.'),
+ );
+ this.syncToLiveTargets();
}
}
+ /**
+ * Restore targets in the Edit Stream window
+ * @remark The destination switchers in the Edit Stream window persist a target as soon as it is
+ * switched, so a user who toggles one and closes the window without updating would leave the
+ * saved settings claiming a target that never started, or dropping one that is still streaming.
+ * Reset the enabled flags to the snapshot taken when the window opened. `updateStream` refreshes
+ * that snapshot on success, so this is a no-op once an update has actually been applied.
+ * @remark When called from the `destroy()` hook of the Edit Stream window's `extend()`, two rules
+ * apply to anything called from one of those, and getting either wrong fails at window close
+ * where it is easy to miss:
+ *
+ * 1. It runs more than once. `extend()` registers the returned object as a child provider of the
+ * module (see slap's `useComponentView`), and on unmount both the child provider's own
+ * `unregister` and the parent module's `childScope.dispose()` reach it.
+ * 2. Module state is already gone. Reading `this.state` throws, because the state controller is
+ * disposed by the time the `useComponentView` hook runs. Read service state or plain fields
+ * on the module instead because `activePlatforms` and `activeDestinations` survive since they
+ * are constructor properties, and the settings are read back from `StreamingService.views`.
+ *
+ * `GoLiveWindow`'s `destroy()` satisfies both without a guard: `resetInfo()` is maintains the same
+ * values and `module.checklist` resolves to streaming service state rather than module state.
+ */
+ restoreTargets() {
+ if (!this.isUpdateMode || !this.activePlatforms || !this.activeDestinations) return;
+ if (this.targetsRestored) return;
+ this.targetsRestored = true;
+
+ const livePlatforms = new Set(this.activePlatforms);
+ const liveDestinations = new Set(
+ this.activeDestinations.map(dest => `${dest.url}/${dest.streamKey}`),
+ );
+
+ const savedSettings = Services.StreamingService.views.savedSettings;
+ if (!savedSettings?.platforms) return;
+
+ // Restore platforms
+ const platforms = cloneDeep(savedSettings.platforms);
+ (Object.keys(platforms) as TPlatform[]).forEach(platform => {
+ const platformSettings = platforms[platform];
+ if (!platformSettings) return;
+ platformSettings.enabled = livePlatforms.has(platform);
+ });
+
+ // Restore custom destinations
+ const customDestinations = (savedSettings.customDestinations ?? []).map(dest => ({
+ ...dest,
+ enabled: liveDestinations.has(`${dest.url}/${dest.streamKey}`),
+ }));
+
+ // Must use `setGoLiveSettings` instead of the module's `updateSettings` because the
+ // module state is already gone when this is called from `destroy()`.
+ Services.StreamSettingsService.actions.setGoLiveSettings({
+ platforms,
+ customDestinations,
+ });
+ }
+
+ /**
+ * Show the targets that are actually streaming after a failed update
+ * @remark The destination switchers persist its target as soon as it is toggled, before the update runs, so a
+ * failure leaves the switchers showing targets that never started or never stopped. The streaming service already
+ * corrected the saved settings against the server, so read them back here to re-render.
+ * `activePlatforms` and `activeDestinations` update to match so `isTargetLive` and `isUpdatingTargets` show the actual state.
+ */
+ private syncToLiveTargets() {
+ if (!this.isUpdateMode) return;
+
+ const savedSettings = this.state.savedSettings;
+
+ this.state.updateSettings({
+ platforms: savedSettings.platforms,
+ customDestinations: savedSettings.customDestinations,
+ });
+
+ this.activePlatforms = this.state.enabledPlatforms;
+ this.activeDestinations = this.state.customDestinations.filter(dest => dest.enabled);
+ }
+
/**
* Returns whether the user has any active destinations, be it an enabled platform or a custom destination
*/
@@ -725,23 +859,11 @@ export class GoLiveSettingsModule {
}
get isStreamShiftDisabled() {
- if (!this.isPrime) return true;
- return this.isPatreonEnabled;
- }
-
- /**
- * Override the default behavior of toggling stream shift so that the user is still
- * able to toggle stream shift on/off when they have a single platform enabled and
- * that platform has its display set to 'both'. Otherwise, the isDualOutputMode check
- * would prevent the user from toggling stream shift on/off.
- * Note: This should never happen but is a failsafe in case something goes wrong with
- * the Go Live window's state.
- */
- get forceStreamShiftToggleEnabled() {
return (
- this.state.isStreamShiftMode &&
- this.state.enabledPlatforms.length === 1 &&
- this.state.settings.platforms[this.state.enabledPlatforms[0]]?.display === 'both'
+ !this.isPrime ||
+ this.isPatreonEnabled ||
+ this.state.isLiveOutputEditingEnabled ||
+ this.isDualOutputMode
);
}
@@ -758,6 +880,10 @@ export class GoLiveSettingsModule {
return this.state.enabledPlatforms.length;
}
+ get enabledCustomDestinations() {
+ return this.state.customDestinations.filter(dest => dest.enabled);
+ }
+
get canAddDestinations() {
return (
this.state.linkedPlatforms.length + this.state.customDestinations.length < maxNumPlatforms + 5
From f9b6934438104e819cf10015b6d122c14cc00d1a Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 9 Sep 2026 09:03:29 -0700
Subject: [PATCH 15/18] Edit stream window UI.
---
.../windows/go-live/DestinationSwitchers.tsx | 65 ++++-
.../windows/go-live/EditStreamWindow.tsx | 234 ++++++++++++++----
.../windows/go-live/GoLiveChecklist.tsx | 170 +++++++++++--
.../windows/go-live/PlatformSettings.tsx | 126 ++--------
4 files changed, 408 insertions(+), 187 deletions(-)
diff --git a/app/components-react/windows/go-live/DestinationSwitchers.tsx b/app/components-react/windows/go-live/DestinationSwitchers.tsx
index 4d36db1a2ceb..28b1876b9f52 100644
--- a/app/components-react/windows/go-live/DestinationSwitchers.tsx
+++ b/app/components-react/windows/go-live/DestinationSwitchers.tsx
@@ -23,7 +23,7 @@ import AnimatedWrapper from 'components-react/shared/AnimatedWrapper';
/**
* Allows enabling/disabling platforms and custom destinations for the stream
*/
-export const DestinationSwitchers = memo(() => {
+export const DestinationSwitchers = memo((p: { disabled?: boolean }) => {
const {
enabledPlatforms,
customDestinations,
@@ -40,6 +40,9 @@ export const DestinationSwitchers = memo(() => {
isLoading,
isFacebookGrandfathered,
isTikTokGrandfathered,
+ activeDisplayPlatforms,
+ isUpdateMode,
+ isLiveOutputEditingEnabled,
} = useGoLiveSettings().extend(module => ({
get renderedPlatforms() {
// Some platforms are always shown, even if not linked so add them to the list of platforms to display
@@ -59,6 +62,35 @@ export const DestinationSwitchers = memo(() => {
const enabledDestRef = useRef(enabledDestinations);
enabledDestRef.current = enabledDestinations;
+ const disabledHorizontalUltraSwitcher = useMemo(() => {
+ // In live output editing, the user is able to toggle platforms freely, so don't disable any switchers
+ if (isLiveOutputEditingEnabled) return null;
+ // In dual output mode, if there is only one platform for a display orientation, disable the switcher for that platform
+ if (
+ isDualOutputMode &&
+ isPrime &&
+ isUpdateMode &&
+ activeDisplayPlatforms.horizontal.length < 2
+ ) {
+ return activeDisplayPlatforms.horizontal[0];
+ }
+
+ // By default, don't disable any switchers
+ return null;
+ }, [isDualOutputMode, isPrime, isUpdateMode, activeDisplayPlatforms, isLiveOutputEditingEnabled]);
+
+ const disabledVerticalUltraSwitcher = useMemo(() => {
+ // In live output editing, the user is able to toggle platforms freely, so don't disable any switchers
+ if (isLiveOutputEditingEnabled) return null;
+ // In dual output mode, if there is only one platform for a display orientation, disable the switcher for that platform
+ if (isDualOutputMode && isPrime && isUpdateMode && activeDisplayPlatforms.vertical.length < 2) {
+ return activeDisplayPlatforms.vertical[0];
+ }
+
+ // By default, don't disable any switchers
+ return null;
+ }, [isDualOutputMode, isPrime, isUpdateMode, activeDisplayPlatforms, isLiveOutputEditingEnabled]);
+
const emitSwitch = useDebounce(500, (ind?: number, enabled?: boolean) => {
if (ind !== undefined && enabled !== undefined) {
switchCustomDestination(ind, enabled);
@@ -80,6 +112,14 @@ export const DestinationSwitchers = memo(() => {
const togglePlatform = useCallback(
(platform: TPlatform, enabled: boolean) => {
+ // In update mode, prevent toggling off the last platform for a display orientation
+ if (
+ disabledHorizontalUltraSwitcher === platform ||
+ disabledVerticalUltraSwitcher === platform
+ ) {
+ return enabledPlatformsRef.current.includes(platform);
+ }
+
// Only allow non-ultra users to have 2 platforms, or 1 platform and 1 custom destination enabled
if (!isPrime) {
return toggleNonUltraPlatform(platform, enabled);
@@ -99,7 +139,7 @@ export const DestinationSwitchers = memo(() => {
emitSwitch();
return enabledPlatformsRef.current.includes(platform);
},
- [emitSwitch, isPrime],
+ [emitSwitch, isPrime, disabledHorizontalUltraSwitcher, disabledVerticalUltraSwitcher],
);
const toggleNonUltraPlatform = useCallback(
@@ -215,7 +255,8 @@ export const DestinationSwitchers = memo(() => {
const enabled = isEnabled(platform);
const disabledByBoth =
!!nonPrimeBothDisplayPlatform && !enabled && platform !== nonPrimeBothDisplayPlatform;
- const switchDisabled = (!enabled && disableNonUltraSwitchers) || disabledByBoth;
+ const switchDisabled =
+ p?.disabled || (!enabled && disableNonUltraSwitchers) || disabledByBoth;
const bothDisplayPlatformLabel = disabledByBoth
? platformLabels(nonPrimeBothDisplayPlatform!)
: undefined;
@@ -230,6 +271,10 @@ export const DestinationSwitchers = memo(() => {
switchDisabled={switchDisabled}
bothDisplayPlatformLabel={bothDisplayPlatformLabel}
showDisplaySelector={visible}
+ showDisabledAlert={
+ disabledHorizontalUltraSwitcher === platform ||
+ disabledVerticalUltraSwitcher === platform
+ }
isPrime={isPrime}
username={getUsername(platform)}
index={ind}
@@ -241,6 +286,7 @@ export const DestinationSwitchers = memo(() => {
{customDestinations?.map((dest, ind) => {
const disabledByBoth = !!nonPrimeBothDisplayPlatform && !dest.enabled;
const switchDisabled =
+ p?.disabled ||
disableCustomDestinationSwitchers ||
(!dest.enabled && disableNonUltraSwitchers) ||
disabledByBoth;
@@ -280,6 +326,7 @@ interface IDestinationSwitcherProps {
isPrime: boolean;
username?: string;
isUnlinked?: boolean;
+ showDisabledAlert?: boolean;
/** Disable the switch while the go live window is loading/refreshing settings */
isLoading?: boolean;
}
@@ -345,10 +392,18 @@ const DestinationSwitcher = memo(
return onChange(!enabled);
}
- if (disabled) return enabled;
+ if (disabled || p.showDisabledAlert) {
+ if (p.showDisabledAlert) {
+ alertInfo({
+ name: 'switcher-info-alert',
+ text: $t('Cannot toggle off only enabled platform for this display orientation.'),
+ });
+ }
+ return enabled;
+ }
return onChange(!enabled);
},
- [p.enabled, p.isLoading, onChange, p.bothDisplayPlatformLabel, disabled],
+ [p.enabled, p.isLoading, onChange, p.bothDisplayPlatformLabel, disabled, p.showDisabledAlert],
);
const { title, description } = useMemo(() => {
diff --git a/app/components-react/windows/go-live/EditStreamWindow.tsx b/app/components-react/windows/go-live/EditStreamWindow.tsx
index 63c77da3d47b..715f5b820e67 100644
--- a/app/components-react/windows/go-live/EditStreamWindow.tsx
+++ b/app/components-react/windows/go-live/EditStreamWindow.tsx
@@ -1,27 +1,49 @@
import styles from './GoLive.m.less';
import { ModalLayout } from '../../shared/ModalLayout';
-import { Button } from 'antd';
-import { useOnCreate } from 'slap';
+import { Button, Col, Row } from 'antd';
+import { inject, useOnCreate } from 'slap';
import { Services } from '../../service-provider';
-import React from 'react';
+import React, { memo, useEffect, useState } from 'react';
import { $t } from '../../../services/i18n';
import GoLiveChecklist from './GoLiveChecklist';
import Form from '../../shared/inputs/Form';
import Animation from 'rc-animate';
-import { useGoLiveSettingsRoot } from './useGoLiveSettings';
-import GoLiveSettings from './GoLiveSettings';
-import TwitterInput from './Twitter';
+import { useGoLiveSettings, useGoLiveSettingsRoot } from './useGoLiveSettings';
+import PlatformSettings from './PlatformSettings';
+import Scrollable from '../../shared/Scrollable';
+import Spinner from '../../shared/Spinner';
+import GoLiveError from './GoLiveError';
+import PrimaryChatSwitcher from './PrimaryChatSwitcher';
+import { DestinationSwitchers } from './DestinationSwitchers';
+import cx from 'classnames';
+import { CaretDownOutlined } from '@ant-design/icons';
+import GoLiveInfoBanner from './GoLiveInfoBanner';
+import { WindowsService } from 'services/windows';
+import { StreamingService } from 'services/streaming';
export default function EditStreamWindow() {
- const { StreamingService, WindowsService } = Services;
- const { error, lifecycle, updateStream, prepopulate, isLoading, form } = useGoLiveSettingsRoot({
- isUpdateMode: true,
- });
+ const { StreamingService } = Services;
+ const {
+ prepopulate,
+ form,
+ shouldShowSettings,
+ shouldShowChecklist,
+ cooldownTimer,
+ } = useGoLiveSettingsRoot({ isUpdateMode: true }).extend(module => ({
+ destroy() {
+ // Toggling a target persists it immediately, but it only reaches the stream on Update, so
+ // drop anything the user switched and then closed the window without applying.
+ module.restoreTargets();
+ },
+
+ get shouldShowChecklist() {
+ return module.lifecycle === 'runChecklist';
+ },
- const shouldShowChecklist = lifecycle === 'runChecklist';
- const shouldShowSettings = !shouldShowChecklist;
- const shouldShowUpdateButton = lifecycle !== 'runChecklist';
- const shouldShowGoBackButton = !shouldShowUpdateButton && error;
+ get shouldShowSettings() {
+ return module.lifecycle !== 'runChecklist';
+ },
+ }));
useOnCreate(() => {
// the streamingService still may keep a error from GoLive flow like a "Post a Tweet" error
@@ -30,49 +52,42 @@ export default function EditStreamWindow() {
prepopulate();
});
- function close() {
- WindowsService.actions.closeChildWindow();
- }
-
- function goBackToSettings() {
- StreamingService.actions.showEditStream();
- }
-
- function renderFooter() {
- return (
-
- );
- }
+ // 10-second countdown timer state
+ const [timer, setTimer] = useState(null);
+ useEffect(() => {
+ const subscription = cooldownTimer.subscribe(() => {
+ setTimer(10);
+ });
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, []);
+
+ useEffect(() => {
+ // 3-second countdown timer for cooldown after adding/removing targets
+ if (timer && timer > 0) {
+ const timeout = setTimeout(() => {
+ setTimer(timer - 1);
+ }, 1000);
+ return () => clearTimeout(timeout);
+ } else if (timer === 0) {
+ // Clear the timer when it reaches 0
+ setTimer(null);
+ }
+ }, [timer]);
return (
-
+ } className={styles.goLiveSettings}>
);
}
+
+const EditStreamSettings = memo(function EditStreamSettings(p: { timer: number | null }) {
+ const {
+ shouldShowLeftCol,
+ isLoading,
+ enabledPlatforms,
+ hasMultiplePlatforms,
+ primaryChat,
+ setPrimaryChat,
+ } = useGoLiveSettings().extend(module => ({
+ get shouldShowLeftCol() {
+ return (
+ module.protectedModeEnabled && module.isMidStreamMode && module.isLiveOutputEditingEnabled
+ );
+ },
+ }));
+
+ const primaryChatSelectorDisabled = !hasMultiplePlatforms || p.timer !== null;
+
+ return (
+
+ {/*LEFT COLUMN*/}
+ {shouldShowLeftCol && (
+
+ {$t('Update Destinations & Outputs:')}
+
+
+
+
}
+ layout="horizontal"
+ logo={false}
+ border={false}
+ disabled={primaryChatSelectorDisabled}
+ />
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+});
+
+const EditStreamFooter = memo(function EditStreamFooter(p: { timer: number | null }) {
+ const {
+ isLoading,
+ shouldShowUpdateButton,
+ shouldShowGoBackButton,
+ updateStream,
+ closeChildWindow,
+ goBackToSettings,
+ } = useGoLiveSettings().extend(module => {
+ return {
+ windowsService: inject(WindowsService),
+ streamingService: inject(StreamingService),
+
+ closeChildWindow() {
+ this.windowsService.actions.closeChildWindow();
+ },
+ goBackToSettings() {
+ this.streamingService.actions.showEditStream();
+ },
+
+ get shouldShowUpdateButton() {
+ return module.lifecycle !== 'runChecklist';
+ },
+ get shouldShowGoBackButton() {
+ return module.lifecycle === 'runChecklist' && !!module.error;
+ },
+ };
+ });
+
+ const isCoolingDown = !!p.timer && p.timer > 0;
+
+ return (
+
+ );
+});
diff --git a/app/components-react/windows/go-live/GoLiveChecklist.tsx b/app/components-react/windows/go-live/GoLiveChecklist.tsx
index 771d4d28c844..11477216ec09 100644
--- a/app/components-react/windows/go-live/GoLiveChecklist.tsx
+++ b/app/components-react/windows/go-live/GoLiveChecklist.tsx
@@ -1,15 +1,17 @@
import { useGoLiveSettings } from './useGoLiveSettings';
import css from './GoLiveChecklist.m.less';
-import React, { HTMLAttributes, useEffect } from 'react';
+import React, { HTMLAttributes, useEffect, useMemo } from 'react';
import { Services } from '../../service-provider';
import { $t } from '../../../services/i18n';
import { TGoLiveChecklistItemState } from '../../../services/streaming';
+import { TDestinationId, getDestinationId } from '../../../services/settings/streaming';
import cx from 'classnames';
import GoLiveError from './GoLiveError';
import MessageLayout from './MessageLayout';
import { Timeline } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import Utils from '../../../services/utils';
+import { difference, intersection } from 'lodash';
/**
* Shows transition to live progress and helps troubleshoot related problems
@@ -28,10 +30,68 @@ export default function GoLiveChecklist(p: HTMLAttributes) {
getPlatformDisplayName,
isUpdateMode,
shouldShowOptimizedProfile,
+ showLiveOutputEditing,
+ stopTargets,
+ startTargets,
+ continueTargets,
+ stopDestinations,
+ startDestinations,
+ continueDestinations,
+ isLiveOutputEditingEnabled,
+ isUpdatingTargets,
} = useGoLiveSettings().extend(module => ({
get shouldShowOptimizedProfile() {
return VideoEncodingOptimizationService.state.useOptimizedProfile && !module.isUpdateMode;
},
+
+ get showLiveOutputEditing() {
+ return module.isLiveOutputEditingEnabled && module.isUpdateMode;
+ },
+
+ get stopTargets() {
+ return module.activePlatforms
+ ? difference(module.activePlatforms, module.enabledPlatforms)
+ : [];
+ },
+
+ get stopDestinations(): TDestinationId[] {
+ return module.activeDestinations
+ ? difference(
+ module.activeDestinations.map(d => getDestinationId(d)),
+ module.enabledCustomDestinations.map(d => getDestinationId(d)),
+ )
+ : [];
+ },
+
+ get startDestinations(): TDestinationId[] {
+ return module.activeDestinations
+ ? difference(
+ module.enabledCustomDestinations.map(d => getDestinationId(d)),
+ module.activeDestinations.map(d => getDestinationId(d)),
+ )
+ : [];
+ },
+
+ get startTargets() {
+ return module.activePlatforms
+ ? difference(module.enabledPlatforms, module.activePlatforms)
+ : [];
+ },
+
+ get continueTargets() {
+ return module.activePlatforms
+ ? intersection(module.enabledPlatforms, module.activePlatforms)
+ : [];
+ },
+
+ get continueDestinations(): TDestinationId[] {
+ return module.activeDestinations
+ ? intersection(
+ module.enabledCustomDestinations.map(d => getDestinationId(d)),
+ module.activeDestinations.map(dest => getDestinationId(dest)),
+ )
+ : [];
+ },
}));
const success = lifecycle === 'live';
@@ -50,29 +110,71 @@ export default function GoLiveChecklist(p: HTMLAttributes) {
function render() {
return (
-
{getHeaderText()}
+
{headerText}
- {/* PLATFORMS UPDATE */}
- {enabledPlatforms.map(platform =>
- renderCheck(
- $t('Update settings for %{platform}', {
- platform: getPlatformDisplayName(platform),
- }),
- checklist[platform],
- ),
+ {/* GO LIVE PLATFORMS UPDATE */}
+ {(!isUpdateMode || !showLiveOutputEditing) &&
+ enabledPlatforms.map(platform =>
+ renderCheck(
+ $t('Update settings for %{platform}', {
+ platform: getPlatformDisplayName(platform),
+ }),
+ checklist[platform],
+ ),
+ )}
+
+ {/* EDIT STREAM - STOP TARGETS */}
+ {showLiveOutputEditing && (
+ <>
+ {stopTargets.map(platform =>
+ renderCheck(
+ $t('Stop streaming to %{target}', {
+ target: getPlatformDisplayName(platform),
+ }),
+ checklist[platform],
+ ),
+ )}
+ {stopDestinations.length > 0 &&
+ renderCheck($t('Stop streaming to Custom Destination'), checklist.destination)}
+ >
+ )}
+
+ {/* EDIT STREAM - START TARGETS */}
+ {showLiveOutputEditing && (
+ <>
+ {startTargets.map(platform =>
+ renderCheck(
+ $t('Start streaming to %{target}', {
+ target: getPlatformDisplayName(platform),
+ }),
+ checklist[platform],
+ ),
+ )}
+ {startDestinations.length > 0 &&
+ renderCheck($t('Start streaming to Custom Destination'), checklist.destination)}
+ >
+ )}
+
+ {/* EDIT STREAM - CONTINUE/UPDATE TARGETS */}
+ {showLiveOutputEditing && (
+ <>
+ {continueTargets.map(platform =>
+ renderCheck(
+ $t('Update settings for %{platform}', {
+ platform: getPlatformDisplayName(platform),
+ }),
+ checklist[platform],
+ ),
+ )}
+ {continueDestinations.length > 0 &&
+ renderCheck($t('Continue streaming to Custom Destination'), checklist.destination)}
+ >
)}
{/* RESTREAM */}
- {!isUpdateMode &&
- isMultiplatformMode &&
- !isDualOutputMode &&
- renderCheck(
- isStreamShiftMode
- ? $t('Configure the Stream Shift service')
- : $t('Configure the Multistream service'),
- checklist.setupMultistream,
- )}
+ {shouldRenderMultistreamItem &&
+ renderCheck(multistreamItemText, checklist.setupMultistream)}
{/* DUAL OUTPUT */}
{!isUpdateMode &&
@@ -97,7 +199,21 @@ export default function GoLiveChecklist(p: HTMLAttributes) {
);
}
- function getHeaderText() {
+ const shouldRenderMultistreamItem = useMemo(() => {
+ // Check to render in Go Live checklist
+ if (!isUpdateMode && isMultiplatformMode) {
+ return true;
+ }
+
+ // Check to render in Edit Stream checklist
+ if (isUpdateMode && isUpdatingTargets) {
+ return true;
+ }
+
+ return false;
+ }, [isUpdateMode, isMultiplatformMode, isDualOutputMode, isUpdatingTargets]);
+
+ const headerText = useMemo(() => {
if (error) {
if (checklist.startVideoTransmission === 'done') {
return $t('Your stream has started, but there were issues with other actions taken');
@@ -109,7 +225,19 @@ export default function GoLiveChecklist(p: HTMLAttributes) {
return $t("You're live!");
}
return $t('Working on your live stream') + '...';
- }
+ }, [error, checklist.startVideoTransmission, lifecycle]);
+
+ const multistreamItemText = useMemo(() => {
+ if (isLiveOutputEditingEnabled) {
+ return $t('Configure the Live Output Editing service');
+ }
+
+ if (isStreamShiftMode) {
+ return $t('Configure the Stream Shift service');
+ }
+
+ return $t('Configure the Multistream service');
+ }, [isLiveOutputEditingEnabled, isStreamShiftMode]);
/**
* Renders a Timeline item in one of 4 states - 'not-started', 'pending', 'done', 'error'
diff --git a/app/components-react/windows/go-live/PlatformSettings.tsx b/app/components-react/windows/go-live/PlatformSettings.tsx
index d3d197bfc4a0..7c4669dbcf6f 100644
--- a/app/components-react/windows/go-live/PlatformSettings.tsx
+++ b/app/components-react/windows/go-live/PlatformSettings.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useCallback } from 'react';
+import React, { useCallback } from 'react';
import { CommonPlatformFields } from './CommonPlatformFields';
import { useGoLiveSettings } from './useGoLiveSettings';
import { $t } from '../../../services/i18n';
@@ -15,11 +15,12 @@ import { InstagramEditStreamInfo } from './platforms/InstagramEditStreamInfo';
import { KickEditStreamInfo } from './platforms/KickEditStreamInfo';
import { PatreonEditStreamInfo } from './platforms/PatreonEditStreamInfo';
import { TInputLayout } from 'components-react/shared/inputs';
-import { SwitcherCard } from './SwitcherCard';
-import UltraIcon from 'components-react/shared/UltraIcon';
import PrimaryChatSwitcher from './PrimaryChatSwitcher';
import { CaretDownOutlined } from '@ant-design/icons';
-import { Services } from 'components-react/service-provider';
+import LiveOutputEditingCard from './LiveOutputEditingCard';
+import StreamShiftCard from './StreamShiftCard';
+import styles from './GoLive.m.less';
+import cx from 'classnames';
export default function PlatformSettings() {
const {
@@ -35,22 +36,13 @@ export default function PlatformSettings() {
isDualOutputMode,
isAiHighlighterEnabled,
isStreamShiftMode,
- isStreamShiftDisabled,
- isPatreonEnabled,
isLiveOutputEditingEnabled,
- isLiveOutputEditingDisabled,
enabledPlatformsCount,
isMidStreamMode,
- isPrime,
primaryChat,
hasMultiplePlatforms,
setPrimaryChat,
- setStreamShift,
- setLiveOutputEditingEnabled,
- canEditLiveOutputs,
- liveOutputTooltip,
- streamShiftTooltip,
- disableStreamShiftTooltip,
+ showFeatureToggleCards,
} = useGoLiveSettings().extend(settings => ({
get descriptionIsRequired() {
const fbSettings = settings.state.platforms['facebook'];
@@ -60,74 +52,10 @@ export default function PlatformSettings() {
get layout(): TInputLayout {
return 'vertical';
},
-
- get liveOutputTooltip() {
- if (!isPrime) {
- return $t('Upgrade to Ultra to manage live outputs mid-stream.');
- }
-
- return '';
- },
-
- get streamShiftTooltip() {
- if (isPatreonEnabled) {
- return $t('Stream Shift cannot be used with Patreon');
- }
-
- if (!isPrime) {
- return $t('Upgrade to Ultra to switch streams between devices.');
- }
-
- if (isDualOutputMode) {
- return $t('Stream Shift cannot be used with Dual Output');
- }
-
- return '';
- },
-
- get disableStreamShiftTooltip() {
- return settings.isPrime && !settings.isStreamShiftDisabled;
- },
}));
const layoutMode = 'multiplatformAdvanced';
- const handleToggleStreamShift = useCallback(
- (status?: boolean) => {
- if (!isPrime) {
- // TODO: Comment in when ready
- // Services.MagicLinkService.actions.linkToPrime('slobs-streamswitcher', {
- // event: 'StreamShift',
- // });
- return;
- }
-
- setStreamShift(status ?? !isStreamShiftMode);
- Services.UsageStatisticsService.actions.recordAnalyticsEvent('StreamShift', {
- toggle: status ?? !isStreamShiftMode,
- });
- },
- [setStreamShift, isStreamShiftMode],
- );
-
- const handleToggleLiveOutputEditing = useCallback(
- (status?: boolean) => {
- if (!isPrime) {
- // TODO: Comment in when ready
- // Services.MagicLinkService.actions.linkToPrime('slobs-live-output-editing', {
- // event: 'LiveOutputEditing',
- // });
- return;
- }
-
- setLiveOutputEditingEnabled(status ?? !isLiveOutputEditingEnabled);
- Services.UsageStatisticsService.actions.recordAnalyticsEvent('LiveOutputEditing', {
- toggle: status ?? !isLiveOutputEditingEnabled,
- });
- },
- [setLiveOutputEditingEnabled, isLiveOutputEditingEnabled],
- );
-
const createPlatformBinding = useCallback(
(platform: T): IPlatformComponentParams => {
return {
@@ -137,6 +65,7 @@ export default function PlatformSettings() {
isStreamShiftMode,
isAiHighlighterEnabled,
isMidStreamMode,
+ isLiveOutputEditingEnabled,
enabledPlatformsCount,
get value() {
return getDefined(settings.platforms[platform]);
@@ -155,6 +84,7 @@ export default function PlatformSettings() {
isStreamShiftMode,
isAiHighlighterEnabled,
isMidStreamMode,
+ isLiveOutputEditingEnabled,
enabledPlatformsCount,
],
);
@@ -169,47 +99,19 @@ export default function PlatformSettings() {
return (
// minHeight is required for the loading spinner
- {canEditLiveOutputs && (
+ {showFeatureToggleCards && !isUpdateMode && (
<>
{$t('Live Settings')}
-
handleToggleLiveOutputEditing()}
- value={isLiveOutputEditingEnabled}
- title={
- <>
- {$t('Live output editing')}
- {!isPrime && }
- >
- }
- name="liveOutput"
- description={$t('Manage output destinations mid-stream.')}
- icon="icon-output"
- disabled={isLiveOutputEditingDisabled}
- tooltip={liveOutputTooltip}
- tooltipDisabled={isPrime}
- />
- handleToggleStreamShift()}
- value={isStreamShiftMode}
- title={
- <>
- {$t('Stream Shift')}
- {!isPrime && }
- >
- }
- name="streamShift"
- description={$t('Switch between devices while live.')}
- icon="icon-repeat-2"
- disabled={isStreamShiftDisabled}
- tooltip={streamShiftTooltip}
- tooltipDisabled={disableStreamShiftTooltip}
- />
+
+
>
)}
-
{$t('Channel Settings')}
+
+ {$t('Channel Settings')}
+
{/*COMMON FIELDS*/}
From d320c600dd9f1aec5740cececaa4a352f1203596 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 9 Sep 2026 12:51:31 -0700
Subject: [PATCH 16/18] Restore doce stream shift toggle enabled.
---
.../windows/go-live/useGoLiveSettings.ts | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts
index c76adab55c0c..0e9148c78732 100644
--- a/app/components-react/windows/go-live/useGoLiveSettings.ts
+++ b/app/components-react/windows/go-live/useGoLiveSettings.ts
@@ -867,6 +867,22 @@ export class GoLiveSettingsModule {
);
}
+ /**
+ * Override the default behavior of toggling stream shift so that the user is still
+ * able to toggle stream shift on/off when they have a single platform enabled and
+ * that platform has its display set to 'both'. Otherwise, the isDualOutputMode check
+ * would prevent the user from toggling stream shift on/off.
+ * Note: This should never happen but is a failsafe in case something goes wrong with
+ * the Go Live window's state.
+ */
+ get forceStreamShiftToggleEnabled() {
+ return (
+ this.state.isStreamShiftMode &&
+ this.state.enabledPlatforms.length === 1 &&
+ this.state.settings.platforms[this.state.enabledPlatforms[0]]?.display === 'both'
+ );
+ }
+
get isLiveOutputEditingDisabled() {
if (!this.isPrime) return true;
return this.state.isStreamShiftMode;
From 3790752659ba01b252ccb1184a6edfb04ad17240 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 9 Sep 2026 14:18:32 -0700
Subject: [PATCH 17/18] Feedback from code review.
---
.../windows/go-live/EditStreamWindow.tsx | 6 +++---
.../windows/go-live/GoLive.m.less | 4 ----
.../windows/go-live/useGoLiveSettings.ts | 4 +---
app/services/restream.ts | 9 +++++----
app/services/streaming/streaming.ts | 19 +++++++++++++++----
5 files changed, 24 insertions(+), 18 deletions(-)
diff --git a/app/components-react/windows/go-live/EditStreamWindow.tsx b/app/components-react/windows/go-live/EditStreamWindow.tsx
index 715f5b820e67..c9009254d049 100644
--- a/app/components-react/windows/go-live/EditStreamWindow.tsx
+++ b/app/components-react/windows/go-live/EditStreamWindow.tsx
@@ -66,7 +66,7 @@ export default function EditStreamWindow() {
}, []);
useEffect(() => {
- // 3-second countdown timer for cooldown after adding/removing targets
+ // 10-second countdown timer for cooldown after adding/removing targets
if (timer && timer > 0) {
const timeout = setTimeout(() => {
setTimer(timer - 1);
@@ -119,9 +119,9 @@ const EditStreamSettings = memo(function EditStreamSettings(p: { timer: number |
{/*LEFT COLUMN*/}
{shouldShowLeftCol && (
-
+
{$t('Update Destinations & Outputs:')}
-
+
`${dest.url}/${dest.streamKey}`),
- );
+ const liveDestinations = new Set(this.activeDestinations.map(dest => getDestinationId(dest)));
const savedSettings = Services.StreamingService.views.savedSettings;
if (!savedSettings?.platforms) return;
diff --git a/app/services/restream.ts b/app/services/restream.ts
index 97051e2d3591..24d6870cf27f 100644
--- a/app/services/restream.ts
+++ b/app/services/restream.ts
@@ -942,7 +942,7 @@ export class RestreamService extends StatefulService {
// Await the settings for every display. Otherwise `beforeGoLive` resolves before the
// stream settings have been written and `createStreaming` reads stale values.
- await Promise.allSettled(
+ await Promise.all(
displays.map(async display => {
const mode = this.getMode(display);
const settings = await this.fetchUserSettings(mode);
@@ -1293,11 +1293,12 @@ export class RestreamService extends StatefulService {
// and show as a new live stream immediately after the previous one ended. To prevent it from
// accidentally being identified as a stream shift stream, force the stream to go live if the
// app recently went live with live output editing enabled.
- if (this.streamInfo.isLiveOutputEditingEnabled) {
+ const lastStream = this.diagnosticsService?.lastStream;
+ if (this.streamInfo.isLiveOutputEditingEnabled && lastStream?.endTime) {
// If the last stream ended within the last minute, assume it is still in the cooldown period
+
const streamEndedRecently =
- this.diagnosticsService.lastStream &&
- Date.now() - new Date(this.diagnosticsService.lastStream.endTime).getTime() < 60 * 1000;
+ lastStream && Date.now() - new Date(lastStream.endTime).getTime() < 60 * 1000;
if (streamEndedRecently) {
this.SET_STREAM_SWITCHER_FORCE_GO_LIVE(true);
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index c6ecc9dde39c..95930eb3a026 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1113,16 +1113,22 @@ export class StreamingService
*/
private async syncTargetsToLive(settings: IGoLiveSettings) {
try {
- const enabledPlatforms = this.views.getEnabledPlatforms(settings.platforms);
- const enabledDestinations = settings.customDestinations.filter(dest => dest.enabled);
+ // Check every linked platform and custom destination against the server, not just the
+ // ones still marked enabled in `settings`. A failed stop leaves its target disabled here
+ // even though it's still live, so restricting the query to enabled targets would never ask
+ // the server about it and it could never be re-enabled to match reality.
+ const allPlatforms = (Object.keys(settings.platforms) as TPlatform[]).filter(platform =>
+ this.views.linkedPlatforms.includes(platform),
+ );
+ const allDestinations = settings.customDestinations;
- const live = await this.restreamService.getLiveTargets(enabledPlatforms, enabledDestinations);
+ const live = await this.restreamService.getLiveTargets(allPlatforms, allDestinations);
const livePlatforms = new Set(live.platforms);
const liveDestinations = new Set(live.customDestinations.map(d => getDestinationId(d)));
const platforms = cloneDeep(settings.platforms);
- enabledPlatforms.forEach(platform => {
+ allPlatforms.forEach(platform => {
const platformSettings = platforms[platform];
if (!platformSettings) return;
platformSettings.enabled = livePlatforms.has(platform);
@@ -3442,6 +3448,11 @@ export class StreamingService
// the display status is `Offline` so the streaming instance is destroyed correctly.
this.SET_STREAMING_STATUS(nextState, context, time);
await this.handleDestroyOutputContexts(context);
+
+ // Update number of streaming instances
+ this.numInstances = Object.values(this.contexts).filter(
+ c => c.streaming !== null && c.streaming !== undefined,
+ ).length;
this.streamingStatusChange.next(nextState);
return;
}
From 87687148baf39942c36e72a000f58ad9cd5bdbd9 Mon Sep 17 00:00:00 2001
From: Micheline Wu <69046953+michelinewu@users.noreply.github.com>
Date: Wed, 9 Sep 2026 14:30:32 -0700
Subject: [PATCH 18/18] Code review.
---
app/components-react/windows/go-live/useGoLiveSettings.ts | 2 +-
app/i18n/en-US/live-output-editing.json | 4 +++-
app/services/restream.ts | 8 ++++----
3 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts
index eb0118941fb2..83d7604a92e9 100644
--- a/app/components-react/windows/go-live/useGoLiveSettings.ts
+++ b/app/components-react/windows/go-live/useGoLiveSettings.ts
@@ -789,7 +789,7 @@ export class GoLiveSettingsModule {
// Restore custom destinations
const customDestinations = (savedSettings.customDestinations ?? []).map(dest => ({
...dest,
- enabled: liveDestinations.has(`${dest.url}/${dest.streamKey}`),
+ enabled: liveDestinations.has(getDestinationId(dest)),
}));
// Must use `setGoLiveSettings` instead of the module's `updateSettings` because the
diff --git a/app/i18n/en-US/live-output-editing.json b/app/i18n/en-US/live-output-editing.json
index 1c974038f906..db6c326182a1 100644
--- a/app/i18n/en-US/live-output-editing.json
+++ b/app/i18n/en-US/live-output-editing.json
@@ -38,5 +38,7 @@
"ask the user to restart the stream and go live again. If in dual output mode, ask the user to stream in single output mode": "ask the user to restart the stream and go live again. If in dual output mode, ask the user to stream in single output mode",
"confirm if the user is in dual output mode and which displays are currently streaming": "confirm if the user is in dual output mode and which displays are currently streaming",
"confirm the platform settings for the platform, then try updating the stream again": "confirm the platform settings for the platform, then try updating the stream again",
- "no live destinations so there was nothing to remove. The stream may have already ended on the server": "no live destinations so there was nothing to remove. The stream may have already ended on the server"
+ "no live destinations so there was nothing to remove. The stream may have already ended on the server": "no live destinations so there was nothing to remove. The stream may have already ended on the server",
+ "Unable to remove targets for %{display} display.": "Unable to remove targets for %{display} display.",
+ "Unable to update targets for %{orientation}.": "Unable to update targets for %{orientation}."
}
diff --git a/app/services/restream.ts b/app/services/restream.ts
index 24d6870cf27f..d3fd0c5d2304 100644
--- a/app/services/restream.ts
+++ b/app/services/restream.ts
@@ -619,7 +619,7 @@ export class RestreamService extends StatefulService {
throwRestreamError(
e,
'RESTREAM_REMOVE_TARGETS_FAILED',
- `Unable to remove targets for ${display}.`,
+ $t('Unable to remove targets for %{display} display.', { display }),
);
}
}
@@ -755,7 +755,7 @@ export class RestreamService extends StatefulService {
throwRestreamError(
e,
'RESTREAM_ADD_TARGETS_FAILED',
- `Unable to update targets for ${orientation}.`,
+ $t('Unable to update targets for %{orientation}.', { orientation }),
);
}
}
@@ -940,9 +940,9 @@ export class RestreamService extends StatefulService {
? this.streamInfo.liveOutputDisplays
: this.streamInfo.displaysToRestream;
- // Await the settings for every display. Otherwise `beforeGoLive` resolves before the
+ // Await the settings for every display with `allSettled`. Otherwise `beforeGoLive` resolves before the
// stream settings have been written and `createStreaming` reads stale values.
- await Promise.all(
+ await Promise.allSettled(
displays.map(async display => {
const mode = this.getMode(display);
const settings = await this.fetchUserSettings(mode);