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 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.
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.
Therefore, at the moment only the Opus and CELT Alpha codecs are supported.
In WebRTC mode (default) only the Opus codec is 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
administrative functionallity, are still missing.
@ -23,7 +21,7 @@ administrative functionallity, are still missing.
#### Download
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
@ -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.
#### Setup
At the time of writing this there do not seem to be any Mumble servers
which natively support Websockets. To use this client with any standard mumble
server, websockify must be set up (preferably on the same machine that the
Mumble server is running on).
At the time of writing this there do not seem to be any Mumble servers which natively support Websockets+WebRTC.
[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.
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).
You can install websockify via your package manager `apt install websockify` or
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.
Additionally you will need some web server to serve static files and terminate the secure websocket connection (mumble-web-proxy only supports insecure ones).
There are two basic ways you can use websockify with mumble-web:
- Standalone, use websockify for both, websockets and serving static files
- 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`.
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 `<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`.
* NGINX configuration file
```Nginx
@ -79,7 +57,7 @@ server {
root /path/to/dist;
}
location /demo {
proxy_pass http://<websockify>:64737;
proxy_pass http://<proxybox>:64737;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
@ -101,12 +79,19 @@ http://voice.example.com {
https://voice.example.com {
tls "/etc/letsencrypt/live/voice.example.com/fullchain.pem" "/etc/letsencrypt/live/voice.example.com/privkey.pem"
root /path/to/dist
proxy /demo http://<websockify>:64737 {
proxy /demo http://<proxybox>:64737 {
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.
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
[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
[Matrix]: https://matrix.org

View file

@ -32,10 +32,13 @@ window.mumbleWebConfig = {
'token': '',
'username': '',
'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
'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': 'MetroMumbleLight',
'startMute': false,
'startDeaf': false
}
}

View file

@ -2,6 +2,7 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 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="icon" type="image/png" href="favicon/favicon-32x32.png" sizes="32x32">
@ -77,6 +78,22 @@
</form>
</div>
<!-- /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 -->
<div class="join-dialog dialog" data-bind="visible: visible() && joinOnly()">
<div class="dialog-header">
@ -412,7 +429,7 @@
class="join">
Join Channel
</li>
<li data-bind="css: { disabled: !canAdd() }"
<li data-bind="css: { disabled: !canAdd() }, click: $root.openAddChannel.bind($root, $root.thisUser())"
class="add">
Add
</li>
@ -420,7 +437,7 @@
class="edit">
Edit
</li>
<li data-bind="css: { disabled: !canRemove() }"
<li data-bind="css: { disabled: !canRemove() }, click: $root.ChannelRemove.bind($root, $root.thisUser())"
class="remove">
Remove
</li>
@ -462,40 +479,52 @@
</script>
<div class="toolbar" data-bind="css: { 'toolbar-horizontal': toolbarHorizontal(),
'toolbar-vertical': !toolbarHorizontal() }">
<img class="handle-horizontal" src="/svg/handle_horizontal.svg"
data-bind="click: toggleToolbarOrientation">
<img class="handle-vertical" src="/svg/handle_vertical.svg"
data-bind="click: toggleToolbarOrientation">
<img class="tb-horizontal handle-horizontal" src="/svg/handle_horizontal.svg"
data-bind="click: toggleToolbarOrientation"
title="Switch Orientation" alt="Switch Orientation">
<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(),
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"
data-bind="click: connectionInfo.show,
css: { disabled: !thisUser() }">
css: { disabled: !thisUser() }"
title="Information" alt="Information">
<div class="divider"></div>
<img class="tb-mute" data-bind="visible: !selfMute(),
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,
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(),
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,
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(){}"
rel="record" src="/svg/media-record.svg">
rel="record" src="/svg/media-record.svg"
title="Record" alt="Record">
<div class="divider"></div>
<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>
<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>
<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 class="chat">
<script type="text/html" id="log-generic">
@ -533,8 +562,10 @@
</div>
</div>
<form data-bind="submit: submitMessageBox">
<input id="message-box" type="text" data-bind="
attr: { placeholder: messageBoxHint }, textInput: messageBox">
<textarea id="message-box" row=1 data-bind="
attr: { placeholder: messageBoxHint },
textInput: messageBox,
event: {keypress: submitOnEnter}"></textarea>
</form>
</div>
<script type="text/html" id="channel">

