352 lines
No EOL
11 KiB
JavaScript
352 lines
No EOL
11 KiB
JavaScript
/*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 <https://www.gnu.org/licenses/>.*/
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/**
|
|
* Flag thrown when a request to view a stream is answered
|
|
*/
|
|
this.answered = false;
|
|
|
|
/**
|
|
* Create variable to hold Internet Connectivity Establishment configuration
|
|
*/
|
|
this.iceConfig;
|
|
|
|
/**
|
|
* 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 array to hold candidates cached before
|
|
*/
|
|
this.cachedCandidates = [];
|
|
|
|
//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));
|
|
}
|
|
|
|
async startStream(){
|
|
//Throw broadcasting flag
|
|
this.isBroadcasting = true;
|
|
|
|
//Get media stream from browser input
|
|
this.broadcastingStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: true,
|
|
audio: true
|
|
});
|
|
|
|
//If we didn't get any audio tracks from the DisplayMedia portion of the API
|
|
if(this.broadcastingStream == null || this.broadcastingStream.getAudioTracks().length == 0){
|
|
//Bug user for an audio device
|
|
const tempStream = await navigator.mediaDevices.getUserMedia({video: false, audio: true});
|
|
|
|
//For each track received from audio device
|
|
for(const track of tempStream.getAudioTracks()){
|
|
//Add to existing broadcast stream
|
|
this.broadcastingStream.addTrack(track);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
|
|
//Setup peer event handlers
|
|
this.currentStream.addEventListener('icecandidate', this.sendIceCandidate.bind(this));
|
|
this.currentStream.addEventListener('track', this.handleVideoStream.bind(this));
|
|
|
|
//Create offer from peerConnection
|
|
const offer = await this.currentStream.createOffer({
|
|
offerToReceiveAudio: true,
|
|
offerToReceiveVideo: true
|
|
});
|
|
|
|
//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);
|
|
|
|
//Throw answered flag
|
|
this.answered = true;
|
|
|
|
//for each cached ICE candidate
|
|
for(const candidate of this.cachedCandidates){
|
|
//Send cached candidate, handing candidate to method packed in a phoney event object
|
|
this.sendIceCandidate({candidate});
|
|
}
|
|
|
|
//Set remote desription
|
|
this.currentStream.setRemoteDescription(answer);
|
|
|
|
}
|
|
|
|
sendIceCandidate(event){
|
|
//If we have a candidate
|
|
if(event.candidate != null){
|
|
//If our stream request was answered
|
|
if(this.answered){
|
|
//Send that shit off!
|
|
this.client.socket.emit('relayRTCData',{
|
|
type: 'candidate',
|
|
body: event.candidate
|
|
});
|
|
//Otherwise
|
|
}else{
|
|
//Cache candidate
|
|
this.cachedCandidates.push(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);
|
|
}
|
|
}
|
|
|
|
async handleVideoStream(event){
|
|
//If we where given a media stream
|
|
if(event.streams != null && event.streams[0] != null){
|
|
//hand it over to our player
|
|
this.client.player.mediaHandler.startStream(event.streams[0]);
|
|
}
|
|
}
|
|
|
|
end(){
|
|
//Drop flags
|
|
this.isBroadcasting = false;
|
|
this.answered = false;
|
|
|
|
//clear peer/media stream objects
|
|
this.currentStream = null;
|
|
this.broadcastingStream = null;
|
|
|
|
//clear currentViewers map
|
|
this.currentViewers.clear();
|
|
|
|
//Clear candidate cache
|
|
this.cachedCandidates = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class representing a single connected remote 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 channel 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 every track from the local stream
|
|
for(const track of this.broadcastingStream.getTracks()){
|
|
//Add it to the list of available tracks for our peer
|
|
peer.addTrack(track, this.broadcastingStream);
|
|
}
|
|
|
|
//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);
|
|
|
|
//Setup peer ICE Candidate event listner
|
|
peer.addEventListener('icecandidate', this.sendIceCandidate.bind(this));
|
|
|
|
//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){
|
|
const peer = this.peers.get(data.source.socket);
|
|
peer.addIceCandidate(data.body);
|
|
|
|
//Save peer by socket identifier
|
|
this.peers.set(data.source.socket, peer);
|
|
}
|
|
} |