From 440b78e3b0da18f2c1ae17eddd59d33542d82b8b Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 27 May 2020 18:24:53 +0000 Subject: [PATCH] mumble-web --- production/mumble-web/config.js | 42 + production/mumble-web/docker-compose.yml | 32 + production/mumble-web/entrypoint.sh | 4 + production/mumble-web/gencert.sh | 1 + production/mumble-web/index.html | 708 +++++++++++ production/mumble-web/index.js | 1165 +++++++++++++++++++ production/mumble-web/index.js.save | 1153 ++++++++++++++++++ production/mumble-web/loc/de.json | 16 + production/mumble-web/loc/en.json | 78 ++ production/mumble-web/loc/eo.json | 16 + production/mumble-web/loc/es.json | 78 ++ production/mumble-web/loc/oc.json | 77 ++ production/mumble-web/mumble-web/Dockerfile | 8 + production/mumble-web/voice.js | 186 +++ 14 files changed, 3564 insertions(+) create mode 100644 production/mumble-web/config.js create mode 100644 production/mumble-web/docker-compose.yml create mode 100644 production/mumble-web/entrypoint.sh create mode 100644 production/mumble-web/gencert.sh create mode 100644 production/mumble-web/index.html create mode 100644 production/mumble-web/index.js create mode 100644 production/mumble-web/index.js.save create mode 100644 production/mumble-web/loc/de.json create mode 100644 production/mumble-web/loc/en.json create mode 100644 production/mumble-web/loc/eo.json create mode 100644 production/mumble-web/loc/es.json create mode 100644 production/mumble-web/loc/oc.json create mode 100644 production/mumble-web/mumble-web/Dockerfile create mode 100644 production/mumble-web/voice.js diff --git a/production/mumble-web/config.js b/production/mumble-web/config.js new file mode 100644 index 0000000..d6f3b4a --- /dev/null +++ b/production/mumble-web/config.js @@ -0,0 +1,42 @@ +// Note: You probably do not want to change any values in here because this +// file might need to be updated with new default values for new +// configuration options. Use the [config.local.js] file instead! + +window.mumbleWebConfig = { + // Which fields to show on the Connect to Server dialog + 'connectDialog': { + 'address': false, + 'port': false, + 'token': false, + 'username': true, + 'password': false, + 'channelName': false + }, + // Default values for user settings + // You can see your current value by typing `localStorage.getItem('mumble.$setting')` in the web console. + 'settings': { + 'voiceMode': 'ptt', // one of 'cont' (Continuous), 'ptt' (Push-to-Talk), 'vad' (Voice Activity Detection) + 'pttKey': 'h', + 'vadLevel': 0.3, + 'toolbarVertical': false, + 'showAvatars': 'always', // one of 'always', 'own_channel', 'linked_channel', 'minimal_only', 'never' + 'userCountInChannelName': false, + 'audioBitrate': 128000, // bits per second + 'samplesPerPacket': 960 + }, + // Default values (can be changed by passing a query parameter of the same name) + 'defaults': { + // Connect Dialog + 'address': window.location.hostname, + 'port': '443', + 'token': '', + 'username': '', + 'password': '', + 'joinDialog': true, // replace whole dialog with single "Join Conference" button + 'matrix': false, // enable Matrix Widget support (mostly auto-detected; implies 'joinDialog') + 'avatarurl': '', // download and set the user's Mumble avatar to the image at this URL + // General + //'theme': 'MetroMumbleLight' + 'theme': 'MetroMumbleDark' + } +} diff --git a/production/mumble-web/docker-compose.yml b/production/mumble-web/docker-compose.yml new file mode 100644 index 0000000..0663ea8 --- /dev/null +++ b/production/mumble-web/docker-compose.yml @@ -0,0 +1,32 @@ +version: '2' + +services: + mumble-web: + build: ./mumble-web + hostname: mumble-web + container_name: mumble-web + restart: always + entrypoint: + - /bin/bash + - entrypoint.sh + expose: + - 8080 + volumes: + - ./entrypoint.sh:/home/node/mumble-web/entrypoint.sh + - ./config.js:/home/node/mumble-web/app/config.js:ro + - ./cert.pem:/home/node/mumble-web/cert.pem:ro + - ./key.pem:/home/node/mumble-web/key.pem:ro + - ./index.html:/home/node/mumble-web/app/index.html:ro + - ./index.js:/home/node/mumble-web/app/index.js:ro + - ./loc:/home/node/mumble-web/loc:ro + networks: + mynet: + ipv4_address: 172.60.0.101 + +networks: + mynet: + driver: bridge + ipam: + config: + - subnet: 172.60.0.0/24 + diff --git a/production/mumble-web/entrypoint.sh b/production/mumble-web/entrypoint.sh new file mode 100644 index 0000000..5c5405a --- /dev/null +++ b/production/mumble-web/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash +rm -rf dist && npm run build +websockify --ssl-target --web=/home/node/mumble-web/dist --key=/home/node/mumble-web/key.pem --cert=/home/node/mumble-web/cert.pem 8080 mumble.hatthieves.es:64738 + diff --git a/production/mumble-web/gencert.sh b/production/mumble-web/gencert.sh new file mode 100644 index 0000000..80f4272 --- /dev/null +++ b/production/mumble-web/gencert.sh @@ -0,0 +1 @@ +openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 365 -out cert.pem diff --git a/production/mumble-web/index.html b/production/mumble-web/index.html new file mode 100644 index 0000000..f36a0d6 --- /dev/null +++ b/production/mumble-web/index.html @@ -0,0 +1,708 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + diff --git a/production/mumble-web/index.js b/production/mumble-web/index.js new file mode 100644 index 0000000..8aae40a --- /dev/null +++ b/production/mumble-web/index.js @@ -0,0 +1,1165 @@ +import 'stream-browserify' // see https://github.com/ericgundrum/pouch-websocket-sync-example/commit/2a4437b013092cc7b2cd84cf1499172c84a963a3 +import 'subworkers' // polyfill for https://bugs.chromium.org/p/chromium/issues/detail?id=31666 +import url from 'url' +import ByteBuffer from 'bytebuffer' +import MumbleClient from 'mumble-client' +import WorkerBasedMumbleConnector from './worker-client' +import BufferQueueNode from 'web-audio-buffer-queue' +import audioContext from 'audio-context' +import ko from 'knockout' +import _dompurify from 'dompurify' +import keyboardjs from 'keyboardjs' + +import { ContinuousVoiceHandler, PushToTalkVoiceHandler, VADVoiceHandler, initVoice } from './voice' +import {initialize as localizationInitialize, translate} from './loc'; + +const dompurify = _dompurify(window) + +function sanitize (html) { + return dompurify.sanitize(html, { + ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p'] + }) +} + +function openContextMenu (event, contextMenu, target) { + contextMenu.posX(event.clientX) + contextMenu.posY(event.clientY) + contextMenu.target(target) + + const closeListener = (event) => { + // Always close, no matter where they clicked + setTimeout(() => { // delay to allow click to be actually processed + contextMenu.target(null) + unregister() + }) + } + const unregister = () => document.removeEventListener('click', closeListener) + document.addEventListener('click', closeListener) + + event.stopPropagation() + event.preventDefault() +} + +// GUI + +function ContextMenu () { + var self = this + self.posX = ko.observable() + self.posY = ko.observable() + self.target = ko.observable() +} + +function ConnectDialog () { + var self = this + self.use = ko.observable('') + self.address = ko.observable('') + self.port = ko.observable('') + self.tokenToAdd = ko.observable('') + self.selectedTokens = ko.observableArray([]) + self.tokens = ko.observableArray([]) + self.username = ko.observable('') + self.password = ko.observable('') + self.channelName = ko.observable('') + self.joinOnly = ko.observable(false) + self.visible = ko.observable(true) + self.show = self.visible.bind(self.visible, true) + self.hide = self.visible.bind(self.visible, false) + self.connect = function () { + self.hide() + ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName()) + } + + self.addToken = function() { + if ((self.tokenToAdd() != "") && (self.tokens.indexOf(self.tokenToAdd()) < 0)) { + self.tokens.push(self.tokenToAdd()) + } + self.tokenToAdd("") + } + + self.removeSelectedTokens = function() { + this.tokens.removeAll(this.selectedTokens()) + this.selectedTokens([]) + } +} + +function ConnectErrorDialog (connectDialog) { + var self = this + self.type = ko.observable(0) + self.reason = ko.observable('') + self.username = connectDialog.username + self.password = connectDialog.password + self.joinOnly = connectDialog.joinOnly + self.visible = ko.observable(false) + self.show = self.visible.bind(self.visible, true) + self.hide = self.visible.bind(self.visible, false) + self.connect = () => { + self.hide() + connectDialog.connect() + } +} + +class ConnectionInfo { + constructor (ui) { + this._ui = ui + this.visible = ko.observable(false) + this.serverVersion = ko.observable() + this.latencyMs = ko.observable(NaN) + this.latencyDeviation = ko.observable(NaN) + this.remoteHost = ko.observable() + this.remotePort = ko.observable() + this.maxBitrate = ko.observable(NaN) + this.currentBitrate = ko.observable(NaN) + this.maxBandwidth = ko.observable(NaN) + this.currentBandwidth = ko.observable(NaN) + this.codec = ko.observable() + + this.show = () => { + if (!ui.thisUser()) return + this.update() + this.visible(true) + } + this.hide = () => this.visible(false) + } + + update () { + let client = this._ui.client + + this.serverVersion(client.serverVersion) + + let dataStats = client.dataStats + if (dataStats) { + this.latencyMs(dataStats.mean) + this.latencyDeviation(Math.sqrt(dataStats.variance)) + } + this.remoteHost(this._ui.remoteHost()) + this.remotePort(this._ui.remotePort()) + + let spp = this._ui.settings.samplesPerPacket + let maxBitrate = client.getMaxBitrate(spp, false) + let maxBandwidth = client.maxBandwidth + let actualBitrate = client.getActualBitrate(spp, false) + let actualBandwidth = MumbleClient.calcEnforcableBandwidth(actualBitrate, spp, false) + this.maxBitrate(maxBitrate) + this.currentBitrate(actualBitrate) + this.maxBandwidth(maxBandwidth) + this.currentBandwidth(actualBandwidth) + this.codec('Opus') // only one supported for sending + } +} + +function CommentDialog () { + var self = this + self.visible = ko.observable(false) + self.show = function () { + self.visible(true) + } +} + +class SettingsDialog { + constructor (settings) { + this.voiceMode = ko.observable(settings.voiceMode) + this.pttKey = ko.observable(settings.pttKey) + this.pttKeyDisplay = ko.observable(settings.pttKey) + this.vadLevel = ko.observable(settings.vadLevel) + this.testVadLevel = ko.observable(0) + this.testVadActive = ko.observable(false) + this.showAvatars = ko.observable(settings.showAvatars()) + this.userCountInChannelName = ko.observable(settings.userCountInChannelName()) + // Need to wrap this in a pureComputed to make sure it's always numeric + let audioBitrate = ko.observable(settings.audioBitrate) + this.audioBitrate = ko.pureComputed({ + read: audioBitrate, + write: (value) => audioBitrate(Number(value)) + }) + this.samplesPerPacket = ko.observable(settings.samplesPerPacket) + this.msPerPacket = ko.pureComputed({ + read: () => this.samplesPerPacket() / 48, + write: (value) => this.samplesPerPacket(value * 48) + }) + + this._setupTestVad() + this.vadLevel.subscribe(() => this._setupTestVad()) + } + + _setupTestVad () { + if (this._testVad) { + this._testVad.end() + } + let dummySettings = new Settings({}) + this.applyTo(dummySettings) + this._testVad = new VADVoiceHandler(null, dummySettings) + this._testVad.on('started_talking', () => this.testVadActive(true)) + .on('stopped_talking', () => this.testVadActive(false)) + .on('level', level => this.testVadLevel(level)) + testVoiceHandler = this._testVad + } + + applyTo (settings) { + settings.voiceMode = this.voiceMode() + settings.pttKey = this.pttKey() + settings.vadLevel = this.vadLevel() + settings.showAvatars(this.showAvatars()) + settings.userCountInChannelName(this.userCountInChannelName()) + settings.audioBitrate = this.audioBitrate() + settings.samplesPerPacket = this.samplesPerPacket() + } + + end () { + this._testVad.end() + testVoiceHandler = null + } + + recordPttKey () { + var combo = [] + const keydown = e => { + combo = e.pressedKeys + let comboStr = combo.join(' + ') + this.pttKeyDisplay('> ' + comboStr + ' <') + } + const keyup = () => { + keyboardjs.unbind('', keydown, keyup) + let comboStr = combo.join(' + ') + if (comboStr) { + this.pttKey(comboStr).pttKeyDisplay(comboStr) + } else { + this.pttKeyDisplay(this.pttKey()) + } + } + keyboardjs.bind('', keydown, keyup) + this.pttKeyDisplay('> ? <') + } + + totalBandwidth () { + return MumbleClient.calcEnforcableBandwidth( + this.audioBitrate(), + this.samplesPerPacket(), + true + ) + } + + positionBandwidth () { + return this.totalBandwidth() - MumbleClient.calcEnforcableBandwidth( + this.audioBitrate(), + this.samplesPerPacket(), + false + ) + } + + overheadBandwidth () { + return MumbleClient.calcEnforcableBandwidth( + 0, + this.samplesPerPacket(), + false + ) + } +} + +class Settings { + constructor (defaults) { + const load = key => window.localStorage.getItem('mumble.' + key) + this.voiceMode = load('voiceMode') || defaults.voiceMode + this.pttKey = load('pttKey') || defaults.pttKey + this.vadLevel = load('vadLevel') || defaults.vadLevel + this.toolbarVertical = load('toolbarVertical') || defaults.toolbarVertical + this.showAvatars = ko.observable(load('showAvatars') || defaults.showAvatars) + this.userCountInChannelName = ko.observable(load('userCountInChannelName') || defaults.userCountInChannelName) + this.audioBitrate = Number(load('audioBitrate')) || defaults.audioBitrate + this.samplesPerPacket = Number(load('samplesPerPacket')) || defaults.samplesPerPacket + } + + save () { + const save = (key, val) => window.localStorage.setItem('mumble.' + key, val) + save('voiceMode', this.voiceMode) + save('pttKey', this.pttKey) + save('vadLevel', this.vadLevel) + save('toolbarVertical', this.toolbarVertical) + save('showAvatars', this.showAvatars()) + save('userCountInChannelName', this.userCountInChannelName()) + save('audioBitrate', this.audioBitrate) + save('samplesPerPacket', this.samplesPerPacket) + } +} + +class GlobalBindings { + constructor (config) { + this.config = config + this.settings = new Settings(config.settings) + this.connector = new WorkerBasedMumbleConnector() + this.client = null + this.userContextMenu = new ContextMenu() + this.channelContextMenu = new ContextMenu() + this.connectDialog = new ConnectDialog() + this.connectErrorDialog = new ConnectErrorDialog(this.connectDialog) + this.connectionInfo = new ConnectionInfo(this) + this.commentDialog = new CommentDialog() + this.settingsDialog = ko.observable() + this.minimalView = ko.observable(false) + this.log = ko.observableArray() + this.remoteHost = ko.observable() + this.remotePort = ko.observable() + this.thisUser = ko.observable() + this.root = ko.observable() + this.avatarView = ko.observable() + this.messageBox = ko.observable('') + this.toolbarHorizontal = ko.observable(!this.settings.toolbarVertical) + this.selected = ko.observable() + this.selfMute = ko.observable() + this.selfDeaf = ko.observable() + + this.selfMute.subscribe(mute => { + if (voiceHandler) { + voiceHandler.setMute(mute) + } + }) + + this.toggleToolbarOrientation = () => { + this.toolbarHorizontal(!this.toolbarHorizontal()) + this.settings.toolbarVertical = !this.toolbarHorizontal() + this.settings.save() + } + + this.select = element => { + this.selected(element) + } + + this.openSettings = () => { + this.settingsDialog(new SettingsDialog(this.settings)) + } + + this.applySettings = () => { + const settingsDialog = this.settingsDialog() + + settingsDialog.applyTo(this.settings) + + this._updateVoiceHandler() + + this.settings.save() + this.closeSettings() + } + + this.closeSettings = () => { + if (this.settingsDialog()) { + this.settingsDialog().end() + } + this.settingsDialog(null) + } + + this.getTimeString = () => { + return '[' + new Date().toLocaleTimeString('en-US') + ']' + } + + this.connect = (username, host, port, tokens = [], password, channelName = "") => { + this.resetClient() + + this.remoteHost(host) + this.remotePort(port) + + log(translate('logentry.connecting'), host) + + // Note: This call needs to be delayed until the user has interacted with + // the page in some way (which at this point they have), see: https://goo.gl/7K7WLu + this.connector.setSampleRate(audioContext().sampleRate) + + // TODO: token + this.connector.connect(`wss://${host}:${port}`, { + username: username, + password: password, + tokens: tokens + }).done(client => { + log(translate('logentry.connected')) + + this.client = client + // Prepare for connection errors + client.on('error', (err) => { + log(translate('logentry.connection_error'), err) + this.resetClient() + }) + + // Make sure we stay open if we're running as Matrix widget + window.matrixWidget.setAlwaysOnScreen(true) + + // Register all channels, recursively + if(channelName.indexOf("/") != 0) { + channelName = "/"+channelName; + } + const registerChannel = (channel, channelPath) => { + this._newChannel(channel) + if(channelPath === channelName) { + client.self.setChannel(channel) + } + channel.children.forEach(ch => registerChannel(ch, channelPath+"/"+ch.name)) + } + registerChannel(client.root, "") + + // Register all users + client.users.forEach(user => this._newUser(user)) + + // Register future channels + client.on('newChannel', channel => this._newChannel(channel)) + // Register future users + client.on('newUser', user => this._newUser(user)) + + // Handle messages + client.on('message', (sender, message, users, channels, trees) => { + sender = sender || { __ui: 'Server' } + ui.log.push({ + type: 'chat-message', + user: sender.__ui, + channel: channels.length > 0, + message: sanitize(message) + }) + }) + + // Log permission denied error messages + client.on('denied', (type) => { + ui.log.push({ + type: 'generic', + value: 'Permission denied : '+ type + }) + }) + + // Set own user and root channel + this.thisUser(client.self.__ui) + this.root(client.root.__ui) + // Upate linked channels + this._updateLinks() + // Log welcome message + if (client.welcomeMessage) { + this.log.push({ + type: 'welcome-message', + message: sanitize(client.welcomeMessage) + }) + } + + // Startup audio input processing + this._updateVoiceHandler() + // Tell server our mute/deaf state (if necessary) + if (this.selfDeaf()) { + this.client.setSelfDeaf(true) + } else if (this.selfMute()) { + this.client.setSelfMute(true) + } + }, err => { + if (err.$type && err.$type.name === 'Reject') { + this.connectErrorDialog.type(err.type) + this.connectErrorDialog.reason(err.reason) + this.connectErrorDialog.show() + } else { + log(translate('logentry.connection_error'), err) + } + }) + } + + this._newUser = user => { + const simpleProperties = { + uniqueId: 'uid', + username: 'name', + mute: 'mute', + deaf: 'deaf', + suppress: 'suppress', + selfMute: 'selfMute', + selfDeaf: 'selfDeaf', + texture: 'rawTexture', + textureHash: 'textureHash', + comment: 'comment' + } + var ui = user.__ui = { + model: user, + talking: ko.observable('off'), + channel: ko.observable() + } + ui.texture = ko.pureComputed(() => { + let raw = ui.rawTexture() + if (!raw || raw.offset >= raw.limit) return null + return 'data:image/*;base64,' + ByteBuffer.wrap(raw).toBase64() + }) + ui.show_avatar = () => { + let setting = this.settings.showAvatars() + switch (setting) { + case 'always': + break + case 'own_channel': + if (this.thisUser().channel() !== ui.channel()) return false + break + case 'linked_channel': + if (!ui.channel().linked()) return false + break + case 'minimal_only': + if (!this.minimalView()) return false + if (this.thisUser().channel() !== ui.channel()) return false + break + case 'never': + default: return false + } + if (!ui.texture()) { + if (ui.textureHash()) { + // The user has an avatar set but it's of sufficient size to not be + // included by default, so we need to fetch it explicitly now. + // mumble-client should make sure we only send one request per hash + user.requestTexture() + } + return false + } + return true + } + ui.openContextMenu = (_, event) => openContextMenu(event, this.userContextMenu, ui) + ui.canChangeMute = () => { + return false // TODO check for perms and implement + } + ui.canChangeDeafen = () => { + return false // TODO check for perms and implement + } + ui.canChangePrioritySpeaker = () => { + return false // TODO check for perms and implement + } + ui.canLocalMute = () => { + return false // TODO implement local mute + // return this.thisUser() !== ui + } + ui.canIgnoreMessages = () => { + return false // TODO implement ignore messages + // return this.thisUser() !== ui + } + ui.canChangeComment = () => { + return false // TODO implement changing of comments + // return this.thisUser() === ui // TODO check for perms + } + ui.canChangeAvatar = () => { + return this.thisUser() === ui // TODO check for perms + } + ui.toggleMute = () => { + if (ui.selfMute()) { + this.requestUnmute(ui) + } else { + this.requestMute(ui) + } + } + ui.toggleDeaf = () => { + if (ui.selfDeaf()) { + this.requestUndeaf(ui) + } else { + this.requestDeaf(ui) + } + } + ui.viewAvatar = () => { + this.avatarView(ui.texture()) + } + ui.changeAvatar = () => { + let input = document.createElement('input') + input.type = 'file' + input.addEventListener('change', () => { + let reader = new window.FileReader() + reader.onload = () => { + this.client.setSelfTexture(reader.result) + } + reader.readAsArrayBuffer(input.files[0]) + }) + input.click() + } + ui.removeAvatar = () => { + user.clearTexture() + } + Object.entries(simpleProperties).forEach(key => { + ui[key[1]] = ko.observable(user[key[0]]) + }) + ui.state = ko.pureComputed(userToState, ui) + if (user.channel) { + ui.channel(user.channel.__ui) + ui.channel().users.push(ui) + ui.channel().users.sort(compareUsers) + } + + user.on('update', (actor, properties) => { + Object.entries(simpleProperties).forEach(key => { + if (properties[key[0]] !== undefined) { + ui[key[1]](properties[key[0]]) + } + }) + if (properties.channel !== undefined) { + if (ui.channel()) { + ui.channel().users.remove(ui) + } + ui.channel(properties.channel.__ui) + ui.channel().users.push(ui) + ui.channel().users.sort(compareUsers) + this._updateLinks() + } + if (properties.textureHash !== undefined) { + // Invalidate avatar texture when its hash has changed + // If the avatar is still visible, this will trigger a fetch of the new one. + ui.rawTexture(null) + } + }).on('remove', () => { + if (ui.channel()) { + ui.channel().users.remove(ui) + } + }).on('voice', stream => { + console.log(`User ${user.username} started takling`) + var userNode = new BufferQueueNode({ + audioContext: audioContext() + }) + userNode.connect(audioContext().destination) + + stream.on('data', data => { + if (data.target === 'normal') { + ui.talking('on') + } else if (data.target === 'shout') { + ui.talking('shout') + } else if (data.target === 'whisper') { + ui.talking('whisper') + } + userNode.write(data.buffer) + }).on('end', () => { + console.log(`User ${user.username} stopped takling`) + ui.talking('off') + userNode.end() + }) + }) + } + + this._newChannel = channel => { + const simpleProperties = { + position: 'position', + name: 'name', + description: 'description' + } + var ui = channel.__ui = { + model: channel, + expanded: ko.observable(true), + parent: ko.observable(), + channels: ko.observableArray(), + users: ko.observableArray(), + linked: ko.observable(false) + } + ui.userCount = () => { + return ui.channels().reduce((acc, c) => acc + c.userCount(), ui.users().length) + } + ui.openContextMenu = (_, event) => openContextMenu(event, this.channelContextMenu, ui) + ui.canJoin = () => { + return true // TODO check for perms + } + ui.canAdd = () => { + return false // TODO check for perms and implement + } + ui.canEdit = () => { + return false // TODO check for perms and implement + } + ui.canRemove = () => { + return false // TODO check for perms and implement + } + ui.canLink = () => { + return false // TODO check for perms and implement + } + ui.canUnlink = () => { + return false // TODO check for perms and implement + } + ui.canSendMessage = () => { + return false // TODO check for perms and implement + } + Object.entries(simpleProperties).forEach(key => { + ui[key[1]] = ko.observable(channel[key[0]]) + }) + if (channel.parent) { + ui.parent(channel.parent.__ui) + ui.parent().channels.push(ui) + ui.parent().channels.sort(compareChannels) + } + this._updateLinks() + + channel.on('update', properties => { + Object.entries(simpleProperties).forEach(key => { + if (properties[key[0]] !== undefined) { + ui[key[1]](properties[key[0]]) + } + }) + if (properties.parent !== undefined) { + if (ui.parent()) { + ui.parent().channel.remove(ui) + } + ui.parent(properties.parent.__ui) + ui.parent().channels.push(ui) + ui.parent().channels.sort(compareChannels) + } + if (properties.links !== undefined) { + this._updateLinks() + } + }).on('remove', () => { + if (ui.parent()) { + ui.parent().channels.remove(ui) + } + this._updateLinks() + }) + } + + this.resetClient = () => { + if (this.client) { + this.client.disconnect() + } + this.client = null + this.selected(null).root(null).thisUser(null) + } + + this.connected = () => this.thisUser() != null + + this._updateVoiceHandler = () => { + if (!this.client) { + return + } + if (voiceHandler) { + voiceHandler.end() + voiceHandler = null + } + let mode = this.settings.voiceMode + if (mode === 'cont') { + voiceHandler = new ContinuousVoiceHandler(this.client, this.settings) + } else if (mode === 'ptt') { + voiceHandler = new PushToTalkVoiceHandler(this.client, this.settings) + } else if (mode === 'vad') { + voiceHandler = new VADVoiceHandler(this.client, this.settings) + } else { + log(translate('logentry.unknown_voice_mode'), mode) + return + } + voiceHandler.on('started_talking', () => { + if (this.thisUser()) { + this.thisUser().talking('on') + } + }) + voiceHandler.on('stopped_talking', () => { + if (this.thisUser()) { + this.thisUser().talking('off') + } + }) + if (this.selfMute()) { + voiceHandler.setMute(true) + } + + this.client.setAudioQuality( + this.settings.audioBitrate, + this.settings.samplesPerPacket + ) + } + + this.messageBoxHint = ko.pureComputed(() => { + if (!this.thisUser()) { + return '' // Not yet connected + } + var target = this.selected() + if (!target) { + target = this.thisUser() + } + if (target === this.thisUser()) { + target = target.channel() + } + if (target.users) { // Channel + return translate('chat.channel_message_placeholder') + .replace('%1', target.name()) + } else { // User + return translate('chat.user_message_placeholder') + .replace('%1', target.name()) + } + }) + + this.submitMessageBox = () => { + this.sendMessage(this.selected(), this.messageBox()) + this.messageBox('') + } + + this.sendMessage = (target, message) => { + if (this.connected()) { + // If no target is selected, choose our own user + if (!target) { + target = this.thisUser() + } + // If target is our own user, send to our channel + if (target === this.thisUser()) { + target = target.channel() + } + // Send message + target.model.sendMessage(message) + if (target.users) { // Channel + this.log.push({ + type: 'chat-message-self', + message: sanitize(message), + channel: target + }) + } else { // User + this.log.push({ + type: 'chat-message-self', + message: sanitize(message), + user: target + }) + } + } + } + + this.requestMove = (user, channel) => { + if (this.connected()) { + user.model.setChannel(channel.model) + + let currentUrl = url.parse(document.location.href, true) + // delete search param so that query one can be taken into account + delete currentUrl.search + + // get full channel path + if( channel.parent() ){ // in case this channel is not Root + let parent = channel.parent() + currentUrl.query.channelName = channel.name() + while( parent.parent() ){ + currentUrl.query.channelName = parent.name() + '/' + currentUrl.query.channelName + parent = parent.parent() + } + } else { + // there is no channelName as we moved to Root + delete currentUrl.query.channelName + } + + // reflect this change in URL + window.history.pushState(null, channel.name(), url.format(currentUrl)) + } + } + + this.requestMute = user => { + if (user === this.thisUser()) { + this.selfMute(true) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfMute(true) + } else { + user.model.setMute(true) + } + } + } + + this.requestDeaf = user => { + if (user === this.thisUser()) { + this.selfMute(true) + this.selfDeaf(true) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfDeaf(true) + } else { + user.model.setDeaf(true) + } + } + } + + this.requestUnmute = user => { + if (user === this.thisUser()) { + this.selfMute(false) + this.selfDeaf(false) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfMute(false) + } else { + user.model.setMute(false) + } + } + } + + this.requestUndeaf = user => { + if (user === this.thisUser()) { + this.selfDeaf(false) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfDeaf(false) + } else { + user.model.setDeaf(false) + } + } + } + + this._updateLinks = () => { + if (!this.thisUser()) { + return + } + + var allChannels = getAllChannels(this.root(), []) + var ownChannel = this.thisUser().channel().model + var allLinked = findLinks(ownChannel, []) + allChannels.forEach(channel => { + channel.linked(allLinked.indexOf(channel.model) !== -1) + }) + + function findLinks (channel, knownLinks) { + knownLinks.push(channel) + channel.links.forEach(next => { + if (next && knownLinks.indexOf(next) === -1) { + findLinks(next, knownLinks) + } + }) + allChannels.map(c => c.model).forEach(next => { + if (next && knownLinks.indexOf(next) === -1 && next.links.indexOf(channel) !== -1) { + findLinks(next, knownLinks) + } + }) + return knownLinks + } + + function getAllChannels (channel, channels) { + channels.push(channel) + channel.channels().forEach(next => getAllChannels(next, channels)) + return channels + } + } + + this.openSourceCode = () => { + var homepage = require('../package.json').homepage + window.open(homepage, '_blank').focus() + } + + this.updateSize = () => { + this.minimalView(window.innerWidth < 320) + if (this.minimalView()) { + this.toolbarHorizontal(window.innerWidth < window.innerHeight) + } else { + this.toolbarHorizontal(!this.settings.toolbarVertical) + } + } + } +} +var ui = new GlobalBindings(window.mumbleWebConfig) + +// Used only for debugging +window.mumbleUi = ui + +function initializeUI () { + var queryParams = url.parse(document.location.href, true).query + queryParams = Object.assign({}, window.mumbleWebConfig.defaults, queryParams) + var useJoinDialog = queryParams.joinDialog + if (queryParams.matrix) { + useJoinDialog = true + } + if (queryParams.address) { + ui.connectDialog.address(queryParams.address) + } else { + useJoinDialog = false + } + if (queryParams.port) { + ui.connectDialog.port(queryParams.port) + } else { + useJoinDialog = false + } + if (queryParams.token) { + var tokens = queryParams.token + if (!Array.isArray(tokens)) { + tokens = [tokens] + } + ui.connectDialog.tokens(tokens) + } + if (queryParams.username) { + ui.connectDialog.username(queryParams.username) + } else { + useJoinDialog = false + } + if (queryParams.password) { + ui.connectDialog.password(queryParams.password) + } + if (queryParams.channelName) { + ui.connectDialog.channelName(queryParams.channelName) + } + if (queryParams.avatarurl) { + // Download the avatar and upload it to the mumble server when connected + let url = queryParams.avatarurl + console.log('Fetching avatar from', url) + let req = new window.XMLHttpRequest() + req.open('GET', url, true) + req.responseType = 'arraybuffer' + req.onload = () => { + let upload = (avatar) => { + if (req.response) { + console.log('Uploading user avatar to server') + ui.client.setSelfTexture(req.response) + } + } + // On any future connections + ui.thisUser.subscribe((thisUser) => { + if (thisUser) { + upload() + } + }) + // And the current one (if already connected) + if (ui.thisUser()) { + upload() + } + } + req.send() + } + ui.connectDialog.joinOnly(useJoinDialog) + ko.applyBindings(ui) + + window.onresize = () => ui.updateSize() + ui.updateSize() +} + +function log () { + console.log.apply(console, arguments) + var args = [] + for (var i = 0; i < arguments.length; i++) { + args.push(arguments[i]) + } + ui.log.push({ + type: 'generic', + value: args.join(' ') + }) +} + +function compareChannels (c1, c2) { + if (c1.position() === c2.position()) { + return c1.name() === c2.name() ? 0 : c1.name() < c2.name() ? -1 : 1 + } + return c1.position() - c2.position() +} + +function compareUsers (u1, u2) { + return u1.name() === u2.name() ? 0 : u1.name() < u2.name() ? -1 : 1 +} + +function userToState () { + var flags = [] + // TODO: Friend + if (this.uid()) { + flags.push('Authenticated') + } + // TODO: Priority Speaker, Recording + if (this.mute()) { + flags.push('Muted (server)') + } + if (this.deaf()) { + flags.push('Deafened (server)') + } + // TODO: Local Ignore (Text messages), Local Mute + if (this.selfMute()) { + flags.push('Muted (self)') + } + if (this.selfDeaf()) { + flags.push('Deafened (self)') + } + return flags.join(', ') +} + +var voiceHandler +var testVoiceHandler + +/** + * @author svartoyg + */ +function translatePiece(selector, kind, parameters, key) { + let element = document.querySelector(selector); + if (element !== null) { + const translation = translate(key); + switch (kind) { + default: + console.warn('unhandled dom translation kind "' + kind + '"'); + break; + case 'textcontent': + element.textContent = translation; + break; + case 'attribute': + element.setAttribute(parameters.name || 'value', translation); + break; + } + } else { + console.warn(`translation selector "${selector}" for "${key}" did not match any element`) + } +} + +/** + * @author svartoyg + */ +function translateEverything() { + translatePiece('#connect-dialog_use', 'textcontent', {}, 'connectdialog.use'); + translatePiece('#connect-dialog_title', 'textcontent', {}, 'connectdialog.title'); + translatePiece('#connect-dialog_input_address', 'textcontent', {}, 'connectdialog.address'); + translatePiece('#connect-dialog_input_port', 'textcontent', {}, 'connectdialog.port'); + translatePiece('#connect-dialog_input_username', 'textcontent', {}, 'connectdialog.username'); + translatePiece('#connect-dialog_input_password', 'textcontent', {}, 'connectdialog.password'); + translatePiece('#connect-dialog_input_tokens', 'textcontent', {}, 'connectdialog.tokens'); + translatePiece('#connect-dialog_controls_remove', 'textcontent', {}, 'connectdialog.remove'); + translatePiece('#connect-dialog_controls_add', 'textcontent', {}, 'connectdialog.add'); + translatePiece('#connect-dialog_controls_cancel', 'attribute', {'name': 'value'}, 'connectdialog.cancel'); + translatePiece('#connect-dialog_controls_connect', 'attribute', {'name': 'value'}, 'connectdialog.connect'); + translatePiece('.connect-dialog.error-dialog .dialog-header', 'textcontent', {}, 'connectdialog.error.title'); + translatePiece('.connect-dialog.error-dialog .reason .refused', 'textcontent', {}, 'connectdialog.error.reason.refused'); + translatePiece('.connect-dialog.error-dialog .reason .version', 'textcontent', {}, 'connectdialog.error.reason.version'); + translatePiece('.connect-dialog.error-dialog .reason .username', 'textcontent', {}, 'connectdialog.error.reason.username'); + translatePiece('.connect-dialog.error-dialog .reason .userpassword', 'textcontent', {}, 'connectdialog.error.reason.userpassword'); + translatePiece('.connect-dialog.error-dialog .reason .serverpassword', 'textcontent', {}, 'connectdialog.error.reason.serverpassword'); + translatePiece('.connect-dialog.error-dialog .reason .username-in-use', 'textcontent', {}, 'connectdialog.error.reason.username_in_use'); + translatePiece('.connect-dialog.error-dialog .reason .full', 'textcontent', {}, 'connectdialog.error.reason.full'); + translatePiece('.connect-dialog.error-dialog .reason .clientcert', 'textcontent', {}, 'connectdialog.error.reason.clientcert'); + translatePiece('.connect-dialog.error-dialog .reason .server', 'textcontent', {}, 'connectdialog.error.reason.server'); + translatePiece('.connect-dialog.error-dialog .alternate-username', 'textcontent', {}, 'connectdialog.username'); + translatePiece('.connect-dialog.error-dialog .alternate-password', 'textcontent', {}, 'connectdialog.password'); + translatePiece('.connect-dialog.error-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'connectdialog.error.retry'); + translatePiece('.connect-dialog.error-dialog .dialog-close', 'attribute', {'name': 'value'}, 'connectdialog.error.cancel'); + translatePiece('.join-dialog .dialog-header', 'textcontent', {}, 'joindialog.title'); + translatePiece('.join-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'joindialog.connect'); + translatePiece('.user-context-menu .mute', 'textcontent', {}, 'usercontextmenu.mute'); + translatePiece('.user-context-menu .deafen', 'textcontent', {}, 'usercontextmenu.deafen'); + translatePiece('.user-context-menu .priority-speaker', 'textcontent', {}, 'usercontextmenu.priority_speaker'); + translatePiece('.user-context-menu .local-mute', 'textcontent', {}, 'usercontextmenu.local_mute'); + translatePiece('.user-context-menu .ignore-messages', 'textcontent', {}, 'usercontextmenu.ignore_messages'); + translatePiece('.user-context-menu .view-comment', 'textcontent', {}, 'usercontextmenu.view_comment'); + translatePiece('.user-context-menu .change-comment', 'textcontent', {}, 'usercontextmenu.change_comment'); + translatePiece('.user-context-menu .reset-comment', 'textcontent', {}, 'usercontextmenu.reset_comment'); + translatePiece('.user-context-menu .view-avatar', 'textcontent', {}, 'usercontextmenu.view_avatar'); + translatePiece('.user-context-menu .change-avatar', 'textcontent', {}, 'usercontextmenu.change_avatar'); + translatePiece('.user-context-menu .reset-avatar', 'textcontent', {}, 'usercontextmenu.reset_avatar'); + translatePiece('.user-context-menu .send-message', 'textcontent', {}, 'usercontextmenu.send_message'); + translatePiece('.user-context-menu .information', 'textcontent', {}, 'usercontextmenu.information'); + translatePiece('.user-context-menu .self-mute', 'textcontent', {}, 'usercontextmenu.self_mute'); + translatePiece('.user-context-menu .self-deafen', 'textcontent', {}, 'usercontextmenu.self_deafen'); + translatePiece('.user-context-menu .add-friend', 'textcontent', {}, 'usercontextmenu.add_friend'); + translatePiece('.user-context-menu .remove-friend', 'textcontent', {}, 'usercontextmenu.remove_friend'); + translatePiece('.channel-context-menu .join', 'textcontent', {}, 'channelcontextmenu.join'); + translatePiece('.channel-context-menu .add', 'textcontent', {}, 'channelcontextmenu.add'); + translatePiece('.channel-context-menu .edit', 'textcontent', {}, 'channelcontextmenu.edit'); + translatePiece('.channel-context-menu .remove', 'textcontent', {}, 'channelcontextmenu.remove'); + translatePiece('.channel-context-menu .link', 'textcontent', {}, 'channelcontextmenu.link'); + translatePiece('.channel-context-menu .unlink', 'textcontent', {}, 'channelcontextmenu.unlink'); + translatePiece('.channel-context-menu .unlink-all', 'textcontent', {}, 'channelcontextmenu.unlink_all'); + translatePiece('.channel-context-menu .copy-mumble-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_url'); + translatePiece('.channel-context-menu .copy-mumble-web-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_web_url'); + translatePiece('.channel-context-menu .send-message', 'textcontent', {}, 'channelcontextmenu.send_message'); +} + +async function main() { + await localizationInitialize(navigator.language); + translateEverything(); + initializeUI(); + initVoice(data => { + if (testVoiceHandler) { + testVoiceHandler.write(data) + } + if (!ui.client) { + if (voiceHandler) { + voiceHandler.end() + } + voiceHandler = null + } else if (voiceHandler) { + var hat = document.getElementById('hat') + hat.addEventListener('mouseover', function(event) { + voiceHandler._keydown_handler() + }) + hat.addEventListener('touchstart', function(event) { + voiceHandler._keydown_handler() + }) + hat.addEventListener('mouseout', function(event) { + voiceHandler._keyup_handler() + }) + hat.addEventListener('touchend', function(event) { + voiceHandler._keyup_handler() + }) + voiceHandler.write(data) + } + }, err => { + log(translate('logentry.mic_init_error'), err) + }) +} + +window.onload = main diff --git a/production/mumble-web/index.js.save b/production/mumble-web/index.js.save new file mode 100644 index 0000000..3871e55 --- /dev/null +++ b/production/mumble-web/index.js.save @@ -0,0 +1,1153 @@ +import 'stream-browserify' // see https://github.com/ericgundrum/pouch-websocket-sync-example/commit/2a4437b013092cc7b2cd84cf1499172c84a963a3 +import 'subworkers' // polyfill for https://bugs.chromium.org/p/chromium/issues/detail?id=31666 +import url from 'url' +import ByteBuffer from 'bytebuffer' +import MumbleClient from 'mumble-client' +import WorkerBasedMumbleConnector from './worker-client' +import BufferQueueNode from 'web-audio-buffer-queue' +import audioContext from 'audio-context' +import ko from 'knockout' +import _dompurify from 'dompurify' +import keyboardjs from 'keyboardjs' + +import { ContinuousVoiceHandler, PushToTalkVoiceHandler, VADVoiceHandler, initVoice } from './voice' +import {initialize as localizationInitialize, translate} from './loc'; + +const dompurify = _dompurify(window) + +function sanitize (html) { + return dompurify.sanitize(html, { + ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p'] + }) +} + +function openContextMenu (event, contextMenu, target) { + contextMenu.posX(event.clientX) + contextMenu.posY(event.clientY) + contextMenu.target(target) + + const closeListener = (event) => { + // Always close, no matter where they clicked + setTimeout(() => { // delay to allow click to be actually processed + contextMenu.target(null) + unregister() + }) + } + const unregister = () => document.removeEventListener('click', closeListener) + document.addEventListener('click', closeListener) + + event.stopPropagation() + event.preventDefault() +} + +// GUI + +function ContextMenu () { + var self = this + self.posX = ko.observable() + self.posY = ko.observable() + self.target = ko.observable() +} + +function ConnectDialog () { + var self = this + self.address = ko.observable('') + self.port = ko.observable('') + self.tokenToAdd = ko.observable('') + self.selectedTokens = ko.observableArray([]) + self.tokens = ko.observableArray([]) + self.username = ko.observable('') + self.password = ko.observable('') + self.channelName = ko.observable('') + self.joinOnly = ko.observable(false) + self.visible = ko.observable(true) + self.show = self.visible.bind(self.visible, true) + self.hide = self.visible.bind(self.visible, false) + self.connect = function () { + self.hide() + ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName()) + } + + self.addToken = function() { + if ((self.tokenToAdd() != "") && (self.tokens.indexOf(self.tokenToAdd()) < 0)) { + self.tokens.push(self.tokenToAdd()) + } + self.tokenToAdd("") + } + + self.removeSelectedTokens = function() { + this.tokens.removeAll(this.selectedTokens()) + this.selectedTokens([]) + } +} + +function ConnectErrorDialog (connectDialog) { + var self = this + self.type = ko.observable(0) + self.reason = ko.observable('') + self.username = connectDialog.username + self.password = connectDialog.password + self.joinOnly = connectDialog.joinOnly + self.visible = ko.observable(false) + self.show = self.visible.bind(self.visible, true) + self.hide = self.visible.bind(self.visible, false) + self.connect = () => { + self.hide() + connectDialog.connect() + } +} + +class ConnectionInfo { + constructor (ui) { + this._ui = ui + this.visible = ko.observable(false) + this.serverVersion = ko.observable() + this.latencyMs = ko.observable(NaN) + this.latencyDeviation = ko.observable(NaN) + this.remoteHost = ko.observable() + this.remotePort = ko.observable() + this.maxBitrate = ko.observable(NaN) + this.currentBitrate = ko.observable(NaN) + this.maxBandwidth = ko.observable(NaN) + this.currentBandwidth = ko.observable(NaN) + this.codec = ko.observable() + + this.show = () => { + if (!ui.thisUser()) return + this.update() + this.visible(true) + } + this.hide = () => this.visible(false) + } + + update () { + let client = this._ui.client + + this.serverVersion(client.serverVersion) + + let dataStats = client.dataStats + if (dataStats) { + this.latencyMs(dataStats.mean) + this.latencyDeviation(Math.sqrt(dataStats.variance)) + } + this.remoteHost(this._ui.remoteHost()) + this.remotePort(this._ui.remotePort()) + + let spp = this._ui.settings.samplesPerPacket + let maxBitrate = client.getMaxBitrate(spp, false) + let maxBandwidth = client.maxBandwidth + let actualBitrate = client.getActualBitrate(spp, false) + let actualBandwidth = MumbleClient.calcEnforcableBandwidth(actualBitrate, spp, false) + this.maxBitrate(maxBitrate) + this.currentBitrate(actualBitrate) + this.maxBandwidth(maxBandwidth) + this.currentBandwidth(actualBandwidth) + this.codec('Opus') // only one supported for sending + } +} + +function CommentDialog () { + var self = this + self.visible = ko.observable(false) + self.show = function () { + self.visible(true) + } +} + +class SettingsDialog { + constructor (settings) { + this.voiceMode = ko.observable(settings.voiceMode) + this.pttKey = ko.observable(settings.pttKey) + this.pttKeyDisplay = ko.observable(settings.pttKey) + this.vadLevel = ko.observable(settings.vadLevel) + this.testVadLevel = ko.observable(0) + this.testVadActive = ko.observable(false) + this.showAvatars = ko.observable(settings.showAvatars()) + this.userCountInChannelName = ko.observable(settings.userCountInChannelName()) + // Need to wrap this in a pureComputed to make sure it's always numeric + let audioBitrate = ko.observable(settings.audioBitrate) + this.audioBitrate = ko.pureComputed({ + read: audioBitrate, + write: (value) => audioBitrate(Number(value)) + }) + this.samplesPerPacket = ko.observable(settings.samplesPerPacket) + this.msPerPacket = ko.pureComputed({ + read: () => this.samplesPerPacket() / 48, + write: (value) => this.samplesPerPacket(value * 48) + }) + + this._setupTestVad() + this.vadLevel.subscribe(() => this._setupTestVad()) + } + + _setupTestVad () { + if (this._testVad) { + this._testVad.end() + } + let dummySettings = new Settings({}) + this.applyTo(dummySettings) + this._testVad = new VADVoiceHandler(null, dummySettings) + this._testVad.on('started_talking', () => this.testVadActive(true)) + .on('stopped_talking', () => this.testVadActive(false)) + .on('level', level => this.testVadLevel(level)) + testVoiceHandler = this._testVad + } + + applyTo (settings) { + settings.voiceMode = this.voiceMode() + settings.pttKey = this.pttKey() + settings.vadLevel = this.vadLevel() + settings.showAvatars(this.showAvatars()) + settings.userCountInChannelName(this.userCountInChannelName()) + settings.audioBitrate = this.audioBitrate() + settings.samplesPerPacket = this.samplesPerPacket() + } + + end () { + this._testVad.end() + testVoiceHandler = null + } + + recordPttKey () { + var combo = [] + const keydown = e => { + combo = e.pressedKeys + let comboStr = combo.join(' + ') + this.pttKeyDisplay('> ' + comboStr + ' <') + } + const keyup = () => { + keyboardjs.unbind('', keydown, keyup) + let comboStr = combo.join(' + ') + if (comboStr) { + this.pttKey(comboStr).pttKeyDisplay(comboStr) + } else { + this.pttKeyDisplay(this.pttKey()) + } + } + keyboardjs.bind('', keydown, keyup) + this.pttKeyDisplay('> ? <') + } + + totalBandwidth () { + return MumbleClient.calcEnforcableBandwidth( + this.audioBitrate(), + this.samplesPerPacket(), + true + ) + } + + positionBandwidth () { + return this.totalBandwidth() - MumbleClient.calcEnforcableBandwidth( + this.audioBitrate(), + this.samplesPerPacket(), + false + ) + } + + overheadBandwidth () { + return MumbleClient.calcEnforcableBandwidth( + 0, + this.samplesPerPacket(), + false + ) + } +} + +class Settings { + constructor (defaults) { + const load = key => window.localStorage.getItem('mumble.' + key) + this.voiceMode = load('voiceMode') || defaults.voiceMode + this.pttKey = load('pttKey') || defaults.pttKey + this.vadLevel = load('vadLevel') || defaults.vadLevel + this.toolbarVertical = load('toolbarVertical') || defaults.toolbarVertical + this.showAvatars = ko.observable(load('showAvatars') || defaults.showAvatars) + this.userCountInChannelName = ko.observable(load('userCountInChannelName') || defaults.userCountInChannelName) + this.audioBitrate = Number(load('audioBitrate')) || defaults.audioBitrate + this.samplesPerPacket = Number(load('samplesPerPacket')) || defaults.samplesPerPacket + } + + save () { + const save = (key, val) => window.localStorage.setItem('mumble.' + key, val) + save('voiceMode', this.voiceMode) + save('pttKey', this.pttKey) + save('vadLevel', this.vadLevel) + save('toolbarVertical', this.toolbarVertical) + save('showAvatars', this.showAvatars()) + save('userCountInChannelName', this.userCountInChannelName()) + save('audioBitrate', this.audioBitrate) + save('samplesPerPacket', this.samplesPerPacket) + } +} + +class GlobalBindings { + constructor (config) { + this.config = config + this.settings = new Settings(config.settings) + this.connector = new WorkerBasedMumbleConnector() + this.client = null + this.userContextMenu = new ContextMenu() + this.channelContextMenu = new ContextMenu() + this.connectDialog = new ConnectDialog() + this.connectErrorDialog = new ConnectErrorDialog(this.connectDialog) + this.connectionInfo = new ConnectionInfo(this) + this.commentDialog = new CommentDialog() + this.settingsDialog = ko.observable() + this.minimalView = ko.observable(false) + this.log = ko.observableArray() + this.remoteHost = ko.observable() + this.remotePort = ko.observable() + this.thisUser = ko.observable() + this.root = ko.observable() + this.avatarView = ko.observable() + this.messageBox = ko.observable('') + this.toolbarHorizontal = ko.observable(!this.settings.toolbarVertical) + this.selected = ko.observable() + this.selfMute = ko.observable() + this.selfDeaf = ko.observable() + + this.selfMute.subscribe(mute => { + if (voiceHandler) { + voiceHandler.setMute(mute) + } + }) + + this.toggleToolbarOrientation = () => { + this.toolbarHorizontal(!this.toolbarHorizontal()) + this.settings.toolbarVertical = !this.toolbarHorizontal() + this.settings.save() + } + + this.select = element => { + this.selected(element) + } + + this.openSettings = () => { + this.settingsDialog(new SettingsDialog(this.settings)) + } + + this.applySettings = () => { + const settingsDialog = this.settingsDialog() + + settingsDialog.applyTo(this.settings) + + this._updateVoiceHandler() + + this.settings.save() + this.closeSettings() + } + + this.closeSettings = () => { + if (this.settingsDialog()) { + this.settingsDialog().end() + } + this.settingsDialog(null) + } + + this.getTimeString = () => { + return '[' + new Date().toLocaleTimeString('en-US') + ']' + } + + this.connect = (username, host, port, tokens = [], password, channelName = "") => { + this.resetClient() + + this.remoteHost(host) + this.remotePort(port) + + log(translate('logentry.connecting'), host) + + // Note: This call needs to be delayed until the user has interacted with + // the page in some way (which at this point they have), see: https://goo.gl/7K7WLu + this.connector.setSampleRate(audioContext().sampleRate) + + // TODO: token + this.connector.connect(`wss://${host}:${port}`, { + username: username, + password: password, + tokens: tokens + }).done(client => { + log(translate('logentry.connected')) + + this.client = client + // Prepare for connection errors + client.on('error', (err) => { + log(translate('logentry.connection_error'), err) + this.resetClient() + }) + + // Make sure we stay open if we're running as Matrix widget + window.matrixWidget.setAlwaysOnScreen(true) + + // Register all channels, recursively + if(channelName.indexOf("/") != 0) { + channelName = "/"+channelName; + } + const registerChannel = (channel, channelPath) => { + this._newChannel(channel) + if(channelPath === channelName) { + client.self.setChannel(channel) + } + channel.children.forEach(ch => registerChannel(ch, channelPath+"/"+ch.name)) + } + registerChannel(client.root, "") + + // Register all users + client.users.forEach(user => this._newUser(user)) + + // Register future channels + client.on('newChannel', channel => this._newChannel(channel)) + // Register future users + client.on('newUser', user => this._newUser(user)) + + // Handle messages + client.on('message', (sender, message, users, channels, trees) => { + sender = sender || { __ui: 'Server' } + ui.log.push({ + type: 'chat-message', + user: sender.__ui, + channel: channels.length > 0, + message: sanitize(message) + }) + }) + + // Log permission denied error messages + client.on('denied', (type) => { + ui.log.push({ + type: 'generic', + value: 'Permission denied : '+ type + }) + }) + + // Set own user and root channel + this.thisUser(client.self.__ui) + this.root(client.root.__ui) + // Upate linked channels + this._updateLinks() + // Log welcome message + if (client.welcomeMessage) { + this.log.push({ + type: 'welcome-message', + message: sanitize(client.welcomeMessage) + }) + } + + // Startup audio input processing + this._updateVoiceHandler() + // Tell server our mute/deaf state (if necessary) + if (this.selfDeaf()) { + this.client.setSelfDeaf(true) + } else if (this.selfMute()) { + this.client.setSelfMute(true) + } + }, err => { + if (err.$type && err.$type.name === 'Reject') { + this.connectErrorDialog.type(err.type) + this.connectErrorDialog.reason(err.reason) + this.connectErrorDialog.show() + } else { + log(translate('logentry.connection_error'), err) + } + }) + } + + this._newUser = user => { + const simpleProperties = { + uniqueId: 'uid', + username: 'name', + mute: 'mute', + deaf: 'deaf', + suppress: 'suppress', + selfMute: 'selfMute', + selfDeaf: 'selfDeaf', + texture: 'rawTexture', + textureHash: 'textureHash', + comment: 'comment' + } + var ui = user.__ui = { + model: user, + talking: ko.observable('off'), + channel: ko.observable() + } + ui.texture = ko.pureComputed(() => { + let raw = ui.rawTexture() + if (!raw || raw.offset >= raw.limit) return null + return 'data:image/*;base64,' + ByteBuffer.wrap(raw).toBase64() + }) + ui.show_avatar = () => { + let setting = this.settings.showAvatars() + switch (setting) { + case 'always': + break + case 'own_channel': + if (this.thisUser().channel() !== ui.channel()) return false + break + case 'linked_channel': + if (!ui.channel().linked()) return false + break + case 'minimal_only': + if (!this.minimalView()) return false + if (this.thisUser().channel() !== ui.channel()) return false + break + case 'never': + default: return false + } + if (!ui.texture()) { + if (ui.textureHash()) { + // The user has an avatar set but it's of sufficient size to not be + // included by default, so we need to fetch it explicitly now. + // mumble-client should make sure we only send one request per hash + user.requestTexture() + } + return false + } + return true + } + ui.openContextMenu = (_, event) => openContextMenu(event, this.userContextMenu, ui) + ui.canChangeMute = () => { + return false // TODO check for perms and implement + } + ui.canChangeDeafen = () => { + return false // TODO check for perms and implement + } + ui.canChangePrioritySpeaker = () => { + return false // TODO check for perms and implement + } + ui.canLocalMute = () => { + return false // TODO implement local mute + // return this.thisUser() !== ui + } + ui.canIgnoreMessages = () => { + return false // TODO implement ignore messages + // return this.thisUser() !== ui + } + ui.canChangeComment = () => { + return false // TODO implement changing of comments + // return this.thisUser() === ui // TODO check for perms + } + ui.canChangeAvatar = () => { + return this.thisUser() === ui // TODO check for perms + } + ui.toggleMute = () => { + if (ui.selfMute()) { + this.requestUnmute(ui) + } else { + this.requestMute(ui) + } + } + ui.toggleDeaf = () => { + if (ui.selfDeaf()) { + this.requestUndeaf(ui) + } else { + this.requestDeaf(ui) + } + } + ui.viewAvatar = () => { + this.avatarView(ui.texture()) + } + ui.changeAvatar = () => { + let input = document.createElement('input') + input.type = 'file' + input.addEventListener('change', () => { + let reader = new window.FileReader() + reader.onload = () => { + this.client.setSelfTexture(reader.result) + } + reader.readAsArrayBuffer(input.files[0]) + }) + input.click() + } + ui.removeAvatar = () => { + user.clearTexture() + } + Object.entries(simpleProperties).forEach(key => { + ui[key[1]] = ko.observable(user[key[0]]) + }) + ui.state = ko.pureComputed(userToState, ui) + if (user.channel) { + ui.channel(user.channel.__ui) + ui.channel().users.push(ui) + ui.channel().users.sort(compareUsers) + } + + user.on('update', (actor, properties) => { + Object.entries(simpleProperties).forEach(key => { + if (properties[key[0]] !== undefined) { + ui[key[1]](properties[key[0]]) + } + }) + if (properties.channel !== undefined) { + if (ui.channel()) { + ui.channel().users.remove(ui) + } + ui.channel(properties.channel.__ui) + ui.channel().users.push(ui) + ui.channel().users.sort(compareUsers) + this._updateLinks() + } + if (properties.textureHash !== undefined) { + // Invalidate avatar texture when its hash has changed + // If the avatar is still visible, this will trigger a fetch of the new one. + ui.rawTexture(null) + } + }).on('remove', () => { + if (ui.channel()) { + ui.channel().users.remove(ui) + } + }).on('voice', stream => { + console.log(`User ${user.username} started takling`) + var userNode = new BufferQueueNode({ + audioContext: audioContext() + }) + userNode.connect(audioContext().destination) + + stream.on('data', data => { + if (data.target === 'normal') { + ui.talking('on') + } else if (data.target === 'shout') { + ui.talking('shout') + } else if (data.target === 'whisper') { + ui.talking('whisper') + } + userNode.write(data.buffer) + }).on('end', () => { + console.log(`User ${user.username} stopped takling`) + ui.talking('off') + userNode.end() + }) + }) + } + + this._newChannel = channel => { + const simpleProperties = { + position: 'position', + name: 'name', + description: 'description' + } + var ui = channel.__ui = { + model: channel, + expanded: ko.observable(true), + parent: ko.observable(), + channels: ko.observableArray(), + users: ko.observableArray(), + linked: ko.observable(false) + } + ui.userCount = () => { + return ui.channels().reduce((acc, c) => acc + c.userCount(), ui.users().length) + } + ui.openContextMenu = (_, event) => openContextMenu(event, this.channelContextMenu, ui) + ui.canJoin = () => { + return true // TODO check for perms + } + ui.canAdd = () => { + return false // TODO check for perms and implement + } + ui.canEdit = () => { + return false // TODO check for perms and implement + } + ui.canRemove = () => { + return false // TODO check for perms and implement + } + ui.canLink = () => { + return false // TODO check for perms and implement + } + ui.canUnlink = () => { + return false // TODO check for perms and implement + } + ui.canSendMessage = () => { + return false // TODO check for perms and implement + } + Object.entries(simpleProperties).forEach(key => { + ui[key[1]] = ko.observable(channel[key[0]]) + }) + if (channel.parent) { + ui.parent(channel.parent.__ui) + ui.parent().channels.push(ui) + ui.parent().channels.sort(compareChannels) + } + this._updateLinks() + + channel.on('update', properties => { + Object.entries(simpleProperties).forEach(key => { + if (properties[key[0]] !== undefined) { + ui[key[1]](properties[key[0]]) + } + }) + if (properties.parent !== undefined) { + if (ui.parent()) { + ui.parent().channel.remove(ui) + } + ui.parent(properties.parent.__ui) + ui.parent().channels.push(ui) + ui.parent().channels.sort(compareChannels) + } + if (properties.links !== undefined) { + this._updateLinks() + } + }).on('remove', () => { + if (ui.parent()) { + ui.parent().channels.remove(ui) + } + this._updateLinks() + }) + } + + this.resetClient = () => { + if (this.client) { + this.client.disconnect() + } + this.client = null + this.selected(null).root(null).thisUser(null) + } + + this.connected = () => this.thisUser() != null + + this._updateVoiceHandler = () => { + if (!this.client) { + return + } + if (voiceHandler) { + voiceHandler.end() + voiceHandler = null + } + let mode = this.settings.voiceMode + if (mode === 'cont') { + voiceHandler = new ContinuousVoiceHandler(this.client, this.settings) + } else if (mode === 'ptt') { + voiceHandler = new PushToTalkVoiceHandler(this.client, this.settings) + } else if (mode === 'vad') { + voiceHandler = new VADVoiceHandler(this.client, this.settings) + } else { + log(translate('logentry.unknown_voice_mode'), mode) + return + } + voiceHandler.on('started_talking', () => { + if (this.thisUser()) { + this.thisUser().talking('on') + } + }) + voiceHandler.on('stopped_talking', () => { + if (this.thisUser()) { + this.thisUser().talking('off') + } + }) + if (this.selfMute()) { + voiceHandler.setMute(true) + } + + this.client.setAudioQuality( + this.settings.audioBitrate, + this.settings.samplesPerPacket + ) + } + + this.messageBoxHint = ko.pureComputed(() => { + if (!this.thisUser()) { + return '' // Not yet connected + } + var target = this.selected() + if (!target) { + target = this.thisUser() + } + if (target === this.thisUser()) { + target = target.channel() + } + if (target.users) { // Channel + return translate('chat.channel_message_placeholder') + .replace('%1', target.name()) + } else { // User + return translate('chat.user_message_placeholder') + .replace('%1', target.name()) + } + }) + + this.submitMessageBox = () => { + this.sendMessage(this.selected(), this.messageBox()) + this.messageBox('') + } + + this.sendMessage = (target, message) => { + if (this.connected()) { + // If no target is selected, choose our own user + if (!target) { + target = this.thisUser() + } + // If target is our own user, send to our channel + if (target === this.thisUser()) { + target = target.channel() + } + // Send message + target.model.sendMessage(message) + if (target.users) { // Channel + this.log.push({ + type: 'chat-message-self', + message: sanitize(message), + channel: target + }) + } else { // User + this.log.push({ + type: 'chat-message-self', + message: sanitize(message), + user: target + }) + } + } + } + + this.requestMove = (user, channel) => { + if (this.connected()) { + user.model.setChannel(channel.model) + + let currentUrl = url.parse(document.location.href, true) + // delete search param so that query one can be taken into account + delete currentUrl.search + + // get full channel path + if( channel.parent() ){ // in case this channel is not Root + let parent = channel.parent() + currentUrl.query.channelName = channel.name() + while( parent.parent() ){ + currentUrl.query.channelName = parent.name() + '/' + currentUrl.query.channelName + parent = parent.parent() + } + } else { + // there is no channelName as we moved to Root + delete currentUrl.query.channelName + } + + // reflect this change in URL + window.history.pushState(null, channel.name(), url.format(currentUrl)) + } + } + + this.requestMute = user => { + if (user === this.thisUser()) { + this.selfMute(true) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfMute(true) + } else { + user.model.setMute(true) + } + } + } + + this.requestDeaf = user => { + if (user === this.thisUser()) { + this.selfMute(true) + this.selfDeaf(true) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfDeaf(true) + } else { + user.model.setDeaf(true) + } + } + } + + this.requestUnmute = user => { + if (user === this.thisUser()) { + this.selfMute(false) + this.selfDeaf(false) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfMute(false) + } else { + user.model.setMute(false) + } + } + } + + this.requestUndeaf = user => { + if (user === this.thisUser()) { + this.selfDeaf(false) + } + if (this.connected()) { + if (user === this.thisUser()) { + this.client.setSelfDeaf(false) + } else { + user.model.setDeaf(false) + } + } + } + + this._updateLinks = () => { + if (!this.thisUser()) { + return + } + + var allChannels = getAllChannels(this.root(), []) + var ownChannel = this.thisUser().channel().model + var allLinked = findLinks(ownChannel, []) + allChannels.forEach(channel => { + channel.linked(allLinked.indexOf(channel.model) !== -1) + }) + + function findLinks (channel, knownLinks) { + knownLinks.push(channel) + channel.links.forEach(next => { + if (next && knownLinks.indexOf(next) === -1) { + findLinks(next, knownLinks) + } + }) + allChannels.map(c => c.model).forEach(next => { + if (next && knownLinks.indexOf(next) === -1 && next.links.indexOf(channel) !== -1) { + findLinks(next, knownLinks) + } + }) + return knownLinks + } + + function getAllChannels (channel, channels) { + channels.push(channel) + channel.channels().forEach(next => getAllChannels(next, channels)) + return channels + } + } + + this.openSourceCode = () => { + var homepage = require('../package.json').homepage + window.open(homepage, '_blank').focus() + } + + this.updateSize = () => { + this.minimalView(window.innerWidth < 320) + if (this.minimalView()) { + this.toolbarHorizontal(window.innerWidth < window.innerHeight) + } else { + this.toolbarHorizontal(!this.settings.toolbarVertical) + } + } + } +} +var ui = new GlobalBindings(window.mumbleWebConfig) + +// Used only for debugging +window.mumbleUi = ui + +function initializeUI () { + var queryParams = url.parse(document.location.href, true).query + queryParams = Object.assign({}, window.mumbleWebConfig.defaults, queryParams) + var useJoinDialog = queryParams.joinDialog + if (queryParams.matrix) { + useJoinDialog = true + } + if (queryParams.address) { + ui.connectDialog.address(queryParams.address) + } else { + useJoinDialog = false + } + if (queryParams.port) { + ui.connectDialog.port(queryParams.port) + } else { + useJoinDialog = false + } + if (queryParams.token) { + var tokens = queryParams.token + if (!Array.isArray(tokens)) { + tokens = [tokens] + } + ui.connectDialog.tokens(tokens) + } + if (queryParams.username) { + ui.connectDialog.username(queryParams.username) + } else { + useJoinDialog = false + } + if (queryParams.password) { + ui.connectDialog.password(queryParams.password) + } + if (queryParams.channelName) { + ui.connectDialog.channelName(queryParams.channelName) + } + if (queryParams.avatarurl) { + // Download the avatar and upload it to the mumble server when connected + let url = queryParams.avatarurl + console.log('Fetching avatar from', url) + let req = new window.XMLHttpRequest() + req.open('GET', url, true) + req.responseType = 'arraybuffer' + req.onload = () => { + let upload = (avatar) => { + if (req.response) { + console.log('Uploading user avatar to server') + ui.client.setSelfTexture(req.response) + } + } + // On any future connections + ui.thisUser.subscribe((thisUser) => { + if (thisUser) { + upload() + } + }) + // And the current one (if already connected) + if (ui.thisUser()) { + upload() + } + } + req.send() + } + ui.connectDialog.joinOnly(useJoinDialog) + ko.applyBindings(ui) + + window.onresize = () => ui.updateSize() + ui.updateSize() +} + +function log () { + console.log.apply(console, arguments) + var args = [] + for (var i = 0; i < arguments.length; i++) { + args.push(arguments[i]) + } + ui.log.push({ + type: 'generic', + value: args.join(' ') + }) +} + +function compareChannels (c1, c2) { + if (c1.position() === c2.position()) { + return c1.name() === c2.name() ? 0 : c1.name() < c2.name() ? -1 : 1 + } + return c1.position() - c2.position() +} + +function compareUsers (u1, u2) { + return u1.name() === u2.name() ? 0 : u1.name() < u2.name() ? -1 : 1 +} + +function userToState () { + var flags = [] + // TODO: Friend + if (this.uid()) { + flags.push('Authenticated') + } + // TODO: Priority Speaker, Recording + if (this.mute()) { + flags.push('Muted (server)') + } + if (this.deaf()) { + flags.push('Deafened (server)') + } + // TODO: Local Ignore (Text messages), Local Mute + if (this.selfMute()) { + flags.push('Muted (self)') + } + if (this.selfDeaf()) { + flags.push('Deafened (self)') + } + return flags.join(', ') +} + +var voiceHandler +var testVoiceHandler + +/** + * @author svartoyg + */ +function translatePiece(selector, kind, parameters, key) { + let element = document.querySelector(selector); + if (element !== null) { + const translation = translate(key); + switch (kind) { + default: + console.warn('unhandled dom translation kind "' + kind + '"'); + break; + case 'textcontent': + element.textContent = translation; + break; + case 'attribute': + element.setAttribute(parameters.name || 'value', translation); + break; + } + } else { + console.warn(`translation selector "${selector}" for "${key}" did not match any element`) + } +} + +/** + * @author svartoyg + */ +function translateEverything() { + translatePiece('#connect-dialog_title', 'textcontent', {}, 'connectdialog.title'); + translatePiece('#connect-dialog_input_address', 'textcontent', {}, 'connectdialog.address'); + translatePiece('#connect-dialog_input_port', 'textcontent', {}, 'connectdialog.port'); + translatePiece('#connect-dialog_input_username', 'textcontent', {}, 'connectdialog.username'); + translatePiece('#connect-dialog_input_password', 'textcontent', {}, 'connectdialog.password'); + translatePiece('#connect-dialog_input_tokens', 'textcontent', {}, 'connectdialog.tokens'); + translatePiece('#connect-dialog_controls_remove', 'textcontent', {}, 'connectdialog.remove'); + translatePiece('#connect-dialog_controls_add', 'textcontent', {}, 'connectdialog.add'); + translatePiece('#connect-dialog_controls_cancel', 'attribute', {'name': 'value'}, 'connectdialog.cancel'); + translatePiece('#connect-dialog_controls_connect', 'attribute', {'name': 'value'}, 'connectdialog.connect'); + translatePiece('.connect-dialog.error-dialog .dialog-header', 'textcontent', {}, 'connectdialog.error.title'); + translatePiece('.connect-dialog.error-dialog .reason .refused', 'textcontent', {}, 'connectdialog.error.reason.refused'); + translatePiece('.connect-dialog.error-dialog .reason .version', 'textcontent', {}, 'connectdialog.error.reason.version'); + translatePiece('.connect-dialog.error-dialog .reason .username', 'textcontent', {}, 'connectdialog.error.reason.username'); + translatePiece('.connect-dialog.error-dialog .reason .userpassword', 'textcontent', {}, 'connectdialog.error.reason.userpassword'); + translatePiece('.connect-dialog.error-dialog .reason .serverpassword', 'textcontent', {}, 'connectdialog.error.reason.serverpassword'); + translatePiece('.connect-dialog.error-dialog .reason .username-in-use', 'textcontent', {}, 'connectdialog.error.reason.username_in_use'); + translatePiece('.connect-dialog.error-dialog .reason .full', 'textcontent', {}, 'connectdialog.error.reason.full'); + translatePiece('.connect-dialog.error-dialog .reason .clientcert', 'textcontent', {}, 'connectdialog.error.reason.clientcert'); + translatePiece('.connect-dialog.error-dialog .reason .server', 'textcontent', {}, 'connectdialog.error.reason.server'); + translatePiece('.connect-dialog.error-dialog .alternate-username', 'textcontent', {}, 'connectdialog.username'); + translatePiece('.connect-dialog.error-dialog .alternate-password', 'textcontent', {}, 'connectdialog.password'); + translatePiece('.connect-dialog.error-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'connectdialog.error.retry'); + translatePiece('.connect-dialog.error-dialog .dialog-close', 'attribute', {'name': 'value'}, 'connectdialog.error.cancel'); + translatePiece('.join-dialog .dialog-header', 'textcontent', {}, 'joindialog.title'); + translatePiece('.join-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'joindialog.connect'); + translatePiece('.user-context-menu .mute', 'textcontent', {}, 'usercontextmenu.mute'); + translatePiece('.user-context-menu .deafen', 'textcontent', {}, 'usercontextmenu.deafen'); + translatePiece('.user-context-menu .priority-speaker', 'textcontent', {}, 'usercontextmenu.priority_speaker'); + translatePiece('.user-context-menu .local-mute', 'textcontent', {}, 'usercontextmenu.local_mute'); + translatePiece('.user-context-menu .ignore-messages', 'textcontent', {}, 'usercontextmenu.ignore_messages'); + translatePiece('.user-context-menu .view-comment', 'textcontent', {}, 'usercontextmenu.view_comment'); + translatePiece('.user-context-menu .change-comment', 'textcontent', {}, 'usercontextmenu.change_comment'); + translatePiece('.user-context-menu .reset-comment', 'textcontent', {}, 'usercontextmenu.reset_comment'); + translatePiece('.user-context-menu .view-avatar', 'textcontent', {}, 'usercontextmenu.view_avatar'); + translatePiece('.user-context-menu .change-avatar', 'textcontent', {}, 'usercontextmenu.change_avatar'); + translatePiece('.user-context-menu .reset-avatar', 'textcontent', {}, 'usercontextmenu.reset_avatar'); + translatePiece('.user-context-menu .send-message', 'textcontent', {}, 'usercontextmenu.send_message'); + translatePiece('.user-context-menu .information', 'textcontent', {}, 'usercontextmenu.information'); + translatePiece('.user-context-menu .self-mute', 'textcontent', {}, 'usercontextmenu.self_mute'); + translatePiece('.user-context-menu .self-deafen', 'textcontent', {}, 'usercontextmenu.self_deafen'); + translatePiece('.user-context-menu .add-friend', 'textcontent', {}, 'usercontextmenu.add_friend'); + translatePiece('.user-context-menu .remove-friend', 'textcontent', {}, 'usercontextmenu.remove_friend'); + translatePiece('.channel-context-menu .join', 'textcontent', {}, 'channelcontextmenu.join'); + translatePiece('.channel-context-menu .add', 'textcontent', {}, 'channelcontextmenu.add'); + translatePiece('.channel-context-menu .edit', 'textcontent', {}, 'channelcontextmenu.edit'); + translatePiece('.channel-context-menu .remove', 'textcontent', {}, 'channelcontextmenu.remove'); + translatePiece('.channel-context-menu .link', 'textcontent', {}, 'channelcontextmenu.link'); + translatePiece('.channel-context-menu .unlink', 'textcontent', {}, 'channelcontextmenu.unlink'); + translatePiece('.channel-context-menu .unlink-all', 'textcontent', {}, 'channelcontextmenu.unlink_all'); + translatePiece('.channel-context-menu .copy-mumble-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_url'); + translatePiece('.channel-context-menu .copy-mumble-web-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_web_url'); + translatePiece('.channel-context-menu .send-message', 'textcontent', {}, 'channelcontextmenu.send_message'); +} + +async function main() { + await localizationInitialize(navigator.language); + translateEverything(); + initializeUI(); + initVoice(data => { + if (testVoiceHandler) { + testVoiceHandler.write(data) + } + if (!ui.client) { + if (voiceHandler) { + voiceHandler.end() + } + voiceHandler = null + } else if (voiceHandler) { + voiceHandler.write(data) + } +window.voiceHandler = voiceHandler + + }, err => { + log(translate('logentry.mic_init_error'), err) + }) +} + +window.onload = main + diff --git a/production/mumble-web/loc/de.json b/production/mumble-web/loc/de.json new file mode 100644 index 0000000..3c2fb14 --- /dev/null +++ b/production/mumble-web/loc/de.json @@ -0,0 +1,16 @@ +{ + "connectdialog": { + "use": "", + "title": "Verbindung herstellen", + "address": "Adresse", + "port": "Port", + "username": "Nutzername", + "password": "Passwort", + "tokens": "Tokens", + "remove": "Entfernen", + "add": "Hinzufügen", + "cancel": "Abbrechen", + "connect": "Verbinden" + } +} + diff --git a/production/mumble-web/loc/en.json b/production/mumble-web/loc/en.json new file mode 100644 index 0000000..0640a36 --- /dev/null +++ b/production/mumble-web/loc/en.json @@ -0,0 +1,78 @@ +{ + "connectdialog": { + "use": "Use the red button or the 'h' key to speak", + "title": "Connect to Server", + "address": "Address", + "port": "Port", + "username": "Username", + "password": "Password", + "tokens": "Tokens", + "remove": "Remove", + "add": "Add", + "cancel": "Cancel", + "connect": "Connect", + "error": { + "title": "Failed to connect", + "reason": { + "refused": "The connection has been refused.", + "version": "The server uses an incompatible version.", + "username": "Your user name was rejected. Maybe try a different one?", + "userpassword": "The given password is incorrect.\nThe user name you have chosen requires a special one.", + "serverpassword": "The given password is incorrect.", + "username_in_use": "The user name you have chosen is already in use.", + "full": "The server is full.", + "clientcert": "The server requires you to provide a client certificate which is not supported by this web application.", + "server": "The server reports:" + }, + "retry": "Retry", + "cancel": "Cancel" + } + }, + "joindialog": { + "title": "Mumble Voice Conference", + "connect": "Join Conference" + }, + "usercontextmenu": { + "mute": "Mute", + "deafen": "Deafen", + "priority_speaker": "Priority Speaker", + "local_mute": "Local Mute", + "ignore_messages": "Ignore Messages", + "view_comment": "View Comment", + "change_comment": "Change Comment", + "reset_comment": "Reset Comment", + "view_avatar": "View Avatar", + "change_avatar": "Change Avatar", + "reset_avatar": "Reset Avatar", + "send_message": "Send Message", + "information": "Information", + "self_mute": "Self Mute", + "self_deafen": "Self Deafen", + "add_friend": "Add Friend", + "remove_friend": "Remove Friend" + }, + "channelcontextmenu": { + "join": "Join Channel", + "add": "Add", + "edit": "Edit", + "remove": "Remove", + "link": "Link", + "unlink": "Unlink", + "unlink_all": "Unlink All", + "copy_mumble_url": "Copy Mumble URL", + "copy_mumble_web_url": "Copy Mumble-Web URL", + "send_message": "Send Message" + }, + "logentry": { + "connecting": "Connecting to server", + "connected": "Connected!", + "connection_error": "Connection error:", + "unknown_voice_mode": "Unknown voice mode:", + "mic_init_error": "Cannot initialize user media. Microphone will not work:" + }, + "chat": { + "channel_message_placeholder": "Type message to channel '%1' here", + "user_message_placeholder": "Type message to user '%1' here" + } +} + diff --git a/production/mumble-web/loc/eo.json b/production/mumble-web/loc/eo.json new file mode 100644 index 0000000..6dc2b6d --- /dev/null +++ b/production/mumble-web/loc/eo.json @@ -0,0 +1,16 @@ +{ + "connectdialog": { + "use": "", + "title": "Konektado", + "address": "Adreso", + "port": "Pordo", + "username": "Uzantnomo", + "password": "Pasvorto", + "tokens": "Ĵetonoj", + "remove": "Forigi", + "add": "Aldoni", + "cancel": "Nuligi", + "connect": "Konekti" + } +} + diff --git a/production/mumble-web/loc/es.json b/production/mumble-web/loc/es.json new file mode 100644 index 0000000..f3b38e1 --- /dev/null +++ b/production/mumble-web/loc/es.json @@ -0,0 +1,78 @@ +{ + "connectdialog": { + "title": "Conectar al servidor", + "use": "Utilice el botón rojo o la tecla 'h' para hablar", + "address": "Dirección", + "port": "Puerto", + "username": "Nombre de usuario", + "password": "Contraseña", + "tokens": "Tokens", + "remove": "Eliminar", + "add": "Añadir", + "cancel": "Cancelar", + "connect": "Conectar", + "error": { + "title": "Fallo al conectar", + "reason": { + "refused": "La conexión ha sido rechazada.", + "version": "El servidor usa una versión incompatible.", + "username": "El nombre de usuario está en uso o no es válido. Prueba con otro.", + "userpassword": "Contraseña incorrecta.\nEl nombre de usuario elegido requiere contraseña.", + "serverpassword": "Contraseña incorrecta.", + "username_in_use": "El nombre de usuario está en uso.", + "full": "El servidor está lleno (completo).", + "clientcert": "El servidor requiere acceder con un certificado, lo que no está soportado en esta aplicación web.", + "server": "El servidor informa:" + }, + "retry": "Reintentar", + "cancel": "Cancelar" + } + }, + "joindialog": { + "title": "Chat de Voz Mumble", + "connect": "Unirse a la conferencia" + }, + "usercontextmenu": { + "mute": "Enmudecer", + "deafen": "Ensordecer", + "priority_speaker": "Orador prioritario", + "local_mute": "Enmudecer localmente", + "ignore_messages": "Ignorar mensajes", + "view_comment": "Ver comentarios", + "change_comment": "Cambiar comentarios", + "reset_comment": "Reiniciar comentarios", + "view_avatar": "Ver Avatar", + "change_avatar": "Cambiar Avatar", + "reset_avatar": "Reiniciar Avatar", + "send_message": "Enviar un mensaje", + "information": "Información", + "self_mute": "Enmudecerse a uno mismo", + "self_deafen": "Ensordecerse a uno mismo", + "add_friend": "Añadir amigo", + "remove_friend": "Eliminar amigo" + }, + "channelcontextmenu": { + "join": "Unirse al canal", + "add": "Añadir", + "edit": "Editar", + "remove": "Eliminar", + "link": "Link", + "unlink": "Unlink", + "unlink_all": "Unlink All", + "copy_mumble_url": "Copiar Mumble URL", + "copy_mumble_web_url": "Copiar Mumble-Web URL", + "send_message": "Enviar mensaje" + }, + "logentry": { + "connecting": "Conectando al servidor", + "connected": "¡Conectado!", + "connection_error": "Error en la conexión:", + "unknown_voice_mode": "Modo de voz desconocido:", + "mic_init_error": "No se pudieron inicializar los medios. El micrófono no funcionará:" + }, + "chat": { + "channel_message_placeholder": "Escribe un mensaje al canal '%1'", + "user_message_placeholder": "Escribe un mensaje al usuario '%1'" + } +} + diff --git a/production/mumble-web/loc/oc.json b/production/mumble-web/loc/oc.json new file mode 100644 index 0000000..9ff75e3 --- /dev/null +++ b/production/mumble-web/loc/oc.json @@ -0,0 +1,77 @@ +{ + "connectdialog": { + "use": "", + "title": "Connexion al servidor", + "address": "Adreça", + "port": "Pòrt", + "username": "Nom d’utilizaire", + "password": "Senhal", + "tokens": "Getons", + "remove": "Suprimir", + "add": "Ajustar", + "cancel": "Anullar", + "connect": "Se connectar", + "error": { + "title": "Connexion impossibla", + "reason": { + "refused": "Lo servidor a refusat la connexion.", + "version": "Lo servidor utiliza una version incompatibla.", + "username": "Vòstre nom d’utilizaire es estat regetat. Ensajatz benlèu un autre ?", + "userpassword": "Lo senhal donat es incorrèct.\nLo nom d’utilizaire qu’avètz causit requerís un senhal especial.", + "serverpassword": "Lo senhal donat es incorrèct.", + "username_in_use": "Lo nom d’utilizaire donat es ja utilizat.", + "full": "Lo servidor es plen.", + "clientcert": "Lo servidor requerís que forniscatz un certificat client qu’es pas compatible amb aquesta web aplicacion.", + "server": "Lo servidor senhala :" + }, + "retry": "Ensajar tornamai", + "cancel": "Anullar" + } + }, + "joindialog": { + "title": "Conferéncia àudio Mumble", + "connect": "Participar a la conferéncia" + }, + "usercontextmenu": { + "mute": "Copar lo son", + "deafen": "Sordina", + "priority_speaker": "Prioritat parlaire", + "local_mute": "Copar lo son localament", + "ignore_messages": "Ignorar los messatges", + "view_comment": "Veire lo comentari", + "change_comment": "Cambiar lo comentari", + "reset_comment": "Escafar lo comentari", + "view_avatar": "Veire l’avatar", + "change_avatar": "Cambiar l’avatar", + "reset_avatar": "Escafar Avatar", + "send_message": "Enviar un messatge", + "information": "Informacions", + "self_mute": "Copar mon son", + "self_deafen": "Me metre en sordina", + "add_friend": "Ajustar coma amic", + "remove_friend": "Tirar dels amics" + }, + "channelcontextmenu": { + "join": "Rejónher la sala", + "add": "Ajustar", + "edit": "Modificar", + "remove": "Suprimir", + "link": "Associar", + "unlink": "Desassociar", + "unlink_all": "Tot desassociar", + "copy_mumble_url": "Copair l’URL Mumble", + "copy_mumble_web_url": "Copiar l’URL Mumble-Web", + "send_message": "Enviar messatge" + }, + "logentry": { + "connecting": "Connexion al servidor", + "connected": "Connectat !", + "connection_error": "Error de connexion :", + "unknown_voice_mode": "Mòde àudio desconegut :", + "mic_init_error": "Aviada del mèdia utilizaire impossibla. Lo microfòn foncionarà pas :" + }, + "chat": { + "channel_message_placeholder": "Escrivètz un messatge per la sala '%1' aquí", + "user_message_placeholder": "Escrivètz un messatge a '%1' aquí" + } +} diff --git a/production/mumble-web/mumble-web/Dockerfile b/production/mumble-web/mumble-web/Dockerfile new file mode 100644 index 0000000..2736aed --- /dev/null +++ b/production/mumble-web/mumble-web/Dockerfile @@ -0,0 +1,8 @@ +FROM node:12-slim +RUN apt update && apt -y upgrade && apt -y install websockify git python python-setuptools +RUN npm i npm -g +USER node +WORKDIR /home/node +RUN git clone https://github.com/Johni0702/mumble-web +WORKDIR /home/node/mumble-web +RUN npm i diff --git a/production/mumble-web/voice.js b/production/mumble-web/voice.js new file mode 100644 index 0000000..c60a003 --- /dev/null +++ b/production/mumble-web/voice.js @@ -0,0 +1,186 @@ +import { Writable } from 'stream' +import MicrophoneStream from 'microphone-stream' +import audioContext from 'audio-context' +import getUserMedia from 'getusermedia' +import keyboardjs from 'keyboardjs' +import vad from 'voice-activity-detection' +import DropStream from 'drop-stream' + +class VoiceHandler extends Writable { + constructor (client, settings) { + super({ objectMode: true }) + this._client = client + this._settings = settings + this._outbound = null + this._mute = false + } + + setMute (mute) { + this._mute = mute + if (mute) { + this._stopOutbound() + } + } + + _getOrCreateOutbound () { + if (this._mute) { + throw new Error('tried to send audio while self-muted') + } + if (!this._outbound) { + if (!this._client) { + this._outbound = DropStream.obj() + this.emit('started_talking') + return this._outbound + } + + // Note: the samplesPerPacket argument is handled in worker.js and not passed on + this._outbound = this._client.createVoiceStream(this._settings.samplesPerPacket) + + this.emit('started_talking') + } + return this._outbound + } + + _stopOutbound () { + if (this._outbound) { + this.emit('stopped_talking') + this._outbound.end() + this._outbound = null + } + } + + _final (callback) { + this._stopOutbound() + callback() + } +} + +export class ContinuousVoiceHandler extends VoiceHandler { + constructor (client, settings) { + super(client, settings) + } + + _write (data, _, callback) { + if (this._mute) { + callback() + } else { + this._getOrCreateOutbound().write(data, callback) + } + } +} + +export class PushToTalkVoiceHandler extends VoiceHandler { + var hat = document.getElementById('hat') + constructor (client, settings) { + super(client, settings) + this._key = settings.pttKey + this._pushed = false + this._keydown_handler = () => this._pushed = true + this._keyup_handler = () => { + this._stopOutbound() + this._pushed = false + } + keyboardjs.bind(this._key, this._keydown_handler, this._keyup_handler) + hat.addEventListener('mouseover', function(event) { + this._getOrCreateOutbound() + this._pushed = true +console.log('start voice') + }) + hat.addEventListener('mouseout', function(event) { + this._stopOutbound() + this._pushed = false +console.log('start voice') + }) + } + + _write (data, _, callback) { + if (this._pushed && !this._mute) { + this._getOrCreateOutbound().write(data, callback) + } else { + callback() + } + } + + _final (callback) { + super._final(e => { + keyboardjs.unbind(this._key, this._keydown_handler, this._keyup_handler) + callback(e) + }) + } +} + +export class VADVoiceHandler extends VoiceHandler { + constructor (client, settings) { + super(client, settings) + let level = settings.vadLevel + const self = this + this._vad = vad(audioContext(), theUserMedia, { + onVoiceStart () { + console.log('vad: start') + self._active = true + }, + onVoiceStop () { + console.log('vad: stop') + self._stopOutbound() + self._active = false + }, + onUpdate (val) { + self._level = val + self.emit('level', val) + }, + noiseCaptureDuration: 0, + minNoiseLevel: level, + maxNoiseLevel: level + }) + // Need to keep a backlog of the last ~150ms (dependent on sample rate) + // because VAD will activate with ~125ms delay + this._backlog = [] + this._backlogLength = 0 + this._backlogLengthMin = 1024 * 6 * 4 // vadBufferLen * (vadDelay + 1) * bytesPerSample + } + + _write (data, _, callback) { + if (this._active && !this._mute) { + if (this._backlog.length > 0) { + for (let oldData of this._backlog) { + this._getOrCreateOutbound().write(oldData) + } + this._backlog = [] + this._backlogLength = 0 + } + this._getOrCreateOutbound().write(data, callback) + } else { + // Make sure we always keep the backlog filled if we're not (yet) talking + this._backlog.push(data) + this._backlogLength += data.length + // Check if we can discard the oldest element without becoming too short + if (this._backlogLength - this._backlog[0].length > this._backlogLengthMin) { + this._backlogLength -= this._backlog.shift().length + } + callback() + } + } + + _final (callback) { + super._final(e => { + this._vad.destroy() + callback(e) + }) + } +} + +var theUserMedia = null + +export function initVoice (onData, onUserMediaError) { + getUserMedia({ audio: true }, (err, userMedia) => { + if (err) { + onUserMediaError(err) + } else { + theUserMedia = userMedia + var micStream = new MicrophoneStream(userMedia, { objectMode: true, bufferSize: 1024 }) + micStream.on('data', data => { + onData(Buffer.from(data.getChannelData(0).buffer)) + }) + } + }) +}