index.js 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325
  1. import 'stream-browserify' // see https://github.com/ericgundrum/pouch-websocket-sync-example/commit/2a4437b013092cc7b2cd84cf1499172c84a963a3
  2. import 'subworkers' // polyfill for https://bugs.chromium.org/p/chromium/issues/detail?id=31666
  3. import url from 'url'
  4. import ByteBuffer from 'bytebuffer'
  5. import MumbleClient from 'mumble-client'
  6. import WorkerBasedMumbleConnector from './worker-client'
  7. import BufferQueueNode from 'web-audio-buffer-queue'
  8. import mumbleConnect from 'mumble-client-websocket'
  9. import audioContext from 'audio-context'
  10. import ko from 'knockout'
  11. import _dompurify from 'dompurify'
  12. import keyboardjs from 'keyboardjs'
  13. import anchorme from 'anchorme'
  14. import { ContinuousVoiceHandler, PushToTalkVoiceHandler, VADVoiceHandler, initVoice } from './voice'
  15. import {initialize as localizationInitialize, translate} from './loc';
  16. const dompurify = _dompurify(window)
  17. // from: https://gist.github.com/haliphax/5379454
  18. ko.extenders.scrollFollow = function (target, selector) {
  19. target.subscribe(function (chat) {
  20. const el = document.querySelector(selector);
  21. // the scroll bar is all the way down, so we know they want to follow the text
  22. if (el.scrollTop == el.scrollHeight - el.clientHeight) {
  23. // have to push our code outside of this thread since the text hasn't updated yet
  24. setTimeout(function () { el.scrollTop = el.scrollHeight - el.clientHeight; }, 0);
  25. } else {
  26. // send notification
  27. const last = chat[chat.length - 1]
  28. if (Notification.permission == 'granted' && last.type != 'chat-message-self') {
  29. let sender = 'Mumble Server'
  30. if (last.user && last.user.name) sender=last.user.name()
  31. new Notification(sender, {body: dompurify.sanitize(last.message, {ALLOWED_TAGS:[]})})
  32. }
  33. }
  34. });
  35. return target;
  36. };
  37. function sanitize (html) {
  38. return dompurify.sanitize(html, {
  39. ALLOWED_TAGS: ['br', 'b', 'i', 'u', 'a', 'span', 'p', 'img', 'center']
  40. })
  41. }
  42. const anchormeOptions = {
  43. // force target _blank attribute
  44. attributes: {
  45. target: "_blank"
  46. },
  47. // force https protocol except email
  48. protocol: function(s) {
  49. if (anchorme.validate.email(s)) {
  50. return "mailto:";
  51. } else {
  52. return "https://";
  53. }
  54. }
  55. }
  56. function openContextMenu (event, contextMenu, target) {
  57. contextMenu.posX(event.clientX)
  58. contextMenu.posY(event.clientY)
  59. contextMenu.target(target)
  60. const closeListener = (event) => {
  61. // Always close, no matter where they clicked
  62. setTimeout(() => { // delay to allow click to be actually processed
  63. contextMenu.target(null)
  64. unregister()
  65. })
  66. }
  67. const unregister = () => document.removeEventListener('click', closeListener)
  68. document.addEventListener('click', closeListener)
  69. event.stopPropagation()
  70. event.preventDefault()
  71. }
  72. // GUI
  73. function ContextMenu () {
  74. var self = this
  75. self.posX = ko.observable()
  76. self.posY = ko.observable()
  77. self.target = ko.observable()
  78. }
  79. function AddChannelDialog () {
  80. var self = this;
  81. self.channelName = ko.observable('')
  82. self.parentID = 0;
  83. self.visible = ko.observable(false);
  84. self.show = self.visible.bind(self.visible, true)
  85. self.hide = self.visible.bind(self.visible, false)
  86. self.addchannel = function() {
  87. self.hide();
  88. ui.addchannel(self.channelName());
  89. }
  90. }
  91. function ConnectDialog () {
  92. var self = this
  93. self.address = ko.observable('')
  94. self.port = ko.observable('')
  95. self.tokenToAdd = ko.observable('')
  96. self.selectedTokens = ko.observableArray([])
  97. self.tokens = ko.observableArray([])
  98. self.username = ko.observable('')
  99. self.password = ko.observable('')
  100. self.channelName = ko.observable('')
  101. self.joinOnly = ko.observable(false)
  102. self.visible = ko.observable(true)
  103. self.show = self.visible.bind(self.visible, true)
  104. self.hide = self.visible.bind(self.visible, false)
  105. self.connect = function () {
  106. self.hide()
  107. if (ui.detectWebRTC) {
  108. ui.webrtc = true
  109. }
  110. ui.connect(self.username(), self.address(), self.port(), self.tokens(), self.password(), self.channelName())
  111. }
  112. self.addToken = function() {
  113. if ((self.tokenToAdd() != "") && (self.tokens.indexOf(self.tokenToAdd()) < 0)) {
  114. self.tokens.push(self.tokenToAdd())
  115. }
  116. self.tokenToAdd("")
  117. }
  118. self.removeSelectedTokens = function() {
  119. this.tokens.removeAll(this.selectedTokens())
  120. this.selectedTokens([])
  121. }
  122. }
  123. function ConnectErrorDialog (connectDialog) {
  124. var self = this
  125. self.type = ko.observable(0)
  126. self.reason = ko.observable('')
  127. self.username = connectDialog.username
  128. self.password = connectDialog.password
  129. self.joinOnly = connectDialog.joinOnly
  130. self.visible = ko.observable(false)
  131. self.show = self.visible.bind(self.visible, true)
  132. self.hide = self.visible.bind(self.visible, false)
  133. self.connect = () => {
  134. self.hide()
  135. connectDialog.connect()
  136. }
  137. }
  138. class ConnectionInfo {
  139. constructor (ui) {
  140. this._ui = ui
  141. this.visible = ko.observable(false)
  142. this.serverVersion = ko.observable()
  143. this.latencyMs = ko.observable(NaN)
  144. this.latencyDeviation = ko.observable(NaN)
  145. this.remoteHost = ko.observable()
  146. this.remotePort = ko.observable()
  147. this.maxBitrate = ko.observable(NaN)
  148. this.currentBitrate = ko.observable(NaN)
  149. this.maxBandwidth = ko.observable(NaN)
  150. this.currentBandwidth = ko.observable(NaN)
  151. this.codec = ko.observable()
  152. this.show = () => {
  153. if (!ui.thisUser()) return
  154. this.update()
  155. this.visible(true)
  156. }
  157. this.hide = () => this.visible(false)
  158. }
  159. update () {
  160. let client = this._ui.client
  161. this.serverVersion(client.serverVersion)
  162. let dataStats = client.dataStats
  163. if (dataStats) {
  164. this.latencyMs(dataStats.mean)
  165. this.latencyDeviation(Math.sqrt(dataStats.variance))
  166. }
  167. this.remoteHost(this._ui.remoteHost())
  168. this.remotePort(this._ui.remotePort())
  169. let spp = this._ui.settings.samplesPerPacket
  170. let maxBitrate = client.getMaxBitrate(spp, false)
  171. let maxBandwidth = client.maxBandwidth
  172. let actualBitrate = client.getActualBitrate(spp, false)
  173. let actualBandwidth = MumbleClient.calcEnforcableBandwidth(actualBitrate, spp, false)
  174. this.maxBitrate(maxBitrate)
  175. this.currentBitrate(actualBitrate)
  176. this.maxBandwidth(maxBandwidth)
  177. this.currentBandwidth(actualBandwidth)
  178. this.codec('Opus') // only one supported for sending
  179. }
  180. }
  181. function CommentDialog () {
  182. var self = this
  183. self.visible = ko.observable(false)
  184. self.show = function () {
  185. self.visible(true)
  186. }
  187. }
  188. class SettingsDialog {
  189. constructor (settings) {
  190. this.voiceMode = ko.observable(settings.voiceMode)
  191. this.pttKey = ko.observable(settings.pttKey)
  192. this.pttKeyDisplay = ko.observable(settings.pttKey)
  193. this.vadLevel = ko.observable(settings.vadLevel)
  194. this.testVadLevel = ko.observable(0)
  195. this.testVadActive = ko.observable(false)
  196. this.showAvatars = ko.observable(settings.showAvatars())
  197. this.userCountInChannelName = ko.observable(settings.userCountInChannelName())
  198. // Need to wrap this in a pureComputed to make sure it's always numeric
  199. let audioBitrate = ko.observable(settings.audioBitrate)
  200. this.audioBitrate = ko.pureComputed({
  201. read: audioBitrate,
  202. write: (value) => audioBitrate(Number(value))
  203. })
  204. this.samplesPerPacket = ko.observable(settings.samplesPerPacket)
  205. this.msPerPacket = ko.pureComputed({
  206. read: () => this.samplesPerPacket() / 48,
  207. write: (value) => this.samplesPerPacket(value * 48)
  208. })
  209. this._setupTestVad()
  210. this.vadLevel.subscribe(() => this._setupTestVad())
  211. }
  212. _setupTestVad () {
  213. if (this._testVad) {
  214. this._testVad.end()
  215. }
  216. let dummySettings = new Settings({})
  217. this.applyTo(dummySettings)
  218. this._testVad = new VADVoiceHandler(null, dummySettings)
  219. this._testVad.on('started_talking', () => this.testVadActive(true))
  220. .on('stopped_talking', () => this.testVadActive(false))
  221. .on('level', level => this.testVadLevel(level))
  222. testVoiceHandler = this._testVad
  223. }
  224. applyTo (settings) {
  225. settings.voiceMode = this.voiceMode()
  226. settings.pttKey = this.pttKey()
  227. settings.vadLevel = this.vadLevel()
  228. settings.showAvatars(this.showAvatars())
  229. settings.userCountInChannelName(this.userCountInChannelName())
  230. settings.audioBitrate = this.audioBitrate()
  231. settings.samplesPerPacket = this.samplesPerPacket()
  232. }
  233. end () {
  234. this._testVad.end()
  235. testVoiceHandler = null
  236. }
  237. recordPttKey () {
  238. var combo = []
  239. const keydown = e => {
  240. combo = e.pressedKeys
  241. let comboStr = combo.join(' + ')
  242. this.pttKeyDisplay('> ' + comboStr + ' <')
  243. }
  244. const keyup = () => {
  245. keyboardjs.unbind('', keydown, keyup)
  246. let comboStr = combo.join(' + ')
  247. if (comboStr) {
  248. this.pttKey(comboStr).pttKeyDisplay(comboStr)
  249. } else {
  250. this.pttKeyDisplay(this.pttKey())
  251. }
  252. }
  253. keyboardjs.bind('', keydown, keyup)
  254. this.pttKeyDisplay('> ? <')
  255. }
  256. totalBandwidth () {
  257. return MumbleClient.calcEnforcableBandwidth(
  258. this.audioBitrate(),
  259. this.samplesPerPacket(),
  260. true
  261. )
  262. }
  263. positionBandwidth () {
  264. return this.totalBandwidth() - MumbleClient.calcEnforcableBandwidth(
  265. this.audioBitrate(),
  266. this.samplesPerPacket(),
  267. false
  268. )
  269. }
  270. overheadBandwidth () {
  271. return MumbleClient.calcEnforcableBandwidth(
  272. 0,
  273. this.samplesPerPacket(),
  274. false
  275. )
  276. }
  277. }
  278. class Settings {
  279. constructor (defaults) {
  280. const load = key => window.localStorage.getItem('mumble.' + key)
  281. this.voiceMode = load('voiceMode') || defaults.voiceMode
  282. this.pttKey = load('pttKey') || defaults.pttKey
  283. this.vadLevel = load('vadLevel') || defaults.vadLevel
  284. this.toolbarVertical = load('toolbarVertical') || defaults.toolbarVertical
  285. this.showAvatars = ko.observable(load('showAvatars') || defaults.showAvatars)
  286. this.userCountInChannelName = ko.observable(load('userCountInChannelName') || defaults.userCountInChannelName)
  287. this.audioBitrate = Number(load('audioBitrate')) || defaults.audioBitrate
  288. this.samplesPerPacket = Number(load('samplesPerPacket')) || defaults.samplesPerPacket
  289. }
  290. save () {
  291. const save = (key, val) => window.localStorage.setItem('mumble.' + key, val)
  292. save('voiceMode', this.voiceMode)
  293. save('pttKey', this.pttKey)
  294. save('vadLevel', this.vadLevel)
  295. save('toolbarVertical', this.toolbarVertical)
  296. save('showAvatars', this.showAvatars())
  297. save('userCountInChannelName', this.userCountInChannelName())
  298. save('audioBitrate', this.audioBitrate)
  299. save('samplesPerPacket', this.samplesPerPacket)
  300. }
  301. }
  302. class GlobalBindings {
  303. constructor (config) {
  304. this.config = config
  305. this.settings = new Settings(config.settings)
  306. this.detectWebRTC = true
  307. this.webrtc = true
  308. this.fallbackConnector = new WorkerBasedMumbleConnector()
  309. this.webrtcConnector = { connect: mumbleConnect }
  310. this.client = null
  311. this.userContextMenu = new ContextMenu()
  312. this.channelContextMenu = new ContextMenu()
  313. this.connectDialog = new ConnectDialog()
  314. this.addChannelDialog = new AddChannelDialog()
  315. this.connectErrorDialog = new ConnectErrorDialog(this.connectDialog)
  316. this.connectionInfo = new ConnectionInfo(this)
  317. this.commentDialog = new CommentDialog()
  318. this.settingsDialog = ko.observable()
  319. this.minimalView = ko.observable(false)
  320. this.log = ko.observableArray()
  321. this.remoteHost = ko.observable()
  322. this.remotePort = ko.observable()
  323. this.thisUser = ko.observable()
  324. this.root = ko.observable()
  325. this.avatarView = ko.observable()
  326. this.messageBox = ko.observable('')
  327. this.toolbarHorizontal = ko.observable(!this.settings.toolbarVertical)
  328. this.selected = ko.observable()
  329. this.selfMute = ko.observable(this.config.defaults.startMute)
  330. this.selfDeaf = ko.observable(this.config.defaults.startDeaf)
  331. this.selfMute.subscribe(mute => {
  332. if (voiceHandler) {
  333. voiceHandler.setMute(mute)
  334. }
  335. })
  336. this.submitOnEnter = function(data, e) {
  337. if (e.which == 13 && !e.shiftKey) {
  338. this.submitMessageBox();
  339. return false;
  340. }
  341. return true;
  342. }
  343. this.toggleToolbarOrientation = () => {
  344. this.toolbarHorizontal(!this.toolbarHorizontal())
  345. this.settings.toolbarVertical = !this.toolbarHorizontal()
  346. this.settings.save()
  347. }
  348. this.select = element => {
  349. this.selected(element)
  350. }
  351. this.openSettings = () => {
  352. this.settingsDialog(new SettingsDialog(this.settings))
  353. }
  354. this.openAddChannel = (user, channel) => {
  355. this.addChannelDialog.parentID = channel.model._id;
  356. this.addChannelDialog.show()
  357. }
  358. this.addchannel = (channelName) => {
  359. var msg = {
  360. name: 'ChannelState',
  361. payload: {
  362. parent: this.addChannelDialog.parentID || 0,
  363. name: channelName
  364. }
  365. }
  366. this.client._send(msg);
  367. }
  368. this.ChannelRemove = (user, channel) => {
  369. var msg = {
  370. name: 'ChannelRemove',
  371. payload: {
  372. channel_id: channel.model._id
  373. }
  374. }
  375. this.client._send(msg);
  376. }
  377. this.applySettings = () => {
  378. const settingsDialog = this.settingsDialog()
  379. settingsDialog.applyTo(this.settings)
  380. this._updateVoiceHandler()
  381. this.settings.save()
  382. this.closeSettings()
  383. }
  384. this.closeSettings = () => {
  385. if (this.settingsDialog()) {
  386. this.settingsDialog().end()
  387. }
  388. this.settingsDialog(null)
  389. }
  390. this.getTimeString = () => {
  391. return '[' + new Date().toLocaleTimeString(navigator.language) + ']'
  392. }
  393. this.connect = (username, host, port, tokens = [], password, channelName = "") => {
  394. // if browser support Notification request permission
  395. if ('Notification' in window) Notification.requestPermission()
  396. this.resetClient()
  397. this.remoteHost(host)
  398. this.remotePort(port)
  399. log(translate('logentry.connecting'), host)
  400. // Note: This call needs to be delayed until the user has interacted with
  401. // the page in some way (which at this point they have), see: https://goo.gl/7K7WLu
  402. let ctx = audioContext()
  403. this.fallbackConnector.setSampleRate(ctx.sampleRate)
  404. if (!this._delayedMicNode) {
  405. this._micNode = ctx.createMediaStreamSource(this._micStream)
  406. this._delayNode = ctx.createDelay()
  407. this._delayNode.delayTime.value = 0.15
  408. this._delayedMicNode = ctx.createMediaStreamDestination()
  409. }
  410. // TODO: token
  411. (this.webrtc ? this.webrtcConnector : this.fallbackConnector).connect(`wss://${host}:${port}`, {
  412. username: username,
  413. password: password,
  414. webrtc: this.webrtc ? {
  415. enabled: true,
  416. required: true,
  417. mic: this._delayedMicNode.stream,
  418. audioContext: ctx
  419. } : {
  420. enabled: false,
  421. },
  422. tokens: tokens
  423. }).done(client => {
  424. log(translate('logentry.connected'))
  425. this.client = client
  426. // Prepare for connection errors
  427. client.on('error', (err) => {
  428. log(translate('logentry.connection_error'), err)
  429. this.resetClient()
  430. })
  431. // Make sure we stay open if we're running as Matrix widget
  432. window.matrixWidget.setAlwaysOnScreen(true)
  433. // Register all channels, recursively
  434. if(channelName.indexOf("/") != 0) {
  435. channelName = "/"+channelName;
  436. }
  437. const registerChannel = (channel, channelPath) => {
  438. this._newChannel(channel)
  439. if(channelPath === channelName) {
  440. client.self.setChannel(channel)
  441. }
  442. channel.children.forEach(ch => registerChannel(ch, channelPath+"/"+ch.name))
  443. }
  444. registerChannel(client.root, "")
  445. // Register all users
  446. client.users.forEach(user => this._newUser(user))
  447. // Register future channels
  448. client.on('newChannel', channel => this._newChannel(channel))
  449. // Register future users
  450. client.on('newUser', user => this._newUser(user))
  451. // Handle messages
  452. client.on('message', (sender, message, users, channels, trees) => {
  453. sender = sender || { __ui: 'Server' }
  454. ui.log.push({
  455. type: 'chat-message',
  456. user: sender.__ui,
  457. channel: channels.length > 0,
  458. message: anchorme({input: sanitize(message), options: anchormeOptions})
  459. })
  460. })
  461. // Log permission denied error messages
  462. client.on('denied', (type) => {
  463. ui.log.push({
  464. type: 'generic',
  465. value: 'Permission denied : '+ type
  466. })
  467. })
  468. // Set own user and root channel
  469. this.thisUser(client.self.__ui)
  470. this.root(client.root.__ui)
  471. // Upate linked channels
  472. this._updateLinks()
  473. // Log welcome message
  474. if (client.welcomeMessage) {
  475. this.log.push({
  476. type: 'welcome-message',
  477. message: sanitize(client.welcomeMessage)
  478. })
  479. }
  480. // Startup audio input processing
  481. this._updateVoiceHandler()
  482. // Tell server our mute/deaf state (if necessary)
  483. if (this.selfDeaf()) {
  484. this.client.setSelfDeaf(true)
  485. } else if (this.selfMute()) {
  486. this.client.setSelfMute(true)
  487. }
  488. }, err => {
  489. if (err.$type && err.$type.name === 'Reject') {
  490. this.connectErrorDialog.type(err.type)
  491. this.connectErrorDialog.reason(err.reason)
  492. this.connectErrorDialog.show()
  493. } else if (err === 'server_does_not_support_webrtc' && this.detectWebRTC && this.webrtc) {
  494. log(translate('logentry.connection_fallback_mode'))
  495. this.webrtc = false
  496. this.connect(username, host, port, tokens, password, channelName)
  497. } else {
  498. log(translate('logentry.connection_error'), err)
  499. }
  500. })
  501. }
  502. this._newUser = user => {
  503. const simpleProperties = {
  504. uniqueId: 'uid',
  505. username: 'name',
  506. mute: 'mute',
  507. deaf: 'deaf',
  508. suppress: 'suppress',
  509. selfMute: 'selfMute',
  510. selfDeaf: 'selfDeaf',
  511. texture: 'rawTexture',
  512. textureHash: 'textureHash',
  513. comment: 'comment'
  514. }
  515. var ui = user.__ui = {
  516. model: user,
  517. talking: ko.observable('off'),
  518. channel: ko.observable()
  519. }
  520. ui.texture = ko.pureComputed(() => {
  521. let raw = ui.rawTexture()
  522. if (!raw || raw.offset >= raw.limit) return null
  523. return 'data:image/*;base64,' + ByteBuffer.wrap(raw).toBase64()
  524. })
  525. ui.show_avatar = () => {
  526. let setting = this.settings.showAvatars()
  527. switch (setting) {
  528. case 'always':
  529. break
  530. case 'own_channel':
  531. if (this.thisUser().channel() !== ui.channel()) return false
  532. break
  533. case 'linked_channel':
  534. if (!ui.channel().linked()) return false
  535. break
  536. case 'minimal_only':
  537. if (!this.minimalView()) return false
  538. if (this.thisUser().channel() !== ui.channel()) return false
  539. break
  540. case 'never':
  541. default: return false
  542. }
  543. if (!ui.texture()) {
  544. if (ui.textureHash()) {
  545. // The user has an avatar set but it's of sufficient size to not be
  546. // included by default, so we need to fetch it explicitly now.
  547. // mumble-client should make sure we only send one request per hash
  548. user.requestTexture()
  549. }
  550. return false
  551. }
  552. return true
  553. }
  554. ui.openContextMenu = (_, event) => openContextMenu(event, this.userContextMenu, ui)
  555. ui.canChangeMute = () => {
  556. return false // TODO check for perms and implement
  557. }
  558. ui.canChangeDeafen = () => {
  559. return false // TODO check for perms and implement
  560. }
  561. ui.canChangePrioritySpeaker = () => {
  562. return false // TODO check for perms and implement
  563. }
  564. ui.canLocalMute = () => {
  565. return false // TODO implement local mute
  566. // return this.thisUser() !== ui
  567. }
  568. ui.canIgnoreMessages = () => {
  569. return false // TODO implement ignore messages
  570. // return this.thisUser() !== ui
  571. }
  572. ui.canChangeComment = () => {
  573. return false // TODO implement changing of comments
  574. // return this.thisUser() === ui // TODO check for perms
  575. }
  576. ui.canChangeAvatar = () => {
  577. return this.thisUser() === ui // TODO check for perms
  578. }
  579. ui.toggleMute = () => {
  580. if (ui.selfMute()) {
  581. this.requestUnmute(ui)
  582. } else {
  583. this.requestMute(ui)
  584. }
  585. }
  586. ui.toggleDeaf = () => {
  587. if (ui.selfDeaf()) {
  588. this.requestUndeaf(ui)
  589. } else {
  590. this.requestDeaf(ui)
  591. }
  592. }
  593. ui.viewAvatar = () => {
  594. this.avatarView(ui.texture())
  595. }
  596. ui.changeAvatar = () => {
  597. let input = document.createElement('input')
  598. input.type = 'file'
  599. input.addEventListener('change', () => {
  600. let reader = new window.FileReader()
  601. reader.onload = () => {
  602. this.client.setSelfTexture(reader.result)
  603. }
  604. reader.readAsArrayBuffer(input.files[0])
  605. })
  606. input.click()
  607. }
  608. ui.removeAvatar = () => {
  609. user.clearTexture()
  610. }
  611. Object.entries(simpleProperties).forEach(key => {
  612. ui[key[1]] = ko.observable(user[key[0]])
  613. })
  614. ui.state = ko.pureComputed(userToState, ui)
  615. if (user.channel) {
  616. ui.channel(user.channel.__ui)
  617. ui.channel().users.push(ui)
  618. ui.channel().users.sort(compareUsers)
  619. }
  620. user.on('update', (actor, properties) => {
  621. Object.entries(simpleProperties).forEach(key => {
  622. if (properties[key[0]] !== undefined) {
  623. ui[key[1]](properties[key[0]])
  624. }
  625. })
  626. if (properties.channel !== undefined) {
  627. if (ui.channel()) {
  628. ui.channel().users.remove(ui)
  629. }
  630. ui.channel(properties.channel.__ui)
  631. ui.channel().users.push(ui)
  632. ui.channel().users.sort(compareUsers)
  633. this._updateLinks()
  634. }
  635. if (properties.textureHash !== undefined) {
  636. // Invalidate avatar texture when its hash has changed
  637. // If the avatar is still visible, this will trigger a fetch of the new one.
  638. ui.rawTexture(null)
  639. }
  640. }).on('remove', () => {
  641. if (ui.channel()) {
  642. ui.channel().users.remove(ui)
  643. }
  644. }).on('voice', stream => {
  645. console.log(`User ${user.username} started takling`)
  646. let userNode
  647. if (!this.webrtc) {
  648. userNode = new BufferQueueNode({
  649. audioContext: audioContext()
  650. })
  651. userNode.connect(audioContext().destination)
  652. }
  653. if (stream.target === 'normal') {
  654. ui.talking('on')
  655. } else if (stream.target === 'shout') {
  656. ui.talking('shout')
  657. } else if (stream.target === 'whisper') {
  658. ui.talking('whisper')
  659. }
  660. stream.on('data', data => {
  661. if (this.webrtc) {
  662. // mumble-client is in WebRTC mode, no pcm data should arrive this way
  663. } else {
  664. userNode.write(data.buffer)
  665. }
  666. }).on('end', () => {
  667. console.log(`User ${user.username} stopped takling`)
  668. ui.talking('off')
  669. if (!this.webrtc) {
  670. userNode.end()
  671. }
  672. })
  673. })
  674. }
  675. this._newChannel = channel => {
  676. const simpleProperties = {
  677. position: 'position',
  678. name: 'name',
  679. description: 'description'
  680. }
  681. var ui = channel.__ui = {
  682. model: channel,
  683. expanded: ko.observable(true),
  684. parent: ko.observable(),
  685. channels: ko.observableArray(),
  686. users: ko.observableArray(),
  687. linked: ko.observable(false)
  688. }
  689. ui.userCount = () => {
  690. return ui.channels().reduce((acc, c) => acc + c.userCount(), ui.users().length)
  691. }
  692. ui.openContextMenu = (_, event) => openContextMenu(event, this.channelContextMenu, ui)
  693. ui.canJoin = () => {
  694. return true // TODO check for perms
  695. }
  696. ui.canAdd = () => {
  697. return true // TODO check for perms
  698. }
  699. ui.canEdit = () => {
  700. return false // TODO check for perms and implement
  701. }
  702. ui.canRemove = () => {
  703. return true // TODO check for perms
  704. }
  705. ui.canLink = () => {
  706. return false // TODO check for perms and implement
  707. }
  708. ui.canUnlink = () => {
  709. return false // TODO check for perms and implement
  710. }
  711. ui.canSendMessage = () => {
  712. return false // TODO check for perms and implement
  713. }
  714. Object.entries(simpleProperties).forEach(key => {
  715. ui[key[1]] = ko.observable(channel[key[0]])
  716. })
  717. if (channel.parent) {
  718. ui.parent(channel.parent.__ui)
  719. ui.parent().channels.push(ui)
  720. ui.parent().channels.sort(compareChannels)
  721. }
  722. this._updateLinks()
  723. channel.on('update', properties => {
  724. Object.entries(simpleProperties).forEach(key => {
  725. if (properties[key[0]] !== undefined) {
  726. ui[key[1]](properties[key[0]])
  727. }
  728. })
  729. if (properties.parent !== undefined) {
  730. if (ui.parent()) {
  731. ui.parent().channel.remove(ui)
  732. }
  733. ui.parent(properties.parent.__ui)
  734. ui.parent().channels.push(ui)
  735. ui.parent().channels.sort(compareChannels)
  736. }
  737. if (properties.links !== undefined) {
  738. this._updateLinks()
  739. }
  740. }).on('remove', () => {
  741. if (ui.parent()) {
  742. ui.parent().channels.remove(ui)
  743. }
  744. this._updateLinks()
  745. })
  746. }
  747. this.resetClient = () => {
  748. if (this.client) {
  749. this.client.disconnect()
  750. }
  751. this.client = null
  752. this.selected(null).root(null).thisUser(null)
  753. }
  754. this.connected = () => this.thisUser() != null
  755. this._updateVoiceHandler = () => {
  756. if (!this.client) {
  757. return
  758. }
  759. if (voiceHandler) {
  760. voiceHandler.end()
  761. voiceHandler = null
  762. }
  763. let mode = this.settings.voiceMode
  764. if (mode === 'cont') {
  765. voiceHandler = new ContinuousVoiceHandler(this.client, this.settings)
  766. } else if (mode === 'ptt') {
  767. voiceHandler = new PushToTalkVoiceHandler(this.client, this.settings)
  768. } else if (mode === 'vad') {
  769. voiceHandler = new VADVoiceHandler(this.client, this.settings)
  770. } else {
  771. log(translate('logentry.unknown_voice_mode'), mode)
  772. return
  773. }
  774. voiceHandler.on('started_talking', () => {
  775. if (this.thisUser()) {
  776. this.thisUser().talking('on')
  777. }
  778. })
  779. voiceHandler.on('stopped_talking', () => {
  780. if (this.thisUser()) {
  781. this.thisUser().talking('off')
  782. }
  783. })
  784. if (this.selfMute()) {
  785. voiceHandler.setMute(true)
  786. }
  787. this._micNode.disconnect()
  788. this._delayNode.disconnect()
  789. if (mode === 'vad') {
  790. this._micNode.connect(this._delayNode)
  791. this._delayNode.connect(this._delayedMicNode)
  792. } else {
  793. this._micNode.connect(this._delayedMicNode)
  794. }
  795. this.client.setAudioQuality(
  796. this.settings.audioBitrate,
  797. this.settings.samplesPerPacket
  798. )
  799. }
  800. this.messageBoxHint = ko.pureComputed(() => {
  801. if (!this.thisUser()) {
  802. return '' // Not yet connected
  803. }
  804. var target = this.selected()
  805. if (!target) {
  806. target = this.thisUser()
  807. }
  808. if (target === this.thisUser()) {
  809. target = target.channel()
  810. }
  811. if (target.users) { // Channel
  812. return translate('chat.channel_message_placeholder')
  813. .replace('%1', target.name())
  814. } else { // User
  815. return translate('chat.user_message_placeholder')
  816. .replace('%1', target.name())
  817. }
  818. })
  819. this.submitMessageBox = () => {
  820. this.sendMessage(this.selected(), this.messageBox())
  821. this.messageBox('')
  822. }
  823. this.sendMessage = (target, message) => {
  824. if (this.connected()) {
  825. // If no target is selected, choose our own user
  826. if (!target) {
  827. target = this.thisUser()
  828. }
  829. // If target is our own user, send to our channel
  830. if (target === this.thisUser()) {
  831. target = target.channel()
  832. }
  833. // Avoid blank message
  834. if (sanitize(message).trim().length == 0) return;
  835. // Support multiline
  836. message = message.replace(/\n\n+/g,"\n\n");
  837. message = message.replace(/\n/g,"<br>");
  838. // Send message
  839. target.model.sendMessage(anchorme(message))
  840. if (target.users) { // Channel
  841. this.log.push({
  842. type: 'chat-message-self',
  843. message: anchorme({input: sanitize(message), options: anchormeOptions}),
  844. channel: target
  845. })
  846. } else { // User
  847. this.log.push({
  848. type: 'chat-message-self',
  849. message: anchorme({input: sanitize(message), options: anchormeOptions}),
  850. user: target
  851. })
  852. }
  853. }
  854. }
  855. this.requestMove = (user, channel) => {
  856. if (this.connected()) {
  857. user.model.setChannel(channel.model)
  858. let currentUrl = url.parse(document.location.href, true)
  859. // delete search param so that query one can be taken into account
  860. delete currentUrl.search
  861. // get full channel path
  862. if( channel.parent() ){ // in case this channel is not Root
  863. let parent = channel.parent()
  864. currentUrl.query.channelName = channel.name()
  865. while( parent.parent() ){
  866. currentUrl.query.channelName = parent.name() + '/' + currentUrl.query.channelName
  867. parent = parent.parent()
  868. }
  869. } else {
  870. // there is no channelName as we moved to Root
  871. delete currentUrl.query.channelName
  872. }
  873. // reflect this change in URL
  874. window.history.pushState(null, channel.name(), url.format(currentUrl))
  875. }
  876. }
  877. this.requestMute = user => {
  878. if (user === this.thisUser()) {
  879. this.selfMute(true)
  880. }
  881. if (this.connected()) {
  882. if (user === this.thisUser()) {
  883. this.client.setSelfMute(true)
  884. } else {
  885. user.model.setMute(true)
  886. }
  887. }
  888. }
  889. this.requestDeaf = user => {
  890. if (user === this.thisUser()) {
  891. this.selfMute(true)
  892. this.selfDeaf(true)
  893. }
  894. if (this.connected()) {
  895. if (user === this.thisUser()) {
  896. this.client.setSelfDeaf(true)
  897. } else {
  898. user.model.setDeaf(true)
  899. }
  900. }
  901. }
  902. this.requestUnmute = user => {
  903. if (user === this.thisUser()) {
  904. this.selfMute(false)
  905. this.selfDeaf(false)
  906. }
  907. if (this.connected()) {
  908. if (user === this.thisUser()) {
  909. this.client.setSelfMute(false)
  910. } else {
  911. user.model.setMute(false)
  912. }
  913. }
  914. }
  915. this.requestUndeaf = user => {
  916. if (user === this.thisUser()) {
  917. this.selfDeaf(false)
  918. }
  919. if (this.connected()) {
  920. if (user === this.thisUser()) {
  921. this.client.setSelfDeaf(false)
  922. } else {
  923. user.model.setDeaf(false)
  924. }
  925. }
  926. }
  927. this._updateLinks = () => {
  928. if (!this.thisUser()) {
  929. return
  930. }
  931. var allChannels = getAllChannels(this.root(), [])
  932. var ownChannel = this.thisUser().channel().model
  933. var allLinked = findLinks(ownChannel, [])
  934. allChannels.forEach(channel => {
  935. channel.linked(allLinked.indexOf(channel.model) !== -1)
  936. })
  937. function findLinks (channel, knownLinks) {
  938. knownLinks.push(channel)
  939. channel.links.forEach(next => {
  940. if (next && knownLinks.indexOf(next) === -1) {
  941. findLinks(next, knownLinks)
  942. }
  943. })
  944. allChannels.map(c => c.model).forEach(next => {
  945. if (next && knownLinks.indexOf(next) === -1 && next.links.indexOf(channel) !== -1) {
  946. findLinks(next, knownLinks)
  947. }
  948. })
  949. return knownLinks
  950. }
  951. function getAllChannels (channel, channels) {
  952. channels.push(channel)
  953. channel.channels().forEach(next => getAllChannels(next, channels))
  954. return channels
  955. }
  956. }
  957. this.openSourceCode = () => {
  958. var homepage = require('../package.json').homepage
  959. window.open(homepage, '_blank').focus()
  960. }
  961. this.updateSize = () => {
  962. this.minimalView(window.innerWidth < 320)
  963. if (this.minimalView()) {
  964. this.toolbarHorizontal(window.innerWidth < window.innerHeight)
  965. } else {
  966. this.toolbarHorizontal(!this.settings.toolbarVertical)
  967. }
  968. }
  969. }
  970. }
  971. var ui = new GlobalBindings(window.mumbleWebConfig)
  972. // Used only for debugging
  973. window.mumbleUi = ui
  974. function initializeUI () {
  975. var queryParams = url.parse(document.location.href, true).query
  976. queryParams = Object.assign({}, window.mumbleWebConfig.defaults, queryParams)
  977. var useJoinDialog = queryParams.joinDialog
  978. if (queryParams.matrix) {
  979. useJoinDialog = true
  980. }
  981. if (queryParams.address) {
  982. ui.connectDialog.address(queryParams.address)
  983. } else {
  984. useJoinDialog = false
  985. }
  986. if (queryParams.port) {
  987. ui.connectDialog.port(queryParams.port)
  988. } else {
  989. useJoinDialog = false
  990. }
  991. if (queryParams.token) {
  992. var tokens = queryParams.token
  993. if (!Array.isArray(tokens)) {
  994. tokens = [tokens]
  995. }
  996. ui.connectDialog.tokens(tokens)
  997. }
  998. if (queryParams.username) {
  999. ui.connectDialog.username(queryParams.username)
  1000. } else {
  1001. useJoinDialog = false
  1002. }
  1003. if (queryParams.password) {
  1004. ui.connectDialog.password(queryParams.password)
  1005. }
  1006. if (queryParams.webrtc !== 'auto') {
  1007. ui.detectWebRTC = false
  1008. if (queryParams.webrtc == 'false') {
  1009. ui.webrtc = false
  1010. }
  1011. }
  1012. if (queryParams.channelName) {
  1013. ui.connectDialog.channelName(queryParams.channelName)
  1014. }
  1015. if (queryParams.avatarurl) {
  1016. // Download the avatar and upload it to the mumble server when connected
  1017. let url = queryParams.avatarurl
  1018. console.log('Fetching avatar from', url)
  1019. let req = new window.XMLHttpRequest()
  1020. req.open('GET', url, true)
  1021. req.responseType = 'arraybuffer'
  1022. req.onload = () => {
  1023. let upload = (avatar) => {
  1024. if (req.response) {
  1025. console.log('Uploading user avatar to server')
  1026. ui.client.setSelfTexture(req.response)
  1027. }
  1028. }
  1029. // On any future connections
  1030. ui.thisUser.subscribe((thisUser) => {
  1031. if (thisUser) {
  1032. upload()
  1033. }
  1034. })
  1035. // And the current one (if already connected)
  1036. if (ui.thisUser()) {
  1037. upload()
  1038. }
  1039. }
  1040. req.send()
  1041. }
  1042. ui.connectDialog.joinOnly(useJoinDialog)
  1043. ko.applyBindings(ui)
  1044. window.onresize = () => ui.updateSize()
  1045. ui.updateSize()
  1046. }
  1047. function log () {
  1048. console.log.apply(console, arguments)
  1049. var args = []
  1050. for (var i = 0; i < arguments.length; i++) {
  1051. args.push(arguments[i])
  1052. }
  1053. ui.log.push({
  1054. type: 'generic',
  1055. value: args.join(' ')
  1056. })
  1057. }
  1058. function compareChannels (c1, c2) {
  1059. if (c1.position() === c2.position()) {
  1060. return c1.name() === c2.name() ? 0 : c1.name() < c2.name() ? -1 : 1
  1061. }
  1062. return c1.position() - c2.position()
  1063. }
  1064. function compareUsers (u1, u2) {
  1065. return u1.name() === u2.name() ? 0 : u1.name() < u2.name() ? -1 : 1
  1066. }
  1067. function userToState () {
  1068. var flags = []
  1069. // TODO: Friend
  1070. if (this.uid()) {
  1071. flags.push('Authenticated')
  1072. }
  1073. // TODO: Priority Speaker, Recording
  1074. if (this.mute()) {
  1075. flags.push('Muted (server)')
  1076. }
  1077. if (this.deaf()) {
  1078. flags.push('Deafened (server)')
  1079. }
  1080. // TODO: Local Ignore (Text messages), Local Mute
  1081. if (this.selfMute()) {
  1082. flags.push('Muted (self)')
  1083. }
  1084. if (this.selfDeaf()) {
  1085. flags.push('Deafened (self)')
  1086. }
  1087. return flags.join(', ')
  1088. }
  1089. var voiceHandler
  1090. var testVoiceHandler
  1091. /**
  1092. * @author svartoyg
  1093. */
  1094. function translatePiece(selector, kind, parameters, key) {
  1095. let element = document.querySelector(selector);
  1096. if (element !== null) {
  1097. const translation = translate(key);
  1098. switch (kind) {
  1099. default:
  1100. console.warn('unhandled dom translation kind "' + kind + '"');
  1101. break;
  1102. case 'textcontent':
  1103. element.textContent = translation;
  1104. break;
  1105. case 'attribute':
  1106. element.setAttribute(parameters.name || 'value', translation);
  1107. break;
  1108. }
  1109. } else {
  1110. console.warn(`translation selector "${selector}" for "${key}" did not match any element`)
  1111. }
  1112. }
  1113. /**
  1114. * @author svartoyg
  1115. */
  1116. function translateEverything() {
  1117. translatePiece('#connect-dialog_title', 'textcontent', {}, 'connectdialog.title');
  1118. translatePiece('#connect-dialog_input_address', 'textcontent', {}, 'connectdialog.address');
  1119. translatePiece('#connect-dialog_input_port', 'textcontent', {}, 'connectdialog.port');
  1120. translatePiece('#connect-dialog_input_username', 'textcontent', {}, 'connectdialog.username');
  1121. translatePiece('#connect-dialog_input_password', 'textcontent', {}, 'connectdialog.password');
  1122. translatePiece('#connect-dialog_input_tokens', 'textcontent', {}, 'connectdialog.tokens');
  1123. translatePiece('#connect-dialog_controls_remove', 'textcontent', {}, 'connectdialog.remove');
  1124. translatePiece('#connect-dialog_controls_add', 'textcontent', {}, 'connectdialog.add');
  1125. translatePiece('#connect-dialog_controls_cancel', 'attribute', {'name': 'value'}, 'connectdialog.cancel');
  1126. translatePiece('#connect-dialog_controls_connect', 'attribute', {'name': 'value'}, 'connectdialog.connect');
  1127. translatePiece('.connect-dialog.error-dialog .dialog-header', 'textcontent', {}, 'connectdialog.error.title');
  1128. translatePiece('.connect-dialog.error-dialog .reason .refused', 'textcontent', {}, 'connectdialog.error.reason.refused');
  1129. translatePiece('.connect-dialog.error-dialog .reason .version', 'textcontent', {}, 'connectdialog.error.reason.version');
  1130. translatePiece('.connect-dialog.error-dialog .reason .username', 'textcontent', {}, 'connectdialog.error.reason.username');
  1131. translatePiece('.connect-dialog.error-dialog .reason .userpassword', 'textcontent', {}, 'connectdialog.error.reason.userpassword');
  1132. translatePiece('.connect-dialog.error-dialog .reason .serverpassword', 'textcontent', {}, 'connectdialog.error.reason.serverpassword');
  1133. translatePiece('.connect-dialog.error-dialog .reason .username-in-use', 'textcontent', {}, 'connectdialog.error.reason.username_in_use');
  1134. translatePiece('.connect-dialog.error-dialog .reason .full', 'textcontent', {}, 'connectdialog.error.reason.full');
  1135. translatePiece('.connect-dialog.error-dialog .reason .clientcert', 'textcontent', {}, 'connectdialog.error.reason.clientcert');
  1136. translatePiece('.connect-dialog.error-dialog .reason .server', 'textcontent', {}, 'connectdialog.error.reason.server');
  1137. translatePiece('.connect-dialog.error-dialog .alternate-username', 'textcontent', {}, 'connectdialog.username');
  1138. translatePiece('.connect-dialog.error-dialog .alternate-password', 'textcontent', {}, 'connectdialog.password');
  1139. translatePiece('.connect-dialog.error-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'connectdialog.error.retry');
  1140. translatePiece('.connect-dialog.error-dialog .dialog-close', 'attribute', {'name': 'value'}, 'connectdialog.error.cancel');
  1141. translatePiece('.join-dialog .dialog-header', 'textcontent', {}, 'joindialog.title');
  1142. translatePiece('.join-dialog .dialog-submit', 'attribute', {'name': 'value'}, 'joindialog.connect');
  1143. translatePiece('.user-context-menu .mute', 'textcontent', {}, 'usercontextmenu.mute');
  1144. translatePiece('.user-context-menu .deafen', 'textcontent', {}, 'usercontextmenu.deafen');
  1145. translatePiece('.user-context-menu .priority-speaker', 'textcontent', {}, 'usercontextmenu.priority_speaker');
  1146. translatePiece('.user-context-menu .local-mute', 'textcontent', {}, 'usercontextmenu.local_mute');
  1147. translatePiece('.user-context-menu .ignore-messages', 'textcontent', {}, 'usercontextmenu.ignore_messages');
  1148. translatePiece('.user-context-menu .view-comment', 'textcontent', {}, 'usercontextmenu.view_comment');
  1149. translatePiece('.user-context-menu .change-comment', 'textcontent', {}, 'usercontextmenu.change_comment');
  1150. translatePiece('.user-context-menu .reset-comment', 'textcontent', {}, 'usercontextmenu.reset_comment');
  1151. translatePiece('.user-context-menu .view-avatar', 'textcontent', {}, 'usercontextmenu.view_avatar');
  1152. translatePiece('.user-context-menu .change-avatar', 'textcontent', {}, 'usercontextmenu.change_avatar');
  1153. translatePiece('.user-context-menu .reset-avatar', 'textcontent', {}, 'usercontextmenu.reset_avatar');
  1154. translatePiece('.user-context-menu .send-message', 'textcontent', {}, 'usercontextmenu.send_message');
  1155. translatePiece('.user-context-menu .information', 'textcontent', {}, 'usercontextmenu.information');
  1156. translatePiece('.user-context-menu .self-mute', 'textcontent', {}, 'usercontextmenu.self_mute');
  1157. translatePiece('.user-context-menu .self-deafen', 'textcontent', {}, 'usercontextmenu.self_deafen');
  1158. translatePiece('.user-context-menu .add-friend', 'textcontent', {}, 'usercontextmenu.add_friend');
  1159. translatePiece('.user-context-menu .remove-friend', 'textcontent', {}, 'usercontextmenu.remove_friend');
  1160. translatePiece('.channel-context-menu .join', 'textcontent', {}, 'channelcontextmenu.join');
  1161. translatePiece('.channel-context-menu .add', 'textcontent', {}, 'channelcontextmenu.add');
  1162. translatePiece('.channel-context-menu .edit', 'textcontent', {}, 'channelcontextmenu.edit');
  1163. translatePiece('.channel-context-menu .remove', 'textcontent', {}, 'channelcontextmenu.remove');
  1164. translatePiece('.channel-context-menu .link', 'textcontent', {}, 'channelcontextmenu.link');
  1165. translatePiece('.channel-context-menu .unlink', 'textcontent', {}, 'channelcontextmenu.unlink');
  1166. translatePiece('.channel-context-menu .unlink-all', 'textcontent', {}, 'channelcontextmenu.unlink_all');
  1167. translatePiece('.channel-context-menu .copy-mumble-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_url');
  1168. translatePiece('.channel-context-menu .copy-mumble-web-url', 'textcontent', {}, 'channelcontextmenu.copy_mumble_web_url');
  1169. translatePiece('.channel-context-menu .send-message', 'textcontent', {}, 'channelcontextmenu.send_message');
  1170. translatePiece('.toolbar .tb-horizontal', 'attribute', {'name': 'title'}, 'toolbar.orientation');
  1171. translatePiece('.toolbar .tb-horizontal', 'attribute', {'name': 'alt'}, 'toolbar.orientation');
  1172. translatePiece('.toolbar .tb-vertical', 'attribute', {'name': 'title'}, 'toolbar.orientation');
  1173. translatePiece('.toolbar .tb-vertical', 'attribute', {'name': 'alt'}, 'toolbar.orientation');
  1174. translatePiece('.toolbar .tb-connect', 'attribute', {'name': 'title'}, 'toolbar.connect');
  1175. translatePiece('.toolbar .tb-connect', 'attribute', {'name': 'alt'}, 'toolbar.connect');
  1176. translatePiece('.toolbar .tb-information', 'attribute', {'name': 'title'}, 'toolbar.information');
  1177. translatePiece('.toolbar .tb-information', 'attribute', {'name': 'alt'}, 'toolbar.information');
  1178. translatePiece('.toolbar .tb-mute', 'attribute', {'name': 'title'}, 'toolbar.mute');
  1179. translatePiece('.toolbar .tb-mute', 'attribute', {'name': 'alt'}, 'toolbar.mute');
  1180. translatePiece('.toolbar .tb-unmute', 'attribute', {'name': 'title'}, 'toolbar.unmute');
  1181. translatePiece('.toolbar .tb-unmute', 'attribute', {'name': 'alt'}, 'toolbar.unmute');
  1182. translatePiece('.toolbar .tb-deaf', 'attribute', {'name': 'title'}, 'toolbar.deaf');
  1183. translatePiece('.toolbar .tb-deaf', 'attribute', {'name': 'alt'}, 'toolbar.deaf');
  1184. translatePiece('.toolbar .tb-undeaf', 'attribute', {'name': 'title'}, 'toolbar.undeaf');
  1185. translatePiece('.toolbar .tb-undeaf', 'attribute', {'name': 'alt'}, 'toolbar.undeaf');
  1186. translatePiece('.toolbar .tb-record', 'attribute', {'name': 'title'}, 'toolbar.record');
  1187. translatePiece('.toolbar .tb-record', 'attribute', {'name': 'alt'}, 'toolbar.record');
  1188. translatePiece('.toolbar .tb-comment', 'attribute', {'name': 'title'}, 'toolbar.comment');
  1189. translatePiece('.toolbar .tb-comment', 'attribute', {'name': 'alt'}, 'toolbar.comment');
  1190. translatePiece('.toolbar .tb-settings', 'attribute', {'name': 'title'}, 'toolbar.settings');
  1191. translatePiece('.toolbar .tb-settings', 'attribute', {'name': 'alt'}, 'toolbar.settings');
  1192. translatePiece('.toolbar .tb-sourcecode', 'attribute', {'name': 'title'}, 'toolbar.sourcecode');
  1193. translatePiece('.toolbar .tb-sourcecode', 'attribute', {'name': 'alt'}, 'toolbar.sourcecode');
  1194. }
  1195. async function main() {
  1196. await localizationInitialize(navigator.language);
  1197. translateEverything();
  1198. try {
  1199. const userMedia = await initVoice(data => {
  1200. if (testVoiceHandler) {
  1201. testVoiceHandler.write(data)
  1202. }
  1203. if (!ui.client) {
  1204. if (voiceHandler) {
  1205. voiceHandler.end()
  1206. }
  1207. voiceHandler = null
  1208. } else if (voiceHandler) {
  1209. voiceHandler.write(data)
  1210. }
  1211. })
  1212. ui._micStream = userMedia
  1213. } catch (err) {
  1214. window.alert('Failed to initialize user media\nRefresh page to retry.\n' + err)
  1215. return
  1216. }
  1217. initializeUI();
  1218. }
  1219. window.onload = main