1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-12-27 17:55:47 +01:00
galene/static/galene.js
2024-12-19 23:07:58 +01:00

4317 lines
116 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 = {};
/** @type {boolean} */
let pwAuth = false;
/** @type {string} */
let token = null;
/** @type {string} */
let probingState = 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 {boolean} [displayAll]
* @property {Array.<number>} [resolution]
* @property {boolean} [mirrorView]
* @property {boolean} [blackboardMode]
* @property {string} [filter]
* @property {boolean} [preprocessing]
* @property {boolean} [hqaudio]
* @property {boolean} [forceRelay]
*/
/** @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(settings.hasOwnProperty('displayAll')) {
getInputElement('displayallbox').checked = settings.displayAll;
} else {
settings.displayAll = getInputElement('displayallbox').checked;
store = true;
}
if(settings.hasOwnProperty('preprocessing')) {
getInputElement('preprocessingbox').checked = settings.preprocessing;
} else {
settings.preprocessing = getInputElement('preprocessingbox').checked;
store = true;
}
if(settings.hasOwnProperty('hqaudio')) {
getInputElement('hqaudiobox').checked = settings.hqaudio;
} else {
settings.hqaudio = getInputElement('hqaudiobox').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 isSafari() {
let ua = navigator.userAgent.toLowerCase();
return ua.indexOf('safari') >= 0 && ua.indexOf('chrome') < 0;
}
function isFirefox() {
let ua = navigator.userAgent.toLowerCase();
return ua.indexOf('firefox') >= 0;
}
/** @type {MediaStream} */
let safariStream = null;
/**
* @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();
}
if(isSafari()) {
/* Safari doesn't allow autoplay and omits host candidates
* unless there is an open device. */
if(!safariStream) {
navigator.mediaDevices.getUserMedia({audio: true}).then(s => {
safariStream = s;
});
}
}
} else {
userbox.classList.add('invisible');
connectionbox.classList.remove('invisible');
hideVideo();
window.onresize = null;
}
}
/**
* @this {ServerConnection}
*/
async function gotConnected() {
setConnected(true);
await join();
}
/**
* @param {string} username
*/
function setChangePassword(username) {
let s = document.getElementById('chpwspan');
let a = s.children[0];
if(!(a instanceof HTMLAnchorElement))
throw new Error('Bad type for chpwspan');
if(username) {
a.href = `/change-password.html?group=${encodeURI(group)}&username=${encodeURI(username)}`;
a.target = '_blank';
s.classList.remove('invisible');
} else {
a.href = null;
s.classList.add('invisible');
}
}
async function join() {
let username = getInputElement('username').value.trim();
let credentials;
if(token) {
pwAuth = false;
credentials = {
type: 'token',
token: token,
};
switch(probingState) {
case null:
// when logging in with a token, we need to give the user
// a chance to interact with the page in order to enable
// autoplay. Probe the group first in order to determine if
// we need a username. We should really extend the protocol
// to have a simpler protocol for probing.
probingState = 'probing';
username = null;
break;
case 'need-username':
case 'success':
probingState = null;
break
default:
console.warn(`Unexpected probing state ${probingState}`);
probingState = null;
break;
}
} else {
if(probingState !== null) {
console.warn(`Unexpected probing state ${probingState}`);
probingState = null;
}
let pw = getInputElement('password').value;
getInputElement('password').value = '';
if(!groupStatus.authServer) {
pwAuth = true;
credentials = pw;
} else {
pwAuth = false;
credentials = {
type: 'authServer',
authServer: groupStatus.authServer,
location: location.href,
password: pw,
};
}
}
try {
await serverConnection.join(group, username, credentials);
} catch(e) {
console.error(e);
displayError(e);
serverConnection.close();
}
}
/**
* @this {ServerConnection}
*/
function onPeerConnection() {
if(!getSettings().forceRelay)
return null;
let old = this.rtcConfiguration;
/** @type {RTCConfiguration} */
let conf = {};
for(let key in old)
conf[key] = old[key];
conf.iceTransportPolicy = 'relay';
return conf;
}
/**
* @this {ServerConnection}
* @param {number} code
* @param {string} reason
*/
function gotClose(code, reason) {
closeUpMedia();
setConnected(false);
if(code != 1000) {
console.warn('Socket close', code, reason);
}
let form = document.getElementById('loginform');
if(!(form instanceof HTMLFormElement))
throw new Error('Bad type for loginform');
form.active = true;
}
/**
* @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.toString());
};
c.ondowntrack = function(track, transceiver, stream) {
setMedia(c);
};
c.onnegotiationcompleted = function() {
resetMedia(c);
}
c.onstatus = function(status) {
setMediaStatus(c);
};
c.onstats = gotDownStats;
if(getSettings().activityDetection)
c.setStatsInterval(activityDetectionInterval);
setMedia(c);
}
// 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 canWebrtc = !(typeof RTCPeerConnection === 'undefined');
let canPresent = canWebrtc &&
('mediaDevices' in navigator) &&
('getUserMedia' in navigator.mediaDevices) &&
permissions.indexOf('present') >= 0;
let canShare = canWebrtc &&
('mediaDevices' in navigator) &&
('getDisplayMedia' in navigator.mediaDevices) &&
permissions.indexOf('present') >= 0;
let local = !!findUpMedia('camera');
let mediacount = document.getElementById('peers').childElementCount;
let mobilelayout = isMobileLayout();
// don't allow multiple presentations
setVisibility('presentbutton', canPresent && !local);
setVisibility('unpresentbutton', local);
setVisibility('mutebutton', !connected || canPresent);
// allow multiple shared documents
setVisibility('sharebutton', canShare);
setVisibility('mediaoptions', canPresent);
setVisibility('sendform', canPresent);
setVisibility('simulcastform', canPresent);
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();
};
getInputElement('preprocessingbox').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
updateSettings({preprocessing: this.checked});
replaceCameraStream();
};
getInputElement('hqaudiobox').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
updateSettings({hqaudio: 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':
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('displayallbox').onchange = function(e) {
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
updateSettings({displayAll: this.checked});
for(let id in serverConnection.down) {
let c = serverConnection.down[id];
let elt = document.getElementById('peer-' + c.localId);
showHideMedia(c, elt);
}
};
/**
* @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['inbound-rtp'] && s['inbound-rtp'].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 {
if('mediaDevices' in navigator)
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');
if(c.label === 'screenshare')
simulcast = false;
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 ||
!simulcast && p.encodings.length != 1) ||
(simulcast && p.encodings.length != 2)) {
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 {(this: filterDefinition) => Promise<boolean>} [predicate]
* @property {(this: Filter) => Promise<void>} [init]
* @property {(this: Filter) => Promise<void>} [cleanup]
* @property {(this: Filter, src: HTMLVideoElement, ctx: CanvasRenderingContext2D) => Promise<boolean>} draw
*/
/**
* @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('2d');
/** @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 = {}
/** @type {MediaStream} */
this.captureStream = this.canvas.captureStream(0);
/** @type {boolean} */
this.busy = false;
}
Filter.prototype.start = async function() {
/** @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 = this.inputStream;
this.video.muted = true;
this.video.play();
if(this.definition.init)
await this.definition.init.call(this);
this.timer = setInterval(() => this.draw(), 1000 / this.frameRate);
}
Filter.prototype.draw = async function() {
if(this.video.videoWidth === 0 && this.video.videoHeight === 0) {
// video not started yet
return;
}
// 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);
}
}
if(this.busy) {
// drop frame
return;
}
try {
this.busy = true;
let ok = false;
try {
ok = await this.definition.draw.call(
this, this.video, this.context,
);
} catch(e) {
console.error(e);
}
if(ok && !this.fixedFramerate) {
/** @ts-ignore */
this.captureStream.getTracks()[0].requestFrame();
}
this.count++;
} finally {
this.busy = false;
}
};
Filter.prototype.stop = async function() {
if(!this.timer)
return;
this.captureStream.getTracks()[0].stop();
clearInterval(this.timer);
this.timer = null;
if(this.definition.cleanup)
await this.definition.cleanup.call(this);
};
/**
* Removes any filter set on c.
*
* @param {Stream} c
*/
async 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.setStream(old.inputStream);
await old.stop();
c.userdata.filter = null;
}
/**
* Sets the filter described by c.userdata.filterDefinition on c.
*
* @param {Stream} c
*/
async function setFilter(c) {
await removeFilter(c);
if(!c.userdata.filterDefinition)
return;
let filter = new Filter(c.stream, c.userdata.filterDefinition);
await filter.start();
c.setStream(filter.outputStream);
c.userdata.filter = filter;
}
/**
* Sends a message to a worker, then waits for a reply.
*
* @param {Worker} worker
* @param {any} message
* @param {any[]} [transfer]
*/
async function workerSendReceive(worker, message, transfer) {
let p = new Promise((resolve, reject) => {
worker.onmessage = e => {
if(e && e.data) {
if(e.data instanceof Error)
reject(e.data);
else
resolve(e.data);
} else {
resolve(null);
}
};
});
worker.postMessage(message, transfer);
return await p
}
/**
* @type {Object.<string,filterDefinition>}
*/
let filters = {
'mirror-h': {
description: "Horizontal mirror",
draw: async function(src, ctx) {
if(!(ctx instanceof CanvasRenderingContext2D))
throw new Error('bad context type');
if(ctx.canvas.width !== src.videoWidth ||
ctx.canvas.height !== src.videoHeight) {
ctx.canvas.width = src.videoWidth;
ctx.canvas.height = src.videoHeight;
}
ctx.scale(-1, 1);
ctx.drawImage(src, -src.videoWidth, 0);
ctx.resetTransform();
return true;
},
},
'mirror-v': {
description: "Vertical mirror",
draw: async function(src, ctx) {
if(!(ctx instanceof CanvasRenderingContext2D))
throw new Error('bad context type');
if(ctx.canvas.width !== src.videoWidth ||
ctx.canvas.height !== src.videoHeight) {
ctx.canvas.width = src.videoWidth;
ctx.canvas.height = src.videoHeight;
}
ctx.scale(1, -1);
ctx.drawImage(src, 0, -src.videoHeight);
ctx.resetTransform();
return true;
},
},
'background-blur': {
description: 'Background blur',
predicate: async function() {
if(isSafari()) {
console.warn(
'Background blur does not work on Safari, disabled.'
);
return false;
}
let r = await fetch('/third-party/tasks-vision/vision_bundle.mjs', {
method: 'HEAD',
});
if(!r.ok) {
if(r.status !== 404)
console.warn(
`Fetch vision_bundle.mjs: ${r.status} ${r.statusText}`,
);
return false;
}
return true;
},
init: async function(ctx) {
if(!(this instanceof Filter))
throw new Error('Bad type for this');
if(this.userdata.worker)
throw new Error("Worker already running (this shouldn't happen)")
this.userdata.worker = new Worker('/background-blur-worker.js');
await workerSendReceive(this.userdata.worker, {
model: '/third-party/tasks-vision/models/selfie_segmenter.tflite',
});
},
cleanup: async function() {
if(this.userdata.worker.onmessage) {
this.userdata.worker.onmessage(null);
}
this.userdata.worker.terminate();
this.userdata.worker = null;
},
draw: async function(src, ctx) {
let bitmap = await createImageBitmap(src);
try {
let result = await workerSendReceive(this.userdata.worker, {
bitmap: bitmap,
timestamp: performance.now(),
}, [bitmap]);
if(!result)
return false;
let mask = result.mask;
bitmap = result.bitmap;
if(ctx.canvas.width !== src.videoWidth ||
ctx.canvas.height !== src.videoHeight) {
ctx.canvas.width = src.videoWidth;
ctx.canvas.height = src.videoHeight;
}
// set the alpha mask, background is opaque
ctx.globalCompositeOperation = 'copy';
ctx.filter = 'none';
ctx.drawImage(mask, 0, 0);
// rather than blurring the original image, we first mask
// the background then blur, this avoids a halo effect
ctx.globalCompositeOperation = 'source-in';
ctx.filter = 'none';
ctx.drawImage(result.bitmap, 0, 0);
ctx.globalCompositeOperation = 'copy';
ctx.filter = `blur(${src.videoWidth / 48}px)`;
ctx.drawImage(ctx.canvas, 0, 0);
// now draw the foreground
ctx.globalCompositeOperation = 'destination-atop';
ctx.filter = 'none';
ctx.drawImage(result.bitmap, 0, 0);
ctx.globalCompositeOperation = 'source-over';
mask.close();
} finally {
bitmap.close();
}
return true;
},
},
};
async function addFilters() {
for(let name in filters) {
let f = filters[name];
if(f.predicate) {
if(!(await f.predicate.call(f)))
continue;
}
let d = f.description || name;
addSelectOption(getSelectElement('filterselect'), d, name);
}
}
const unlimitedRate = 1000000000;
const simulcastRate = 100000;
const hqAudioRate = 128000;
/**
* Decide whether we want to send simulcast.
*
* @returns {boolean}
*/
function doSimulcast() {
switch(getSettings().simulcast) {
case 'on':
return true;
case 'off':
return false;
default:
let count = 0;
for(let n in serverConnection.users) {
if(!serverConnection.users[n].permissions["system"]) {
count++;
if(count > 2)
break;
}
}
if(count <= 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
*/
async function setUpStream(c, stream) {
if(c.stream != null)
throw new Error("Setting nonempty stream");
c.setStream(stream);
// set up the handler early, in case setFilter fails.
c.onclose = async replace => {
await removeFilter(c);
if(!replace) {
stopStream(c.stream);
if(c.userdata.onclose)
c.userdata.onclose.call(c);
delMedia(c.localId);
}
}
await setFilter(c);
/**
* @param {MediaStreamTrack} t
*/
function addUpTrack(t) {
let settings = getSettings();
if(c.label === 'camera') {
if(t.kind == 'audio') {
if(settings.localMute)
t.enabled = false;
} else if(t.kind == 'video') {
if(settings.blackboardMode) {
t.contentHint = 'detail';
}
}
}
t.onended = e => {
stream.onaddtrack = null;
stream.onremovetrack = null;
c.close();
};
let encodings = [];
let simulcast = c.label !== 'screenshare' && doSimulcast();
if(t.kind === 'video') {
let bps = getMaxVideoThroughput();
// Firefox doesn't like us setting the RID if we're not
// simulcasting.
if(simulcast) {
encodings.push({
rid: 'h',
maxBitrate: bps || unlimitedRate,
});
encodings.push({
rid: 'l',
scaleResolutionDownBy: 2,
maxBitrate: simulcastRate,
});
} else {
encodings.push({
maxBitrate: bps || unlimitedRate,
});
}
} else {
if(settings.hqaudio) {
encodings.push({
maxBitrate: hqAudioRate,
});
}
}
let tr = c.pc.addTransceiver(t, {
direction: 'sendonly',
streams: [stream],
sendEncodings: encodings,
});
// Firefox before 110 does not implement sendEncodings, and
// requires this hack, which throws an exception on Chromium.
try {
let p = tr.sender.getParameters();
if(!p.encodings) {
p.encodings = encodings;
tr.sender.setParameters(p);
}
} catch(e) {
}
}
// 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) {
await 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));
try {
await setUpStream(cn, c.stream);
} catch(e) {
console.error(e);
displayError(e);
cn.close();
c.close();
return null;
}
await setMedia(cn,
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();
/** @type{boolean|MediaTrackConstraints} */
let audio = settings.audio ? {deviceId: settings.audio} : false;
/** @type{boolean|MediaTrackConstraints} */
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 };
} else {
video.aspectRatio = { ideal: 4/3 };
}
}
if(audio) {
if(!settings.preprocessing) {
audio.echoCancellation = false;
audio.noiseSuppression = false;
audio.autoGainControl = false;
}
}
let old = serverConnection.findByLocalId(localId);
if(old) {
// make sure that the camera is released before we try to reopen it
await 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}`);
}
try {
await setUpStream(c, stream);
await setMedia(c, settings.mirrorView);
} catch(e) {
console.error(e);
displayError(e);
c.close();
}
setButtonsVisibility();
}
let safariScreenshareDone = false;
async function addShareMedia() {
if(!safariScreenshareDone) {
if(isSafari()) {
let ok = confirm(
'Screen sharing in Safari is broken. ' +
'It will work at first, ' +
'but then your video will randomly freeze. ' +
'Are you sure that you wish to enable screensharing?'
);
if(!ok)
return
}
safariScreenshareDone = true;
}
/** @type {MediaStream} */
let stream = null;
try {
if(!('getDisplayMedia' in navigator.mediaDevices))
throw new Error('Your browser does not support screen sharing');
stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});
} catch(e) {
console.error(e);
displayError(e);
return;
}
let c = newUpStream();
c.label = 'screenshare';
await setUpStream(c, stream);
await setMedia(c);
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, 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) {
if(!serverConnection)
return null;
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} [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, mirror, video) {
let div = document.getElementById('peer-' + c.localId);
if(!div) {
div = document.createElement('div');
div.id = 'peer-' + c.localId;
div.classList.add('peer');
let peersdiv = document.getElementById('peers');
peersdiv.appendChild(div);
}
showHideMedia(c, div)
let media = /** @type {HTMLVideoElement} */
(document.getElementById('media-' + c.localId));
if(!media) {
if(video) {
media = video;
} else {
media = document.createElement('video');
if(c.up)
media.muted = true;
}
media.classList.add('media');
media.autoplay = true;
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(!c.up) {
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();
}
/**
* @param {Stream} c
* @param {HTMLElement} elt
*/
function showHideMedia(c, elt) {
let display = c.up || getSettings().displayAll;
if(!display && c.stream) {
let tracks = c.stream.getTracks();
for(let i = 0; i < tracks.length; i++) {
let t = tracks[i];
if(t.kind === 'video') {
display = true;
break;
}
}
}
if(display)
elt.classList.remove('peer-hidden');
else
elt.classList.add('peer-hidden');
}
/**
* 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) {
if(HTMLVideoElement.prototype.requestPictureInPicture) {
pip.onclick = function(e) {
e.preventDefault();
if(media.requestPictureInPicture) {
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');
}
if(!c.up && status === 'failed') {
let from = c.username ?
`from user ${c.username}` :
'from anonymous user';
displayWarning(`Cannot receive media ${from}, still trying...`);
}
}
/**
* @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} v
*/
function dateFromInput(v) {
let d = new Date(v);
if(d.toString() === 'Invalid Date')
throw new Error('Invalid date');
return d;
}
/**
* @param {Date} d
*/
function dateToInput(d) {
let dd = new Date(d);
dd.setMinutes(dd.getMinutes() - dd.getTimezoneOffset());
return dd.toISOString().slice(0, -1);
}
function inviteMenu() {
let d = /** @type {HTMLDialogElement} */
(document.getElementById('invite-dialog'));
if(!('HTMLDialogElement' in window) || !d.showModal) {
displayError("This browser doesn't support modal dialogs");
return;
}
d.returnValue = '';
let c = getButtonElement('invite-cancel');
c.onclick = function(e) { d.close('cancel'); };
let u = getInputElement('invite-username');
u.value = '';
let now = new Date();
now.setMilliseconds(0);
now.setSeconds(0);
let nb = getInputElement('invite-not-before');
nb.min = dateToInput(now);
let ex = getInputElement('invite-expires');
let expires = new Date(now);
expires.setDate(expires.getDate() + 2);
ex.min = dateToInput(now);
ex.value = dateToInput(expires);
d.showModal();
}
document.getElementById('invite-dialog').onclose = function(e) {
if(!(this instanceof HTMLDialogElement))
throw new Error('Unexpected type for this');
let dialog = /** @type {HTMLDialogElement} */(this);
if(dialog.returnValue !== 'invite')
return;
let u = getInputElement('invite-username');
let username = u.value.trim() || null;
let nb = getInputElement('invite-not-before');
let notBefore = null;
if(nb.value) {
try {
notBefore = dateFromInput(nb.value);
} catch(e) {
displayError(`Couldn't parse ${nb.value}: ${e}`);
return;
}
}
let ex = getInputElement('invite-expires');
let expires = null;
if(ex.value) {
try {
expires = dateFromInput(ex.value);
} catch(e) {
displayError(`Couldn't parse ${nb.value}: ${e}`);
return;
}
}
let template = {}
if(username)
template.username = username;
if(notBefore)
template['not-before'] = notBefore;
if(expires)
template.expires = expires;
makeToken(template);
};
/**
* @param {HTMLElement} elt
*/
function userMenu(elt) {
if(!elt.id.startsWith('user-'))
throw new Error('Unexpected id for user menu');
let id = elt.id.slice('user-'.length);
let user = serverConnection.users[id];
if(!user)
throw new Error("Couldn't find user")
let items = [];
if(id === serverConnection.id) {
let mydata = serverConnection.users[serverConnection.id].data;
if(mydata['raisehand'])
items.push({label: 'Unraise hand', onClick: () => {
serverConnection.userAction(
'setdata', serverConnection.id, {'raisehand': null},
);
}});
else
items.push({label: 'Raise hand', onClick: () => {
serverConnection.userAction(
'setdata', serverConnection.id, {'raisehand': true},
);
}});
if(serverConnection.version !== "1" &&
serverConnection.permissions.indexOf('token') >= 0) {
items.push({label: 'Invite user', onClick: () => {
inviteMenu();
}});
}
if(serverConnection.permissions.indexOf('present') >= 0 && canFile())
items.push({label: 'Broadcast file', onClick: presentFile});
items.push({label: 'Restart media', onClick: renegotiateStreams});
} else {
items.push({label: 'Send file', onClick: () => {
sendFile(id);
}});
if(serverConnection.permissions.indexOf('op') >= 0) {
items.push({type: 'seperator'}); // sic
if(user.permissions.indexOf('present') >= 0)
items.push({label: 'Forbid presenting', onClick: () => {
serverConnection.userAction('unpresent', id);
}});
else
items.push({label: 'Allow presenting', onClick: () => {
serverConnection.userAction('present', id);
}});
items.push({label: 'Mute', onClick: () => {
serverConnection.userMessage('mute', id);
}});
items.push({label: 'Kick out', onClick: () => {
serverConnection.userAction('kick', id);
}});
items.push({label: 'Identify', onClick: () => {
serverConnection.userAction('identify', id);
}});
}
}
/** @ts-ignore */
new Contextual({
items: items,
});
}
/**
* @param {string} id
* @param {user} userinfo
*/
function addUser(id, userinfo) {
let div = document.getElementById('users');
let user = document.createElement('div');
user.id = 'user-' + id;
user.classList.add("user-p");
setUserStatus(id, user, userinfo);
user.addEventListener('click', function(e) {
let elt = e.target;
if(!elt || !(elt instanceof HTMLElement))
throw new Error("Couldn't find user div");
userMenu(elt);
});
let us = div.children;
if(id === serverConnection.id) {
if(us.length === 0)
div.appendChild(user);
else
div.insertBefore(user, us[0]);
return;
}
if(userinfo.username) {
for(let i = 0; i < us.length; i++) {
let child = us[i];
let childid = child.id.slice('user-'.length);
if(childid === serverConnection.id)
continue;
let childuser = serverConnection.users[childid] || null;
let childname = (childuser && childuser.username) || null;
if(!childname || stringCompare(childname, userinfo.username) > 0) {
div.insertBefore(user, child);
return;
}
}
}
div.appendChild(user);
}
/**
* @param {string} id
* @param {user} userinfo
*/
function changeUser(id, userinfo) {
let elt = document.getElementById('user-' + id);
if(!elt) {
console.warn('Unknown user ' + id);
return;
}
setUserStatus(id, elt, userinfo);
}
/**
* @param {string} id
* @param {HTMLElement} elt
* @param {user} userinfo
*/
function setUserStatus(id, elt, userinfo) {
elt.textContent = userinfo.username ? userinfo.username : '(anon)';
if(userinfo.data.raisehand)
elt.classList.add('user-status-raisehand');
else
elt.classList.remove('user-status-raisehand');
let microphone=false, camera = false;
for(let label in userinfo.streams) {
for(let kind in userinfo.streams[label]) {
if(kind == 'audio')
microphone = true;
else
camera = true;
}
}
if(camera) {
elt.classList.remove('user-status-microphone');
elt.classList.add('user-status-camera');
} else if(microphone) {
elt.classList.add('user-status-microphone');
elt.classList.remove('user-status-camera');
} else {
elt.classList.remove('user-status-microphone');
elt.classList.remove('user-status-camera');
}
}
/**
* @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]);
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]);
break;
default:
console.warn('Unknown user kind', kind);
break;
}
}
function displayUsername() {
document.getElementById('userspan').textContent = serverConnection.username;
let op = serverConnection.permissions.indexOf('op') >= 0;
let present = serverConnection.permissions.indexOf('present') >= 0;
let text = '';
if(op && present)
text = '(op, presenter)';
else if(op)
text = 'operator';
else if(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 {Array<string>} perms
* @param {Object<string,any>} status
* @param {Object<string,any>} data
* @param {string} error
* @param {string} message
*/
async function gotJoined(kind, group, perms, status, data, error, message) {
let present = presentRequested;
presentRequested = null;
switch(kind) {
case 'fail':
if(probingState === 'probing' && error === 'need-username') {
probingState = 'need-username';
setVisibility('passwordform', false);
} else {
token = null;
displayError('The server said: ' + message);
}
this.close();
setButtonsVisibility();
return;
case 'redirect':
this.close();
token = null;
document.location.href = message;
return;
case 'leave':
this.close();
setButtonsVisibility();
setChangePassword(null);
return;
case 'join':
case 'change':
if(probingState === 'probing') {
probingState = 'success';
setVisibility('userform', false);
setVisibility('passwordform', false);
this.close();
setButtonsVisibility();
return;
} else {
token = null;
}
// don't discard endPoint and friends
for(let key in status)
groupStatus[key] = status[key];
setTitle((status && status.displayName) || capitalise(group));
displayUsername();
setButtonsVisibility();
setChangePassword(pwAuth && !!groupStatus.canChangePassword &&
serverConnection.username
);
if(kind === 'change')
return;
break;
default:
token = null;
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(status.locked)
displayWarning('This group is locked');
if(typeof RTCPeerConnection === 'undefined')
displayWarning("This browser doesn't support WebRTC");
else
this.request(mapRequest(getSettings().request));
if(('mediaDevices' in navigator) &&
('getUserMedia' in navigator.mediaDevices) &&
serverConnection.permissions.indexOf('present') >= 0 &&
!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 {TransferredFile} f
*/
function gotFileTransfer(f) {
f.onevent = gotFileTransferEvent;
let p = document.createElement('p');
if(f.up)
p.textContent =
`We have offered to send a file called "${f.name}" ` +
`to user ${f.username}.`;
else
p.textContent =
`User ${f.username} offered to send us a file ` +
`called "${f.name}" of size ${f.size}.`
let bno = null, byes = null;
if(!f.up) {
byes = document.createElement('button');
byes.textContent = 'Accept';
byes.onclick = function(e) {
f.receive();
};
byes.id = "byes-" + f.fullid();
}
bno = document.createElement('button');
bno.textContent = f.up ? 'Cancel' : 'Reject';
bno.onclick = function(e) {
f.cancel();
};
bno.id = "bno-" + f.fullid();
let status = document.createElement('span');
status.id = 'status-' + f.fullid();
if(!f.up) {
status.textContent =
'(Choosing "Accept" will disclose your IP address.)';
}
let statusp = document.createElement('p');
statusp.id = 'statusp-' + f.fullid();
statusp.appendChild(status);
let div = document.createElement('div');
div.id = 'file-' + f.fullid();
div.appendChild(p);
if(byes)
div.appendChild(byes);
if(bno)
div.appendChild(bno);
div.appendChild(statusp);
div.classList.add('message');
div.classList.add('message-private');
div.classList.add('message-row');
let box = document.getElementById('box');
box.appendChild(div);
return div;
}
/**
* @param {TransferredFile} f
* @param {string} status
* @param {number} [value]
*/
function setFileStatus(f, status, value) {
let statuselt = document.getElementById('status-' + f.fullid());
if(!statuselt)
throw new Error("Couldn't find statusp");
statuselt.textContent = status;
if(value) {
let progress = document.getElementById('progress-' + f.fullid());
if(!progress || !(progress instanceof HTMLProgressElement))
throw new Error("Couldn't find progress element");
progress.value = value;
let label = document.getElementById('progresstext-' + f.fullid());
let percent = Math.round(100 * value / progress.max);
label.textContent = `${percent}%`;
}
}
/**
* @param {TransferredFile} f
* @param {number} [max]
*/
function createFileProgress(f, max) {
let statusp = document.getElementById('statusp-' + f.fullid());
if(!statusp)
throw new Error("Couldn't find status div");
/** @type HTMLProgressElement */
let progress = document.createElement('progress');
progress.id = 'progress-' + f.fullid();
progress.classList.add('file-progress');
progress.max = max;
progress.value = 0;
statusp.appendChild(progress);
let progresstext = document.createElement('span');
progresstext.id = 'progresstext-' + f.fullid();
progresstext.textContent = '0%';
statusp.appendChild(progresstext);
}
/**
* @param {TransferredFile} f
* @param {boolean} delyes
* @param {boolean} delno
* @param {boolean} [delprogress]
*/
function delFileStatusButtons(f, delyes, delno, delprogress) {
let div = document.getElementById('file-' + f.fullid());
if(!div)
throw new Error("Couldn't find file div");
if(delyes) {
let byes = document.getElementById('byes-' + f.fullid())
if(byes)
div.removeChild(byes);
}
if(delno) {
let bno = document.getElementById('bno-' + f.fullid())
if(bno)
div.removeChild(bno);
}
if(delprogress) {
let statusp = document.getElementById('statusp-' + f.fullid());
let progress = document.getElementById('progress-' + f.fullid());
let progresstext =
document.getElementById('progresstext-' + f.fullid());
if(progress)
statusp.removeChild(progress);
if(progresstext)
statusp.removeChild(progresstext);
}
}
/**
* @this {TransferredFile}
* @param {string} state
* @param {any} [data]
*/
function gotFileTransferEvent(state, data) {
let f = this;
switch(state) {
case 'inviting':
break;
case 'connecting':
delFileStatusButtons(f, true, false);
setFileStatus(f, 'Connecting...');
createFileProgress(f, f.size);
break;
case 'connected':
setFileStatus(f, f.up ? 'Sending...' : 'Receiving...', f.datalen);
break;
case 'done':
delFileStatusButtons(f, true, true, true);
setFileStatus(f, 'Done.');
if(!f.up) {
let url = URL.createObjectURL(data);
let a = document.createElement('a');
a.href = url;
a.textContent = f.name;
a.download = f.name;
a.type = f.mimetype;
a.click();
URL.revokeObjectURL(url);
}
break;
case 'cancelled':
delFileStatusButtons(f, true, true, true);
if(data)
setFileStatus(f, `Cancelled: ${data.toString()}.`);
else
setFileStatus(f, 'Cancelled.');
break;
case 'closed':
break;
default:
console.error(`Unexpected state "${state}"`);
f.cancel(`unexpected state "${state}" (this shouldn't happen)`);
break;
}
}
/**
* @param {string} id
* @param {string} dest
* @param {string} username
* @param {Date} time
* @param {boolean} privileged
* @param {string} kind
* @param {string} error
* @param {any} message
*/
function gotUserMessage(id, dest, username, time, privileged, kind, error, message) {
switch(kind) {
case 'kicked':
case 'error':
case 'warning':
case 'info':
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
let from = id ? (username || 'Anonymous') : 'The Server';
displayError(`${from} said: ${message}`, kind);
break;
case 'mute':
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
setLocalMute(true, true);
let by = username ? ' by ' + username : '';
displayWarning(`You have been muted${by}`);
break;
case 'clearchat': {
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
let id = message && message.id;
let userId = message && message.userId;
clearChat(id, userId);
break;
}
case 'token':
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
if(error) {
displayError(`Token operation failed: ${message}`)
return
}
if(typeof message != 'object') {
displayError('Unexpected type for token');
return;
}
let f = formatToken(message, false);
localMessage(f[0] + ': ' + f[1]);
if('share' in navigator) {
try {
navigator.share({
title: `Invitation to Galene group ${message.group}`,
text: f[0],
url: f[1],
});
} catch(e) {
console.warn("Share failed", e);
}
}
break;
case 'tokenlist':
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
if(error) {
displayError(`Token operation failed: ${message}`)
return
}
let s = '';
for(let i = 0; i < message.length; i++) {
let f = formatToken(message[i], true);
s = s + f[0] + ': ' + f[1] + "\n";
}
localMessage(s);
break;
case 'userinfo':
if(!privileged) {
console.error(`Got unprivileged message of kind ${kind}`);
return;
}
let u = message.username ?
'username ' + message.username :
'unknown username';
let a = message.address ?
'address ' + message.address :
'unknown address';
localMessage(`User ${message.id} has ${u} and ${a}.`);
break;
default:
console.warn(`Got unknown user message ${kind}`);
break;
}
};
/**
* @param {Object} token
* @param {boolean} [details]
*/
function formatToken(token, details) {
let url = new URL(window.location.href);
let params = new URLSearchParams();
params.append('token', token.token);
url.search = params.toString();
let foruser = '', by = '', togroup = '';
if(token.username)
foruser = ` for user ${token.username}`;
if(details) {
if(token.issuedBy)
by = ' issued by ' + token.issuedBy;
if(token.issuedAt) {
if(by === '')
by = ' issued at ' + token.issuedAt;
else
by = by + ' at ' + (new Date(token.issuedAt)).toLocaleString();
}
} else {
if(token.group)
togroup = ' to group ' + token.group;
}
let since = '';
if(token["not-before"])
since = ` since ${(new Date(token['not-before'])).toLocaleString()}`
/** @type{Date} */
let expires = null;
let until = '';
if(token.expires) {
expires = new Date(token.expires)
until = ` until ${expires.toLocaleString()}`;
}
return [
(expires && (expires >= new Date())) ?
`Invitation${foruser}${togroup}${by} valid${since}${until}` :
`Expired invitation${foruser}${togroup}${by}`,
url.toString(),
];
}
const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g;
/**
* @param {string} text
* @returns {HTMLDivElement}
*/
function formatText(text) {
let r = new RegExp(urlRegexp);
let result = [];
let pos = 0;
while(true) {
let m = r.exec(text);
if(!m)
break;
result.push(document.createTextNode(text.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(text.slice(pos)));
let div = document.createElement('div');
result.forEach(e => {
div.appendChild(e);
});
return div;
}
/**
* @param {Date} time
* @returns {string}
*/
function formatTime(time) {
let delta = Date.now() - time.getTime();
let m = time.getMinutes();
if(delta > -30000)
return time.getHours() + ':' + ((m < 10) ? '0' : '') + m;
return time.toLocaleString();
}
/**
* @typedef {Object} lastMessage
* @property {string} [nick]
* @property {string} [peerId]
* @property {string} [dest]
* @property {Date} [time]
*/
/** @type {lastMessage} */
let lastMessage = {};
/**
* @param {string} peerId
* @param {string} dest
* @param {string} nick
* @param {Date} time
* @param {boolean} privileged
* @param {boolean} history
* @param {string} kind
* @param {string|HTMLElement} message
*/
function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, message) {
if(kind === 'caption') {
displayCaption(message);
return;
}
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(serverConnection && peerId === serverConnection.id)
container.classList.add('message-sender');
if(dest)
container.classList.add('message-private');
if(id)
container.dataset.id = id;
if(peerId) {
container.dataset.peerId = peerId;
container.dataset.username = nick;
container.addEventListener('click', function(e) {
if(e.detail !== 2)
return;
let elt = e.currentTarget;
if(!elt || !(elt instanceof HTMLElement))
throw new Error("Couldn't find chat message div");
chatMessageMenu(elt);
});
}
/** @type{HTMLElement} */
let body;
if(message instanceof HTMLElement) {
body = message;
} else if(typeof message === 'string') {
body = formatText(message);
} else {
throw new Error('Cannot add element to chatbox');
}
if(kind !== 'me') {
let doHeader = true;
if(lastMessage.nick !== (nick || null) ||
lastMessage.peerId !== (peerId || null) ||
lastMessage.dest !== (dest || null) ||
!time || !lastMessage.time) {
doHeader = true;
} else {
let delta = time.getTime() - lastMessage.time.getTime();
doHeader = delta < 0 || delta > 60000;
}
if(doHeader) {
let header = document.createElement('p');
let user = document.createElement('span');
let u = dest && 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);
}
}
let p = document.createElement('p');
p.appendChild(body);
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');
body.classList.add('message-me-content');
container.appendChild(asterisk);
container.appendChild(user);
container.appendChild(body);
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;
}
/**
* @param {HTMLElement} elt
*/
function chatMessageMenu(elt) {
if(!(serverConnection && serverConnection.permissions &&
serverConnection.permissions.indexOf('op') >= 0))
return;
let messageId = elt.dataset.id;
let peerId = elt.dataset.peerId;
if(!peerId)
return;
let username = elt.dataset.username;
let u = username || 'user';
let items = [];
if(messageId)
items.push({label: 'Delete message', onClick: () => {
serverConnection.groupAction('clearchat', {
id: messageId,
userId: peerId,
});
}});
items.push({label: `Delete all from ${u}`,
onClick: () => {
serverConnection.groupAction('clearchat', {
userId: peerId,
});
}});
items.push({label: `Identify ${u}`, onClick: () => {
serverConnection.userAction('identify', peerId);
}});
items.push({label: `Kick out ${u}`, onClick: () => {
serverConnection.userAction('kick', peerId);
}});
/** @ts-ignore */
new Contextual({
items: items,
});
}
/**
* @param {string|HTMLElement} message
*/
function setCaption(message) {
let container = document.getElementById('captions-container');
let captions = document.getElementById('captions');
if(!message) {
captions.replaceChildren();
container.classList.add('invisible');
} else {
if(message instanceof HTMLElement)
captions.replaceChildren(message);
else
captions.textContent = message;
container.classList.remove('invisible');
}
}
let captionsTimer = null;
/**
* @param {string|HTMLElement} message
*/
function displayCaption(message) {
if(captionsTimer != null) {
clearTimeout(captionsTimer);
captionsTimer = null;
}
setCaption(message);
captionsTimer = setTimeout(() => setCaption(null), 3000);
}
/**
* @param {string|HTMLElement} message
*/
function localMessage(message) {
return addToChatbox(null, null, null, null, new Date(), false, false, '', message);
}
/**
* @param {string} [id]
* @param {string} [userId]
*/
function clearChat(id, userId) {
lastMessage = {};
let box = document.getElementById('box');
if(!id && !userId) {
box.textContent = '';
return;
}
let elts = box.children;
for(let i = 0; i < elts.length; i++) {
let row = elts.item(i);
if(!(row instanceof HTMLDivElement))
continue;
let div = row.firstChild;
console.log(div);
if(!(div instanceof HTMLDivElement))
continue;
if((!id || div.dataset.id === id) && div.dataset.peerId === userId)
box.removeChild(row);
}
}
/**
* 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.indexOf('op') >= 0)
return null;
return 'You are not an operator';
}
function recordingPredicate() {
if(serverConnection && serverConnection.permissions &&
serverConnection.permissions.indexOf('record') >= 0)
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');
}
};
/**
* @type {Object<string,number>}
*/
const units = {
s: 1000,
min: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
mon: 31 * 24 * 60 * 60 * 1000,
yr: 365 * 24 * 60 * 60 * 1000,
};
/**
* @param {string} s
* @returns {Date|number}
*/
function parseExpiration(s) {
if(!s)
return null;
let re = /^([0-9]+)(s|min|h|d|yr)$/
let e = re.exec(s)
if(e) {
let unit = units[e[2]];
if(!unit)
throw new Error(`Couldn't find unit ${e[2]}`);
return parseInt(e[1]) * unit;
}
let d = new Date(s);
if(d.toString() === 'Invalid Date')
throw new Error("Couldn't parse expiration date");
return d;
}
function makeTokenPredicate() {
return (serverConnection.permissions.indexOf('token') < 0 ?
"You don't have permission to create tokens" : null);
}
function editTokenPredicate() {
return (serverConnection.permissions.indexOf('token') < 0 ||
serverConnection.permissions.indexOf('op') < 0 ?
"You don't have permission to edit or list tokens" : null);
}
/**
* @param {Object} [template]
*/
function makeToken(template) {
if(!template)
template = {};
let v = {
group: group,
}
if('username' in template)
v.username = template.username;
if('expires' in template)
v.expires = template.expires;
else
v.expires = units.d;
if('not-before' in template)
v["not-before"] = template["not-before"];
if('permissions' in template)
v.permissions = template.permissions;
else {
v.permissions = [];
if(serverConnection.permissions.indexOf('present') >= 0)
v.permissions.push('present');
if(serverConnection.permissions.indexOf('message') >= 0)
v.permissions.push('message');
}
serverConnection.groupAction('maketoken', v);
}
commands.invite = {
predicate: makeTokenPredicate,
description: "create an invitation link",
parameters: "[username] [expiration]",
f: (c, r) => {
let p = parseCommand(r);
let template = {};
if(p[0])
template.username = p[0];
let expires = parseExpiration(p[1]);
if(expires)
template.expires = expires;
makeToken(template);
}
}
/**
* @param {string} t
*/
function parseToken(t) {
let m = /^https?:\/\/.*?token=([^?]+)/.exec(t);
if(m) {
return m[1];
} else if(!/^https?:\/\//.exec(t)) {
return t
} else {
throw new Error("Couldn't parse link");
}
}
commands.reinvite = {
predicate: editTokenPredicate,
description: "extend an invitation link",
parameters: "link [expiration]",
f: (c, r) => {
let p = parseCommand(r);
let v = {}
v.token = parseToken(p[0]);
if(p[1])
v.expires = parseExpiration(p[1]);
else
v.expires = units.d;
serverConnection.groupAction('edittoken', v);
}
}
commands.revoke = {
predicate: editTokenPredicate,
description: "revoke an invitation link",
parameters: "link",
f: (c, r) => {
let token = parseToken(r);
serverConnection.groupAction('edittoken', {
token: token,
expires: -units.s,
});
}
}
commands.listtokens = {
predicate: editTokenPredicate,
description: "list invitation links",
f: (c, r) => {
serverConnection.groupAction('listtokens');
}
}
function renegotiateStreams() {
for(let id in serverConnection.up)
serverConnection.up[id].restartIce();
for(let id in serverConnection.down)
serverConnection.down[id].restartIce();
}
commands.renegotiate = {
description: 'renegotiate media streams',
f: (c, r) => {
renegotiateStreams();
}
};
commands.replace = {
f: (c, r) => {
replaceUpStreams(null);
}
};
commands.stopshare = {
description: 'stop screen share',
f: (c, r) => {
closeUpMedia('screenshare');
}
}
/**
* 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, null, id, serverConnection.username,
new Date(), 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.identify = {
parameters: 'user [message]',
description: 'identify 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.shutup = {
parameters: 'user',
description: 'revoke the right to send chat messages',
predicate: operatorPredicate,
f: userCommand,
};
commands.unshutup = {
parameters: 'user',
description: 'give the right to send chat messages',
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(
"setdata", serverConnection.id, {"raisehand": true},
);
}
}
commands.unraise = {
description: 'unraise hand',
f: (c, r) => {
serverConnection.userAction(
"setdata", serverConnection.id, {"raisehand": null},
);
}
}
/** @returns {boolean} */
function canFile() {
let v =
/** @ts-ignore */
!!HTMLVideoElement.prototype.captureStream ||
/** @ts-ignore */
!!HTMLVideoElement.prototype.mozCaptureStream;
return v;
}
function presentFile() {
let input = document.createElement('input');
input.type = 'file';
input.accept="audio/*,video/*";
input.onchange = function(e) {
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
let files = this.files;
for(let i = 0; i < files.length; i++) {
addFileMedia(files[i]).catch(e => {
console.error(e);
displayError(e);
});
}
};
input.click();
}
commands.presentfile = {
description: 'broadcast a video or audio file',
f: (c, r) => {
presentFile();
},
predicate: () => {
if(!canFile())
return 'Your browser does not support presenting arbitrary files';
if(!serverConnection || !serverConnection.permissions ||
serverConnection.permissions.indexOf('present') < 0)
return 'You are not authorised to present.';
return null;
}
};
/**
* @param {string} id
*/
function sendFile(id) {
let input = document.createElement('input');
input.type = 'file';
input.onchange = function(e) {
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
let files = this.files;
for(let i = 0; i < files.length; i++) {
try {
serverConnection.sendFile(id, files[i]);
} catch(e) {
console.error(e);
displayError(e);
}
}
};
input.click();
}
commands.sendfile = {
parameters: 'user',
description: 'send a file (this will disclose your IP address)',
f: (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]}`);
sendFile(id);
},
};
/**
* Test loopback through a TURN relay.
*
* @returns {Promise<number>}
*/
async function relayTest() {
if(!serverConnection)
throw new Error('not connected');
let conf = Object.assign({}, serverConnection.getRTCConfiguration());
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";
let position = 'center';
let gravity = 'top';
switch(level) {
case "info":
position = 'right';
gravity = 'bottom';
break;
case "warning":
break;
case "kicked":
level = "error";
break;
}
/** @ts-ignore */
Toastify({
text: message,
duration: 4000,
close: true,
position: position,
gravity: gravity,
className: level,
}).showToast();
}
/**
* @param {unknown} message
*/
function displayWarning(message) {
return displayError(message, "warning");
}
/**
* @param {unknown} message
*/
function displayMessage(message) {
return displayError(message, "info");
}
document.getElementById('loginform').onsubmit = async function(e) {
e.preventDefault();
let form = this;
if(!(form instanceof HTMLFormElement))
throw new Error('Bad type for loginform');
setVisibility('passwordform', true);
if(getInputElement('presentboth').checked)
presentRequested = 'both';
else if(getInputElement('presentmike').checked)
presentRequested = 'mike';
else
presentRequested = null;
getInputElement('presentoff').checked = true;
// Connect to the server, gotConnected will join.
form.active = false;
serverConnect();
};
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.onerror = function(e) {
console.error(e);
displayError(e.toString());
};
serverConnection.onpeerconnection = onPeerConnection;
serverConnection.onclose = gotClose;
serverConnection.ondownstream = gotDownStream;
serverConnection.onuser = gotUser;
serverConnection.onjoined = gotJoined;
serverConnection.onchat = addToChatbox;
serverConnection.onusermessage = gotUserMessage;
serverConnection.onfiletransfer = gotFileTransfer;
let url = groupStatus.endpoint;
if(!url) {
console.warn("no endpoint in status");
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() {
try {
let r = await fetch(".status")
if(!r.ok)
throw new Error(`${r.status} ${r.statusText}`);
groupStatus = await r.json()
} catch(e) {
console.error(e);
displayWarning("Couldn't fetch status: " + e);
groupStatus = {};
}
if(groupStatus.name) {
group = groupStatus.name;
} else {
console.warn("no group name in status");
group = decodeURIComponent(
location.pathname.replace(/^\/[a-z]*\//, '').replace(/\/$/, ''),
);
}
// Disable simulcast on Firefox by default, it's buggy.
if(isFirefox())
getSelectElement('simulcastselect').value = 'off';
let parms = new URLSearchParams(window.location.search);
if(window.location.search)
window.history.replaceState(null, '', window.location.pathname);
setTitle(groupStatus.displayName || capitalise(group));
addFilters();
await setMediaChoices(false);
reflectSettings();
if(parms.has('token'))
token = parms.get('token');
if(token) {
await serverConnect();
} else if(groupStatus.authPortal) {
window.location.href = groupStatus.authPortal;
} else {
setVisibility('login-container', true);
document.getElementById('username').focus()
}
setViewportHeight();
}
start();