View file

@ -5,22 +5,62 @@ import ByteBuffer from 'bytebuffer'
import MumbleClient from 'mumble-client'
import WorkerBasedMumbleConnector from './worker-client'
import BufferQueueNode from 'web-audio-buffer-queue'
import mumbleConnect from 'mumble-client-websocket'
import audioContext from 'audio-context'
import ko from 'knockout'
import _dompurify from 'dompurify'
import keyboardjs from 'keyboardjs'
import anchorme from 'anchorme'
import { ContinuousVoiceHandler, PushToTalkVoiceHandler, VADVoiceHandler, initVoice } from './voice'
import {initialize as localizationInitialize, translate} from './loc';
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) {
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) {
contextMenu.posX(event.clientX)
contextMenu.posY(event.clientY)
@ -49,6 +89,20 @@ function ContextMenu () {
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 () {
var self = this
self.address = ko.observable('')
@ -65,6 +119,9 @@ function ConnectDialog () {
self.hide = self.visible.bind(self.visible, false)
self.connect = function () {
self.hide()
if (ui.detectWebRTC) {
ui.webrtc = true
}
ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName())
}
@ -283,11 +340,15 @@ class GlobalBindings {
constructor (config) {
this.config = config
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.userContextMenu = new ContextMenu()
this.channelContextMenu = new ContextMenu()
this.connectDialog = new ConnectDialog()
this.addChannelDialog = new AddChannelDialog()
this.connectErrorDialog = new ConnectErrorDialog(this.connectDialog)
this.connectionInfo = new ConnectionInfo(this)
this.commentDialog = new CommentDialog()
@ -302,8 +363,8 @@ class GlobalBindings {
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 = ko.observable(this.config.defaults.startMute)
this.selfDeaf = ko.observable(this.config.defaults.startDeaf)
this.selfMute.subscribe(mute => {
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.toolbarHorizontal(!this.toolbarHorizontal())
this.settings.toolbarVertical = !this.toolbarHorizontal()
@ -325,6 +394,32 @@ class GlobalBindings {
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 = () => {
const settingsDialog = this.settingsDialog()
@ -344,10 +439,14 @@ class GlobalBindings {
}
this.getTimeString = () => {
return '[' + new Date().toLocaleTimeString('en-US') + ']'
return '[' + new Date().toLocaleTimeString(navigator.language) + ']'
}
this.connect = (username, host, port, tokens = [], password, channelName = "") => {
// if browser support Notification request permission
if ('Notification' in window) Notification.requestPermission()
this.resetClient()
this.remoteHost(host)
@ -357,12 +456,29 @@ class GlobalBindings {
// 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)
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
this.connector.connect(`wss://${host}:${port}`, {
(this.webrtc ? this.webrtcConnector : this.fallbackConnector).connect(`wss://${host}:${port}`, {
username: username,
password: password,
webrtc: this.webrtc ? {
enabled: true,
required: true,
mic: this._delayedMicNode.stream,
audioContext: ctx
} : {
enabled: false,
},
tokens: tokens
}).done(client => {
log(translate('logentry.connected'))
@ -405,7 +521,7 @@ class GlobalBindings {
type: 'chat-message',
user: sender.__ui,
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.reason(err.reason)
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 {
log(translate('logentry.connection_error'), err)
}
@ -594,24 +714,32 @@ class GlobalBindings {
}
}).on('voice', stream => {
console.log(`User ${user.username} started takling`)
var userNode = new BufferQueueNode({
audioContext: audioContext()
})
userNode.connect(audioContext().destination)
let userNode
if (!this.webrtc) {
userNode = new BufferQueueNode({
audioContext: audioContext()
})
userNode.connect(audioContext().destination)
}
if (stream.target === 'normal') {
ui.talking('on')
} else if (stream.target === 'shout') {
ui.talking('shout')
} else if (stream.target === 'whisper') {
ui.talking('whisper')
}
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')
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', () => {
console.log(`User ${user.username} stopped takling`)
ui.talking('off')
userNode.end()
if (!this.webrtc) {
userNode.end()
}
})
})
}
@ -638,13 +766,13 @@ class GlobalBindings {
return true // TODO check for perms
}
ui.canAdd = () => {
return false // TODO check for perms and implement
return true // TODO check for perms
}
ui.canEdit = () => {
return false // TODO check for perms and implement
}
ui.canRemove = () => {
return false // TODO check for perms and implement
return true // TODO check for perms
}
ui.canLink = () => {
return false // TODO check for perms and implement
@ -733,6 +861,15 @@ class GlobalBindings {
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.settings.audioBitrate,
this.settings.samplesPerPacket
@ -774,18 +911,23 @@ class GlobalBindings {
if (target === this.thisUser()) {
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
target.model.sendMessage(message)
target.model.sendMessage(anchorme(message))
if (target.users) { // Channel
this.log.push({
type: 'chat-message-self',
message: sanitize(message),
message: anchorme({input: sanitize(message), options: anchormeOptions}),
channel: target
})
} else { // User
this.log.push({
type: 'chat-message-self',
message: sanitize(message),
message: anchorme({input: sanitize(message), options: anchormeOptions}),
user: target
})
}
@ -958,6 +1100,12 @@ function initializeUI () {
if (queryParams.password) {
ui.connectDialog.password(queryParams.password)
}
if (queryParams.webrtc !== 'auto') {
ui.detectWebRTC = false
if (queryParams.webrtc == 'false') {
ui.webrtc = false
}
}
if (queryParams.channelName) {
ui.connectDialog.channelName(queryParams.channelName)
}
@ -1124,28 +1272,56 @@ function translateEverything() {
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');
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() {
await localizationInitialize(navigator.language);
translateEverything();
initializeUI();
initVoice(data => {
if (testVoiceHandler) {
testVoiceHandler.write(data)
}
if (!ui.client) {
if (voiceHandler) {
voiceHandler.end()
try {
const userMedia = await initVoice(data => {
if (testVoiceHandler) {
testVoiceHandler.write(data)
}
voiceHandler = null
} else if (voiceHandler) {
voiceHandler.write(data)
}
}, err => {
log(translate('logentry.mic_init_error'), err)
})
if (!ui.client) {
if (voiceHandler) {
voiceHandler.end()
}
voiceHandler = null
} else if (voiceHandler) {
voiceHandler.write(data)
}
})
ui._micStream = userMedia
} catch (err) {
window.alert('Failed to initialize user media\nRefresh page to retry.\n' + err)
return
}
initializeUI();
}
window.onload = main

View file

@ -1,10 +1,10 @@
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'
import { WorkerBasedMumbleClient } from './worker-client'
class VoiceHandler extends Writable {
constructor (client, settings) {
@ -33,8 +33,12 @@ class VoiceHandler extends Writable {
return this._outbound
}
// Note: the samplesPerPacket argument is handled in worker.js and not passed on
this._outbound = this._client.createVoiceStream(this._settings.samplesPerPacket)
if (this._client instanceof WorkerBasedMumbleClient) {
// Note: the samplesPerPacket argument is handled in worker.js and not passed on
this._outbound = this._client.createVoiceStream(this._settings.samplesPerPacket)
} else {
this._outbound = this._client.createVoiceStream()
}
this.emit('started_talking')
}
@ -160,16 +164,13 @@ export class VADVoiceHandler extends VoiceHandler {
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))
})
}
export function initVoice (onData) {
return window.navigator.mediaDevices.getUserMedia({ audio: true }).then((userMedia) => {
theUserMedia = userMedia
var micStream = new MicrophoneStream(userMedia, { objectMode: true, bufferSize: 1024 })
micStream.on('data', data => {
onData(Buffer.from(data.getChannelData(0).buffer))
})
return userMedia
})
}

View file

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

View file

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

View file

@ -31,6 +31,19 @@
"title": "Mumble Voice 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": {
"mute": "Mute",
"deafen": "Deafen",
@ -66,6 +79,7 @@
"connecting": "Connecting to server",
"connected": "Connected!",
"connection_error": "Connection error:",
"connection_fallback_mode": "Server does not support WebRTC, re-trying in fallback mode..",
"unknown_voice_mode": "Unknown voice mode:",
"mic_init_error": "Cannot initialize user media. Microphone will not work:"
},

2986
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -537,3 +537,49 @@ form {
.minimal .user-status {
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;
}
}