// Copyright (c) 2020 by Juliusz Chroboczek. // This is not open source software. Copy it, and I'll break into your // house and tell your three year-old that Santa doesn't exist. '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; } /** * Return null if the user hasn't logged in yet. * * @returns {string} */ function getUsername() { let userpass = getUserPass(); if(!userpass) return null; return userpass.username; } /** * @typedef {Object} settings * @property {boolean} [localMute] * @property {string} [video] * @property {string} [audio] * @property {string} [send] * @property {string} [request] * @property {boolean} [activityDetection] * @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.video || !selectOptionAvailable(videoselect, settings.video)) { settings.video = selectOptionDefault(videoselect); store = true; } videoselect.value = settings.video; let audioselect = getSelectElement('audioselect'); if(!settings.audio || !selectOptionAvailable(audioselect, settings.audio)) { settings.audio = selectOptionDefault(audioselect); store = true; } audioselect.value = settings.audio; if(settings.request) getSelectElement('requestselect').value = settings.request; else { settings.request = getSelectElement('requestselect').value; store = true; } if(settings.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 = ""; } /** * @param{boolean} connected */ function setConnected(connected) { let statspan = document.getElementById('statspan'); let userbox = document.getElementById('user'); let connectionbox = document.getElementById('login-container'); if(connected) { resetUsers(); clearChat(); statspan.textContent = 'Connected'; statspan.classList.remove('disconnected'); statspan.classList.add('connected'); userbox.classList.remove('invisible'); connectionbox.classList.add('invisible'); displayUsername(); } else { resetUsers(); let userpass = getUserPass(); getInputElement('username').value = userpass ? userpass.username : ''; getInputElement('password').value = userpass ? userpass.password : ''; getInputElement('presentoff').checked = true; statspan.textContent = 'Disconnected'; statspan.classList.remove('connected'); statspan.classList.add('disconnected'); userbox.classList.add('invisible'); connectionbox.classList.remove('invisible'); clearUsername(); displayError("Disconnected!", "error"); hideVideo(); closeVideoControls(); } } /** @this {ServerConnection} */ function gotConnected() { setConnected(true); let up = getUserPass(); this.login(up.username, up.password); this.join(group); this.request(getSettings().request); } /** * @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.onlabel = function(label) { setLabel(c); } c.onstatus = function(status) { setMediaStatus(c); } c.onstats = gotDownStats; if(getSettings().activityDetection) c.setStatsInterval(activityDetectionInterval); } // 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'); // don't allow multiple presentations setVisibility('presentbutton', permissions.present && !local); setVisibility('unpresentbutton', local); // allow multiple shared documents setVisibility('sharebutton', permissions.present && ('getDisplayMedia' in navigator.mediaDevices)); setVisibility('unsharebutton', share); setVisibility('stopvideobutton', video); setVisibility('mediaoptions', permissions.present); } /** * @param {boolean} mute */ function setLocalMute(mute) { 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'); } } 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; updateSettings({localMute: localMute}) setLocalMute(localMute); } 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) { let input = /** @type{HTMLInputElement} */(this); let files = input.files; for(let i = 0; i < files.length; i++) addFileMedia(files[i]); input.value = ''; } /** * @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); } } } /** * @param {string} [id] * @param {boolean} [disableVideo] */ async function addLocalMedia(id, disableVideo) { if(!getUserPass()) return; let settings = getSettings(); let audio = settings.audio ? {deviceId: settings.audio} : false; let video = disableVideo ? false : settings.video ? {deviceId: settings.video} : false; if(video) { 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); setButtonsVisibility(); } let safariWarningDone = false; async function addShareMedia() { if(!getUserPass()) return; /** @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(!safariWarningDone) { let ua = navigator.userAgent.toLowerCase(); if(ua.indexOf('safari') >= 0 && ua.indexOf('chrome') < 0) { displayWarning('Screen sharing under Safari is experimental. ' + 'Please use a different browser if possible.'); } safariWarningDone = 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) { if(!getUserPass()) 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; 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'); }; setMedia(c, true, video); video.play(); 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(true); 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(true); 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; } }); } } } /** * @param {Stream} c * @param {boolean} isUp * @param {HTMLVideoElement} [video] */ function setMedia(c, isUp, video) { let peersdiv = document.getElementById('peers'); let settings = getSettings(); let local_media; for(let id in serverConnection.up) { if (id === c.id) { local_media = serverConnection.up[id]; } } 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) { media = video; } else { media = document.createElement('video'); media.controls = false; 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); } 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); } if(!video) { let template = document.getElementById('videocontrols-template') .firstElementChild; let top_template = document.getElementById('top-videocontrols-template') .firstElementChild; let top_controls = document.getElementById('topcontrols-' + c.id); if(template && !top_controls) { top_controls = /** @type{HTMLElement} */(top_template.cloneNode(true)); top_controls.id = 'topcontrols-' + c.id; div.appendChild(top_controls); } let controls = document.getElementById('controls-' + c.id); if(template && !controls) { controls = /** @type{HTMLElement} */(template.cloneNode(true)); controls.id = 'controls-' + c.id; div.appendChild(controls); let volume = controls.querySelector(".fa-volume-up"); if(media.muted) { if (volume) { volume.classList.remove("fa-volume-up"); volume.classList.add("fa-volume-off"); } } let camera = controls.querySelector("span.camera"); if (local_media && local_media.kind === "local") { if (!settings.video) { if (camera) camera.classList.add("camera-off"); } volume.parentElement.remove(); } else camera.remove(); } media.srcObject = c.stream; } setLabel(c); setMediaStatus(c); showVideo(); resizePeers(); registerControlEvent(div.id); } /** * @param {HTMLVideoElement} video */ async function videoPIP(video) { /** @ts-ignore */ if (video.requestPictureInPicture) { /** @ts-ignore */ await video.requestPictureInPicture(); } else { displayWarning("Video PIP Mode not supported!"); } } /** * @param {HTMLElement} target */ function getParentVideo(target) { // target is the element, parent the div
let control = target.parentElement.parentElement; let id = control.id.split('-')[1]; let media = /** @type {HTMLVideoElement} */ (document.getElementById('media-' + id)); if (!media) { displayError("Cannot find media!"); } return media; } /** * @param {string} peerid */ function registerControlEvent(peerid) { let settings = getSettings(); let peer = document.getElementById(peerid); //Add event listener when a video component is added to the DOM let volume = /** @type {HTMLElement} */(peer.querySelector("span.volume")); if (volume) { volume.onclick = function(event) { event.preventDefault(); let volume = /** @type{HTMLElement} */(event.target); let video = getParentVideo(volume); if(volume.className.indexOf("fa-volume-off") !== -1) { volume.classList.remove("fa-volume-off"); volume.classList.add("fa-volume-up"); video.muted = false; } else { volume.classList.remove("fa-volume-up"); volume.classList.add("fa-volume-off"); // mute video sound video.muted = true; } }; } let pip = /** @type {HTMLElement} */(peer.querySelector("span.pip")); if(pip) { /** @ts-ignore */ if(HTMLVideoElement.prototype.requestPictureInPicture) { pip.onclick = function(event) { event.preventDefault(); let pip = /** @type{HTMLElement} */(event.target); let video = getParentVideo(pip); videoPIP(video); }; } else { pip.style.display = 'none'; } } let fs = /** @type {HTMLElement} */(peer.querySelector("span.fullscreen")); if(fs) { fs.onclick = function(event) { event.preventDefault(); let fs = /** @type {HTMLElement} */(event.target); let video = getParentVideo(fs); if(video.requestFullscreen) { video.requestFullscreen(); } else { displayWarning("Video Fullscreen not supported!"); } }; } let camera = /** @type {HTMLElement} */(peer.querySelector("span.camera")); if(camera) { camera.onclick = function(event) { event.preventDefault(); let camera = /** @type {HTMLElement} */(event.target); let video = getParentVideo(camera); let id = video.id.split("-")[1]; if(!settings.video) return; if(camera.getAttribute("data-type") === "bt-camera") { addLocalMedia(id, true); camera.setAttribute("data-type", "bt-camera-off"); camera.parentElement.classList.add("disabled"); } else { camera.setAttribute("data-type", "bt-camera"); camera.parentElement.classList.remove("disabled"); addLocalMedia(id); } }; } } /** * @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'); 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.label; 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) text = 'as ' + userpass.username; if(serverConnection.permissions.op && serverConnection.permissions.present) text = text + ' (op, presenter)'; else if(serverConnection.permissions.op) text = text + ' (op)'; else if(serverConnection.permissions.present) text = text + ' (presenter)'; document.getElementById('userspan').textContent = text; } function clearUsername() { document.getElementById('userspan').textContent = ''; } /** * @param {Object} perms */ function gotPermissions(perms) { displayUsername(); setButtonsVisibility(); if(serverConnection.permissions.present) displayMessage("Press Present to enable your camera or microphone"); } const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g; /** * @param {string} line * @returns {(Text|HTMLElement)[]} */ function formatLine(line) { let r = new RegExp(urlRegexp); let result = []; let pos = 0; while(true) { let m = r.exec(line); if(!m) break; result.push(document.createTextNode(line.slice(pos, m.index))); let a = document.createElement('a'); a.href = m[0]; a.textContent = m[0]; a.target = '_blank'; a.rel = 'noreferrer noopener'; result.push(a); pos = m.index + m[0].length; } result.push(document.createTextNode(line.slice(pos))); return result; } /** * @param {string[]} lines * @returns {HTMLElement} */ function formatLines(lines) { let elts = []; if(lines.length > 0) elts = formatLine(lines[0]); for(let i = 1; i < lines.length; i++) { elts.push(document.createElement('br')); elts = elts.concat(formatLine(lines[i])); } let elt = document.createElement('p'); elts.forEach(e => elt.appendChild(e)); return elt; } /** * @param {number} time * @returns {string} */ function formatTime(time) { let delta = Date.now() - time; let date = new Date(time); let m = date.getMinutes(); if(delta > -30000) return date.getHours() + ':' + ((m < 10) ? '0' : '') + m; return date.toLocaleString(); } /** * @typedef {Object} lastMessage * @property {string} [nick] * @property {string} [peerId] * @property {string} [dest] */ /** @type {lastMessage} */ let lastMessage = {}; /** * @param {string} peerId * @param {string} nick * @param {number} time * @param {string} kind * @param {string} message */ function addToChatbox(peerId, dest, nick, time, 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.split('\n')); if(lastMessage.nick !== (nick || null) || lastMessage.peerId !== peerId || lastMessage.dest !== (dest || null)) { let header = document.createElement('p'); 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); container.appendChild(footer); } 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).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 = {}; } 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.permissions.op) return null; return 'You are not an operator'; } function recordingPredicate() { if(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(), 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(), 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) => { 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'); } }; /** * 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]}`); let username = getUsername(); serverConnection.chat(username, '', id, p[1]); addToChatbox(serverConnection.id, id, username, Date.now(), '', 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]); } 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, }; 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; } let username = getUsername(); try { serverConnection.chat(username, 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, #edd800, #c9c200)"; 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; } let presentboth = getInputElement('presentboth').checked; if(presentboth) { let button = getButtonElement('presentbutton'); button.disabled = true; try { let id = findUpMedia('local'); if(!id) await addLocalMedia(); } finally { button.disabled = false; } } }; document.getElementById('disconnectbutton').onclick = function(e) { serverConnection.close(); let user_box = document.getElementById('userDropdown'); if (user_box.classList.contains("show")) { user_box.classList.toggle("show"); } }; 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('user').onclick = function(e) { e.preventDefault(); document.getElementById("userDropdown").classList.toggle("show"); }; 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) { let user_box = document.getElementById('userDropdown'); if (user_box.classList.contains("show")) { return; } // 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"; }; window.onclick = function(event) { let user_box = document.getElementById('userDropdown'); if (user_box.classList.contains("show") && event.target.id != "user") { let parent = event.target; while (parent.id !== "main" && parent.id !== "userDropdown" && parent.id !== "user" && parent.tagName !== "body") { parent = parent.parentNode; } if (parent.id !="userDropdown" && parent.id !== "user") { user_box.classList.toggle("show"); } } }; 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.onpermissions = gotPermissions; serverConnection.onchat = addToChatbox; serverConnection.onclearchat = clearChat; serverConnection.onusermessage = function(kind, message) { if(kind === 'error') displayError(`The server said: ${message}`); else displayWarning(`The server said: ${message}`); } 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()); document.getElementById("user").classList.add('invisible'); document.getElementById("login-container").classList.remove('invisible'); } start();