diff --git a/app/components-react/root/LiveDock.tsx b/app/components-react/root/LiveDock.tsx index 150c2f0470c9..558ef31eb619 100644 --- a/app/components-react/root/LiveDock.tsx +++ b/app/components-react/root/LiveDock.tsx @@ -430,7 +430,11 @@ function LiveDock() { placement="right" autoAdjustOverflow={false} > - ctrl.showEditStreamInfo()} className="icon-edit" /> + ctrl.showEditStreamInfo()} + className="icon-edit" + /> )} {hasLiveDockFeature('view-stream') && isStreaming && ( diff --git a/app/components-react/root/StartStreamingButton.tsx b/app/components-react/root/StartStreamingButton.tsx index eeca006d2178..4120cc7be4c6 100644 --- a/app/components-react/root/StartStreamingButton.tsx +++ b/app/components-react/root/StartStreamingButton.tsx @@ -3,7 +3,7 @@ import cx from 'classnames'; import { EStreamingState } from 'services/streaming'; import { EGlobalSyncStatus } from 'services/media-backup'; import { $t } from 'services/i18n'; -import { useVuex } from '../hooks'; +import { useDebounce, useVuex } from '../hooks'; import { Services } from '../service-provider'; import * as remote from '@electron/remote'; import { TStreamShiftStatus } from 'services/restream'; @@ -11,7 +11,6 @@ import { promptAction } from 'components-react/modals'; import { TSocketEvent } from 'services/websocket'; import { useRealmObject } from 'components-react/hooks/realm'; import debounce from 'lodash/debounce'; -import Utils from 'services/utils'; function StartStreamingButton(p: { disabled?: boolean }) { const { @@ -22,7 +21,6 @@ function StartStreamingButton(p: { disabled?: boolean }) { MediaBackupService, SourcesService, RestreamService, - UsageStatisticsService, } = Services; const { @@ -30,7 +28,6 @@ function StartStreamingButton(p: { disabled?: boolean }) { delayEnabled, delaySeconds, streamShiftStatus, - streamShiftForceGoLive, isDualOutputMode, isLoggedIn, isPrime, @@ -42,7 +39,6 @@ function StartStreamingButton(p: { disabled?: boolean }) { delayEnabled: StreamingService.views.delayEnabled, delaySeconds: StreamingService.views.delaySeconds, streamShiftStatus: RestreamService.state.streamShiftStatus, - streamShiftForceGoLive: RestreamService.state.streamShiftForceGoLive, isDualOutputMode: StreamingService.views.isDualOutputMode, isLoggedIn: UserService.isLoggedIn, isPrime: UserService.state.isPrime, @@ -79,88 +75,35 @@ function StartStreamingButton(p: { disabled?: boolean }) { useEffect(() => { // Check for stream shift status on mount. This will happen on app launch because the main window is always active if (isPrime && streamingStatus === EStreamingState.Offline) { - fetchStreamShiftStatus().catch((e: unknown) => { - console.error('Error fetching stream shift status:', e); - }); + checkIsLive(); } - const streamShiftEvent = StreamingService.streamShiftEvent.subscribe((event: TSocketEvent) => { - if (streamShiftForceGoLive) return; - if (event.type !== 'streamSwitchRequest' && event.type !== 'switchActionComplete') { - return; - } - - const { streamShiftStreamId } = RestreamService.state; - console.debug('Event ID: ' + event.data.identifier, '\n Stream ID: ' + streamShiftStreamId); - const isIncomingStream: boolean = - (streamShiftStreamId && event.data.identifier === streamShiftStreamId) || false; - - if (event.type === 'streamSwitchRequest') { - if (isIncomingStream) { - // Don't record the request from this device because the other device will record it - RestreamService.actions.confirmStreamShift('approved'); - } else { - recordStreamShiftAnalytics('request', event.data.identifier); - } - } - - if (event.type === 'switchActionComplete') { - // End the stream on this device if switching the stream to another device - // Only record analytics if the stream was switched from this device to a different one - if (!isIncomingStream) { - Services.RestreamService.actions.endStreamShiftStream(event.data.identifier); - - recordStreamShiftAnalytics('complete', event.data.identifier); - } - + const streamShiftEvent = StreamingService.streamShiftEvent.subscribe( + async (event: TSocketEvent) => { // Notify the user - const message = formatStreamShiftMessage(isIncomingStream, event.data.identifier); - - promptAction({ - title: $t('Stream successfully switched'), - message, - btnText: $t('Close'), - btnType: 'default', - cancelBtnPosition: 'none', - }); - } - }); + const message = await RestreamService.actions.return.handleStreamShiftEvent(event); + + // An empty message means the handler declined to notify (e.g. a forced go live), + // so don't show an alert with an empty body + if (event.type === 'switchActionComplete' && message) { + promptAction({ + title: $t('Stream successfully switched'), + message, + btnText: $t('Close'), + btnType: 'default', + cancelBtnPosition: 'none', + }); + } + }, + ); return () => { toggleStreaming.cancel(); + checkIsLive.cancel(); streamShiftEvent.unsubscribe(); }; }, []); - const recordStreamShiftAnalytics = useCallback((action: 'request' | 'complete', id: string) => { - // Prevent recording analytics event in test mode - if (Utils.isTestMode()) return; - - // Note: because the event's stream id is from the device that requested the switch, - // it is not possible to know what type of device the stream will be switching from. - // We can only identify the type of device the stream is switching to. - const remoteDeviceType = /[A-Z]/.test(id) ? 'mobile' : 'desktop'; - const switchType = `desktop-${remoteDeviceType}`; - - UsageStatisticsService.recordAnalyticsEvent('StreamShift', { - stream: switchType, - action, - }); - }, []); - - const formatStreamShiftMessage = useCallback((isFromOtherDevice: boolean, id: string) => { - if (isFromOtherDevice) { - return $t( - 'Your stream has been switched to Streamlabs Desktop from another device. Enjoy your stream!', - ); - } - - const remoteDeviceType = /[A-Z]/.test(id) ? 'mobile' : 'desktop'; - return remoteDeviceType === 'mobile' - ? $t('Your stream has been successfully switched to Streamlabs Mobile. Enjoy your stream!') - : $t('Your stream has been successfully switched to Streamlabs Desktop. Enjoy your stream!'); - }, []); - const handleToggleStreaming = useCallback(async () => { if (StreamingService.isStreaming) { StreamingService.toggleStreaming(); @@ -220,10 +163,14 @@ function StartStreamingButton(p: { disabled?: boolean }) { // Wrap the toggleStreaming function in a debounce to prevent multiple rapid clicks // and also to cancel the action on unmount to prevent memory leaks and state updates on unmounted components + // Don't use the useDebounce hook here to maintain stateful callbacks const toggleStreaming = useMemo(() => debounce(handleToggleStreaming, 500), [ handleToggleStreaming, ]); + // Debounce checking for the live status of the stream and enable canceling on unmount + const checkIsLive = useDebounce(0, RestreamService.actions.checkIsLive); + const getIsRedButton = useMemo(() => { return streamingStatus !== EStreamingState.Offline && streamShiftStatus !== 'pending'; }, [streamingStatus, streamShiftStatus]); @@ -236,17 +183,6 @@ function StartStreamingButton(p: { disabled?: boolean }) { ); }, [p.disabled, streamingStatus, delaySecondsRemaining]); - const fetchStreamShiftStatus = useCallback(async () => { - try { - const isLive = await RestreamService.actions.return.checkIsLive(); - return isLive; - } catch (e: unknown) { - console.log('Error checking stream shift status', e); - setIsLoading(false); - return false; - } - }, []); - const shouldShowGoLiveWindow = useCallback(() => { if (!UserService.isLoggedIn) return false; const primaryPlatform = UserService.state.auth?.primaryPlatform; @@ -254,13 +190,17 @@ function StartStreamingButton(p: { disabled?: boolean }) { if (!primaryPlatform) return false; + if (streamShiftStatus === 'pending') { + return true; + } + if (StreamingService.views.isDualOutputMode) { return true; } if ( !!UserService.state.auth?.platforms && - StreamingService.views.isMultiplatformMode && + isMultiplatformMode && Object.keys(UserService.state.auth?.platforms).length > 1 ) { return true; @@ -269,14 +209,14 @@ function StartStreamingButton(p: { disabled?: boolean }) { if (primaryPlatform === 'twitch') { // For Twitch, we can show the Go Live window even with protected mode off // This is mainly for legacy reasons. - return StreamingService.views.isMultiplatformMode || updateStreamInfoOnLive; + return isMultiplatformMode || updateStreamInfoOnLive; } else { return ( StreamSettingsService.state.protectedModeEnabled && StreamSettingsService.isSafeToModifyStreamKey() ); } - }, [primaryPlatform, isMultiplatformMode, updateStreamInfoOnLive]); + }, [primaryPlatform, isMultiplatformMode, updateStreamInfoOnLive, streamShiftStatus]); return ( + + )} {!recordingModeEnabled && (
diff --git a/app/components-react/shared/DisplaySelector.tsx b/app/components-react/shared/DisplaySelector.tsx index 4cea81da0604..5810a0279441 100644 --- a/app/components-react/shared/DisplaySelector.tsx +++ b/app/components-react/shared/DisplaySelector.tsx @@ -25,11 +25,24 @@ export default function DisplaySelector(p: IDisplaySelectorProps) { canDualStream, updateCustomDestinationDisplayAndSaveSettings, updatePlatformDisplayAndSaveSettings, + isLiveOutputEditingEnabled, + isUpdateMode, + isLive, } = useGoLiveSettings().extend(module => ({ get canDualStream() { if (!p.platform) return false; + if (module.isLiveOutputEditingEnabled) return false; return module.getCanDualStream(p.platform); }, + + get isLive(): boolean { + return ( + module.isUpdateMode && + module.isLiveOutputEditingEnabled && + !!module.isTargetLive(p.platform ?? p.index) + ); + }, + get display(): TDisplayOutput { const defaultDisplay = p.platform ? module.settings.platforms[p.platform]?.display @@ -57,6 +70,29 @@ export default function DisplaySelector(p: IDisplaySelectorProps) { }, ]; + if (isLive) { + // A live target cannot change display without restarting its stream, so offer only the + // display it is already using and explain how to change it + const activeDisplay = + defaultDisplays.find(option => option.value === display) ?? defaultDisplays[0]; + + return [ + { + ...activeDisplay, + disabled: true, + tooltip: $t( + 'Go offline to change orientation, then select a new resolution and go live again', + ), + }, + ]; + } + + if (isUpdateMode) { + // Don't show Dual stream option in the Edit Stream window because it is not compatible with + // live output editing, which is the only time the display toggles are shown in the update window + return defaultDisplays; + } + if (canDualStream) { const tooltip = p?.platform ? $t('Stream both horizontally and vertically to %{platform}', { @@ -76,7 +112,7 @@ export default function DisplaySelector(p: IDisplaySelectorProps) { } return defaultDisplays; - }, [canDualStream]); + }, [canDualStream, isLiveOutputEditingEnabled, isUpdateMode, isLive, display, p.platform]); const onChange = useCallback( (val: string) => { diff --git a/app/components-react/shared/InfoBanner.tsx b/app/components-react/shared/InfoBanner.tsx index 1cf5534b47a9..59301cdf0ed8 100644 --- a/app/components-react/shared/InfoBanner.tsx +++ b/app/components-react/shared/InfoBanner.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties } from 'react'; +import React, { CSSProperties, ReactNode } from 'react'; import styles from './InfoBanner.m.less'; import cx from 'classnames'; import { EDismissable } from 'services/dismissables'; @@ -7,7 +7,7 @@ import { Services } from 'components-react/service-provider'; interface IInfoBannerProps { id?: string; - message: string | JSX.Element; + message: string | JSX.Element | ReactNode; type?: 'info' | 'warning'; style?: CSSProperties; className?: string; diff --git a/app/components-react/shared/Spinner.m.less b/app/components-react/shared/Spinner.m.less index 91394bf48564..c483a423b3e4 100644 --- a/app/components-react/shared/Spinner.m.less +++ b/app/components-react/shared/Spinner.m.less @@ -21,6 +21,27 @@ visibility: visible; opacity: 1; } + + &.inline { + position: static; + display: inline-flex; + align-items: center; + width: auto; + height: auto; + background-color: transparent; + + :global(.s-spinner) { + width: auto; + height: auto; + padding: 0; + } + + :global(.s-bars) { + display: flex; + align-items: center; + min-width: 0 !important; + } + } } .spinner-relative:extend(.container) { diff --git a/app/components-react/shared/Spinner.tsx b/app/components-react/shared/Spinner.tsx index 4051a641cdc0..f376aed3f8d1 100644 --- a/app/components-react/shared/Spinner.tsx +++ b/app/components-react/shared/Spinner.tsx @@ -12,9 +12,18 @@ export default function Spinner( delay?: number; relative?: boolean; pageLoader?: boolean; + inline?: boolean; + width?: string; + height?: string; } & HTMLAttributes = {}, ) { - const defaultProps = { visible: false, delay: 0, relative: false, pageLoader: false }; + const defaultProps = { + visible: false, + delay: 0, + relative: false, + pageLoader: false, + inline: false, + }; const p = { ...defaultProps, ...props }; const timeoutRef = useRef(0); @@ -50,12 +59,17 @@ export default function Spinner( [css.hasVisibleSpinner]: visibility.isSpinnerVisible, [css.spinnerRelative]: p.relative, [css.pageLoader]: p.pageLoader, + [css.inline]: p.inline, }); return ( {visibility.isContainerVisible && ( -
+
)} diff --git a/app/components-react/shared/inputs/RadioInput.m.less b/app/components-react/shared/inputs/RadioInput.m.less index 0276efaabd4e..16fcb5964aea 100644 --- a/app/components-react/shared/inputs/RadioInput.m.less +++ b/app/components-react/shared/inputs/RadioInput.m.less @@ -25,11 +25,19 @@ color: var(--icon-toggle-active); transition: color 0.3s ease-in-out; } + + i.disabled { + opacity: 0.7; + } } :global(.ant-radio) { display: none; } + + i.disabled { + opacity: 0.7; + } } .icon-default:extend(.icon-radio) { @@ -75,6 +83,11 @@ border-bottom-right-radius: 4px; } + // Must be last to override the above two rules + :global(.ant-radio-wrapper):only-child { + border-radius: 4px; + } + :global(.ant-radio-wrapper-checked) { background-color: var(--button); diff --git a/app/components-react/shared/inputs/RadioInput.tsx b/app/components-react/shared/inputs/RadioInput.tsx index aca02707b2eb..09bf35ef56ea 100644 --- a/app/components-react/shared/inputs/RadioInput.tsx +++ b/app/components-react/shared/inputs/RadioInput.tsx @@ -13,6 +13,7 @@ export interface ICustomRadioOption { defaultValue?: string; icon?: string; tooltip?: string; + disabled?: boolean; children?: React.ReactNode; } @@ -95,14 +96,22 @@ export const RadioInput = InputComponent((p: TRadioInputProps) => { - + ) : ( - + ) } /> diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.m.less b/app/components-react/windows/go-live/AiHighlighterToggle.m.less index 5821a52da82e..b90a3921f00e 100644 --- a/app/components-react/windows/go-live/AiHighlighterToggle.m.less +++ b/app/components-react/windows/go-live/AiHighlighterToggle.m.less @@ -223,4 +223,10 @@ border-radius: 8px; } -// .highlighter-banner___36EFl .ant-switch-handle::before +.dismissable { + display: flex; + justify-content: flex-end; + width: 100%; + padding: 10px; + text-decoration: underline; +} diff --git a/app/components-react/windows/go-live/AiHighlighterToggle.tsx b/app/components-react/windows/go-live/AiHighlighterToggle.tsx index bb8ba8bc24df..d1e968f800c8 100644 --- a/app/components-react/windows/go-live/AiHighlighterToggle.tsx +++ b/app/components-react/windows/go-live/AiHighlighterToggle.tsx @@ -2,7 +2,6 @@ import { SwitchInput } from 'components-react/shared/inputs/SwitchInput'; import React, { useEffect, useState, memo } from 'react'; import styles from './AiHighlighterToggle.m.less'; import { Services } from 'components-react/service-provider'; -import * as remote from '@electron/remote'; import { useDebounce, useVuex } from 'components-react/hooks'; import { DownOutlined, UpOutlined } from '@ant-design/icons'; import { Alert, Button } from 'antd'; @@ -15,10 +14,22 @@ import { EAvailableFeatures } from 'services/incremental-rollout'; import { promptAction } from 'components-react/modals'; import InputWrapper from 'components-react/shared/inputs/InputWrapper'; import Translate from 'components-react/shared/Translate'; +import { EDismissable } from 'services/dismissables'; -export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded: boolean }) { +export default function AiHighlighterToggle({ + cardIsExpanded, + isUpdateMode, +}: { + cardIsExpanded: boolean; + isUpdateMode?: boolean; +}) { //TODO M: Probably good way to integrate the highlighter in to GoLiveSettings - const { HighlighterService, StreamingService, IncrementalRolloutService } = Services; + const { + HighlighterService, + StreamingService, + IncrementalRolloutService, + DismissablesService, + } = Services; const { useHighlighter, highlighterVersion, @@ -26,6 +37,7 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded isVerticalReplayBuffer, outputDisplay, gameName, + shouldShow, } = useVuex(() => { return { useHighlighter: HighlighterService.views.useAiHighlighter, @@ -34,6 +46,7 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded isVerticalReplayBuffer: StreamingService.views.isVerticalReplayBuffer, outputDisplay: StreamingService.views.outputDisplay, gameName: StreamingService.views.gameName, + shouldShow: DismissablesService.views.shouldShow(EDismissable.HighlighterBanner), }; }); @@ -47,8 +60,8 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded const supportedGame = isGameSupported(gameName); setGameIsSupported(!!supportedGame); if (supportedGame) { - setIsExpanded(true); setGameConfig(getConfigByGame(supportedGame)); + if (!isUpdateMode) setIsExpanded(true); } else { setGameConfig(null); } @@ -83,19 +96,16 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded } function getInitialExpandedState() { - if (gameIsSupported) { - return true; - } else { - if (useHighlighter) { - return true; - } else { - return cardIsExpanded; - } - } + if (isUpdateMode) return false; + if (gameIsSupported) return true; + if (useHighlighter) return true; + return cardIsExpanded; } const initialExpandedState = getInitialExpandedState(); const [isExpanded, setIsExpanded] = useState(initialExpandedState); + const showHighlighterBanner = shouldShow || !isUpdateMode; + const toggleHighlighter = useDebounce(300, handleToggleHighlighter); function handleToggleHighlighter() { @@ -160,7 +170,7 @@ export default function AiHighlighterToggle({ cardIsExpanded }: { cardIsExpanded return (
- {gameIsSupported ? ( + {gameIsSupported && showHighlighterBanner ? (
) : ( diff --git a/app/components-react/windows/go-live/CommonPlatformFields.tsx b/app/components-react/windows/go-live/CommonPlatformFields.tsx index 061712ce642f..350946d8fec5 100644 --- a/app/components-react/windows/go-live/CommonPlatformFields.tsx +++ b/app/components-react/windows/go-live/CommonPlatformFields.tsx @@ -60,6 +60,15 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { ? view.supports('description', [p.platform as TPlatform]) : view.supports('description'); + // Only the shared instance can run out of platforms to write to, and only while live, where + // `updateCommonFields` skips any platform using its own title. Once every enabled platform has + // opted out, editing the shared title changes nothing. + const titleDisabled = + !p.platform && + view.isMidStreamMode && + view.enabledPlatforms.length > 0 && + !view.platformsWithoutCustomFields.length; + const fields = p.value; const height = useMemo(() => { @@ -121,7 +130,10 @@ export const CommonPlatformFields = InputComponent((rawProps: IProps) => { $t('Title') ) } - required={true} + // A disabled input cannot be corrected, so it must not be able to fail validation. Each + // platform using its own title validates that title in its own section. + required={!titleDisabled} + disabled={titleDisabled} max={maxCharacters} min={minCharacters} layout={p.layout} diff --git a/app/components-react/windows/go-live/DestinationSwitchers.m.less b/app/components-react/windows/go-live/DestinationSwitchers.m.less index 79d110e4d485..780a44ac44bc 100644 --- a/app/components-react/windows/go-live/DestinationSwitchers.m.less +++ b/app/components-react/windows/go-live/DestinationSwitchers.m.less @@ -60,6 +60,10 @@ overflow: hidden; width: 100%; + &.card-disabled { + background-color: var(--card-disabled); + } + .destination-info { display: flex; flex-direction: row; @@ -174,6 +178,7 @@ :global(div.ant-tooltip-inner) { box-shadow: 0 2px 16px -4px rgba(211, 211, 211, 0.274), 0 2px 15px 0 rgba(211, 211, 211, 0.32), 0 2px 28px 6px rgba(211, 211, 211, 0.2) !important; + white-space: normal; } :global(.ant-radio-wrapper:last-child::after) { display: none; diff --git a/app/components-react/windows/go-live/GameSelector.tsx b/app/components-react/windows/go-live/GameSelector.tsx index b7457104618f..5e70596ead1d 100644 --- a/app/components-react/windows/go-live/GameSelector.tsx +++ b/app/components-react/windows/go-live/GameSelector.tsx @@ -123,6 +123,15 @@ export default function GameSelector(p: TProps) { }); } + if (isKick) { + // Kick's API requires the category id, but this component renders the name, + // so the service has to track both + Services.KickService.actions.setGameInfo({ + gameId: game?.value ?? '', + gameName: game?.label ?? '', + }); + } + if (!game) return; setGames([game]); } diff --git a/app/components-react/windows/go-live/GoLive.m.less b/app/components-react/windows/go-live/GoLive.m.less index bdcff7f11e5e..4ff5edd5569f 100644 --- a/app/components-react/windows/go-live/GoLive.m.less +++ b/app/components-react/windows/go-live/GoLive.m.less @@ -52,6 +52,17 @@ .destination-mode { padding-right: 25px !important; padding-left: 25px !important; + + // Without a left column this padding is the only gutter, so the children's own right margins + // sit on top of it and inset the right edge further than the left. Those margins exist to + // separate the two columns, which is not this layout. + > *:not(:first-child) { + margin-right: 0; + } + + .right-column-scroll { + margin-right: 0 !important; + } } .update-mode { @@ -105,14 +116,14 @@ button.bottom { text-align: right; } -.banner-wrapper { +.info-banner-wrapper { flex: 1; display: flex; flex-direction: row; align-items: flex-start; } -.banner { +.info-banner { margin-right: 5px; height: 32px !important; width: unset !important; @@ -182,6 +193,12 @@ button.bottom { .confirm-btn { width: 141.25px; + + && { + display: inline-flex; + align-items: center; + justify-content: center; + } } .footer-content { @@ -270,3 +287,38 @@ button.bottom { flex-direction: row; align-items: center; } + +.update-btn-tooltip { + margin-left: 8px; + display: flex; +} + +.spinner { + height: 20px; + width: 20px; +} + +.update-btn { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; + line-height: 1; +} + +.ultra-icon { + background: linear-gradient( + 123.53deg, + #2de8b0 25.56%, + #cbe953 60.27%, + #ffab48 79.52%, + #ff5151 96.69% + ) !important; + background-clip: text !important; + color: transparent !important; +} + +.section-title { + margin-top: 15px; +} diff --git a/app/components-react/windows/go-live/GoLiveError.tsx b/app/components-react/windows/go-live/GoLiveError.tsx index af80464edbe7..6cb6b2b513da 100644 --- a/app/components-react/windows/go-live/GoLiveError.tsx +++ b/app/components-react/windows/go-live/GoLiveError.tsx @@ -58,6 +58,15 @@ export default function GoLiveError() { return renderSettingsUpdateError(error); case 'RESTREAM_DISABLED': case 'RESTREAM_SETUP_FAILED': + case 'RESTREAM_UPDATE_FAILED': + case 'RESTREAM_INVALID_CONFIG': + case 'RESTREAM_STREAM_KEY_MISSING': + case 'RESTREAM_STREAM_KEY_FETCH_FAILED': + case 'RESTREAM_DISPLAY_SETUP_FAILED': + case 'RESTREAM_ADD_TARGETS_FAILED': + case 'RESTREAM_NO_ACTIVE_TARGETS': + case 'RESTREAM_REMOVE_TARGET_NOT_FOUND': + case 'RESTREAM_REMOVE_TARGETS_FAILED': return renderRestreamError(error); case 'DUAL_OUTPUT_RESTREAM_DISABLED': case 'DUAL_OUTPUT_SETUP_FAILED': @@ -268,14 +277,16 @@ export default function GoLiveError() { ] : error.details.split('\n'); + // Leave the message to `MessageLayout`, which falls back to the error's own message. Each + // restream failure has its own error type, so the headline names what actually went wrong + // instead of repeating the same generic line for every one of them. return ( - + +

+ {$t( + 'Please try again. If the issue persists, you can stream directly to a single platform instead or click the button below to bypass and go live.', + )} +

{`${$t('Issues')}:`}
    {details.map((detail: string, index: number) => ( diff --git a/app/components-react/windows/go-live/GoLiveInfoBanner.tsx b/app/components-react/windows/go-live/GoLiveInfoBanner.tsx new file mode 100644 index 000000000000..17e2a1dda70e --- /dev/null +++ b/app/components-react/windows/go-live/GoLiveInfoBanner.tsx @@ -0,0 +1,26 @@ +import React, { ReactNode } from 'react'; +import styles from './GoLive.m.less'; +import InfoBanner from 'components-react/shared/InfoBanner'; +import { EDismissable } from 'services/dismissables'; + +interface IGoLiveInfoBannerProps { + message: string | JSX.Element | ReactNode; + onClick?: () => void; + dismissableKey?: EDismissable; +} + +export function GoLiveInfoBanner(p: IGoLiveInfoBannerProps) { + return ( +
    + +
    + ); +} + +export default GoLiveInfoBanner; diff --git a/app/components-react/windows/go-live/GoLiveSettings.tsx b/app/components-react/windows/go-live/GoLiveSettings.tsx index 8a1be19eb278..7fa9c7afc8c0 100644 --- a/app/components-react/windows/go-live/GoLiveSettings.tsx +++ b/app/components-react/windows/go-live/GoLiveSettings.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import styles from './GoLive.m.less'; import Scrollable from 'components-react/shared/Scrollable'; import { useGoLiveSettings } from './useGoLiveSettings'; @@ -14,13 +14,14 @@ import ColorSpaceWarnings from './ColorSpaceWarnings'; import { DestinationSwitchers } from './DestinationSwitchers'; import AddDestinationButton from 'components-react/shared/AddDestinationButton'; import cx from 'classnames'; -import StreamShiftToggle from 'components-react/shared/StreamShiftToggle'; import { CaretDownOutlined } from '@ant-design/icons'; import * as remote from '@electron/remote'; import { inject } from 'slap'; import { VideoEncodingOptimizationService } from 'services/video-encoding-optimizations'; import { MagicLinkService } from 'services/magic-link'; import { SettingsService } from 'services/settings'; +import { EAvailableFeatures, IncrementalRolloutService } from 'services/incremental-rollout'; +import StreamShiftToggle from 'components-react/shared/StreamShiftToggle'; /** * Renders settings for starting the stream @@ -39,7 +40,7 @@ export default function GoLiveSettings() { isPrime, shouldShowLeftCol, isStreamShiftDisabled, - isUpdateMode, + canEditLiveOutputs, addDestination, showTopAddDestination, showBottomAddDestination, @@ -50,6 +51,7 @@ export default function GoLiveSettings() { videoEncodingOptimizationService: inject(VideoEncodingOptimizationService), settingsService: inject(SettingsService), magicLinkService: inject(MagicLinkService), + incrementalRolloutService: inject(IncrementalRolloutService), addDestination() { this.settingsService.actions.showSettings('Stream'); @@ -69,10 +71,15 @@ export default function GoLiveSettings() { }, get shouldShowLeftCol() { - if (module.isUpdateMode) return false; return module.isStreamShiftMode ? true : module.protectedModeEnabled; }, + get canEditLiveOutputs() { + return this.incrementalRolloutService.views.featureIsEnabled( + EAvailableFeatures.liveOutputEditing, + ); + }, + async openPlatformSettings() { try { const link = await this.magicLinkService.getDashboardMagicLink( @@ -131,10 +138,15 @@ export default function GoLiveSettings() { border={false} disabled={!hasMultiplePlatforms} /> - + + {/* STREAM SHIFT TOGGLE */} + {/* Remove after feature flag removed */} + {!canEditLiveOutputs && ( + + )}
@@ -144,8 +156,7 @@ export default function GoLiveSettings() { @@ -158,7 +169,7 @@ export default function GoLiveSettings() { {/*PLATFORM SETTINGS*/} {/*EXTRAS*/} - {!!canUseOptimizedProfile && !isUpdateMode && ( + {!!canUseOptimizedProfile && (
diff --git a/app/components-react/windows/go-live/GoLiveWindow.tsx b/app/components-react/windows/go-live/GoLiveWindow.tsx index feb5fbc877cc..fde048341d00 100644 --- a/app/components-react/windows/go-live/GoLiveWindow.tsx +++ b/app/components-react/windows/go-live/GoLiveWindow.tsx @@ -66,9 +66,7 @@ function ModalFooter() { isPrime, isStreamShiftMode, hasIncompatibleCodec, - streamShiftStatus, codec, - checkIsLive, forceStreamShiftGoLive, goLiveWithDefaultCodec, showSettings, @@ -95,10 +93,6 @@ function ModalFooter() { return module.streamShiftStatus; }, - async checkIsLive() { - return this.restreamService.actions.return.checkIsLive(); - }, - async forceStreamShiftGoLive() { this.restreamService.actions.forceStreamShiftGoLive(); }, @@ -146,14 +140,6 @@ function ModalFooter() { const [isCoolingDown, setIsCoolingDown] = useState(false); const isStreamShiftPromptShown = useRef(false); - // Check stream shift status on mount for Prime users - useEffect(() => { - if (!isPrime) return; - checkIsLive().catch((e: unknown) => { - console.error('Error checking stream shift status on mount:', e); - }); - }, []); - const promptUseDefaultCodec = useCallback(async () => { // If the user is not live but has an incompatible codec, prompt to change codec let message = $t( @@ -193,15 +179,6 @@ function ModalFooter() { }); }, [isStreamShiftMode, isDualOutputMode, codec, goLiveWithDefaultCodec, showSettings]); - const startStreamShift = useCallback(() => { - if (isDualOutputMode) { - Services.DualOutputService.actions.toggleDisplay(false, 'vertical'); - } - - setStreamShift(true); - goLive(); - }, [isDualOutputMode, goLive, setStreamShift]); - const promptStreamShift = useCallback(async () => { isStreamShiftPromptShown.current = true; await promptAction({ @@ -215,7 +192,8 @@ function ModalFooter() { if (hasIncompatibleCodec) { promptUseDefaultCodec(); } else { - startStreamShift(); + setStreamShift(true); + goLive(); close(); } }, @@ -236,8 +214,9 @@ function ModalFooter() { maskClosable: false, }); }, [ + isStreamShiftPromptShown, hasIncompatibleCodec, - startStreamShift, + setStreamShift, close, forceStreamShiftGoLive, promptUseDefaultCodec, @@ -245,11 +224,22 @@ function ModalFooter() { ]); // When the streaming service detects an active stream on another device, show the prompt + // Note: `promptStreamShift` is intentionally left out of the dependency array to avoid multiple prompts on window load useEffect(() => { - if (streamShiftStatus !== 'pending') return; - if (isStreamShiftPromptShown.current) return; - promptStreamShift(); - }, [streamShiftStatus, promptStreamShift]); + // Prompt the user to switch to Streamlabs Desktop if a stream is detected on another device + if (Services.RestreamService.views.streamShiftStatus === 'pending') { + promptStreamShift(); + } + + const isLive = Services.RestreamService.isLive.subscribe(isLive => { + if (isLive && !isStreamShiftPromptShown.current) { + promptStreamShift(); + } + }); + + return () => isLive.unsubscribe(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { if (!isCoolingDown) return; diff --git a/app/components-react/windows/go-live/LiveOutputEditingCard.tsx b/app/components-react/windows/go-live/LiveOutputEditingCard.tsx new file mode 100644 index 000000000000..ac038072dafd --- /dev/null +++ b/app/components-react/windows/go-live/LiveOutputEditingCard.tsx @@ -0,0 +1,74 @@ +import React, { useCallback, useMemo } from 'react'; +import { useGoLiveSettings } from './useGoLiveSettings'; +import { Services } from 'components-react/service-provider'; +import { SwitcherCard } from './SwitcherCard'; +import UltraIcon from 'components-react/shared/UltraIcon'; +import { $t } from 'services/i18n'; +import styles from './GoLive.m.less'; + +export default function LiveOutputEditingCard() { + const { + isLiveOutputEditingEnabled, + isLiveOutputEditingDisabled, + isPrime, + isStreamShiftMode, + setLiveOutputEditingEnabled, + } = useGoLiveSettings(); + + const liveOutputTooltip = useMemo(() => { + if (!isPrime) { + return $t('Upgrade to Ultra to manage live outputs mid-stream'); + } + + if (isStreamShiftMode) { + return $t('Live Output Editing cannot be used with Stream Shift'); + } + + return $t('Update your live outputs mid-stream'); + }, [isPrime, isStreamShiftMode]); + + const tooltipDisabled = useMemo(() => { + return isPrime && !isStreamShiftMode; + }, [isPrime, isStreamShiftMode]); + + const handleToggleLiveOutputEditing = useCallback( + (status?: boolean) => { + if (!isPrime) { + Services.MagicLinkService.actions.linkToPrime('slobs-live-output-editing', { + event: 'LiveOutputEditing', + }); + return; + } + + // A disabled card still receives the click, so stop here rather than switching on a feature + // that is mutually exclusive with stream shift + if (isLiveOutputEditingDisabled) return; + + setLiveOutputEditingEnabled(status ?? !isLiveOutputEditingEnabled); + Services.UsageStatisticsService.actions.recordAnalyticsEvent('LiveOutputEditing', { + toggle: status ?? !isLiveOutputEditingEnabled, + }); + }, + [setLiveOutputEditingEnabled, isLiveOutputEditingEnabled, isLiveOutputEditingDisabled], + ); + + return ( + handleToggleLiveOutputEditing()} + value={isLiveOutputEditingEnabled} + title={ + <> + {$t('Live output editing')} + {!isPrime && } + + } + name="liveOutput" + description={$t('Manage output destinations mid-stream.')} + icon="icon-output" + disabled={isLiveOutputEditingDisabled} + switchTooltip={liveOutputTooltip} + switchTooltipDisabled={tooltipDisabled} + iconClassName={!isPrime ? styles.ultraIcon : undefined} + /> + ); +} diff --git a/app/components-react/windows/go-live/StreamShiftCard.tsx b/app/components-react/windows/go-live/StreamShiftCard.tsx new file mode 100644 index 000000000000..7699f127b7d3 --- /dev/null +++ b/app/components-react/windows/go-live/StreamShiftCard.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useMemo } from 'react'; +import { useGoLiveSettings } from './useGoLiveSettings'; +import { Services } from 'components-react/service-provider'; +import { SwitcherCard } from './SwitcherCard'; +import UltraIcon from 'components-react/shared/UltraIcon'; +import { $t } from 'services/i18n/i18n'; +import styles from './GoLive.m.less'; +import { shell } from '@electron/remote'; + +export default function StreamShiftCard() { + const { isStreamShiftMode, isPrime, setStreamShift, isStreamShiftDisabled } = useGoLiveSettings(); + + const tooltipDisabled = !isStreamShiftDisabled; + + const handleToggleStreamShift = useCallback( + (status?: boolean) => { + if (!isPrime) { + Services.MagicLinkService.actions.linkToPrime('slobs-streamswitcher', { + event: 'StreamShift', + }); + return; + } + + // A disabled card still receives the click, so stop here rather than switching on a feature + // that is mutually exclusive with live output editing + if (isStreamShiftDisabled) return; + + setStreamShift(status ?? !isStreamShiftMode); + Services.UsageStatisticsService.actions.recordAnalyticsEvent('StreamShift', { + toggle: status ?? !isStreamShiftMode, + }); + }, + [setStreamShift, isStreamShiftMode, isStreamShiftDisabled, isPrime], + ); + + return ( + handleToggleStreamShift()} + value={isStreamShiftDisabled ? false : isStreamShiftMode} + title={ + <> + {$t('Stream Shift')} + {!isPrime && } + + } + name="streamShift" + description={$t('Switch between devices while live.')} + icon="icon-repeat-2" + iconClassName={!isPrime ? styles.ultraIcon : undefined} + disabled={isStreamShiftDisabled} + switchTooltip={} + switchTooltipDisabled={tooltipDisabled} + /> + ); +} + +function StreamShiftTooltip() { + const { + isPrime, + isDualOutputMode, + isPatreonEnabled, + isStreamShiftMode, + isLiveOutputEditingEnabled, + showTooltip, + } = useGoLiveSettings().extend(module => ({ + get showTooltip() { + if (module.isPatreonEnabled) return true; + if (!module.isPrime) return true; + if (module.isStreamShiftMode) return false; + if (module.isLiveOutputEditingEnabled) return true; + if (module.isDualOutputMode) return true; + return false; + }, + })); + + const tooltipText = useMemo(() => { + if (!isPrime) { + return { name: 'non-ultra', text: $t('Upgrade to Ultra to switch streams between devices.') }; + } + + if (isDualOutputMode) { + return { name: 'dual-output', text: $t('Stream Shift cannot be used with Dual Output') }; + } + + if (isPatreonEnabled) { + return { name: 'patreon', text: $t('Stream Shift cannot be used with Patreon') }; + } + + if (isLiveOutputEditingEnabled) { + return { + name: 'live-output', + text: $t('Stream Shift cannot be used with Live Output Editing'), + }; + } + + return { name: 'default', text: '' }; + }, [isPrime, isPatreonEnabled, isDualOutputMode, isStreamShiftMode, isLiveOutputEditingEnabled]); + + function handleTooltipClick() { + shell.openExternal( + 'https://streamlabs.com/content-hub/post/how-to-use-streamlabs-stream-shift', + ); + } + + return showTooltip ? ( + {tooltipText.text} + ) : ( + + {$t( + 'Stay uninterrupted by switching between devices mid stream. Works between Desktop and Mobile App.', + )} + {$t('Learn More')} + + ); +} diff --git a/app/components-react/windows/go-live/SwitcherCard.tsx b/app/components-react/windows/go-live/SwitcherCard.tsx index 56e8f58f6a55..ea22274732a6 100644 --- a/app/components-react/windows/go-live/SwitcherCard.tsx +++ b/app/components-react/windows/go-live/SwitcherCard.tsx @@ -28,11 +28,15 @@ interface ISwitcherCardProps { description: string; value: boolean; onClick: (e: MouseEvent) => boolean | void | unknown; - tooltip?: string; + tooltip?: string | ReactNode; tooltipDisabled?: boolean; + switchTooltip?: string | ReactNode; + switchTooltipDisabled?: boolean; className?: string; switchClassName?: string; tooltipClassName?: string; + switchTooltipClassName?: string; + iconClassName?: string; disabled?: boolean; switchDisabled?: boolean; } @@ -40,6 +44,7 @@ interface ISwitcherCardProps { interface ISwitcherCardContentsProps { className?: string; switchClassName?: string; + iconClassName?: string; onClick: (e: MouseEvent) => void; onTransitionEnd: (e: React.TransitionEvent) => void; value: boolean; @@ -51,6 +56,9 @@ interface ISwitcherCardContentsProps { description: string; children?: ReactNode; switchDisabled?: boolean; + switchTooltip?: string | ReactNode; + switchTooltipDisabled?: boolean; + switchTooltipClassName?: string; } /** @@ -128,7 +136,11 @@ export const SwitcherCard = forwardRef( label={p.label} title={p.title} icon={p.icon} + iconClassName={p.iconClassName} description={p.description} + switchTooltip={p.switchTooltip} + switchTooltipDisabled={p.switchTooltipDisabled} + switchTooltipClassName={p.switchTooltipClassName} > {p.children} @@ -138,25 +150,53 @@ export const SwitcherCard = forwardRef( function SwitcherCardContents(p: ISwitcherCardContentsProps) { return ( -
+
- + {p.switchTooltip ? ( + + + + ) : ( + + )}
{/* PLATFORM LOGO AND NAME*/} {typeof p.icon === 'string' ? ( - + ) : ( p.icon )} diff --git a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx index b6c0f0ddea94..4875b00c7656 100644 --- a/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx +++ b/app/components-react/windows/go-live/platforms/PlatformSettingsLayout.tsx @@ -53,4 +53,5 @@ export interface IPlatformComponentParams { isAiHighlighterEnabled?: boolean; isStreamShiftMode?: boolean; isMidStreamMode?: boolean; + isLiveOutputEditingEnabled?: boolean; } diff --git a/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx b/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx index 7857d1f9b363..a2a1ad9109b9 100644 --- a/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx +++ b/app/components-react/windows/go-live/platforms/TwitchEditStreamInfo.tsx @@ -59,7 +59,9 @@ const TwitchRequiredFields = memo((p: IPlatformComponentParams<'twitch'>) => {
- {p.isAiHighlighterEnabled && } + {p.isAiHighlighterEnabled && ( + + )} ); }); @@ -75,20 +77,36 @@ const TwitchOptionalFields = memo((p: IPlatformComponentParams<'twitch'>) => { }, [twSettings?.display]); const enhancedBroadcastingTooltipText = useMemo(() => { - return p.isDualOutputMode - ? $t( - 'Enhanced broadcasting in dual output mode is only available when streaming to both the horizontal and vertical displays in Twitch', - ) - : $t( - 'Enhanced broadcasting automatically optimizes your settings to encode and send multiple video qualities to Twitch. Selecting this option will send basic information about your computer and software setup.', - ); - }, [p.isDualOutputMode]); + if (p.isLiveOutputEditingEnabled) { + return $t('Enhanced broadcasting is not available for live output editing'); + } + + if (p.isDualOutputMode) { + return $t( + 'Enhanced broadcasting in dual output mode is only available when streaming to both the horizontal and vertical displays in Twitch', + ); + } + + return $t( + 'Enhanced broadcasting automatically optimizes your settings to encode and send multiple video qualities to Twitch. Selecting this option will send basic information about your computer and software setup.', + ); + }, [p.isDualOutputMode, p.isLiveOutputEditingEnabled]); const enhancedBroadcastingEnabled = useMemo(() => { + if (p.isLiveOutputEditingEnabled) return false; if (isDualStream) return true; if (p.isStreamShiftMode) return false; return twSettings?.isEnhancedBroadcasting; - }, [isDualStream, twSettings?.isEnhancedBroadcasting, p.isStreamShiftMode]); + }, [ + isDualStream, + twSettings?.isEnhancedBroadcasting, + p.isStreamShiftMode, + p.isLiveOutputEditingEnabled, + ]); + + const disableEnhancedBroadcasting = useMemo(() => { + return isDualStream || p.isStreamShiftMode || p.isLiveOutputEditingEnabled || p.isUpdateMode; + }, [isDualStream, p.isStreamShiftMode, p.isLiveOutputEditingEnabled, p.isUpdateMode]); return ( <> @@ -109,7 +127,7 @@ const TwitchOptionalFields = memo((p: IPlatformComponentParams<'twitch'>) => { label={$t('Enhanced broadcasting')} tooltip={enhancedBroadcastingTooltipText} {...bind.isEnhancedBroadcasting} - disabled={isDualStream || p.isStreamShiftMode} + disabled={disableEnhancedBroadcasting} value={enhancedBroadcastingEnabled} tooltipIcon={ 4; } + get lastStream(): IStreamDiagnosticInfo | undefined { + return this.state.streams[this.state.streams.length - 1]; + } + static defaultState: IDiagnosticsServiceState = { streams: [], }; diff --git a/app/services/dismissables.ts b/app/services/dismissables.ts index 66db1e18e0c0..da8ec677e0f3 100644 --- a/app/services/dismissables.ts +++ b/app/services/dismissables.ts @@ -20,6 +20,7 @@ export enum EDismissable { TikTokReapply = 'tiktok_reapply', EnhancedBroadcasting = 'enhanced_broadcasting', StreamAvatarAutomationsWelcome = 'stream_avatar_automations_welcome', + HighlighterBanner = 'highlighter_banner', } interface IDismissablesServiceState { @@ -60,9 +61,7 @@ export class DismissablesService extends PersistentStatefulService - this.dismiss(EDismissable[key]), - ); + Object.values(EDismissable).forEach((key: EDismissable) => this.dismiss(key)); } /** diff --git a/app/services/platforms/kick.ts b/app/services/platforms/kick.ts index 1a8eae6a67a9..9c744b941278 100644 --- a/app/services/platforms/kick.ts +++ b/app/services/platforms/kick.ts @@ -84,6 +84,7 @@ interface IKickUpdateStreamResponse { interface IKickStartStreamSettings { title: string; game: string; + gameName?: string; video?: IVideo; mode?: TOutputOrientation; } @@ -91,6 +92,7 @@ interface IKickStartStreamSettings { export interface IKickStartStreamOptions { title: string; game: string; + gameName?: string; } interface IKickRequestHeaders extends Dictionary { @@ -109,6 +111,7 @@ export class KickService title: '', mode: 'landscape', game: '', + gameName: '', }, ingest: '', chatUrl: '', @@ -421,13 +424,25 @@ export class KickService * show live approval status. */ async searchGames(searchString: string): Promise { + if (!searchString || searchString === '') { + console.debug('Kick search string is empty.'); + return [] as IGame[]; + } + const host = this.hostsService.streamlabs; - const url = `https://${host}/api/v5/slobs/kick/info?category=${searchString}`; + const params = new URLSearchParams({ category: searchString }); + const url = `https://${host}/api/v5/slobs/kick/info?${params.toString()}`; const headers = authorizedHeaders(this.userService.apiToken); const request = new Request(url, { headers }); return jfetch(request) .then(async res => { + // To prevent errors when the response is not valid return an empty array + if (typeof res !== 'object' || res === null) { + console.error('Received a non-JSON response fetching Kick categories info.'); + return [] as IGame[]; + } + const data = res as IKickStreamInfoResponse; if (data.categories && data.categories.length > 0) { @@ -452,7 +467,19 @@ export class KickService } async fetchGame(name: string): Promise { - return (await this.searchGames(name))[0]; + const defaultGame: IGame = { id: '', name: '', image: '' }; + + // Don't attempt to search for an empty game name + // Note: on app start, there will not be a game selected yet + if (!name || name === '') return Promise.resolve(defaultGame); + + const games = await this.searchGames(name); + return games && games.length > 0 ? games[0] : defaultGame; + } + + setGameInfo({ gameId, gameName }: { gameId: string; gameName: string }) { + this.UPDATE_STREAM_SETTINGS({ game: gameId, gameName }); + this.SET_GAME_NAME(gameName); } /** @@ -593,5 +620,8 @@ export class KickService @mutation() SET_GAME_NAME(gameName: string) { this.state.gameName = gameName; + // also mirror into settings so it survives into savedSettings, which clones + // state.settings rather than reading the top-level state + this.state.settings = { ...this.state.settings, gameName }; } } diff --git a/app/services/platforms/twitch.ts b/app/services/platforms/twitch.ts index e7308ff03ec7..81aa3b36dda0 100644 --- a/app/services/platforms/twitch.ts +++ b/app/services/platforms/twitch.ts @@ -275,6 +275,10 @@ export class TwitchService } catch (e: unknown) { console.error('Error setting up dual stream:', e); } + } else if (this.streamingService.views.isLiveOutputEditingEnabled) { + // When live output editing is enabled enhanced broadcasting won't work because it + // uses restream, which is incompatible with enhanced broadcasting. + this.settingsService.setEnhancedBroadcasting(false); } else { // Update enhanced broadcasting setting based on go live settings this.settingsService.setEnhancedBroadcasting(channelInfo.isEnhancedBroadcasting); diff --git a/app/services/restream.ts b/app/services/restream.ts index 8ecca8f7a565..f3c7660a7de1 100644 --- a/app/services/restream.ts +++ b/app/services/restream.ts @@ -23,6 +23,7 @@ import { InstagramService } from './platforms/instagram'; 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 { Subject } from 'rxjs'; import uuid from 'uuid'; @@ -30,6 +31,7 @@ import Utils from './utils'; import { $t } from './i18n'; import { RealmObject } from './realm'; import { ObjectSchema } from 'realm'; +import { TSocketEvent } from './websocket'; interface IIngestServer { name: string; @@ -160,6 +162,7 @@ export class RestreamService extends StatefulService { @Inject() platformAppsService: PlatformAppsService; @Inject() dualOutputService: DualOutputService; @Inject() settingsService: SettingsService; + @Inject() usageStatisticsService: UsageStatisticsService; settings: IUserSettingsResponse; @@ -1301,6 +1304,86 @@ export class RestreamService extends StatefulService { this.SET_STREAM_SWITCHER_FORCE_GO_LIVE(true); } + /** + * Infer the type of the remote device from its stream identifier + * @remarks Mobile identifiers contain uppercase characters, desktop identifiers do not. + * Note: because the event's stream id is from the device that requested the switch, it is not + * possible to know what type of device the stream will be switching from. We can only identify + * the type of device the stream is switching to. + */ + private getStreamShiftDeviceType(id: string): 'mobile' | 'desktop' { + return /[A-Z]/.test(id) ? 'mobile' : 'desktop'; + } + + /** + * Handle an incoming stream shift socket event + * @returns A message to show the user, or an empty string when no alert should be shown + */ + async handleStreamShiftEvent(event: TSocketEvent): Promise { + if (this.state.streamShiftForceGoLive) return ''; + if (event.type !== 'streamSwitchRequest' && event.type !== 'switchActionComplete') { + return ''; + } + + const streamShiftStreamId = this.state.streamShiftStreamId; + console.debug('Event ID: ' + event.data.identifier, '\n Stream ID: ' + streamShiftStreamId); + const isIncomingStream: boolean = + (streamShiftStreamId && event.data.identifier === streamShiftStreamId) || false; + + // Handle stream shift request events + if (event.type === 'streamSwitchRequest') { + if (isIncomingStream) { + // Don't record the request from this device because the other device will record it + this.confirmStreamShift('approved'); + } else { + this.recordStreamShiftAnalytics('request', event.data.identifier); + } + + // Currently no alert is shown for stream shift requests, so this is a placeholder message + return $t('Switch Stream'); + } + + // Handle stream shift completed events + if (event.type === 'switchActionComplete') { + // End the stream on this device if switching the stream to another device + // Only record analytics if the stream was switched from this device to a different one + + if (!isIncomingStream) { + this.endStreamShiftStream(event.data.identifier); + this.recordStreamShiftAnalytics('complete', event.data.identifier); + } + + // Notify the user + if (isIncomingStream) { + // close go live window + return $t( + 'Your stream has been switched to Streamlabs Desktop from another device. Enjoy your stream!', + ); + } + + return this.getStreamShiftDeviceType(event.data.identifier) === 'mobile' + ? $t('Your stream has been successfully switched to Streamlabs Mobile. Enjoy your stream!') + : $t( + 'Your stream has been successfully switched to Streamlabs Desktop. Enjoy your stream!', + ); + } + + // Placeholder for a default return value when no stream shift event is handled + return ''; + } + + /** + * @param id - The stream identifier of the device the stream is switching to + */ + recordStreamShiftAnalytics(action: 'request' | 'complete', id: string) { + if (Utils.isTestMode()) return; + + this.usageStatisticsService.recordAnalyticsEvent('StreamShift', { + stream: `desktop-${this.getStreamShiftDeviceType(id)}`, + action, + }); + } + /** * Test helper to emit isLive for testing purposes * @param isLive - Whether the stream is live or not diff --git a/app/services/settings/streaming/stream-settings.ts b/app/services/settings/streaming/stream-settings.ts index 8236af65f2ba..89c89797adbd 100644 --- a/app/services/settings/streaming/stream-settings.ts +++ b/app/services/settings/streaming/stream-settings.ts @@ -42,6 +42,13 @@ export interface ICustomStreamDestination { dualStream?: boolean; } +// Used for uniquely identifying custom destinations +export type TDestinationId = `${string}/${string}`; + +export function getDestinationId(dest: ICustomStreamDestination): TDestinationId { + return `${dest.url}/${dest.streamKey}` as TDestinationId; +} + /** * settings that we keep in the localStorage */ diff --git a/app/services/streaming/stream-error.ts b/app/services/streaming/stream-error.ts index ff66fb8c61f8..520d51219452 100644 --- a/app/services/streaming/stream-error.ts +++ b/app/services/streaming/stream-error.ts @@ -51,9 +51,87 @@ export const errorTypes = { return $t('Failed to update Multistream platforms and destinations while live'); }, }, + RESTREAM_INVALID_CONFIG: { + get message() { + return $t( + 'Multistream settings are invalid, please check your platforms and destinations and try again', + ); + }, + get action() { + return $t( + 'confirm the user has Ultra and confirm the settings for enabled platforms and destinations', + ); + }, + }, + RESTREAM_STREAM_KEY_MISSING: { + get message() { + return $t('Multistream stream key does not exist'); + }, + get action() { + return $t( + 'there was no Multistream session key, ask the user to end the stream and go live again', + ); + }, + }, + RESTREAM_STREAM_KEY_FETCH_FAILED: { + get message() { + return $t('Cannot add targets in live output editing mode because the stream key is missing'); + }, + get action() { + return $t( + 'ask the user to restart the stream and go live again. If in dual output mode, ask the user to stream in single output mode', + ); + }, + }, + RESTREAM_DISPLAY_SETUP_FAILED: { + get message() { + return $t('Failed to start Multistreaming for one of the displays'); + }, + get action() { + return $t( + 'confirm if the user is in dual output mode and which displays are currently streaming', + ); + }, + }, + RESTREAM_ADD_TARGETS_FAILED: { + get message() { + return $t('Failed to add the destination to your live stream'); + }, + get action() { + return $t( + 'confirm the platform settings for the platform, then try updating the stream again', + ); + }, + }, + RESTREAM_NO_ACTIVE_TARGETS: { + get message() { + return $t('No active Multistream destinations were found for your live stream'); + }, + get action() { + return $t( + 'no live destinations so there was nothing to remove. The stream may have already ended on the server', + ); + }, + }, + RESTREAM_REMOVE_TARGET_NOT_FOUND: { + get message() { + return $t('Failed to find the destination to remove on your live stream'); + }, + get action() { + return $t('one of the platforms requesting removal does not exist'); + }, + }, + RESTREAM_REMOVE_TARGETS_FAILED: { + get message() { + return $t('Failed to remove the destination from your live stream'); + }, + get action() { + return $t('failed to remove the platform while live, confirm the stream is still active'); + }, + }, RESTREAM_ENHANCED_BROADCASTING_FAILED: { get message() { - return $t('Failed to configure the Multistream server for Enhanced Broadcasting'); + return $t('Failed to multistream because Enhanced Broadcasting is enabled'); }, get action() { return $t('disable Enhanced Broadcasting for Twitch and try again'); @@ -573,6 +651,39 @@ export function formatUnknownErrorMessage( details, }; } +export function throwRestreamError(e: unknown, errorType?: TStreamErrorType, message?: string) { + console.error('Restream error:', e); + + const error = + e instanceof StreamError + ? e + : { + status: 400, + statusText: + message ?? $t('Failed to update Multistream platforms and destinations while live'), + }; + + const type = getRestreamErrorType(e, errorType); + const details = formatRestreamErrorMessage(e, message); + + throwStreamError(type, error, details); +} + +function getRestreamErrorType(e: unknown, errorType?: TStreamErrorType): TStreamErrorType { + if (e instanceof StreamError) { + return e.type; + } + + return errorType ?? ('RESTREAM_UPDATE_FAILED' as TStreamErrorType); +} + +function formatRestreamErrorMessage(e: unknown, message?: string) { + if (e instanceof StreamError) { + return e.details ?? e.statusText; + } + + return message ?? $t('Failed to update Multistream platforms and destinations while live'); +} function obsStringErrorAsMessages(info: { error: string; code: number }) { const error = { message: info.error, code: info.code }; diff --git a/app/services/streaming/streaming-api.ts b/app/services/streaming/streaming-api.ts index 51dcdf409280..87749e30918e 100644 --- a/app/services/streaming/streaming-api.ts +++ b/app/services/streaming/streaming-api.ts @@ -60,6 +60,7 @@ export interface IStreamInfo { facebook: TGoLiveChecklistItemState; twitter: TGoLiveChecklistItemState; instagram: TGoLiveChecklistItemState; + destination: TGoLiveChecklistItemState; setupMultistream: TGoLiveChecklistItemState; setupDualOutput: TGoLiveChecklistItemState; startVideoTransmission: TGoLiveChecklistItemState; diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts index 7963383d345d..20b84b0a3359 100644 --- a/app/services/streaming/streaming.ts +++ b/app/services/streaming/streaming.ts @@ -267,6 +267,7 @@ export class StreamingService facebook: 'not-started', twitter: 'not-started', instagram: 'not-started', + destination: 'not-started', setupMultistream: 'not-started', setupDualOutput: 'not-started', startVideoTransmission: 'not-started', diff --git a/app/styles/loader.less b/app/styles/loader.less index cd16b3a67a44..67ad7a1d10b9 100644 --- a/app/styles/loader.less +++ b/app/styles/loader.less @@ -1,4 +1,4 @@ -@import "./mixins.less"; +@import './mixins.less'; .s-loader__bg { position: relative; @@ -51,6 +51,10 @@ height: 80px; width: 56px; } +.s-spinner--small { + height: 15px; + width: 15px; +} .s-spinner__bar { fill: var(--title); }