1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-12-23 07:45:46 +01:00
galene/static/galene.js
2021-10-30 19:12:06 +02:00

3126 lines
82 KiB
JavaScript

// Copyright (c) 2020 by Juliusz Chroboczek.
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
'use strict';
/** @type {string} */
let group;
/** @type {ServerConnection} */
let serverConnection;
/** @type {Object} */
let groupStatus = {};
/**
* @typedef {Object} userpass
* @property {string} username
* @property {string} password
*/
/* Some browsers disable session storage when cookies are disabled,
we fall back to a global variable. */
/**
* @type {userpass}
*/
let fallbackUserPass = null;
/**
* @param {string} username
* @param {string} password
*/
function storeUserPass(username, password) {
let userpass = {username: username, password: password};
try {
window.sessionStorage.setItem('userpass', JSON.stringify(userpass));
fallbackUserPass = null;
} catch(e) {
console.warn("Couldn't store password:", e);
fallbackUserPass = userpass;
}
}
/**
* Returns null if the user hasn't logged in yet.
*
* @returns {userpass}
*/
function getUserPass() {
/** @type{userpass} */
let userpass;
try {
let json = window.sessionStorage.getItem('userpass');
userpass = JSON.parse(json);
} catch(e) {
console.warn("Couldn't retrieve password:", e);
userpass = fallbackUserPass;
}
return userpass || null;
}
/**
* @typedef {Object} settings
* @property {boolean} [localMute]
* @property {string} [video]
* @property {string} [audio]
* @property {string} [simulcast]
* @property {string} [send]
* @property {string} [request]
* @property {boolean} [activityDetection]
* @property {Array.<number>} [resolution]
* @property {boolean} [mirrorView]
* @property {boolean} [blackboardMode]
* @property {string} [filter]
*/
/** @type{settings} */
let fallbackSettings = null;
/**
* @param {settings} settings
*/
function storeSettings(settings) {
try {
window.sessionStorage.setItem('settings', JSON.stringify(settings));
fallbackSettings = null;
} catch(e) {
console.warn("Couldn't store settings:", e);
fallbackSettings = settings;
}
}
/**
* This always returns a dictionary.
*
* @returns {settings}
*/
function getSettings() {
/** @type {settings} */
let settings;
try {
let json = window.sessionStorage.getItem('settings');
settings = JSON.parse(json);
} catch(e) {
console.warn("Couldn't retrieve settings:", e);
settings = fallbackSettings;
}
return settings || {};
}
/**
* @param {settings} settings
*/
function updateSettings(settings) {
let s = getSettings();
for(let key in settings)
s[key] = settings[key];
storeSettings(s);
}
/**
* @param {string} key
* @param {any} value
*/
function updateSetting(key, value) {
let s = {};
s[key] = value;
updateSettings(s);
}
/**
* @param {string} key
*/
function delSetting(key) {
let s = getSettings();
if(!(key in s))
return;
delete(s[key]);
storeSettings(s);
}
/**
* @param {string} id
*/
function getSelectElement(id) {
let elt = document.getElementById(id);
if(!elt || !(elt instanceof HTMLSelectElement))
throw new Error(`Couldn't find ${id}`);
return elt;
}
/**
* @param {string} id
*/
function getInputElement(id) {
let elt = document.getElementById(id);
if(!elt || !(elt instanceof HTMLInputElement))
throw new Error(`Couldn't find ${id}`);
return elt;
}
/**
* @param {string} id
*/
function getButtonElement(id) {
let elt = document.getElementById(id);
if(!elt || !(elt instanceof HTMLButtonElement))
throw new Error(`Couldn't find ${id}`);
return elt;
}
function reflectSettings() {
let settings = getSettings();
let store = false;
setLocalMute(settings.localMute);
let videoselect = getSelectElement('videoselect');
if(!settings.hasOwnProperty('video') ||
!selectOptionAvailable(videoselect, settings.video)) {
settings.video = selectOptionDefault(videoselect);
store = true;
}
videoselect.value = settings.video;
let audioselect = getSelectElement('audioselect');
if(!settings.hasOwnProperty('audio') ||
!selectOptionAvailable(audioselect, settings.audio)) {
settings.audio = selectOptionDefault(audioselect);
store = true;
}
audioselect.value = settings.audio;
if(settings.hasOwnProperty('filter')) {
getSelectElement('filterselect').value = settings.filter;
} else {
let s = getSelectElement('filterselect').value;
if(s) {
settings.filter = s;
store = true;
}
}
if(settings.hasOwnProperty('request')) {
getSelectElement('requestselect').value = settings.request;
} else {
settings.request = getSelectElement('requestselect').value;
store = true;
}
if(settings.hasOwnProperty('send')) {
getSelectElement('sendselect').value = settings.send;
} else {
settings.send = getSelectElement('sendselect').value;
store = true;
}
if(settings.hasOwnProperty('simulcast')) {
getSelectElement('simulcastselect').value = settings.simulcast
} else {
settings.simulcast = getSelectElement('simulcastselect').value;
store = true;
}
if(settings.hasOwnProperty('blackboardMode')) {
getInputElement('blackboardbox').checked = settings.blackboardMode;
} else {
settings.blackboardMode = getInputElement('blackboardbox').checked;
store = true;
}
if(settings.hasOwnProperty('mirrorView')) {
getInputElement('mirrorbox').checked = settings.mirrorView;
} else {
settings.mirrorView = getInputElement('mirrorbox').checked;
store = true;
}
if(settings.hasOwnProperty('activityDetection')) {
getInputElement('activitybox').checked = settings.activityDetection;
} else {
settings.activityDetection = getInputElement('activitybox').checked;
store = true;
}
if(store)
storeSettings(settings);
}
function isMobileLayout() {
if (window.matchMedia('only screen and (max-width: 1024px)').matches)
return true;
return false;
}
/**
* @param {boolean} [force]
*/
function hideVideo(force) {
let mediadiv = document.getElementById('peers');
if(mediadiv.childElementCount > 0 && !force)
return;
setVisibility('video-container', false);
scheduleReconsiderDownRate();
}
function showVideo() {
let hasmedia = document.getElementById('peers').childElementCount > 0;
if(isMobileLayout()) {
setVisibility('show-video', false);
setVisibility('collapse-video', hasmedia);
}
setVisibility('video-container', hasmedia);
scheduleReconsiderDownRate();
}
function fillLogin() {
let userpass = getUserPass();
getInputElement('username').value =
userpass ? userpass.username : '';
getInputElement('password').value =
userpass ? userpass.password : '';
}
/**
* @param{boolean} connected
*/
function setConnected(connected) {
let userbox = document.getElementById('profile');
let connectionbox = document.getElementById('login-container');
if(connected) {
clearChat();
userbox.classList.remove('invisible');
connectionbox.classList.add('invisible');
displayUsername();
window.onresize = function(e) {
scheduleReconsiderDownRate();
}
} else {
fillLogin();
userbox.classList.add('invisible');
connectionbox.classList.remove('invisible');
displayError('Disconnected', 'error');
hideVideo();
window.onresize = null;
}
}
/** @this {ServerConnection} */
function gotConnected() {
setConnected(true);
let up = getUserPass();
try {
this.join(group, up.username, up.password);
} catch(e) {
console.error(e);
displayError(e);
serverConnection.close();
}
}
/**
* @this {ServerConnection}
* @param {number} code
* @param {string} reason
*/
function gotClose(code, reason) {
closeUpMedia();
setConnected(false);
if(code != 1000) {
console.warn('Socket close', code, reason);
}
}
/**
* @this {ServerConnection}
* @param {Stream} c
*/
function gotDownStream(c) {
c.onclose = function(replace) {
if(!replace)
delMedia(c.localId);
};
c.onerror = function(e) {
console.error(e);
displayError(e);
};
c.ondowntrack = function(track, transceiver, label, stream) {
setMedia(c, false);
};
c.onnegotiationcompleted = function() {
resetMedia(c);
}
c.onstatus = function(status) {
setMediaStatus(c);
};
c.onstats = gotDownStats;
if(getSettings().activityDetection)
c.setStatsInterval(activityDetectionInterval);
setMedia(c, false);
}
// Store current browser viewport height in css variable
function setViewportHeight() {
document.documentElement.style.setProperty(
'--vh', `${window.innerHeight/100}px`,
);
showVideo();
// Ajust video component size
resizePeers();
}
// On resize and orientation change, we update viewport height
addEventListener('resize', setViewportHeight);
addEventListener('orientationchange', setViewportHeight);
getButtonElement('presentbutton').onclick = async function(e) {
e.preventDefault();
let button = this;
if(!(button instanceof HTMLButtonElement))
throw new Error('Unexpected type for this.');
// there's a potential race condition here: the user might click the
// button a second time before the stream is set up and the button hidden.
button.disabled = true;
try {
let id = findUpMedia('camera');
if(!id)
await addLocalMedia();
} finally {
button.disabled = false;
}
};
getButtonElement('unpresentbutton').onclick = function(e) {
e.preventDefault();
closeUpMedia('camera');
resizePeers();
};
/**
* @param {string} id
* @param {boolean} visible
*/
function setVisibility(id, visible) {
let elt = document.getElementById(id);
if(visible)
elt.classList.remove('invisible');
else
elt.classList.add('invisible');
}
function setButtonsVisibility() {
let connected = serverConnection && serverConnection.socket;
let permissions = serverConnection.permissions;
let local = !!findUpMedia('camera');
let canWebrtc = !(typeof RTCPeerConnection === 'undefined');
let canFile =
/** @ts-ignore */
!!HTMLVideoElement.prototype.captureStream ||
/** @ts-ignore */
!!HTMLVideoElement.prototype.mozCaptureStream;
let mediacount = document.getElementById('peers').childElementCount;
let mobilelayout = isMobileLayout();
// don't allow multiple presentations
setVisibility('presentbutton', canWebrtc && permissions.present && !local);
setVisibility('unpresentbutton', local);
setVisibility('mutebutton', !connected || permissions.present);
// allow multiple shared documents
setVisibility('sharebutton', canWebrtc && permissions.present &&
('getDisplayMedia' in navigator.mediaDevices));
setVisibility('mediaoptions', permissions.present);
setVisibility('sendform', permissions.present);
setVisibility('simulcastform', permissions.present);
setVisibility('fileform', canFile && permissions.present);
setVisibility('collapse-video', mediacount && mobilelayout);
}
/**
* @param {boolean} mute
* @param {boolean} [reflect]
*/
function setLocalMute(mute, reflect) {
muteLocalTracks(mute);
let button = document.getElementById('mutebutton');
let icon = button.querySelector("span .fas");
if(mute){
icon.classList.add('fa-microphone-slash');
icon.classList.remove('fa-microphone');
button.classList.add('muted');
} else {
icon.classList.remove('fa-microphone-slash');
icon.classList.add('fa-microphone');
button.classList.remove('muted');
}
if(reflect)
updateSettings({localMute: mute});
}
getSelectElement('videoselect').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLSelectElement))
throw new Error('Unexpected type for this');
updateSettings({video: this.value});
replaceCameraStream();
};
getSelectElement('audioselect').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLSelectElement))
throw new Error('Unexpected type for this');
updateSettings({audio: this.value});
replaceCameraStream();
};
getInputElement('mirrorbox').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
updateSettings({mirrorView: this.checked});
// no need to reopen the camera
replaceUpStreams('camera');
};
getInputElement('blackboardbox').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
updateSettings({blackboardMode: this.checked});
replaceCameraStream();
};
document.getElementById('mutebutton').onclick = function(e) {
e.preventDefault();
let localMute = getSettings().localMute;
localMute = !localMute;
setLocalMute(localMute, true);
};
document.getElementById('sharebutton').onclick = function(e) {
e.preventDefault();
addShareMedia();
};
getSelectElement('filterselect').onchange = async function(e) {
if(!(this instanceof HTMLSelectElement))
throw new Error('Unexpected type for this');
updateSettings({filter: this.value});
let c = findUpMedia('camera');
if(c) {
let filter = (this.value && filters[this.value]) || null;
if(filter)
c.userdata.filterDefinition = filter;
else
delete c.userdata.filterDefinition;
replaceUpStream(c);
}
};
/** @returns {number} */
function getMaxVideoThroughput() {
let v = getSettings().send;
switch(v) {
case 'lowest':
return 150000;
case 'low':
return 300000;
case 'normal':
return 700000;
case 'unlimited':
return null;
default:
console.error('Unknown video quality', v);
return 700000;
}
}
getSelectElement('sendselect').onchange = async function(e) {
if(!(this instanceof HTMLSelectElement))
throw new Error('Unexpected type for this');
updateSettings({send: this.value});
await reconsiderSendParameters();
};
getSelectElement('simulcastselect').onchange = async function(e) {
if(!(this instanceof HTMLSelectElement))
throw new Error('Unexpected type for this');
updateSettings({simulcast: this.value});
await reconsiderSendParameters();
};
/**
* @param {string} what
* @returns {Object<string,Array<string>>}
*/
function mapRequest(what) {
switch(what) {
case '':
return {};
break;
case 'audio':
return {'': ['audio']};
break;
case 'screenshare-low':
return {screenshare: ['audio','video-low'], '': ['audio']};
break;
case 'screenshare':
return {screenshare: ['audio','video'], '': ['audio']};
break;
case 'everything-low':
return {'': ['audio','video-low']};
break;
case 'everything':
return {'': ['audio','video']}
break;
default:
throw new Error(`Unknown value ${what} in request`);
}
}
/**
* @param {string} what
* @param {string} label
* @returns {Array<string>}
*/
function mapRequestLabel(what, label) {
let r = mapRequest(what);
if(label in r)
return r[label];
else
return r[''];
}
getSelectElement('requestselect').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLSelectElement))
throw new Error('Unexpected type for this');
updateSettings({request: this.value});
serverConnection.request(mapRequest(this.value));
reconsiderDownRate();
};
const activityDetectionInterval = 200;
const activityDetectionPeriod = 700;
const activityDetectionThreshold = 0.2;
getInputElement('activitybox').onchange = function(e) {
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
updateSettings({activityDetection: this.checked});
for(let id in serverConnection.down) {
let c = serverConnection.down[id];
if(this.checked)
c.setStatsInterval(activityDetectionInterval);
else {
c.setStatsInterval(0);
setActive(c, false);
}
}
};
getInputElement('fileinput').onchange = function(e) {
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
let input = this;
let files = input.files;
for(let i = 0; i < files.length; i++) {
addFileMedia(files[i]).catch(e => {
console.error(e);
displayError(e);
});
}
input.value = '';
closeNav();
};
/**
* @this {Stream}
* @param {Object<string,any>} stats
*/
function gotUpStats(stats) {
let c = this;
let values = [];
for(let id in stats) {
if(stats[id] && stats[id]['outbound-rtp']) {
let rate = stats[id]['outbound-rtp'].rate;
if(typeof rate === 'number') {
values.push(rate);
}
}
}
if(values.length === 0) {
setLabel(c, '');
} else {
values.sort((x,y) => x - y);
setLabel(c, values
.map(x => Math.round(x / 1000).toString())
.reduce((x, y) => x + '+' + y));
}
}
/**
* @param {Stream} c
* @param {boolean} value
*/
function setActive(c, value) {
let peer = document.getElementById('peer-' + c.localId);
if(value)
peer.classList.add('peer-active');
else
peer.classList.remove('peer-active');
}
/**
* @this {Stream}
* @param {Object<string,any>} stats
*/
function gotDownStats(stats) {
if(!getInputElement('activitybox').checked)
return;
let c = this;
let maxEnergy = 0;
c.pc.getReceivers().forEach(r => {
let tid = r.track && r.track.id;
let s = tid && stats[tid];
let energy = s && s['track'] && s['track'].audioEnergy;
if(typeof energy === 'number')
maxEnergy = Math.max(maxEnergy, energy);
});
// totalAudioEnergy is defined as the integral of the square of the
// volume, so square the threshold.
if(maxEnergy > activityDetectionThreshold * activityDetectionThreshold) {
c.userdata.lastVoiceActivity = Date.now();
setActive(c, true);
} else {
let last = c.userdata.lastVoiceActivity;
if(!last || Date.now() - last > activityDetectionPeriod)
setActive(c, false);
}
}
/**
* @param {HTMLSelectElement} select
* @param {string} label
* @param {string} [value]
*/
function addSelectOption(select, label, value) {
if(!value)
value = label;
for(let i = 0; i < select.children.length; i++) {
let child = select.children[i];
if(!(child instanceof HTMLOptionElement)) {
console.warn('Unexpected select child');
continue;
}
if(child.value === value) {
if(child.label !== label) {
child.label = label;
}
return;
}
}
let option = document.createElement('option');
option.value = value;
option.textContent = label;
select.appendChild(option);
}
/**
* @param {HTMLSelectElement} select
* @param {string} value
*/
function selectOptionAvailable(select, value) {
let children = select.children;
for(let i = 0; i < children.length; i++) {
let child = select.children[i];
if(!(child instanceof HTMLOptionElement)) {
console.warn('Unexpected select child');
continue;
}
if(child.value === value)
return true;
}
return false;
}
/**
* @param {HTMLSelectElement} select
* @returns {string}
*/
function selectOptionDefault(select) {
/* First non-empty option. */
for(let i = 0; i < select.children.length; i++) {
let child = select.children[i];
if(!(child instanceof HTMLOptionElement)) {
console.warn('Unexpected select child');
continue;
}
if(child.value)
return child.value;
}
/* The empty option is always available. */
return '';
}
/* media names might not be available before we call getDisplayMedia. So
we call this twice, the second time to update the menu with user-readable
labels. */
/** @type {boolean} */
let mediaChoicesDone = false;
/**
* @param{boolean} done
*/
async function setMediaChoices(done) {
if(mediaChoicesDone)
return;
let devices = [];
try {
devices = await navigator.mediaDevices.enumerateDevices();
} catch(e) {
console.error(e);
return;
}
let cn = 1, mn = 1;
devices.forEach(d => {
let label = d.label;
if(d.kind === 'videoinput') {
if(!label)
label = `Camera ${cn}`;
addSelectOption(getSelectElement('videoselect'),
label, d.deviceId);
cn++;
} else if(d.kind === 'audioinput') {
if(!label)
label = `Microphone ${mn}`;
addSelectOption(getSelectElement('audioselect'),
label, d.deviceId);
mn++;
}
});
mediaChoicesDone = done;
}
/**
* @param {string} [localId]
*/
function newUpStream(localId) {
let c = serverConnection.newUpStream(localId);
c.onstatus = function(status) {
setMediaStatus(c);
};
c.onerror = function(e) {
console.error(e);
displayError(e);
};
return c;
}
/**
* Sets an up stream's video throughput and simulcast parameters.
*
* @param {Stream} c
* @param {number} bps
* @param {boolean} simulcast
*/
async function setSendParameters(c, bps, simulcast) {
if(!c.up)
throw new Error('Setting throughput of down stream');
let senders = c.pc.getSenders();
for(let i = 0; i < senders.length; i++) {
let s = senders[i];
if(!s.track || s.track.kind !== 'video')
continue;
let p = s.getParameters();
if(!p.encodings)
p.encodings = [{}];
if((!simulcast && p.encodings.length != 1) ||
(simulcast && p.encodings.length != 2)) {
// change the simulcast envelope
await replaceUpStream(c);
return;
}
p.encodings.forEach(e => {
if(!e.rid || e.rid === 'h')
e.maxBitrate = bps || unlimitedRate;
});
await s.setParameters(p);
}
}
let reconsiderParametersTimer = null;
/**
* Sets the send parameters for all up streams.
*/
async function reconsiderSendParameters() {
cancelReconsiderParameters();
let t = getMaxVideoThroughput();
let s = doSimulcast();
let promises = [];
for(let id in serverConnection.up) {
let c = serverConnection.up[id];
promises.push(setSendParameters(c, t, s));
}
await Promise.all(promises);
}
/**
* Schedules a call to reconsiderSendParameters after a delay.
* The delay avoids excessive flapping.
*/
function scheduleReconsiderParameters() {
cancelReconsiderParameters();
reconsiderParametersTimer =
setTimeout(reconsiderSendParameters, 10000 + Math.random() * 10000);
}
function cancelReconsiderParameters() {
if(reconsiderParametersTimer) {
clearTimeout(reconsiderParametersTimer);
reconsiderParametersTimer = null;
}
}
/**
* @typedef {Object} filterDefinition
* @property {string} [description]
* @property {string} [contextType]
* @property {Object} [contextAttributes]
* @property {(this: Filter, ctx: RenderingContext) => void} [init]
* @property {(this: Filter) => void} [cleanup]
* @property {(this: Filter, src: CanvasImageSource, width: number, height: number, ctx: RenderingContext) => boolean} f
*/
/**
* @param {MediaStream} stream
* @param {filterDefinition} definition
* @constructor
*/
function Filter(stream, definition) {
/** @ts-ignore */
if(!HTMLCanvasElement.prototype.captureStream) {
throw new Error('Filters are not supported on this platform');
}
/** @type {MediaStream} */
this.inputStream = stream;
/** @type {filterDefinition} */
this.definition = definition;
/** @type {number} */
this.frameRate = 30;
/** @type {HTMLVideoElement} */
this.video = document.createElement('video');
/** @type {HTMLCanvasElement} */
this.canvas = document.createElement('canvas');
/** @type {any} */
this.context = this.canvas.getContext(
definition.contextType || '2d',
definition.contextAttributes || null);
/** @type {MediaStream} */
this.captureStream = null;
/** @type {MediaStream} */
this.outputStream = null;
/** @type {number} */
this.timer = null;
/** @type {number} */
this.count = 0;
/** @type {boolean} */
this.fixedFramerate = false;
/** @type {Object} */
this.userdata = {}
/** @ts-ignore */
this.captureStream = this.canvas.captureStream(0);
/** @ts-ignore */
if(!this.captureStream.getTracks()[0].requestFrame) {
console.warn('captureFrame not supported, using fixed framerate');
/** @ts-ignore */
this.captureStream = this.canvas.captureStream(this.frameRate);
this.fixedFramerate = true;
}
this.outputStream = new MediaStream();
this.outputStream.addTrack(this.captureStream.getTracks()[0]);
this.inputStream.getTracks().forEach(t => {
t.onended = e => this.stop();
if(t.kind != 'video')
this.outputStream.addTrack(t);
});
this.video.srcObject = stream;
this.video.muted = true;
this.video.play();
if(this.definition.init)
this.definition.init.call(this, this.context);
this.timer = setInterval(() => this.draw(), 1000 / this.frameRate);
}
Filter.prototype.draw = function() {
// check framerate every 30 frames
if((this.count % 30) === 0) {
let frameRate = 0;
this.inputStream.getTracks().forEach(t => {
if(t.kind === 'video') {
let r = t.getSettings().frameRate;
if(r)
frameRate = r;
}
});
if(frameRate && frameRate != this.frameRate) {
clearInterval(this.timer);
this.timer = setInterval(() => this.draw(), 1000 / this.frameRate);
}
}
let ok = false;
try {
ok = this.definition.f.call(this, this.video,
this.video.videoWidth,
this.video.videoHeight,
this.context);
} catch(e) {
console.error(e);
}
if(ok && !this.fixedFramerate) {
/** @ts-ignore */
this.captureStream.getTracks()[0].requestFrame();
}
this.count++;
};
Filter.prototype.stop = function() {
if(!this.timer)
return;
this.captureStream.getTracks()[0].stop();
clearInterval(this.timer);
this.timer = null;
if(this.definition.cleanup)
this.definition.cleanup.call(this);
};
/**
* Removes any filter set on c.
*
* @param {Stream} c
*/
function removeFilter(c) {
let old = c.userdata.filter;
if(!old)
return;
if(!(old instanceof Filter))
throw new Error('userdata.filter is not a filter');
c.stream = old.inputStream;
old.stop();
c.userdata.filter = null;
}
/**
* Sets the filter described by c.userdata.filterDefinition on c.
*
* @param {Stream} c
*/
function setFilter(c) {
removeFilter(c);
if(!c.userdata.filterDefinition)
return;
let filter = new Filter(c.stream, c.userdata.filterDefinition);
c.stream = filter.outputStream;
c.userdata.filter = filter;
}
/**
* @type {Object.<string,filterDefinition>}
*/
let filters = {
'mirror-h': {
description: "Horizontal mirror",
f: function(src, width, height, ctx) {
if(!(ctx instanceof CanvasRenderingContext2D))
throw new Error('bad context type');
if(ctx.canvas.width !== width || ctx.canvas.height !== height) {
ctx.canvas.width = width;
ctx.canvas.height = height;
}
ctx.scale(-1, 1);
ctx.drawImage(src, -width, 0);
ctx.resetTransform();
return true;
},
},
'mirror-v': {
description: "Vertical mirror",
f: function(src, width, height, ctx) {
if(!(ctx instanceof CanvasRenderingContext2D))
throw new Error('bad context type');
if(ctx.canvas.width !== width || ctx.canvas.height !== height) {
ctx.canvas.width = width;
ctx.canvas.height = height;
}
ctx.scale(1, -1);
ctx.drawImage(src, 0, -height);
ctx.resetTransform();
return true;
},
},
};
function addFilters() {
for(let name in filters) {
let f = filters[name];
let d = f.description || name;
addSelectOption(getSelectElement('filterselect'), d, name);
}
}
function isSafari() {
let ua = navigator.userAgent.toLowerCase();
return ua.indexOf('safari') >= 0 && ua.indexOf('chrome') < 0;
}
const unlimitedRate = 1000000000;
const simulcastRate = 100000;
/**
* Decide whether we want to send simulcast.
*
* @returns {boolean}
*/
function doSimulcast() {
switch(getSettings().simulcast) {
case 'on':
return true;
case 'off':
return false;
default:
if(Object.keys(serverConnection.users).length <= 2)
return false;
let bps = getMaxVideoThroughput();
return bps <= 0 || bps >= 2 * simulcastRate;
}
}
/**
* Sets up c to send the given stream. Some extra parameters are stored
* in c.userdata.
*
* @param {Stream} c
* @param {MediaStream} stream
*/
function setUpStream(c, stream) {
if(c.stream != null)
throw new Error("Setting nonempty stream");
c.stream = stream;
try {
setFilter(c);
} catch(e) {
displayWarning("Couldn't set filter: " + e);
}
c.onclose = replace => {
removeFilter(c);
if(!replace) {
stopStream(c.stream);
if(c.userdata.onclose)
c.userdata.onclose.call(c);
delMedia(c.localId);
}
}
/**
* @param {MediaStreamTrack} t
*/
function addUpTrack(t) {
if(c.label === 'camera') {
let settings = getSettings();
if(t.kind == 'audio') {
if(settings.localMute)
t.enabled = false;
} else if(t.kind == 'video') {
if(settings.blackboardMode) {
/** @ts-ignore */
t.contentHint = 'detail';
}
}
}
t.onended = e => {
stream.onaddtrack = null;
stream.onremovetrack = null;
c.close();
};
let encodings = [];
let simulcast = doSimulcast();
if(t.kind === 'video') {
let bps = getMaxVideoThroughput();
encodings.push({
rid: 'h',
maxBitrate: bps || unlimitedRate,
});
if(simulcast)
encodings.push({
rid: 'l',
scaleResolutionDownBy: 2,
maxBitrate: simulcastRate,
});
}
let tr = c.pc.addTransceiver(t, {
direction: 'sendonly',
streams: [stream],
sendEncodings: encodings,
});
if(t.kind === 'video') {
let p = tr.sender.getParameters();
if(!p.encodings) {
// Firefox workaround
updateSettings({simulcast: 'off'});
reflectSettings();
p.encodings = [encodings[0]];
tr.sender.setParameters(p);
}
}
}
// c.stream might be different from stream if there's a filter
c.stream.getTracks().forEach(addUpTrack);
stream.onaddtrack = function(e) {
addUpTrack(e.track);
};
stream.onremovetrack = function(e) {
let t = e.track;
/** @type {RTCRtpSender} */
let sender;
c.pc.getSenders().forEach(s => {
if(s.track === t)
sender = s;
});
if(sender) {
c.pc.removeTrack(sender);
} else {
console.warn('Removing unknown track');
}
let found = false;
c.pc.getSenders().forEach(s => {
if(s.track)
found = true;
});
if(!found) {
stream.onaddtrack = null;
stream.onremovetrack = null;
c.close();
}
};
c.onstats = gotUpStats;
c.setStatsInterval(2000);
}
/**
* Replaces c with a freshly created stream, duplicating any relevant
* parameters in c.userdata.
*
* @param {Stream} c
* @returns {Promise<Stream>}
*/
async function replaceUpStream(c) {
removeFilter(c);
let cn = newUpStream(c.localId);
cn.label = c.label;
if(c.userdata.filterDefinition)
cn.userdata.filterDefinition = c.userdata.filterDefinition;
if(c.userdata.onclose)
cn.userdata.onclose = c.userdata.onclose;
let media = /** @type{HTMLVideoElement} */
(document.getElementById('media-' + c.localId));
setUpStream(cn, c.stream);
await setMedia(cn, true,
cn.label == 'camera' && getSettings().mirrorView,
cn.label == 'video' && media);
return cn;
}
/**
* Replaces all up streams with the given label. If label is null,
* replaces all up stream.
*
* @param {string} label
*/
async function replaceUpStreams(label) {
let promises = [];
for(let id in serverConnection.up) {
let c = serverConnection.up[id];
if(label && c.label !== label)
continue
promises.push(replaceUpStream(c));
}
await Promise.all(promises);
}
/**
* Closes and reopens the camera then replaces the camera stream.
*/
function replaceCameraStream() {
let c = findUpMedia('camera');
if(c)
addLocalMedia(c.localId);
}
/**
* @param {string} [localId]
*/
async function addLocalMedia(localId) {
let settings = getSettings();
let audio = settings.audio ? {deviceId: settings.audio} : false;
let video = settings.video ? {deviceId: settings.video} : false;
if(video) {
let resolution = settings.resolution;
if(resolution) {
video.width = { ideal: resolution[0] };
video.height = { ideal: resolution[1] };
} else if(settings.blackboardMode) {
video.width = { min: 640, ideal: 1920 };
video.height = { min: 400, ideal: 1080 };
}
}
let old = serverConnection.findByLocalId(localId);
if(old) {
// make sure that the camera is released before we try to reopen it
removeFilter(old);
stopStream(old.stream);
}
let constraints = {audio: audio, video: video};
/** @type {MediaStream} */
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch(e) {
displayError(e);
return;
}
setMediaChoices(true);
let c;
try {
c = newUpStream(localId);
} catch(e) {
console.log(e);
displayError(e);
return;
}
c.label = 'camera';
if(settings.filter) {
let filter = filters[settings.filter];
if(filter)
c.userdata.filterDefinition = filter;
else
displayWarning(`Unknown filter ${settings.filter}`);
}
setUpStream(c, stream);
await setMedia(c, true, settings.mirrorView);
setButtonsVisibility();
}
let safariScreenshareDone = false;
async function addShareMedia() {
/** @type {MediaStream} */
let stream = null;
try {
if(!('getDisplayMedia' in navigator.mediaDevices))
throw new Error('Your browser does not support screen sharing');
/** @ts-ignore */
stream = await navigator.mediaDevices.getDisplayMedia({video: true});
} catch(e) {
console.error(e);
displayError(e);
return;
}
if(!safariScreenshareDone) {
if(isSafari())
displayWarning('Screen sharing under Safari is experimental. ' +
'Please use a different browser if possible.');
safariScreenshareDone = true;
}
let c = newUpStream();
c.label = 'screenshare';
setUpStream(c, stream);
await setMedia(c, true);
setButtonsVisibility();
}
/**
* @param {File} file
*/
async function addFileMedia(file) {
let url = URL.createObjectURL(file);
let video = document.createElement('video');
video.src = url;
video.controls = true;
let stream;
/** @ts-ignore */
if(video.captureStream)
/** @ts-ignore */
stream = video.captureStream();
/** @ts-ignore */
else if(video.mozCaptureStream)
/** @ts-ignore */
stream = video.mozCaptureStream();
else {
displayError("This browser doesn't support file playback");
return;
}
let c = newUpStream();
c.label = 'video';
c.userdata.onclose = function() {
let media = /** @type{HTMLVideoElement} */
(document.getElementById('media-' + this.localId));
if(media && media.src) {
URL.revokeObjectURL(media.src);
media.src = null;
}
};
await setUpStream(c, stream);
let presenting = !!findUpMedia('camera');
let muted = getSettings().localMute;
if(presenting && !muted) {
setLocalMute(true, true);
displayWarning('You have been muted');
}
await setMedia(c, true, false, video);
c.userdata.play = true;
setButtonsVisibility();
}
/**
* @param {MediaStream} s
*/
function stopStream(s) {
s.getTracks().forEach(t => {
try {
t.stop();
} catch(e) {
console.warn(e);
}
});
}
/**
* closeUpMedia closes all up connections with the given label. If label
* is null, it closes all up connections.
*
* @param {string} [label]
*/
function closeUpMedia(label) {
for(let id in serverConnection.up) {
let c = serverConnection.up[id];
if(label && c.label !== label)
continue
c.close();
}
}
/**
* @param {string} label
* @returns {Stream}
*/
function findUpMedia(label) {
for(let id in serverConnection.up) {
let c = serverConnection.up[id]
if(c.label === label)
return c;
}
return null;
}
/**
* @param {boolean} mute
*/
function muteLocalTracks(mute) {
if(!serverConnection)
return;
for(let id in serverConnection.up) {
let c = serverConnection.up[id];
if(c.label === 'camera') {
let stream = c.stream;
stream.getTracks().forEach(t => {
if(t.kind === 'audio') {
t.enabled = !mute;
}
});
}
}
}
/**
* @param {string} id
* @param {boolean} force
* @param {boolean} [value]
*/
function forceDownRate(id, force, value) {
let c = serverConnection.down[id];
if(!c)
throw new Error("Unknown down stream");
if('requested' in c.userdata) {
if(force)
c.userdata.requested.force = !!value;
else
delete(c.userdata.requested.force);
} else {
if(force)
c.userdata.requested = {force: value};
}
reconsiderDownRate(id);
}
/**
* Maps 'video' to 'video-low'. Returns null if nothing changed.
*
* @param {string[]} requested
* @returns {string[]}
*/
function mapVideoToLow(requested) {
let result = [];
let found = false;
for(let i = 0; i < requested.length; i++) {
let r = requested[i];
if(r === 'video') {
r = 'video-low';
found = true;
}
result.push(r);
}
if(!found)
return null;
return result;
}
/**
* Reconsider the video track requested for a given down stream.
*
* @param {string} [id] - the id of the track to reconsider, all if null.
*/
function reconsiderDownRate(id) {
if(!serverConnection)
return;
if(!id) {
for(let id in serverConnection.down) {
reconsiderDownRate(id);
}
return;
}
let c = serverConnection.down[id];
if(!c)
throw new Error("Unknown down stream");
let normalrequest = mapRequestLabel(getSettings().request, c.label);
let requestlow = mapVideoToLow(normalrequest);
if(requestlow === null)
return;
let old = c.userdata.requested;
let low = false;
if(old && ('force' in old)) {
low = old.force;
} else {
let media = /** @type {HTMLVideoElement} */
(document.getElementById('media-' + c.localId));
if(!media)
throw new Error("No media for stream");
let w = media.scrollWidth;
let h = media.scrollHeight;
if(w && h && w * h <= 320 * 240) {
low = true;
}
}
if(low !== !!(old && old.low)) {
if('requested' in c.userdata)
c.userdata.requested.low = low;
else
c.userdata.requested = {low: low};
c.request(low ? requestlow : null);
}
}
let reconsiderDownRateTimer = null;
/**
* Schedules reconsiderDownRate() to be run later. The delay avoids too
* much recomputations when resizing the window.
*/
function scheduleReconsiderDownRate() {
if(reconsiderDownRateTimer)
return;
reconsiderDownRateTimer =
setTimeout(() => {
reconsiderDownRateTimer = null;
reconsiderDownRate();
}, 200);
}
/**
* setMedia adds a new media element corresponding to stream c.
*
* @param {Stream} c
* @param {boolean} isUp
* - indicates whether the stream goes in the up direction
* @param {boolean} [mirror]
* - whether to mirror the video
* @param {HTMLVideoElement} [video]
* - the video element to add. If null, a new element with custom
* controls will be created.
*/
async function setMedia(c, isUp, mirror, video) {
let peersdiv = document.getElementById('peers');
let div = document.getElementById('peer-' + c.localId);
if(!div) {
div = document.createElement('div');
div.id = 'peer-' + c.localId;
div.classList.add('peer');
peersdiv.appendChild(div);
}
let media = /** @type {HTMLVideoElement} */
(document.getElementById('media-' + c.localId));
if(!media) {
if(video) {
media = video;
} else {
media = document.createElement('video');
if(isUp)
media.muted = true;
}
media.classList.add('media');
media.autoplay = true;
/** @ts-ignore */
media.playsinline = true;
media.id = 'media-' + c.localId;
div.appendChild(media);
addCustomControls(media, div, c, !!video);
}
if(mirror)
media.classList.add('mirror');
else
media.classList.remove('mirror');
if(!video && media.srcObject !== c.stream)
media.srcObject = c.stream;
if(!isUp) {
media.onfullscreenchange = function(e) {
forceDownRate(c.id, document.fullscreenElement === media, false);
}
}
let label = document.getElementById('label-' + c.localId);
if(!label) {
label = document.createElement('div');
label.id = 'label-' + c.localId;
label.classList.add('label');
div.appendChild(label);
}
setLabel(c);
setMediaStatus(c);
showVideo();
resizePeers();
if(!isUp && isSafari() && !findUpMedia('camera')) {
// Safari doesn't allow autoplay unless the user has granted media access
try {
let stream = await navigator.mediaDevices.getUserMedia({audio: true});
stream.getTracks().forEach(t => t.stop());
} catch(e) {
}
}
}
/**
* resetMedia resets the source stream of the media element associated
* with c. This has the side-effect of resetting any frozen frames.
*
* @param {Stream} c
*/
function resetMedia(c) {
let media = /** @type {HTMLVideoElement} */
(document.getElementById('media-' + c.localId));
if(!media) {
console.error("Resetting unknown media element")
return;
}
media.srcObject = media.srcObject;
}
/**
* @param {Element} elt
*/
function cloneHTMLElement(elt) {
if(!(elt instanceof HTMLElement))
throw new Error('Unexpected element type');
return /** @type{HTMLElement} */(elt.cloneNode(true));
}
/**
* @param {HTMLVideoElement} media
* @param {HTMLElement} container
* @param {Stream} c
*/
function addCustomControls(media, container, c, toponly) {
if(!toponly && !document.getElementById('controls-' + c.localId)) {
media.controls = false;
let template =
document.getElementById('videocontrols-template').firstElementChild;
let controls = cloneHTMLElement(template);
controls.id = 'controls-' + c.localId;
let volume = getVideoButton(controls, 'volume');
if(c.up && c.label === 'camera') {
volume.remove();
} else {
setVolumeButton(media.muted,
getVideoButton(controls, "volume-mute"),
getVideoButton(controls, "volume-slider"));
}
container.appendChild(controls);
}
if(c.up && !document.getElementById('topcontrols-' + c.localId)) {
let toptemplate =
document.getElementById('topvideocontrols-template').firstElementChild;
let topcontrols = cloneHTMLElement(toptemplate);
topcontrols.id = 'topcontrols-' + c.localId;
container.appendChild(topcontrols);
}
registerControlHandlers(c.localId, media, container);
}
/**
* @param {HTMLElement} container
* @param {string} name
*/
function getVideoButton(container, name) {
return /** @type {HTMLElement} */(container.getElementsByClassName(name)[0]);
}
/**
* @param {boolean} muted
* @param {HTMLElement} button
* @param {HTMLElement} slider
*/
function setVolumeButton(muted, button, slider) {
if(!muted) {
button.classList.remove("fa-volume-mute");
button.classList.add("fa-volume-up");
} else {
button.classList.remove("fa-volume-up");
button.classList.add("fa-volume-mute");
}
if(!(slider instanceof HTMLInputElement))
throw new Error("Couldn't find volume slider");
slider.disabled = muted;
}
/**
* @param {string} localId
* @param {HTMLVideoElement} media
* @param {HTMLElement} container
*/
function registerControlHandlers(localId, media, container) {
let play = getVideoButton(container, 'video-play');
if(play) {
play.onclick = function(event) {
event.preventDefault();
media.play();
};
}
let stop = getVideoButton(container, 'video-stop');
if(stop) {
stop.onclick = function(event) {
event.preventDefault();
try {
let c = serverConnection.findByLocalId(localId);
if(!c)
throw new Error('Closing unknown stream');
c.close();
} catch(e) {
console.error(e);
displayError(e);
}
};
}
let volume = getVideoButton(container, 'volume');
if (volume) {
volume.onclick = function(event) {
let target = /** @type{HTMLElement} */(event.target);
if(!target.classList.contains('volume-mute'))
// if click on volume slider, do nothing
return;
event.preventDefault();
media.muted = !media.muted;
setVolumeButton(media.muted, target,
getVideoButton(volume, "volume-slider"));
};
volume.oninput = function() {
let slider = /** @type{HTMLInputElement} */
(getVideoButton(volume, "volume-slider"));
media.volume = parseInt(slider.value, 10)/100;
};
}
let pip = getVideoButton(container, 'pip');
if(pip) {
/** @ts-ignore */
if(HTMLVideoElement.prototype.requestPictureInPicture) {
pip.onclick = function(e) {
e.preventDefault();
/** @ts-ignore */
if(media.requestPictureInPicture) {
/** @ts-ignore */
media.requestPictureInPicture();
} else {
displayWarning('Picture in Picture not supported.');
}
};
} else {
pip.style.display = 'none';
}
}
let fs = getVideoButton(container, 'fullscreen');
if(fs) {
if(HTMLVideoElement.prototype.requestFullscreen ||
/** @ts-ignore */
HTMLVideoElement.prototype.webkitRequestFullscreen) {
fs.onclick = function(e) {
e.preventDefault();
if(media.requestFullscreen) {
media.requestFullscreen();
/** @ts-ignore */
} else if(media.webkitRequestFullscreen) {
/** @ts-ignore */
media.webkitRequestFullscreen();
} else {
displayWarning('Full screen not supported!');
}
};
} else {
fs.style.display = 'none';
}
}
}
/**
* @param {string} localId
*/
function delMedia(localId) {
let mediadiv = document.getElementById('peers');
let peer = document.getElementById('peer-' + localId);
if(!peer)
throw new Error('Removing unknown media');
let media = /** @type{HTMLVideoElement} */
(document.getElementById('media-' + localId));
media.srcObject = null;
mediadiv.removeChild(peer);
setButtonsVisibility();
resizePeers();
hideVideo();
}
/**
* @param {Stream} c
*/
function setMediaStatus(c) {
let state = c && c.pc && c.pc.iceConnectionState;
let good = state === 'connected' || state === 'completed';
let media = document.getElementById('media-' + c.localId);
if(!media) {
console.warn('Setting status of unknown media.');
return;
}
if(good) {
media.classList.remove('media-failed');
if(c.userdata.play) {
if(media instanceof HTMLMediaElement)
media.play().catch(e => {
console.error(e);
displayError(e);
});
delete(c.userdata.play);
}
} else {
media.classList.add('media-failed');
}
}
/**
* @param {Stream} c
* @param {string} [fallback]
*/
function setLabel(c, fallback) {
let label = document.getElementById('label-' + c.localId);
if(!label)
return;
let l = c.username;
if(l) {
label.textContent = l;
label.classList.remove('label-fallback');
} else if(fallback) {
label.textContent = fallback;
label.classList.add('label-fallback');
} else {
label.textContent = '';
label.classList.remove('label-fallback');
}
}
function resizePeers() {
// Window resize can call this method too early
if (!serverConnection)
return;
let count =
Object.keys(serverConnection.up).length +
Object.keys(serverConnection.down).length;
let peers = document.getElementById('peers');
let columns = Math.ceil(Math.sqrt(count));
if (!count)
// No video, nothing to resize.
return;
let container = document.getElementById("video-container");
// Peers div has total padding of 40px, we remove 40 on offsetHeight
// Grid has row-gap of 5px
let rows = Math.ceil(count / columns);
let margins = (rows - 1) * 5 + 40;
if (count <= 2 && container.offsetHeight > container.offsetWidth) {
peers.style['grid-template-columns'] = "repeat(1, 1fr)";
rows = count;
} else {
peers.style['grid-template-columns'] = `repeat(${columns}, 1fr)`;
}
if (count === 1)
return;
let max_video_height = (peers.offsetHeight - margins) / rows;
let media_list = peers.querySelectorAll(".media");
for(let i = 0; i < media_list.length; i++) {
let media = media_list[i];
if(!(media instanceof HTMLMediaElement)) {
console.warn('Unexpected media');
continue;
}
media.style['max-height'] = max_video_height + "px";
}
}
/**
* Lexicographic order, with case differences secondary.
* @param{string} a
* @param{string} b
*/
function stringCompare(a, b) {
let la = a.toLowerCase();
let lb = b.toLowerCase();
if(la < lb)
return -1;
else if(la > lb)
return +1;
else if(a < b)
return -1;
else if(a > b)
return +1;
return 0
}
/**
* @param {string} id
* @param {string} name
*/
function addUser(id, name) {
if(!name)
name = null;
let div = document.getElementById('users');
let user = document.createElement('div');
user.id = 'user-' + id;
user.classList.add("user-p");
user.textContent = name ? name : '(anon)';
if(name) {
let us = div.children;
for(let i = 0; i < us.length; i++) {
let child = us[i];
let childuser =
serverConnection.users[child.id.slice('user-'.length)] || null;
let childname = (childuser && childuser.username) || null;
if(!childname || stringCompare(childname, name) > 0) {
div.insertBefore(user, child);
return;
}
}
}
div.appendChild(user);
}
/**
* @param {string} id
* @param {string} name
*/
function changeUser(id, name) {
let user = document.getElementById('user-' + id);
if(!user) {
console.warn('Unknown user ' + id);
return;
}
user.textContent = name ? name : '(anon)';
}
/**
* @param {string} id
*/
function delUser(id) {
let div = document.getElementById('users');
let user = document.getElementById('user-' + id);
div.removeChild(user);
}
/**
* @param {string} id
* @param {string} kind
*/
function gotUser(id, kind) {
switch(kind) {
case 'add':
addUser(id, serverConnection.users[id].username);
if(Object.keys(serverConnection.users).length == 3)
reconsiderSendParameters();
break;
case 'delete':
delUser(id);
if(Object.keys(serverConnection.users).length < 3)
scheduleReconsiderParameters();
break;
case 'change':
changeUser(id, serverConnection.users[id].username);
break;
default:
console.warn('Unknown user kind', kind);
break;
}
}
function displayUsername() {
let userpass = getUserPass();
let text = '';
if(userpass && userpass.username)
document.getElementById('userspan').textContent = userpass.username;
if(serverConnection.permissions.op && serverConnection.permissions.present)
text = '(op, presenter)';
else if(serverConnection.permissions.op)
text = 'operator';
else if(serverConnection.permissions.present)
text = 'presenter';
document.getElementById('permspan').textContent = text;
}
let presentRequested = null;
/**
* @param {string} s
*/
function capitalise(s) {
if(s.length <= 0)
return s;
return s.charAt(0).toUpperCase() + s.slice(1);
}
/**
* @param {string} title
*/
function setTitle(title) {
function set(title) {
document.title = title;
document.getElementById('title').textContent = title;
}
if(title)
set(title);
else
set('Galène');
}
/**
* @this {ServerConnection}
* @param {string} group
* @param {Object<string,boolean>} perms
* @param {Object<string,any>} status
* @param {string} message
*/
async function gotJoined(kind, group, perms, status, message) {
let present = presentRequested;
presentRequested = null;
switch(kind) {
case 'fail':
displayError('The server said: ' + message);
this.close();
setButtonsVisibility();
return;
case 'redirect':
this.close();
document.location.href = message;
return;
case 'leave':
this.close();
setButtonsVisibility();
return;
case 'join':
case 'change':
groupStatus = status;
setTitle((status && status.displayName) || capitalise(group));
displayUsername();
setButtonsVisibility();
if(kind === 'change')
return;
break;
default:
displayError('Unknown join message');
this.close();
return;
}
let input = /** @type{HTMLTextAreaElement} */
(document.getElementById('input'));
input.placeholder = 'Type /help for help';
setTimeout(() => {input.placeholder = '';}, 8000);
if(typeof RTCPeerConnection === 'undefined')
displayWarning("This browser doesn't support WebRTC");
else
this.request(mapRequest(getSettings().request));
if(serverConnection.permissions.present && !findUpMedia('camera')) {
if(present) {
if(present === 'mike')
updateSettings({video: ''});
else if(present === 'both')
delSetting('video');
reflectSettings();
let button = getButtonElement('presentbutton');
button.disabled = true;
try {
await addLocalMedia();
} finally {
button.disabled = false;
}
} else {
displayMessage(
"Press Enable to enable your camera or microphone"
);
}
}
}
/**
* @param {string} id
* @param {string} dest
* @param {string} username
* @param {number} time
* @param {boolean} privileged
* @param {string} kind
* @param {unknown} message
*/
function gotUserMessage(id, dest, username, time, privileged, kind, message) {
switch(kind) {
case 'kicked':
case 'error':
case 'warning':
case 'info':
let from = id ? (username || 'Anonymous') : 'The Server';
if(privileged)
displayError(`${from} said: ${message}`, kind);
else
console.error(`Got unprivileged message of kind ${kind}`);
break;
case 'mute':
if(privileged) {
setLocalMute(true, true);
let by = username ? ' by ' + username : '';
displayWarning(`You have been muted${by}`);
} else {
console.error(`Got unprivileged message of kind ${kind}`);
}
break;
case 'clearchat':
if(privileged) {
clearChat();
} else {
console.error(`Got unprivileged message of kind ${kind}`);
}
break;
default:
console.warn(`Got unknown user message ${kind}`);
break;
}
};
const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g;
/**
* @param {string} line
* @returns {Array.<Text|HTMLElement>}
*/
function formatLine(line) {
let r = new RegExp(urlRegexp);
let result = [];
let pos = 0;
while(true) {
let m = r.exec(line);
if(!m)
break;
result.push(document.createTextNode(line.slice(pos, m.index)));
let a = document.createElement('a');
a.href = m[0];
a.textContent = m[0];
a.target = '_blank';
a.rel = 'noreferrer noopener';
result.push(a);
pos = m.index + m[0].length;
}
result.push(document.createTextNode(line.slice(pos)));
return result;
}
/**
* @param {string[]} lines
* @returns {HTMLElement}
*/
function formatLines(lines) {
let elts = [];
if(lines.length > 0)
elts = formatLine(lines[0]);
for(let i = 1; i < lines.length; i++) {
elts.push(document.createElement('br'));
elts = elts.concat(formatLine(lines[i]));
}
let elt = document.createElement('p');
elts.forEach(e => elt.appendChild(e));
return elt;
}
/**
* @param {number} time
* @returns {string}
*/
function formatTime(time) {
let delta = Date.now() - time;
let date = new Date(time);
let m = date.getMinutes();
if(delta > -30000)
return date.getHours() + ':' + ((m < 10) ? '0' : '') + m;
return date.toLocaleString();
}
/**
* @typedef {Object} lastMessage
* @property {string} [nick]
* @property {string} [peerId]
* @property {string} [dest]
* @property {number} [time]
*/
/** @type {lastMessage} */
let lastMessage = {};
/**
* @param {string} peerId
* @param {string} dest
* @param {string} nick
* @param {number} time
* @param {boolean} privileged
* @param {boolean} history
* @param {string} kind
* @param {unknown} message
*/
function addToChatbox(peerId, dest, nick, time, privileged, history, kind, message) {
let row = document.createElement('div');
row.classList.add('message-row');
let container = document.createElement('div');
container.classList.add('message');
row.appendChild(container);
let footer = document.createElement('p');
footer.classList.add('message-footer');
if(!peerId)
container.classList.add('message-system');
if(peerId === serverConnection.id)
container.classList.add('message-sender');
if(dest)
container.classList.add('message-private');
if(kind !== 'me') {
let p = formatLines(message.toString().split('\n'));
let doHeader = true;
if(!peerId && !dest && !nick) {
doHeader = false;
} else if(lastMessage.nick !== (nick || null) ||
lastMessage.peerId !== peerId ||
lastMessage.dest !== (dest || null) ||
!time || !lastMessage.time) {
doHeader = true;
} else {
let delta = time - lastMessage.time;
doHeader = delta < 0 || delta > 60000;
}
if(doHeader) {
let header = document.createElement('p');
if(peerId || nick || dest) {
let user = document.createElement('span');
let u = serverConnection.users[dest];
let name = (u && u.username);
user.textContent = dest ?
`${nick||'(anon)'} \u2192 ${name || '(anon)'}` :
(nick || '(anon)');
user.classList.add('message-user');
header.appendChild(user);
}
header.classList.add('message-header');
container.appendChild(header);
if(time) {
let tm = document.createElement('span');
tm.textContent = formatTime(time);
tm.classList.add('message-time');
header.appendChild(tm);
}
}
p.classList.add('message-content');
container.appendChild(p);
lastMessage.nick = (nick || null);
lastMessage.peerId = peerId;
lastMessage.dest = (dest || null);
lastMessage.time = (time || null);
} else {
let asterisk = document.createElement('span');
asterisk.textContent = '*';
asterisk.classList.add('message-me-asterisk');
let user = document.createElement('span');
user.textContent = nick || '(anon)';
user.classList.add('message-me-user');
let content = document.createElement('span');
formatLine(message.toString()).forEach(elt => {
content.appendChild(elt);
});
content.classList.add('message-me-content');
container.appendChild(asterisk);
container.appendChild(user);
container.appendChild(content);
container.classList.add('message-me');
lastMessage = {};
}
container.appendChild(footer);
let box = document.getElementById('box');
box.appendChild(row);
if(box.scrollHeight > box.clientHeight) {
box.scrollTop = box.scrollHeight - box.clientHeight;
}
return message;
}
/**
* @param {string} message
*/
function localMessage(message) {
return addToChatbox(null, null, null, Date.now(), false, false, '', message);
}
function clearChat() {
lastMessage = {};
document.getElementById('box').textContent = '';
}
/**
* A command known to the command-line parser.
*
* @typedef {Object} command
* @property {string} [parameters]
* - A user-readable list of parameters.
* @property {string} [description]
* - A user-readable description, null if undocumented.
* @property {() => string} [predicate]
* - Returns null if the command is available.
* @property {(c: string, r: string) => void} f
*/
/**
* The set of commands known to the command-line parser.
*
* @type {Object.<string,command>}
*/
let commands = {};
function operatorPredicate() {
if(serverConnection && serverConnection.permissions &&
serverConnection.permissions.op)
return null;
return 'You are not an operator';
}
function recordingPredicate() {
if(serverConnection && serverConnection.permissions &&
serverConnection.permissions.record)
return null;
return 'You are not allowed to record';
}
commands.help = {
description: 'display this help',
f: (c, r) => {
/** @type {string[]} */
let cs = [];
for(let cmd in commands) {
let c = commands[cmd];
if(!c.description)
continue;
if(c.predicate && c.predicate())
continue;
cs.push(`/${cmd}${c.parameters?' ' + c.parameters:''}: ${c.description}`);
}
localMessage(cs.sort().join('\n'));
}
};
commands.me = {
f: (c, r) => {
// handled as a special case
throw new Error("this shouldn't happen");
}
};
commands.set = {
f: (c, r) => {
if(!r) {
let settings = getSettings();
let s = "";
for(let key in settings)
s = s + `${key}: ${JSON.stringify(settings[key])}\n`;
localMessage(s);
return;
}
let p = parseCommand(r);
let value;
if(p[1]) {
value = JSON.parse(p[1]);
} else {
value = true;
}
updateSetting(p[0], value);
reflectSettings();
}
};
commands.unset = {
f: (c, r) => {
delSetting(r.trim());
return;
}
};
commands.leave = {
description: "leave group",
f: (c, r) => {
if(!serverConnection)
throw new Error('Not connected');
serverConnection.close();
}
};
commands.clear = {
predicate: operatorPredicate,
description: 'clear the chat history',
f: (c, r) => {
serverConnection.groupAction('clearchat');
}
};
commands.lock = {
predicate: operatorPredicate,
description: 'lock this group',
parameters: '[message]',
f: (c, r) => {
serverConnection.groupAction('lock', r);
}
};
commands.unlock = {
predicate: operatorPredicate,
description: 'unlock this group, revert the effect of /lock',
f: (c, r) => {
serverConnection.groupAction('unlock');
}
};
commands.record = {
predicate: recordingPredicate,
description: 'start recording',
f: (c, r) => {
serverConnection.groupAction('record');
}
};
commands.unrecord = {
predicate: recordingPredicate,
description: 'stop recording',
f: (c, r) => {
serverConnection.groupAction('unrecord');
}
};
commands.subgroups = {
predicate: operatorPredicate,
description: 'list subgroups',
f: (c, r) => {
serverConnection.groupAction('subgroups');
}
};
commands.renegotiate = {
description: 'renegotiate media streams',
f: (c, r) => {
for(let id in serverConnection.up)
serverConnection.up[id].restartIce();
for(let id in serverConnection.down)
serverConnection.down[id].restartIce();
}
};
commands.replace = {
f: (c, r) => {
replaceUpStreams(null);
}
};
/**
* parseCommand splits a string into two space-separated parts. The first
* part may be quoted and may include backslash escapes.
*
* @param {string} line
* @returns {string[]}
*/
function parseCommand(line) {
let i = 0;
while(i < line.length && line[i] === ' ')
i++;
let start = ' ';
if(i < line.length && line[i] === '"' || line[i] === "'") {
start = line[i];
i++;
}
let first = "";
while(i < line.length) {
if(line[i] === start) {
if(start !== ' ')
i++;
break;
}
if(line[i] === '\\' && i < line.length - 1)
i++;
first = first + line[i];
i++;
}
while(i < line.length && line[i] === ' ')
i++;
return [first, line.slice(i)];
}
/**
* @param {string} user
*/
function findUserId(user) {
if(user in serverConnection.users)
return user;
for(let id in serverConnection.users) {
let u = serverConnection.users[id];
if(u && u.username === user)
return id;
}
return null;
}
commands.msg = {
parameters: 'user message',
description: 'send a private message',
f: (c, r) => {
let p = parseCommand(r);
if(!p[0])
throw new Error('/msg requires parameters');
let id = findUserId(p[0]);
if(!id)
throw new Error(`Unknown user ${p[0]}`);
serverConnection.chat('', id, p[1]);
addToChatbox(serverConnection.id, id, serverConnection.username,
Date.now(), false, false, '', p[1]);
}
};
/**
@param {string} c
@param {string} r
*/
function userCommand(c, r) {
let p = parseCommand(r);
if(!p[0])
throw new Error(`/${c} requires parameters`);
let id = findUserId(p[0]);
if(!id)
throw new Error(`Unknown user ${p[0]}`);
serverConnection.userAction(c, id, p[1]);
}
function userMessage(c, r) {
let p = parseCommand(r);
if(!p[0])
throw new Error(`/${c} requires parameters`);
let id = findUserId(p[0]);
if(!id)
throw new Error(`Unknown user ${p[0]}`);
serverConnection.userMessage(c, id, p[1]);
}
commands.kick = {
parameters: 'user [message]',
description: 'kick out a user',
predicate: operatorPredicate,
f: userCommand,
};
commands.op = {
parameters: 'user',
description: 'give operator status',
predicate: operatorPredicate,
f: userCommand,
};
commands.unop = {
parameters: 'user',
description: 'revoke operator status',
predicate: operatorPredicate,
f: userCommand,
};
commands.present = {
parameters: 'user',
description: 'give user the right to present',
predicate: operatorPredicate,
f: userCommand,
};
commands.unpresent = {
parameters: 'user',
description: 'revoke the right to present',
predicate: operatorPredicate,
f: userCommand,
};
commands.mute = {
parameters: 'user',
description: 'mute a remote user',
predicate: operatorPredicate,
f: userMessage,
};
commands.muteall = {
description: 'mute all remote users',
predicate: operatorPredicate,
f: (c, r) => {
serverConnection.userMessage('mute', null, null, true);
}
}
commands.warn = {
parameters: 'user message',
description: 'send a warning to a user',
predicate: operatorPredicate,
f: (c, r) => {
userMessage('warning', r);
},
};
commands.wall = {
parameters: 'message',
description: 'send a warning to all users',
predicate: operatorPredicate,
f: (c, r) => {
if(!r)
throw new Error('empty message');
serverConnection.userMessage('warning', '', r);
},
};
commands.raise = {
description: 'raise hand',
f: (c, r) => {
serverConnection.userAction(
"setstatus", serverConnection.id, {"raisehand": true},
);
}
}
commands.unraise = {
description: 'unraise hand',
f: (c, r) => {
serverConnection.userAction(
"setstatus", serverConnection.id, {"raisehand": null},
);
}
}
/**
* Test loopback through a TURN relay.
*
* @returns {Promise<number>}
*/
async function relayTest() {
if(!serverConnection)
throw new Error('not connected');
let conf = Object.assign({}, serverConnection.rtcConfiguration);
conf.iceTransportPolicy = 'relay';
let pc1 = new RTCPeerConnection(conf);
let pc2 = new RTCPeerConnection(conf);
pc1.onicecandidate = e => {e.candidate && pc2.addIceCandidate(e.candidate);};
pc2.onicecandidate = e => {e.candidate && pc1.addIceCandidate(e.candidate);};
try {
return await new Promise(async (resolve, reject) => {
let d1 = pc1.createDataChannel('loopbackTest');
d1.onopen = e => {
d1.send(Date.now().toString());
};
let offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(pc1.localDescription);
let answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(pc2.localDescription);
pc2.ondatachannel = e => {
let d2 = e.channel;
d2.onmessage = e => {
let t = parseInt(e.data);
if(isNaN(t))
reject(new Error('corrupt data'));
else
resolve(Date.now() - t);
}
}
setTimeout(() => reject(new Error('timeout')), 5000);
})
} finally {
pc1.close();
pc2.close();
}
}
commands['relay-test'] = {
f: async (c, r) => {
localMessage('Relay test in progress...');
try {
let s = Date.now();
let rtt = await relayTest();
let e = Date.now();
localMessage(`Relay test successful in ${e-s}ms, RTT ${rtt}ms`);
} catch(e) {
localMessage(`Relay test failed: ${e}`);
}
}
}
function handleInput() {
let input = /** @type {HTMLTextAreaElement} */
(document.getElementById('input'));
let data = input.value;
input.value = '';
let message, me;
if(data === '')
return;
if(data[0] === '/') {
if(data.length > 1 && data[1] === '/') {
message = data.slice(1);
me = false;
} else {
let cmd, rest;
let space = data.indexOf(' ');
if(space < 0) {
cmd = data.slice(1);
rest = '';
} else {
cmd = data.slice(1, space);
rest = data.slice(space + 1);
}
if(cmd === 'me') {
message = rest;
me = true;
} else {
let c = commands[cmd];
if(!c) {
displayError(`Uknown command /${cmd}, type /help for help`);
return;
}
if(c.predicate) {
let s = c.predicate();
if(s) {
displayError(s);
return;
}
}
try {
c.f(cmd, rest);
} catch(e) {
console.error(e);
displayError(e);
}
return;
}
}
} else {
message = data;
me = false;
}
if(!serverConnection || !serverConnection.socket) {
displayError("Not connected.");
return;
}
try {
serverConnection.chat(me ? 'me' : '', '', message);
} catch(e) {
console.error(e);
displayError(e);
}
}
document.getElementById('inputform').onsubmit = function(e) {
e.preventDefault();
handleInput();
};
document.getElementById('input').onkeypress = function(e) {
if(e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
e.preventDefault();
handleInput();
}
};
function chatResizer(e) {
e.preventDefault();
let full_width = document.getElementById("mainrow").offsetWidth;
let left = document.getElementById("left");
let right = document.getElementById("right");
let start_x = e.clientX;
let start_width = left.offsetWidth;
function start_drag(e) {
let left_width = (start_width + e.clientX - start_x) * 100 / full_width;
// set min chat width to 300px
let min_left_width = 300 * 100 / full_width;
if (left_width < min_left_width) {
return;
}
left.style.flex = left_width.toString();
right.style.flex = (100 - left_width).toString();
}
function stop_drag(e) {
document.documentElement.removeEventListener(
'mousemove', start_drag, false,
);
document.documentElement.removeEventListener(
'mouseup', stop_drag, false,
);
}
document.documentElement.addEventListener(
'mousemove', start_drag, false,
);
document.documentElement.addEventListener(
'mouseup', stop_drag, false,
);
}
document.getElementById('resizer').addEventListener('mousedown', chatResizer, false);
/**
* @param {unknown} message
* @param {string} [level]
*/
function displayError(message, level) {
if(!level)
level = "error";
var background = 'linear-gradient(to right, #e20a0a, #df2d2d)';
var position = 'center';
var gravity = 'top';
switch(level) {
case "info":
background = 'linear-gradient(to right, #529518, #96c93d)';
position = 'right';
gravity = 'bottom';
break;
case "warning":
background = "linear-gradient(to right, #bdc511, #c2cf01)";
break;
case "kicked":
level = "error";
break;
}
/** @ts-ignore */
Toastify({
text: message,
duration: 4000,
close: true,
position: position,
gravity: gravity,
backgroundColor: background,
className: level,
}).showToast();
}
/**
* @param {unknown} message
*/
function displayWarning(message) {
return displayError(message, "warning");
}
/**
* @param {unknown} message
*/
function displayMessage(message) {
return displayError(message, "info");
}
let connecting = false;
document.getElementById('userform').onsubmit = async function(e) {
e.preventDefault();
if(connecting)
return;
connecting = true;
try {
let username = getInputElement('username').value.trim();
let password = getInputElement('password').value;
storeUserPass(username, password);
serverConnect();
} finally {
connecting = false;
}
if(getInputElement('presentboth').checked)
presentRequested = 'both';
else if(getInputElement('presentmike').checked)
presentRequested = 'mike';
else
presentRequested = null;
getInputElement('presentoff').checked = true;
};
document.getElementById('disconnectbutton').onclick = function(e) {
serverConnection.close();
closeNav();
};
function openNav() {
document.getElementById("sidebarnav").style.width = "250px";
}
function closeNav() {
document.getElementById("sidebarnav").style.width = "0";
}
document.getElementById('sidebarCollapse').onclick = function(e) {
document.getElementById("left-sidebar").classList.toggle("active");
document.getElementById("mainrow").classList.toggle("full-width-active");
};
document.getElementById('openside').onclick = function(e) {
e.preventDefault();
let sidewidth = document.getElementById("sidebarnav").style.width;
if (sidewidth !== "0px" && sidewidth !== "") {
closeNav();
return;
} else {
openNav();
}
};
document.getElementById('clodeside').onclick = function(e) {
e.preventDefault();
closeNav();
};
document.getElementById('collapse-video').onclick = function(e) {
e.preventDefault();
setVisibility('collapse-video', false);
setVisibility('show-video', true);
hideVideo(true);
};
document.getElementById('show-video').onclick = function(e) {
e.preventDefault();
setVisibility('video-container', true);
setVisibility('collapse-video', true);
setVisibility('show-video', false);
};
document.getElementById('close-chat').onclick = function(e) {
e.preventDefault();
setVisibility('left', false);
setVisibility('show-chat', true);
resizePeers();
};
document.getElementById('show-chat').onclick = function(e) {
e.preventDefault();
setVisibility('left', true);
setVisibility('show-chat', false);
resizePeers();
};
async function serverConnect() {
if(serverConnection && serverConnection.socket)
serverConnection.close();
serverConnection = new ServerConnection();
serverConnection.onconnected = gotConnected;
serverConnection.onclose = gotClose;
serverConnection.ondownstream = gotDownStream;
serverConnection.onuser = gotUser;
serverConnection.onjoined = gotJoined;
serverConnection.onchat = addToChatbox;
serverConnection.onusermessage = gotUserMessage;
let url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`;
try {
await serverConnection.connect(url);
} catch(e) {
console.error(e);
displayError(e.message ? e.message : "Couldn't connect to " + url);
}
}
async function start() {
group = decodeURIComponent(
location.pathname.replace(/^\/[a-z]*\//, '').replace(/\/$/, '')
);
/** @type {Object} */
try {
let r = await fetch(".status.json")
if(!r.ok)
throw new Error(`${r.status} ${r.statusText}`);
groupStatus = await r.json()
} catch(e) {
console.error(e);
return;
}
setTitle(groupStatus.displayName || capitalise(group));
addFilters();
setMediaChoices(false).then(e => reflectSettings());
fillLogin();
document.getElementById("login-container").classList.remove('invisible');
setViewportHeight();
}
start();