diff --git a/Frontend/Docs/Settings Panel.md b/Frontend/Docs/Settings Panel.md index b2705d62..d636f76a 100644 --- a/Frontend/Docs/Settings Panel.md +++ b/Frontend/Docs/Settings Panel.md @@ -26,7 +26,7 @@ This page will be updated with new features and commands as they become availabl | **Suppress browser keys** | Suppress or allow certain keys we use in UE, for example F5 to show shader complexity instead of refreshing the page. | | **AFK if Idle** | Timeout the connection if no input is detected for a period of time. | | **AFK timeout** | Allows you to specify the AFK timeout period. | - +| **Max Reconnects** | The maximum number of reconnects the application will attempt when a streamer disconnects. | ### UI | **Setting** | **Description** | diff --git a/Frontend/library/src/Config/Config.ts b/Frontend/library/src/Config/Config.ts index 6f64fc82..7c6e840a 100644 --- a/Frontend/library/src/Config/Config.ts +++ b/Frontend/library/src/Config/Config.ts @@ -53,6 +53,7 @@ export class NumericParameters { static WebRTCFPS = 'WebRTCFPS' as const; static WebRTCMinBitrate = 'WebRTCMinBitrate' as const; static WebRTCMaxBitrate = 'WebRTCMaxBitrate' as const; + static MaxReconnectAttempts = 'MaxReconnectAttempts' as const; } export type NumericParametersKeys = Exclude< @@ -240,7 +241,7 @@ export class Config { })(), useUrlParams ) - ); + ); /** * Boolean parameters @@ -386,7 +387,7 @@ export class Config { 'Either locked mouse, where the pointer is consumed by the video and locked to it, or hovering mouse, where the mouse is not consumed.', false, useUrlParams, - (isHoveringMouse: boolean, setting: SettingBase) => { + (isHoveringMouse: boolean, setting: SettingBase) => { setting.label = `Control Scheme: ${isHoveringMouse ? 'Hovering' : 'Locked'} Mouse`; } ) @@ -475,6 +476,19 @@ export class Config { ) ); + this.numericParameters.set( + NumericParameters.MaxReconnectAttempts, + new SettingNumber( + NumericParameters.MaxReconnectAttempts, + 'Max Reconnects', + 'Maximum number of reconnects the application will attempt when a streamer disconnects.', + 0 /*min*/, + 999 /*max*/, + 3 /*value*/, + useUrlParams + ) + ); + this.numericParameters.set( NumericParameters.MinQP, new SettingNumber( diff --git a/Frontend/library/src/PixelStreaming/PixelStreaming.test.ts b/Frontend/library/src/PixelStreaming/PixelStreaming.test.ts index fb28e881..3bba0fab 100644 --- a/Frontend/library/src/PixelStreaming/PixelStreaming.test.ts +++ b/Frontend/library/src/PixelStreaming/PixelStreaming.test.ts @@ -176,7 +176,8 @@ describe('PixelStreaming', () => { }); it('should disconnect and reconnect to signalling server if reconnect is called and connection is up', () => { - const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}}); + // We explicitly set the max reconnect attempts to 0 to stop the auto-reconnect flow as that is tested separate + const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true, MaxReconnectAttempts: 0}}); const autoconnectedSpy = jest.fn(); const pixelStreaming = new PixelStreaming(config); pixelStreaming.addEventListener("webRtcAutoConnect", autoconnectedSpy); @@ -197,6 +198,56 @@ describe('PixelStreaming', () => { expect(autoconnectedSpy).toHaveBeenCalled(); }); + it('should automatically reconnect and request streamer list N times on websocket close', () => { + const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true, MaxReconnectAttempts: 3}}); + const autoconnectedSpy = jest.fn(); + + const pixelStreaming = new PixelStreaming(config); + pixelStreaming.addEventListener("webRtcAutoConnect", autoconnectedSpy); + + expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl); + expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledTimes(1); + expect(webSocketSpyFunctions.closeSpy).not.toHaveBeenCalled(); + + pixelStreaming.disconnect(); + + expect(webSocketSpyFunctions.closeSpy).toHaveBeenCalled(); + + // wait 2 seconds + jest.advanceTimersByTime(2000); + + // we should have attempted a reconnection + expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledWith(mockSignallingUrl); + expect(webSocketSpyFunctions.constructorSpy).toHaveBeenCalledTimes(2); + // Reconnect triggers the first list streamer message + triggerWebSocketOpen(); + expect(webSocketSpyFunctions.sendSpy).toHaveBeenCalledWith( + expect.stringMatching(/"type":"listStreamers"/) + ); + // We don't have a signalling server to respond with data so lets just fake a response with no streamers + triggerStreamerListMessage([]); + // Wait 2 seconds. This delay waits for the WebRtcPlayerController to realise the previously received list doesn't contain + // the streamer is was preiously subscribed to, so it'll request the list again + jest.advanceTimersByTime(2000); + + // Same as above but repeated for the second call + expect(webSocketSpyFunctions.sendSpy).toHaveBeenCalledWith( + expect.stringMatching(/"type":"listStreamers"/) + ); + triggerStreamerListMessage([]); + jest.advanceTimersByTime(2000); + + // Expect the third call + expect(webSocketSpyFunctions.sendSpy).toHaveBeenCalledWith( + expect.stringMatching(/"type":"listStreamers"/) + ); + triggerStreamerListMessage([]); + jest.advanceTimersByTime(2000); + + // We should expect only 3 calls based on our config + expect(webSocketSpyFunctions.sendSpy).toHaveBeenCalledTimes(3); + }); + it('should request streamer list when connected to the signalling server', () => { const config = new Config({ initialSettings: {ss: mockSignallingUrl, AutoConnect: true}}); const pixelStreaming = new PixelStreaming(config); diff --git a/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts b/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts index 319d2693..c460c2f3 100644 --- a/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts +++ b/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts @@ -19,7 +19,8 @@ import { Flags, ControlSchemeType, TextParameters, - OptionParameters + OptionParameters, + NumericParameters } from '../Config/Config'; import { EncoderSettings, @@ -101,6 +102,10 @@ export class WebRtcPlayerController { preferredCodec: string; peerConfig: RTCConfiguration; videoAvgQp: number; + shouldReconnect: boolean; + isReconnecting: boolean; + reconnectAttempt: number; + subscribedStream: string | null; signallingUrlBuilder: () => string; // if you override the disconnection message by calling the interface method setDisconnectMessageOverride @@ -221,6 +226,12 @@ export class WebRtcPlayerController { this.setMouseInputEnabled(false); this.setKeyboardInputEnabled(false); this.setGamePadInputEnabled(false); + + if(this.shouldReconnect && this.config.getNumericSettingValue(NumericParameters.MaxReconnectAttempts) > 0) { + this.isReconnecting = true; + this.reconnectAttempt++; + this.restartStreamAutomatically(); + } }); // set up the final webRtc player controller methods from within our application so a connection can be activated @@ -247,16 +258,24 @@ export class WebRtcPlayerController { this.isUsingSFU = false; this.isQualityController = false; this.preferredCodec = ''; + this.shouldReconnect = true; + this.isReconnecting = false; + this.reconnectAttempt = 0; this.config._addOnOptionSettingChangedListener( OptionParameters.StreamerId, (streamerid) => { + if(streamerid === "") { + return; + } + // close the current peer connection and create a new one this.peerConnectionController.peerConnection.close(); this.peerConnectionController.createPeerConnection( this.peerConfig, this.preferredCodec ); + this.subscribedStream = streamerid; this.webSocketController.sendSubscribe(streamerid); } ); @@ -1304,45 +1323,75 @@ export class WebRtcPlayerController { 6 ); - const settingOptions = [...messageStreamerList.ids]; // copy the original messageStreamerList.ids - settingOptions.unshift(''); // add an empty option at the top - this.config.setOptionSettingOptions( - OptionParameters.StreamerId, - settingOptions - ); - - const urlParams = new URLSearchParams(window.location.search); - let autoSelectedStreamerId: string | null = null; - if (messageStreamerList.ids.length == 1) { - // If there's only a single streamer, subscribe to it regardless of what is in the URL - autoSelectedStreamerId = messageStreamerList.ids[0]; - } else if ( - this.config.isFlagEnabled(Flags.PreferSFU) && - messageStreamerList.ids.includes('SFU') - ) { - // If the SFU toggle is on and there's an SFU connected, subscribe to it regardless of what is in the URL - autoSelectedStreamerId = 'SFU'; - } else if ( - urlParams.has(OptionParameters.StreamerId) && - messageStreamerList.ids.includes( - urlParams.get(OptionParameters.StreamerId) - ) - ) { - // If there's a streamer ID in the URL and a streamer with this ID is connected, set it as the selected streamer - autoSelectedStreamerId = urlParams.get(OptionParameters.StreamerId); - } - if (autoSelectedStreamerId !== null) { - this.config.setOptionSettingValue( + if(this.isReconnecting) { + if(messageStreamerList.ids.includes(this.subscribedStream)) { + // If we're reconnecting and the previously subscribed stream has come back, resubscribe to it + this.isReconnecting = false; + this.reconnectAttempt = 0; + this.webSocketController.sendSubscribe(this.subscribedStream); + } else if(this.reconnectAttempt < this.config.getNumericSettingValue(NumericParameters.MaxReconnectAttempts)) { + // Our previous stream hasn't come back, wait 2 seconds and request an updated stream list + this.reconnectAttempt++; + setTimeout(() => { + this.webSocketController.requestStreamerList(); + }, 2000) + } else { + // We've exhausted our reconnect attempts, return to main screen + this.reconnectAttempt = 0; + this.isReconnecting = false; + this.shouldReconnect = false; + this.webSocketController.close(); + + this.config.setOptionSettingValue( + OptionParameters.StreamerId, + "" + ); + this.config.setOptionSettingOptions( + OptionParameters.StreamerId, + [] + ); + } + } else { + const settingOptions = [...messageStreamerList.ids]; // copy the original messageStreamerList.ids + settingOptions.unshift(''); // add an empty option at the top + this.config.setOptionSettingOptions( OptionParameters.StreamerId, - autoSelectedStreamerId + settingOptions + ); + + const urlParams = new URLSearchParams(window.location.search); + let autoSelectedStreamerId: string | null = null; + if (messageStreamerList.ids.length == 1) { + // If there's only a single streamer, subscribe to it regardless of what is in the URL + autoSelectedStreamerId = messageStreamerList.ids[0]; + } else if ( + this.config.isFlagEnabled(Flags.PreferSFU) && + messageStreamerList.ids.includes('SFU') + ) { + // If the SFU toggle is on and there's an SFU connected, subscribe to it regardless of what is in the URL + autoSelectedStreamerId = 'SFU'; + } else if ( + urlParams.has(OptionParameters.StreamerId) && + messageStreamerList.ids.includes( + urlParams.get(OptionParameters.StreamerId) + ) + ) { + // If there's a streamer ID in the URL and a streamer with this ID is connected, set it as the selected streamer + autoSelectedStreamerId = urlParams.get(OptionParameters.StreamerId); + } + if (autoSelectedStreamerId !== null) { + this.config.setOptionSettingValue( + OptionParameters.StreamerId, + autoSelectedStreamerId + ); + } + this.pixelStreaming.dispatchEvent( + new StreamerListMessageEvent({ + messageStreamerList, + autoSelectedStreamerId + }) ); } - this.pixelStreaming.dispatchEvent( - new StreamerListMessageEvent({ - messageStreamerList, - autoSelectedStreamerId - }) - ); } /** diff --git a/Frontend/ui-library/src/Config/ConfigUI.ts b/Frontend/ui-library/src/Config/ConfigUI.ts index ffcb0665..4594802d 100644 --- a/Frontend/ui-library/src/Config/ConfigUI.ts +++ b/Frontend/ui-library/src/Config/ConfigUI.ts @@ -202,6 +202,10 @@ export class ConfigUI { psSettingsSection, this.numericParametersUi.get(NumericParameters.AFKTimeoutSecs) ); + this.addSettingNumeric( + psSettingsSection, + this.numericParametersUi.get(NumericParameters.MaxReconnectAttempts) + ); /* Setup all view/ui related settings under this section */ const viewSettingsSection = this.buildSectionWithHeading(