// 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; /** * @typedef {Object} userpass * @property {string} username * @property {string} password */ /* Some browsers disable session storage when cookies are disabled, we fall back to a global variable. */ /** * @type {userpass} */ let fallbackUserPass = null; /** * @param {string} username * @param {string} password */ function storeUserPass(username, password) { let userpass = {username: username, password: password}; try { window.sessionStorage.setItem('userpass', JSON.stringify(userpass)); fallbackUserPass = null; } catch(e) { console.warn("Couldn't store password:", e); fallbackUserPass = userpass; } } /** * Returns null if the user hasn't logged in yet. * * @returns {userpass} */ function getUserPass() { /** @type{userpass} */ let userpass; try { let json = window.sessionStorage.getItem('userpass'); userpass = JSON.parse(json); } catch(e) { console.warn("Couldn't retrieve password:", e); userpass = fallbackUserPass; } return userpass || null; } /** * @typedef {Object} settings * @property {boolean} [localMute] * @property {string} [video] * @property {string} [audio] * @property {string} [send] * @property {string} [request] * @property {boolean} [activityDetection] * @property {Array.} [resolution] * @property {boolean} [blackboardMode] */ /** @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 password:", 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 password:", 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('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; } getInputElement('activitybox').checked = settings.activityDetection; getInputElement('blackboardbox').checked = settings.blackboardMode; if(store) storeSettings(settings); } function showVideo() { let width = window.innerWidth; let video_container = document.getElementById('video-container'); video_container.classList.remove('no-video'); if (width <= 768) document.getElementById('collapse-video').style.display = "block"; } /** * @param {boolean} [force] */ function hideVideo(force) { let mediadiv = document.getElementById('peers'); if(mediadiv.childElementCount > 0 && !force) return; let video_container = document.getElementById('video-container'); video_container.classList.add('no-video'); let left = document.getElementById("left"); if (left.style.display !== "none") { // hide all video buttons used to switch video on mobile layout closeVideoControls(); } } function closeVideoControls() { // hide all video buttons used to switch video on mobile layout document.getElementById('switch-video').style.display = ""; document.getElementById('collapse-video').style.display = ""; } function fillLogin() { let userpass = getUserPass(); getInputElement('username').value = userpass ? userpass.username : ''; getInputElement('password').value = userpass ? userpass.password : ''; } /** * @param{boolean} connected */ function setConnected(connected) { let userbox = document.getElementById('profile'); let connectionbox = document.getElementById('login-container'); if(connected) { resetUsers(); clearChat(); userbox.classList.remove('invisible'); connectionbox.classList.add('invisible'); displayUsername(); } else { resetUsers(); fillLogin(); userbox.classList.add('invisible'); connectionbox.classList.remove('invisible'); displayError('Disconnected', 'error'); hideVideo(); closeVideoControls(); } } /** @this {ServerConnection} */ function gotConnected() { setConnected(true); let up = getUserPass(); this.join(group, up.username, up.password); } /** * @this {ServerConnection} * @param {number} code * @param {string} reason */ function gotClose(code, reason) { delUpMediaKind(null); setConnected(false); if(code != 1000) { console.warn('Socket close', code, reason); } } /** * @this {ServerConnection} * @param {Stream} c */ function gotDownStream(c) { c.onclose = function() { delMedia(c.id); }; c.onerror = function(e) { console.error(e); displayError(e); } c.ondowntrack = function(track, transceiver, label, stream) { setMedia(c, false); } c.onstatus = function(status) { setMediaStatus(c); } c.onstats = gotDownStats; if(getSettings().activityDetection) c.setStatsInterval(activityDetectionInterval); setMedia(c, false); } // Store current browser viewport height in css variable function setViewportHeight() { document.documentElement.style.setProperty( '--vh', `${window.innerHeight/100}px`, ); // Ajust video component size resizePeers(); } setViewportHeight(); // 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('local'); if(!id) await addLocalMedia(); } finally { button.disabled = false; } }; getButtonElement('unpresentbutton').onclick = function(e) { e.preventDefault(); delUpMediaKind('local'); resizePeers(); }; function changePresentation() { let id = findUpMedia('local'); if(id) { addLocalMedia(id); } } /** * @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 permissions = serverConnection.permissions; let local = !!findUpMedia('local'); let share = !!findUpMedia('screenshare'); let video = !!findUpMedia('video'); /** @ts-ignore */ let canFile = !!HTMLVideoElement.prototype.captureStream; // don't allow multiple presentations setVisibility('presentbutton', permissions.present && !local); setVisibility('unpresentbutton', local); setVisibility('mutebutton', permissions.present); // allow multiple shared documents setVisibility('sharebutton', permissions.present && ('getDisplayMedia' in navigator.mediaDevices)); setVisibility('unsharebutton', share); setVisibility('stopvideobutton', video); setVisibility('mediaoptions', permissions.present); setVisibility('sendform', permissions.present); setVisibility('fileform', canFile && permissions.present); } /** * @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}); changePresentation(); }; getSelectElement('audioselect').onchange = function(e) { e.preventDefault(); if(!(this instanceof HTMLSelectElement)) throw new Error('Unexpected type for this'); updateSettings({audio: this.value}); changePresentation(); }; getInputElement('blackboardbox').onchange = function(e) { e.preventDefault(); if(!(this instanceof HTMLInputElement)) throw new Error('Unexpected type for this'); updateSettings({blackboardMode: this.checked}); changePresentation(); } 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(); }; document.getElementById('unsharebutton').onclick = function(e) { e.preventDefault(); delUpMediaKind('screenshare'); resizePeers(); } document.getElementById('stopvideobutton').onclick = function(e) { e.preventDefault(); delUpMediaKind('video'); resizePeers(); } /** @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}); let t = getMaxVideoThroughput(); for(let id in serverConnection.up) { let c = serverConnection.up[id]; await setMaxVideoThroughput(c, t); } } getSelectElement('requestselect').onchange = function(e) { e.preventDefault(); if(!(this instanceof HTMLSelectElement)) throw new Error('Unexpected type for this'); updateSettings({request: this.value}); serverConnection.request(this.value); }; const activityDetectionInterval = 200; const activityDetectionPeriod = 700; const activityDetectionThreshold = 0.2; getInputElement('activitybox').onchange = function(e) { if(!(this instanceof HTMLInputElement)) throw new Error('Unexpected type for this'); updateSettings({activityDetection: this.checked}); for(let id in serverConnection.down) { let c = serverConnection.down[id]; if(this.checked) c.setStatsInterval(activityDetectionInterval); else { c.setStatsInterval(0); setActive(c, false); } } } getInputElement('fileinput').onchange = function(e) { if(!(this instanceof HTMLInputElement)) throw new Error('Unexpected type for this'); let input = this; let files = input.files; for(let i = 0; i < files.length; i++) { addFileMedia(files[i]).catch(e => { console.error(e); displayError(e); }); } input.value = ''; closeNav(); } /** * @this {Stream} * @param {Object} stats */ function gotUpStats(stats) { let c = this; let text = ''; c.pc.getSenders().forEach(s => { let tid = s.track && s.track.id; let stats = tid && c.stats[tid]; let rate = stats && stats['outbound-rtp'] && stats['outbound-rtp'].rate; if(typeof rate === 'number') { if(text) text = text + ' + '; text = text + Math.round(rate / 1000) + 'kbps'; } }); setLabel(c, text); } /** * @param {Stream} c * @param {boolean} value */ function setActive(c, value) { let peer = document.getElementById('peer-' + c.id); if(value) peer.classList.add('peer-active'); else peer.classList.remove('peer-active'); } /** * @this {Stream} * @param {Object} stats */ function gotDownStats(stats) { if(!getInputElement('activitybox').checked) return; let c = this; let maxEnergy = 0; c.pc.getReceivers().forEach(r => { let tid = r.track && r.track.id; let s = tid && stats[tid]; let energy = s && s['track'] && s['track'].audioEnergy; if(typeof energy === 'number') maxEnergy = Math.max(maxEnergy, energy); }); // totalAudioEnergy is defined as the integral of the square of the // volume, so square the threshold. if(maxEnergy > activityDetectionThreshold * activityDetectionThreshold) { c.userdata.lastVoiceActivity = Date.now(); setActive(c, true); } else { let last = c.userdata.lastVoiceActivity; if(!last || Date.now() - last > activityDetectionPeriod) setActive(c, false); } } /** * @param {HTMLSelectElement} select * @param {string} label * @param {string} [value] */ function addSelectOption(select, label, value) { if(!value) value = label; for(let i = 0; i < select.children.length; i++) { let child = select.children[i]; if(!(child instanceof HTMLOptionElement)) { console.warn('Unexpected select child'); continue; } if(child.value === value) { if(child.label !== label) { child.label = label; } return; } } let option = document.createElement('option'); option.value = value; option.textContent = label; select.appendChild(option); } /** * @param {HTMLSelectElement} select * @param {string} value */ function selectOptionAvailable(select, value) { let children = select.children; for(let i = 0; i < children.length; i++) { let child = select.children[i]; if(!(child instanceof HTMLOptionElement)) { console.warn('Unexpected select child'); continue; } if(child.value === value) return true; } return false; } /** * @param {HTMLSelectElement} select * @returns {string} */ function selectOptionDefault(select) { /* First non-empty option. */ for(let i = 0; i < select.children.length; i++) { let child = select.children[i]; if(!(child instanceof HTMLOptionElement)) { console.warn('Unexpected select child'); continue; } if(child.value) return child.value; } /* The empty option is always available. */ return ''; } /* media names might not be available before we call getDisplayMedia. So we call this twice, the second time to update the menu with user-readable labels. */ /** @type {boolean} */ let mediaChoicesDone = false; /** * @param{boolean} done */ async function setMediaChoices(done) { if(mediaChoicesDone) return; let devices = []; try { devices = await navigator.mediaDevices.enumerateDevices(); } catch(e) { console.error(e); return; } let cn = 1, mn = 1; devices.forEach(d => { let label = d.label; if(d.kind === 'videoinput') { if(!label) label = `Camera ${cn}`; addSelectOption(getSelectElement('videoselect'), label, d.deviceId); cn++; } else if(d.kind === 'audioinput') { if(!label) label = `Microphone ${mn}`; addSelectOption(getSelectElement('audioselect'), label, d.deviceId); mn++; } }); mediaChoicesDone = done; } /** * @param {string} [id] */ function newUpStream(id) { let c = serverConnection.newUpStream(id); c.onstatus = function(status) { setMediaStatus(c); } c.onerror = function(e) { console.error(e); displayError(e); delUpMedia(c); } c.onabort = function() { delUpMedia(c); } c.onnegotiationcompleted = function() { setMaxVideoThroughput(c, getMaxVideoThroughput()) } return c; } /** * @param {Stream} c * @param {number} [bps] */ async function setMaxVideoThroughput(c, bps) { let senders = c.pc.getSenders(); for(let i = 0; i < senders.length; i++) { let s = senders[i]; if(!s.track || s.track.kind !== 'video') continue; let p = s.getParameters(); if(!p.encodings) p.encodings = [{}]; p.encodings.forEach(e => { if(bps > 0) e.maxBitrate = bps; else delete e.maxBitrate; }); try { await s.setParameters(p); } catch(e) { console.error(e); } } } function isSafari() { let ua = navigator.userAgent.toLowerCase(); return ua.indexOf('safari') >= 0 && ua.indexOf('chrome') < 0; } /** * @param {string} [id] */ async function addLocalMedia(id) { let settings = getSettings(); let audio = settings.audio ? {deviceId: settings.audio} : false; let video = settings.video ? {deviceId: settings.video} : false; if(video) { let resolution = settings.resolution; if(resolution) { video.width = { ideal: resolution[0] }; video.height = { ideal: resolution[1] }; } else if(settings.blackboardMode) { video.width = { min: 640, ideal: 1920 }; video.height = { min: 400, ideal: 1080 }; } } let old = id && serverConnection.up[id]; if(!audio && !video) { if(old) delUpMedia(old); return; } if(old) stopUpMedia(old); let constraints = {audio: audio, video: video}; /** @type {MediaStream} */ let stream = null; try { stream = await navigator.mediaDevices.getUserMedia(constraints); } catch(e) { displayError(e); if(old) delUpMedia(old); return; } setMediaChoices(true); let c = newUpStream(id); c.kind = 'local'; c.stream = stream; let mute = getSettings().localMute; stream.getTracks().forEach(t => { c.labels[t.id] = t.kind if(t.kind == 'audio') { if(mute) t.enabled = false; } else if(t.kind == 'video') { if(settings.blackboardMode) { /** @ts-ignore */ t.contentHint = 'detail'; } } c.pc.addTrack(t, stream); }); c.onstats = gotUpStats; c.setStatsInterval(2000); await setMedia(c, true, true); setButtonsVisibility(); } let safariScreenshareDone = false; async function addShareMedia() { /** @type {MediaStream} */ let stream = null; try { if(!('getDisplayMedia' in navigator.mediaDevices)) throw new Error('Your browser does not support screen sharing'); /** @ts-ignore */ stream = await navigator.mediaDevices.getDisplayMedia({video: true}); } catch(e) { console.error(e); displayError(e); return; } if(!safariScreenshareDone) { if(isSafari()) displayWarning('Screen sharing under Safari is experimental. ' + 'Please use a different browser if possible.'); safariScreenshareDone = true; } let c = newUpStream(); c.kind = 'screenshare'; c.stream = stream; stream.getTracks().forEach(t => { c.pc.addTrack(t, stream); t.onended = e => { delUpMedia(c); }; c.labels[t.id] = 'screenshare'; }); c.onstats = gotUpStats; c.setStatsInterval(2000); await setMedia(c, true); setButtonsVisibility() } /** * @param {File} file */ async function addFileMedia(file) { /** @ts-ignore */ if(!HTMLVideoElement.prototype.captureStream) { displayError("This browser doesn't support file playback"); return; } let url = URL.createObjectURL(file); let video = document.createElement('video'); video.src = url; video.controls = true; /** @ts-ignore */ let stream = video.captureStream(); let c = newUpStream(); c.kind = 'video'; c.stream = stream; stream.onaddtrack = function(e) { let t = e.track; if(t.kind === 'audio') { let presenting = !!findUpMedia('local'); let muted = getSettings().localMute; if(presenting && !muted) { setLocalMute(true, true); displayWarning('You have been muted'); } } c.pc.addTrack(t, stream); c.labels[t.id] = t.kind; c.onstats = gotUpStats; c.setStatsInterval(2000); }; stream.onremovetrack = function(e) { let t = e.track; delete(c.labels[t.id]); /** @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'); } if(Object.keys(c.labels).length === 0) { stream.onaddtrack = null; stream.onremovetrack == null; delUpMedia(c); } }; await setMedia(c, true, false, video); c.userdata.play = true; setButtonsVisibility() } /** * @param {Stream} c */ function stopUpMedia(c) { if(!c.stream) return; c.stream.getTracks().forEach(t => { try { t.stop(); } catch(e) { } }); } /** * @param {Stream} c */ function delUpMedia(c) { stopUpMedia(c); try { delMedia(c.id); } catch(e) { console.warn(e); } c.close(); delete(serverConnection.up[c.id]); setButtonsVisibility() } /** * delUpMediaKind reoves all up media of the given kind. If kind is * falseish, it removes all up media. * @param {string} kind */ function delUpMediaKind(kind) { for(let id in serverConnection.up) { let c = serverConnection.up[id]; if(kind && c.kind != kind) continue c.close(); delMedia(id); delete(serverConnection.up[id]); } setButtonsVisibility(); hideVideo(); } /** * @param {string} kind */ function findUpMedia(kind) { for(let id in serverConnection.up) { if(serverConnection.up[id].kind === kind) return id; } return null; } /** * @param {boolean} mute */ function muteLocalTracks(mute) { if(!serverConnection) return; for(let id in serverConnection.up) { let c = serverConnection.up[id]; if(c.kind === 'local') { let stream = c.stream; stream.getTracks().forEach(t => { if(t.kind === 'audio') { t.enabled = !mute; } }); } } } /** * setMedia adds a new media element corresponding to stream c. * * @param {Stream} c * @param {boolean} isUp * - indicates whether the stream goes in the up direction * @param {boolean} [mirror] * - whether to mirror the video * @param {HTMLVideoElement} [video] * - the video element to add. If null, a new element with custom * controls will be created. */ async function setMedia(c, isUp, mirror, video) { let peersdiv = document.getElementById('peers'); let div = document.getElementById('peer-' + c.id); if(!div) { div = document.createElement('div'); div.id = 'peer-' + c.id; div.classList.add('peer'); peersdiv.appendChild(div); } let media = /** @type {HTMLVideoElement} */ (document.getElementById('media-' + c.id)); if(media) { if(video) { throw new Error("Duplicate video"); } } else { if(video) { media = video; } else { media = document.createElement('video'); if(isUp) media.muted = true; } media.classList.add('media'); media.autoplay = true; /** @ts-ignore */ media.playsinline = true; media.id = 'media-' + c.id; div.appendChild(media); if(!video) addCustomControls(media, div, c); if(mirror) media.classList.add('mirror'); } if(!video) media.srcObject = c.stream; let label = document.getElementById('label-' + c.id); if(!label) { label = document.createElement('div'); label.id = 'label-' + c.id; label.classList.add('label'); div.appendChild(label); } setLabel(c); setMediaStatus(c); showVideo(); resizePeers(); if(!isUp && isSafari() && !findUpMedia('local')) { // Safari doesn't allow autoplay unless the user has granted media access try { let stream = await navigator.mediaDevices.getUserMedia({audio: true}); stream.getTracks().forEach(t => t.stop()); } catch(e) { } } } /** * @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) { media.controls = false; let controls = document.getElementById('controls-' + c.id); if(controls) { console.warn('Attempted to add duplicate controls'); return; } let template = document.getElementById('videocontrols-template').firstElementChild; controls = cloneHTMLElement(template); controls.id = 'controls-' + c.id; let volume = getVideoButton(controls, 'volume'); if(c.kind === 'local') { volume.remove(); } else { setVolumeButton(media.muted, getVideoButton(controls, "volume-mute"), getVideoButton(controls, "volume-slider")); } container.appendChild(controls); registerControlHandlers(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 {HTMLVideoElement} media * @param {HTMLElement} container */ function registerControlHandlers(media, container) { let play = getVideoButton(container, 'video-play'); if(play) { play.onclick = function(event) { event.preventDefault(); media.play(); }; } let volume = getVideoButton(container, 'volume'); if (volume) { volume.onclick = function(event) { let target = /** @type{HTMLElement} */(event.target); if(!target.classList.contains('volume-mute')) // if click on volume slider, do nothing return; event.preventDefault(); media.muted = !media.muted; setVolumeButton(media.muted, target, getVideoButton(volume, "volume-slider")); }; volume.oninput = function() { let slider = /** @type{HTMLInputElement} */ (getVideoButton(volume, "volume-slider")); media.volume = parseInt(slider.value, 10)/100; }; } let pip = getVideoButton(container, 'pip'); if(pip) { /** @ts-ignore */ if(HTMLVideoElement.prototype.requestPictureInPicture) { pip.onclick = function(e) { e.preventDefault(); /** @ts-ignore */ if(media.requestPictureInPicture) { /** @ts-ignore */ media.requestPictureInPicture(); } else { displayWarning('Picture in Picture not supported.'); } }; } else { pip.style.display = 'none'; } } let fs = getVideoButton(container, 'fullscreen'); if(fs) { if(HTMLVideoElement.prototype.requestFullscreen || /** @ts-ignore */ HTMLVideoElement.prototype.webkitRequestFullscreen) { fs.onclick = function(e) { e.preventDefault(); if(media.requestFullscreen) { media.requestFullscreen(); /** @ts-ignore */ } else if(media.webkitRequestFullscreen) { /** @ts-ignore */ media.webkitRequestFullscreen(); } else { displayWarning('Full screen not supported!'); } }; } else { fs.style.display = 'none'; } } } /** * @param {string} id */ function delMedia(id) { let mediadiv = document.getElementById('peers'); let peer = document.getElementById('peer-' + id); if(!peer) throw new Error('Removing unknown media'); let media = /** @type{HTMLVideoElement} */ (document.getElementById('media-' + id)); if(media.src) { URL.revokeObjectURL(media.src); media.src = null; } media.srcObject = null; mediadiv.removeChild(peer); 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.id); if(!media) { console.warn('Setting status of unknown media.'); return; } if(good) { media.classList.remove('media-failed'); if(c.userdata.play) { if(media instanceof HTMLMediaElement) media.play().catch(e => { console.error(e); displayError(e); }); delete(c.userdata.play); } } else { media.classList.add('media-failed'); } } /** * @param {Stream} c * @param {string} [fallback] */ function setLabel(c, fallback) { let label = document.getElementById('label-' + c.id); 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"; } } /** @type{Object} */ let users = {}; /** * Lexicographic order, with case differences secondary. * @param{string} a * @param{string} b */ function stringCompare(a, b) { let la = a.toLowerCase() let lb = b.toLowerCase() if(la < lb) return -1; else if(la > lb) return +1; else if(a < b) return -1; else if(a > b) return +1; return 0 } /** * @param {string} id * @param {string} name */ function addUser(id, name) { if(!name) name = null; if(id in users) throw new Error('Duplicate user id'); users[id] = name; let div = document.getElementById('users'); let user = document.createElement('div'); user.id = 'user-' + id; user.classList.add("user-p"); user.textContent = name ? name : '(anon)'; if(name) { let us = div.children; for(let i = 0; i < us.length; i++) { let child = us[i]; let childname = users[child.id.slice('user-'.length)] || null; if(!childname || stringCompare(childname, name) > 0) { div.insertBefore(user, child); return; } } } div.appendChild(user); } /** * @param {string} id * @param {string} name */ function delUser(id, name) { if(!name) name = null; if(!(id in users)) throw new Error('Unknown user id'); if(users[id] !== name) throw new Error('Inconsistent user name'); delete(users[id]); let div = document.getElementById('users'); let user = document.getElementById('user-' + id); div.removeChild(user); } function resetUsers() { for(let id in users) delUser(id, users[id]); } /** * @param {string} id * @param {string} kind * @param {string} name */ function gotUser(id, kind, name) { switch(kind) { case 'add': addUser(id, name); break; case 'delete': delUser(id, name); break; default: console.warn('Unknown user kind', kind); break; } } function displayUsername() { let userpass = getUserPass(); let text = ''; if(userpass && userpass.username) document.getElementById('userspan').textContent = userpass.username; if(serverConnection.permissions.op && serverConnection.permissions.present) text = '(op, presenter)'; else if(serverConnection.permissions.op) text = 'operator'; else if(serverConnection.permissions.present) text = 'presenter'; document.getElementById('permspan').textContent = text; } let presentRequested = null; /** * @this {ServerConnection} * @param {string} group * @param {Object} perms */ async function gotJoined(kind, group, perms, message) { let present = presentRequested; presentRequested = null; switch(kind) { case 'fail': displayError('The server said: ' + message); this.close(); return; case 'redirect': this.close(); document.location = message; return; case 'leave': this.close(); return; case 'join': case 'change': displayUsername(); setButtonsVisibility(); if(kind === 'change') return; break; default: displayError('Unknown join message'); this.close(); return; } let input = /** @type{HTMLTextAreaElement} */ (document.getElementById('input')); input.placeholder = 'Type /help for help'; setTimeout(() => {input.placeholder = '';}, 8000); this.request(getSettings().request); if(serverConnection.permissions.present && !findUpMedia('local')) { 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 Ready to enable your camera or microphone" ); } } } /** * @param {string} id * @param {string} dest * @param {string} username * @param {number} time * @param {boolean} privileged * @param {string} kind * @param {unknown} message */ function gotUserMessage(id, dest, username, time, privileged, kind, message) { switch(kind) { case 'error': case 'warning': case 'info': let from = id ? (username || 'Anonymous') : 'The Server'; if(privileged) displayError(`${from} said: ${message}`, kind); else console.error(`Got unprivileged message of kind ${kind}`); break; case 'mute': if(privileged) { setLocalMute(true, true); let by = username ? ' by ' + username : ''; displayWarning(`You have been muted${by}`); } else { console.error(`Got unprivileged message of kind ${kind}`); } break; case 'clearchat': if(privileged) { clearChat(); } else { console.error(`Got unprivileged message of kind ${kind}`); } break; default: console.warn(`Got unknown user message ${kind}`); break; } }; const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g; /** * @param {string} line * @returns {Array.} */ function formatLine(line) { let r = new RegExp(urlRegexp); let result = []; let pos = 0; while(true) { let m = r.exec(line); if(!m) break; result.push(document.createTextNode(line.slice(pos, m.index))); let a = document.createElement('a'); a.href = m[0]; a.textContent = m[0]; a.target = '_blank'; a.rel = 'noreferrer noopener'; result.push(a); pos = m.index + m[0].length; } result.push(document.createTextNode(line.slice(pos))); return result; } /** * @param {string[]} lines * @returns {HTMLElement} */ function formatLines(lines) { let elts = []; if(lines.length > 0) elts = formatLine(lines[0]); for(let i = 1; i < lines.length; i++) { elts.push(document.createElement('br')); elts = elts.concat(formatLine(lines[i])); } let elt = document.createElement('p'); elts.forEach(e => elt.appendChild(e)); return elt; } /** * @param {number} time * @returns {string} */ function formatTime(time) { let delta = Date.now() - time; let date = new Date(time); let m = date.getMinutes(); if(delta > -30000) return date.getHours() + ':' + ((m < 10) ? '0' : '') + m; return date.toLocaleString(); } /** * @typedef {Object} lastMessage * @property {string} [nick] * @property {string} [peerId] * @property {string} [dest] * @property {number} [time] */ /** @type {lastMessage} */ let lastMessage = {}; /** * @param {string} peerId * @param {string} nick * @param {number} time * @param {string} kind * @param {unknown} message */ function addToChatbox(peerId, dest, nick, time, privileged, kind, message) { let userpass = getUserPass(); 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(userpass.username === nick) container.classList.add('message-sender'); if(dest) container.classList.add('message-private'); if(kind !== 'me') { let p = formatLines(message.toString().split('\n')); let doHeader = true; if(!peerId && !dest && !nick) { doHeader = false; } else if(lastMessage.nick !== (nick || null) || lastMessage.peerId !== peerId || lastMessage.dest !== (dest || null) || !time || !lastMessage.time) { doHeader = true; } else { let delta = time - lastMessage.time; doHeader = delta < 0 || delta > 60000; } if(doHeader) { let header = document.createElement('p'); if(peerId || nick || dest) { let user = document.createElement('span'); user.textContent = dest ? `${nick||'(anon)'} \u2192 ${users[dest]||'(anon)'}` : (nick || '(anon)'); user.classList.add('message-user'); header.appendChild(user); } header.classList.add('message-header'); container.appendChild(header); if(time) { let tm = document.createElement('span'); tm.textContent = formatTime(time); tm.classList.add('message-time'); header.appendChild(tm); } } p.classList.add('message-content'); container.appendChild(p); lastMessage.nick = (nick || null); lastMessage.peerId = peerId; lastMessage.dest = (dest || null); lastMessage.time = (time || null); } else { let asterisk = document.createElement('span'); asterisk.textContent = '*'; asterisk.classList.add('message-me-asterisk'); let user = document.createElement('span'); user.textContent = nick || '(anon)'; user.classList.add('message-me-user'); let content = document.createElement('span'); formatLine(message.toString()).forEach(elt => { content.appendChild(elt); }); content.classList.add('message-me-content'); container.appendChild(asterisk); container.appendChild(user); container.appendChild(content); container.classList.add('message-me'); lastMessage = {}; } container.appendChild(footer); let box = document.getElementById('box'); box.appendChild(row); if(box.scrollHeight > box.clientHeight) { box.scrollTop = box.scrollHeight - box.clientHeight; } return message; } function clearChat() { lastMessage = {}; document.getElementById('box').textContent = ''; } /** * A command known to the command-line parser. * * @typedef {Object} command * @property {string} [parameters] * - A user-readable list of parameters. * @property {string} [description] * - A user-readable description, null if undocumented. * @property {() => string} [predicate] * - Returns null if the command is available. * @property {(c: string, r: string) => void} f */ /** * The set of commands known to the command-line parser. * * @type {Object.} */ let commands = {} function operatorPredicate() { if(serverConnection && serverConnection.permissions && serverConnection.permissions.op) return null; return 'You are not an operator'; } function recordingPredicate() { if(serverConnection && serverConnection.permissions && serverConnection.permissions.record) return null; return 'You are not allowed to record'; } commands.help = { description: 'display this help', f: (c, r) => { /** @type {string[]} */ let cs = []; for(let cmd in commands) { let c = commands[cmd]; if(!c.description) continue; if(c.predicate && c.predicate()) continue; cs.push(`/${cmd}${c.parameters?' ' + c.parameters:''}: ${c.description}`); } cs.sort(); let s = ''; for(let i = 0; i < cs.length; i++) s = s + cs[i] + '\n'; addToChatbox(null, null, null, Date.now(), false, null, s); } }; 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`; addToChatbox(null, null, null, Date.now(), false, null, s); return; } let p = parseCommand(r); let value; if(p[1]) { value = JSON.parse(p[1]) } else { value = true; } updateSetting(p[0], value); reflectSettings(); } }; commands.unset = { f: (c, r) => { delSetting(r.trim()); return; } }; commands.leave = { description: "leave group", f: (c, r) => { if(!serverConnection) throw new Error('Not connected'); serverConnection.close(); } }; commands.clear = { predicate: operatorPredicate, description: 'clear the chat history', f: (c, r) => { serverConnection.groupAction('clearchat'); } }; commands.lock = { predicate: operatorPredicate, description: 'lock this group', parameters: '[message]', f: (c, r) => { serverConnection.groupAction('lock', r); } }; commands.unlock = { predicate: operatorPredicate, description: 'unlock this group, revert the effect of /lock', f: (c, r) => { serverConnection.groupAction('unlock'); } }; commands.record = { predicate: recordingPredicate, description: 'start recording', f: (c, r) => { serverConnection.groupAction('record'); } }; commands.unrecord = { predicate: recordingPredicate, description: 'stop recording', f: (c, r) => { serverConnection.groupAction('unrecord'); } }; commands.subgroups = { predicate: operatorPredicate, description: 'list subgroups', f: (c, r) => { serverConnection.groupAction('subgroups'); } }; commands.renegotiate = { description: 'renegotiate media streams', f: (c, r) => { for(let id in serverConnection.up) serverConnection.up[id].restartIce(); for(let id in serverConnection.down) serverConnection.down[id].restartIce(); } }; /** * 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 users) return user; for(let id in users) { if(users[id] === user) return id; } return null; } commands.msg = { parameters: 'user message', description: 'send a private message', f: (c, r) => { let p = parseCommand(r); if(!p[0]) throw new Error('/msg requires parameters'); let id = findUserId(p[0]); if(!id) throw new Error(`Unknown user ${p[0]}`); serverConnection.chat('', id, p[1]); addToChatbox(serverConnection.id, id, serverConnection.username, Date.now(), false, '', p[1]); } }; /** @param {string} c @param {string} r */ function userCommand(c, r) { let p = parseCommand(r); if(!p[0]) throw new Error(`/${c} requires parameters`); let id = findUserId(p[0]); if(!id) throw new Error(`Unknown user ${p[0]}`); serverConnection.userAction(c, id, p[1]); } function userMessage(c, r) { let p = parseCommand(r); if(!p[0]) throw new Error(`/${c} requires parameters`); let id = findUserId(p[0]); if(!id) throw new Error(`Unknown user ${p[0]}`); serverConnection.userMessage(c, id, p[1]); } commands.kick = { parameters: 'user [message]', description: 'kick out a user', predicate: operatorPredicate, f: userCommand, }; commands.op = { parameters: 'user', description: 'give operator status', predicate: operatorPredicate, f: userCommand, }; commands.unop = { parameters: 'user', description: 'revoke operator status', predicate: operatorPredicate, f: userCommand, }; commands.present = { parameters: 'user', description: 'give user the right to present', predicate: operatorPredicate, f: userCommand, }; commands.unpresent = { parameters: 'user', description: 'revoke the right to present', predicate: operatorPredicate, f: userCommand, }; commands.mute = { parameters: 'user', description: 'mute a remote user', predicate: operatorPredicate, f: userMessage, }; commands.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); }, }; 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) { displayError(e); } return; } } } else { message = data; me = false; } if(!serverConnection || !serverConnection.socket) { displayError("Not connected."); return; } try { serverConnection.chat(me ? 'me' : '', '', message); } catch(e) { console.error(e); displayError(e); } } document.getElementById('inputform').onsubmit = function(e) { e.preventDefault(); handleInput(); }; document.getElementById('input').onkeypress = function(e) { if(e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.metaKey) { e.preventDefault(); handleInput(); } }; function chatResizer(e) { e.preventDefault(); let full_width = document.getElementById("mainrow").offsetWidth; let left = document.getElementById("left"); let right = document.getElementById("right"); let start_x = e.clientX; let start_width = left.offsetWidth; function start_drag(e) { let left_width = (start_width + e.clientX - start_x) * 100 / full_width; // set min chat width to 300px let min_left_width = 300 * 100 / full_width; if (left_width < min_left_width) { return; } left.style.flex = left_width.toString(); right.style.flex = (100 - left_width).toString(); } function stop_drag(e) { document.documentElement.removeEventListener( 'mousemove', start_drag, false, ); document.documentElement.removeEventListener( 'mouseup', stop_drag, false, ); } document.documentElement.addEventListener( 'mousemove', start_drag, false, ); document.documentElement.addEventListener( 'mouseup', stop_drag, false, ); } document.getElementById('resizer').addEventListener('mousedown', chatResizer, false); /** * @param {unknown} message * @param {string} [level] */ function displayError(message, level) { if(!level) level = "error"; var background = 'linear-gradient(to right, #e20a0a, #df2d2d)'; var position = 'center'; var gravity = 'top'; switch(level) { case "info": background = 'linear-gradient(to right, #529518, #96c93d)'; position = 'right'; gravity = 'bottom'; break; case "warning": background = "linear-gradient(to right, #bdc511, #c2cf01)"; break; } /** @ts-ignore */ Toastify({ text: message, duration: 4000, close: true, position: position, gravity: gravity, backgroundColor: background, className: level, }).showToast(); } /** * @param {unknown} message */ function displayWarning(message) { return displayError(message, "warning"); } /** * @param {unknown} message */ function displayMessage(message) { return displayError(message, "info"); } let connecting = false; document.getElementById('userform').onsubmit = async function(e) { e.preventDefault(); if(connecting) return; connecting = true; try { let username = getInputElement('username').value.trim(); let password = getInputElement('password').value; storeUserPass(username, password); serverConnect(); } finally { connecting = false; } if(getInputElement('presentboth').checked) presentRequested = 'both'; else if(getInputElement('presentmike').checked) presentRequested = 'mike'; else presentRequested = null; getInputElement('presentoff').checked = true; }; document.getElementById('disconnectbutton').onclick = function(e) { serverConnection.close(); closeNav(); }; function openNav() { document.getElementById("sidebarnav").style.width = "250px"; } function closeNav() { document.getElementById("sidebarnav").style.width = "0"; } document.getElementById('sidebarCollapse').onclick = function(e) { document.getElementById("left-sidebar").classList.toggle("active"); document.getElementById("mainrow").classList.toggle("full-width-active"); }; document.getElementById('openside').onclick = function(e) { e.preventDefault(); let sidewidth = document.getElementById("sidebarnav").style.width; if (sidewidth !== "0px" && sidewidth !== "") { closeNav(); return; } else { openNav(); } }; document.getElementById('clodeside').onclick = function(e) { e.preventDefault(); closeNav(); }; document.getElementById('collapse-video').onclick = function(e) { e.preventDefault(); if(!(this instanceof HTMLElement)) throw new Error('Unexpected type for this'); let width = window.innerWidth; let left = document.getElementById("left"); if (left.style.display === "" || left.style.display === "none") { //left chat is hidden, we show the chat and hide collapse button left.style.display = "block"; this.style.display = ""; } if (width <= 768) { // fixed div for small screen this.style.display = ""; hideVideo(true); document.getElementById('switch-video').style.display = "block"; } }; document.getElementById('switch-video').onclick = function(e) { e.preventDefault(); if(!(this instanceof HTMLElement)) throw new Error('Unexpected type for this'); showVideo(); this.style.display = ""; document.getElementById('collapse-video').style.display = "block"; }; document.getElementById('close-chat').onclick = function(e) { e.preventDefault(); let left = document.getElementById("left"); left.style.display = "none"; document.getElementById('collapse-video').style.display = "block"; }; async function serverConnect() { if(serverConnection && serverConnection.socket) serverConnection.close(); serverConnection = new ServerConnection(); serverConnection.onconnected = gotConnected; serverConnection.onclose = gotClose; serverConnection.ondownstream = gotDownStream; serverConnection.onuser = gotUser; serverConnection.onjoined = gotJoined; serverConnection.onchat = addToChatbox; serverConnection.onusermessage = gotUserMessage; let url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`; try { await serverConnection.connect(url); } catch(e) { console.error(e); displayError(e.message ? e.message : "Couldn't connect to " + url); } } function start() { group = decodeURIComponent(location.pathname.replace(/^\/[a-z]*\//, '')); let title = group.charAt(0).toUpperCase() + group.slice(1); if(group !== '') { document.title = title; document.getElementById('title').textContent = title; } setMediaChoices(false).then(e => reflectSettings()); fillLogin(); document.getElementById("login-container").classList.remove('invisible'); } start();