Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/components-react/root/LiveDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,11 @@ function LiveDock() {
placement="right"
autoAdjustOverflow={false}
>
<i onClick={() => ctrl.showEditStreamInfo()} className="icon-edit" />
<i
data-name="edit-stream"
onClick={() => ctrl.showEditStreamInfo()}
className="icon-edit"
/>
</Tooltip>
)}
{hasLiveDockFeature('view-stream') && isStreaming && (
Expand Down
122 changes: 31 additions & 91 deletions app/components-react/root/StartStreamingButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@ 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';
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 {
Expand All @@ -22,15 +21,13 @@ function StartStreamingButton(p: { disabled?: boolean }) {
MediaBackupService,
SourcesService,
RestreamService,
UsageStatisticsService,
} = Services;

const {
streamingStatus,
delayEnabled,
delaySeconds,
streamShiftStatus,
streamShiftForceGoLive,
isDualOutputMode,
isLoggedIn,
isPrime,
Expand All @@ -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,
Expand Down Expand Up @@ -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);

Comment on lines +81 to +85
Comment on lines +81 to +85
// 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();
Expand Down Expand Up @@ -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]);
Expand All @@ -236,31 +183,24 @@ 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;
const updateStreamInfoOnLive = CustomizationService.state.updateStreamInfoOnLive;

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;
Expand All @@ -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 (
<button
Expand Down
20 changes: 20 additions & 0 deletions app/components-react/root/StudioFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function StudioFooterComponent() {
replayBufferEnabled,
replayBufferStatus,
isReplayBufferActive,
isLiveOutputEditingEnabled,
} = useVuex(
() => ({
streamingStatus: StreamingService.views.streamingStatus,
Expand All @@ -46,6 +47,7 @@ function StudioFooterComponent() {
replayBufferEnabled: SettingsService.views.values.Output.RecRB,
replayBufferStatus: StreamingService.views.replayBufferStatus,
isReplayBufferActive: StreamingService.views.isReplayBufferActive,
isLiveOutputEditingEnabled: StreamingService.views.isLiveOutputEditingEnabled,
}),
false,
);
Expand Down Expand Up @@ -110,6 +112,12 @@ function StudioFooterComponent() {
StreamingService.actions.saveReplay();
}, [replayBufferSaving, replayBufferStopping]);

const openEditStream = useCallback(() => {
if (streamingStatus === EStreamingState.Live) {
StreamingService.actions.showEditStream();
}
}, [streamingStatus]);

const showRecordingModeDisableModal = useCallback(async () => {
const result = await confirmAsync({
title: $t('Enable Live Streaming?'),
Expand Down Expand Up @@ -197,6 +205,18 @@ function StudioFooterComponent() {
</Tooltip>
</div>
)}
{isLiveOutputEditingEnabled && streamingStatus === EStreamingState.Live && (
<div className={styles.navItem}>
<button
style={{ minWidth: '130px' }}
className={'button button--action'}
onClick={openEditStream}
data-name="ManageStreamButton"
>
{$t('Manage Stream')}
</button>
</div>
)}
{!recordingModeEnabled && (
<div className={styles.navItem}>
<StartStreamingButton />
Expand Down
38 changes: 37 additions & 1 deletion app/components-react/shared/DisplaySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}', {
Expand All @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions app/components-react/shared/InfoBanner.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions app/components-react/shared/Spinner.m.less
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading