FreedomBox/plinth/modules/janus/static/janus-video-room.js
Sunil Mohan Adapa b6bade7d06
janus: Update the video room code from latest upstream
- Bootswatch is a theme library for bootstrap. In Debian, only 3.x version of
the package is available. It is compatible with bootstrap 3.x but not bootstrap
5. Drop the theming altogether and use the basic bootstrap style (which is
already very close to the theme).

- Updated copyright year, mention the video room files in debian/copyright.

- Drop libjs-spin.js which is no longer used by the updated code.

- Change bootstrap version to 5.x from the earlier 4.x. Also add node-popper2
library (needed by bootstrap5 and video room code) as explicit dependency.

- Add missing style for btn-default class dropped in bootstrap 5.

- .simulcast-button CSS style is not longer needed as updated code used flex box
with .d-flex bootstrap class.

Tests:

- Compare the files in janus source code around Mar 2022 with the files in
FreedomBox source code before this patch. Compare latest janus source code with
the files after this patch. Both sets of changes are very similar.

- Connect to video room using two browser windows. Connection is successful and
2 video streams are shown in each of the browser windows.

- Styling looks close to the demo on janus website and is acceptable.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2025-11-17 13:03:02 -05:00

1088 lines
62 KiB
JavaScript

// SPDX-License-Identifier: AGPL-3.0-or-later
/*
#
# This file is part of FreedomBox.
#
# 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 <http://www.gnu.org/licenses/>.
#
# This file based on example code from Janus Demos which is licensed as
# follows.
#
# 2014-2025 Meetecho
#
# GPL-3 with OpenSSL exception
# If you modify this Program, or any covered work,
# by linking or combining it with OpenSSL
# (or a modified version of that library),
# containing parts covered by the terms of OpenSSL License,
# the licensors of this Program grant you
# additional permission to convey the resulting work.
# Corresponding Source for a non-source form of such a combination
# shall include the source code for the parts of openssl used
# as well as that of the covered work.
*/
var janus = null;
var sfutest = null;
var opaqueId = "videoroomtest-"+Janus.randomString(12);
var myroom = 1234; // Demo room
if(getQueryStringValue("room") !== "")
myroom = parseInt(getQueryStringValue("room"));
var myusername = null;
var myid = null;
var mystream = null;
// We use this other ID just to map our subscriptions to us
var mypvtid = null;
var remoteFeed = null;
var feeds = {}, feedStreams = {}, subStreams = {}, slots = {}, mids = {}, subscriptions = {};
var localTracks = {}, localVideos = 0, remoteTracks = {};
var bitrateTimer = [], simulcastStarted = {}, svcStarted = {};
var doSimulcast = (getQueryStringValue("simulcast") === "yes" || getQueryStringValue("simulcast") === "true");
var doSvc = getQueryStringValue("svc");
if(doSvc === "")
doSvc = null;
var acodec = (getQueryStringValue("acodec") !== "" ? getQueryStringValue("acodec") : null);
var vcodec = (getQueryStringValue("vcodec") !== "" ? getQueryStringValue("vcodec") : null);
var subscriber_mode = (getQueryStringValue("subscriber-mode") === "yes" || getQueryStringValue("subscriber-mode") === "true");
var use_msid = (getQueryStringValue("msid") === "yes" || getQueryStringValue("msid") === "true");
$(document).ready(function() {
// Initialize the library (all console debuggers enabled)
Janus.init({debug: "all", callback: function() {
// Use a button to start the video room
$('#start').one('click', function() {
$(this).attr('disabled', true).unbind('click');
// Make sure the browser supports WebRTC
if(!Janus.isWebrtcSupported()) {
bootbox.alert("No WebRTC support... ");
return;
}
// Create session
janus = new Janus(
{
server: server,
iceServers: iceServers,
token: token,
apisecret: apisecret,
success: function() {
// Attach to video room test plugin
janus.attach(
{
plugin: "janus.plugin.videoroom",
opaqueId: opaqueId,
success: function(pluginHandle) {
$('#details').remove();
sfutest = pluginHandle;
Janus.log("Plugin attached! (" + sfutest.getPlugin() + ", id=" + sfutest.getId() + ")");
Janus.log(" -- This is a publisher/manager");
// Prepare the username registration
$('#videojoin').removeClass('hide');
$('#registernow').removeClass('hide');
$('#register').click(registerUsername);
$('#username').focus();
$('#start').removeAttr('disabled').html("Stop")
.click(function() {
$(this).attr('disabled', true);
janus.destroy();
});
},
error: function(error) {
Janus.error(" -- Error attaching plugin...", error);
bootbox.alert("Error attaching plugin... " + error);
},
consentDialog: function(on) {
Janus.debug("Consent dialog should be " + (on ? "on" : "off") + " now");
},
iceState: function(state) {
Janus.log("ICE state changed to " + state);
},
mediaState: function(medium, on, mid) {
Janus.log("Janus " + (on ? "started" : "stopped") + " receiving our " + medium + " (mid=" + mid + ")");
},
webrtcState: function(on) {
Janus.log("Janus says our WebRTC PeerConnection is " + (on ? "up" : "down") + " now");
$("#videolocal").parent().parent().unblock();
if(!on)
return;
$('#publish').remove();
// This controls allows us to override the global room bitrate cap
$('#bitrate').parent().parent().removeClass('hide');
$('#bitrate a').click(function() {
$('.dropdown-toggle').dropdown('hide');
let id = $(this).attr("id");
let bitrate = parseInt(id)*1000;
if(bitrate === 0) {
Janus.log("Not limiting bandwidth via REMB");
} else {
Janus.log("Capping bandwidth to " + bitrate + " via REMB");
}
$('#bitrateset').text($(this).text()).parent().removeClass('open');
sfutest.send({ message: { request: "configure", bitrate: bitrate }});
return false;
});
},
slowLink: function(uplink, lost, mid) {
Janus.warn("Janus reports problems " + (uplink ? "sending" : "receiving") +
" packets on mid " + mid + " (" + lost + " lost packets)");
},
onmessage: function(msg, jsep) {
Janus.debug(" ::: Got a message (publisher) :::", msg);
let event = msg["videoroom"];
Janus.debug("Event: " + event);
if(event != undefined && event != null) {
if(event === "joined") {
// Publisher/manager created, negotiate WebRTC and attach to existing feeds, if any
myid = msg["id"];
mypvtid = msg["private_id"];
Janus.log("Successfully joined room " + msg["room"] + " with ID " + myid);
if(subscriber_mode) {
$('#videojoin').addClass('hide');
$('#videos').removeClass('hide');
} else {
publishOwnFeed(true);
}
// Any new feed to attach to?
if(msg["publishers"]) {
let list = msg["publishers"];
Janus.debug("Got a list of available publishers/feeds:", list);
let sources = null;
for(let f in list) {
if(list[f]["dummy"])
continue;
let id = list[f]["id"];
let display = list[f]["display"];
let streams = list[f]["streams"];
for(let i in streams) {
let stream = streams[i];
stream["id"] = id;
stream["display"] = display;
}
let slot = feedStreams[id] ? feedStreams[id].slot : null;
let remoteVideos = feedStreams[id] ? feedStreams[id].remoteVideos : 0;
feedStreams[id] = {
id: id,
display: display,
streams: streams,
slot: slot,
remoteVideos: remoteVideos
}
Janus.debug(" >> [" + id + "] " + display + ":", streams);
if(!sources)
sources = [];
sources.push(streams);
}
if(sources)
subscribeTo(sources);
}
} else if(event === "destroyed") {
// The room has been destroyed
Janus.warn("The room has been destroyed!");
bootbox.alert("The room has been destroyed", function() {
window.location.reload();
});
} else if(event === "event") {
// Any info on our streams or a new feed to attach to?
if(msg["streams"]) {
let streams = msg["streams"];
for(let i in streams) {
let stream = streams[i];
stream["id"] = myid;
stream["display"] = myusername;
}
feedStreams[myid] = {
id: myid,
display: myusername,
streams: streams
}
} else if(msg["publishers"]) {
let list = msg["publishers"];
Janus.debug("Got a list of available publishers/feeds:", list);
let sources = null;
for(let f in list) {
if(list[f]["dummy"])
continue;
let id = list[f]["id"];
let display = list[f]["display"];
let streams = list[f]["streams"];
for(let i in streams) {
let stream = streams[i];
stream["id"] = id;
stream["display"] = display;
}
let slot = feedStreams[id] ? feedStreams[id].slot : null;
let remoteVideos = feedStreams[id] ? feedStreams[id].remoteVideos : 0;
feedStreams[id] = {
id: id,
display: display,
streams: streams,
slot: slot,
remoteVideos: remoteVideos
}
Janus.debug(" >> [" + id + "] " + display + ":", streams);
if(!sources)
sources = [];
sources.push(streams);
}
if(sources)
subscribeTo(sources);
} else if(msg["leaving"]) {
// One of the publishers has gone away?
let leaving = msg["leaving"];
Janus.log("Publisher left: " + leaving);
unsubscribeFrom(leaving);
} else if(msg["unpublished"]) {
// One of the publishers has unpublished?
let unpublished = msg["unpublished"];
Janus.log("Publisher left: " + unpublished);
if(unpublished === 'ok') {
// That's us
sfutest.hangup();
return;
}
unsubscribeFrom(unpublished);
} else if(msg["error"]) {
if(msg["error_code"] === 426) {
// This is a "no such room" error: give a more meaningful description
bootbox.alert(
"<p>Apparently room <code>" + myroom + "</code> (the one this page uses as a test room) " +
"does not exist...</p><p>Do you have an updated <code>janus.plugin.videoroom.cfg</code> " +
"configuration file? If not, make sure you copy the details of room <code>" + myroom + "</code> " +
"from that sample in your current configuration file, then restart Janus and try again."
);
} else {
bootbox.alert(msg["error"]);
}
}
}
}
if(jsep) {
Janus.debug("Handling SDP as well...", jsep);
sfutest.handleRemoteJsep({ jsep: jsep });
// Check if any of the media we wanted to publish has
// been rejected (e.g., wrong or unsupported codec)
let audio = msg["audio_codec"];
if(mystream && mystream.getAudioTracks() && mystream.getAudioTracks().length > 0 && !audio) {
// Audio has been rejected
toastr.warning("Our audio stream has been rejected, viewers won't hear us");
}
let video = msg["video_codec"];
if(mystream && mystream.getVideoTracks() && mystream.getVideoTracks().length > 0 && !video) {
// Video has been rejected
toastr.warning("Our video stream has been rejected, viewers won't see us");
// Hide the webcam video
$('#myvideo').addClass('hide');
$('#videolocal').prepend(
'<div class="no-video-container">' +
'<span class="no-video-text-sm">Video rejected, no webcam</span>' +
'</div>');
}
}
},
onlocaltrack: function(track, on) {
Janus.debug(" ::: Got a local track event :::");
Janus.debug("Local track " + (on ? "added" : "removed") + ":", track);
// We use the track ID as name of the element, but it may contain invalid characters
let trackId = track.id.replace(/[{}]/g, "");
if(!on) {
// Track removed, get rid of the stream and the rendering
let stream = localTracks[trackId];
if(stream) {
try {
let tracks = stream.getTracks();
for(let i in tracks) {
let mst = tracks[i];
if(mst)
mst.stop();
}
// eslint-disable-next-line no-unused-vars
} catch(e) {}
}
if(track.kind === "video") {
$('#myvideo' + trackId).remove();
localVideos--;
if(localVideos === 0) {
// No video, at least for now: show a placeholder
if($('#videolocal .no-video-container').length === 0) {
$('#videolocal').prepend(
'<div class="no-video-container">' +
'<span class="no-video-text">No webcam available</span>' +
'</div>');
}
}
}
delete localTracks[trackId];
return;
}
// If we're here, a new track was added
let stream = localTracks[trackId];
if(stream) {
// We've been here already
return;
}
$('#videos').removeClass('hide');
if($('#mute').length === 0) {
// Add a 'mute' button
$('#videolocal').append('<button class="btn btn-warning btn-xs mute-button" id="mute">Mute</button>');
$('#mute').click(toggleMute);
// Add an 'unpublish' button
$('#videolocal').append('<button class="btn btn-warning btn-xs unpublish-button" id="unpublish">Unpublish</button>');
$('#unpublish').click(unpublishOwnFeed);
}
if(track.kind === "audio") {
// We ignore local audio tracks, they'd generate echo anyway
if(localVideos === 0) {
// No video, at least for now: show a placeholder
if($('#videolocal .no-video-container').length === 0) {
$('#videolocal').prepend(
'<div class="no-video-container">' +
'<span class="no-video-text">No webcam available</span>' +
'</div>');
}
}
} else {
// New video track: create a stream out of it
localVideos++;
$('#videolocal .no-video-container').remove();
let stream = new MediaStream([track]);
localTracks[trackId] = stream;
Janus.log("Created local stream:", stream);
Janus.log(stream.getTracks());
Janus.log(stream.getVideoTracks());
$('#videolocal').prepend('<video class="rounded centered" id="myvideo' + trackId + '" width=100% autoplay playsinline muted="muted"/>');
Janus.attachMediaStream($('#myvideo' + trackId).get(0), stream);
}
if(sfutest.webrtcStuff.pc.iceConnectionState !== "completed" &&
sfutest.webrtcStuff.pc.iceConnectionState !== "connected") {
$("#videolocal").parent().parent().block({
message: '<b>Publishing...</b>',
css: {
border: 'none',
backgroundColor: 'transparent',
color: 'white'
}
});
}
},
// eslint-disable-next-line no-unused-vars
onremotetrack: function(track, mid, on) {
// The publisher stream is sendonly, we don't expect anything here
},
oncleanup: function() {
Janus.log(" ::: Got a cleanup notification: we are unpublished now :::");
mystream = null;
delete feedStreams[myid];
$('#videolocal').html('<button id="publish" class="btn btn-primary">Publish</button>');
$('#publish').click(function() { publishOwnFeed(true); });
$("#videolocal").parent().parent().unblock();
$('#bitrate').parent().parent().addClass('hide');
$('#bitrate a').unbind('click');
localTracks = {};
localVideos = 0;
}
});
},
error: function(error) {
Janus.error(error);
bootbox.alert(error, function() {
window.location.reload();
});
},
destroyed: function() {
window.location.reload();
}
});
});
}});
});
// eslint-disable-next-line no-unused-vars
function checkEnter(field, event) {
let theCode = event.keyCode ? event.keyCode : event.which ? event.which : event.charCode;
if(theCode == 13) {
registerUsername();
return false;
} else {
return true;
}
}
function registerUsername() {
if($('#username').length === 0) {
// Create fields to register
$('#register').click(registerUsername);
$('#username').focus();
} else {
// Try a registration
$('#username').attr('disabled', true);
$('#register').attr('disabled', true).unbind('click');
let username = $('#username').val();
if(username === "") {
$('#you')
.removeClass().addClass('badge bg-warning')
.html("Insert your display name (e.g., pippo)");
$('#username').removeAttr('disabled');
$('#register').removeAttr('disabled').click(registerUsername);
return;
}
if(/[^a-zA-Z0-9]/.test(username)) {
$('#you')
.removeClass().addClass('badge bg-warning')
.html('Input is not alphanumeric');
$('#username').removeAttr('disabled').val("");
$('#register').removeAttr('disabled').click(registerUsername);
return;
}
let register = {
request: "join",
room: myroom,
ptype: "publisher",
display: username
};
myusername = escapeXmlTags(username);
sfutest.send({ message: register });
}
}
function publishOwnFeed(useAudio) {
// Publish our stream
$('#publish').attr('disabled', true).unbind('click');
// We want sendonly audio and video (uncomment the data track
// too if you want to publish via datachannels as well)
let tracks = [];
if(useAudio)
tracks.push({ type: 'audio', capture: true, recv: false });
tracks.push({ type: 'video', capture: true, recv: false,
// We may need to enable simulcast or SVC on the video track
simulcast: doSimulcast,
// We only support SVC for VP9 and (still WIP) AV1
svc: ((vcodec === 'vp9' || vcodec === 'av1') && doSvc) ? doSvc : null
});
//~ tracks.push({ type: 'data' });
sfutest.createOffer(
{
tracks: tracks,
success: function(jsep) {
Janus.debug("Got publisher SDP!");
Janus.debug(jsep);
let publish = { request: "configure", audio: useAudio, video: true };
// You can force a specific codec to use when publishing by using the
// audiocodec and videocodec properties, for instance:
// publish["audiocodec"] = "opus"
// to force Opus as the audio codec to use, or:
// publish["videocodec"] = "vp9"
// to force VP9 as the videocodec to use. In both case, though, forcing
// a codec will only work if: (1) the codec is actually in the SDP (and
// so the browser supports it), and (2) the codec is in the list of
// allowed codecs in a room. With respect to the point (2) above,
// refer to the text in janus.plugin.videoroom.cfg for more details
if(acodec)
publish["audiocodec"] = acodec;
if(vcodec)
publish["videocodec"] = vcodec;
sfutest.send({ message: publish, jsep: jsep });
},
error: function(error) {
Janus.error("WebRTC error:", error);
if (useAudio) {
publishOwnFeed(false);
} else {
bootbox.alert("WebRTC error... " + error.message);
$('#publish').removeAttr('disabled').click(function() { publishOwnFeed(true); });
}
}
});
}
function toggleMute() {
let muted = sfutest.isAudioMuted();
Janus.log((muted ? "Unmuting" : "Muting") + " local stream...");
if(muted)
sfutest.unmuteAudio();
else
sfutest.muteAudio();
muted = sfutest.isAudioMuted();
$('#mute').html(muted ? "Unmute" : "Mute");
}
function unpublishOwnFeed() {
// Unpublish our stream
$('#unpublish').attr('disabled', true).unbind('click');
let unpublish = { request: "unpublish" };
sfutest.send({ message: unpublish });
}
var creatingSubscription = false;
function subscribeTo(sources) {
// Check if we're still creating the subscription handle
if(creatingSubscription) {
// Still working on the handle, send this request later when it's ready
setTimeout(function() {
subscribeTo(sources);
}, 500);
return;
}
// If we already have a working subscription handle, just update that one
if(remoteFeed) {
// Prepare the streams to subscribe to, as an array: we have the list of
// streams the feeds are publishing, so we can choose what to pick or skip
let added = null, removed = null;
for(let s in sources) {
let streams = sources[s];
for(let i in streams) {
let stream = streams[i];
// If the publisher is VP8/VP9 and this is an older Safari, let's avoid video
if(stream.type === "video" && Janus.webRTCAdapter.browserDetails.browser === "safari" &&
((stream.codec === "vp9" && !Janus.safariVp9) || (stream.codec === "vp8" && !Janus.safariVp8))) {
toastr.warning("Publisher is using " + stream.codec.toUpperCase +
", but Safari doesn't support it: disabling video stream #" + stream.mindex);
continue;
}
if(stream.disabled) {
Janus.log("Disabled stream:", stream);
// Unsubscribe
if(!removed)
removed = [];
removed.push({
feed: stream.id, // This is mandatory
mid: stream.mid // This is optional (all streams, if missing)
});
delete subscriptions[stream.id][stream.mid];
continue;
}
if(subscriptions[stream.id] && subscriptions[stream.id][stream.mid]) {
Janus.log("Already subscribed to stream, skipping:", stream);
continue;
}
// Find an empty slot in the UI for each new source
if(!feedStreams[stream.id].slot) {
let slot;
for(let i=1;i<6;i++) {
if(!feeds[i]) {
slot = i;
feeds[slot] = stream.id;
feedStreams[stream.id].slot = slot;
feedStreams[stream.id].remoteVideos = 0;
$('#remote' + slot).removeClass('hide').html(escapeXmlTags(stream.display)).removeClass('hide');
break;
}
}
}
// Subscribe
if(!added)
added = [];
added.push({
feed: stream.id, // This is mandatory
mid: stream.mid // This is optional (all streams, if missing)
});
if(!subscriptions[stream.id])
subscriptions[stream.id] = {};
subscriptions[stream.id][stream.mid] = true;
}
}
if((!added || added.length === 0) && (!removed || removed.length === 0)) {
// Nothing to do
return;
}
let update = { request: 'update' };
if(added)
update.subscribe = added;
if(removed)
update.unsubscribe = removed;
remoteFeed.send({ message: update });
// Nothing else we need to do
return;
}
// If we got here, we're creating a new handle for the subscriptions (we only need one)
creatingSubscription = true;
janus.attach(
{
plugin: "janus.plugin.videoroom",
opaqueId: opaqueId,
success: function(pluginHandle) {
remoteFeed = pluginHandle;
remoteTracks = {};
Janus.log("Plugin attached! (" + remoteFeed.getPlugin() + ", id=" + remoteFeed.getId() + ")");
Janus.log(" -- This is a multistream subscriber");
// Prepare the streams to subscribe to, as an array: we have the list of
// streams the feed is publishing, so we can choose what to pick or skip
let subscription = [];
for(let s in sources) {
let streams = sources[s];
for(let i in streams) {
let stream = streams[i];
// If the publisher is VP8/VP9 and this is an older Safari, let's avoid video
if(stream.type === "video" && Janus.webRTCAdapter.browserDetails.browser === "safari" &&
((stream.codec === "vp9" && !Janus.safariVp9) || (stream.codec === "vp8" && !Janus.safariVp8))) {
toastr.warning("Publisher is using " + stream.codec.toUpperCase +
", but Safari doesn't support it: disabling video stream #" + stream.mindex);
continue;
}
if(stream.disabled) {
Janus.log("Disabled stream:", stream);
// TODO Skipping for now, we should unsubscribe
continue;
}
Janus.log("Subscribed to " + stream.id + "/" + stream.mid + "?", subscriptions);
if(subscriptions[stream.id] && subscriptions[stream.id][stream.mid]) {
Janus.log("Already subscribed to stream, skipping:", stream);
continue;
}
// Find an empty slot in the UI for each new source
if(!feedStreams[stream.id].slot) {
let slot;
for(let i=1;i<6;i++) {
if(!feeds[i]) {
slot = i;
feeds[slot] = stream.id;
feedStreams[stream.id].slot = slot;
feedStreams[stream.id].remoteVideos = 0;
$('#remote' + slot).removeClass('hide').html(escapeXmlTags(stream.display)).removeClass('hide');
break;
}
}
}
subscription.push({
feed: stream.id, // This is mandatory
mid: stream.mid // This is optional (all streams, if missing)
});
if(!subscriptions[stream.id])
subscriptions[stream.id] = {};
subscriptions[stream.id][stream.mid] = true;
}
}
// We wait for the plugin to send us an offer
let subscribe = {
request: "join",
room: myroom,
ptype: "subscriber",
streams: subscription,
use_msid: use_msid,
private_id: mypvtid
};
remoteFeed.send({ message: subscribe });
},
error: function(error) {
Janus.error(" -- Error attaching plugin...", error);
bootbox.alert("Error attaching plugin... " + error);
},
iceState: function(state) {
Janus.log("ICE state (remote feed) changed to " + state);
},
webrtcState: function(on) {
Janus.log("Janus says this WebRTC PeerConnection (remote feed) is " + (on ? "up" : "down") + " now");
},
slowLink: function(uplink, lost, mid) {
Janus.warn("Janus reports problems " + (uplink ? "sending" : "receiving") +
" packets on mid " + mid + " (" + lost + " lost packets)");
},
onmessage: function(msg, jsep) {
Janus.debug(" ::: Got a message (subscriber) :::", msg);
let event = msg["videoroom"];
Janus.debug("Event: " + event);
if(msg["error"]) {
bootbox.alert(msg["error"]);
} else if(event) {
if(event === "attached") {
// Now we have a working subscription, next requests will update this one
creatingSubscription = false;
Janus.log("Successfully attached to feed in room " + msg["room"]);
} else if(event === "event") {
// Check if we got an event on a simulcast-related event from this publisher
let mid = msg["mid"];
let substream = msg["substream"];
let temporal = msg["temporal"];
if((substream !== null && substream !== undefined) || (temporal !== null && temporal !== undefined)) {
// Check which this feed this refers to
let slot = slots[mid];
if(!simulcastStarted[slot]) {
simulcastStarted[slot] = true;
// Add some new buttons
addSimulcastSvcButtons(slot, true);
}
// We just received notice that there's been a switch, update the buttons
updateSimulcastSvcButtons(slot, substream, temporal);
}
// Or maybe SVC?
let spatial = msg["spatial_layer"];
temporal = msg["temporal_layer"];
if((spatial !== null && spatial !== undefined) || (temporal !== null && temporal !== undefined)) {
let slot = slots[mid];
if(!svcStarted[slot]) {
svcStarted[slot] = true;
// Add some new buttons
addSimulcastSvcButtons(slot, true);
}
// We just received notice that there's been a switch, update the buttons
updateSimulcastSvcButtons(slot, spatial, temporal);
}
} else {
// What has just happened?
}
}
if(msg["streams"]) {
// Update map of subscriptions by mid
for(let i in msg["streams"]) {
let mid = msg["streams"][i]["mid"];
subStreams[mid] = msg["streams"][i];
let feed = feedStreams[msg["streams"][i]["feed_id"]];
if(feed && feed.slot) {
slots[mid] = feed.slot;
mids[feed.slot] = mid;
}
}
}
if(jsep) {
Janus.debug("Handling SDP as well...", jsep);
// Answer and attach
remoteFeed.createAnswer(
{
jsep: jsep,
// We only specify data channels here, as this way in
// case they were offered we'll enable them. Since we
// don't mention audio or video tracks, we autoaccept them
// as recvonly (since we won't capture anything ourselves)
tracks: [
{ type: 'data' }
],
success: function(jsep) {
Janus.debug("Got SDP!");
Janus.debug(jsep);
let body = { request: "start", room: myroom };
remoteFeed.send({ message: body, jsep: jsep });
},
error: function(error) {
Janus.error("WebRTC error:", error);
bootbox.alert("WebRTC error... " + error.message);
}
});
}
},
// eslint-disable-next-line no-unused-vars
onlocaltrack: function(track, on) {
// The subscriber stream is recvonly, we don't expect anything here
},
onremotetrack: function(track, mid, on, metadata) {
Janus.debug(
"Remote track (mid=" + mid + ") " +
(on ? "added" : "removed") +
(metadata ? " (" + metadata.reason + ") ": "") + ":", track
);
// Which publisher are we getting on this mid?
let sub = subStreams[mid];
let feed = feedStreams[sub.feed_id];
Janus.debug(" >> This track is coming from feed " + sub.feed_id + ":", feed);
let slot = slots[mid];
if(feed && !slot) {
slot = feed.slot;
slots[mid] = feed.slot;
mids[feed.slot] = mid;
}
Janus.debug(" >> mid " + mid + " is in slot " + slot);
if(!on) {
// Track removed, get rid of the stream and the rendering
$('#remotevideo' + slot + '-' + mid).remove();
if(track.kind === "video" && feed) {
feed.remoteVideos--;
if(feed.remoteVideos === 0) {
// No video, at least for now: show a placeholder
if($('#videoremote' + slot + ' .no-video-container').length === 0) {
$('#videoremote' + slot).append(
'<div class="no-video-container">' +
'<span class="no-video-text">No remote video available</span>' +
'</div>');
}
}
}
delete remoteTracks[mid];
delete slots[mid];
delete mids[slot];
return;
}
// If we're here, a new track was added
if($('#remotevideo' + slot + '-' + mid).length > 0)
return;
if(track.kind === "audio") {
// New audio track: create a stream out of it, and use a hidden <audio> element
let stream = new MediaStream([track]);
remoteTracks[mid] = stream;
Janus.log("Created remote audio stream:", stream);
$('#videoremote' + slot).append('<audio class="hide" id="remotevideo' + slot + '-' + mid + '" autoplay playsinline/>');
Janus.attachMediaStream($('#remotevideo' + slot + '-' + mid).get(0), stream);
if(feed.remoteVideos === 0) {
// No video, at least for now: show a placeholder
if($('#videoremote' + slot + ' .no-video-container').length === 0) {
$('#videoremote' + slot).append(
'<div class="no-video-container">' +
'<span class="no-video-text">No remote video available</span>' +
'</div>');
}
}
} else {
// New video track: create a stream out of it
feed.remoteVideos++;
$('#videoremote' + slot + ' .no-video-container').remove();
let stream = new MediaStream([track]);
remoteTracks[mid] = stream;
Janus.log("Created remote video stream:", stream);
$('#videoremote' + slot).append('<video class="rounded centered" id="remotevideo' + slot + '-' + mid + '" width=100% autoplay playsinline/>');
$('#videoremote' + slot).append(
'<span class="badge bg-primary hide resolution-label" id="curres'+slot+'"></span>' +
'<span class="badge bg-info hide bitrate-label" id="curbitrate'+slot+'"></span>');
Janus.attachMediaStream($('#remotevideo' + slot + '-' + mid).get(0), stream);
// Note: we'll need this for additional videos too
if(!bitrateTimer[slot]) {
$('#curbitrate' + slot).removeClass('hide');
bitrateTimer[slot] = setInterval(function() {
if(!$("#videoremote" + slot + ' video').get(0))
return;
// Display updated bitrate, if supported
let bitrate = remoteFeed.getBitrate(mid);
$('#curbitrate' + slot).text(bitrate);
// Check if the resolution changed too
let width = $("#videoremote" + slot + ' video').get(0).videoWidth;
let height = $("#videoremote" + slot + ' video').get(0).videoHeight;
if(width > 0 && height > 0) {
let res = width + 'x' + height;
if(simulcastStarted[slot])
res += ' (simulcast)';
else if(svcStarted[slot])
res += ' (SVC)';
$('#curres' + slot).removeClass('hide').text(res).removeClass('hide');
}
}, 1000);
}
}
},
oncleanup: function() {
Janus.log(" ::: Got a cleanup notification (remote feed) :::");
for(let i=1;i<6;i++) {
$('#videoremote'+i).empty();
if(bitrateTimer[i])
clearInterval(bitrateTimer[i]);
bitrateTimer[i] = null;
feedStreams[i].simulcastStarted = false;
feedStreams[i].svcStarted = false;
feedStreams[i].remoteVideos = 0;
$('#simulcast'+i).remove();
}
remoteTracks = {};
}
});
}
function unsubscribeFrom(id) {
// Unsubscribe from this publisher
let feed = feedStreams[id];
if(!feed)
return;
Janus.debug("Feed " + id + " (" + feed.display + ") has left the room, detaching");
if(bitrateTimer[feed.slot])
clearInterval(bitrateTimer[feed.slot]);
bitrateTimer[feed.slot] = null;
$('#remote' + feed.slot).empty().addClass('hide');
$('#videoremote' + feed.slot).empty();
delete simulcastStarted[feed.slot];
delete svcStarted[feed.slot];
$('#simulcast' + feed.slot).remove();
delete feeds[feed.slot];
feeds.slot = 0;
delete feedStreams[id];
// Send an unsubscribe request
let unsubscribe = {
request: "unsubscribe",
streams: [{ feed: id }]
};
if(remoteFeed != null)
remoteFeed.send({ message: unsubscribe });
delete subscriptions[id];
}
// Helper to parse query string
function getQueryStringValue(name) {
name = name.replace(/[[]/, "\\[").replace(/[\]]/, "\\]");
let regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
// Helper to escape XML tags
function escapeXmlTags(value) {
if(value) {
let escapedValue = value.replace(new RegExp('<', 'g'), '&lt');
escapedValue = escapedValue.replace(new RegExp('>', 'g'), '&gt');
return escapedValue;
}
}
// Helpers to create Simulcast- or SVC-related UI, if enabled
function addSimulcastSvcButtons(feed, temporal) {
let index = feed;
let simulcast = simulcastStarted[index];
let what = (simulcast ? 'simulcast' : 'SVC');
let layer = (simulcast ? 'substream' : 'layer');
$('#remote'+index).parent().append(
'<div id="simulcast'+index+'" class="btn-group-vertical btn-group-xs top-right">' +
' <div class="btn-group btn-group-xs d-flex simulcast-button-group">' +
' <button id="sl'+index+'-2" type="button" class="btn btn-primary" data-bs-toggle="tooltip" title="Switch to higher quality">SL 2</button>' +
' <button id="sl'+index+'-1" type="button" class="btn btn-primary" data-bs-toggle="tooltip" title="Switch to normal quality">SL 1</button>' +
' <button id="sl'+index+'-0" type="button" class="btn btn-primary" data-bs-toggle="tooltip" title="Switch to lower quality">SL 0</button>' +
' </div>' +
' <div class="btn-group btn-group-xs d-flex hide simulcast-button-group">' +
' <button id="tl'+index+'-2" type="button" class="btn btn-primary" data-bs-toggle="tooltip" title="Cap to temporal layer 2">TL 2</button>' +
' <button id="tl'+index+'-1" type="button" class="btn btn-primary" data-bs-toggle="tooltip" title="Cap to temporal layer 1">TL 1</button>' +
' <button id="tl'+index+'-0" type="button" class="btn btn-primary" data-bs-toggle="tooltip" title="Cap to temporal layer 0">TL 0</button>' +
' </div>' +
'</div>'
);
if(simulcast && Janus.webRTCAdapter.browserDetails.browser !== "firefox") {
// Chromium-based browsers only have two temporal layers, when doing simulcast
$('#tl'+index+'-2').remove();
}
// Enable the simulcast selection buttons
$('#sl' + index + '-0').removeClass('btn-primary btn-success').addClass('btn-primary')
.unbind('click').click(function() {
let index = $(this).attr('id').split('sl')[1].split('-')[0];
toastr.info("Switching " + what + " " + layer + " (mid=" + mids[index] + "), wait for it... (lower quality)", null, {timeOut: 2000});
if(!$('#sl' + index + '-2').hasClass('btn-success'))
$('#sl' + index + '-2').removeClass('btn-primary btn-info').addClass('btn-primary');
if(!$('#sl' + index + '-1').hasClass('btn-success'))
$('#sl' + index + '-1').removeClass('btn-primary btn-info').addClass('btn-primary');
$('#sl' + index + '-0').removeClass('btn-primary btn-info btn-success').addClass('btn-info');
if(simulcastStarted[index])
remoteFeed.send({ message: { request: "configure", mid: mids[index], substream: 0 }});
else
remoteFeed.send({ message: { request: "configure", mid: mids[index], spatial_layer: 0 }});
});
$('#sl' + index + '-1').removeClass('btn-primary btn-success').addClass('btn-primary')
.unbind('click').click(function() {
let index = $(this).attr('id').split('sl')[1].split('-')[0];
toastr.info("Switching " + what + " " + layer + " (mid=" + mids[index] + "), wait for it... (normal quality)", null, {timeOut: 2000});
if(!$('#sl' + index + '-2').hasClass('btn-success'))
$('#sl' + index + '-2').removeClass('btn-primary btn-info').addClass('btn-primary');
$('#sl' + index + '-1').removeClass('btn-primary btn-info btn-success').addClass('btn-info');
if(!$('#sl' + index + '-0').hasClass('btn-success'))
$('#sl' + index + '-0').removeClass('btn-primary btn-info').addClass('btn-primary');
if(simulcastStarted[index])
remoteFeed.send({ message: { request: "configure", mid: mids[index], substream: 1 }});
else
remoteFeed.send({ message: { request: "configure", mid: mids[index], spatial_layer: 1 }});
});
$('#sl' + index + '-2').removeClass('btn-primary btn-success').addClass('btn-primary')
.unbind('click').click(function() {
let index = $(this).attr('id').split('sl')[1].split('-')[0];
toastr.info("Switching " + what + " " + layer + " (mid=" + mids[index] + "), wait for it... (higher quality)", null, {timeOut: 2000});
$('#sl' + index + '-2').removeClass('btn-primary btn-info btn-success').addClass('btn-info');
if(!$('#sl' + index + '-1').hasClass('btn-success'))
$('#sl' + index + '-1').removeClass('btn-primary btn-info').addClass('btn-primary');
if(!$('#sl' + index + '-0').hasClass('btn-success'))
$('#sl' + index + '-0').removeClass('btn-primary btn-info').addClass('btn-primary');
if(simulcastStarted[index])
remoteFeed.send({ message: { request: "configure", mid: mids[index], substream: 2 }});
else
remoteFeed.send({ message: { request: "configure", mid: mids[index], spatial_layer: 2 }});
});
if(!temporal) // No temporal layer support
return;
$('#tl' + index + '-0').parent().removeClass('hide');
$('#tl' + index + '-0').removeClass('btn-primary btn-success').addClass('btn-primary')
.unbind('click').click(function() {
let index = $(this).attr('id').split('tl')[1].split('-')[0];
toastr.info("Capping " + what + " temporal layer (mid=" + mids[index] + "), wait for it... (lowest FPS)", null, {timeOut: 2000});
if(!$('#tl' + index + '-2').hasClass('btn-success'))
$('#tl' + index + '-2').removeClass('btn-primary btn-info').addClass('btn-primary');
if(!$('#tl' + index + '-1').hasClass('btn-success'))
$('#tl' + index + '-1').removeClass('btn-primary btn-info').addClass('btn-primary');
$('#tl' + index + '-0').removeClass('btn-primary btn-info btn-success').addClass('btn-info');
if(simulcastStarted[index])
remoteFeed.send({ message: { request: "configure", mid: mids[index], temporal: 0 }});
else
remoteFeed.send({ message: { request: "configure", mid: mids[index], temporal_layer: 0 }});
});
$('#tl' + index + '-1').removeClass('btn-primary btn-success').addClass('btn-primary')
.unbind('click').click(function() {
let index = $(this).attr('id').split('tl')[1].split('-')[0];
toastr.info("Capping " + what + " temporal layer (mid=" + mids[index] + "), wait for it... (medium FPS)", null, {timeOut: 2000});
if(!$('#tl' + index + '-2').hasClass('btn-success'))
$('#tl' + index + '-2').removeClass('btn-primary btn-info').addClass('btn-primary');
$('#tl' + index + '-1').removeClass('btn-primary btn-info').addClass('btn-info');
if(!$('#tl' + index + '-0').hasClass('btn-success'))
$('#tl' + index + '-0').removeClass('btn-primary btn-info').addClass('btn-primary');
if(simulcastStarted[index])
remoteFeed.send({ message: { request: "configure", mid: mids[index], temporal: 1 }});
else
remoteFeed.send({ message: { request: "configure", mid: mids[index], temporal_layer: 1 }});
});
$('#tl' + index + '-2').removeClass('btn-primary btn-success').addClass('btn-primary')
.unbind('click').click(function() {
let index = $(this).attr('id').split('tl')[1].split('-')[0];
toastr.info("Capping " + what + " temporal layer (mid=" + mids[index] + "), wait for it... (highest FPS)", null, {timeOut: 2000});
$('#tl' + index + '-2').removeClass('btn-primary btn-info btn-success').addClass('btn-info');
if(!$('#tl' + index + '-1').hasClass('btn-success'))
$('#tl' + index + '-1').removeClass('btn-primary btn-info').addClass('btn-primary');
if(!$('#tl' + index + '-0').hasClass('btn-success'))
$('#tl' + index + '-0').removeClass('btn-primary btn-info').addClass('btn-primary');
if(simulcastStarted[index])
remoteFeed.send({ message: { request: "configure", mid: mids[index], temporal: 2 }});
else
remoteFeed.send({ message: { request: "configure", mid: mids[index], temporal_layer: 2 }});
});
}
function updateSimulcastSvcButtons(feed, substream, temporal) {
// Check the substream
let index = feed;
let simulcast = simulcastStarted[index];
let what = (simulcast ? 'simulcast' : 'SVC');
let layer = (simulcast ? 'substream' : 'layer');
if(substream === 0) {
toastr.success("Switched " + what + " " + layer + "! (lower quality)", null, {timeOut: 2000});
$('#sl' + index + '-2').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#sl' + index + '-1').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#sl' + index + '-0').removeClass('btn-primary btn-info btn-success').addClass('btn-success');
} else if(substream === 1) {
toastr.success("Switched " + what + " " + layer + "! (normal quality)", null, {timeOut: 2000});
$('#sl' + index + '-2').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#sl' + index + '-1').removeClass('btn-primary btn-info btn-success').addClass('btn-success');
$('#sl' + index + '-0').removeClass('btn-primary btn-success').addClass('btn-primary');
} else if(substream === 2) {
toastr.success("Switched " + what + " " + layer + "! (higher quality)", null, {timeOut: 2000});
$('#sl' + index + '-2').removeClass('btn-primary btn-info btn-success').addClass('btn-success');
$('#sl' + index + '-1').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#sl' + index + '-0').removeClass('btn-primary btn-success').addClass('btn-primary');
}
// Check the temporal layer
if(temporal === 0) {
toastr.success("Capped " + what + " temporal layer! (lowest FPS)", null, {timeOut: 2000});
$('#tl' + index + '-2').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#tl' + index + '-1').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#tl' + index + '-0').removeClass('btn-primary btn-info btn-success').addClass('btn-success');
} else if(temporal === 1) {
toastr.success("Capped " + what + " temporal layer! (medium FPS)", null, {timeOut: 2000});
$('#tl' + index + '-2').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#tl' + index + '-1').removeClass('btn-primary btn-info btn-success').addClass('btn-success');
$('#tl' + index + '-0').removeClass('btn-primary btn-success').addClass('btn-primary');
} else if(temporal === 2) {
toastr.success("Capped " + what + " temporal layer! (highest FPS)", null, {timeOut: 2000});
$('#tl' + index + '-2').removeClass('btn-primary btn-info btn-success').addClass('btn-success');
$('#tl' + index + '-1').removeClass('btn-primary btn-success').addClass('btn-primary');
$('#tl' + index + '-0').removeClass('btn-primary btn-success').addClass('btn-primary');
}
}