This commit is contained in:
Chris Josten 2020-11-26 21:47:12 +01:00
commit 186766f89b
11 changed files with 1691 additions and 1828 deletions

View file

@ -1,20 +1,18 @@
**If you do not have specific requirements, please consider using the `webrtc` version instead: https://github.com/Johni0702/mumble-web/tree/webrtc (note that setup instructions differ significantly).
It should be near identical in features but less susceptible to performance issues. If you are having trouble with the `webrtc` version, please let us know.**
PRs, unless webrtc-specific, should still target `master`.
# mumble-web # mumble-web
mumble-web is an HTML5 [Mumble] client for use in modern browsers. mumble-web is an HTML5 [Mumble] client for use in modern browsers.
A live demo is running [here](https://voice.johni0702.de/?address=voice.johni0702.de&port=443/demo). A live demo is running [here](https://voice.johni0702.de/?address=voice.johni0702.de&port=443/demo) (or [without WebRTC](https://voice.johni0702.de/?address=voice.johni0702.de&port=443/demo&webrtc=false)).
The Mumble protocol uses TCP for control and UDP for voice. The Mumble protocol uses TCP for control and UDP for voice.
Running in a browser, both are unavailable to this client. Running in a browser, both are unavailable to this client.
Instead Websockets are used for all communications. Instead Websockets are used for control and WebRTC is used for voice (using Websockets as fallback if the server does not support WebRTC).
libopus, libcelt (0.7.1) and libsamplerate, compiled to JS via emscripten, are used for audio decoding. In WebRTC mode (default) only the Opus codec is supported.
Therefore, at the moment only the Opus and CELT Alpha codecs are supported.
In fallback mode, when WebRTC is not supported by the server, only the Opus and CELT Alpha codecs are supported.
This is accomplished with libopus, libcelt (0.7.1) and libsamplerate, compiled to JS via emscripten.
Performance is expected to be less reliable (especially on low-end devices) than in WebRTC mode and loading time will be significantly increased.
Quite a few features, most noticeably all Quite a few features, most noticeably all
administrative functionallity, are still missing. administrative functionallity, are still missing.
@ -23,7 +21,7 @@ administrative functionallity, are still missing.
#### Download #### Download
mumble-web can either be installed directly from npm with `npm install -g mumble-web` mumble-web can either be installed directly from npm with `npm install -g mumble-web`
or from git: or from git (recommended because the npm version may be out of date):
``` ```
git clone https://github.com/johni0702/mumble-web git clone https://github.com/johni0702/mumble-web
@ -38,34 +36,14 @@ to e.g. customize the theme before building it.
Either way you will end up with a `dist` folder that contains the static page. Either way you will end up with a `dist` folder that contains the static page.
#### Setup #### Setup
At the time of writing this there do not seem to be any Mumble servers At the time of writing this there do not seem to be any Mumble servers which natively support Websockets+WebRTC.
which natively support Websockets. To use this client with any standard mumble [Grumble](https://github.com/mumble-voip/grumble) natively supports Websockets and can run mumble-web in fallback mode but not (on its own) in WebRTC mode.
server, websockify must be set up (preferably on the same machine that the To use this client with any standard mumble server in WebRTC mode, [mumble-web-proxy] must be set up (preferably on the same machine that the Mumble server is running on).
Mumble server is running on).
You can install websockify via your package manager `apt install websockify` or Additionally you will need some web server to serve static files and terminate the secure websocket connection (mumble-web-proxy only supports insecure ones).
manually from the [websockify GitHub page]. Note that while some versions might
function better than others, the python version generally seems to be the best.
There are two basic ways you can use websockify with mumble-web: Here are two web server configuration files (one for [NGINX](https://www.nginx.com/) and one for [Caddy server](https://caddyserver.com/)) which will serve the mumble-web interface at `https://voice.example.com` and allow the websocket to connect at `wss://voice.example.com/demo` (similar to the demo server).
- Standalone, use websockify for both, websockets and serving static files Replace `<proxybox>` with the host name of the machine where `mumble-web-proxy` is running. If `mumble-web-proxy` is running on the same machine as your web server, use `localhost`.
- Proxied, let your favorite web server serve static files and proxy websocket connections to websockify
##### Standalone
This is the simplest but at the same time least flexible configuration. Replace `<mumbleserver>` with the URI of your mumble server. If `websockify` is running on the same machine as `mumble-server`, use `localhost`.
```
websockify --cert=mycert.crt --key=mykey.key --ssl-only --ssl-target --web=path/to/dist 443 <mumbleserver>:64738
```
##### Proxied
This configuration allows you to run websockify on a machine that already has
another webserver running. Replace `<mumbleserver>` with the URI of your mumble server. If `websockify` is running on the same machine as `mumble-server`, use `localhost`.
```
websockify --ssl-target 64737 <mumbleserver>:64738
```
Here are two web server configuration files (one for [NGINX](https://www.nginx.com/) and one for [Caddy server](https://caddyserver.com/)) which will serve the mumble-web interface at `https://voice.example.com` and allow the websocket to connect at `wss://voice.example.com/demo` (similar to the demo server). Replace `<websockify>` with the URI to the machine where `websockify` is running. If `websockify` is running on the same machine as your web server, use `localhost`.
* NGINX configuration file * NGINX configuration file
```Nginx ```Nginx
@ -79,7 +57,7 @@ server {
root /path/to/dist; root /path/to/dist;
} }
location /demo { location /demo {
proxy_pass http://<websockify>:64737; proxy_pass http://<proxybox>:64737;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
@ -101,12 +79,19 @@ http://voice.example.com {
https://voice.example.com { https://voice.example.com {
tls "/etc/letsencrypt/live/voice.example.com/fullchain.pem" "/etc/letsencrypt/live/voice.example.com/privkey.pem" tls "/etc/letsencrypt/live/voice.example.com/fullchain.pem" "/etc/letsencrypt/live/voice.example.com/privkey.pem"
root /path/to/dist root /path/to/dist
proxy /demo http://<websockify>:64737 { proxy /demo http://<proxybox>:64737 {
websocket websocket
} }
} }
``` ```
To run `mumble-web-proxy`, execute the following command. Replace `<mumbleserver>` with the host name of your Mumble server (the one you connect to using the normal Mumble client).
Note that even if your Mumble server is running on the same machine as your `mumble-web-proxy`, you should use the external name because (by default, for disabling see its README) `mumble-web-proxy` will try to verify the certificate provided by the Mumble server and fail if it does not match the given host name.
```
mumble-web-proxy --listen-ws 64737 --server <mumbleserver>:64738
```
If your mumble-web-proxy is running behind a NAT or firewall, take note of the respective section in its README.
Make sure that your Mumble server is running. You may now open `https://voice.example.com` in a web browser. You will be prompted for server details: choose either `address: voice.example.com/demo` with `port: 443` or `address: voice.example.com` with `port: 443/demo`. You may prefill these values by appending `?address=voice.example.com/demo&port=443`. Choose a username, and click `Connect`: you should now be able to talk and use the chat. Make sure that your Mumble server is running. You may now open `https://voice.example.com` in a web browser. You will be prompted for server details: choose either `address: voice.example.com/demo` with `port: 443` or `address: voice.example.com` with `port: 443/demo`. You may prefill these values by appending `?address=voice.example.com/demo&port=443`. Choose a username, and click `Connect`: you should now be able to talk and use the chat.
Here is an example of systemd service, put it in `/etc/systemd/system/mumble-web.service` and adapt it to your needs: Here is an example of systemd service, put it in `/etc/systemd/system/mumble-web.service` and adapt it to your needs:
@ -180,6 +165,6 @@ See [here](https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFR
ISC ISC
[Mumble]: https://wiki.mumble.info/wiki/Main_Page [Mumble]: https://wiki.mumble.info/wiki/Main_Page
[websockify GitHub page]: https://github.com/novnc/websockify [mumble-web-proxy]: https://github.com/johni0702/mumble-web-proxy
[MetroMumble]: https://github.com/xPoke/MetroMumble [MetroMumble]: https://github.com/xPoke/MetroMumble
[Matrix]: https://matrix.org [Matrix]: https://matrix.org

View file

@ -32,10 +32,13 @@ window.mumbleWebConfig = {
'token': '', 'token': '',
'username': '', 'username': '',
'password': '', 'password': '',
'webrtc': 'auto', // whether to enable (true), disable (false) or auto-detect ('auto') WebRTC support
'joinDialog': false, // replace whole dialog with single "Join Conference" button 'joinDialog': false, // replace whole dialog with single "Join Conference" button
'matrix': false, // enable Matrix Widget support (mostly auto-detected; implies 'joinDialog') '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 'avatarurl': '', // download and set the user's Mumble avatar to the image at this URL
// General // General
'theme': 'MetroMumbleLight' 'theme': 'MetroMumbleLight',
'startMute': false,
'startDeaf': false
} }
} }

View file

@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Favicon as generated by realfavicongenerator.net (slightly modified for webpack) --> <!-- Favicon as generated by realfavicongenerator.net (slightly modified for webpack) -->
<link rel="apple-touch-icon" sizes="180x180" href="favicon/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" href="favicon/favicon-32x32.png" sizes="32x32"> <link rel="icon" type="image/png" href="favicon/favicon-32x32.png" sizes="32x32">
@ -77,6 +78,22 @@
</form> </form>
</div> </div>
<!-- /ko --> <!-- /ko -->
<!-- ko with: addChannelDialog -->
<div class="add-channel-dialog dialog" data-bind="visible: visible()">
<div class="dialog-header">
Add channel
</div>
<form data-bind="submit: addchannel">
<table>
<tr>
<td>Channel</td>
<td><input id="channelName" type="text" data-bind="value: channelName" required></td>
</tr>
</table>
<input class="dialog-submit" type="submit" value="Add channel">
</form>
</div>
<!-- /ko -->
<!-- ko with: connectDialog --> <!-- ko with: connectDialog -->
<div class="join-dialog dialog" data-bind="visible: visible() && joinOnly()"> <div class="join-dialog dialog" data-bind="visible: visible() && joinOnly()">
<div class="dialog-header"> <div class="dialog-header">
@ -412,7 +429,7 @@
class="join"> class="join">
Join Channel Join Channel
</li> </li>
<li data-bind="css: { disabled: !canAdd() }" <li data-bind="css: { disabled: !canAdd() }, click: $root.openAddChannel.bind($root, $root.thisUser())"
class="add"> class="add">
Add Add
</li> </li>
@ -420,7 +437,7 @@
class="edit"> class="edit">
Edit Edit
</li> </li>
<li data-bind="css: { disabled: !canRemove() }" <li data-bind="css: { disabled: !canRemove() }, click: $root.ChannelRemove.bind($root, $root.thisUser())"
class="remove"> class="remove">
Remove Remove
</li> </li>
@ -462,40 +479,52 @@
</script> </script>
<div class="toolbar" data-bind="css: { 'toolbar-horizontal': toolbarHorizontal(), <div class="toolbar" data-bind="css: { 'toolbar-horizontal': toolbarHorizontal(),
'toolbar-vertical': !toolbarHorizontal() }"> 'toolbar-vertical': !toolbarHorizontal() }">
<img class="handle-horizontal" src="/svg/handle_horizontal.svg" <img class="tb-horizontal handle-horizontal" src="/svg/handle_horizontal.svg"
data-bind="click: toggleToolbarOrientation"> data-bind="click: toggleToolbarOrientation"
<img class="handle-vertical" src="/svg/handle_vertical.svg" title="Switch Orientation" alt="Switch Orientation">
data-bind="click: toggleToolbarOrientation"> <img class="tb-vertical handle-vertical" src="/svg/handle_vertical.svg"
data-bind="click: toggleToolbarOrientation"
title="Switch Orientation" alt="Switch Orientation">
<img class="tb-connect" data-bind="visible: !connectDialog.joinOnly(), <img class="tb-connect" data-bind="visible: !connectDialog.joinOnly(),
click: connectDialog.show" click: connectDialog.show"
rel="connect" src="/svg/applications-internet.svg"> rel="connect" src="/svg/applications-internet.svg"
title="Connect to Server" alt="Connection">
<img class="tb-information" rel="information" src="/svg/information_icon.svg" <img class="tb-information" rel="information" src="/svg/information_icon.svg"
data-bind="click: connectionInfo.show, data-bind="click: connectionInfo.show,
css: { disabled: !thisUser() }"> css: { disabled: !thisUser() }"
title="Information" alt="Information">
<div class="divider"></div> <div class="divider"></div>
<img class="tb-mute" data-bind="visible: !selfMute(), <img class="tb-mute" data-bind="visible: !selfMute(),
click: function () { requestMute(thisUser()) }" click: function () { requestMute(thisUser()) }"
rel="mute" src="/svg/audio-input-microphone.svg"> rel="mute" src="/svg/audio-input-microphone.svg"
title="Mute" alt="Mute">
<img class="tb-unmute tb-active" data-bind="visible: selfMute, <img class="tb-unmute tb-active" data-bind="visible: selfMute,
click: function () { requestUnmute(thisUser()) }" click: function () { requestUnmute(thisUser()) }"
rel="unmute" src="/svg/audio-input-microphone-muted.svg"> rel="unmute" src="/svg/audio-input-microphone-muted.svg"
title="Unmute" alt="Unmute">
<img class="tb-deaf" data-bind="visible: !selfDeaf(), <img class="tb-deaf" data-bind="visible: !selfDeaf(),
click: function () { requestDeaf(thisUser()) }" click: function () { requestDeaf(thisUser()) }"
rel="deaf" src="/svg/audio-output.svg"> rel="deaf" src="/svg/audio-output.svg"
title="Deafen" alt="Deafen">
<img class="tb-undeaf tb-active" data-bind="visible: selfDeaf, <img class="tb-undeaf tb-active" data-bind="visible: selfDeaf,
click: function () { requestUndeaf(thisUser()) }" click: function () { requestUndeaf(thisUser()) }"
rel="undeaf" src="/svg/audio-output-deafened.svg"> rel="undeaf" src="/svg/audio-output-deafened.svg"
title="Undeafen" alt="Undeafen">
<img class="tb-record" data-bind="click: function(){}" <img class="tb-record" data-bind="click: function(){}"
rel="record" src="/svg/media-record.svg"> rel="record" src="/svg/media-record.svg"
title="Record" alt="Record">
<div class="divider"></div> <div class="divider"></div>
<img class="tb-comment" data-bind="click: commentDialog.show" <img class="tb-comment" data-bind="click: commentDialog.show"
rel="comment" src="/svg/toolbar-comment.svg"> rel="comment" src="/svg/toolbar-comment.svg"
title="Comment" alt="Comment">
<div class="divider"></div> <div class="divider"></div>
<img class="tb-settings" data-bind="click: openSettings" <img class="tb-settings" data-bind="click: openSettings"
rel="settings" src="/svg/config_basic.svg"> rel="settings" src="/svg/config_basic.svg"
title="Settings" alt="Settings">
<div class="divider"></div> <div class="divider"></div>
<img class="tb-sourcecode" data-bind="click: openSourceCode" <img class="tb-sourcecode" data-bind="click: openSourceCode"
rel="Source Code" src="/svg/source-code.svg"> rel="Source Code" src="/svg/source-code.svg"
title="Open Soure Code" alt="Open Source Code">
</div> </div>
<div class="chat"> <div class="chat">
<script type="text/html" id="log-generic"> <script type="text/html" id="log-generic">
@ -533,8 +562,10 @@
</div> </div>
</div> </div>
<form data-bind="submit: submitMessageBox"> <form data-bind="submit: submitMessageBox">
<input id="message-box" type="text" data-bind=" <textarea id="message-box" row=1 data-bind="
attr: { placeholder: messageBoxHint }, textInput: messageBox"> attr: { placeholder: messageBoxHint },
textInput: messageBox,
event: {keypress: submitOnEnter}"></textarea>
</form> </form>
</div> </div>
<script type="text/html" id="channel"> <script type="text/html" id="channel">

View file

@ -5,22 +5,62 @@ import ByteBuffer from 'bytebuffer'
import MumbleClient from 'mumble-client' import MumbleClient from 'mumble-client'
import WorkerBasedMumbleConnector from './worker-client' import WorkerBasedMumbleConnector from './worker-client'
import BufferQueueNode from 'web-audio-buffer-queue' import BufferQueueNode from 'web-audio-buffer-queue'
import mumbleConnect from 'mumble-client-websocket'
import audioContext from 'audio-context' import audioContext from 'audio-context'
import ko from 'knockout' import ko from 'knockout'
import _dompurify from 'dompurify' import _dompurify from 'dompurify'
import keyboardjs from 'keyboardjs' import keyboardjs from 'keyboardjs'
import anchorme from 'anchorme'
import { ContinuousVoiceHandler, PushToTalkVoiceHandler, VADVoiceHandler, initVoice } from './voice' import { ContinuousVoiceHandler, PushToTalkVoiceHandler, VADVoiceHandler, initVoice } from './voice'
import {initialize as localizationInitialize, translate} from './loc'; import {initialize as localizationInitialize, translate} from './loc';
const dompurify = _dompurify(window) const dompurify = _dompurify(window)
// from: https://gist.github.com/haliphax/5379454
ko.extenders.scrollFollow = function (target, selector) {
target.subscribe(function (chat) {
const el = document.querySelector(selector);
// the scroll bar is all the way down, so we know they want to follow the text
if (el.scrollTop == el.scrollHeight - el.clientHeight) {
// have to push our code outside of this thread since the text hasn't updated yet
setTimeout(function () { el.scrollTop = el.scrollHeight - el.clientHeight; }, 0);
} else {
// send notification
const last = chat[chat.length - 1]
if (Notification.permission == 'granted' && last.type != 'chat-message-self') {
let sender = 'Mumble Server'
if (last.user && last.user.name) sender=last.user.name()
new Notification(sender, {body: dompurify.sanitize(last.message, {ALLOWED_TAGS:[]})})
}
}
});
return target;
};
function sanitize (html) { function sanitize (html) {
return dompurify.sanitize(html, { return dompurify.sanitize(html, {
ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p'] ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p', 'img', 'center']
}) })
} }
const anchormeOptions = {
// force target _blank attribute
attributes: {
target: "_blank"
},
// force https protocol except email
protocol: function(s) {
if (anchorme.validate.email(s)) {
return "mailto:";
} else {
return "https://";
}
}
}
function openContextMenu (event, contextMenu, target) { function openContextMenu (event, contextMenu, target) {
contextMenu.posX(event.clientX) contextMenu.posX(event.clientX)
contextMenu.posY(event.clientY) contextMenu.posY(event.clientY)
@ -49,6 +89,20 @@ function ContextMenu () {
self.target = ko.observable() self.target = ko.observable()
} }
function AddChannelDialog () {
var self = this;
self.channelName = ko.observable('')
self.parentID = 0;
self.visible = ko.observable(false);
self.show = self.visible.bind(self.visible, true)
self.hide = self.visible.bind(self.visible, false)
self.addchannel = function() {
self.hide();
ui.addchannel(self.channelName());
}
}
function ConnectDialog () { function ConnectDialog () {
var self = this var self = this
self.address = ko.observable('') self.address = ko.observable('')
@ -65,6 +119,9 @@ function ConnectDialog () {
self.hide = self.visible.bind(self.visible, false) self.hide = self.visible.bind(self.visible, false)
self.connect = function () { self.connect = function () {
self.hide() self.hide()
if (ui.detectWebRTC) {
ui.webrtc = true
}
ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName()) ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName())
} }
@ -283,11 +340,15 @@ class GlobalBindings {
constructor (config) { constructor (config) {
this.config = config this.config = config
this.settings = new Settings(config.settings) this.settings = new Settings(config.settings)
this.connector = new WorkerBasedMumbleConnector() this.detectWebRTC = true
this.webrtc = true
this.fallbackConnector = new WorkerBasedMumbleConnector()
this.webrtcConnector = { connect: mumbleConnect }
this.client = null this.client = null
this.userContextMenu = new ContextMenu() this.userContextMenu = new ContextMenu()
this.channelContextMenu = new ContextMenu() this.channelContextMenu = new ContextMenu()
this.connectDialog = new ConnectDialog() this.connectDialog = new ConnectDialog()
this.addChannelDialog = new AddChannelDialog()
this.connectErrorDialog = new ConnectErrorDialog(this.connectDialog) this.connectErrorDialog = new ConnectErrorDialog(this.connectDialog)
this.connectionInfo = new ConnectionInfo(this) this.connectionInfo = new ConnectionInfo(this)
this.commentDialog = new CommentDialog() this.commentDialog = new CommentDialog()
@ -302,8 +363,8 @@ class GlobalBindings {
this.messageBox = ko.observable('') this.messageBox = ko.observable('')
this.toolbarHorizontal = ko.observable(!this.settings.toolbarVertical) this.toolbarHorizontal = ko.observable(!this.settings.toolbarVertical)
this.selected = ko.observable() this.selected = ko.observable()
this.selfMute = ko.observable() this.selfMute = ko.observable(this.config.defaults.startMute)
this.selfDeaf = ko.observable() this.selfDeaf = ko.observable(this.config.defaults.startDeaf)
this.selfMute.subscribe(mute => { this.selfMute.subscribe(mute => {
if (voiceHandler) { if (voiceHandler) {
@ -311,6 +372,14 @@ class GlobalBindings {
} }
}) })
this.submitOnEnter = function(data, e) {
if (e.which == 13 && !e.shiftKey) {
this.submitMessageBox();
return false;
}
return true;
}
this.toggleToolbarOrientation = () => { this.toggleToolbarOrientation = () => {
this.toolbarHorizontal(!this.toolbarHorizontal()) this.toolbarHorizontal(!this.toolbarHorizontal())
this.settings.toolbarVertical = !this.toolbarHorizontal() this.settings.toolbarVertical = !this.toolbarHorizontal()
@ -325,6 +394,32 @@ class GlobalBindings {
this.settingsDialog(new SettingsDialog(this.settings)) this.settingsDialog(new SettingsDialog(this.settings))
} }
this.openAddChannel = (user, channel) => {
this.addChannelDialog.parentID = channel.model._id;
this.addChannelDialog.show()
}
this.addchannel = (channelName) => {
var msg = {
name: 'ChannelState',
payload: {
parent: this.addChannelDialog.parentID || 0,
name: channelName
}
}
this.client._send(msg);
}
this.ChannelRemove = (user, channel) => {
var msg = {
name: 'ChannelRemove',
payload: {
channel_id: channel.model._id
}
}
this.client._send(msg);
}
this.applySettings = () => { this.applySettings = () => {
const settingsDialog = this.settingsDialog() const settingsDialog = this.settingsDialog()
@ -344,10 +439,14 @@ class GlobalBindings {
} }
this.getTimeString = () => { this.getTimeString = () => {
return '[' + new Date().toLocaleTimeString('en-US') + ']' return '[' + new Date().toLocaleTimeString(navigator.language) + ']'
} }
this.connect = (username, host, port, tokens = [], password, channelName = "") => { this.connect = (username, host, port, tokens = [], password, channelName = "") => {
// if browser support Notification request permission
if ('Notification' in window) Notification.requestPermission()
this.resetClient() this.resetClient()
this.remoteHost(host) this.remoteHost(host)
@ -357,12 +456,29 @@ class GlobalBindings {
// Note: This call needs to be delayed until the user has interacted with // 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 // the page in some way (which at this point they have), see: https://goo.gl/7K7WLu
this.connector.setSampleRate(audioContext().sampleRate) let ctx = audioContext()
if (!this.webrtc) {
this.fallbackConnector.setSampleRate(ctx.sampleRate)
}
if (!this._delayedMicNode) {
this._micNode = ctx.createMediaStreamSource(this._micStream)
this._delayNode = ctx.createDelay()
this._delayNode.delayTime.value = 0.15
this._delayedMicNode = ctx.createMediaStreamDestination()
}
// TODO: token // TODO: token
this.connector.connect(`wss://${host}:${port}`, { (this.webrtc ? this.webrtcConnector : this.fallbackConnector).connect(`wss://${host}:${port}`, {
username: username, username: username,
password: password, password: password,
webrtc: this.webrtc ? {
enabled: true,
required: true,
mic: this._delayedMicNode.stream,
audioContext: ctx
} : {
enabled: false,
},
tokens: tokens tokens: tokens
}).done(client => { }).done(client => {
log(translate('logentry.connected')) log(translate('logentry.connected'))
@ -405,7 +521,7 @@ class GlobalBindings {
type: 'chat-message', type: 'chat-message',
user: sender.__ui, user: sender.__ui,
channel: channels.length > 0, channel: channels.length > 0,
message: sanitize(message) message: anchorme({input: sanitize(message), options: anchormeOptions})
}) })
}) })
@ -443,6 +559,10 @@ class GlobalBindings {
this.connectErrorDialog.type(err.type) this.connectErrorDialog.type(err.type)
this.connectErrorDialog.reason(err.reason) this.connectErrorDialog.reason(err.reason)
this.connectErrorDialog.show() this.connectErrorDialog.show()
} else if (err === 'server_does_not_support_webrtc' && this.detectWebRTC && this.webrtc) {
log(translate('logentry.connection_fallback_mode'))
this.webrtc = false
this.connect(username, host, port, tokens, password, channelName)
} else { } else {
log(translate('logentry.connection_error'), err) log(translate('logentry.connection_error'), err)
} }
@ -594,24 +714,32 @@ class GlobalBindings {
} }
}).on('voice', stream => { }).on('voice', stream => {
console.log(`User ${user.username} started takling`) console.log(`User ${user.username} started takling`)
var userNode = new BufferQueueNode({ let userNode
if (!this.webrtc) {
userNode = new BufferQueueNode({
audioContext: audioContext() audioContext: audioContext()
}) })
userNode.connect(audioContext().destination) userNode.connect(audioContext().destination)
}
stream.on('data', data => { if (stream.target === 'normal') {
if (data.target === 'normal') {
ui.talking('on') ui.talking('on')
} else if (data.target === 'shout') { } else if (stream.target === 'shout') {
ui.talking('shout') ui.talking('shout')
} else if (data.target === 'whisper') { } else if (stream.target === 'whisper') {
ui.talking('whisper') ui.talking('whisper')
} }
stream.on('data', data => {
if (this.webrtc) {
// mumble-client is in WebRTC mode, no pcm data should arrive this way
} else {
userNode.write(data.buffer) userNode.write(data.buffer)
}
}).on('end', () => { }).on('end', () => {
console.log(`User ${user.username} stopped takling`) console.log(`User ${user.username} stopped takling`)
ui.talking('off') ui.talking('off')
if (!this.webrtc) {
userNode.end() userNode.end()
}
}) })
}) })
} }
@ -638,13 +766,13 @@ class GlobalBindings {
return true // TODO check for perms return true // TODO check for perms
} }
ui.canAdd = () => { ui.canAdd = () => {
return false // TODO check for perms and implement return true // TODO check for perms
} }
ui.canEdit = () => { ui.canEdit = () => {
return false // TODO check for perms and implement return false // TODO check for perms and implement
} }
ui.canRemove = () => { ui.canRemove = () => {
return false // TODO check for perms and implement return true // TODO check for perms
} }
ui.canLink = () => { ui.canLink = () => {
return false // TODO check for perms and implement return false // TODO check for perms and implement
@ -733,6 +861,15 @@ class GlobalBindings {
voiceHandler.setMute(true) voiceHandler.setMute(true)
} }
this._micNode.disconnect()
this._delayNode.disconnect()
if (mode === 'vad') {
this._micNode.connect(this._delayNode)
this._delayNode.connect(this._delayedMicNode)
} else {
this._micNode.connect(this._delayedMicNode)
}
this.client.setAudioQuality( this.client.setAudioQuality(
this.settings.audioBitrate, this.settings.audioBitrate,
this.settings.samplesPerPacket this.settings.samplesPerPacket
@ -774,18 +911,23 @@ class GlobalBindings {
if (target === this.thisUser()) { if (target === this.thisUser()) {
target = target.channel() target = target.channel()
} }
// Avoid blank message
if (sanitize(message).trim().length == 0) return;
// Support multiline
message = message.replace(/\n\n+/g,"\n\n");
message = message.replace(/\n/g,"<br>");
// Send message // Send message
target.model.sendMessage(message) target.model.sendMessage(anchorme(message))
if (target.users) { // Channel if (target.users) { // Channel
this.log.push({ this.log.push({
type: 'chat-message-self', type: 'chat-message-self',
message: sanitize(message), message: anchorme({input: sanitize(message), options: anchormeOptions}),
channel: target channel: target
}) })
} else { // User } else { // User
this.log.push({ this.log.push({
type: 'chat-message-self', type: 'chat-message-self',
message: sanitize(message), message: anchorme({input: sanitize(message), options: anchormeOptions}),
user: target user: target
}) })
} }
@ -958,6 +1100,12 @@ function initializeUI () {
if (queryParams.password) { if (queryParams.password) {
ui.connectDialog.password(queryParams.password) ui.connectDialog.password(queryParams.password)
} }
if (queryParams.webrtc !== 'auto') {
ui.detectWebRTC = false
if (queryParams.webrtc == 'false') {
ui.webrtc = false
}
}
if (queryParams.channelName) { if (queryParams.channelName) {
ui.connectDialog.channelName(queryParams.channelName) ui.connectDialog.channelName(queryParams.channelName)
} }
@ -1124,13 +1272,38 @@ function translateEverything() {
translatePiece('.channel-context-menu .copy-mumble-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_url'); 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 .copy-mumble-web-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_web_url');
translatePiece('.channel-context-menu .send-message', 'textcontent', {}, 'channelcontextmenu.send_message'); translatePiece('.channel-context-menu .send-message', 'textcontent', {}, 'channelcontextmenu.send_message');
translatePiece('.toolbar .tb-horizontal', 'attribute', {'name': 'title'}, 'toolbar.orientation');
translatePiece('.toolbar .tb-horizontal', 'attribute', {'name': 'alt'}, 'toolbar.orientation');
translatePiece('.toolbar .tb-vertical', 'attribute', {'name': 'title'}, 'toolbar.orientation');
translatePiece('.toolbar .tb-vertical', 'attribute', {'name': 'alt'}, 'toolbar.orientation');
translatePiece('.toolbar .tb-connect', 'attribute', {'name': 'title'}, 'toolbar.connect');
translatePiece('.toolbar .tb-connect', 'attribute', {'name': 'alt'}, 'toolbar.connect');
translatePiece('.toolbar .tb-information', 'attribute', {'name': 'title'}, 'toolbar.information');
translatePiece('.toolbar .tb-information', 'attribute', {'name': 'alt'}, 'toolbar.information');
translatePiece('.toolbar .tb-mute', 'attribute', {'name': 'title'}, 'toolbar.mute');
translatePiece('.toolbar .tb-mute', 'attribute', {'name': 'alt'}, 'toolbar.mute');
translatePiece('.toolbar .tb-unmute', 'attribute', {'name': 'title'}, 'toolbar.unmute');
translatePiece('.toolbar .tb-unmute', 'attribute', {'name': 'alt'}, 'toolbar.unmute');
translatePiece('.toolbar .tb-deaf', 'attribute', {'name': 'title'}, 'toolbar.deaf');
translatePiece('.toolbar .tb-deaf', 'attribute', {'name': 'alt'}, 'toolbar.deaf');
translatePiece('.toolbar .tb-undeaf', 'attribute', {'name': 'title'}, 'toolbar.undeaf');
translatePiece('.toolbar .tb-undeaf', 'attribute', {'name': 'alt'}, 'toolbar.undeaf');
translatePiece('.toolbar .tb-record', 'attribute', {'name': 'title'}, 'toolbar.record');
translatePiece('.toolbar .tb-record', 'attribute', {'name': 'alt'}, 'toolbar.record');
translatePiece('.toolbar .tb-comment', 'attribute', {'name': 'title'}, 'toolbar.comment');
translatePiece('.toolbar .tb-comment', 'attribute', {'name': 'alt'}, 'toolbar.comment');
translatePiece('.toolbar .tb-settings', 'attribute', {'name': 'title'}, 'toolbar.settings');
translatePiece('.toolbar .tb-settings', 'attribute', {'name': 'alt'}, 'toolbar.settings');
translatePiece('.toolbar .tb-sourcecode', 'attribute', {'name': 'title'}, 'toolbar.sourcecode');
translatePiece('.toolbar .tb-sourcecode', 'attribute', {'name': 'alt'}, 'toolbar.sourcecode');
} }
async function main() { async function main() {
await localizationInitialize(navigator.language); await localizationInitialize(navigator.language);
translateEverything(); translateEverything();
initializeUI(); try {
initVoice(data => { const userMedia = await initVoice(data => {
if (testVoiceHandler) { if (testVoiceHandler) {
testVoiceHandler.write(data) testVoiceHandler.write(data)
} }
@ -1142,10 +1315,13 @@ async function main() {
} else if (voiceHandler) { } else if (voiceHandler) {
voiceHandler.write(data) voiceHandler.write(data)
} }
}, err => {
log(translate('logentry.mic_init_error'), err)
}) })
ui._micStream = userMedia
} catch (err) {
window.alert('Failed to initialize user media\nRefresh page to retry.\n' + err)
return
}
initializeUI();
} }
window.onload = main window.onload = main

View file

@ -1,10 +1,10 @@
import { Writable } from 'stream' import { Writable } from 'stream'
import MicrophoneStream from 'microphone-stream' import MicrophoneStream from 'microphone-stream'
import audioContext from 'audio-context' import audioContext from 'audio-context'
import getUserMedia from 'getusermedia'
import keyboardjs from 'keyboardjs' import keyboardjs from 'keyboardjs'
import vad from 'voice-activity-detection' import vad from 'voice-activity-detection'
import DropStream from 'drop-stream' import DropStream from 'drop-stream'
import { WorkerBasedMumbleClient } from './worker-client'
class VoiceHandler extends Writable { class VoiceHandler extends Writable {
constructor (client, settings) { constructor (client, settings) {
@ -33,8 +33,12 @@ class VoiceHandler extends Writable {
return this._outbound return this._outbound
} }
if (this._client instanceof WorkerBasedMumbleClient) {
// Note: the samplesPerPacket argument is handled in worker.js and not passed on // Note: the samplesPerPacket argument is handled in worker.js and not passed on
this._outbound = this._client.createVoiceStream(this._settings.samplesPerPacket) this._outbound = this._client.createVoiceStream(this._settings.samplesPerPacket)
} else {
this._outbound = this._client.createVoiceStream()
}
this.emit('started_talking') this.emit('started_talking')
} }
@ -160,16 +164,13 @@ export class VADVoiceHandler extends VoiceHandler {
var theUserMedia = null var theUserMedia = null
export function initVoice (onData, onUserMediaError) { export function initVoice (onData) {
getUserMedia({ audio: true }, (err, userMedia) => { return window.navigator.mediaDevices.getUserMedia({ audio: true }).then((userMedia) => {
if (err) {
onUserMediaError(err)
} else {
theUserMedia = userMedia theUserMedia = userMedia
var micStream = new MicrophoneStream(userMedia, { objectMode: true, bufferSize: 1024 }) var micStream = new MicrophoneStream(userMedia, { objectMode: true, bufferSize: 1024 })
micStream.on('data', data => { micStream.on('data', data => {
onData(Buffer.from(data.getChannelData(0).buffer)) onData(Buffer.from(data.getChannelData(0).buffer))
}) })
} return userMedia
}) })
} }

View file

@ -12,8 +12,6 @@ import Worker from './worker'
*/ */
class WorkerBasedMumbleConnector { class WorkerBasedMumbleConnector {
constructor () { constructor () {
this._worker = new Worker()
this._worker.addEventListener('message', this._onMessage.bind(this))
this._reqId = 1 this._reqId = 1
this._requests = {} this._requests = {}
this._clients = {} this._clients = {}
@ -29,6 +27,10 @@ class WorkerBasedMumbleConnector {
} }
_postMessage (msg, transfer) { _postMessage (msg, transfer) {
if (!this._worker) {
this._worker = new Worker()
this._worker.addEventListener('message', this._onMessage.bind(this))
}
try { try {
this._worker.postMessage(msg, transfer) this._worker.postMessage(msg, transfer)
} catch (err) { } catch (err) {
@ -125,7 +127,7 @@ class WorkerBasedMumbleConnector {
} }
} }
class WorkerBasedMumbleClient extends EventEmitter { export class WorkerBasedMumbleClient extends EventEmitter {
constructor (connector, clientId) { constructor (connector, clientId) {
super() super()
this._connector = connector this._connector = connector
@ -138,6 +140,7 @@ class WorkerBasedMumbleClient extends EventEmitter {
connector._addCall(this, 'setSelfMute', id) connector._addCall(this, 'setSelfMute', id)
connector._addCall(this, 'setSelfTexture', id) connector._addCall(this, 'setSelfTexture', id)
connector._addCall(this, 'setAudioQuality', id) connector._addCall(this, 'setAudioQuality', id)
connector._addCall(this, '_send', id)
connector._addCall(this, 'disconnect', id) connector._addCall(this, 'disconnect', id)
let _disconnect = this.disconnect let _disconnect = this.disconnect
@ -341,11 +344,12 @@ class WorkerBasedMumbleUser extends EventEmitter {
props props
] ]
} else if (name === 'voice') { } else if (name === 'voice') {
let [id] = args let [id, target] = args
let stream = new PassThrough({ let stream = new PassThrough({
objectMode: true objectMode: true
}) })
this._connector._voiceStreams[id] = stream this._connector._voiceStreams[id] = stream
stream.target = target
args = [stream] args = [stream]
} else if (name === 'remove') { } else if (name === 'remove') {
delete this._client._users[this._id] delete this._client._users[this._id]

View file

@ -164,7 +164,7 @@ import 'subworkers'
}) })
}) })
return [voiceId] return [voiceId, stream.target]
}) })
registerEventProxy(id, user, 'remove') registerEventProxy(id, user, 'remove')

