Initial deploy bundle for WebRTC signaling server and multipeer demo.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
._*
|
||||
.DS_Store
|
||||
9
error/404.html
Normal file
9
error/404.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!--
|
||||
! Excerpted from "Programming WebRTC",
|
||||
! published by The Pragmatic Bookshelf.
|
||||
! Copyrights apply to this code. It may not be used to create training material,
|
||||
! courses, books, articles, and the like. Contact us if you are in doubt.
|
||||
! We make no guarantees that this code is fit for any purpose.
|
||||
! Visit https://pragprog.com/titles/ksrtc for more book information.
|
||||
-->
|
||||
<pre>404: Not Found</pre>
|
||||
9
error/500.html
Normal file
9
error/500.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!--
|
||||
! Excerpted from "Programming WebRTC",
|
||||
! published by The Pragmatic Bookshelf.
|
||||
! Copyrights apply to this code. It may not be used to create training material,
|
||||
! courses, books, articles, and the like. Contact us if you are in doubt.
|
||||
! We make no guarantees that this code is fit for any purpose.
|
||||
! Visit https://pragprog.com/titles/ksrtc for more book information.
|
||||
-->
|
||||
<pre>500: Server Error</pre>
|
||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "ksrtc-deploy",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "Deployment example for Programming WebRTC: Build Real-Time Streaming Applications for the Web. Pragmatic Programmers, 2023.",
|
||||
"author": "Karl Stolley <karl.stolley@gmail.com> (https://stolley.dev/)",
|
||||
"homepage": "https://pragprog.com/titles/ksrtc/programming-webrtc/",
|
||||
"bugs": {
|
||||
"url": "https://forum.devtalk.com/c/community/pragprog-customers/85?tags=book-programming-webrtc",
|
||||
"email": "karl.stolley@gmail.com"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"start": "PUBLIC=www DEBUG=signaling-server: node ./scripts/start-server"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"http-errors": "~1.6",
|
||||
"morgan": "~1.9",
|
||||
"socket.io": "^4.6.1"
|
||||
}
|
||||
}
|
||||
15
scripts/post-receive
Normal file
15
scripts/post-receive
Normal file
@@ -0,0 +1,15 @@
|
||||
#! /usr/bin/env zsh
|
||||
|
||||
WEB_ROOT=/var/www
|
||||
APP_DOMAIN=prag.webrtc.gallery
|
||||
|
||||
GIT_REPO=$HOME/repos/$APP_DOMAIN.git
|
||||
TMP_GIT_CLONE=/tmp/$APP_DOMAIN
|
||||
DEPLOYED_APP=$WEB_ROOT/$APP_DOMAIN/app
|
||||
|
||||
git clone $GIT_REPO $TMP_GIT_CLONE
|
||||
cd $TMP_GIT_CLONE
|
||||
npm install
|
||||
rm -rf $DEPLOYED_APP
|
||||
mv $TMP_GIT_CLONE $DEPLOYED_APP
|
||||
exit
|
||||
81
scripts/start-server
Normal file
81
scripts/start-server
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const {app, io, public_dir} = require('../server');
|
||||
const debug = require('debug')(process.env.DEBUG || '');
|
||||
const fs = require('fs');
|
||||
const ifaces = require('os').networkInterfaces();
|
||||
|
||||
/*
|
||||
Because of proxying, it's safe to use http:// on
|
||||
the server. No need to load up keys here.
|
||||
*/
|
||||
|
||||
const protocol = 'http';
|
||||
const server = require(protocol).createServer(app);
|
||||
|
||||
/**
|
||||
* Use the PORT environment variable or default to 3000.
|
||||
*/
|
||||
|
||||
const port = process.env.PORT ?? '3000';
|
||||
app.set('port', port);
|
||||
|
||||
/**
|
||||
* Attach socket.io to the web server.
|
||||
*/
|
||||
io.attach(server);
|
||||
|
||||
/**
|
||||
* Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
|
||||
server.listen(port);
|
||||
server.on('error', handleError);
|
||||
server.on('listening', handleListening);
|
||||
|
||||
/**
|
||||
* Event listener for HTTP server "error" event.
|
||||
*/
|
||||
|
||||
function handleError(error) {
|
||||
if (error.syscall !== 'listen') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
switch (error.code) {
|
||||
case 'EADDRINUSE':
|
||||
console.error(`Port ${port} is already being used`);
|
||||
process.exit(1);
|
||||
break;
|
||||
case 'EACCES':
|
||||
console.error(`Port ${port} requires elevated user privileges (sudo)`);
|
||||
process.exit(1);
|
||||
break;
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for server listen event
|
||||
*/
|
||||
|
||||
function handleListening() {
|
||||
const address = server.address();
|
||||
// Inspired by https://github.com/http-party/http-server/blob/master/bin/http-server#L163
|
||||
const interfaces = [];
|
||||
Object.keys(ifaces).forEach(function(dev) {
|
||||
ifaces[dev].forEach(function(details) {
|
||||
// Node v. 18+ returns a number (4, 6) for family;
|
||||
// earlier versions returned IPv4 or IPv6. This handles
|
||||
// both cases.
|
||||
if (details.family.toString().endsWith('4')) {
|
||||
interfaces.push(`-> ${protocol}://${details.address}:${address.port}/`);
|
||||
}
|
||||
});
|
||||
});
|
||||
debug(
|
||||
` ** Serving from the ${public_dir}/ directory. **`
|
||||
);
|
||||
}
|
||||
101
server.js
Normal file
101
server.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/***
|
||||
* Excerpted from "Programming WebRTC",
|
||||
* published by The Pragmatic Bookshelf.
|
||||
* Copyrights apply to this code. It may not be used to create training material,
|
||||
* courses, books, articles, and the like. Contact us if you are in doubt.
|
||||
* We make no guarantees that this code is fit for any purpose.
|
||||
* Visit https://pragprog.com/titles/ksrtc for more book information.
|
||||
***/
|
||||
'use strict';
|
||||
|
||||
// Load up necessary modules
|
||||
const crypto = require('crypto');
|
||||
|
||||
// snip, snip...
|
||||
|
||||
const createError = require('http-errors');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const logger = require('morgan');
|
||||
const io = require('socket.io')();
|
||||
// Create an Express app
|
||||
const app = express();
|
||||
// Set the public directory to serve from
|
||||
const public_dir = process.env.PUBLIC ?? 'www';
|
||||
// Log activity to the console
|
||||
app.use(logger('dev'));
|
||||
// Serve static files from the `www/` directory
|
||||
app.use(express.static(path.join(__dirname, public_dir)));
|
||||
|
||||
// Catch 404 errors and forward them to error handler
|
||||
app.use(function(req, res, next) {
|
||||
next(createError(404));
|
||||
});
|
||||
// Handle errors with the error handler
|
||||
app.use(function(err, req, res, next) {
|
||||
// Set the error code
|
||||
res.status(err.status || 500);
|
||||
// Respond with a static error page (404 or 500)
|
||||
res.sendFile(`error/${err.status}.html`, { root: __dirname });
|
||||
});
|
||||
|
||||
/**
|
||||
* The main monkey business:
|
||||
* Signaling with the socket server, Socket.io
|
||||
*/
|
||||
|
||||
const mp_namespaces = io.of(/^\/[a-z]{4}\-[a-z]{4}\-[a-z]{4}$/);
|
||||
|
||||
mp_namespaces.on('connect', function(socket) {
|
||||
|
||||
const namespace = socket.nsp;
|
||||
|
||||
const expiry_in_hours = 4; // credentials last for four hours
|
||||
const secret = process.env.TURNSECRET || 'your secret goes here';
|
||||
const credentials = createCoturnCredentials(expiry_in_hours, secret);
|
||||
|
||||
const peers = [];
|
||||
|
||||
for (let peer of namespace.sockets.keys()) {
|
||||
peers.push(peer);
|
||||
}
|
||||
console.log(` Socket namespace: ${namespace.name}`);
|
||||
|
||||
// Send the array of connected-peer IDs and the TURN credentials
|
||||
// to the connecting peer
|
||||
socket.emit('connected peers', { peers, credentials });
|
||||
|
||||
// Send the connecting peer ID to all connected peers
|
||||
socket.broadcast.emit('connected peer', socket.id);
|
||||
|
||||
socket.on('signal', function({ recipient, sender, signal }) {
|
||||
socket.to(recipient).emit('signal', { recipient, sender, signal });
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
namespace.emit('disconnected peer', socket.id);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
* End of the main monkey business.
|
||||
*/
|
||||
|
||||
function createCoturnCredentials(expiry_in_hours, secret) {
|
||||
// JavaScript timestamps are in milliseconds, so divide by 1000
|
||||
const now = Math.round(Date.now() / 1000);
|
||||
const expiry = (now + (expiry_in_hours * 60 * 60)).toString();
|
||||
const hmac = crypto.createHmac('sha1', secret);
|
||||
hmac.setEncoding('base64');
|
||||
hmac.write(expiry);
|
||||
hmac.end();
|
||||
return {
|
||||
username: expiry,
|
||||
password: hmac.read()
|
||||
};
|
||||
}
|
||||
|
||||
// Export the Express app and Socket.io instances for use in
|
||||
// /scripts/start-server:
|
||||
module.exports = {app, io, public_dir};
|
||||
166
www/css/screen.css
Normal file
166
www/css/screen.css
Normal file
@@ -0,0 +1,166 @@
|
||||
/* http://meyerweb.com/eric/tools/css/reset/
|
||||
v2.0 | 20110126
|
||||
License: none (public domain)
|
||||
*/
|
||||
/* stylelint-disable */
|
||||
a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section{display:block}.lint,html{line-height:1}.lint,body{line-height:inherit}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:'';content:none}table{border-collapse:collapse;border-spacing:0}
|
||||
/* stylelint-enable */
|
||||
|
||||
/* Begin site styles below */
|
||||
|
||||
html {
|
||||
font-family: "Lucida Grande", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#interface {
|
||||
height: 100vh;
|
||||
padding: 22px;
|
||||
display: grid;
|
||||
grid-gap: 11px;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
#header > h1 {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
#footer {
|
||||
display: flex;
|
||||
column-gap: 11px;
|
||||
}
|
||||
|
||||
.preserve-access {
|
||||
position: absolute;
|
||||
left: -20000px;
|
||||
}
|
||||
|
||||
*[aria-hidden="true"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Forms and Controls */
|
||||
|
||||
button,
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
cursor: pointer;
|
||||
/* Box Styles */
|
||||
display: block;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
padding: 11px;
|
||||
}
|
||||
#call-button {
|
||||
width: 143px; /* 6.5 typographic grid lines */
|
||||
margin-right: 11px;
|
||||
}
|
||||
button[aria-checked="false"] {
|
||||
text-decoration: line-through;
|
||||
color: white;
|
||||
background: red;
|
||||
}
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
text-decoration: unset;
|
||||
color: revert;
|
||||
background: revert;
|
||||
}
|
||||
.join {
|
||||
background-color: green;
|
||||
color: white;
|
||||
}
|
||||
.leave {
|
||||
background-color: #CA0;
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* Video Elements */
|
||||
#videos {
|
||||
align-self: start;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 11px;
|
||||
}
|
||||
video {
|
||||
background-color: #DDD;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
#mic-status {
|
||||
padding: 11px;
|
||||
}
|
||||
figure {
|
||||
position: relative;
|
||||
}
|
||||
figcaption {
|
||||
color: #EEE;
|
||||
background: rgba(16,16,16,0.6);
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 5.5px;
|
||||
}
|
||||
figcaption form {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 5.5px;
|
||||
}
|
||||
figcaption form > * {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
#username-input {
|
||||
background-color: inherit;
|
||||
border: 1px solid #CCC;
|
||||
color: #EEE;
|
||||
display: block;
|
||||
padding: 5.5px;
|
||||
max-width: 100%;
|
||||
}
|
||||
#username-set-btn {
|
||||
background: rgba(64,64,64,0.8);
|
||||
color: #EEE;
|
||||
padding: 5.5px;
|
||||
}
|
||||
/* Media Queries */
|
||||
|
||||
@media screen and (min-width: 500px) {
|
||||
#header {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
#header > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#header > h1 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@media screen and (min-width: 680px) {
|
||||
#interface {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
#header {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
#footer {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
}
|
||||
BIN
www/favicon.ico
Normal file
BIN
www/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
www/img/placeholder.png
Normal file
BIN
www/img/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
61
www/index.html
Normal file
61
www/index.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
! Excerpted from "Programming WebRTC",
|
||||
! published by The Pragmatic Bookshelf.
|
||||
! Copyrights apply to this code. It may not be used to create training material,
|
||||
! courses, books, articles, and the like. Contact us if you are in doubt.
|
||||
! We make no guarantees that this code is fit for any purpose.
|
||||
! Visit https://pragprog.com/titles/ksrtc for more book information.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>WebRTC Demo: Deployed Multipeer</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,shrink-to-fit=no" />
|
||||
<link rel="stylesheet" href="css/screen.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="interface">
|
||||
<header id="header" role="banner">
|
||||
<h1>Welcome</h1>
|
||||
<button class="join" type="button" id="call-button">Join Call</button>
|
||||
</header>
|
||||
<article id="videos">
|
||||
<h2 class="preserve-access">Streaming Videos</h2>
|
||||
<figure id="self">
|
||||
<video
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
poster="img/placeholder.png">
|
||||
</video>
|
||||
<figcaption>
|
||||
<form id="username-form" action="#null">
|
||||
<label for="username-input" class="preserve-access">Username</label>
|
||||
<input type="text" id="username-input" placeholder="Username" />
|
||||
<button type="submit" id="username-set-btn">Set Username</button>
|
||||
</form>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</article>
|
||||
<footer id="footer">
|
||||
<button
|
||||
aria-label="Toggle microphone"
|
||||
role="switch"
|
||||
aria-checked="true"
|
||||
type="button"
|
||||
id="toggle-mic">Mic</button>
|
||||
<button
|
||||
aria-label="Toggle camera"
|
||||
role="switch"
|
||||
aria-checked="true"
|
||||
type="button"
|
||||
id="toggle-cam">Cam</button>
|
||||
</footer>
|
||||
</main>
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script
|
||||
src="https://webrtchacks.github.io/adapter/adapter-latest.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
781
www/js/main.js
Normal file
781
www/js/main.js
Normal file
@@ -0,0 +1,781 @@
|
||||
/***
|
||||
* Excerpted from "Programming WebRTC",
|
||||
* published by The Pragmatic Bookshelf.
|
||||
* Copyrights apply to this code. It may not be used to create training material,
|
||||
* courses, books, articles, and the like. Contact us if you are in doubt.
|
||||
* We make no guarantees that this code is fit for any purpose.
|
||||
* Visit https://pragprog.com/titles/ksrtc for more book information.
|
||||
***/
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Global Variables: $self and $peers
|
||||
*/
|
||||
|
||||
const $self = {
|
||||
rtcConfig: {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
],
|
||||
},
|
||||
mediaConstraints: { audio: true, video: true },
|
||||
mediaDevices: { audioinput: [], videoinput: [] },
|
||||
mediaStream: new MediaStream(),
|
||||
mediaTracks: {},
|
||||
features: {
|
||||
audio: false,
|
||||
video: true,
|
||||
},
|
||||
};
|
||||
|
||||
const $peers = new Map();
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Signaling-Channel Setup
|
||||
*/
|
||||
|
||||
const namespace = prepareNamespace(window.location.hash, true);
|
||||
|
||||
const sc = io.connect('/' + namespace, { autoConnect: false });
|
||||
|
||||
registerScCallbacks();
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* =========================================================================
|
||||
* Begin Application-Specific Code
|
||||
* =========================================================================
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* User-Interface Setup
|
||||
*/
|
||||
|
||||
document.querySelector('#toggle-mic')
|
||||
.setAttribute('aria-checked', $self.features.audio);
|
||||
|
||||
document.querySelector('#header h1')
|
||||
.innerText = 'Welcome to Room #' + namespace;
|
||||
|
||||
document.querySelector('#call-button')
|
||||
.addEventListener('click', handleCallButton);
|
||||
|
||||
document.querySelector('#footer')
|
||||
.addEventListener('click', handleMediaButtons);
|
||||
|
||||
document.querySelector('#username-form')
|
||||
.addEventListener('submit', handleUsernameForm);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* User-Media Setup and Events
|
||||
*/
|
||||
|
||||
requestUserMedia($self.mediaConstraints);
|
||||
|
||||
// These events can fire in rapid succession when,
|
||||
// for example, a camera with built-in mic is connected.
|
||||
// That's why it's necessary to debounce to 500ms before
|
||||
// executing the callback. Otherwise, the callback might
|
||||
// prompt users to access the camera, and then again to
|
||||
// access the camera and the mic both.
|
||||
navigator.mediaDevices.ondevicechange = debounce(handleMediaDeviceChange, 500);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* User-Interface Functions and Callbacks
|
||||
*/
|
||||
|
||||
function handleCallButton(event) {
|
||||
const call_button = event.target;
|
||||
if (call_button.className === 'join') {
|
||||
console.log('Joining the call...');
|
||||
call_button.className = 'leave';
|
||||
call_button.innerText = 'Leave Call';
|
||||
joinCall();
|
||||
} else {
|
||||
console.log('Leaving the call...');
|
||||
call_button.className = 'join';
|
||||
call_button.innerText = 'Join Call';
|
||||
leaveCall();
|
||||
}
|
||||
}
|
||||
|
||||
function joinCall() {
|
||||
sc.open();
|
||||
}
|
||||
|
||||
function leaveCall() {
|
||||
sc.close();
|
||||
for (let id of $peers.keys()) {
|
||||
resetPeer(id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMediaButtons(event) {
|
||||
const target = event.target;
|
||||
if (target.tagName !== 'BUTTON') return;
|
||||
switch (target.id) {
|
||||
case 'toggle-mic':
|
||||
toggleMic(target);
|
||||
break;
|
||||
case 'toggle-cam':
|
||||
toggleCam(target);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMic(button) {
|
||||
const audio = $self.mediaTracks.audio;
|
||||
const enabled_state = audio.enabled = !audio.enabled;
|
||||
|
||||
$self.features.audio = enabled_state;
|
||||
|
||||
button.setAttribute('aria-checked', enabled_state);
|
||||
|
||||
for (let id of $peers.keys()) {
|
||||
shareFeatures(id, 'audio');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCam(button) {
|
||||
const video = $self.mediaTracks.video;
|
||||
const enabled_state = video.enabled = !video.enabled;
|
||||
|
||||
$self.features.video = enabled_state;
|
||||
|
||||
button.setAttribute('aria-checked', enabled_state);
|
||||
|
||||
for (let id of $peers.keys()) {
|
||||
shareFeatures(id, 'video');
|
||||
}
|
||||
|
||||
if (enabled_state) {
|
||||
$self.mediaStream.addTrack($self.mediaTracks.video);
|
||||
} else {
|
||||
$self.mediaStream.removeTrack($self.mediaTracks.video);
|
||||
displayStream($self.mediaStream);
|
||||
}
|
||||
}
|
||||
|
||||
function enableOrDisableMediaToggleButtons() {
|
||||
const audio_button = document.querySelector('#toggle-mic');
|
||||
const video_button = document.querySelector('#toggle-cam');
|
||||
|
||||
// Set the disabled attribute's value based on the
|
||||
// available media devices
|
||||
audio_button.disabled = $self.mediaDevices.audioinput.length === 0;
|
||||
video_button.disabled = $self.mediaDevices.videoinput.length === 0;
|
||||
}
|
||||
|
||||
function handleUsernameForm(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const username = form.querySelector('#username-input').value;
|
||||
const figcaption = document.querySelector('#self figcaption');
|
||||
figcaption.innerText = username;
|
||||
|
||||
$self.features.username = username;
|
||||
|
||||
for (let id of $peers.keys()) {
|
||||
shareFeatures(id, 'username');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* User-Media and Data-Channel Functions
|
||||
*/
|
||||
|
||||
async function detectAvailableMediaDevices() {
|
||||
// Assume there are no devices until we detect them
|
||||
$self.mediaDevices.audioinput = [];
|
||||
$self.mediaDevices.videoinput = [];
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
for (let device of devices) {
|
||||
// Only interested in audio and video inputs
|
||||
const input_kinds = ['audioinput', 'videoinput'];
|
||||
if (input_kinds.includes(device.kind)) {
|
||||
$self.mediaDevices[device.kind].push(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMediaDeviceChange() {
|
||||
const previous_devices =
|
||||
$self.mediaDevices.audioinput.length > 0
|
||||
|| $self.mediaDevices.videoinput.length > 0;
|
||||
|
||||
// First things first: on any device change,
|
||||
// update the list of available media devices
|
||||
await detectAvailableMediaDevices();
|
||||
|
||||
const available_devices =
|
||||
$self.mediaDevices.audioinput.length > 0
|
||||
|| $self.mediaDevices.videoinput.length > 0;
|
||||
|
||||
// Case One: A device has been plugged in and
|
||||
// no other devices were previously available
|
||||
if (!previous_devices && available_devices) {
|
||||
// Request user media as though the app had just been opened,
|
||||
// but await it so that we can ensure there's media available
|
||||
// before adding it to the connection
|
||||
await requestUserMedia($self.mediaConstraints);
|
||||
|
||||
for (let id of $peers.keys()) {
|
||||
addStreamingMedia(id);
|
||||
shareFeatures(id, 'audio', 'video');
|
||||
}
|
||||
}
|
||||
|
||||
// Case Two: The current device has been unplugged
|
||||
// and there are now no devices available
|
||||
if (!available_devices) {
|
||||
// Reset all the media properties
|
||||
$self.media = false;
|
||||
$self.mediaTracks = {};
|
||||
$self.mediaStream = new MediaStream();
|
||||
// Toggle off the media buttons, and null out
|
||||
// the self-side stream
|
||||
enableOrDisableMediaToggleButtons();
|
||||
displayStream(null);
|
||||
// Remove tracks from peer connections
|
||||
for (let id of $peers.keys()) {
|
||||
removeStreamingMedia(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requestUserMedia(media_constraints) {
|
||||
// Duplicate the media constraints so as to not affect
|
||||
// the original values on $self
|
||||
const refined_media_constraints =
|
||||
JSON.parse(JSON.stringify(media_constraints));
|
||||
|
||||
// See what devices are available
|
||||
await detectAvailableMediaDevices();
|
||||
|
||||
// Refine media constraints based on device availability
|
||||
if ($self.mediaDevices.audioinput.length === 0) {
|
||||
// There's no audio device, so ensure that constraint is false
|
||||
refined_media_constraints.audio = false;
|
||||
}
|
||||
if ($self.mediaDevices.videoinput.length === 0) {
|
||||
// There's no video device, so ensure that constraint is false
|
||||
refined_media_constraints.video = false;
|
||||
}
|
||||
|
||||
// Disable or re-enable toggle buttons
|
||||
enableOrDisableMediaToggleButtons();
|
||||
|
||||
if (!refined_media_constraints.audio &&
|
||||
!refined_media_constraints.video) {
|
||||
// If no media is available, we exit, as passing
|
||||
// { audio: false, video: false } as a constraint
|
||||
// results in a TypeError
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$self.media = await navigator.mediaDevices
|
||||
.getUserMedia(refined_media_constraints);
|
||||
// Detect the devices again, now that permissions
|
||||
// have been granted; this helps capture a value
|
||||
// on the "label" field on MediaDeviceInfo for
|
||||
// easier inspection
|
||||
await detectAvailableMediaDevices();
|
||||
} catch(e) {
|
||||
console.error(e.name, e.message);
|
||||
}
|
||||
|
||||
// Hold onto audio- and video-track references
|
||||
$self.mediaTracks.audio = $self.media.getAudioTracks()[0];
|
||||
$self.mediaTracks.video = $self.media.getVideoTracks()[0];
|
||||
|
||||
if ($self.mediaTracks.audio) {
|
||||
// Mute the audio if `$self.features.audio` evaluates to `false`
|
||||
$self.mediaTracks.audio.enabled = !!$self.features.audio;
|
||||
// Add audio track to mediaStream
|
||||
$self.mediaStream.addTrack($self.mediaTracks.audio);
|
||||
}
|
||||
if ($self.mediaTracks.video) {
|
||||
// Toggle off the camera if `$self.features.video` evaluates to `false`
|
||||
$self.mediaTracks.video.enabled = !!$self.features.video;
|
||||
// Add video track to mediaStream
|
||||
$self.mediaStream.addTrack($self.mediaTracks.video);
|
||||
}
|
||||
|
||||
displayStream($self.mediaStream);
|
||||
}
|
||||
|
||||
function createVideoStructure(id) {
|
||||
const figure = document.createElement('figure');
|
||||
const figcaption = document.createElement('figcaption');
|
||||
const video = document.createElement('video');
|
||||
const attributes = {
|
||||
autoplay: '',
|
||||
playsinline: '',
|
||||
poster: 'img/placeholder.png',
|
||||
};
|
||||
const attributes_list = Object.keys(attributes);
|
||||
|
||||
// Set attributes
|
||||
figure.id = `peer-${id}`;
|
||||
figcaption.innerText = id;
|
||||
for (let attr of attributes_list) {
|
||||
video.setAttribute(attr, attributes[attr]);
|
||||
}
|
||||
// Append the video and figcaption elements
|
||||
figure.appendChild(video);
|
||||
figure.appendChild(figcaption);
|
||||
// Return the complete figure
|
||||
return figure;
|
||||
}
|
||||
|
||||
function displayStream(stream, id = 'self') {
|
||||
const selector = id === 'self' ? '#self' : `#peer-${id}`;
|
||||
let video_structure = document.querySelector(selector);
|
||||
if (!video_structure) {
|
||||
const videos = document.querySelector('#videos');
|
||||
video_structure = createVideoStructure(id);
|
||||
videos.appendChild(video_structure);
|
||||
}
|
||||
video_structure.querySelector('video').srcObject = stream;
|
||||
}
|
||||
|
||||
function addStreamingMedia(id) {
|
||||
const peer = $peers.get(id);
|
||||
const tracks = Object.keys($self.mediaTracks);
|
||||
for (let track of tracks) {
|
||||
if ($self.mediaTracks[track]) {
|
||||
peer.connection.addTrack($self.mediaTracks[track]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeStreamingMedia(id) {
|
||||
const peer = $peers.get(id);
|
||||
// Detect the senders on the connection; we need
|
||||
// them to remove tracks from the peer connection
|
||||
const senders = peer.connection.getSenders();
|
||||
const senders_list = Object.keys(senders);
|
||||
// Loop through the senders, and pull the tracks
|
||||
// off them, one by one
|
||||
for (let sender of senders_list) {
|
||||
const track = senders[sender].track;
|
||||
if (track) {
|
||||
// Remove the track on its associated sender
|
||||
peer.connection.removeTrack(senders[sender]);
|
||||
}
|
||||
}
|
||||
// Send a 'removeAllTracks' feature to clean things up
|
||||
// on the receiving peer's side.
|
||||
shareFeatures(id, 'removeAllTracks');
|
||||
}
|
||||
|
||||
function addFeaturesChannel(id) {
|
||||
const peer = $peers.get(id);
|
||||
|
||||
const featureFunctions = {
|
||||
audio: function() {
|
||||
const username = peer.features.username ? peer.features.username : id;
|
||||
showUsernameAndMuteStatus(username);
|
||||
},
|
||||
removeAllTracks: function() {
|
||||
const tracks_list = Object.keys(peer.mediaTracks);
|
||||
for (let track of tracks_list) {
|
||||
peer.mediaStream.removeTrack(peer.mediaTracks[track]);
|
||||
}
|
||||
// Empty out the media tracks object
|
||||
peer.mediaTracks = {};
|
||||
// Create a brand-new media stream in case tracks
|
||||
// need to be added later
|
||||
peer.mediaStream = new MediaStream();
|
||||
// But null out the display of the media stream
|
||||
// for the time being
|
||||
displayStream(null, id);
|
||||
},
|
||||
|
||||
username: function() {
|
||||
// Update the username
|
||||
showUsernameAndMuteStatus(peer.features.username);
|
||||
},
|
||||
video: function() {
|
||||
// This is all just to display the poster image,
|
||||
// rather than a black frame
|
||||
if (peer.mediaTracks.video) {
|
||||
if (peer.features.video) {
|
||||
peer.mediaStream.addTrack(peer.mediaTracks.video);
|
||||
} else {
|
||||
peer.mediaStream.removeTrack(peer.mediaTracks.video);
|
||||
displayStream(peer.mediaStream, id);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
peer.featuresChannel =
|
||||
peer.connection.createDataChannel('features',
|
||||
{ negotiated: true, id: 110 });
|
||||
|
||||
peer.featuresChannel.onopen = function() {
|
||||
// send features information just as soon as the channel opens
|
||||
peer.featuresChannel.send(JSON.stringify($self.features));
|
||||
};
|
||||
|
||||
peer.featuresChannel.onmessage = function(event) {
|
||||
const features = JSON.parse(event.data);
|
||||
const features_list = Object.keys(features);
|
||||
for (let f of features_list) {
|
||||
// update the corresponding features field on $peer
|
||||
peer.features[f] = features[f];
|
||||
// if there's a corresponding function, run it
|
||||
if (typeof featureFunctions[f] === 'function') {
|
||||
featureFunctions[f]();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function showUsernameAndMuteStatus(username) {
|
||||
const fc = document.querySelector(`#peer-${id} figcaption`);
|
||||
if (peer.features.audio) {
|
||||
fc.innerText = username;
|
||||
} else {
|
||||
fc.innerText = `${username} (Muted)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shareFeatures(id, ...features) {
|
||||
const peer = $peers.get(id);
|
||||
|
||||
const featuresToShare = {};
|
||||
|
||||
if (!peer.featuresChannel) return;
|
||||
|
||||
for (let f of features) {
|
||||
featuresToShare[f] =
|
||||
$self.features[f] ? $self.features[f] : 'true';
|
||||
}
|
||||
|
||||
try {
|
||||
peer.featuresChannel.send(JSON.stringify(featuresToShare));
|
||||
} catch(e) {
|
||||
console.error('Error sending features:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Call Features & Reset Functions
|
||||
*/
|
||||
|
||||
function initializePeer(id, polite) {
|
||||
$peers.set(id, {
|
||||
connection: new RTCPeerConnection($self.rtcConfig),
|
||||
mediaStream: new MediaStream(),
|
||||
mediaTracks: {},
|
||||
features: {},
|
||||
selfStates: {
|
||||
isPolite: polite,
|
||||
isMakingOffer: false,
|
||||
isIgnoringOffer: false,
|
||||
isSettingRemoteAnswerPending: false,
|
||||
isSuppressingInitialOffer: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function establishCallFeatures(id) {
|
||||
registerRtcCallbacks(id);
|
||||
addFeaturesChannel(id);
|
||||
// Always set up a display stream, even
|
||||
// for users who might not have streaming
|
||||
// media. Set the stream itself to null
|
||||
displayStream(null, id);
|
||||
addStreamingMedia(id);
|
||||
}
|
||||
|
||||
function resetPeer(id) {
|
||||
const peer = $peers.get(id);
|
||||
displayStream(null, id);
|
||||
document.querySelector(`#peer-${id}`).remove();
|
||||
peer.connection.close();
|
||||
$peers.delete(id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* WebRTC Functions and Callbacks
|
||||
*/
|
||||
|
||||
function registerRtcCallbacks(id) {
|
||||
const peer = $peers.get(id);
|
||||
peer.connection
|
||||
.onconnectionstatechange = handleRtcConnectionStateChange(id);
|
||||
peer.connection
|
||||
.onnegotiationneeded = handleRtcConnectionNegotiation(id);
|
||||
peer.connection
|
||||
.onicecandidate = handleRtcIceCandidate(id);
|
||||
peer.connection
|
||||
.ontrack = handleRtcPeerTrack(id);
|
||||
}
|
||||
|
||||
function handleRtcPeerTrack(id) {
|
||||
return function({ track }) {
|
||||
const peer = $peers.get(id);
|
||||
console.log(`Handle incoming ${track.kind} track from peer ID: ${id}`);
|
||||
peer.mediaTracks[track.kind] = track;
|
||||
peer.mediaStream.addTrack(track);
|
||||
displayStream(peer.mediaStream, id);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* =========================================================================
|
||||
* End Application-Specific Code
|
||||
* =========================================================================
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Reusable WebRTC Functions and Callbacks
|
||||
*/
|
||||
function handleRtcConnectionNegotiation(id) {
|
||||
return async function() {
|
||||
const peer = $peers.get(id);
|
||||
const self_state = peer.selfStates;
|
||||
// peers suppressing initial offers should do nothing but exit
|
||||
if (self_state.isSuppressingInitialOffer) return;
|
||||
// older browsers do not automatically create an offer when they
|
||||
// call `setLocalDescription`
|
||||
try {
|
||||
self_state.isMakingOffer = true;
|
||||
await peer.connection.setLocalDescription();
|
||||
} catch(e) {
|
||||
// manually create the offer with the automatic version fails
|
||||
const offer = await peer.connection.createOffer();
|
||||
await peer.connection.setLocalDescription(offer);
|
||||
} finally {
|
||||
// however the local description is set, send it over the
|
||||
// signaling channel
|
||||
sc.emit('signal',
|
||||
{ recipient: id, sender: $self.id,
|
||||
signal: { description: peer.connection.localDescription } });
|
||||
self_state.isMakingOffer = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function handleRtcIceCandidate(id) {
|
||||
return function({ candidate }) {
|
||||
if (candidate) {
|
||||
console.log(`Handling ICE candidate, type '${ candidate.type }'...`);
|
||||
}
|
||||
sc.emit('signal', { recipient: id, sender: $self.id,
|
||||
signal: { candidate } });
|
||||
};
|
||||
}
|
||||
|
||||
function handleRtcConnectionStateChange(id) {
|
||||
return function() {
|
||||
const peer = $peers.get(id);
|
||||
const connection_state = peer.connection.connectionState;
|
||||
// Assume *some* element will take a unique peer ID
|
||||
const peer_element = document.querySelector(`#peer-${id}`);
|
||||
if (peer_element) {
|
||||
peer_element.dataset.connectionState = connection_state;
|
||||
}
|
||||
console.log(`Connection state '${connection_state}' for Peer ID: ${id}`);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Signaling-Channel Functions and Callbacks
|
||||
*/
|
||||
|
||||
function registerScCallbacks() {
|
||||
sc.on('connect', handleScConnect);
|
||||
sc.on('connected peers', handleScConnectedPeers);
|
||||
sc.on('connected peer', handleScConnectedPeer);
|
||||
sc.on('disconnected peer', handleScDisconnectedPeer);
|
||||
sc.on('signal', handleScSignal);
|
||||
}
|
||||
|
||||
function handleScConnect() {
|
||||
console.log('Successfully connected to the signaling server!');
|
||||
$self.id = sc.id;
|
||||
console.log(`Self ID: ${$self.id}`);
|
||||
}
|
||||
|
||||
function handleScConnectedPeers({ peers, credentials }) {
|
||||
const ids = peers;
|
||||
console.log(`Connected peer IDs: ${ids.join(', ')}`);
|
||||
|
||||
console.log(`TURN Credentials: ${JSON.stringify(credentials)}`);
|
||||
// addCredentialedTurnServer('turn:coturn.example.com:3478', credentials);
|
||||
|
||||
for (let id of ids) {
|
||||
if (id === $self.id) continue;
|
||||
// be polite with already-connected peers
|
||||
initializePeer(id, true);
|
||||
establishCallFeatures(id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleScConnectedPeer(id) {
|
||||
console.log(`Newly connected peer ID: ${id}`);
|
||||
// be impolite with each newly connecting peer
|
||||
initializePeer(id, false);
|
||||
establishCallFeatures(id);
|
||||
}
|
||||
|
||||
function handleScDisconnectedPeer(id) {
|
||||
console.log(`Disconnected peer ID: ${id}`);
|
||||
resetPeer(id);
|
||||
}
|
||||
|
||||
function resetAndRetryConnection(id) {
|
||||
const peer = $peers.get(id);
|
||||
// Preserve the original politeness value
|
||||
const is_polite = peer.selfStates.isPolite;
|
||||
// Re-initialize the peer
|
||||
initializePeer(id, is_polite);
|
||||
const self_state = peer.selfStates;
|
||||
// Set the offer-suppression state property to `true` for the
|
||||
// polite peer
|
||||
self_state.isSuppressingInitialOffer = is_polite;
|
||||
|
||||
// Reestablish the call features, which triggers
|
||||
// the `negotiationneeded` event and its callback again
|
||||
establishCallFeatures(id);
|
||||
|
||||
// Inform the impolite peer to reset, too:
|
||||
if (is_polite) {
|
||||
sc.emit('signal', { description: { type: '_reset' } });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScSignal({ sender,
|
||||
signal: { candidate, description } }) {
|
||||
|
||||
const id = sender;
|
||||
const peer = $peers.get(id);
|
||||
const self_state = peer.selfStates;
|
||||
|
||||
if (description) {
|
||||
|
||||
if (description.type === '_reset') {
|
||||
// Reset and retry the connection, and exit the `handleScSignal()`
|
||||
// function
|
||||
resetAndRetryConnection(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const ready_for_offer =
|
||||
!self_state.isMakingOffer &&
|
||||
(peer.connection.signalingState === 'stable'
|
||||
|| self_state.isSettingRemoteAnswerPending);
|
||||
|
||||
const offer_collision =
|
||||
description.type === 'offer' && !ready_for_offer;
|
||||
|
||||
self_state.isIgnoringOffer = !self_state.isPolite && offer_collision;
|
||||
|
||||
if (self_state.isIgnoringOffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
self_state.isSettingRemoteAnswerPending = description.type === 'answer';
|
||||
|
||||
await peer.connection.setRemoteDescription(description);
|
||||
|
||||
self_state.isSettingRemoteAnswerPending = false;
|
||||
|
||||
if (description.type === 'offer') {
|
||||
await peer.connection.setLocalDescription();
|
||||
sc.emit('signal', { recipient: id, sender: $self.id,
|
||||
signal: { description: peer.connection.localDescription } });
|
||||
}
|
||||
|
||||
} else if (candidate) {
|
||||
// Handle ICE candidates
|
||||
try {
|
||||
await peer.connection.addIceCandidate(candidate);
|
||||
} catch(e) {
|
||||
// Log error unless state is ignoring offers
|
||||
// and candidate is not an empty string
|
||||
if (!self_state.isIgnoringOffer && candidate.candidate.length > 1) {
|
||||
console.error(`Unable to add ICE candidate for peer ID: ${id}.`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Utility Functions
|
||||
*/
|
||||
|
||||
function addCredentialedTurnServer(server_string, { username, password }) {
|
||||
// Add TURN server and credentials to iceServers array
|
||||
$self.rtcConfig.iceServers.push({
|
||||
urls: server_string,
|
||||
username: username,
|
||||
password: password,
|
||||
});
|
||||
}
|
||||
|
||||
function prepareNamespace(hash, set_location) {
|
||||
let ns = hash.replace(/^#/, ''); // remove # from the hash
|
||||
if (/^[a-z]{4}-[a-z]{4}-[a-z]{4}$/.test(ns)) {
|
||||
console.log(`Checked existing namespace '${ns}'`);
|
||||
return ns;
|
||||
}
|
||||
ns = generateRandomAlphaString('-', 4, 4, 4);
|
||||
console.log(`Created new namespace '${ns}'`);
|
||||
if (set_location) window.location.hash = ns;
|
||||
return ns;
|
||||
}
|
||||
|
||||
function generateRandomAlphaString(separator, ...groups) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz';
|
||||
let ns = [];
|
||||
for (let group of groups) {
|
||||
let str = '';
|
||||
for (let i = 0; i < group; i++) {
|
||||
str += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
}
|
||||
ns.push(str);
|
||||
}
|
||||
return ns.join(separator);
|
||||
}
|
||||
|
||||
function debounce(callback_function, wait_in_milliseconds) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(
|
||||
() => callback_function.apply(context, args),
|
||||
wait_in_milliseconds);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user