1
Fork 0
mirror of https://github.com/jech/galene.git synced 2024-11-14 04:35:57 +01:00
galene/static/sfu.js
Juliusz Chroboczek 9575b80893 Use mids instead of track ids for indexing labels.
It turns out that track ids are not necessarily the same on the local and
remote sides.  Thanks to Ines Klimann for noticing the issue.
2020-05-21 22:40:11 +02:00

1172 lines
29 KiB
JavaScript

// 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';
let myid;
let group;
let socket;
let up = {}, down = {};
let iceServers = [];
let permissions = {};
function toHex(array) {
let a = new Uint8Array(array);
function hex(x) {
let h = x.toString(16);
if(h.length < 2)
h = '0' + h;
return h;
}
return a.reduce((x, y) => x + hex(y), '');
}
function randomid() {
let a = new Uint8Array(16);
crypto.getRandomValues(a);
return toHex(a);
}
function Connection(id, pc) {
this.id = id;
this.kind = null;
this.label = null;
this.pc = pc;
this.stream = null;
this.labels = {};
this.labelsByMid = {};
this.iceCandidates = [];
this.timers = [];
this.audioStats = {};
this.videoStats = {};
}
Connection.prototype.setInterval = function(f, t) {
this.timers.push(setInterval(f, t));
};
Connection.prototype.close = function(sendit) {
while(this.timers.length > 0)
clearInterval(this.timers.pop());
this.stream.getTracks().forEach(t => {
try {
t.stop();
} catch(e) {
}
});
this.pc.close();
if(sendit) {
send({
type: 'close',
id: this.id,
});
}
};
function setUserPass(username, password) {
window.sessionStorage.setItem(
'userpass',
JSON.stringify({username: username, password: password}),
);
}
function getUserPass() {
let userpass = window.sessionStorage.getItem('userpass');
if(!userpass)
return null;
return JSON.parse(userpass);
}
function getUsername() {
let userpass = getUserPass();
if(!userpass)
return null;
return userpass.username;
}
function setConnected(connected) {
let statspan = document.getElementById('statspan');
let userform = document.getElementById('userform');
let disconnectbutton = document.getElementById('disconnectbutton');
if(connected) {
clearError();
statspan.textContent = 'Connected';
statspan.classList.remove('disconnected');
statspan.classList.add('connected');
userform.classList.add('invisible');
userform.classList.remove('userform');
disconnectbutton.classList.remove('invisible');
displayUsername();
} else {
let userpass = getUserPass();
document.getElementById('username').value =
userpass ? userpass.username : '';
document.getElementById('password').value =
userpass ? userpass.password : '';
statspan.textContent = 'Disconnected';
statspan.classList.remove('connected');
statspan.classList.add('disconnected');
userform.classList.add('userform');
userform.classList.remove('invisible');
disconnectbutton.classList.add('invisible');
permissions={};
clearUsername(false);
}
}
document.getElementById('presentbutton').onclick = function(e) {
e.preventDefault();
addLocalMedia();
};
document.getElementById('unpresentbutton').onclick = function(e) {
e.preventDefault();
delUpMediaKind('local');
};
function changePresentation() {
let found = false;
for(let id in up) {
if(up[id].kind === 'local')
found = true;
}
delUpMediaKind('local');
if(found)
addLocalMedia();
}
function setVisibility(id, visible) {
let elt = document.getElementById(id);
if(visible)
elt.classList.remove('invisible');
else
elt.classList.add('invisible');
}
function setButtonsVisibility() {
let local = findUpMedia('local');
let share = findUpMedia('screenshare')
// don't allow multiple presentations
setVisibility('presentbutton', permissions.present && !local);
setVisibility('unpresentbutton', local);
// allow multiple shared documents
setVisibility('sharebutton', permissions.present);
setVisibility('unsharebutton', share);
setVisibility('mediaoptions', permissions.present);
}
document.getElementById('audioselect').onchange = function(e) {
e.preventDefault();
changePresentation();
};
document.getElementById('videoselect').onchange = function(e) {
e.preventDefault();
changePresentation();
};
document.getElementById('sharebutton').onclick = function(e) {
e.preventDefault();
addShareMedia();
};
document.getElementById('unsharebutton').onclick = function(e) {
e.preventDefault();
delUpMediaKind('screenshare');
}
document.getElementById('requestselect').onchange = function(e) {
e.preventDefault();
sendRequest(this.value);
};
async function updateStats(conn, sender) {
let stats;
if(!sender.track)
return;
if(sender.track.kind === 'audio')
stats = conn.audioStats;
else if(sender.track.kind === 'video')
stats = conn.videoStats;
else
return;
let report;
try {
report = await sender.getStats();
} catch(e) {
delete(stats.rate);
delete(stats.timestamp);
delete(stats.bytesSent);
return;
}
for(let r of report.values()) {
if(r.type !== 'outbound-rtp')
continue;
if(stats.timestamp) {
stats.rate =
((r.bytesSent - stats.bytesSent) * 1000 /
(r.timestamp - stats.timestamp)) * 8;
} else {
delete(stats.rate);
}
stats.timestamp = r.timestamp;
stats.bytesSent = r.bytesSent;
return;
}
}
function displayStats(id) {
let conn = up[id];
if(!conn.audioStats.rate && !conn.videoStats.rate) {
setLabel(id);
return;
}
let a = conn.audioStats.rate;
let v = conn.videoStats.rate;
let text = '';
if(a)
text = text + Math.round(a / 1000) + 'kbps';
if(a && v)
text = text + ' + ';
if(v)
text = text + Math.round(v / 1000) + 'kbps';
if(text)
setLabel(id, text);
else
setLabel(id);
}
function mapMediaOption(value) {
console.assert(typeof(value) === 'string');
switch(value) {
case 'default':
return true;
case 'off':
return false;
default:
return value;
}
}
function addSelectOption(select, label, value) {
if(!value)
value = label;
for(let i = 0; i < select.children.length; i++) {
if(select.children[i].value === value) {
return;
}
}
let option = document.createElement('option');
option.value = value;
option.textContent = label;
select.appendChild(option);
}
// media names might not be available before we call getDisplayMedia. So
// we call this lazily.
let mediaChoicesDone = false;
async function setMediaChoices() {
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(document.getElementById('videoselect'),
label, d.deviceId);
cn++;
} else if(d.kind === 'audioinput') {
if(!label)
label = `Microphone ${mn}`;
addSelectOption(document.getElementById('audioselect'),
label, d.deviceId);
mn++;
}
});
mediaChoicesDone = true;
}
async function addLocalMedia() {
if(!getUserPass())
return;
let audio = mapMediaOption(document.getElementById('audioselect').value);
let video = mapMediaOption(document.getElementById('videoselect').value);
if(!audio && !video)
return;
let constraints = {audio: audio, video: video};
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch(e) {
console.error(e);
return;
}
setMediaChoices();
let id = await newUpStream();
let c = up[id];
c.kind = 'local';
c.stream = stream;
stream.getTracks().forEach(t => {
c.labels[t.id] = t.kind
let sender = c.pc.addTrack(t, stream);
c.setInterval(() => {
updateStats(c, sender);
}, 2000);
});
c.setInterval(() => {
displayStats(id);
}, 2500);
await setMedia(id);
setButtonsVisibility()
}
async function addShareMedia(setup) {
if(!getUserPass())
return;
let stream = null;
try {
stream = await navigator.mediaDevices.getDisplayMedia({});
} catch(e) {
console.error(e);
return;
}
let id = await newUpStream();
let c = up[id];
c.kind = 'screenshare';
c.stream = stream;
stream.getTracks().forEach(t => {
let sender = c.pc.addTrack(t, stream);
t.onended = e => {
delUpMedia(id);
};
c.labels[t.id] = 'screenshare';
c.setInterval(() => {
updateStats(c, sender);
}, 2000);
});
c.setInterval(() => {
displayStats(id);
}, 2500);
await setMedia(id);
setButtonsVisibility()
}
function delUpMedia(id) {
let c = up[id];
if(!c) {
console.error("Deleting unknown up media");
return;
}
c.close(true);
delMedia(id);
delete(up[id]);
setButtonsVisibility()
}
function delUpMediaKind(kind) {
for(let id in up) {
let c = up[id];
if(c.kind != kind)
continue
c.close(true);
delMedia(id);
delete(up[id]);
}
setButtonsVisibility()
}
function findUpMedia(kind) {
for(let id in up) {
if(up[id].kind === kind)
return true;
}
return false;
}
function setMedia(id) {
let mine = true;
let c = up[id];
if(!c) {
c = down[id];
mine = false;
}
if(!c)
throw new Error('Unknown connection');
let peersdiv = document.getElementById('peers');
let div = document.getElementById('peer-' + id);
if(!div) {
div = document.createElement('div');
div.id = 'peer-' + id;
div.classList.add('peer');
peersdiv.appendChild(div);
}
let media = document.getElementById('media-' + id);
if(!media) {
media = document.createElement('video');
media.id = 'media-' + id;
media.classList.add('media');
media.autoplay = true;
media.playsinline = true;
media.controls = true;
if(mine)
media.muted = true;
div.appendChild(media);
}
let label = document.getElementById('label-' + id);
if(!label) {
label = document.createElement('div');
label.id = 'label-' + id;
label.classList.add('label');
div.appendChild(label);
}
media.srcObject = c.stream;
setLabel(id);
resizePeers();
}
function delMedia(id) {
let mediadiv = document.getElementById('peers');
let peer = document.getElementById('peer-' + id);
let media = document.getElementById('media-' + id);
media.srcObject = null;
mediadiv.removeChild(peer);
resizePeers();
}
function setLabel(id, fallback) {
let label = document.getElementById('label-' + id);
if(!label)
return;
let l = down[id] ? down[id].label : null;
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() {
let count = Object.keys(up).length + Object.keys(down).length;
let columns = Math.ceil(Math.sqrt(count));
document.getElementById('peers').style['grid-template-columns'] =
`repeat(${columns}, 1fr)`;
}
function serverConnect() {
if(socket) {
socket.close(1000, 'Reconnecting');
socket = null;
setConnected(false);
}
try {
socket = new WebSocket(
`ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`,
);
} catch(e) {
console.error(e);
setConnected(false);
return Promise.reject(e);
}
return new Promise((resolve, reject) => {
socket.onerror = function(e) {
console.error(e);
reject(e.error ? e.error : e);
};
socket.onopen = function(e) {
resetUsers();
resetChat();
setConnected(true);
let up = getUserPass();
send({
type: 'handshake',
id: myid,
group: group,
username: up.username,
password: up.password,
});
sendRequest(document.getElementById('requestselect').value);
resolve();
};
socket.onclose = function(e) {
setConnected(false);
delUpMediaKind('local');
delUpMediaKind('screenshare');
for(let id in down) {
let c = down[id];
delete(down[id]);
c.close(false);
delMedia(id);
}
reject(new Error('websocket close ' + e.code + ' ' + e.reason));
};
socket.onmessage = function(e) {
let m = JSON.parse(e.data);
switch(m.type) {
case 'offer':
gotOffer(m.id, m.labels, m.offer);
break;
case 'answer':
gotAnswer(m.id, m.answer);
break;
case 'close':
gotClose(m.id);
break;
case 'abort':
gotAbort(m.id);
break;
case 'ice':
gotICE(m.id, m.candidate);
break;
case 'label':
gotLabel(m.id, m.value);
break;
case 'permissions':
gotPermissions(m.permissions);
break;
case 'user':
gotUser(m.id, m.username, m.del);
break;
case 'chat':
addToChatbox(m.id, m.username, m.value, m.me);
break;
case 'clearchat':
resetChat();
break;
case 'ping':
send({
type: 'pong',
});
break;
case 'pong':
/* nothing */
break;
case 'error':
displayError('The server said: ' + m.value);
break;
default:
console.warn('Unexpected server message', m.type);
return;
}
};
});
}
function sendRequest(value) {
let request = [];
switch(value) {
case 'audio':
request = {audio: true};
break;
case 'screenshare':
request = {audio: true, screenshare: true};
break;
case 'everything':
request = {audio: true, screenshare: true, video: true};
break;
default:
console.error(`Uknown value ${value} in sendRequest`);
break;
}
send({
type: 'request',
request: request,
});
}
async function gotOffer(id, labels, offer) {
let c = down[id];
if(!c) {
let pc = new RTCPeerConnection({
iceServers: iceServers,
});
c = new Connection(id, pc);
down[id] = c;
c.pc.onicecandidate = function(e) {
if(!e.candidate)
return;
send({type: 'ice',
id: id,
candidate: e.candidate,
});
};
c.pc.ontrack = function(e) {
let label = e.transceiver && c.labelsByMid[e.transceiver.mid];
if(label) {
c.labels[e.track.id] = label;
} else {
console.error("Couldn't find label for track");
}
c.stream = e.streams[0];
setMedia(id);
};
}
c.labelsByMid = labels;
await c.pc.setRemoteDescription(offer);
await addIceCandidates(c);
let answer = await c.pc.createAnswer();
if(!answer)
throw new Error("Didn't create answer");
await c.pc.setLocalDescription(answer);
send({
type: 'answer',
id: id,
answer: answer,
});
}
function gotLabel(id, label) {
let c = down[id];
if(!c)
throw new Error('Got label for unknown id');
c.label = label;
setLabel(id);
}
async function gotAnswer(id, answer) {
let c = up[id];
if(!c)
throw new Error('unknown up stream');
await c.pc.setRemoteDescription(answer);
await addIceCandidates(c);
}
function gotClose(id) {
let c = down[id];
if(!c)
throw new Error('unknown down stream');
delete(down[id]);
c.close(false);
delMedia(id);
}
function gotAbort(id) {
delUpMedia(id);
}
async function gotICE(id, candidate) {
let conn = up[id];
if(!conn)
conn = down[id];
if(!conn)
throw new Error('unknown stream');
if(conn.pc.remoteDescription)
await conn.pc.addIceCandidate(candidate).catch(console.warn);
else
conn.iceCandidates.push(candidate);
}
async function addIceCandidates(conn) {
let promises = [];
conn.iceCandidates.forEach(c => {
promises.push(conn.pc.addIceCandidate(c).catch(console.warn));
});
conn.iceCandidates = [];
return await Promise.all(promises);
}
function send(m) {
if(!m)
throw(new Error('Sending null message'));
return socket.send(JSON.stringify(m));
}
let users = {};
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.textContent = name ? name : '(anon)';
div.appendChild(user);
}
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]);
}
function gotUser(id, name, del) {
if(del)
delUser(id, name);
else
addUser(id, name);
}
function displayUsername() {
let userpass = getUserPass();
let text = '';
if(userpass && userpass.username)
text = 'as ' + userpass.username;
if(permissions.op && permissions.present)
text = text + ' (op, presenter)';
else if(permissions.op)
text = text + ' (op)';
else if(permissions.present)
text = text + ' (presenter)';
document.getElementById('userspan').textContent = text;
}
function clearUsername() {
document.getElementById('userspan').textContent = '';
}
function gotPermissions(perm) {
permissions = perm;
displayUsername();
setButtonsVisibility();
}
const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#=?]+[-a-zA-Z0-9@:%/_\\+~#=]/g;
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;
}
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;
}
let lastMessage = {};
function addToChatbox(peerId, nick, message, me){
let container = document.createElement('div');
container.classList.add('message');
if(!me) {
let p = formatLines(message.split('\n'));
if (lastMessage.nick !== nick || lastMessage.peerId !== peerId) {
let user = document.createElement('p');
user.textContent = nick;
user.classList.add('message-user');
container.appendChild(user);
}
p.classList.add('message-content');
container.appendChild(p);
lastMessage.nick = nick;
lastMessage.peerId = peerId;
} else {
let asterisk = document.createElement('span');
asterisk.textContent = '*';
asterisk.classList.add('message-me-asterisk');
let user = document.createElement('span');
user.textContent = nick;
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');
delete(lastMessage.nick);
delete(lastMessage.peerId);
}
let box = document.getElementById('box');
box.appendChild(container);
if(box.scrollHeight > box.clientHeight) {
box.scrollTop = box.scrollHeight - box.clientHeight;
}
return message;
}
function resetChat() {
lastMessage = {};
document.getElementById('box').textContent = '';
}
function handleInput() {
let username = getUsername();
let input = document.getElementById('input');
let data = input.value;
input.value = '';
let message, me;
if(data === '')
return;
if(data.charAt(0) === '/') {
if(data.charAt(1) === '/') {
message = data.substring(1);
me = false;
} else {
let space, cmd, rest;
space = data.indexOf(' ');
if(space < 0) {
cmd = data;
rest = '';
} else {
cmd = data.slice(0, space);
rest = data.slice(space + 1).trim();
}
switch(cmd) {
case '/me':
message = rest;
me = true;
break;
case '/leave':
socket.close();
return;
case '/clear':
if(!permissions.op) {
displayError("You're not an operator");
return;
}
send({
type: 'clearchat',
});
return;
case '/lock':
case '/unlock':
if(!permissions.op) {
displayError("You're not an operator");
return;
}
send({
type: cmd === '/lock' ? 'lock' : 'unlock',
});
return;
case '/op':
case '/unop':
case '/kick':
case '/present':
case '/unpresent': {
if(!permissions.op) {
displayError("You're not an operator");
return;
}
let id;
if(id in users) {
id = rest;
} else {
for(let i in users) {
if(users[i] === rest) {
id = i;
break;
}
}
}
if(!id) {
displayError('Unknown user ' + rest);
return;
}
send({
type: cmd.slice(1),
id: id,
});
return;
}
default:
displayError('Uknown command ' + cmd);
return;
}
}
} else {
message = data;
me = false;
}
if(!username) {
displayError("Sorry, you're anonymous, you cannot chat");
return;
}
addToChatbox(myid, username, message, me);
send({
type: 'chat',
id: myid,
username: username,
value: message,
me: me,
});
}
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 chat = document.getElementById('chat');
let start_x = e.clientX;
let start_width = parseFloat(
document.defaultView.getComputedStyle(chat).width.replace('px', ''),
);
let inputbutton = document.getElementById('inputbutton');
function start_drag(e) {
let width = start_width + e.clientX - start_x;
if(width < 40)
inputbutton.style.display = 'none';
else
inputbutton.style.display = 'inline';
chat.style.width = width + 'px';
}
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);
async function newUpStream() {
let id = randomid();
if(up[id])
throw new Error('Eek!');
let pc = new RTCPeerConnection({
iceServers: iceServers,
});
if(!pc)
throw new Error("Couldn't create peer connection");
up[id] = new Connection(id, pc);
pc.onnegotiationneeded = e => negotiate(id);
pc.onicecandidate = function(e) {
if(!e.candidate)
return;
send({type: 'ice',
id: id,
candidate: e.candidate,
});
};
pc.ontrack = console.error;
return id;
}
async function negotiate(id) {
let c = up[id];
if(!c)
throw new Error('unknown connection');
let offer = await c.pc.createOffer({});
if(!offer)
throw(new Error("Didn't create offer"));
await c.pc.setLocalDescription(offer);
// mids are not known until this point
c.pc.getTransceivers().forEach(t => {
if(t.sender && t.sender.track) {
let label = c.labels[t.sender.track.id];
if(label)
c.labelsByMid[t.mid] = label;
else
console.error("Couldn't find label for track");
}
});
send({
type: 'offer',
id: id,
labels: c.labelsByMid,
offer: offer,
});
}
let errorTimeout = null;
function setErrorTimeout(ms) {
if(errorTimeout) {
clearTimeout(errorTimeout);
errorTimeout = null;
}
if(ms) {
errorTimeout = setTimeout(clearError, ms);
}
}
function displayError(message) {
let errspan = document.getElementById('errspan');
errspan.textContent = message;
errspan.classList.remove('noerror');
errspan.classList.add('error');
setErrorTimeout(8000);
}
function displayWarning(message) {
// don't overwrite real errors
if(!errorTimeout)
return displayError(message);
}
function clearError() {
let errspan = document.getElementById('errspan');
errspan.textContent = '';
errspan.classList.remove('error');
errspan.classList.add('noerror');
setErrorTimeout(null);
}
async function getIceServers() {
let r = await fetch('/ice-servers.json');
if(!r.ok)
throw new Error("Couldn't fetch ICE servers: " +
r.status + ' ' + r.statusText);
let servers = await r.json();
if(!(servers instanceof Array))
throw new Error("couldn't parse ICE servers");
iceServers = servers;
}
document.getElementById('userform').onsubmit = async function(e) {
e.preventDefault();
let username = document.getElementById('username').value.trim();
let password = document.getElementById('password').value;
setUserPass(username, password);
await serverConnect();
};
document.getElementById('disconnectbutton').onclick = function(e) {
socket.close();
};
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;
}
myid = randomid();
getIceServers().catch(console.error).then(c => {
document.getElementById('connectbutton').disabled = false;
}).then(c => {
let userpass = getUserPass();
if(userpass)
return serverConnect();
});
}
start();