From 86e9c586b6232c0777486fd8dc63785d6eb13b84 Mon Sep 17 00:00:00 2001 From: rainbow napkin Date: Thu, 24 Sep 2026 17:16:29 -0400 Subject: [PATCH] Continued work expirementing with WebRTC --- src/app/channel/media/queue.js | 14 +- src/app/channel/media/webRTCHandler.js | 32 +- src/views/channel.ejs | 1 + www/js/channel/channel.js | 2 + www/js/channel/mediaHandler.js | 24 +- .../channel/panels/queuePanel/queuePanel.js | 7 + www/js/channel/webRTCHandler.js | 276 ++++++++++++++++++ 7 files changed, 341 insertions(+), 15 deletions(-) create mode 100644 www/js/channel/webRTCHandler.js diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index 5f9d4df..e6d9c2c 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -369,11 +369,6 @@ class queue{ return; } - /*if(chanDB.settings.streamType == "webrtc"){ - console.log("WEBRTC ENABLED, BAILING!"); - return; - }*/ - //If something is playing if(this.nowPlaying != null){ //Capture currently playing object @@ -410,6 +405,10 @@ class queue{ } rawLink = {combo:[['default',streamURL]]}; + //Otherwise if we're starting a web RTC Stream + }else if(chanDB.settings.streamType == "webrtc"){ + //Let our webRTCHandler know + this.channel.webRTCHandler.startStream(socket); } //Create queued media object from stream URL and set it to nowPlaying @@ -1351,6 +1350,11 @@ class queue{ //Set duration from start and end time wasPlaying.duration = (now - wasPlaying.startTime) / 1000; + //If we're ending a webrtc stream + if(wasPlaying.type == "webrtc"){ + this.channel.webRTCHandler.endStream(); + } + //If we're in pushback mode if(this.liveMode == "pushback"){ await this.livestreamPushbackSchedule(wasPlaying, chanDB); diff --git a/src/app/channel/media/webRTCHandler.js b/src/app/channel/media/webRTCHandler.js index e091e22..19f7c08 100644 --- a/src/app/channel/media/webRTCHandler.js +++ b/src/app/channel/media/webRTCHandler.js @@ -47,6 +47,15 @@ class webRTCHandler{ defineListeners(socket){ //Queueing Functions socket.on("relayRTCData", (data) => {this.relayRTCData(socket, data)}); + socket.on("disconnect", (data) => {this.handleStreamerDisconnect(socket, data)}); + } + + handleStreamerDisconnect(socket){ + //If the disconnecting socket is the current streamer + if(socket.id == this.currentStreamer){ + //Tell the queue to stop current stream + this.channel.queue.stop(); + } } startStream(socket){ @@ -61,20 +70,33 @@ class webRTCHandler{ //Set the recipient to the current streamer by default, assuming that this isn't coming from the streaming socket let recipientID = this.currentStreamer; + //Create object to hold data being relayed to client + let webRTCData = { + type: data.type, + body: data.body + }; + //if data is being relayed from streaming socket to a viewer if(socket.id == this.currentStreamer){ //Change recipient to reflect streaming socket's request recipientID = data.recipient; + //Otherwise if the data is being relayed from a viewer to the streaming socket + }else{ + //Add viewers socket id + webRTCData.source = { + user: socket.user.user, + socket: socket.id + }; } //Get recipient socket const recipient = this.server.io.sockets.sockets.get(recipientID); - //Relay RTC Data - recipient.emit("RTCData", { - type: data.type, - body: data.body - }); + //If we have a recipient + if(recipient != null){ + //Relay RTC Data + recipient.emit("RTCData", webRTCData); + } } } diff --git a/src/views/channel.ejs b/src/views/channel.ejs index 4dac445..30dbbd4 100644 --- a/src/views/channel.ejs +++ b/src/views/channel.ejs @@ -48,6 +48,7 @@ along with this program. If not, see . %> + <%# panels %> diff --git a/www/js/channel/channel.js b/www/js/channel/channel.js index 840b939..bd6b181 100644 --- a/www/js/channel/channel.js +++ b/www/js/channel/channel.js @@ -56,6 +56,8 @@ class channel{ * Child PM Handler */ this.pmHandler = new pmHandler(this); + + this.webRTCHandler = new webRTCHandler(this); /** * Child Canopy Panel Object diff --git a/www/js/channel/mediaHandler.js b/www/js/channel/mediaHandler.js index d98a792..97708c5 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -944,11 +944,6 @@ class WebRTCLivestreamHandler extends rawFileBase{ //Call derived constructor super(client, player, media, "webrtc"); - /** - * Internet Connectivity Establishment configuration - */ - this.iceConfig = {iceServers: new Map(media.rawLink.combo).get('default')}; - this.defineListeners(); } @@ -960,6 +955,25 @@ class WebRTCLivestreamHandler extends rawFileBase{ this.video.addEventListener('contextmenu', (e)=>{e.preventDefault()}); } + + async buildPlayer(){ + //Call derived buildPlayer function + super.buildPlayer(); + + /** + * set Internet Connectivity Establishment configuration + */ + this.client.webRTCHandler.iceConfig = { + iceServers: new Map(this.nowPlaying.rawLink.combo).get('default') + }; + + //If we're not streaming + if(!this.client.webRTCHandler.isStreaming){ + //Send stream request + await this.client.webRTCHandler.requestStream(); + } + } + start(){ //call derived start function super.start(); diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index 6824d53..4224c47 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -307,6 +307,13 @@ class queuePanel extends panelObj{ * @param {Event} event - Event passed down from Event Listener */ async goLive(event){ + //If we're broadcastinv via webrtc + if(this.client.streamType == "webrtc"){ + this.client.webRTCHandler.isBroadcasting = true; + + this.client.webRTCHandler.broadcastingStream = await navigator.mediaDevices.getDisplayMedia(); + } + //Start a livestream client.socket.emit('goLive',{title: this.goLiveNamePrompt.value, mode: event.target.dataset['mode']}); } diff --git a/www/js/channel/webRTCHandler.js b/www/js/channel/webRTCHandler.js new file mode 100644 index 0000000..d5a08f1 --- /dev/null +++ b/www/js/channel/webRTCHandler.js @@ -0,0 +1,276 @@ +/*Canopy - The next generation of stoner streaming software +Copyright (C) 2024-2026 Rainbownapkin and the TTN Community + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see .*/ + +/** + * Class for handling negotiation between peers during WebRTC Livestreams + */ +class webRTCHandler{ + /** + * Instantiates a new webRTCHandler object + * @param {channel} client - Parent client mgmt object + */ + constructor(client){ + /** + * Parent Client Management object + */ + this.client = client + + /** + * Flag thrown when this device is broadcasting via webRTC + */ + this.isBroadcasting = false; + + /** + * Create variable to hold the peer connection object for the currently playing remote stream + */ + this.currentStream; + + /** + * Create variable to hold currently broadcasting stream + */ + this.broadcastingStream; + + /** + * Create map to hold objects representing viewers receiving our broadcasted stream, organized by username + */ + this.currentViewers = new Map(); + + /** + * Create variable to hold Internet Connectivity Establishment configuration + */ + this.iceConfig; + + //Call setup functions + this.setupInput(); + this.defineListeners(); + } + + /** + * Defines input-related event listeners + */ + setupInput(){ + } + + /** + * Defines network-related event listeners + */ + defineListeners(){ + this.client.socket.on("RTCData", this.handleRTCData.bind(this)); + } + + /** + * Handles and routes incoming RTC Messages + * Normally I'd use dedicated socket.io message types, however throwing user input seems like bad security practice + * I'll take ugly code over insecure code any day of the week + */ + handleRTCData(data){ + //If we're brodcasting + if(this.isBroadcasting){ + switch(data.type){ + case "request": + this.handleViewer(data); + break; + case "candidate": + this.handleIceCandidate(data); + break; + } + //otherwise + }else{ + switch(data.type){ + case "answer": + this.handleAnswer(data); + break; + case "candidate": + this.handleIceCandidate(data); + break; + } + } + } + + /** + * Requests stream from streamer + */ + async requestStream(){ + //If we're streaming + if(this.client.webRTCHandler.isBroadcasting){ + //fack off and die + return; + } + + //Create new peer connection w/ provided ice config + this.currentStream = new RTCPeerConnection(this.iceConfig) + + //Not really used atm but it's necissary for most browsers to need either a data channel or media track + //addeed to the requesting peer before they start pulling ICE candidates... + this.currentStream.createDataChannel("aux"); + + this.currentStream.addEventListener('icecandidate', this.sendIceCandidate.bind(this)); + + //Create offer from peerConnection + const offer = await this.currentStream.createOffer(); + + //Set offer as peerConnections local description + await this.currentStream.setLocalDescription(offer); + + //Send RTC connection offer to server to be relayed to streamer's device + this.client.socket.emit("relayRTCData", {type:"request",body:offer}); + } + + /** + * Handles request to connect to streamer from channel viewer + * @param {data} data - Data from requesting socket + */ + async handleViewer(data){ + //Check if user is already viewing stream + const viewer = this.currentViewers.get(data.source.user); + + //If not + if(data.user == null){ + //Create a new connected viewer object from the received data, and store it in the currentViewers map by username + this.currentViewers.set(data.source.user, new connectedViewer( + data.source.user, + data.source.socket, + data.body, + this.broadcastingStream, + this.iceConfig, + this.client)); + //Otherwise, if they're already watching elsewhere via the same account + }else{ + //Hand over new socket ID and offer to the currentViewer object assigned to the current user + await viewer.addSocket(data.source.socket, data.body); + + //Update the object within the currentViewers map + this.currentViewers.set(data.source.user, viewer); + } + } + + async handleAnswer(data){ + //Get answer from received data + const answer = new RTCSessionDescription(data.body); + + //Set remote desription + this.currentStream.setRemoteDescription(answer); + } + + sendIceCandidate(event){ + //If we have a candidate + if(event.candidate != null){ + //Send that shit off! + this.client.socket.emit('relayRTCData',{ + type: 'candidate', + body: event.candidate + }); + } + } + + handleIceCandidate(data){ + //If we're broadcasting + if(this.isBroadcasting){ + const user = this.currentViewers.get(data.source.user); + user.handleIceCandidate(data); + //If we're receiving a stream + }else{ + this.currentStream.addIceCandidate(data.body); + } + } +} + +/** + * Class representing a single connected viewer + */ +class connectedViewer{ + constructor(user, socket, offer, broadcastingStream, iceConfig, client){ + /** + * Name of user + */ + this.user = user; + + /** + * Map of user's connected peers by socket + */ + this.peers = new Map(); + + /** + * Broadcasting Stream + */ + this.broadcastingStream = broadcastingStream; + + /** + * ICE Config + */ + this.iceConfig = iceConfig; + + /** + * Client channe object + */ + this.client = client; + + //Intake initial connection offer from user + this.addSocket(socket, offer); + } + + async addSocket(socket, offer){ + //Create peer from iceConfig + const peer = new RTCPeerConnection(this.iceConfig); + + for(const track of this.broadcastingStream.getTracks()){ + peer.addTrack(track, this.broadcastingStream); + } + + peer.addEventListener('icecandidate', this.sendIceCandidate.bind(this)); + + //Set peer remote description to connection offer from viewer + peer.setRemoteDescription(new RTCSessionDescription(offer)); + + //Generate answer to connection offer + const answer = await peer.createAnswer(); + + //Set answer as local description + await peer.setLocalDescription(answer); + + //Save peer by socket identifier + this.peers.set(socket, peer); + + //Send answer to server to relay to peer + this.client.socket.emit('relayRTCData',{ + type:'answer', + body:answer, + recipient: socket + }); + } + + sendIceCandidate(event){ + //If we have a candidate + if(event.candidate != null){ + //Send that shit off! + this.client.socket.emit('relayRTCData',{ + type: 'candidate', + body: event.candidate, + recipient: this.socket + }); + } + } + + handleIceCandidate(data){ + console.log(this); + const peer = this.peers.get(data.source.socket); + console.log(peer); + peer.addIceCandidate(data.body); + + //Save peer by socket identifier + this.peers.set(data.source.socket, peer); + } +} \ No newline at end of file