commit 7bad9baaafc0b878e654b3bf68f15f7ba2e36ba6 Author: johni0702 Date: Sun Nov 27 17:43:59 2016 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7864099 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +dist + +# Created by https://www.gitignore.io/api/node + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b532172 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# 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). + +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. + +libopus and libsamplerate, compiled to JS via emscripten, are used for audio decoding. +Therefore, at the moment only the Opus codec is supported. + +Quite a few features, most noticeably voice activity detection and all +administrative functionallity, are still missing. + +### Installing + +#### Download +mumble-web can either be installed directly from npm with `npm install -g mumble-web` +or from git: + +``` +git clone https://github.com/johni0702/mumble-web +cd mumble-web +npm install +npm run build +``` + +The npm version is prebuilt and ready to use whereas the git version allows you +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). + +You can install websockify via `npm install -g websockify` or via your package +manager `apt install websockify`. + +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. +``` +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. +``` +websockify --ssl-target 64737 mumbleserver:64738 +``` + +A sample configuration for nginx that allows access to mumble-web at +`https://voice.example.com/` and connecting at `wss://voice.example.com/demo` +(similar to the demo server) looks like this: +``` +server { + listen 443 ssl; + server_name voice.example.com; + ssl_certificate /etc/letsencrypt/live/voice.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/voice.example.com/privkey.pem; + + location / { + root /path/to/dist; + } + location /mumble { + proxy_pass http://websockify:64737; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } +} + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +``` + +### License +ISC + +[Mumble]: https://wiki.mumble.info/wiki/Main_Page diff --git a/app/favicon/android-chrome-192x192.png b/app/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..a08d992 Binary files /dev/null and b/app/favicon/android-chrome-192x192.png differ diff --git a/app/favicon/android-chrome-512x512.png b/app/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..122503b Binary files /dev/null and b/app/favicon/android-chrome-512x512.png differ diff --git a/app/favicon/apple-touch-icon.png b/app/favicon/apple-touch-icon.png new file mode 100644 index 0000000..83a08dd Binary files /dev/null and b/app/favicon/apple-touch-icon.png differ diff --git a/app/favicon/browserconfig.xml b/app/favicon/browserconfig.xml new file mode 100644 index 0000000..eea97da --- /dev/null +++ b/app/favicon/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/app/favicon/favicon-16x16.png b/app/favicon/favicon-16x16.png new file mode 100644 index 0000000..df4e45f Binary files /dev/null and b/app/favicon/favicon-16x16.png differ diff --git a/app/favicon/favicon-32x32.png b/app/favicon/favicon-32x32.png new file mode 100644 index 0000000..1fbe564 Binary files /dev/null and b/app/favicon/favicon-32x32.png differ diff --git a/app/favicon/favicon.ico b/app/favicon/favicon.ico new file mode 100644 index 0000000..7771aaa Binary files /dev/null and b/app/favicon/favicon.ico differ diff --git a/app/favicon/manifest.json b/app/favicon/manifest.json new file mode 100644 index 0000000..e1434c9 --- /dev/null +++ b/app/favicon/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "Mumble", + "icons": [ + { + "src": "#require('./android-chrome-192x192.png')", + "sizes": "192x192", + "type": "image\/png" + }, + { + "src": "#require('./android-chrome-512x512.png')", + "sizes": "512x512", + "type": "image\/png" + } + ], + "theme_color": "#ffffff", + "display": "standalone" +} diff --git a/app/favicon/mstile-144x144.png b/app/favicon/mstile-144x144.png new file mode 100644 index 0000000..86ebe8f Binary files /dev/null and b/app/favicon/mstile-144x144.png differ diff --git a/app/favicon/mstile-150x150.png b/app/favicon/mstile-150x150.png new file mode 100644 index 0000000..44b1482 Binary files /dev/null and b/app/favicon/mstile-150x150.png differ diff --git a/app/favicon/mstile-310x150.png b/app/favicon/mstile-310x150.png new file mode 100644 index 0000000..f179c81 Binary files /dev/null and b/app/favicon/mstile-310x150.png differ diff --git a/app/favicon/mstile-310x310.png b/app/favicon/mstile-310x310.png new file mode 100644 index 0000000..e3cb406 Binary files /dev/null and b/app/favicon/mstile-310x310.png differ diff --git a/app/favicon/mstile-70x70.png b/app/favicon/mstile-70x70.png new file mode 100644 index 0000000..1fb00b3 Binary files /dev/null and b/app/favicon/mstile-70x70.png differ diff --git a/app/favicon/safari-pinned-tab.svg b/app/favicon/safari-pinned-tab.svg new file mode 100644 index 0000000..8978569 --- /dev/null +++ b/app/favicon/safari-pinned-tab.svg @@ -0,0 +1,44 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..6cbf002 --- /dev/null +++ b/app/index.html @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + diff --git a/app/index.js b/app/index.js new file mode 100644 index 0000000..5e38036 --- /dev/null +++ b/app/index.js @@ -0,0 +1,512 @@ +import url from 'url' +import mumbleConnect from 'mumble-client-websocket' +import CodecsBrowser from 'mumble-client-codecs-browser' +import BufferQueueNode from 'web-audio-buffer-queue' +import MicrophoneStream from 'microphone-stream' +import audioContext from 'audio-context' +import chunker from 'stream-chunker' +import Resampler from 'libsamplerate.js' +import getUserMedia from 'getusermedia' +import ko from 'knockout' +import _dompurify from 'dompurify' + +const dompurify = _dompurify(window) + +function sanitize (html) { + return dompurify.sanitize(html, { + ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p'] + }) +} + +// GUI + +function ConnectDialog () { + var self = this + self.address = ko.observable('') + self.port = ko.observable('443') + self.token = ko.observable('') + self.username = ko.observable('') + self.visible = ko.observable(true) + self.show = self.visible.bind(self.visible, true) + self.hide = self.visible.bind(self.visible, false) + self.connect = function () { + self.hide() + ui.connect(self.username(), self.address(), self.port(), self.token()) + } +} + +function ConnectionInfo () { + var self = this + self.visible = ko.observable(false) + self.show = function () { + self.visible(true) + } +} + +function CommentDialog () { + var self = this + self.visible = ko.observable(false) + self.show = function () { + self.visible(true) + } +} + +function SettingsDialog () { + var self = this + self.visible = ko.observable(false) + self.show = function () { + self.visible(true) + } +} + +class GlobalBindings { + constructor () { + this.client = null + this.connectDialog = new ConnectDialog() + this.connectionInfo = new ConnectionInfo() + this.commentDialog = new CommentDialog() + this.settingsDialog = new SettingsDialog() + this.log = ko.observableArray() + this.thisUser = ko.observable() + this.root = ko.observable() + this.messageBox = ko.observable('') + this.selected = ko.observable() + + this.select = element => { + this.selected(element) + } + + this.getTimeString = () => { + return '[' + new Date().toLocaleTimeString('en-US') + ']' + } + + this.connect = (username, host, port, token) => { + this.resetClient() + + log('Connecting to server ', host) + + // TODO: token + mumbleConnect(`wss://${host}:${port}`, { + username: username, + codecs: CodecsBrowser + }).done(client => { + log('Connected!') + + this.client = client + + // Prepare for connection errors + client.on('error', function (err) { + log('Connection error:', err) + this.resetClient() + }) + + // Register all channels, recursively + const registerChannel = channel => { + this._newChannel(channel) + channel.children.forEach(registerChannel) + } + registerChannel(client.root) + + // Register all users + client.users.forEach(user => this._newUser(user)) + + // Register future channels + client.on('newChannel', channel => this._newChannel(channel)) + // Register future users + client.on('newUser', user => this._newUser(user)) + + // Handle messages + client.on('message', (sender, message, users, channels, trees) => { + ui.log.push({ + type: 'chat-message', + user: sender.__ui, + channel: channels.length > 0, + message: sanitize(message) + }) + }) + + // Set own user and root channel + this.thisUser(client.self.__ui) + this.root(client.root.__ui) + // Upate linked channels + this._updateLinks() + // Log welcome message + if (client.welcomeMessage) { + this.log.push({ + type: 'welcome-message', + message: sanitize(client.welcomeMessage) + }) + } + }, err => { + log('Connection error:', err) + }) + } + + this._newUser = user => { + const simpleProperties = { + uniqueId: 'uid', + username: 'name', + mute: 'mute', + deaf: 'deaf', + suppress: 'suppress', + selfMute: 'selfMute', + selfDeaf: 'selfDeaf', + comment: 'comment' + } + var ui = user.__ui = { + model: user, + talking: ko.observable('off'), + channel: ko.observable() + } + Object.entries(simpleProperties).forEach(key => { + ui[key[1]] = ko.observable(user[key[0]]) + }) + ui.state = ko.pureComputed(userToState, ui) + if (user.channel) { + ui.channel(user.channel.__ui) + ui.channel().users.push(ui) + ui.channel().users.sort(compareUsers) + } + + user.on('update', (actor, properties) => { + Object.entries(simpleProperties).forEach(key => { + if (properties[key[0]] !== undefined) { + ui[key[1]](properties[key[0]]) + } + }) + if (properties.channel !== undefined) { + if (ui.channel()) { + ui.channel().users.remove(ui) + } + ui.channel(properties.channel.__ui) + ui.channel().users.push(ui) + ui.channel().users.sort(compareUsers) + this._updateLinks() + } + }).on('remove', () => { + if (ui.channel()) { + ui.channel().users.remove(ui) + } + }).on('voice', stream => { + console.log(`User ${user.username} started takling`) + var userNode = new BufferQueueNode({ + audioContext: audioContext + }) + userNode.connect(audioContext.destination) + + var resampler = new Resampler({ + unsafe: true, + type: Resampler.Type.ZERO_ORDER_HOLD, + ratio: audioContext.sampleRate / 48000 + }) + resampler.pipe(userNode) + + 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') + } + resampler.write(Buffer.from(data.pcm.buffer)) + }).on('end', () => { + console.log(`User ${user.username} stopped takling`) + ui.talking('off') + resampler.end() + }) + }) + } + + this._newChannel = channel => { + const simpleProperties = { + position: 'position', + name: 'name', + description: 'description' + } + var ui = channel.__ui = { + model: channel, + expanded: ko.observable(true), + parent: ko.observable(), + channels: ko.observableArray(), + users: ko.observableArray(), + linked: ko.observable(false) + } + Object.entries(simpleProperties).forEach(key => { + ui[key[1]] = ko.observable(channel[key[0]]) + }) + if (channel.parent) { + ui.parent(channel.parent.__ui) + ui.parent().channels.push(ui) + ui.parent().channels.sort(compareChannels) + } + this._updateLinks() + + channel.on('update', properties => { + Object.entries(simpleProperties).forEach(key => { + if (properties[key[0]] !== undefined) { + ui[key[1]](properties[key[0]]) + } + }) + if (properties.parent !== undefined) { + if (ui.parent()) { + ui.parent().channel.remove(ui) + } + ui.parent(properties.parent.__ui) + ui.parent().channels.push(ui) + ui.parent().channels.sort(compareChannels) + } + if (properties.links !== undefined) { + this._updateLinks() + } + }).on('remove', () => { + if (ui.parent()) { + ui.parent().channels.remove(ui) + } + this._updateLinks() + }) + } + + this.resetClient = () => { + if (this.client) { + this.client.disconnect() + } + this.client = null + this.thisUser(null).root(null).selected(null) + } + + this.connected = () => this.thisUser() != null + + this.messageBoxHint = ko.pureComputed(() => { + if (!this.thisUser()) { + return '' // Not yet connected + } + var target = this.selected() + if (!target) { + target = this.thisUser() + } + if (target === this.thisUser()) { + target = target.channel() + } + if (target.users) { // Channel + return "Type message to channel '" + target.name() + "' here" + } else { // User + return "Type message to user '" + target.name() + "' here" + } + }) + + this.submitMessageBox = () => { + this.sendMessage(this.selected(), this.messageBox()) + this.messageBox('') + } + + this.sendMessage = (target, message) => { + if (this.connected()) { + // If no target is selected, choose our own user + if (!target) { + target = this.thisUser() + } + // If target is our own user, send to our channel + if (target === this.thisUser()) { + target = target.channel() + } + // Send message + target.model.sendMessage(message) + if (target.users) { // Channel + this.log.push({ + type: 'chat-message-self', + message: sanitize(message), + channel: target + }) + } else { // User + this.log.push({ + type: 'chat-message-self', + message: sanitize(message), + user: target + }) + } + } + } + + this.requestMove = (user, channel) => { + if (this.connected()) { + user.model.setChannel(channel.model) + } + } + + this.requestMute = user => { + if (this.connected()) { + if (user === this.thisUser) { + this.client.setSelfMute(true) + } else { + user.model.setMute(true) + } + } + } + + this.requestDeaf = user => { + if (this.connected()) { + if (user === this.thisUser) { + this.client.setSelfDeaf(true) + } else { + user.model.setDeaf(true) + } + } + } + + this.requestUnmute = user => { + if (this.connected()) { + if (user === this.thisUser) { + this.client.setSelfMute(false) + } else { + user.model.setMute(false) + } + } + } + + this.requestUndeaf = user => { + if (this.connected()) { + if (user === this.thisUser) { + this.client.setSelfDeaf(false) + } else { + user.model.setDeaf(false) + } + } + } + + this._updateLinks = () => { + if (!this.thisUser()) { + return + } + + var allChannels = getAllChannels(this.root(), []) + var ownChannel = this.thisUser().channel().model + var allLinked = findLinks(ownChannel, []) + allChannels.forEach(channel => { + channel.linked(allLinked.indexOf(channel.model) !== -1) + }) + + function findLinks (channel, knownLinks) { + knownLinks.push(channel) + channel.links.forEach(next => { + if (next && knownLinks.indexOf(next) === -1) { + findLinks(next, knownLinks) + } + }) + allChannels.map(c => c.model).forEach(next => { + if (next && knownLinks.indexOf(next) === -1 && next.links.indexOf(channel) !== -1) { + findLinks(next, knownLinks) + } + }) + return knownLinks + } + + function getAllChannels (channel, channels) { + channels.push(channel) + channel.channels().forEach(next => getAllChannels(next, channels)) + return channels + } + } + + this.openSourceCode = () => { + var homepage = require('../package.json').homepage + window.open(homepage, '_blank').focus() + } + } +} +var ui = new GlobalBindings() + +// Used only for debugging +window.mumbleUi = ui + +window.onload = function () { + var queryParams = url.parse(document.location.href, true).query + if (queryParams.address) { + ui.connectDialog.address(queryParams.address) + } + if (queryParams.port) { + ui.connectDialog.port(queryParams.port) + } + if (queryParams.token) { + ui.connectDialog.token(queryParams.token) + } + if (queryParams.username) { + ui.connectDialog.username(queryParams.username) + } + ko.applyBindings(ui) +} + +function log () { + console.log.apply(console, arguments) + var args = [] + for (var i = 0; i < arguments.length; i++) { + args.push(arguments[i]) + } + ui.log.push({ + type: 'generic', + value: args.join(' ') + }) +} + +function compareChannels (c1, c2) { + if (c1.position() === c2.position()) { + return c1.name() === c2.name() ? 0 : c1.name() < c2.name() ? -1 : 1 + } + return c1.position() - c2.position() +} + +function compareUsers (u1, u2) { + return u1.name() === u2.name() ? 0 : u1.name() < u2.name() ? -1 : 1 +} + +function userToState () { + var flags = [] + // TODO: Friend + if (this.uid()) { + flags.push('Authenticated') + } + // TODO: Priority Speaker, Recording + if (this.mute()) { + flags.push('Muted (server)') + } + if (this.deaf()) { + flags.push('Deafened (server)') + } + // TODO: Local Ignore (Text messages), Local Mute + if (this.selfMute()) { + flags.push('Muted (self)') + } + if (this.selfDeaf()) { + flags.push('Deafened (self)') + } + return flags.join(', ') +} + +// Audio input + +var resampler = new Resampler({ + unsafe: true, + type: Resampler.Type.SINC_FASTEST, + ratio: 48000 / audioContext.sampleRate +}) + +var voiceStream +resampler.pipe(chunker(4 * 480)).on('data', function (data) { + if (!voiceStream && ui.client) { + voiceStream = ui.client.createVoiceStream() + } + if (voiceStream) { + voiceStream.write(new Float32Array(data.buffer, data.byteOffset, data.byteLength / 4)) + } +}) + +getUserMedia({ audio: true }, function (err, userMedia) { + if (err) { + log('Cannot initialize user media. Microphone will not work:', err) + } else { + var micStream = new MicrophoneStream(userMedia, { objectMode: true }) + micStream.on('data', function (data) { + resampler.write(Buffer.from(data.getChannelData(0).buffer)) + }) + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..212d37f --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "mumble-web", + "version": "0.0.1", + "description": "An HTML5 Mumble client.", + "scripts": { + "build": "webpack", + "prepublish": "rm -r dist && npm run build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Jonas Herzig ", + "license": "ISC", + "repository": "johni0702/mumble-web", + "homepage": "https://github.com/johni0702/mumble-web", + "files": [ + "dist" + ], + "devDependencies": { + "audio-buffer-utils": "^3.1.2", + "audio-context": "^0.1.0", + "babel-core": "^6.18.2", + "babel-loader": "^6.2.8", + "babel-plugin-transform-runtime": "^6.15.0", + "babel-preset-es2015": "^6.14.0", + "babel-runtime": "^6.18.0", + "brfs": "^1.4.3", + "css-loader": "^0.26.0", + "dompurify": "^0.8.3", + "duplex-maker": "^1.0.0", + "extract-loader": "^0.1.0", + "file-loader": "^0.9.0", + "getusermedia": "^2.0.0", + "html-loader": "^0.4.4", + "json-loader": "^0.5.4", + "knockout": "^3.4.0", + "lodash.assign": "^4.2.0", + "microphone-stream": "^3.0.5", + "raw-loader": "^0.5.1", + "regexp-replace-loader": "0.0.1", + "stream-chunker": "^1.2.8", + "transform-loader": "^0.2.3", + "webpack": "^1.13.3", + "webworkify-webpack-dropin": "^1.1.9", + "libsamplerate.js": "^1.0.0", + "mumble-client-codecs-browser": "^1.0.1", + "mumble-client-websocket": "^1.0.0", + "web-audio-buffer-queue": "^1.0.0" + } +} diff --git a/themes/MetroMumbleLight/loading.css b/themes/MetroMumbleLight/loading.css new file mode 100644 index 0000000..b5d9a41 --- /dev/null +++ b/themes/MetroMumbleLight/loading.css @@ -0,0 +1,33 @@ +.loading-container { + position: absolute; + top: 0; + width: 100%; + height: 100%; + background-color: #eee; + z-index: 1000; +} + +.loading-circle { + box-sizing: border-box; + width: 80px; + height: 80px; + position: absolute; + top: calc(50% - 40px); + left: calc(50% - 40px); + border-radius: 100%; + border: 10px solid #ddd; + border-top-color: #999; + animation: spin 1s infinite linear; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + +.loaded { + top: -100%; + transition: top 1s; + transition-delay: 2s; +} diff --git a/themes/MetroMumbleLight/main.css b/themes/MetroMumbleLight/main.css new file mode 100644 index 0000000..c3ad28f --- /dev/null +++ b/themes/MetroMumbleLight/main.css @@ -0,0 +1,252 @@ +html, body { + background-color: #eee; + margin: 0; + overflow: hidden; + height: 100% +} +#container { + height: 98%; + margin: 0 1% 0 1%; +} +#container::before { + display: block; + content: ""; + height: 1%; +} +.channel-root-container { + text-size: 16px; + margin-left: 2px; + background-color: white; + border: 1px solid lightgray; + float: left; + width: calc(60% - 6px); + height: calc(100% - 38px); + border-radius: 3px; + overflow-x: hidden; + overflow-y: auto; +} +.chat { + margin-right: 2px; + float: left; + width: 40%; + height: calc(100% - 38px); +} +.log { + background-color: white; + height: calc(100% - 42px); + padding: 5px; + border: 1px lightgray solid; + border-radius: 3px; + overflow-x: hidden; + overflow-y: scroll; +} +.branch img { + height: 19px; +} +.branch { + float: left; + padding-top: 3px; + padding-bottom: 3px; + background-color: white; + margin-right: +} +.channel-sub { + margin-left: 9px; + border-left: 1px transparent solid; + padding-left: 9px; +} +.channel-wrapper:nth-last-child(n + 2) > .branch:not(:empty) + .channel-sub { + border-left: 1px lightgray solid; +} +.channel-tree, +.user-wrapper { + margin-left: 9px; +} +.channel-tree, +.user-tree { + float: left; +} +.channel-tree::before, +.user-tree::before { + content: ""; + display: block; + position: relative; + width: 9px; + border-left: 1px lightgray solid; + border-bottom: 1px lightgray solid; + height: 14px; +} +.channel-wrapper:nth-last-child(n + 2) > .channel-tree:after, +.user-wrapper:nth-last-child(n + 2) .user-tree:after { + content: ""; + display: block; + position: relative; + width: 0px; + border-left: 1px lightgray solid; + height: 14px; +} +.user { + margin-left: 9px; +} +.user-status, .channel-status { + float: right; +} +.user,.channel{ + height: 23px; + padding: 2px; + border: 1px solid transparent; +} +.selected { + background-color: lightblue !important; + border: 1px solid gray; + border-radius: 3px; +} +.user:hover,.channel:hover { + background-color: lightgray; +} +.thisClient { + font-weight: bold +} +.currentChannel { + font-weight: bold +} +.user-status img, .channel-status img { + margin-top: 2px; + width: 19px; + height: 19px +} +.channel img, .user img { + width: auto; + height: 19px; +} +.channel-name, .user-name { + display: inline; +} +.channel:hover .tooltip, .user:hover .tooltip { + visibility: visible; + height: auto; + transition-delay: 1s; +} +.tooltip { + visibility: hidden; + height: 0px; + background: white; + border: 1px solid gray; + margin-top: 16px; + margin-left: 30px; + padding: 10px; + position: absolute; + z-index: 100; +} +.toolbar { + height: 36px; +} +.toolbar img { + height: 28px; + width: 28px; + padding: 2px; + border: 1px solid transparent; + border-radius: 3px; +} +.toolbar img:hover { + border: 1px solid #bbb; + background-color: #ddd; +} +.toolbar .tb-active { + border: 1px solid #bbb; + background-color: white; +} +.divider { + display: inline-block; + height: 32px; + border-left: 1px lightgray solid; +} +.handle-horizontal { + width: auto !important; + border: none !important; + background-color: #eee !important; +} +.handle-vertical { + display: none; +} +.channel-icon .channel-icon-active { + display: none; +} +.channel-tag { + font-weight: bold; + color: orange; +} +.user-tag { + font-weight: bold; + color: green; +} +#message-box { + width: 100%; + border: none; + background: none; + margin: 5px 0 5px 0; + padding: 0; + height: 20px; +} +form { + margin: 0; + padding: 0; +} +.message-content p { + margin: 0; +} +.tb-information, .tb-record, .tb-comment, .tb-settings{ + filter: grayscale(100%); +} +.dialog-header { + height: 20px; + width: calc(100% - 10px); + padding: 5px; + text-align: center; + color: white; + background-color: gray; + border-bottom: 1px solid darkgray; +} +.dialog-footer { + margin: 10px; + margin-bottom: 0px; +} +.dialog-close { + float: left; +} +.dialog-submit { + float: right; +} +.dialog-close, .dialog-submit { + width: 45%; + font-size: 15px; + border: 1px gray solid; + border-radius: 3px; + background-color: white; + color: black; + padding: 1px; +} +.connect-dialog table { + text-align: center; + width: 100% +} +.connect-dialog { + position: absolute; + width: 300px; + height: 197px; + top: calc(50% - 100px); + left: calc(50% - 150px); + background-color: #eee; + border: 1px gray solid; + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.25); + z-index: 20; +} +.connect-dialog input[type=text] { + font-size: 15px; + border: 1px gray solid; + border-radius: 3px; + background-color: white; + color: black; + padding: 2px; + width: calc(100% - 8px); +} diff --git a/themes/MetroMumbleLight/svg/applications-internet.svg b/themes/MetroMumbleLight/svg/applications-internet.svg new file mode 100644 index 0000000..85bbbce --- /dev/null +++ b/themes/MetroMumbleLight/svg/applications-internet.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/audio-input-microphone-muted.svg b/themes/MetroMumbleLight/svg/audio-input-microphone-muted.svg new file mode 100644 index 0000000..b5e433a --- /dev/null +++ b/themes/MetroMumbleLight/svg/audio-input-microphone-muted.svg @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/audio-input-microphone.svg b/themes/MetroMumbleLight/svg/audio-input-microphone.svg new file mode 100644 index 0000000..c462f75 --- /dev/null +++ b/themes/MetroMumbleLight/svg/audio-input-microphone.svg @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/audio-output-deafened.svg b/themes/MetroMumbleLight/svg/audio-output-deafened.svg new file mode 100644 index 0000000..8e22f09 --- /dev/null +++ b/themes/MetroMumbleLight/svg/audio-output-deafened.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/audio-output.svg b/themes/MetroMumbleLight/svg/audio-output.svg new file mode 100644 index 0000000..bae1168 --- /dev/null +++ b/themes/MetroMumbleLight/svg/audio-output.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/authenticated.svg b/themes/MetroMumbleLight/svg/authenticated.svg new file mode 100644 index 0000000..81b9886 --- /dev/null +++ b/themes/MetroMumbleLight/svg/authenticated.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/themes/MetroMumbleLight/svg/branch_closed.svg b/themes/MetroMumbleLight/svg/branch_closed.svg new file mode 100644 index 0000000..56c90d1 --- /dev/null +++ b/themes/MetroMumbleLight/svg/branch_closed.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/themes/MetroMumbleLight/svg/branch_open.svg b/themes/MetroMumbleLight/svg/branch_open.svg new file mode 100644 index 0000000..57fb6b0 --- /dev/null +++ b/themes/MetroMumbleLight/svg/branch_open.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/themes/MetroMumbleLight/svg/channel.svg b/themes/MetroMumbleLight/svg/channel.svg new file mode 100644 index 0000000..40c6dd6 --- /dev/null +++ b/themes/MetroMumbleLight/svg/channel.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/themes/MetroMumbleLight/svg/channel_active.svg b/themes/MetroMumbleLight/svg/channel_active.svg new file mode 100644 index 0000000..40c6dd6 --- /dev/null +++ b/themes/MetroMumbleLight/svg/channel_active.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/themes/MetroMumbleLight/svg/channel_linked.svg b/themes/MetroMumbleLight/svg/channel_linked.svg new file mode 100644 index 0000000..ec5892d --- /dev/null +++ b/themes/MetroMumbleLight/svg/channel_linked.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/comment.svg b/themes/MetroMumbleLight/svg/comment.svg new file mode 100644 index 0000000..a6351af --- /dev/null +++ b/themes/MetroMumbleLight/svg/comment.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/comment_seen.svg b/themes/MetroMumbleLight/svg/comment_seen.svg new file mode 100644 index 0000000..6261099 --- /dev/null +++ b/themes/MetroMumbleLight/svg/comment_seen.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/themes/MetroMumbleLight/svg/config_basic.svg b/themes/MetroMumbleLight/svg/config_basic.svg new file mode 100644 index 0000000..f450b30 --- /dev/null +++ b/themes/MetroMumbleLight/svg/config_basic.svg @@ -0,0 +1,24 @@ + + + + + + diff --git a/themes/MetroMumbleLight/svg/deafened_self.svg b/themes/MetroMumbleLight/svg/deafened_self.svg new file mode 100644 index 0000000..8e22f09 --- /dev/null +++ b/themes/MetroMumbleLight/svg/deafened_self.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/deafened_server.svg b/themes/MetroMumbleLight/svg/deafened_server.svg new file mode 100644 index 0000000..e0c7ccd --- /dev/null +++ b/themes/MetroMumbleLight/svg/deafened_server.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/default_avatar.svg b/themes/MetroMumbleLight/svg/default_avatar.svg new file mode 100644 index 0000000..11d787f --- /dev/null +++ b/themes/MetroMumbleLight/svg/default_avatar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/filter.svg b/themes/MetroMumbleLight/svg/filter.svg new file mode 100644 index 0000000..1af56f1 --- /dev/null +++ b/themes/MetroMumbleLight/svg/filter.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/filter_off.svg b/themes/MetroMumbleLight/svg/filter_off.svg new file mode 100644 index 0000000..919fafa --- /dev/null +++ b/themes/MetroMumbleLight/svg/filter_off.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/filter_on.svg b/themes/MetroMumbleLight/svg/filter_on.svg new file mode 100644 index 0000000..53ed7ad --- /dev/null +++ b/themes/MetroMumbleLight/svg/filter_on.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/handle_horizontal.svg b/themes/MetroMumbleLight/svg/handle_horizontal.svg new file mode 100644 index 0000000..704a71b --- /dev/null +++ b/themes/MetroMumbleLight/svg/handle_horizontal.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/handle_vertical.svg b/themes/MetroMumbleLight/svg/handle_vertical.svg new file mode 100644 index 0000000..8e0a10f --- /dev/null +++ b/themes/MetroMumbleLight/svg/handle_vertical.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/information_icon.svg b/themes/MetroMumbleLight/svg/information_icon.svg new file mode 100644 index 0000000..8c9b3c7 --- /dev/null +++ b/themes/MetroMumbleLight/svg/information_icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/layout_classic.svg b/themes/MetroMumbleLight/svg/layout_classic.svg new file mode 100644 index 0000000..27d00af --- /dev/null +++ b/themes/MetroMumbleLight/svg/layout_classic.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + Chatbar + Tree + + + + + Log + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/layout_custom.svg b/themes/MetroMumbleLight/svg/layout_custom.svg new file mode 100644 index 0000000..aeb7323 --- /dev/null +++ b/themes/MetroMumbleLight/svg/layout_custom.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + Chatbar + Tree + + + + + Log + + + + + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/layout_hybrid.svg b/themes/MetroMumbleLight/svg/layout_hybrid.svg new file mode 100644 index 0000000..ac93498 --- /dev/null +++ b/themes/MetroMumbleLight/svg/layout_hybrid.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + Chatbar + Tree + + + + + Log + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/layout_stacked.svg b/themes/MetroMumbleLight/svg/layout_stacked.svg new file mode 100644 index 0000000..d8a7c36 --- /dev/null +++ b/themes/MetroMumbleLight/svg/layout_stacked.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + Chatbar + Tree + + + + + Log + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/media-record.svg b/themes/MetroMumbleLight/svg/media-record.svg new file mode 100644 index 0000000..fee6d6d --- /dev/null +++ b/themes/MetroMumbleLight/svg/media-record.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/themes/MetroMumbleLight/svg/mumble.svg b/themes/MetroMumbleLight/svg/mumble.svg new file mode 100644 index 0000000..9cb60a6 --- /dev/null +++ b/themes/MetroMumbleLight/svg/mumble.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/muted_local.svg b/themes/MetroMumbleLight/svg/muted_local.svg new file mode 100644 index 0000000..30a5712 --- /dev/null +++ b/themes/MetroMumbleLight/svg/muted_local.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/themes/MetroMumbleLight/svg/muted_self.svg b/themes/MetroMumbleLight/svg/muted_self.svg new file mode 100644 index 0000000..8cf934f --- /dev/null +++ b/themes/MetroMumbleLight/svg/muted_self.svg @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/muted_server.svg b/themes/MetroMumbleLight/svg/muted_server.svg new file mode 100644 index 0000000..d619427 --- /dev/null +++ b/themes/MetroMumbleLight/svg/muted_server.svg @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/muted_suppressed.svg b/themes/MetroMumbleLight/svg/muted_suppressed.svg new file mode 100644 index 0000000..e9702ea --- /dev/null +++ b/themes/MetroMumbleLight/svg/muted_suppressed.svg @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/priority_speaker.svg b/themes/MetroMumbleLight/svg/priority_speaker.svg new file mode 100644 index 0000000..793f9df --- /dev/null +++ b/themes/MetroMumbleLight/svg/priority_speaker.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/self_comment.svg b/themes/MetroMumbleLight/svg/self_comment.svg new file mode 100644 index 0000000..25c99ff --- /dev/null +++ b/themes/MetroMumbleLight/svg/self_comment.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/source-code.svg b/themes/MetroMumbleLight/svg/source-code.svg new file mode 100644 index 0000000..146f66f --- /dev/null +++ b/themes/MetroMumbleLight/svg/source-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/themes/MetroMumbleLight/svg/talking_alt.svg b/themes/MetroMumbleLight/svg/talking_alt.svg new file mode 100644 index 0000000..293f890 --- /dev/null +++ b/themes/MetroMumbleLight/svg/talking_alt.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/talking_off.svg b/themes/MetroMumbleLight/svg/talking_off.svg new file mode 100644 index 0000000..ed2fce5 --- /dev/null +++ b/themes/MetroMumbleLight/svg/talking_off.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/talking_on.svg b/themes/MetroMumbleLight/svg/talking_on.svg new file mode 100644 index 0000000..a9305e2 --- /dev/null +++ b/themes/MetroMumbleLight/svg/talking_on.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/talking_whisper.svg b/themes/MetroMumbleLight/svg/talking_whisper.svg new file mode 100644 index 0000000..53eff05 --- /dev/null +++ b/themes/MetroMumbleLight/svg/talking_whisper.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/themes/MetroMumbleLight/svg/toolbar-comment.svg b/themes/MetroMumbleLight/svg/toolbar-comment.svg new file mode 100644 index 0000000..a6351af --- /dev/null +++ b/themes/MetroMumbleLight/svg/toolbar-comment.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..2f3c905 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,93 @@ +var theme = 'MetroMumbleLight' + +var path = require('path') + +module.exports = { + entry: [ + './app/index.js', + './app/index.html' + ], + output: { + filename: 'index.js', + path: './dist' + }, + module: { + postLoaders: [ + { + include: /mumble-streams\/lib\/data.js/, + loader: 'transform-loader?brfs' + } + ], + loaders: [ + { + test: /\.js$/, + exclude: /node_modules/, + loader: 'babel-loader', + query: { + presets: ['es2015'], + plugins: ['transform-runtime'] + } + }, + { + test: /\.html$/, + loaders: [ + 'file-loader?name=[name].[ext]', + 'extract-loader', + 'html-loader?' + JSON.stringify({ + attrs: ['img:src', 'link:href'], + interpolate: 'require', + root: theme + }) + ] + }, + { + test: /\.css$/, + loaders: [ + 'file-loader', + 'extract-loader', + 'css-loader' + ] + }, + { + test: /manifest\.json$|\.xml$/, + loaders: [ + 'file-loader', + 'extract-loader', + 'regexp-replace-loader?' + JSON.stringify({ + match: { + pattern: "#require\\('([^']*)'\\)", + flags: 'g' + }, + replaceWith: '"+require("$1")+"' + }), + 'raw-loader' + ] + }, + { + test: /\.json$/, + exclude: /manifest\.json$/, + loader: 'json-loader' + }, + { + test: /\.(svg|png|ico)$/, + loader: 'file-loader' + } + ] + }, + resolve: { + alias: { + webworkify: 'webworkify-webpack-dropin' + }, + root: [ + path.resolve('./themes/') + ] + }, + includes: { + pattern: function (filepath) { + return { + re: /#require\((.+)\)/, + index: 1 + } + } + } +}