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..c9009254d049 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 ( -
-
- -
- {/* CLOSE BUTTON */} - - - {/* GO BACK BUTTON */} - {shouldShowGoBackButton && ( - - )} - - {/* UPDATE BUTTON */} - {shouldShowUpdateButton && ( - - )} -
- ); - } + // 10-second countdown timer state + const [timer, setTimer] = useState(null); + useEffect(() => { + const subscription = cooldownTimer.subscribe(() => { + setTimer(10); + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + useEffect(() => { + // 10-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}>
- + {/* STEP 1 - FILL OUT THE SETTINGS FORM */} - {shouldShowSettings && } + {shouldShowSettings && } {/* STEP 2 - RUN THE CHECKLIST */} {shouldShowChecklist && } @@ -81,3 +96,124 @@ export default function EditStreamWindow() { ); } + +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 ( + +
+ {p.timer !== null && } +
+ {/* CLOSE BUTTON */} + + + {/* GO BACK BUTTON */} + {shouldShowGoBackButton && ( + + )} + + {/* UPDATE BUTTON */} + {shouldShowUpdateButton && ( + + )} + + ); +}); diff --git a/app/components-react/windows/go-live/GoLive.m.less b/app/components-react/windows/go-live/GoLive.m.less index 4ff5edd5569f..6d23fd0666da 100644 --- a/app/components-react/windows/go-live/GoLive.m.less +++ b/app/components-react/windows/go-live/GoLive.m.less @@ -65,10 +65,6 @@ } } -.update-mode { - padding-left: 25px !important; -} - .label { align-self: center; margin-right: 10px; 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*/}
diff --git a/app/components-react/windows/go-live/useGoLiveSettings.ts b/app/components-react/windows/go-live/useGoLiveSettings.ts index 66d35d77ddf2..83d7604a92e9 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,90 @@ 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 => getDestinationId(dest))); + + 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(getDestinationId(dest)), + })); + + // 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,8 +857,12 @@ export class GoLiveSettingsModule { } get isStreamShiftDisabled() { - if (!this.isPrime) return true; - return this.isPatreonEnabled; + return ( + !this.isPrime || + this.isPatreonEnabled || + this.state.isLiveOutputEditingEnabled || + this.isDualOutputMode + ); } /** @@ -758,6 +894,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 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 f3c7660a7de1..d3fd0c5d2304 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'; @@ -24,7 +24,8 @@ import { PlatformAppsService } from './platform-apps'; import { DualOutputService } from 'services/dual-output'; import { SettingsService } from 'services/settings'; import { UsageStatisticsService } from 'services/usage-statistics'; -import { throwStreamError } from './streaming/stream-error'; +import { DiagnosticsService } from './diagnostics'; +import { StreamError, throwRestreamError } from './streaming/stream-error'; import { Subject } from 'rxjs'; import uuid from 'uuid'; import Utils from './utils'; @@ -163,6 +164,7 @@ export class RestreamService extends StatefulService { @Inject() dualOutputService: DualOutputService; @Inject() settingsService: SettingsService; @Inject() usageStatisticsService: UsageStatisticsService; + @Inject() diagnosticsService: DiagnosticsService; settings: IUserSettingsResponse; @@ -370,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', }); @@ -420,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( @@ -500,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)) { @@ -516,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. @@ -526,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 }), + ); } } } @@ -552,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 @@ -566,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); @@ -574,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', + $t('Unable to remove targets for %{display} display.', { 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 @@ -599,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; }, @@ -627,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; }, @@ -637,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', + $t('Unable to update targets for %{orientation}.', { 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 @@ -727,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; @@ -749,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 with `allSettled`. 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); } } @@ -854,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(); - return this.streamInfo.enabledPlatforms.reduce((platforms, platform) => { + const targetPlatforms = updatedPlatforms ?? this.streamInfo.enabledPlatforms; + + 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; } @@ -909,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 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; - // 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)) { + if (!usesDisplays || modesToRestream.includes(mode)) { platforms.push({ ...targetInfo, mode }); } @@ -922,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(); + + // 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 this.streamInfo.customDestinations.reduce((dests, dest) => { + return targetDestinations.reduce((dests, dest) => { if (!dest.enabled) return dests; const targetInfo = { @@ -936,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)) { @@ -949,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, '') + '/'; } @@ -964,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, }; @@ -998,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}`, }; } @@ -1020,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`, @@ -1051,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; @@ -1080,11 +1259,53 @@ 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. + 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 = + lastStream && Date.now() - new Date(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); @@ -1093,6 +1314,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; } @@ -1122,18 +1345,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); @@ -1174,13 +1400,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, @@ -1244,12 +1472,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'); } } @@ -1487,11 +1725,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'; } @@ -1511,7 +1757,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 20b84b0a3359..95930eb3a026 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 = { @@ -841,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); } @@ -1011,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' }); @@ -1118,16 +1103,316 @@ 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 + */ + private async syncTargetsToLive(settings: IGoLiveSettings) { + try { + // 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(allPlatforms, allDestinations); + + const livePlatforms = new Set(live.platforms); + const liveDestinations = new Set(live.customDestinations.map(d => getDestinationId(d))); + + const platforms = cloneDeep(settings.platforms); + allPlatforms.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 */ - async addTargetsToStream(platforms: TPlatform[], destinations: ICustomStreamDestination[]) { + 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( @@ -1135,7 +1420,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)); + }); } } @@ -1168,7 +1466,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); } } @@ -1221,26 +1554,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); @@ -1255,8 +1585,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; @@ -1291,22 +1621,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( @@ -1314,7 +1666,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 @@ -1322,10 +1674,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)) { @@ -1338,7 +1700,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'), ); @@ -1348,12 +1710,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); } /** @@ -2033,9 +2409,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 }); @@ -2064,6 +2440,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 @@ -2072,6 +2454,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, @@ -2165,13 +2551,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; @@ -2980,6 +3360,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 { @@ -3002,7 +3394,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 && @@ -3027,12 +3419,52 @@ 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); + + // 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; + } + } 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. @@ -3621,8 +4053,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 ); @@ -4327,6 +4757,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) || @@ -4514,6 +4948,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