diff --git a/config.example.json b/config.example.json index 7b869b3..990e2a7 100644 --- a/config.example.json +++ b/config.example.json @@ -34,6 +34,11 @@ "address": "toke@42069.weed", "pass": "CHANGE_ME" }, + "iceServers":[ + { + "url":"stun:stun.moonlight-stream.org:3478" + } + ], "links":{ "About": "/about", "Code": "https://git.ourfore.st/rainbownapkin/canopy", diff --git a/config.example.jsonc b/config.example.jsonc index 3669d09..c9dfb42 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -65,6 +65,12 @@ "address": "toke@42069.weed", "pass": "CHANGE_ME" }, + //Privdes STUN/TURN servers for WebRTC functionality + "iceServers":[ + { + "url":"stun:stun.moonlight-stream.org:3478" + } + ], //Provides customizable links for navbar "links":{ "About": "/about", diff --git a/src/app/channel/activeChannel.js b/src/app/channel/activeChannel.js index 54e4fc5..44b170d 100644 --- a/src/app/channel/activeChannel.js +++ b/src/app/channel/activeChannel.js @@ -19,7 +19,8 @@ const connectedUser = require('./connectedUser'); const chatBuffer = require('./chatBuffer'); const queue = require('./media/queue'); const channelModel = require('../../schemas/channel/channelSchema'); -const playlistHandler = require('./media/playlistHandler') +const playlistHandler = require('./media/playlistHandler'); +const webRTCHandler = require('./media/webRTCHandler'); /** * Class representing a single active channel @@ -67,6 +68,11 @@ class activeChannel{ */ this.playlistHandler = new playlistHandler(server, this); + /** + * Child WebRTC Handler Object + */ + this.webRTCHandler = new webRTCHandler(server, this); + /** * Child Chat Buffer Object */ @@ -108,6 +114,7 @@ class activeChannel{ //Define per-channel event listeners this.queue.defineListeners(socket); this.playlistHandler.defineListeners(socket); + this.webRTCHandler.defineListeners(socket); //Hand off the connection initiation to it's user object const activeUser = await userObj.handleConnection(userDB, chanDB, socket) diff --git a/src/app/channel/media/queue.js b/src/app/channel/media/queue.js index ac2eb75..e6d9c2c 100644 --- a/src/app/channel/media/queue.js +++ b/src/app/channel/media/queue.js @@ -329,7 +329,11 @@ class queue{ */ async goLive(socket, data){ try{ + //Define and/or declare required variables let title = "Livestream"; + let filename = '/'; + let streamURL = '/'; + let rawLink = {combo: [['default', config.iceServers]]}; if(data != null && data.title != null){ //If the title is too long @@ -365,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 @@ -383,24 +382,33 @@ class queue{ //Kill schedule timers to prevent items from starting during the stream await this.stopScheduleTimers(); - //Syntatic sugar because I'm lazy :P - const streamURL = chanDB.settings.streamURL; + //If we're streaming via HLS + if(chanDB.settings.streamType == "livehls"){ + //Syntatic sugar because I'm lazy :P + streamURL = chanDB.settings.streamURL; - if(streamURL == ''){ - throw loggerUtils.exceptionSmith('This channel\'s HLS Livestream Source has not been set!', 'queue'); - } + if(streamURL == ''){ + throw loggerUtils.exceptionSmith('This channel\'s HLS Livestream Source has not been set!', 'queue'); + } - //Pull filename from streamURL - let filename = streamURL.match(/^.+\..+\/(.+)$/); + //Pull filename from streamURL + filename = streamURL.match(/^.+\..+\/(.+)$/); - //If we're streaming from the root of the domain - if(filename == null){ - //Set filename to root - filename = '/'; - }else{ - //Otherwise, hand over the filename - filename = filename[1]; + //If we're streaming from the root of the domain + if(filename == null){ + //Set filename to root + filename = '/'; + }else{ + //Otherwise, hand over the filename + filename = filename[1]; + } + + 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 @@ -409,9 +417,9 @@ class queue{ filename, streamURL, streamURL, - "livehls", + chanDB.settings.streamType, 0, - streamURL, + rawLink, new Date().getTime() ); @@ -1261,7 +1269,7 @@ class queue{ } //If we're ending an HLS Livestream - if(wasPlaying.type == "livehls"){ + if(wasPlaying.type == "livehls" || wasPlaying.type == "webrtc"){ //Redirect to the endLivestream function return this.endLivestream(wasPlaying, chanDB) } @@ -1342,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); @@ -1569,7 +1582,7 @@ class queue{ const stoppedMedia = this.nowPlaying; //Ignore early end for livestreams - if(this.nowPlaying.type != 'livehls'){ + if(this.nowPlaying.type != 'livehls' && this.nowPlaying.type != 'webrtc'){ //Get difference between current time and start time and set as early end stoppedMedia.earlyEnd = (new Date().getTime() - stoppedMedia.startTime) / 1000; } @@ -1806,7 +1819,7 @@ class queue{ const now = new Date().getTime(); //If something was playing - if(chanDB.media.nowPlaying != null && chanDB.media.nowPlaying.type != 'livehls'){ + if(chanDB.media.nowPlaying != null && (chanDB.media.nowPlaying.type != 'livehls' && chanDB.media.nowPlaying.type != 'webrtc')){ //Rehydrate the currently playing item int oa queued media object const wasPlaying = chanDB.media.nowPlaying.rehydrate(); diff --git a/src/app/channel/media/webRTCHandler.js b/src/app/channel/media/webRTCHandler.js new file mode 100644 index 0000000..19f7c08 --- /dev/null +++ b/src/app/channel/media/webRTCHandler.js @@ -0,0 +1,103 @@ +/*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 .*/ + +/** + * Object to relay WebRTC Negotiation data for a single channel + */ +class webRTCHandler{ + /** + * Instantiates a new object to relay WebRTC Negotiation data for a single channel + * @param {channelManager} server - Parent server object + * @param {activeChannel} channel - Parent Channel object + */ + constructor(server, channel){ + /** + * Parent Server Object + */ + this.server = server + + /** + * Parent Channel Object + */ + this.channel = channel; + + /** + * Declare class property to hold socked it of the current device/browser-tab we're streaming from + */ + this.currentStreamer; + } + + /** + * Defines server-side socket.io listeners for newly connected sockets + * @param {Socket} socket - Newly connected socket to define listeners against + */ + 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){ + this.currentStreamer = socket.id; + } + + endStream(){ + this.currentStreamer = null; + } + + relayRTCData(socket, data){ + //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); + + //If we have a recipient + if(recipient != null){ + //Relay RTC Data + recipient.emit("RTCData", webRTCData); + } + } +} + +module.exports = webRTCHandler; \ No newline at end of file diff --git a/src/schemas/channel/channelSchema.js b/src/schemas/channel/channelSchema.js index 8bfa484..bd02fe0 100644 --- a/src/schemas/channel/channelSchema.js +++ b/src/schemas/channel/channelSchema.js @@ -38,7 +38,7 @@ const { exceptionHandler, errorHandler } = require('../../utils/loggerUtils'); /** * "Enum" for emote type property */ -const streamMethodEnum = ["webrtc","hls"]; +const streamMethodEnum = ["webrtc","livehls"]; /** * DB Schema for Documents containing de-hydrated representations of Canopy Stream/Chat Channels diff --git a/src/validators/channelValidator.js b/src/validators/channelValidator.js index 9c87f55..0e9be56 100644 --- a/src/validators/channelValidator.js +++ b/src/validators/channelValidator.js @@ -85,7 +85,7 @@ module.exports.settingsMap = function(){ optional: true, errorMessage: "Invalid Stream Type", matches: { - options: [/\b(?:webrtc|hls)\b/] + options: [/\b(?:webrtc|livehls)\b/] } } }) 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/src/views/partial/channelSettings/settings.ejs b/src/views/partial/channelSettings/settings.ejs index d8dd2ea..feb30c4 100644 --- a/src/views/partial/channelSettings/settings.ejs +++ b/src/views/partial/channelSettings/settings.ejs @@ -42,10 +42,10 @@ along with this program. If not, see . %> - checked <% }%> > + checked <% }%> > - disabled <%}%> id="channel-preference-list-stream-url" placeholder="Stream URL" value="<%= channel.settings["streamURL"] %>"> + disabled <%}%> id="channel-preference-list-stream-url" placeholder="Stream URL" value="<%= channel.settings["streamURL"] %>"> 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 0923876..97708c5 100644 --- a/www/js/channel/mediaHandler.js +++ b/www/js/channel/mediaHandler.js @@ -926,4 +926,74 @@ class hlsLiveStreamHandler extends hlsBase{ } } } +} + +/** + * Wrapper handler for WebRTC Streams, calling back to webRTCHandler.js for heavy lifting + * @extends rawFileBase + */ +class WebRTCLivestreamHandler extends rawFileBase{ + /** + /** + * Instantiates a new WebRTCMediaHandler + * @param {channel} client - Parent Client Management Object + * @param {player} player - Parent Canopy Player Object + * @param {Object} media - De-hydrated media object from server + */ + constructor(client, player, media){ + //Call derived constructor + super(client, player, media, "webrtc"); + + this.defineListeners(); + } + + defineListeners(){ + //Run derived method + super.defineListeners(); + + //Disable right clicking + 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(); + + //Lock the player + this.setPlayerLock(true); + } + + setVideoTitle(title){ + //Add title as text content for security :P + this.player.title.innerText = `: ${utils.unescapeEntities(title)}`; + + //Create glow span + const glowSpan = document.createElement('span'); + //Fill glow span content + glowSpan.innerText = "🔴LIVE"; + //Set glowspan class + glowSpan.classList.add('critical-danger-text'); + + //Inject glowspan into title in a way that allows it to be easily replaced + this.player.title.prepend(glowSpan); + } } \ No newline at end of file diff --git a/www/js/channel/panels/queuePanel/queuePanel.js b/www/js/channel/panels/queuePanel/queuePanel.js index 7f258e3..4224c47 100644 --- a/www/js/channel/panels/queuePanel/queuePanel.js +++ b/www/js/channel/panels/queuePanel/queuePanel.js @@ -188,7 +188,7 @@ class queuePanel extends panelObj{ */ handleStart(data){ //If we're starting an HLS Livestream - if(data.media != null && data.media.type == 'livehls'){ + if(data.media != null && (data.media.type == 'livehls' || data.media.type == 'webrtc' )){ //Hide the 'goLive' controls this.goLiveDiv.style.display = 'none'; } @@ -282,7 +282,7 @@ class queuePanel extends panelObj{ client.socket.emit('getStreamType'); //If we're not livestreaming - if(client.player.mediaHandler.type != "livehls"){ + if(client.player.mediaHandler.type != "livehls" && client.player.mediaHandler.type != "webrtc"){ //If the div is hidden if(this.goLiveDiv.style.display == 'none'){ //Show the div @@ -306,7 +306,14 @@ class queuePanel extends panelObj{ * Handles sending request to server to start a live stream * @param {Event} event - Event passed down from Event Listener */ - goLive(event){ + 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']}); } @@ -1188,7 +1195,7 @@ class queuePanel extends panelObj{ const staleEntry = this.queueContainer.querySelector('.queue-entry.live'); //If we're not livestreaming - if(client.player.mediaHandler.type != "livehls"){ + if(client.player.mediaHandler.type != "livehls" && client.player.mediaHandler.type != "webrtc"){ //If we have a stale entry if(staleEntry != null){ //Remove stale entry since we're no longer streaming diff --git a/www/js/channel/player.js b/www/js/channel/player.js index 26413d6..a454e2e 100644 --- a/www/js/channel/player.js +++ b/www/js/channel/player.js @@ -188,6 +188,9 @@ class player{ }else if(data.media.type == "livehls"){ //Create a new HLS Livestream Handler for it this.mediaHandler = new hlsLiveStreamHandler(this.client, this, data.media); + }else if(data.media.type == "webrtc"){ + //Create a new HLS Livestream Handler for it + this.mediaHandler = new WebRTCLivestreamHandler(this.client, this, data.media); }else if(data.media.type == 'dm'){ this.mediaHandler = new hlsDailymotionHandler(this.client, this, data.media); //Otherwise, if we have a raw-file compatible source 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 diff --git a/www/js/channelSettings.js b/www/js/channelSettings.js index 74bbe49..a048b76 100644 --- a/www/js/channelSettings.js +++ b/www/js/channelSettings.js @@ -412,7 +412,7 @@ class livestreamPreferences{ handleTypeUpdate(data){ //If the stream type is HLS - if(data.streamType == "hls"){ + if(data.streamType == "livehls" || data.streamType == "webrtc"){ //Ensure the hls button is checked this.hlsButton.checked = true; //Ensure the stream url input is enabled