Merge branch 'master' of https://github.com/johni0702/mumble-web
This commit is contained in:
commit
186766f89b
63
README.md
63
README.md
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
224
app/index.js
224
app/index.js
|
@ -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
|
||||||
|
|
||||||
|
|
15
app/voice.js
15
app/voice.js
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -164,7 +164,7 @@ import 'subworkers'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return [voiceId]
|
return [voiceId, stream.target]
|
||||||
})
|
})
|
||||||
registerEventProxy(id, user, 'remove')
|
registerEventProxy(id, user, 'remove')
|
||||||
|
|
||||||
|
|
14
loc/en.json
14
loc/en.json
|
@ -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
2984
package-lock.json
generated
File diff suppressed because it is too large
Load diff
35
package.json
35
package.json
|
@ -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": {}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue