From 127feac2e44e5904f6f3752d7514fb50178fed59 Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Tue, 24 Oct 2023 11:42:30 +1100 Subject: [PATCH 01/10] Better handling of streamer ids. Specifically legacy ids. --- SignallingWebServer/cirrus.js | 79 +++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/SignallingWebServer/cirrus.js b/SignallingWebServer/cirrus.js index 5dc2b03e..c4294b85 100644 --- a/SignallingWebServer/cirrus.js +++ b/SignallingWebServer/cirrus.js @@ -353,7 +353,8 @@ class Player { let streamers = new Map(); // streamerId <-> streamer socket let players = new Map(); // playerId <-> player, where player is either a web-browser or a native webrtc player const SFUPlayerId = "SFU"; -const LegacyStreamerId = "__LEGACY__"; // old streamers that dont know how to ID will be assigned this id. +const LegacyStreamerPrefix = "__LEGACY_STREAMER__"; // old streamers that dont know how to ID will be assigned this id prefix. +const streamerIdTimeoutSecs = 5; function sfuIsConnected() { const sfuPlayer = players.get(SFUPlayerId); @@ -401,30 +402,65 @@ function getPlayerIdFromMessage(msg) { return sanitizePlayerId(msg.playerId); } +function getUniqueLegacyId() { + for (let i = 0; i < 99; ++i) { + const testId = LegacyStreamerPrefix + i; + if (!streamers.has(testId)) { + return testId; + } + } + return ""; // no available id +} + +function requestStreamerId(streamer) { + // first we ask the streamer to id itself. + // if it doesnt reply within a time limit we assume it's an older streamer + // and assign it an id. + + // request id + const msg = { type: "identify" }; + logOutgoing(streamer.id, msg); + streamer.ws.send(JSON.stringify(msg)); + + streamer.idTimer = setTimeout(function() { + // streamer did not respond in time. give it a legacy id. + const newLegacyId = getUniqueLegacyId(); + if (newLegacyId.length == 0) { + const error = `Ran out of legacy ids.`; + console.error(error); + streamer.ws.close(1008, error); + } else { + registerStreamer(newLegacyId, streamer); + } + + }, streamerIdTimeoutSecs * 1000); +} + function registerStreamer(id, streamer) { streamer.id = id; streamers.set(streamer.id, streamer); + if (!!streamer.idTimer) { + clearTimeout(streamer.idTimer); + delete streamer.idTimer; + } + console.logColor(logging.Green, `Registered new streamer: ${streamer.id}`); } function onStreamerDisconnected(streamer) { - if (!streamer.id) { + if (!streamer.id || !streamers.has(streamer.id)) { return; } - if (!streamers.has(streamer.id)) { - console.error(`Disconnecting streamer ${streamer.id} does not exist.`); - } else { - sendStreamerDisconnectedToMatchmaker(); - let sfuPlayer = getSFU(); - if (sfuPlayer) { - const msg = { type: "streamerDisconnected" }; - logOutgoing(sfuPlayer.id, msg); - sfuPlayer.sendTo(msg); - disconnectAllPlayers(sfuPlayer.id); - } - disconnectAllPlayers(streamer.id); - streamers.delete(streamer.id); + sendStreamerDisconnectedToMatchmaker(); + let sfuPlayer = getSFU(); + if (sfuPlayer) { + const msg = { type: "streamerDisconnected" }; + logOutgoing(sfuPlayer.id, msg); + sfuPlayer.sendTo(msg); + disconnectAllPlayers(sfuPlayer.id); } + disconnectAllPlayers(streamer.id); + streamers.delete(streamer.id); } function onStreamerMessageId(streamer, msg) { @@ -438,9 +474,6 @@ function onStreamerMessageId(streamer, msg) { if (sfuPlayer) { sfuPlayer.subscribe(streamer.id); } - - // if any streamer id's assume the legacy streamer is not needed. - streamers.delete(LegacyStreamerId); } function onStreamerMessagePing(streamer, msg) { @@ -495,7 +528,7 @@ streamerServer.on('connection', function (ws, req) { console.logColor(logging.Green, `Streamer connected: ${req.connection.remoteAddress}`); sendStreamerConnectedToMatchmaker(); - let streamer = { ws: ws }; + let streamer = { id: req.connection.remoteAddress, ws: ws }; ws.on('message', (msgRaw) => { var msg; @@ -535,13 +568,7 @@ streamerServer.on('connection', function (ws, req) { }); ws.send(JSON.stringify(clientConfig)); - - // request id - const msg = { type: "identify" }; - logOutgoing("unknown", msg); - ws.send(JSON.stringify(msg)); - - registerStreamer(LegacyStreamerId, streamer); + requestStreamerId(streamer); }); function forwardSFUMessageToPlayer(msg) { From 01d8056bee4eb910ad739f1cb5ed9feadc0ba2a0 Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Tue, 24 Oct 2023 15:01:34 +1100 Subject: [PATCH 02/10] working on handling multiple sfus gracefully --- SFU/config.js | 3 + SFU/sfu_server.js | 62 ++++++++++- SignallingWebServer/cirrus.js | 195 +++++++++++++++++++++++----------- 3 files changed, 197 insertions(+), 63 deletions(-) diff --git a/SFU/config.js b/SFU/config.js index 1387c88c..1820b037 100644 --- a/SFU/config.js +++ b/SFU/config.js @@ -12,6 +12,9 @@ for(let arg of process.argv){ const config = { signallingURL: "ws://localhost:8889", + SFUId: "SFU", + subscribeStreamerId: "DefaultStreamer", + retrySubscribeDelaySecs: 10, mediasoup: { worker: { diff --git a/SFU/sfu_server.js b/SFU/sfu_server.js index 3401395f..fe7cd88f 100644 --- a/SFU/sfu_server.js +++ b/SFU/sfu_server.js @@ -3,6 +3,10 @@ const WebSocket = require('ws'); const mediasoup = require('mediasoup_prebuilt'); const mediasoupSdp = require('mediasoup-sdp-bridge'); +if (!config.retrySubscribeDelaySecs) { + config.retrySubscribeDelaySecs = 10; +} + let signalServer = null; let mediasoupRouter; let streamer = null; @@ -11,7 +15,7 @@ let peers = new Map(); function connectSignalling(server) { console.log("Connecting to Signalling Server at %s", server); signalServer = new WebSocket(server); - signalServer.addEventListener("open", _ => { console.log(`Connected to signalling server`); }); + signalServer.addEventListener("open", _ => onSignallingConnected()); signalServer.addEventListener("error", result => { console.log(`Error: ${result.message}`); }); signalServer.addEventListener("message", result => onSignallingMessage(result.data)); signalServer.addEventListener("close", result => { @@ -24,6 +28,42 @@ function connectSignalling(server) { }); } +async function onSignallingConnected() { + console.log(`Connected to signalling server`); + //signalServer.send(JSON.stringify({type: 'listStreamers'})); +} + +async function onStreamerList(msg) { + let success = false; + + // subscribe to either the configured streamer, or if not configured, just grab the first id + if (msg.ids.length > 0) { + if (!!config.subscribeStreamerId) { + if (msg.ids.includes(config.subscribeStreamerId)) { + signalServer.send(JSON.stringify({type: 'subscribe', streamerId: config.subscribeStreamerId})); + success = true; + } + } else { + signalServer.send(JSON.stringify({type: 'subscribe', streamerId: msg.ids[0]})); + success = true; + } + } + + if (!success) { + // did not subscribe to anything + console.log(`No subscribe (${config.retrySubscribeDelaySecs}`) + setTimeout(function() { + signalServer.send(JSON.stringify({type: 'listStreamers'})); + }, config.retrySubscribeDelaySecs * 1000); + } +} + +async function onIdentify(msg) { + console.log(JSON.stringify({type: 'endpointId', id: config.SFUId})); + signalServer.send(JSON.stringify({type: 'endpointId', id: config.SFUId})); + signalServer.send(JSON.stringify({type: 'listStreamers'})); +} + async function onStreamerOffer(sdp) { console.log("Got offer from streamer"); @@ -228,7 +268,7 @@ function onLayerPreference(msg) { } async function onSignallingMessage(message) { - //console.log(`Got MSG: ${message}`); + console.log(`Got MSG: ${message}`); const msg = JSON.parse(message); if (msg.type == 'offer') { @@ -255,6 +295,14 @@ async function onSignallingMessage(message) { else if (msg.type == 'layerPreference') { onLayerPreference(msg); } + else if (msg.type == 'streamerList') { + console.log('WA WA WEE WOO ----------------------------------------------------------------------'); + onStreamerList(msg); + } + else if (msg.type == 'identify') { + console.log('identifying...'); + onIdentify(msg); + } } async function startMediasoup() { @@ -276,6 +324,14 @@ async function startMediasoup() { return mediasoupRouter; } +async function onICEStateChange(identifier, iceState) { + console.log("%s ICE state changed to %s", identifier, iceState); + + if (identifier == 'Streamer' && iceState == 'completed') { + signalServer.send(JSON.stringify({type: 'startStreaming'})); + } +} + async function createWebRtcTransport(identifier) { const { listenIps, @@ -291,7 +347,7 @@ async function createWebRtcTransport(identifier) { initialAvailableOutgoingBitrate: initialAvailableOutgoingBitrate }); - transport.on("icestatechange", (iceState) => { console.log("%s ICE state changed to %s", identifier, iceState); }); + transport.on("icestatechange", (iceState) => onICEStateChange(identifier, iceState)); transport.on("iceselectedtuplechange", (iceTuple) => { console.log("%s ICE selected tuple %s", identifier, JSON.stringify(iceTuple)); }); transport.on("sctpstatechange", (sctpState) => { console.log("%s SCTP state changed to %s", identifier, sctpState); }); diff --git a/SignallingWebServer/cirrus.js b/SignallingWebServer/cirrus.js index c4294b85..90eb64fc 100644 --- a/SignallingWebServer/cirrus.js +++ b/SignallingWebServer/cirrus.js @@ -304,6 +304,14 @@ class Player { return; } this.streamerId = streamerId; + if (this.type == PlayerType.SFU) { + let streamer = streamers.get(this.streamerId); + if (!!streamer.SFUId) { + console.error(`Streamer ${this.streamerId} already has an SFU (${streamer.SFUId}) but we're trying to register player ${this.id} as an SFU.`); + } else { + streamer.SFUId = this.id; + } + } const msg = { type: 'playerConnected', playerId: this.id, dataChannel: true, sfu: this.type == PlayerType.SFU, sendOffer: !this.browserSendOffer }; logOutgoing(this.streamerId, msg); this.sendFrom(msg); @@ -311,6 +319,14 @@ class Player { unsubscribe() { if (this.streamerId && streamers.has(this.streamerId)) { + if (this.type == PlayerType.SFU) { + let streamer = streamers.get(this.streamerId); + if (!streamer.SFUId || streamer.SFUId != this.id) { + console.error(`Trying to unsibscribe SFU player ${this.id} from streamer ${streamer.id} but the current SFUId does not match (${streamer.SFUId}).`) + } else { + delete streamer.SFUId; + } + } const msg = { type: 'playerDisconnected', playerId: this.id }; logOutgoing(this.streamerId, msg); this.sendFrom(msg); @@ -352,17 +368,18 @@ class Player { let streamers = new Map(); // streamerId <-> streamer socket let players = new Map(); // playerId <-> player, where player is either a web-browser or a native webrtc player -const SFUPlayerId = "SFU"; const LegacyStreamerPrefix = "__LEGACY_STREAMER__"; // old streamers that dont know how to ID will be assigned this id prefix. const streamerIdTimeoutSecs = 5; -function sfuIsConnected() { - const sfuPlayer = players.get(SFUPlayerId); - return sfuPlayer && sfuPlayer.ws && sfuPlayer.ws.readyState == 1; -} - -function getSFU() { - return players.get(SFUPlayerId); +function getSFUForStreamer(streamerId) { + if (!streamers.has(streamerId)) { + return null; + } + const streamer = streamers.get(streamerId); + if (!streamer.SFUId) { + return null; + } + return players.get(streamer.SFUId); } function logIncoming(sourceName, msg) { @@ -412,6 +429,25 @@ function getUniqueLegacyId() { return ""; // no available id } +function getUniqueSFUId() { + for (let i = 0; i < 99; ++i) { + const testId = SFUStreamerPrefix + i; + let available = true; + for (let player of players) { + if (player.type == PlayerType.SFU) { + if (player.streamer.id == testId) { + available = false; + break; + } + } + } + if (available) { + return testId; + } + } + return ""; // no available id +} + function requestStreamerId(streamer) { // first we ask the streamer to id itself. // if it doesnt reply within a time limit we assume it's an older streamer @@ -452,7 +488,7 @@ function onStreamerDisconnected(streamer) { } sendStreamerDisconnectedToMatchmaker(); - let sfuPlayer = getSFU(); + let sfuPlayer = getSFUForStreamer(streamer.id); if (sfuPlayer) { const msg = { type: "streamerDisconnected" }; logOutgoing(sfuPlayer.id, msg); @@ -468,12 +504,6 @@ function onStreamerMessageId(streamer, msg) { let streamerId = msg.id; registerStreamer(streamerId, streamer); - - // subscribe any sfu to the latest connected streamer - const sfuPlayer = getSFU(); - if (sfuPlayer) { - sfuPlayer.subscribe(streamer.id); - } } function onStreamerMessagePing(streamer, msg) { @@ -494,7 +524,7 @@ function onStreamerMessageDisconnectPlayer(streamer, msg) { } function onStreamerMessageLayerPreference(streamer, msg) { - let sfuPlayer = getSFU(); + let sfuPlayer = getSFUForStreamer(streamer.id); if (sfuPlayer) { logOutgoing(sfuPlayer.id, msg); sfuPlayer.sendTo(msg); @@ -571,60 +601,109 @@ streamerServer.on('connection', function (ws, req) { requestStreamerId(streamer); }); -function forwardSFUMessageToPlayer(msg) { +function forwardSFUMessageToPlayer(sfuPlayer, msg) { const playerId = getPlayerIdFromMessage(msg); const player = players.get(playerId); if (player) { - logForward(SFUPlayerId, playerId, msg); + logForward(sfuPlayer.streamer.id, playerId, msg); player.sendTo(msg); } } -function forwardSFUMessageToStreamer(msg) { - const sfuPlayer = getSFU(); - if (sfuPlayer) { - logForward(SFUPlayerId, sfuPlayer.streamerId, msg); - msg.sfuId = SFUPlayerId; - sfuPlayer.sendFrom(msg); - } +function forwardSFUMessageToStreamer(sfuPlayer, msg) { + logForward(sfuPlayer.streamer.id, sfuPlayer.streamerId, msg); + msg.sfuId = sfuPlayer.id; + sfuPlayer.sendFrom(msg); } -function onPeerDataChannelsSFUMessage(msg) { +function onPeerDataChannelsSFUMessage(sfuPlayer, msg) { // sfu is telling a peer what stream id to use for a data channel const playerId = getPlayerIdFromMessage(msg); const player = players.get(playerId); if (player) { - logForward(SFUPlayerId, playerId, msg); + logForward(sfuPlayer.streamer.id, playerId, msg); player.sendTo(msg); player.datachannel = true; } } -function onSFUDisconnected() { - console.log("disconnecting SFU from streamer"); - disconnectAllPlayers(SFUPlayerId); - const sfuPlayer = getSFU(); - if (sfuPlayer) { - sfuPlayer.unsubscribe(); - sfuPlayer.ws.close(4000, "SFU Disconnected"); +// basically a duplicate of the streamer id request but this one does not register the streamer +function requestSFUStreamerId(sfuPlayer) { + // request id + const msg = { type: "identify" }; + logOutgoing(sfuPlayer.streamer.id, msg); + sfuPlayer.streamer.ws.send(JSON.stringify(msg)); + + sfuPlayer.streamer.idTimer = setTimeout(function() { + // streamer did not respond in time. give it a legacy id. + const newLegacyId = getUniqueSFUId(); + if (newLegacyId.length == 0) { + const error = `Ran out of legacy ids.`; + console.error(error); + sfuPlayer.ws.close(1008, error); + } else { + sfuPlayer.streamer.id = newLegacyId; + } + }, streamerIdTimeoutSecs * 1000); +} + +function onSFUMessageId(sfuPlayer, msg) { + logIncoming(sfuPlayer.streamer.id, msg); + sfuPlayer.streamer.id = msg.id; + + if (!!sfuPlayer.streamer.idTimer) { + clearTimeout(sfuPlayer.streamer.idTimer); + delete sfuPlayer.streamer.idTimer; } - players.delete(SFUPlayerId); - streamers.delete(SFUPlayerId); } +function onSFUMessageStartStreaming(sfuPlayer, msg) { + if (streamers.has(sfuPlayer.streamer.id)) { + console.error(`SFU ${sfuPlayer.streamer.id} is already registered as a streamer and streaming.`) + return; + } + + registerStreamer(sfuPlayer.streamer.id, sfuPlayer.streamer); +} + +function onSFUMessageStopStreaming(sfuPlayer, msg) { +if (!streamers.has(sfuPlayer.streamer.id)) { + console.error(`SFU ${sfuPlayer.streamer.id} is not registered as a streamer or streaming.`) + return; + } + + onStreamerDisconnected(sfuPlayer.streamer); +} + +function onSFUDisconnected(sfuPlayer) { + console.log("disconnecting SFU from streamer"); + disconnectAllPlayers(sfuPlayer.id); + sfuPlayer.unsubscribe(); + sfuPlayer.ws.close(4000, "SFU Disconnected"); + players.delete(sfuPlayer.id); + streamers.delete(sfuPlayer.id); +} + +sfuMessageHandlers.set('listStreamers', onPlayerMessageListStreamers); +sfuMessageHandlers.set('subscribe', onPlayerMessageSubscribe); +sfuMessageHandlers.set('unsubscribe', onPlayerMessageUnsubscribe); sfuMessageHandlers.set('offer', forwardSFUMessageToPlayer); sfuMessageHandlers.set('answer', forwardSFUMessageToStreamer); sfuMessageHandlers.set('streamerDataChannels', forwardSFUMessageToStreamer); sfuMessageHandlers.set('peerDataChannels', onPeerDataChannelsSFUMessage); +sfuMessageHandlers.set('endpointId', onSFUMessageId); +sfuMessageHandlers.set('startStreaming', onSFUMessageStartStreaming); +sfuMessageHandlers.set('stopStreaming', onSFUMessageStopStreaming); console.logColor(logging.Green, `WebSocket listening for SFU connections on :${sfuPort}`); let sfuServer = new WebSocket.Server({ port: sfuPort }); sfuServer.on('connection', function (ws, req) { - // reject if we already have an sfu - if (sfuIsConnected()) { - ws.close(1013, 'Already have an SFU'); - return; - } + + let playerId = sanitizePlayerId(nextPlayerId++); + console.logColor(logging.Green, `SFU (${req.connection.remoteAddress}) connected `); + let player = new Player(playerId, ws, PlayerType.SFU, false); + player.streamer = { id: req.connection.remoteAddress, ws: ws }; // SFU also has a streamer component + players.set(playerId, player); ws.on('message', (msgRaw) => { var msg; @@ -636,26 +715,33 @@ sfuServer.on('connection', function (ws, req) { return; } + let sfuPlayer = players.get(playerId); + if (!sfuPlayer) { + console.error(`Received a message from an SFU not in the player list ${playerId}`); + ws.close(1001, 'Broken'); + return; + } + let handler = sfuMessageHandlers.get(msg.type); if (!handler || (typeof handler != 'function')) { if (config.LogVerbose) { - console.logColor(logging.White, "\x1b[37m-> %s\x1b[34m: %s", SFUPlayerId, msgRaw); + console.logColor(logging.White, "\x1b[37m-> %s\x1b[34m: %s", sfuPlayer.id, msgRaw); } console.error(`unsupported SFU message type: ${msg.type}`); ws.close(1008, 'Unsupported message type'); return; } - handler(msg); + handler(sfuPlayer, msg); }); ws.on('close', function(code, reason) { console.error(`SFU disconnected: ${code} - ${reason}`); - onSFUDisconnected(); + onSFUDisconnected(player); }); ws.on('error', function(error) { console.error(`SFU connection error: ${error}`); - onSFUDisconnected(); + onSFUDisconnected(player); try { ws.close(1006 /* abnormal closure */, error); } catch(err) { @@ -663,18 +749,7 @@ sfuServer.on('connection', function (ws, req) { } }); - let sfuPlayer = new Player(SFUPlayerId, ws, PlayerType.SFU, false); - players.set(SFUPlayerId, sfuPlayer); - console.logColor(logging.Green, `SFU (${req.connection.remoteAddress}) connected `); - - // TODO subscribe it to one of any of the streamers for now - for (let [streamerId, streamer] of streamers) { - sfuPlayer.subscribe(streamerId); - break; - } - - // sfu also acts as a streamer - registerStreamer(SFUPlayerId, { ws: ws }); + requestStreamerId(player.streamer); }); let playerCount = 0; @@ -815,9 +890,9 @@ function disconnectAllPlayers(streamerId) { for (let player of clone.values()) { if (player.streamerId == streamerId) { // disconnect players but just unsubscribe the SFU - if (player.id == SFUPlayerId) { - // because we're working on a clone here we have to access directly - getSFU().unsubscribe(); + const sfuPlayer = getSFUForStreamer(streamerId); + if (player.id == sfuPlayer.id) { + sfuPlayer.unsubscribe(); } else { player.ws.close(); } From c0e715ca9dcfef46d8490cde42d037347edbcffb Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Wed, 25 Oct 2023 11:49:55 +1100 Subject: [PATCH 03/10] Allowing SFU to work with multiple streamers. --- .../implementations/typescript/package-lock.json | 4 ++-- .../src/WebRtcPlayer/WebRtcPlayerController.ts | 7 ++++++- SFU/config.js | 7 +++++++ SFU/sfu_server.js | 14 +++++++------- SignallingWebServer/cirrus.js | 5 ++++- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Frontend/implementations/typescript/package-lock.json b/Frontend/implementations/typescript/package-lock.json index c9f2fffb..94705322 100644 --- a/Frontend/implementations/typescript/package-lock.json +++ b/Frontend/implementations/typescript/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@epicgames-ps/reference-pixelstreamingfrontend-ue5.3", + "name": "@epicgames-ps/reference-pixelstreamingfrontend-ue5.4", "version": "0.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "@epicgames-ps/reference-pixelstreamingfrontend-ue5.3", + "name": "@epicgames-ps/reference-pixelstreamingfrontend-ue5.4", "version": "0.0.1", "devDependencies": { "css-loader": "^6.7.3", diff --git a/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts b/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts index 5878073b..b693ad94 100644 --- a/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts +++ b/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts @@ -1398,6 +1398,10 @@ export class WebRtcPlayerController { ) { // 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); + } else if (messageStreamerList.ids.length > 0 && this.config.isFlagEnabled(Flags.WaitForStreamer)) { + // we're waiting for a streamer and there are multiple connected but none were auto selected + // select the first + autoSelectedStreamerId = messageStreamerList.ids[0]; } if (autoSelectedStreamerId !== null) { this.config.setOptionSettingValue( @@ -1407,7 +1411,8 @@ export class WebRtcPlayerController { } else { // no auto selected streamer if (this.config.isFlagEnabled(Flags.WaitForStreamer)) { - this.startAutoJoinTimer() + this.closeSignalingServer(); + this.startAutoJoinTimer(); } } this.pixelStreaming.dispatchEvent( diff --git a/SFU/config.js b/SFU/config.js index 1820b037..fbf11ff8 100644 --- a/SFU/config.js +++ b/SFU/config.js @@ -11,9 +11,16 @@ for(let arg of process.argv){ } const config = { + // The URL of the signalling server to connect to signallingURL: "ws://localhost:8889", + + // The ID for this SFU to use. This will show up as a streamer ID on the signalling server SFUId: "SFU", + + // The ID of the streamer to subscribe to. If you leave this blank it will subscribe to the first streamer it sees. subscribeStreamerId: "DefaultStreamer", + + // Delay between list requests when looking for a specifc streamer. retrySubscribeDelaySecs: 10, mediasoup: { diff --git a/SFU/sfu_server.js b/SFU/sfu_server.js index fe7cd88f..5201c298 100644 --- a/SFU/sfu_server.js +++ b/SFU/sfu_server.js @@ -30,7 +30,6 @@ function connectSignalling(server) { async function onSignallingConnected() { console.log(`Connected to signalling server`); - //signalServer.send(JSON.stringify({type: 'listStreamers'})); } async function onStreamerList(msg) { @@ -38,7 +37,7 @@ async function onStreamerList(msg) { // subscribe to either the configured streamer, or if not configured, just grab the first id if (msg.ids.length > 0) { - if (!!config.subscribeStreamerId) { + if (!!config.subscribeStreamerId && config.subscribeStreamerId.length != 0) { if (msg.ids.includes(config.subscribeStreamerId)) { signalServer.send(JSON.stringify({type: 'subscribe', streamerId: config.subscribeStreamerId})); success = true; @@ -51,7 +50,6 @@ async function onStreamerList(msg) { if (!success) { // did not subscribe to anything - console.log(`No subscribe (${config.retrySubscribeDelaySecs}`) setTimeout(function() { signalServer.send(JSON.stringify({type: 'listStreamers'})); }, config.retrySubscribeDelaySecs * 1000); @@ -59,7 +57,6 @@ async function onStreamerList(msg) { } async function onIdentify(msg) { - console.log(JSON.stringify({type: 'endpointId', id: config.SFUId})); signalServer.send(JSON.stringify({type: 'endpointId', id: config.SFUId})); signalServer.send(JSON.stringify({type: 'listStreamers'})); } @@ -97,6 +94,11 @@ function onStreamerDisconnected() { } streamer.transport.close(); streamer = null; + signalServer.send(JSON.stringify({type: 'stopStreaming'})); + + setTimeout(function() { + signalServer.send(JSON.stringify({type: 'listStreamers'})); + }, config.retrySubscribeDelaySecs * 1000); } } @@ -268,7 +270,7 @@ function onLayerPreference(msg) { } async function onSignallingMessage(message) { - console.log(`Got MSG: ${message}`); + //console.log(`Got MSG: ${message}`); const msg = JSON.parse(message); if (msg.type == 'offer') { @@ -296,11 +298,9 @@ async function onSignallingMessage(message) { onLayerPreference(msg); } else if (msg.type == 'streamerList') { - console.log('WA WA WEE WOO ----------------------------------------------------------------------'); onStreamerList(msg); } else if (msg.type == 'identify') { - console.log('identifying...'); onIdentify(msg); } } diff --git a/SignallingWebServer/cirrus.js b/SignallingWebServer/cirrus.js index 90eb64fc..847c6b3d 100644 --- a/SignallingWebServer/cirrus.js +++ b/SignallingWebServer/cirrus.js @@ -658,6 +658,7 @@ function onSFUMessageId(sfuPlayer, msg) { } function onSFUMessageStartStreaming(sfuPlayer, msg) { + logIncoming(sfuPlayer.streamer.id, msg); if (streamers.has(sfuPlayer.streamer.id)) { console.error(`SFU ${sfuPlayer.streamer.id} is already registered as a streamer and streaming.`) return; @@ -667,6 +668,7 @@ function onSFUMessageStartStreaming(sfuPlayer, msg) { } function onSFUMessageStopStreaming(sfuPlayer, msg) { + logIncoming(sfuPlayer.streamer.id, msg); if (!streamers.has(sfuPlayer.streamer.id)) { console.error(`SFU ${sfuPlayer.streamer.id} is not registered as a streamer or streaming.`) return; @@ -678,6 +680,7 @@ if (!streamers.has(sfuPlayer.streamer.id)) { function onSFUDisconnected(sfuPlayer) { console.log("disconnecting SFU from streamer"); disconnectAllPlayers(sfuPlayer.id); + onStreamerDisconnected(sfuPlayer.streamer); sfuPlayer.unsubscribe(); sfuPlayer.ws.close(4000, "SFU Disconnected"); players.delete(sfuPlayer.id); @@ -891,7 +894,7 @@ function disconnectAllPlayers(streamerId) { if (player.streamerId == streamerId) { // disconnect players but just unsubscribe the SFU const sfuPlayer = getSFUForStreamer(streamerId); - if (player.id == sfuPlayer.id) { + if (sfuPlayer && player.id == sfuPlayer.id) { sfuPlayer.unsubscribe(); } else { player.ws.close(); From 090cc89b08d94505f639718b966561bb32f4482a Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Wed, 25 Oct 2023 11:50:33 +1100 Subject: [PATCH 04/10] Fixing the windows build script nuking the PATH env variable. --- .../platform_scripts/cmd/refreshenv.cmd | 66 ------------------ .../platform_scripts/cmd/setenv/License.txt | 24 ------- .../platform_scripts/cmd/setenv/ReadMe.txt | 46 ------------ .../platform_scripts/cmd/setenv/SetEnv.exe | Bin 126976 -> 0 bytes .../platform_scripts/cmd/setup_frontend.bat | 12 ++-- 5 files changed, 6 insertions(+), 142 deletions(-) delete mode 100644 SignallingWebServer/platform_scripts/cmd/refreshenv.cmd delete mode 100644 SignallingWebServer/platform_scripts/cmd/setenv/License.txt delete mode 100644 SignallingWebServer/platform_scripts/cmd/setenv/ReadMe.txt delete mode 100644 SignallingWebServer/platform_scripts/cmd/setenv/SetEnv.exe diff --git a/SignallingWebServer/platform_scripts/cmd/refreshenv.cmd b/SignallingWebServer/platform_scripts/cmd/refreshenv.cmd deleted file mode 100644 index e0a272c0..00000000 --- a/SignallingWebServer/platform_scripts/cmd/refreshenv.cmd +++ /dev/null @@ -1,66 +0,0 @@ -:: -:: RefreshEnv.cmd -:: -:: Batch file to read environment variables from registry and -:: set session variables to these values. -:: -:: With this batch file, there should be no need to reload command -:: environment every time you want environment changes to propagate - -::echo "RefreshEnv.cmd only works from cmd.exe, please install the Chocolatey Profile to take advantage of refreshenv from PowerShell" -echo | set /p dummy="Refreshing environment variables from registry for cmd.exe. Please wait..." - -goto main - -:: Set one environment variable from registry key -:SetFromReg - "%WinDir%\System32\Reg" QUERY "%~1" /v "%~2" > "%TEMP%\_envset.tmp" 2>NUL - for /f "usebackq skip=2 tokens=2,*" %%A IN ("%TEMP%\_envset.tmp") do ( - echo/set "%~3=%%B" - ) - goto :EOF - -:: Get a list of environment variables from registry -:GetRegEnv - "%WinDir%\System32\Reg" QUERY "%~1" > "%TEMP%\_envget.tmp" - for /f "usebackq skip=2" %%A IN ("%TEMP%\_envget.tmp") do ( - if /I not "%%~A"=="Path" ( - call :SetFromReg "%~1" "%%~A" "%%~A" - ) - ) - goto :EOF - -:main - echo/@echo off >"%TEMP%\_env.cmd" - - :: Slowly generating final file - call :GetRegEnv "HKLM\System\CurrentControlSet\Control\Session Manager\Environment" >> "%TEMP%\_env.cmd" - call :GetRegEnv "HKCU\Environment">>"%TEMP%\_env.cmd" >> "%TEMP%\_env.cmd" - - :: Special handling for PATH - mix both User and System - call :SetFromReg "HKLM\System\CurrentControlSet\Control\Session Manager\Environment" Path Path_HKLM >> "%TEMP%\_env.cmd" - call :SetFromReg "HKCU\Environment" Path Path_HKCU >> "%TEMP%\_env.cmd" - - :: Caution: do not insert space-chars before >> redirection sign - echo/set "Path=%%Path_HKLM%%;%%Path_HKCU%%" >> "%TEMP%\_env.cmd" - - :: Cleanup - del /f /q "%TEMP%\_envset.tmp" 2>nul - del /f /q "%TEMP%\_envget.tmp" 2>nul - - :: capture user / architecture - SET "OriginalUserName=%USERNAME%" - SET "OriginalArchitecture=%PROCESSOR_ARCHITECTURE%" - - :: Set these variables - call "%TEMP%\_env.cmd" - - :: Cleanup - del /f /q "%TEMP%\_env.cmd" 2>nul - - :: reset user / architecture - SET "USERNAME=%OriginalUserName%" - SET "PROCESSOR_ARCHITECTURE=%OriginalArchitecture%" - - echo | set /p dummy="Finished." - echo ... \ No newline at end of file diff --git a/SignallingWebServer/platform_scripts/cmd/setenv/License.txt b/SignallingWebServer/platform_scripts/cmd/setenv/License.txt deleted file mode 100644 index ff66d6bc..00000000 --- a/SignallingWebServer/platform_scripts/cmd/setenv/License.txt +++ /dev/null @@ -1,24 +0,0 @@ -License -------- - -Copyright (C) 1999-2008 - Jonathan Wilkes -http://www.xanya.net - -Installing and using this software (or source code) signifies acceptance of these terms and the conditions of the license. -This license applies to everything in this package (Including any supplied Source Code), except where otherwise noted. - -License Agreement ------------------ - -This software is provided 'as-is', without any express or implied warranty. -In no event will the author be held liable for any damages arising from the use of this software. - -Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software/source code. -(If you use the supplied source code (if any) in a product, then an acknowledgment in the product documentation would be appreciated but is not required.) - -2. If you have downloaded the Source Code for this application (where available) then altered source versions must be plainly marked as such, and must not be misrepresented as being the original software. - -3. This notice may not be removed or altered from any distribution of the software. -(If you use the supplied source code (if any) in a product, including commercial applications, then you do NOT need to distribute this license with your product.) diff --git a/SignallingWebServer/platform_scripts/cmd/setenv/ReadMe.txt b/SignallingWebServer/platform_scripts/cmd/setenv/ReadMe.txt deleted file mode 100644 index dc193079..00000000 --- a/SignallingWebServer/platform_scripts/cmd/setenv/ReadMe.txt +++ /dev/null @@ -1,46 +0,0 @@ - -SetEnv -Version 1.09 - ( For Windows 9x/NT/2000/XP/S2K3/Vista ) - -Copyright (C) 2005-2008 - Jonathan Wilkes - All Rights Reserved. -http://www.xanya.net - -================================================================================ - -1. Installation - - Simply download and run the Setup_SetEnv.exe application to install SetEnv. - -2. Using SetEnv - - The SetEnv is a free tool for setting/updating/deleting System Environment Variables. - Type the following at a command prompt (assumes SetEnv.exe is in current path), for command line usage information. - - setenv -? - - See our website for full usage details, http://www.xanya.net/site/utils/setenv.php - -3. Version History - - 1.09 [Fix] - (Feb 9, 2008) - Fixed a problem on Windows 98 where it sometimes failed to open the Autoexec.bat file. - 1.08 [New] - (May 31, 2007) - Added how to delete a USER environment variable to the usage information. - 1.07 [Fix] - (Jan 25, 2007) - Fixed a bug found by depaolim. - 1.06 [New] - (Jan 14, 2007) - Added dynamic expansion support (same as using ~ with setx) - - Originally requested by Andre Amaral, further Request by Synetech - 1.05 [New] - (Sep 06, 2006) - Added support to prepend (rather than append) a value to an expanded string - - Requested by Masuia - 1.04 [New] - (May 30, 2006) - Added support for User environment variables. - 1.03 [Fix] - (Apr 20, 2006) - Bug fix in ProcessWinXP() discovered by attiasr - 1.01 [Fix] - (Nov 15, 2005) - Bug fix in IsWinME() discovered by frankd - 1.00 [New] - (Oct 29, 2005) - Initial Public Release. - -4. License and Terms of Use - - Please see the License.txt file for licensing information. - -5. Reporting Problems - - If you encounter any problems whilst using SetEnv, please try downloading the latest version from http://www.xanya.net to see if the problem has already been resolved. - If this does not help, then please send an e-mail to darka@xanya.net with details describing the problem. - -================================================================================ \ No newline at end of file diff --git a/SignallingWebServer/platform_scripts/cmd/setenv/SetEnv.exe b/SignallingWebServer/platform_scripts/cmd/setenv/SetEnv.exe deleted file mode 100644 index b1d5d5554ef2c5896cdf54b94c3b1b971cbba002..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 126976 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 Tqt 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
  • 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 Date: Wed, 25 Oct 2023 11:59:45 +1100 Subject: [PATCH 05/10] Just some small cleanup --- SFU/sfu_server.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/SFU/sfu_server.js b/SFU/sfu_server.js index 5201c298..cc26cfa2 100644 --- a/SFU/sfu_server.js +++ b/SFU/sfu_server.js @@ -15,7 +15,7 @@ let peers = new Map(); function connectSignalling(server) { console.log("Connecting to Signalling Server at %s", server); signalServer = new WebSocket(server); - signalServer.addEventListener("open", _ => onSignallingConnected()); + signalServer.addEventListener("open", _ => { console.log(`Connected to signalling server`); }); signalServer.addEventListener("error", result => { console.log(`Error: ${result.message}`); }); signalServer.addEventListener("message", result => onSignallingMessage(result.data)); signalServer.addEventListener("close", result => { @@ -28,10 +28,6 @@ function connectSignalling(server) { }); } -async function onSignallingConnected() { - console.log(`Connected to signalling server`); -} - async function onStreamerList(msg) { let success = false; From bbcfe8a6b572648fac2a9c21389cc8c254345da5 Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Thu, 26 Oct 2023 10:01:31 +1100 Subject: [PATCH 06/10] Removing PreferSFU option since this is now handled with the stream selection option. Fixing browser behaviour when multiple streamers detected (previous failed tests). --- Frontend/library/src/Config/Config.ts | 12 ------------ .../src/WebRtcPlayer/WebRtcPlayerController.ts | 12 +----------- Frontend/ui-library/src/Config/ConfigUI.ts | 4 ---- 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/Frontend/library/src/Config/Config.ts b/Frontend/library/src/Config/Config.ts index 8f2c8e7d..b4892b6a 100644 --- a/Frontend/library/src/Config/Config.ts +++ b/Frontend/library/src/Config/Config.ts @@ -23,7 +23,6 @@ export class Flags { static FakeMouseWithTouches = 'FakeMouseWithTouches' as const; static IsQualityController = 'ControlsQuality' as const; static MatchViewportResolution = 'MatchViewportRes' as const; - static PreferSFU = 'preferSFU' as const; static StartVideoMuted = 'StartVideoMuted' as const; static SuppressBrowserKeys = 'SuppressBrowserKeys' as const; static UseMic = 'UseMic' as const; @@ -315,17 +314,6 @@ export class Config { ) ); - this.flags.set( - Flags.PreferSFU, - new SettingFlag( - Flags.PreferSFU, - 'Prefer SFU', - 'Try to connect to the SFU instead of P2P.', - false, - useUrlParams - ) - ); - this.flags.set( Flags.IsQualityController, new SettingFlag( diff --git a/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts b/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts index b693ad94..390d701e 100644 --- a/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts +++ b/Frontend/library/src/WebRtcPlayer/WebRtcPlayerController.ts @@ -1384,12 +1384,6 @@ export class WebRtcPlayerController { 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( @@ -1398,10 +1392,6 @@ export class WebRtcPlayerController { ) { // 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); - } else if (messageStreamerList.ids.length > 0 && this.config.isFlagEnabled(Flags.WaitForStreamer)) { - // we're waiting for a streamer and there are multiple connected but none were auto selected - // select the first - autoSelectedStreamerId = messageStreamerList.ids[0]; } if (autoSelectedStreamerId !== null) { this.config.setOptionSettingValue( @@ -1410,7 +1400,7 @@ export class WebRtcPlayerController { ); } else { // no auto selected streamer - if (this.config.isFlagEnabled(Flags.WaitForStreamer)) { + if (messageStreamerList.ids.length == 0 && this.config.isFlagEnabled(Flags.WaitForStreamer)) { this.closeSignalingServer(); this.startAutoJoinTimer(); } diff --git a/Frontend/ui-library/src/Config/ConfigUI.ts b/Frontend/ui-library/src/Config/ConfigUI.ts index f4ea65bd..e28c1aff 100644 --- a/Frontend/ui-library/src/Config/ConfigUI.ts +++ b/Frontend/ui-library/src/Config/ConfigUI.ts @@ -174,10 +174,6 @@ export class ConfigUI { psSettingsSection, this.flagsUi.get(Flags.StartVideoMuted) ); - this.addSettingFlag( - psSettingsSection, - this.flagsUi.get(Flags.PreferSFU) - ); this.addSettingFlag( psSettingsSection, this.flagsUi.get(Flags.IsQualityController) From adfca6c42d9fd2d40376a4519ae2ec8fd5aeb234 Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Fri, 27 Oct 2023 15:15:41 +1100 Subject: [PATCH 07/10] Cleanup and fixing sfu behaviour. --- SignallingWebServer/cirrus.js | 209 ++++++++++++++++++++++++++-------- 1 file changed, 162 insertions(+), 47 deletions(-) diff --git a/SignallingWebServer/cirrus.js b/SignallingWebServer/cirrus.js index 847c6b3d..24decda1 100644 --- a/SignallingWebServer/cirrus.js +++ b/SignallingWebServer/cirrus.js @@ -288,14 +288,83 @@ console.logColor(logging.Cyan, `Running Cirrus - The Pixel Streaming reference i let nextPlayerId = 1; +const StreamerType = { Regular: 0, SFU: 1 }; + +class Streamer { + constructor(initialId, ws, type) { + this.id = initialId; + this.ws = ws; + this.type = type; + this.idCommitted = false; + } + + // registers this streamers id + commitId(id) { + this.id = id; + this.idCommitted = true; + } + + // returns true if we have a valid id + isIdCommitted() { + return this.idCommitted; + } + + // links this streamer to a subscribed SFU player (player component of an SFU) + addSFUPlayer(sfuPlayerId) { + if (!!this.SFUPlayerId && this.SFUPlayerId != sfuPlayerId) { + console.error(`Streamer ${this.id} already has an SFU ${this.SFUPlayerId}. Trying to add ${sfuPlayerId} as SFU.`); + return; + } + this.SFUPlayerId = sfuPlayerId; + } + + // removes the previously subscribed SFU player + removeSFUPlayer() { + delete this.SFUPlayerId; + } + + // gets the player id of the subscribed SFU if any + getSFUPlayerId() { + return this.SFUPlayerId; + } + + // returns true if this streamer is forwarding another streamer + isSFU() { + return this.type == StreamerType.SFU; + } + + // links this streamer to a player, used for SFU connections since they have both components + setSFUPlayerComponent(playerComponent) { + if (!this.isSFU()) { + console.error(`Trying to add an SFU player component ${playerComponent.id} to streamer ${this.id} but it is not an SFU type.`); + return; + } + this.sfuPlayerComponent = playerComponent; + } + + // gets the player component for this sfu + getSFUPlayerComponent() { + if (!this.isSFU()) { + console.error(`Trying to get an SFU player component from streamer ${this.id} but it is not an SFU type.`); + return null; + } + return this.sfuPlayerComponent; + } +} + const PlayerType = { Regular: 0, SFU: 1 }; +const WhoSendsOffer = { Streamer: 0, Browser: 1 }; class Player { - constructor(id, ws, type, browserSendOffer) { + constructor(id, ws, type, whoSendsOffer) { this.id = id; this.ws = ws; this.type = type; - this.browserSendOffer = browserSendOffer; + this.whoSendsOffer = whoSendsOffer; + } + + isSFU() { + return this.type == PlayerType.SFU; } subscribe(streamerId) { @@ -306,13 +375,9 @@ class Player { this.streamerId = streamerId; if (this.type == PlayerType.SFU) { let streamer = streamers.get(this.streamerId); - if (!!streamer.SFUId) { - console.error(`Streamer ${this.streamerId} already has an SFU (${streamer.SFUId}) but we're trying to register player ${this.id} as an SFU.`); - } else { - streamer.SFUId = this.id; - } + streamer.addSFUPlayer(this.id); } - const msg = { type: 'playerConnected', playerId: this.id, dataChannel: true, sfu: this.type == PlayerType.SFU, sendOffer: !this.browserSendOffer }; + const msg = { type: 'playerConnected', playerId: this.id, dataChannel: true, sfu: this.type == PlayerType.SFU, sendOffer: this.whoSendsOffer == WhoSendsOffer.Streamer }; logOutgoing(this.streamerId, msg); this.sendFrom(msg); } @@ -321,10 +386,10 @@ class Player { if (this.streamerId && streamers.has(this.streamerId)) { if (this.type == PlayerType.SFU) { let streamer = streamers.get(this.streamerId); - if (!streamer.SFUId || streamer.SFUId != this.id) { - console.error(`Trying to unsibscribe SFU player ${this.id} from streamer ${streamer.id} but the current SFUId does not match (${streamer.SFUId}).`) + if (streamer.getSFUPlayerId() != this.id) { + console.error(`Trying to unsibscribe SFU player ${this.id} from streamer ${streamer.id} but the current SFUId does not match (${streamer.getSFUPlayerId()}).`) } else { - delete streamer.SFUId; + streamer.removeSFUPlayer(); } } const msg = { type: 'playerDisconnected', playerId: this.id }; @@ -364,22 +429,40 @@ class Player { const msgString = JSON.stringify(message); this.ws.send(msgString); } + + setSFUStreamerComponent(streamerComponent) { + if (!this.isSFU()) { + console.error(`Trying to add an SFU streamer component ${streamerComponent.id} to player ${this.id} but it is not an SFU type.`); + return; + } + this.sfuStreamerComponent = streamerComponent; + } + + getSFUStreamerComponent() { + if (!this.isSFU()) { + console.error(`Trying to get an SFU streamer component from player ${this.id} but it is not an SFU type.`); + return null; + } + return this.sfuStreamerComponent; + } }; -let streamers = new Map(); // streamerId <-> streamer socket -let players = new Map(); // playerId <-> player, where player is either a web-browser or a native webrtc player +let streamers = new Map(); // streamerId <-> streamer +let players = new Map(); // playerId <-> player/peer/viewer const LegacyStreamerPrefix = "__LEGACY_STREAMER__"; // old streamers that dont know how to ID will be assigned this id prefix. const streamerIdTimeoutSecs = 5; +// gets the SFU subscribed to this streamer if any. function getSFUForStreamer(streamerId) { if (!streamers.has(streamerId)) { return null; } const streamer = streamers.get(streamerId); - if (!streamer.SFUId) { + const sfuPlayerId = streamer.getSFUPlayerId(); + if (!!sfuPlayerId) { return null; } - return players.get(streamer.SFUId); + return players.get(sfuPlayerId); } function logIncoming(sourceName, msg) { @@ -472,13 +555,34 @@ function requestStreamerId(streamer) { }, streamerIdTimeoutSecs * 1000); } +function sanitizeStreamerId(id) { + let maxPostfix = -1; + for (let [streamerId, streamer] of streamers) { + const idMatchRegex = /^(.*?)(\d*)$/; + const [, baseId, postfix] = streamerId.match(idMatchRegex); + if (baseId != id) { + continue; + } + const numPostfix = Number(postfix); + if (numPostfix > maxPostfix) { + maxPostfix = numPostfix + } + } + if (maxPostfix >= 0) { + return id + (maxPostfix + 1); + } + return id; +} + function registerStreamer(id, streamer) { - streamer.id = id; - streamers.set(streamer.id, streamer); + // make sure the id is unique + const uniqueId = sanitizeStreamerId(id); + streamer.commitId(uniqueId); if (!!streamer.idTimer) { clearTimeout(streamer.idTimer); delete streamer.idTimer; } + streamers.set(uniqueId, streamer); console.logColor(logging.Green, `Registered new streamer: ${streamer.id}`); } @@ -558,7 +662,8 @@ streamerServer.on('connection', function (ws, req) { console.logColor(logging.Green, `Streamer connected: ${req.connection.remoteAddress}`); sendStreamerConnectedToMatchmaker(); - let streamer = { id: req.connection.remoteAddress, ws: ws }; + const temporaryId = req.connection.remoteAddress; + let streamer = new Streamer(temporaryId, ws, StreamerType.Regular); ws.on('message', (msgRaw) => { var msg; @@ -569,6 +674,7 @@ streamerServer.on('connection', function (ws, req) { ws.close(1008, 'Cannot parse'); return; } + console.log(msgRaw); let handler = streamerMessageHandlers.get(msg.type); if (!handler || (typeof handler != 'function')) { @@ -605,13 +711,13 @@ function forwardSFUMessageToPlayer(sfuPlayer, msg) { const playerId = getPlayerIdFromMessage(msg); const player = players.get(playerId); if (player) { - logForward(sfuPlayer.streamer.id, playerId, msg); + logForward(sfuPlayer.getSFUStreamerComponent().id, playerId, msg); player.sendTo(msg); } } function forwardSFUMessageToStreamer(sfuPlayer, msg) { - logForward(sfuPlayer.streamer.id, sfuPlayer.streamerId, msg); + logForward(sfuPlayer.getSFUStreamerComponent().id, sfuPlayer.streamerId, msg); msg.sfuId = sfuPlayer.id; sfuPlayer.sendFrom(msg); } @@ -621,7 +727,7 @@ function onPeerDataChannelsSFUMessage(sfuPlayer, msg) { const playerId = getPlayerIdFromMessage(msg); const player = players.get(playerId); if (player) { - logForward(sfuPlayer.streamer.id, playerId, msg); + logForward(sfuPlayer.getSFUStreamerComponent().id, playerId, msg); player.sendTo(msg); player.datachannel = true; } @@ -631,10 +737,11 @@ function onPeerDataChannelsSFUMessage(sfuPlayer, msg) { function requestSFUStreamerId(sfuPlayer) { // request id const msg = { type: "identify" }; - logOutgoing(sfuPlayer.streamer.id, msg); - sfuPlayer.streamer.ws.send(JSON.stringify(msg)); + const sfuStreamerComponent = sfuPlayer.getSFUStreamerComponent(); + logOutgoing(sfuStreamerComponent.id, msg); + sfuStreamerComponent.ws.send(JSON.stringify(msg)); - sfuPlayer.streamer.idTimer = setTimeout(function() { + sfuStreamerComponent.idTimer = setTimeout(function() { // streamer did not respond in time. give it a legacy id. const newLegacyId = getUniqueSFUId(); if (newLegacyId.length == 0) { @@ -642,45 +749,48 @@ function requestSFUStreamerId(sfuPlayer) { console.error(error); sfuPlayer.ws.close(1008, error); } else { - sfuPlayer.streamer.id = newLegacyId; + sfuStreamerComponent.id = newLegacyId; } }, streamerIdTimeoutSecs * 1000); } function onSFUMessageId(sfuPlayer, msg) { - logIncoming(sfuPlayer.streamer.id, msg); - sfuPlayer.streamer.id = msg.id; + const sfuStreamerComponent = sfuPlayer.getSFUStreamerComponent(); + logIncoming(sfuStreamerComponent.id, msg); + sfuStreamerComponent.id = msg.id; - if (!!sfuPlayer.streamer.idTimer) { - clearTimeout(sfuPlayer.streamer.idTimer); - delete sfuPlayer.streamer.idTimer; + if (!!sfuStreamerComponent.idTimer) { + clearTimeout(sfuStreamerComponent.idTimer); + delete sfuStreamerComponent.idTimer; } } function onSFUMessageStartStreaming(sfuPlayer, msg) { - logIncoming(sfuPlayer.streamer.id, msg); - if (streamers.has(sfuPlayer.streamer.id)) { - console.error(`SFU ${sfuPlayer.streamer.id} is already registered as a streamer and streaming.`) + const sfuStreamerComponent = sfuPlayer.getSFUStreamerComponent(); + logIncoming(sfuStreamerComponent.id, msg); + if (streamers.has(sfuStreamerComponent.id)) { + console.error(`SFU ${sfuStreamerComponent.id} is already registered as a streamer and streaming.`) return; } - registerStreamer(sfuPlayer.streamer.id, sfuPlayer.streamer); + registerStreamer(sfuStreamerComponent.id, sfuStreamerComponent); } function onSFUMessageStopStreaming(sfuPlayer, msg) { - logIncoming(sfuPlayer.streamer.id, msg); -if (!streamers.has(sfuPlayer.streamer.id)) { - console.error(`SFU ${sfuPlayer.streamer.id} is not registered as a streamer or streaming.`) + const sfuStreamerComponent = sfuPlayer.getSFUStreamerComponent(); + logIncoming(sfuStreamerComponent.id, msg); +if (!streamers.has(sfuStreamerComponent.id)) { + console.error(`SFU ${sfuStreamerComponent.id} is not registered as a streamer or streaming.`) return; } - onStreamerDisconnected(sfuPlayer.streamer); + onStreamerDisconnected(sfuStreamerComponent); } function onSFUDisconnected(sfuPlayer) { console.log("disconnecting SFU from streamer"); disconnectAllPlayers(sfuPlayer.id); - onStreamerDisconnected(sfuPlayer.streamer); + onStreamerDisconnected(sfuPlayer.getSFUStreamerComponent()); sfuPlayer.unsubscribe(); sfuPlayer.ws.close(4000, "SFU Disconnected"); players.delete(sfuPlayer.id); @@ -704,9 +814,14 @@ sfuServer.on('connection', function (ws, req) { let playerId = sanitizePlayerId(nextPlayerId++); console.logColor(logging.Green, `SFU (${req.connection.remoteAddress}) connected `); - let player = new Player(playerId, ws, PlayerType.SFU, false); - player.streamer = { id: req.connection.remoteAddress, ws: ws }; // SFU also has a streamer component - players.set(playerId, player); + + let streamerComponent = new Streamer(req.connection.remoteAddress, ws, StreamerType.SFU); + let playerComponent = new Player(playerId, ws, PlayerType.SFU, WhoSendsOffer.Streamer); + + streamerComponent.setSFUPlayerComponent(playerComponent); + playerComponent.setSFUStreamerComponent(streamerComponent); + + players.set(playerId, playerComponent); ws.on('message', (msgRaw) => { var msg; @@ -739,12 +854,12 @@ sfuServer.on('connection', function (ws, req) { ws.on('close', function(code, reason) { console.error(`SFU disconnected: ${code} - ${reason}`); - onSFUDisconnected(player); + onSFUDisconnected(playerComponent); }); ws.on('error', function(error) { console.error(`SFU connection error: ${error}`); - onSFUDisconnected(player); + onSFUDisconnected(playerComponent); try { ws.close(1006 /* abnormal closure */, error); } catch(err) { @@ -752,7 +867,7 @@ sfuServer.on('connection', function (ws, req) { } }); - requestStreamerId(player.streamer); + requestStreamerId(playerComponent.getSFUStreamerComponent()); }); let playerCount = 0; @@ -823,7 +938,7 @@ playerServer.on('connection', function (ws, req) { var url = require('url'); const parsedUrl = url.parse(req.url); const urlParams = new URLSearchParams(parsedUrl.search); - const browserSendOffer = urlParams.has('OfferToReceive') && urlParams.get('OfferToReceive') !== 'false'; + const whoSendsOffer = urlParams.has('OfferToReceive') && urlParams.get('OfferToReceive') !== 'false' ? WhoSendsOffer.Browser : WhoSendsOffer.Streamer; if (playerCount + 1 > maxPlayerCount && maxPlayerCount !== -1) { @@ -835,7 +950,7 @@ playerServer.on('connection', function (ws, req) { ++playerCount; let playerId = sanitizePlayerId(nextPlayerId++); console.logColor(logging.Green, `player ${playerId} (${req.connection.remoteAddress}) connected`); - let player = new Player(playerId, ws, PlayerType.Regular, browserSendOffer); + let player = new Player(playerId, ws, PlayerType.Regular, whoSendsOffer); players.set(playerId, player); ws.on('message', (msgRaw) =>{ From 403fe39f4be6cbf363dfc23220484165a868e90e Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Mon, 30 Oct 2023 16:52:26 +1100 Subject: [PATCH 08/10] Updating the handling of generating new legacy streamer and sfu ids. --- SignallingWebServer/cirrus.js | 36 +++++++++++------------------------ 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/SignallingWebServer/cirrus.js b/SignallingWebServer/cirrus.js index 24decda1..79d49298 100644 --- a/SignallingWebServer/cirrus.js +++ b/SignallingWebServer/cirrus.js @@ -450,6 +450,7 @@ class Player { let streamers = new Map(); // streamerId <-> streamer let players = new Map(); // playerId <-> player/peer/viewer const LegacyStreamerPrefix = "__LEGACY_STREAMER__"; // old streamers that dont know how to ID will be assigned this id prefix. +const LegacySFUPrefix = "__LEGACY_SFU__"; // same as streamer version but for SFUs const streamerIdTimeoutSecs = 5; // gets the SFU subscribed to this streamer if any. @@ -502,33 +503,18 @@ function getPlayerIdFromMessage(msg) { return sanitizePlayerId(msg.playerId); } -function getUniqueLegacyId() { - for (let i = 0; i < 99; ++i) { - const testId = LegacyStreamerPrefix + i; - if (!streamers.has(testId)) { - return testId; - } - } - return ""; // no available id +let uniqueLegacyStreamerPostfix = 0; +function getUniqueLegacyStreamerId() { + const finalId = LegacyStreamerPrefix + uniqueLegacyStreamerPostfix; + ++uniqueLegacyStreamerPostfix; + return finalId; } -function getUniqueSFUId() { - for (let i = 0; i < 99; ++i) { - const testId = SFUStreamerPrefix + i; - let available = true; - for (let player of players) { - if (player.type == PlayerType.SFU) { - if (player.streamer.id == testId) { - available = false; - break; - } - } - } - if (available) { - return testId; - } - } - return ""; // no available id +let uniqueLegacySFUPostfix = 0; +function getUniqueLegacySFUId() { + const finalId = LegacySFUPrefix + uniqueLegacySFUPostfix; + ++uniqueLegacySFUPostfix; + return finalId; } function requestStreamerId(streamer) { From 339aa088cc6c74e3647614a018a5a68a64ae6ad9 Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Tue, 31 Oct 2023 09:17:26 +1100 Subject: [PATCH 09/10] Catching case where we're sanitizing a streamer id that is numeric. Updating docs to remove PreferSFU (SFU is just selected as a streamer now). --- Frontend/Docs/Settings Panel.md | 1 - SignallingWebServer/cirrus.js | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Frontend/Docs/Settings Panel.md b/Frontend/Docs/Settings Panel.md index d636f76a..54fd26e1 100644 --- a/Frontend/Docs/Settings Panel.md +++ b/Frontend/Docs/Settings Panel.md @@ -19,7 +19,6 @@ This page will be updated with new features and commands as they become availabl | **Browser send offer** | The browser will start the WebRTC handshake instead of the Unreal Engine application. This is an advanced setting for users customising the frontend. Primarily for backwards compatibility for 4.x versions of the engine. | | **Use microphone** | Will start receiving audio input from your microphone and transmit it to the Unreal Engine. | | **Start video muted** | Muted audio when the stream starts. | -| **Prefer SFU** | Will attempt to use the Selective Forwarding Unit (SFU), if you have one running. | | **Is quality controller?** | Makes the encoder of the Pixel Streaming Plugin use the current browser connection to determine the bandwidth available, and therefore the quality of the stream encoding. **See notes below** | | **Force mono audio** | Force the browser to request mono audio in the SDP. | | **Force TURN** | Will attempt to connect exclusively via the TURN server. Will not work without an active TURN server. | diff --git a/SignallingWebServer/cirrus.js b/SignallingWebServer/cirrus.js index 79d49298..fd2c0fa6 100644 --- a/SignallingWebServer/cirrus.js +++ b/SignallingWebServer/cirrus.js @@ -546,7 +546,8 @@ function sanitizeStreamerId(id) { for (let [streamerId, streamer] of streamers) { const idMatchRegex = /^(.*?)(\d*)$/; const [, baseId, postfix] = streamerId.match(idMatchRegex); - if (baseId != id) { + // if the id is numeric then base id will be empty and we need to compare with the postfix + if ((baseId != '' && baseId != id) || (baseId == '' && postfix != id)) { continue; } const numPostfix = Number(postfix); From 7f07f4b29ed0cb3ff0e93636bd42d91d38d7a24e Mon Sep 17 00:00:00 2001 From: Matthew Cotton Date: Tue, 31 Oct 2023 12:54:41 +1100 Subject: [PATCH 10/10] Fixing sfu forwarding. --- SignallingWebServer/cirrus.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/SignallingWebServer/cirrus.js b/SignallingWebServer/cirrus.js index fd2c0fa6..615a4a98 100644 --- a/SignallingWebServer/cirrus.js +++ b/SignallingWebServer/cirrus.js @@ -460,7 +460,7 @@ function getSFUForStreamer(streamerId) { } const streamer = streamers.get(streamerId); const sfuPlayerId = streamer.getSFUPlayerId(); - if (!!sfuPlayerId) { + if (!sfuPlayerId) { return null; } return players.get(sfuPlayerId); @@ -661,7 +661,6 @@ streamerServer.on('connection', function (ws, req) { ws.close(1008, 'Cannot parse'); return; } - console.log(msgRaw); let handler = streamerMessageHandlers.get(msg.type); if (!handler || (typeof handler != 'function')) {