diff --git a/app/components-react/root/StartStreamingButton.tsx b/app/components-react/root/StartStreamingButton.tsx
index 03c4ccdfce34..85427547e675 100644
--- a/app/components-react/root/StartStreamingButton.tsx
+++ b/app/components-react/root/StartStreamingButton.tsx
@@ -9,6 +9,7 @@ import * as remote from '@electron/remote';
import { TStreamShiftStatus } from 'services/restream';
import { promptAction } from 'components-react/modals';
import { IStreamShiftRequested, IStreamShiftActionCompleted } from 'services/websocket';
+import { confirmStreamShift } from 'components-react/shared/StreamShiftModal';
export default function StartStreamingButton(p: { disabled?: boolean }) {
const {
@@ -77,41 +78,35 @@ export default function StartStreamingButton(p: { disabled?: boolean }) {
(event: IStreamShiftRequested | IStreamShiftActionCompleted) => {
const { streamShiftStreamId } = RestreamService.state;
console.debug('Event ID: ' + event.data.identifier, '\n Stream ID: ' + streamShiftStreamId);
- const isFromOtherDevice =
- streamShiftStreamId && event.data.identifier !== streamShiftStreamId;
-
- const isMobileRemote = isFromOtherDevice ? /[A-Z]/.test(event.data.identifier) : false;
- const remoteDeviceType = isMobileRemote ? 'mobile' : 'desktop';
-
- // 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 switchType = `desktop-${remoteDeviceType}`;
+ const isFromOtherDevice = streamShiftStreamId
+ ? event.data.identifier !== streamShiftStreamId
+ : true;
+ const switchType = formatStreamType(isFromOtherDevice, event.data.identifier);
if (event.type === 'streamSwitchRequest') {
if (!isFromOtherDevice) {
- // Don't record the request from this device because the other device will record it
RestreamService.actions.confirmStreamShift('approved');
- } else {
- UsageStatisticsService.recordAnalyticsEvent('StreamShift', {
- stream: switchType,
- action: 'request',
- });
}
+
+ UsageStatisticsService.recordAnalyticsEvent('StreamShift', {
+ stream: switchType,
+ action: 'request',
+ });
}
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 (isFromOtherDevice) {
Services.RestreamService.actions.endStreamShiftStream(event.data.identifier);
-
- UsageStatisticsService.recordAnalyticsEvent('StreamShift', {
- stream: switchType,
- action: 'complete',
- });
}
+ UsageStatisticsService.recordAnalyticsEvent('StreamShift', {
+ stream: switchType,
+ action: 'complete',
+ });
+
// Notify the user
const message = isFromOtherDevice
? $t(
@@ -137,6 +132,19 @@ export default function StartStreamingButton(p: { disabled?: boolean }) {
};
}, []);
+ const formatStreamType = useCallback((isFromOtherDevice: boolean, eventStreamId?: string) => {
+ // 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.
+ if (!isFromOtherDevice || !eventStreamId) {
+ return 'other-desktop';
+ }
+
+ // Mobile stream ids have capital letters, Desktop stream ids do not.
+ const remoteDeviceType = /[A-Z]/.test(eventStreamId) ? 'mobile' : 'desktop';
+ return `desktop-${remoteDeviceType}`;
+ }, []);
+
const toggleStreaming = useCallback(async () => {
if (StreamingService.isStreaming) {
StreamingService.toggleStreaming();
@@ -189,33 +197,8 @@ export default function StartStreamingButton(p: { disabled?: boolean }) {
const isLive = await fetchStreamShiftStatus();
setIsLoading(false);
- const message = isDualOutputMode
- ? $t(
- 'A stream on another device has been detected. Would you like to switch your stream to Streamlabs Desktop? If you do not wish to continue this stream, please end it from the current streaming source. Dual Output will be disabled since not supported in this mode. If you\'re sure you\'re not live and it has been incorrectly detected, choose "Force Start" below.',
- )
- : $t(
- 'A stream on another device has been detected. Would you like to switch your stream to Streamlabs Desktop? If you do not wish to continue this stream, please end it from the current streaming source. If you\'re sure you\'re not live and it has been incorrectly detected, choose "Force Start" below.',
- );
-
if (isLive) {
- const { streamShiftForceGoLive } = RestreamService.state;
- let shouldForceGoLive = streamShiftForceGoLive;
-
- await promptAction({
- title: $t('Another stream detected'),
- message,
- btnText: $t('Switch to Streamlabs Desktop'),
- fn: startStreamShift,
- cancelBtnText: $t('Cancel'),
- cancelBtnPosition: 'left',
- secondaryActionText: $t('Force Start'),
- secondaryActionFn: async () => {
- // FIXME: this should actually do something server-side
- RestreamService.actions.return.forceStreamShiftGoLive(true);
- shouldForceGoLive = true;
- },
- });
-
+ const shouldForceGoLive = await confirmStreamShift();
if (!shouldForceGoLive) {
return;
}
@@ -252,14 +235,6 @@ export default function StartStreamingButton(p: { disabled?: boolean }) {
}
}, []);
- const startStreamShift = useCallback(() => {
- if (isDualOutputMode) {
- Services.DualOutputService.actions.toggleDisplay(false, 'vertical');
- }
-
- StreamingService.actions.goLive();
- }, [isDualOutputMode]);
-
const shouldShowGoLiveWindow = useCallback(() => {
if (!UserService.isLoggedIn) return false;
const primaryPlatform = UserService.state.auth?.primaryPlatform;
diff --git a/app/components-react/shared/StreamShiftModal.tsx b/app/components-react/shared/StreamShiftModal.tsx
new file mode 100644
index 000000000000..5cbb74a97605
--- /dev/null
+++ b/app/components-react/shared/StreamShiftModal.tsx
@@ -0,0 +1,37 @@
+import { Services } from '../service-provider';
+import { promptAction } from 'components-react/modals';
+import { $t } from 'services/i18n/i18n';
+
+export async function confirmStreamShift() {
+ const { RestreamService, DualOutputService } = Services;
+ const { streamShiftForceGoLive } = RestreamService.state;
+ let shouldForceGoLive = streamShiftForceGoLive;
+
+ const message = DualOutputService.state.dualOutputMode
+ ? $t(
+ 'A stream on another device has been detected. Would you like to switch your stream to Streamlabs Desktop? If you do not wish to continue this stream, please end it from the current streaming source. Dual Output will be disabled since not supported in this mode. If you\'re sure you\'re not live and it has been incorrectly detected, choose "Force Start" below.',
+ )
+ : $t(
+ 'A stream on another device has been detected. Would you like to switch your stream to Streamlabs Desktop? If you do not wish to continue this stream, please end it from the current streaming source. If you\'re sure you\'re not live and it has been incorrectly detected, choose "Force Start" below.',
+ );
+
+ await promptAction({
+ title: $t('Another stream detected'),
+ message,
+ btnText: $t('Switch to Streamlabs Desktop'),
+ fn: () => {
+ RestreamService.actions.startStreamShift();
+ shouldForceGoLive = false;
+ },
+ cancelBtnText: $t('Cancel'),
+ cancelBtnPosition: 'left',
+ secondaryActionText: $t('Force Start'),
+ secondaryActionFn: async () => {
+ // FIXME: this should actually do something server-side
+ RestreamService.actions.return.forceStreamShiftGoLive(true);
+ shouldForceGoLive = true;
+ },
+ });
+
+ return shouldForceGoLive;
+}
diff --git a/app/components-react/shared/StreamShiftToggle.tsx b/app/components-react/shared/StreamShiftToggle.tsx
index ba7f46376a68..12c86db1033b 100644
--- a/app/components-react/shared/StreamShiftToggle.tsx
+++ b/app/components-react/shared/StreamShiftToggle.tsx
@@ -38,6 +38,7 @@ export default function StreamShiftToggle(p: IStreamShiftToggle) {
({
@@ -86,31 +85,11 @@ function ModalFooter() {
setIsFetchingStreamStatus(false);
// Prompt to confirm stream switch if the stream exists
- // TODO: unify with start streaming button prompt
- const { streamShiftForceGoLive } = Services.RestreamService.state;
- if (isLive && !streamShiftForceGoLive) {
- let shouldForceGoLive = false;
-
- await promptAction({
- title: $t('Another stream detected'),
- message: $t(
- 'A stream on another device has been detected. Would you like to switch your stream to Streamlabs Desktop? If you do not wish to continue this stream, please end it from the current streaming source. If you\'re sure you\'re not live and it has been incorrectly detected, choose "Force Start" below.',
- ),
- btnText: $t('Switch to Streamlabs Desktop'),
- fn: () => {
- goLive();
- close();
- },
- cancelBtnText: $t('Cancel'),
- cancelBtnPosition: 'left',
- secondaryActionText: $t('Force Start'),
- secondaryActionFn: async () => {
- Services.RestreamService.actions.forceStreamShiftGoLive(true);
- shouldForceGoLive = true;
- },
- });
-
- if (!shouldForceGoLive) return;
+ if (isLive && !Services.RestreamService.state.streamShiftForceGoLive) {
+ const shouldForceGoLive = await confirmStreamShift();
+ if (!shouldForceGoLive) {
+ return;
+ }
}
} catch (e: unknown) {
console.error('Error checking stream switcher status:', e);
diff --git a/app/services/restream.ts b/app/services/restream.ts
index f430fe716c5c..68f71195c924 100644
--- a/app/services/restream.ts
+++ b/app/services/restream.ts
@@ -48,7 +48,7 @@ export interface ITargetLiveData extends IStreamShiftTarget {
game_name?: string;
}
-export type TStreamShiftStatus = 'pending' | 'inactive' | 'active';
+export type TStreamShiftStatus = 'pending' | 'claimed' | 'inactive' | 'active';
export type TStreamShiftAction = 'approved' | 'rejected';
interface IRestreamState {
@@ -121,7 +121,7 @@ export class RestreamService extends StatefulService {
enabled: true,
grandfathered: false,
tiktokGrandfathered: false,
- streamShiftStreamId: undefined,
+ streamShiftStreamId: null,
streamShiftStatus: 'inactive',
streamShiftTargets: [],
streamShiftForceGoLive: false,
@@ -518,7 +518,8 @@ export class RestreamService extends StatefulService {
this.streamSettingsService.setGoLiveSettings({ streamShift: true });
this.SET_STREAM_SWITCHER_STATUS('pending');
this.SET_STREAM_SWITCHER_TARGETS(status.targets);
- } else if (this.state.streamShiftStatus === 'pending') {
+ } else {
+ this.SET_STREAM_SWITCHER_STREAM_ID();
this.SET_STREAM_SWITCHER_STATUS('inactive');
this.SET_STREAM_SWITCHER_TARGETS([]);
}
@@ -661,6 +662,19 @@ export class RestreamService extends StatefulService {
this.SET_STREAM_SWITCHER_TARGETS([]);
}
+ async startStreamShift() {
+ try {
+ await this.streamingService.goLive();
+ this.updateStreamShift('approved');
+
+ return Promise.resolve();
+ } catch (e: unknown) {
+ console.error('Error switching stream shift stream:', e);
+
+ return Promise.reject(e);
+ }
+ }
+
async confirmStreamShift(action: TStreamShiftAction) {
if (action === 'rejected') {
this.SET_STREAM_SWITCHER_STATUS('pending');
@@ -669,7 +683,7 @@ export class RestreamService extends StatefulService {
this.dualOutputService.toggleDisplay(false, 'vertical');
}
- this.SET_STREAM_SWITCHER_STATUS('inactive');
+ this.SET_STREAM_SWITCHER_STATUS('claimed');
this.updateStreamShift('approved');
}
}
@@ -699,7 +713,7 @@ export class RestreamService extends StatefulService {
*/
async endStreamShiftStream(remoteStreamId: string): Promise {
try {
- this.SET_STREAM_SWITCHER_STATUS('active');
+ this.SET_STREAM_SWITCHER_STATUS('inactive');
await this.streamingService.toggleStreaming();
this.SET_STREAM_SWITCHER_STREAM_ID(remoteStreamId);
} catch (error: unknown) {
diff --git a/app/services/streaming/streaming.ts b/app/services/streaming/streaming.ts
index dce31f504884..618f3838eff8 100644
--- a/app/services/streaming/streaming.ts
+++ b/app/services/streaming/streaming.ts
@@ -1057,8 +1057,9 @@ export class StreamingService
startStreamingPromise
.then(() => {
if (this.views.settings.streamShift) {
- // Remove the pending state to show the correct text in the start streaming button
- this.restreamService.setStreamShiftStatus('inactive');
+ // Remove the pending state and update streaming state to show the correct text in the start streaming button
+ this.restreamService.setStreamShiftStatus('active');
+ this.SET_STREAMING_STATUS(EStreamingState.Live);
// Confirm that the primary platform is streaming to correctly show chat
// Otherwise, use the first enabled platform. Note: this is a failsafe to guarantee
diff --git a/test/regular/streaming/multistream.ts b/test/regular/streaming/multistream.ts
index c5b9aff9de54..572d83d8ec62 100644
--- a/test/regular/streaming/multistream.ts
+++ b/test/regular/streaming/multistream.ts
@@ -6,6 +6,7 @@ import {
switchAdvancedMode,
waitForSettingsWindowLoaded,
waitForStreamStart,
+ waitForStreamStop,
} from '../../helpers/modules/streaming';
import { fillForm, useForm } from '../../helpers/modules/forms';
import {
@@ -20,6 +21,7 @@ import { releaseUserInPool, reserveUserFromPool, withUser } from '../../helpers/
import { showSettingsWindow } from '../../helpers/modules/settings/settings';
import { test, TExecutionContext, useWebdriver } from '../../helpers/webdriver';
import { sleep } from '../../helpers/sleep';
+import { getApiClient } from '../../helpers/api-client';
// not a react hook
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -33,9 +35,99 @@ async function enableAllPlatforms() {
}
}
-async function goLiveWithStreamShift(t: TExecutionContext, multistream: boolean) {
- await fillForm({ streamShift: true });
+async function shiftStream(t: TExecutionContext) {
+ const client = await getApiClient();
+ // const restreamService = client.getResource('RestreamService');
+
+ const jsonRpcRequest = {
+ result: {
+ _type: 'EVENT',
+ resourceId: 'WebsocketService.socketEvent',
+ emitter: 'STREAM',
+ },
+ jsonrpc: '2.0',
+ };
+
+ const mobileSwitchRequest = {
+ data: {
+ identifier: 'MOBILE-STREAM-IDENTIFIER-1234',
+ },
+ event_id: 'evt_1234',
+ for: 'streamlabs',
+ type: 'streamSwitchRequest',
+ };
+
+ const desktopSwitchRequest = {
+ data: {
+ identifier: 'desktop-stream-identifier-5678',
+ },
+ event_id: 'evt_5678',
+ for: 'streamlabs',
+ type: 'streamSwitchRequest',
+ };
+
+ const selfSwitchRequest = JSON.stringify({
+ data: {
+ identifier: 'self-stream-identifier-91011',
+ },
+ event_id: 'evt_91011',
+ for: 'streamlabs',
+ type: 'streamSwitchRequest',
+ });
+
+ // client.eventReceived.subscribe(async event => {
+ // console.log('Received socket event:', event);
+ // if (event.data.type === 'streamSwitchRequest') {
+ // console.log('\nStream shift received ', event.data);
+ // // await clickGoLive();
+ // await waitForDisplayed('span=Another stream detected', { timeout: 10000 });
+ // await clickButton('Switch to Streamlabs Desktop');
+ // // await waitForStreamStart();
+ // // await stopStream();
+ // // await waitForStreamStop();
+ // }
+
+ // if (event.data.type === 'switchActionComplete') {
+ // console.log('\nStream shift to completed ', event.data);
+ // await waitForDisplayed('span=Stream successfully switched', { timeout: 10000 });
+ // await sleep(10000);
+ // await clickButton('Close');
+ // }
+ // });
+
+ // client.sendJson(JSON.stringify({ data: { ...jsonRpcRequest, ...mobileSwitchRequest } }));
+
+ await new Promise((resolve, reject) => {
+ client.eventReceived.subscribe(async event => {
+ if (event.data.type === 'streamSwitchRequest') {
+ await waitForDisplayed('span=Another stream detected', { timeout: 10000 });
+ await clickButton('Switch to Streamlabs Desktop');
+ }
+
+ if (event.data.type === 'switchActionComplete') {
+ await waitForDisplayed('span=Stream successfully switched', { timeout: 10000 });
+ await sleep(10000);
+ await clickButton('Close');
+ await waitForStreamStart();
+ await stopStream();
+ await waitForStreamStop();
+ resolve();
+ }
+ });
+
+ const data = { data: { ...jsonRpcRequest, ...mobileSwitchRequest } };
+ client.sendJson(JSON.stringify(data));
+
+ // setup waiting timeout
+ setTimeout(
+ () => reject(`Socket event has not been received ${JSON.stringify(data, null, 2)}`),
+ 30000,
+ );
+ });
+}
+
+async function goLiveWithStreamShift(t: TExecutionContext, multistream: boolean) {
if (multistream) {
await enableAllPlatforms();
await waitForSettingsWindowLoaded();
@@ -43,17 +135,18 @@ async function goLiveWithStreamShift(t: TExecutionContext, multistream: boolean)
title: 'Test stream',
description: 'Test stream description',
twitchGame: 'Fortnite',
- kickGame: 'Fortnite',
+ trovoGame: 'Doom',
+ streamShift: true,
});
} else {
await fillForm({ twitch: true });
await waitForSettingsWindowLoaded();
- await fillForm({ title: 'Test stream', game: 'Fortnite' });
+ await fillForm({ title: 'Test stream', twitchGame: 'Fortnite', streamShift: true });
}
await waitForSettingsWindowLoaded();
await submit();
- await waitForDisplayed('span=Configure the Multistream service');
+ await waitForDisplayed('span=Configure the Multistream service', { timeout: 10000 });
await waitForStreamStart();
await focusMain();
@@ -68,8 +161,6 @@ async function goLiveWithStreamShift(t: TExecutionContext, multistream: boolean)
'Multistream chat tab not visible',
);
}
-
- await stopStream();
}
test(
@@ -228,8 +319,7 @@ test('Custom stream destinations', async t => {
}
});
-// TODO: enable after merge
-test.skip('Stream Shift', withUser('twitch', { prime: true, multistream: true }), async t => {
+test('Stream Shift', withUser('twitch', { prime: true, multistream: true }), async t => {
await prepareToGoLive();
await clickGoLive();
await waitForSettingsWindowLoaded();
@@ -237,8 +327,6 @@ test.skip('Stream Shift', withUser('twitch', { prime: true, multistream: true })
// Single stream shift
await goLiveWithStreamShift(t, false);
- // Multistream shift
- await goLiveWithStreamShift(t, true);
-
- t.pass();
+ // TODO: Add socket events to test
+ // await shiftStream(t);
});