Files
program-webrtc-deploy/www/js/main.js
2026-07-19 16:31:41 +08:00

782 lines
21 KiB
JavaScript

/***
* 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);
};
}