View file

@ -31,6 +31,19 @@
"title": "Mumble Voice Conference", "title": "Mumble Voice Conference",
"connect": "Join Conference" "connect": "Join Conference"
}, },
"toolbar": {
"orientation": "Switch Orientation",
"connect": "Connection",
"information": "Information",
"mute": "Mute",
"unmute": "Unmute",
"deaf": "Deafen",
"undeaf": "Undeafen",
"record": "Record",
"comment": "Comment",
"settings": "Settings",
"sourcecode": "Open Source Code"
},
"usercontextmenu": { "usercontextmenu": {
"mute": "Mute", "mute": "Mute",
"deafen": "Deafen", "deafen": "Deafen",
@ -66,6 +79,7 @@
"connecting": "Connecting to server", "connecting": "Connecting to server",
"connected": "Connected!", "connected": "Connected!",
"connection_error": "Connection error:", "connection_error": "Connection error:",
"connection_fallback_mode": "Server does not support WebRTC, re-trying in fallback mode..",
"unknown_voice_mode": "Unknown voice mode:", "unknown_voice_mode": "Unknown voice mode:",
"mic_init_error": "Cannot initialize user media. Microphone will not work:" "mic_init_error": "Cannot initialize user media. Microphone will not work:"
}, },

2984
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,46 +17,47 @@
"dist" "dist"
], ],
"devDependencies": { "devDependencies": {
"@babel/core": "^7.9.0", "@babel/core": "^7.12.9",
"@babel/plugin-transform-runtime": "^7.9.0", "@babel/plugin-transform-runtime": "^7.12.1",
"@babel/preset-env": "^7.9.0", "@babel/preset-env": "^7.12.7",
"@babel/runtime": "^7.9.2", "@babel/runtime": "^7.12.5",
"anchorme": "^2.1.2",
"audio-buffer-utils": "^5.1.2", "audio-buffer-utils": "^5.1.2",
"audio-context": "^1.0.3", "audio-context": "^1.0.3",
"babel-loader": "^8.1.0", "babel-loader": "^8.2.1",
"brfs": "^2.0.2", "brfs": "^2.0.2",
"bytebuffer": "^5.0.1", "bytebuffer": "^5.0.1",
"css-loader": "^3.4.2", "css-loader": "^3.6.0",
"dompurify": "^2.0.8", "dompurify": "^2.2.2",
"drop-stream": "^1.0.0", "drop-stream": "^1.0.0",
"duplex-maker": "^1.0.0", "duplex-maker": "^1.0.0",
"extract-loader": "^5.0.1", "extract-loader": "^5.1.0",
"file-loader": "^4.3.0", "file-loader": "^4.3.0",
"fs": "0.0.1-security", "fs": "0.0.1-security",
"getusermedia": "^2.0.1", "getusermedia": "^2.0.1",
"html-loader": "^0.5.5", "html-loader": "^0.5.5",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"keyboardjs": "^2.5.1", "keyboardjs": "^2.6.4",
"knockout": "^3.5.1", "knockout": "^3.5.1",
"libsamplerate.js": "^1.0.0", "libsamplerate.js": "^1.0.0",
"lodash.assign": "^4.2.0", "lodash.assign": "^4.2.0",
"microphone-stream": "^5.0.1", "microphone-stream": "^5.1.0",
"mumble-client": "^1.3.0", "mumble-client": "github:johni0702/mumble-client#f73a08b",
"mumble-client-codecs-browser": "^1.2.0", "mumble-client-codecs-browser": "^1.2.0",
"mumble-client-websocket": "^1.0.0", "mumble-client-websocket": "github:johni0702/mumble-client-websocket#5b0ed8d",
"node-sass": "^4.13.1", "node-sass": "^4.14.1",
"patch-package": "^6.2.1", "patch-package": "^6.2.1",
"raw-loader": "^4.0.0", "raw-loader": "^4.0.2",
"regexp-replace-loader": "1.0.1", "regexp-replace-loader": "1.0.1",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"stream-chunker": "^1.2.8", "stream-chunker": "^1.2.8",
"subworkers": "^1.0.1", "subworkers": "^1.0.1",
"to-arraybuffer": "^1.0.1", "to-arraybuffer": "^1.0.1",
"transform-loader": "^0.2.4", "transform-loader": "^0.2.4",
"voice-activity-detection": "johni0702/voice-activity-detection#9f8bd90", "voice-activity-detection": "github:johni0702/voice-activity-detection#9f8bd90",
"web-audio-buffer-queue": "^1.1.0", "web-audio-buffer-queue": "^1.1.0",
"webpack": "^4.42.1", "webpack": "^4.44.2",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.12",
"worker-loader": "^2.0.0" "worker-loader": "^2.0.0"
}, },
"optionalDependencies": {} "optionalDependencies": {}

View file

@ -537,3 +537,49 @@ form {
.minimal .user-status { .minimal .user-status {
height: 19px; height: 19px;
} }
/* Mobile view */
@media only screen and (max-width: 600px) and (min-width: 320px) and (min-height: 600px) {
.toolbar-horizontal ~ .channel-root-container, .toolbar-vertical ~ .channel-root-container {
height:calc(100% - 440px);
position:static;
width:100%;
}
.toolbar-horizontal ~ .chat, .toolbar-vertical ~ .chat {
position:fixed;
bottom: 60px;
left:0;
width:100%;
height:330px;
y-overflow:auto;
font-size:0.8em;
z-index:10;
}
.toolbar-vertical {
flex-direction: row;
height: 36px;
margin-top: 4px;
margin-left: 1%;
padding-left: 5px;
}
#message-box {
margin: 10px 5px 10px 5px;
padding: 10px;
height: 2em;
font-size: 1.2em;
font-weight: bold;
}
.handle-vertical, .handle-horizontal {
display: none;
}
.dialog {
min-width: 350px;
}
}