diff --git a/ChangeLog.md b/ChangeLog.md index 164f2b8f..7347a177 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,13 @@ # Changelog: +* **15.02.21** + - Fixed critical bug within the event registry class + - Added a dropdown for the microphone control button to quickly change microphones + - Fixed the microphone settings microphone selection (The default device wasn't selected) + - Adding a hint whatever the device is the default device or not + - Fixed issue [#169](https://github.com/TeaSpeak/TeaWeb/issues/169) (Adding permissions dosn't work for TS3 server) + - Fixed issue [#166](https://github.com/TeaSpeak/TeaWeb/issues/166) (Private conversations are not accessible when IndexDB could not be opened) + - Using the last used emoji to indicate the chat emoji button + * **22.01.21** - Allowing the user to easily change the channel name mode - Fixed channel name mode parsing diff --git a/shared/js/ConnectionManager.ts b/shared/js/ConnectionManager.ts index 5ef4f6a9..2bcb79cb 100644 --- a/shared/js/ConnectionManager.ts +++ b/shared/js/ConnectionManager.ts @@ -138,6 +138,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { name: "server manager init", function: async () => { server_connections = new ConnectionManager(); + (window as any).server_connections = server_connections; }, priority: 80 }); diff --git a/shared/js/connection/ConnectionBase.ts b/shared/js/connection/ConnectionBase.ts index c4b5cce8..985b8f2b 100644 --- a/shared/js/connection/ConnectionBase.ts +++ b/shared/js/connection/ConnectionBase.ts @@ -58,6 +58,8 @@ export abstract class AbstractServerConnection { abstract connected() : boolean; abstract disconnect(reason?: string) : Promise; + abstract getServerType() : "teaspeak" | "teamspeak" | "unknown"; + abstract getVoiceConnection() : AbstractVoiceConnection; abstract getVideoConnection() : VideoConnection; diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index 53fc1179..21d9b42f 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -11,6 +11,7 @@ import {ErrorCode} from "tc-shared/connection/ErrorCode"; import {WhisperTarget} from "tc-shared/voice/VoiceWhisper"; import {globalAudioContext} from "tc-backend/audio/player"; import {VideoBroadcastConfig, VideoBroadcastType} from "tc-shared/connection/VideoConnection"; +import {Settings, settings} from "tc-shared/settings"; const kSdpCompressionMode = 1; @@ -372,9 +373,7 @@ class InternalRemoteRTPAudioTrack extends RemoteRTPAudioTrack { if(state === 1) { validateInfo(); this.shouldReplay = true; - if(this.gainNode) { - this.gainNode.gain.value = this.gain; - } + this.updateGainNode(); this.setState(RemoteRTPTrackState.Started); } else { /* There wil be no info present */ @@ -383,9 +382,7 @@ class InternalRemoteRTPAudioTrack extends RemoteRTPAudioTrack { /* since we're might still having some jitter stuff */ this.muteTimeout = setTimeout(() => { this.shouldReplay = false; - if(this.gainNode) { - this.gainNode.gain.value = 0; - } + this.updateGainNode(); }, 1000); } } @@ -882,18 +879,23 @@ export class RTCConnection { iceServers: [{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }] }); - /* If set to false FF failed: FIXME! */ - const kAddGenericTransceiver = true; - if(this.audioSupport) { this.currentTransceiver["audio"] = this.peer.addTransceiver("audio"); this.currentTransceiver["audio-whisper"] = this.peer.addTransceiver("audio"); - /* add some other transceivers for later use */ - for(let i = 0; i < 8 && kAddGenericTransceiver; i++) { - const transceiver = this.peer.addTransceiver("audio"); - /* we only want to received on that and don't share any bandwidth limits */ - transceiver.direction = "recvonly"; + if(window.detectedBrowser.name === "firefox") { + /* + * For some reason FF (<= 85.0) does not replay any audio from extra added transceivers. + * On the other hand, if the server is creating that track or we're using it for sending audio as well + * it works. So we just wait for the server to come up with new streams (even though we need to renegotiate...). + * For Chrome we only need to negotiate once in most cases. + * Side note: This does not apply to video channels! + */ + } else { + /* add some other transceivers for later use */ + for(let i = 0; i < settings.getValue(Settings.KEY_RTC_EXTRA_AUDIO_CHANNELS); i++) { + this.peer.addTransceiver("audio", { direction: "recvonly" }); + } } } @@ -901,10 +903,8 @@ export class RTCConnection { this.currentTransceiver["video-screen"] = this.peer.addTransceiver("video"); /* add some other transceivers for later use */ - for(let i = 0; i < 4 && kAddGenericTransceiver; i++) { - const transceiver = this.peer.addTransceiver("video"); - /* we only want to received on that and don't share any bandwidth limits */ - transceiver.direction = "recvonly"; + for(let i = 0; i < settings.getValue(Settings.KEY_RTC_EXTRA_VIDEO_CHANNELS); i++) { + this.peer.addTransceiver("video", { direction: "recvonly" }); } this.peer.onicecandidate = event => this.handleLocalIceCandidate(event.candidate); diff --git a/shared/js/connection/rtc/RemoteTrack.ts b/shared/js/connection/rtc/RemoteTrack.ts index 3d29c3b6..009844b6 100644 --- a/shared/js/connection/rtc/RemoteTrack.ts +++ b/shared/js/connection/rtc/RemoteTrack.ts @@ -65,7 +65,7 @@ export class RemoteRTPTrack { } getSsrc() : number { - return this.ssrc; + return this.ssrc >>> 0; } getTrack() : MediaStreamTrack { @@ -144,7 +144,20 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack { this.htmlAudioNode.msRealTime = true; /* - TODO: ontimeupdate may gives us a hint whatever we're still replaying audio or not + { + const track = transceiver.receiver.track; + for(let key in track) { + if(!key.startsWith("on")) { + continue; + } + + track[key] = () => console.log("Track %d: %s", this.getSsrc(), key); + } + } + */ + + /* + //TODO: ontimeupdate may gives us a hint whatever we're still replaying audio or not for(let key in this.htmlAudioNode) { if(!key.startsWith("on")) { continue; @@ -153,7 +166,7 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack { this.htmlAudioNode[key] = () => console.log("AudioElement %d: %s", this.getSsrc(), key); this.htmlAudioNode.ontimeupdate = () => { console.log("AudioElement %d: Time update. Current time: %d", this.getSsrc(), this.htmlAudioNode.currentTime, this.htmlAudioNode.buffered) - } + }; } */ @@ -166,8 +179,7 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack { const audioContext = globalAudioContext(); this.audioNode = audioContext.createMediaStreamSource(this.mediaStream); this.gainNode = audioContext.createGain(); - - this.gainNode.gain.value = this.shouldReplay ? this.gain : 0; + this.updateGainNode(); this.audioNode.connect(this.gainNode); this.gainNode.connect(audioContext.destination); @@ -195,10 +207,7 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack { setGain(value: number) { this.gain = value; - - if(this.gainNode) { - this.gainNode.gain.value = this.shouldReplay ? this.gain : 0; - } + this.updateGainNode(); } /** @@ -209,4 +218,13 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack { this.gainNode.gain.value = 0; } } + + protected updateGainNode() { + if(!this.gainNode) { + return; + } + + this.gainNode.gain.value = this.shouldReplay ? this.gain : 0; + //console.error("Change gain for %d to %f (%o)", this.getSsrc(), this.gainNode.gain.value, this.shouldReplay); + } } \ No newline at end of file diff --git a/shared/js/connection/rtc/SdpUtils.ts b/shared/js/connection/rtc/SdpUtils.ts index 0787d249..25fb7811 100644 --- a/shared/js/connection/rtc/SdpUtils.ts +++ b/shared/js/connection/rtc/SdpUtils.ts @@ -28,7 +28,7 @@ export class SdpProcessor { rate: 48000, encoding: 2, fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, usedtx: 1, stereo: 0, "sprop-stereo": 0 }, - rtcpFb: [ "transport-cc" ] + rtcpFb: [ "transport-cc", "nack", "goog-remb" ] }, { // Opus Stereo/Opus Music @@ -37,7 +37,7 @@ export class SdpProcessor { rate: 48000, encoding: 2, fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, usedtx: 1, stereo: 1, "sprop-stereo": 1 }, - rtcpFb: [ "transport-cc" ] + rtcpFb: [ "transport-cc", "nack", "goog-remb" ] }, ]; diff --git a/shared/js/conversations/PrivateConversationHistory.ts b/shared/js/conversations/PrivateConversationHistory.ts index 1d26db4a..4a06954e 100644 --- a/shared/js/conversations/PrivateConversationHistory.ts +++ b/shared/js/conversations/PrivateConversationHistory.ts @@ -18,7 +18,17 @@ async function requestDatabase() { } else if(databaseMode === "opening" || databaseMode === "updating") { await new Promise(resolve => databaseStateChangedCallbacks.push(resolve)); } else if(databaseMode === "closed") { - await doOpenDatabase(false); + try { + await doOpenDatabase(false); + } catch (error) { + currentDatabase = undefined; + if(databaseMode !== "closed") { + databaseMode = "closed"; + fireDatabaseStateChanged(); + } + + throw error; + } } } } @@ -143,6 +153,11 @@ async function importChatsFromCacheStorage(database: IDBDatabase) { } async function doOpenDatabase(forceUpgrade: boolean) { + if(!('indexedDB' in window)) { + loader.critical_error(tr("Missing Indexed DB support")); + throw tr("Missing Indexed DB support"); + } + if(databaseMode === "closed") { databaseMode = "opening"; fireDatabaseStateChanged(); @@ -231,13 +246,8 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { priority: 0, name: "Chat history setup", function: async () => { - if(!('indexedDB' in window)) { - loader.critical_error(tr("Missing Indexed DB support")); - throw tr("Missing Indexed DB support"); - } - try { - await doOpenDatabase(false); + await requestDatabase(); logDebug(LogCategory.CHAT, tr("Successfully initialized private conversation history database")); } catch (error) { logError(LogCategory.CHAT, tr("Failed to initialize private conversation history database: %o"), error); @@ -255,8 +265,9 @@ export async function queryConversationEvents(clientUniqueId: string, query: { const storeName = clientUniqueId2StoreName(clientUniqueId); await requestDatabase(); - if(!currentDatabase.objectStoreNames.contains(storeName)) + if(!currentDatabase.objectStoreNames.contains(storeName)) { return { events: [], hasMore: false }; + } const transaction = currentDatabase.transaction(storeName, "readonly"); const store = transaction.objectStore(storeName); diff --git a/shared/js/conversations/PrivateConversationManager.ts b/shared/js/conversations/PrivateConversationManager.ts index 059cbff0..257efe81 100644 --- a/shared/js/conversations/PrivateConversationManager.ts +++ b/shared/js/conversations/PrivateConversationManager.ts @@ -282,6 +282,20 @@ export class PrivateConversation extends AbstractChat this.presentMessages = result.events.filter(e => e.type === "message"); this.setHistory(!!result.hasMore); + this.setCurrentMode("normal"); + }).catch(error => { + console.error("Error open!"); + this.presentEvents = []; + this.presentMessages = []; + this.setHistory(false); + + this.registerChatEvent({ + type: "query-failed", + timestamp: Date.now(), + uniqueId: "la-" + this.chatId + "-" + Date.now(), + message: tr("Failed to query chat history:\n") + error + }, false); + this.setCurrentMode("normal"); }); } diff --git a/shared/js/events.ts b/shared/js/events.ts index 8a9002bb..cc0cf62e 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -298,7 +298,8 @@ export class Registry = EventMap> implement } } - for(const handler of this.persistentEventHandler[event.type] || []) { + const handlers = [...(this.persistentEventHandler[event.type] || [])]; + for(const handler of handlers) { handler(event); } diff --git a/shared/js/file/FileManager.tsx b/shared/js/file/FileManager.tsx index b5d6ce91..14a92574 100644 --- a/shared/js/file/FileManager.tsx +++ b/shared/js/file/FileManager.tsx @@ -558,8 +558,9 @@ export class FileManager { "proto": 1 }, {process_result: false}); - if(transfer.transferState() === FileTransferState.INITIALIZING) + if(transfer.transferState() === FileTransferState.INITIALIZING) { throw tr("missing transfer start notify"); + } } catch (error) { transfer.setFailed({ @@ -620,8 +621,9 @@ export class FileManager { transfer: transfer, executeCallback: async () => { await callbackInitialize(transfer); /* noexcept */ - if(transfer.transferState() !== FileTransferState.CONNECTING) + if(transfer.transferState() !== FileTransferState.CONNECTING) { return; + } try { const provider = TransferProvider.provider(); @@ -633,12 +635,13 @@ export class FileManager { return; } - if(transfer instanceof FileDownloadTransfer) + if(transfer instanceof FileDownloadTransfer) { provider.executeFileDownload(transfer); - else if(transfer instanceof FileUploadTransfer) + } else if(transfer instanceof FileUploadTransfer) { provider.executeFileUpload(transfer); - else + } else { throw tr("unknown transfer type"); + } } catch (error) { const message = typeof error === "string" ? error : error instanceof Error ? error.message : tr("Unknown error"); transfer.setFailed({ @@ -651,7 +654,7 @@ export class FileManager { finishPromise: new Promise(resolve => { const unregisterTransfer = () => { transfer.events.off("notify_state_updated", stateListener); - transfer.events.off("action_request_cancel", cancelListener); + transfer.events.off("notify_transfer_canceled", unregisterTransfer); const index = this.registeredTransfers_.findIndex(e => e.transfer === transfer); if(index === -1) { @@ -681,6 +684,9 @@ export class FileManager { } else { logWarn(LogCategory.FILE_TRANSFER, tra("File transfer finished callback called with invalid transfer state ({0})", FileTransferState[state])); } + + /* destroy the transfer after all events have been fired */ + setTimeout(() => transfer.destroy(), 250); }; const stateListener = () => { @@ -690,13 +696,9 @@ export class FileManager { } }; - const cancelListener = () => { - unregisterTransfer(); - transfer.events.fire_later("notify_transfer_canceled", {}, resolve); - }; - transfer.events.on("notify_state_updated", stateListener); - transfer.events.on("action_request_cancel", cancelListener); + transfer.events.on("notify_transfer_canceled", unregisterTransfer); + stateListener(); }) }); @@ -705,8 +707,9 @@ export class FileManager { } private scheduleTransferUpdate() { - if(this.scheduledTransferUpdate) + if(this.scheduledTransferUpdate) { return; + } this.scheduledTransferUpdate = setTimeout(() => { this.scheduledTransferUpdate = undefined; diff --git a/shared/js/file/Transfer.ts b/shared/js/file/Transfer.ts index d23e7749..5e482dc0 100644 --- a/shared/js/file/Transfer.ts +++ b/shared/js/file/Transfer.ts @@ -113,8 +113,6 @@ export enum FileTransferDirection { export interface FileTransferEvents { "notify_state_updated": { oldState: FileTransferState, newState: FileTransferState }, "notify_progress": { progress: TransferProgress }, - - "action_request_cancel": { reason: CancelReason }, "notify_transfer_canceled": {} } @@ -239,9 +237,14 @@ export class FileTransfer { this.setTransferState(FileTransferState.PENDING); this.events = new Registry(); - this.events.on("notify_transfer_canceled", () => { + } + + destroy() { + if(!this.isFinished()) { this.setTransferState(FileTransferState.CANCELED); - }); + } + + this.events.destroy(); } isRunning() { @@ -253,7 +256,7 @@ export class FileTransfer { } isFinished() { - return this.transferState() === FileTransferState.FINISHED || this.transferState() === FileTransferState.ERRORED || this.transferState() === FileTransferState.CANCELED; + return this.transferState_ === FileTransferState.FINISHED || this.transferState_ === FileTransferState.ERRORED || this.transferState_ === FileTransferState.CANCELED; } transferState() { @@ -297,16 +300,19 @@ export class FileTransfer { } requestCancel(reason: CancelReason) { - if(this.isFinished()) + if(this.isFinished()) { throw tr("invalid transfer state"); + } this.cancelReason = reason; - this.events.fire("action_request_cancel"); + this.events.fire("notify_transfer_canceled"); + this.setTransferState(FileTransferState.CANCELED); } setTransferState(newState: FileTransferState) { - if(this.transferState_ === newState) + if(this.transferState_ === newState) { return; + } const newIsFinishedState = newState === FileTransferState.CANCELED || newState === FileTransferState.ERRORED || newState === FileTransferState.FINISHED; try { @@ -335,8 +341,9 @@ export class FileTransfer { case FileTransferState.FINISHED: case FileTransferState.CANCELED: case FileTransferState.ERRORED: - if(this.isFinished()) + if(this.isFinished()) { throw void 0; + } this.timings.timestampEnd = Date.now(); break; } @@ -358,7 +365,6 @@ export class FileTransfer { } } catch (e) { throw "invalid transfer state transform from " + this.transferState_ + " to " + newState; - return; } const oldState = this.transferState_; @@ -368,7 +374,7 @@ export class FileTransfer { updateProgress(progress: TransferProgress) { this.progress_ = progress; - this.events.fire_later("notify_progress", { progress: progress }); + this.events.fire("notify_progress", { progress: progress }); } awaitFinished() : Promise { diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 141e74bc..20521489 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -73,10 +73,14 @@ async function initializeApp() { aplayer.on_ready(() => aplayer.set_master_volume(settings.getValue(Settings.KEY_SOUND_MASTER) / 100)); - setDefaultRecorder(new RecorderProfile("default")); - defaultRecorder.initialize().catch(error => { + const recorder = new RecorderProfile("default"); + try { + await recorder.initialize(); + } catch (error) { + /* TODO: Recover into a defined state? */ logError(LogCategory.AUDIO, tr("Failed to initialize default recorder: %o"), error); - }); + } + setDefaultRecorder(recorder); sound.initialize().then(() => { logInfo(LogCategory.AUDIO, tr("Sounds initialized")); diff --git a/shared/js/permission/PermissionManager.ts b/shared/js/permission/PermissionManager.ts index f58c2da3..2627b13b 100644 --- a/shared/js/permission/PermissionManager.ts +++ b/shared/js/permission/PermissionManager.ts @@ -181,7 +181,7 @@ export class PermissionManager extends AbstractCommandHandler { }[] = []; initializedListener: ((initialized: boolean) => void)[] = []; - private _cacheNeededPermissions: any; + private cacheNeededPermissions: any; /* Static info mapping until TeaSpeak implements a detailed info */ static readonly group_mapping: {name: string, deep: number}[] = [ @@ -280,7 +280,7 @@ export class PermissionManager extends AbstractCommandHandler { delete this[key]; this.initializedListener = undefined; - this._cacheNeededPermissions = undefined; + this.cacheNeededPermissions = undefined; } handle_command(command: ServerCommand): boolean { @@ -361,68 +361,95 @@ export class PermissionManager extends AbstractCommandHandler { group.end(); logInfo(LogCategory.PERMISSIONS, tr("Got %i permissions"), this.permissionList.length); - if(this._cacheNeededPermissions) - this.onNeededPermissions(this._cacheNeededPermissions); - for(let listener of this.initializedListener) + if(this.cacheNeededPermissions) { + this.onNeededPermissions(this.cacheNeededPermissions); + } + + for(let listener of this.initializedListener) { listener(true); + } } - private onNeededPermissions(json) { + private onNeededPermissions(json: any[]) { if(this.permissionList.length == 0) { logWarn(LogCategory.PERMISSIONS, tr("Got needed permissions but don't have a permission list!")); - this._cacheNeededPermissions = json; + this.cacheNeededPermissions = json; return; } - this._cacheNeededPermissions = undefined; + this.cacheNeededPermissions = undefined; - let copy = this.neededPermissions.slice(); - let addcount = 0; + let permissionsCopy = this.neededPermissions.slice(); + let permissionAddCount = 0; + let permissionRemoveCount = 0; let group = log.group(log.LogType.TRACE, LogCategory.PERMISSIONS, tr("Got %d needed permissions."), json.length); - const table_entries = []; + const tableEntries = []; - for(let e of json) { + for(let notifyEntry of json) { let entry: NeededPermissionValue = undefined; - for(let p of copy) { - if(p.type.id == e["permid"]) { - entry = p; - copy.remove(p); + for(let permission of permissionsCopy) { + if(permission.type.id == notifyEntry["permid"]) { + entry = permission; + permissionsCopy.remove(permission); break; } } + + const permissionValue = parseInt(notifyEntry["permvalue"]); + if(permissionValue === 0) { + if(entry) { + permissionRemoveCount++; + + entry.value = -2; + for(const listener of this.needed_permission_change_listener[entry.type.name] || []) { + listener(); + } + } + /* + * Permission hasn't been granted. + * TeamSpeak uses this as "permission removed". + */ + continue; + } + if(!entry) { - let info = this.resolveInfo(e["permid"]); + let info = this.resolveInfo(notifyEntry["permid"]); if(info) { entry = new NeededPermissionValue(info, -2); this.neededPermissions.push(entry); } else { - logWarn(LogCategory.PERMISSIONS, tr("Could not resolve perm for id %s (%o|%o)"), e["permid"], e, info); + logWarn(LogCategory.PERMISSIONS, tr("Could not resolve perm for id %s (%o|%o)"), notifyEntry["permid"], notifyEntry, info); continue; } - addcount++; + permissionAddCount++; } + entry.value = permissionValue; - if(entry.value == parseInt(e["permvalue"])) continue; - entry.value = parseInt(e["permvalue"]); - - for(const listener of this.needed_permission_change_listener[entry.type.name] || []) + for(const listener of this.needed_permission_change_listener[entry.type.name] || []) { listener(); + } - table_entries.push({ + tableEntries.push({ "permission": entry.type.name, "value": entry.value }); } - log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Needed client permissions", table_entries); + log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Needed client permissions", tableEntries); group.end(); - logDebug(LogCategory.PERMISSIONS, tr("Dropping %o needed permissions and added %o permissions."), copy.length, addcount); - for(let e of copy) { - e.value = -2; - for(const listener of this.needed_permission_change_listener[e.type.name] || []) - listener(); + if(this.handle.serverConnection.getServerType() === "teamspeak" || json[0]["relative"] === "1") { + /* We don't update the full list every time. Instead we're only propagating changes. */ + } else { + permissionRemoveCount = permissionsCopy.length; + for(let entry of permissionsCopy) { + entry.value = -2; + for(const listener of this.needed_permission_change_listener[entry.type.name] || []) { + listener(); + } + } } + logDebug(LogCategory.PERMISSIONS, tr("Dropping %o needed permissions and added %o permissions."), permissionRemoveCount, permissionAddCount); this.events.fire("client_permissions_changed"); } diff --git a/shared/js/proto.ts b/shared/js/proto.ts index f604f7c3..e64e2df4 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -156,7 +156,7 @@ if(!JSON.map_field_to) { if (!Array.prototype.remove) { Array.prototype.remove = function(elem?: T): boolean { - const index = this.indexOf(elem, 0); + const index = this.indexOf(elem); if (index > -1) { this.splice(index, 1); return true; diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 13615f9f..5288ff3c 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -79,7 +79,6 @@ function resolveKey( resolver: (key: string) => string | undefined, defaultValue: DefaultType ) : ValueType | DefaultType { - let value = resolver(key.key); if(typeof value === "string") { return decodeValueFromString(value, key.valueType); @@ -92,17 +91,14 @@ function resolveKey( continue; } - if(!key.fallbackImports) { - break; - } - - /* fallback key succeeded */ - const fallbackValueImporter = key.fallbackImports[fallback]; - if(fallbackValueImporter) { - return fallbackValueImporter(value); + if(key.fallbackImports) { + const fallbackValueImporter = key.fallbackImports[fallback]; + if(fallbackValueImporter) { + return fallbackValueImporter(value); + } } - break; + return decodeValueFromString(value, key.valueType); } return defaultValue; @@ -499,6 +495,12 @@ export class Settings { valueType: "string", }; + static readonly KEY_CHAT_LAST_USED_EMOJI: ValuedRegistryKey = { + key: "chat_last_used_emoji", + defaultValue: ":joy:", + valueType: "string", + }; + static readonly KEY_SWITCH_INSTANT_CHAT: ValuedRegistryKey = { key: "switch_instant_chat", defaultValue: true, @@ -595,10 +597,30 @@ export class Settings { valueType: "boolean", }; + static readonly KEY_RTC_EXTRA_VIDEO_CHANNELS: ValuedRegistryKey = { + key: "rtc_extra_video_channels", + defaultValue: 0, + requireRestart: true, + valueType: "number", + description: "Extra video channels within the initial WebRTC sdp offer.\n" + + "Note: By default the screen/camera share channels are already present" + }; + + static readonly KEY_RTC_EXTRA_AUDIO_CHANNELS: ValuedRegistryKey = { + key: "rtc_extra_audio_channels", + defaultValue: 6, + requireRestart: true, + valueType: "number", + description: "Extra audio channels within the initial WebRTC sdp offer.\n" + + "Note:\n" + + "1. By default the voice/whisper channels are already present.\n" + + "2. This setting does not work for Firefox." + }; + static readonly KEY_RNNOISE_FILTER: ValuedRegistryKey = { key: "rnnoise_filter", defaultValue: true, - description: "Enable the rnnoise filter for supressing background noise", + description: "Enable the rnnoise filter for suppressing background noise", valueType: "boolean", }; diff --git a/shared/js/text/bbcode/EmojiUtil.ts b/shared/js/text/bbcode/EmojiUtil.ts new file mode 100644 index 00000000..4a63bd68 --- /dev/null +++ b/shared/js/text/bbcode/EmojiUtil.ts @@ -0,0 +1,28 @@ +function toCodePoint(unicodeSurrogates) { + let r = [], + c = 0, + p = 0, + i = 0; + while (i < unicodeSurrogates.length) { + c = unicodeSurrogates.charCodeAt(i++); + if (p) { + r.push((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)).toString(16)); + p = 0; + } else if (0xD800 <= c && c <= 0xDBFF) { + p = c; + } else { + r.push(c.toString(16)); + } + } + return r.join("-"); +} + +const U200D = String.fromCharCode(0x200D); +const UFE0Fg = /\uFE0F/g; +export function getTwenmojiHashFromNativeEmoji(emoji: string) : string { + // if variant is present as \uFE0F + return toCodePoint(emoji.indexOf(U200D) < 0 ? + emoji.replace(UFE0Fg, '') : + emoji + ); +} \ No newline at end of file diff --git a/shared/js/text/bbcode/emoji.tsx b/shared/js/text/bbcode/emoji.tsx index 52e5d3c5..e62029d7 100644 --- a/shared/js/text/bbcode/emoji.tsx +++ b/shared/js/text/bbcode/emoji.tsx @@ -7,6 +7,7 @@ import ReactRenderer from "vendor/xbbcode/renderer/react"; import {Settings, settings} from "tc-shared/settings"; import * as emojiRegex from "emoji-regex"; +import {getTwenmojiHashFromNativeEmoji} from "tc-shared/text/bbcode/EmojiUtil"; const emojiRegexInstance = (emojiRegex as any)() as RegExp; @@ -15,39 +16,11 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { function: async () => { let reactId = 0; - function toCodePoint(unicodeSurrogates) { - let r = [], - c = 0, - p = 0, - i = 0; - while (i < unicodeSurrogates.length) { - c = unicodeSurrogates.charCodeAt(i++); - if (p) { - r.push((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)).toString(16)); - p = 0; - } else if (0xD800 <= c && c <= 0xDBFF) { - p = c; - } else { - r.push(c.toString(16)); - } - } - return r.join("-"); - } - - const U200D = String.fromCharCode(0x200D); - const UFE0Fg = /\uFE0F/g; - function grabTheRightIcon(rawText) { - // if variant is present as \uFE0F - return toCodePoint(rawText.indexOf(U200D) < 0 ? - rawText.replace(UFE0Fg, '') : - rawText - ); - } - rendererReact.setTextRenderer(new class extends ElementRenderer { render(element: TextElement, renderer: ReactRenderer): React.ReactNode { - if(!settings.getValue(Settings.KEY_CHAT_COLORED_EMOJIES)) + if(!settings.getValue(Settings.KEY_CHAT_COLORED_EMOJIES)) { return element.text(); + } let text = element.text(); emojiRegexInstance.lastIndex = 0; @@ -59,13 +32,15 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { let match = emojiRegexInstance.exec(text); const rawText = text.substring(lastIndex, match?.index); - if(rawText) + if(rawText) { result.push(renderer.renderAsText(rawText, false)); + } - if(!match) + if(!match) { break; + } - let hash = grabTheRightIcon(match[0]); + let hash = getTwenmojiHashFromNativeEmoji(match[0]); result.push({match[0]}); lastIndex = match.index + match[0].length; } diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index d6a23c5c..0f088dd3 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -27,6 +27,8 @@ import {VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/Vi import {tr} from "tc-shared/i18n/localize"; import {getVideoDriver} from "tc-shared/video/VideoSource"; import {kLocalBroadcastChannels} from "tc-shared/ui/frames/video/Definitions"; +import {getRecorderBackend, IDevice} from "tc-shared/audio/recorder"; +import {defaultRecorder, defaultRecorderEvents} from "tc-shared/voice/RecorderProfile"; class InfoController { private readonly mode: ControlBarMode; @@ -36,6 +38,7 @@ class InfoController { private globalEvents: (() => void)[] = []; private globalHandlerRegisteredEvents: {[key: string]: (() => void)[]} = {}; private handlerRegisteredEvents: (() => void)[] = []; + private defaultRecorderListener: () => void; constructor(events: Registry, mode: ControlBarMode) { this.events = events; @@ -64,7 +67,13 @@ class InfoController { this.sendVideoState("camera"); })); events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks())); - events.push(getVideoDriver().getEvents().on("notify_device_list_changed", () => this.sendCameraList())) + events.push(getVideoDriver().getEvents().on("notify_device_list_changed", () => this.sendCameraList())); + events.push(getRecorderBackend().getDeviceList().getEvents().on("notify_list_updated", () => this.sendMicrophoneList())); + events.push(defaultRecorderEvents.on("notify_default_recorder_changed", () => { + this.unregisterDefaultRecorderEvents(); + this.registerDefaultRecorderEvents(); + this.sendMicrophoneList(); + })); if(this.mode === "main") { events.push(server_connections.events().on("notify_active_handler_changed", event => this.setConnectionHandler(event.newHandler))); } @@ -73,6 +82,8 @@ class InfoController { } public destroy() { + this.unregisterDefaultRecorderEvents(); + server_connections.getAllConnectionHandlers().forEach(handler => this.unregisterGlobalHandlerEvents(handler)); this.unregisterCurrentHandlerEvents(); @@ -80,6 +91,21 @@ class InfoController { this.globalEvents = []; } + private registerDefaultRecorderEvents() { + if(!defaultRecorder) { + return; + } + + this.defaultRecorderListener = defaultRecorder.events.on("notify_device_changed", () => this.sendMicrophoneList()); + } + + private unregisterDefaultRecorderEvents() { + if(this.defaultRecorderListener) { + this.defaultRecorderListener(); + this.defaultRecorderListener = undefined; + } + } + private registerGlobalHandlerEvents(handler: ConnectionHandler) { const events = this.globalHandlerRegisteredEvents[handler.handlerId] = []; @@ -219,6 +245,31 @@ class InfoController { }); } + public sendMicrophoneList() { + const deviceList = getRecorderBackend().getDeviceList(); + const devices = deviceList.getDevices(); + const defaultDevice = deviceList.getDefaultDeviceId(); + const selectedDevice = defaultRecorder?.getDeviceId(); + + this.events.fire_react("notify_microphone_list", { + devices: devices.map(device => { + let selected = false; + if(selectedDevice === IDevice.DefaultDeviceId && device.deviceId === defaultDevice) { + selected = true; + } else if(selectedDevice === device.deviceId) { + selected = true; + } + + return { + name: device.name, + driver: device.driver, + id: device.deviceId, + selected: selected + }; + }) + }) + } + public sendSpeakerState() { this.events.fire_react("notify_speaker_state", { enabled: !this.currentHandler?.isSpeakerMuted() @@ -303,10 +354,6 @@ export function initializePopoutControlBarController(events: Registry) { - initializeControlBarController(events, "main"); -} - export function initializeControlBarController(events: Registry, mode: ControlBarMode) : InfoController { const infoHandler = new InfoController(events, mode); infoHandler.initialize(); @@ -318,6 +365,7 @@ export function initializeControlBarController(events: Registry infoHandler.sendBookmarks()); events.on("query_away_state", () => infoHandler.sendAwayState()); events.on("query_microphone_state", () => infoHandler.sendMicrophoneState()); + events.on("query_microphone_list", () => infoHandler.sendMicrophoneList()); events.on("query_speaker_state", () => infoHandler.sendSpeakerState()); events.on("query_subscribe_state", () => infoHandler.sendSubscribeState()); events.on("query_host_button", () => infoHandler.sendHostButton()); @@ -373,10 +421,24 @@ export function initializeControlBarController(events: Registry { + events.on("action_toggle_microphone", async event => { /* change the default global setting */ settings.setValue(Settings.KEY_CLIENT_STATE_MICROPHONE_MUTED, !event.enabled); + if(typeof event.targetDeviceId === "string") { + const device = getRecorderBackend().getDeviceList().getDevices().find(device => device.deviceId === event.targetDeviceId); + try { + if(!device) { + throw tr("Target device could not be found."); + } + + await defaultRecorder?.setDevice(device); + } catch (error) { + createErrorModal(tr("Failed to change microphone"), tr("Failed to change microphone.\nTarget device could not be found.")).open(); + return; + } + } + const current_connection_handler = infoHandler.getCurrentHandler(); if(current_connection_handler) { current_connection_handler.setMicrophoneMuted(!event.enabled); @@ -390,6 +452,10 @@ export function initializeControlBarController(events: Registry { + global_client_actions.fire("action_open_window_settings", { defaultCategory: "audio-microphone" }); + }); + events.on("action_toggle_speaker", event => { /* change the default global setting */ settings.setValue(Settings.KEY_CLIENT_STATE_SPEAKER_MUTED, !event.enabled); diff --git a/shared/js/ui/frames/control-bar/Definitions.ts b/shared/js/ui/frames/control-bar/Definitions.ts index 0562f462..5ee05a73 100644 --- a/shared/js/ui/frames/control-bar/Definitions.ts +++ b/shared/js/ui/frames/control-bar/Definitions.ts @@ -9,6 +9,7 @@ export type MicrophoneState = "enabled" | "disabled" | "muted"; export type VideoState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected"; export type HostButtonInfo = { title?: string, target?: string, url: string }; export type VideoDeviceInfo = { name: string, id: string }; +export type MicrophoneDeviceInfo = { name: string, id: string, driver: string, selected: boolean }; export interface ControlBarEvents { action_connection_connect: { newTab: boolean }, @@ -17,19 +18,21 @@ export interface ControlBarEvents { action_bookmark_manage: {}, action_bookmark_add_current_server: {}, action_toggle_away: { away: boolean, globally: boolean, promptMessage?: boolean }, - action_toggle_microphone: { enabled: boolean }, + action_toggle_microphone: { enabled: boolean, targetDeviceId?: string }, action_toggle_speaker: { enabled: boolean }, action_toggle_subscribe: { subscribe: boolean }, action_toggle_query: { show: boolean }, action_query_manage: {}, action_toggle_video: { broadcastType: VideoBroadcastType, enable: boolean, quickStart?: boolean, deviceId?: string }, - action_manage_video: { broadcastType: VideoBroadcastType } + action_manage_video: { broadcastType: VideoBroadcastType }, + action_open_microphone_settings: {}, query_mode: {}, query_connection_state: {}, query_bookmarks: {}, query_away_state: {}, query_microphone_state: {}, + query_microphone_list: {}, query_speaker_state: {}, query_subscribe_state: {}, query_query_state: {}, @@ -42,6 +45,7 @@ export interface ControlBarEvents { notify_bookmarks: { marks: Bookmark[] }, notify_away_state: { state: AwayState }, notify_microphone_state: { state: MicrophoneState }, + notify_microphone_list: { devices: MicrophoneDeviceInfo[] }, notify_speaker_state: { enabled: boolean }, notify_subscribe_state: { subscribe: boolean }, notify_query_state: { shown: boolean }, diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index 235183ad..06798ac6 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -5,7 +5,7 @@ import { ConnectionState, ControlBarEvents, ControlBarMode, - HostButtonInfo, + HostButtonInfo, MicrophoneDeviceInfo, MicrophoneState, VideoDeviceInfo, VideoState @@ -316,17 +316,108 @@ const MicrophoneButton = () => { events.on("notify_microphone_state", event => setState(event.state)); if(state === "muted") { - return + ); } else if(state === "enabled") { - return + ); } else { - return + ); } } +/* This should be above all driver weights */ +const kDriverWeightSelected = 1000; +const kDriverWeights = { + "MME": 100, + "Windows DirectSound": 80, + "Windows WASAPI": 50 +}; + +const MicrophoneDeviceList = React.memo(() => { + const events = useContext(Events); + const [ deviceList, setDeviceList ] = useState(() => { + events.fire("query_microphone_list"); + return []; + }); + events.reactUse("notify_microphone_list", event => setDeviceList(event.devices)); + + if(deviceList.length <= 1) { + /* we don't need a select here */ + return null; + } + + const devices: {[key: string]: { weight: number, device: MicrophoneDeviceInfo }} = {}; + for(const entry of deviceList) { + const weight = entry.selected ? kDriverWeightSelected : (kDriverWeights[entry.driver] | 0); + if(typeof devices[entry.name] !== "undefined" && devices[entry.name].weight >= weight) { + continue; + } + + devices[entry.name] = { + weight, + device: entry + } + } + + return ( + <> +
+ {Object.values(devices).map(({ device }) => ( + events.fire("action_toggle_microphone", { enabled: true, targetDeviceId: device.id })} + /> + ))} + + ); +}); + const SpeakerButton = () => { const events = useContext(Events); diff --git a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx index 1095c9e0..b841707e 100644 --- a/shared/js/ui/frames/side/AbstractConversationRenderer.tsx +++ b/shared/js/ui/frames/side/AbstractConversationRenderer.tsx @@ -619,7 +619,7 @@ class ConversationMessages extends React.PureComponent= this.unreadTimestamp && !unreadSet) { + if(event.timestamp > this.unreadTimestamp && !unreadSet) { this.viewEntries.push(); unreadSet = true; } diff --git a/shared/js/ui/modal/ModalAbout.ts b/shared/js/ui/modal/ModalAbout.ts index 38b63ab0..4514cfd0 100644 --- a/shared/js/ui/modal/ModalAbout.ts +++ b/shared/js/ui/modal/ModalAbout.ts @@ -1,5 +1,5 @@ import {createModal} from "../../ui/elements/Modal"; -import {LogCategory, logError} from "../../log"; +import {getBackend} from "tc-shared/backend"; import {tr} from "tc-shared/i18n/localize"; function format_date(date: number) { @@ -30,11 +30,7 @@ export function spawnAbout() { connectModal.open(); if (__build.target !== "web") { - (window as any).native.client_version().then(version => { - connectModal.htmlTag.find(".version-client").text(version); - }).catch(error => { - logError(LogCategory.GENERAL, tr("Failed to load client version: %o"), error); - connectModal.htmlTag.find(".version-client").text("unknown"); - }); + const version = getBackend("native").getVersionInfo(); + connectModal.htmlTag.find(".version-client").text(version.version); } } \ No newline at end of file diff --git a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx index 6a06f6a6..0c89e753 100644 --- a/shared/js/ui/modal/permission/ModalPermissionEditor.tsx +++ b/shared/js/ui/modal/permission/ModalPermissionEditor.tsx @@ -972,7 +972,7 @@ function initializePermissionEditor(connection: ConnectionHandler, modalEvents: if (error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { events.fire("action_set_mode", { mode: "no-permissions", - failedPermission: connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon") + failedPermission: connection.permissions.getFailedPermission(error) }); return; } diff --git a/shared/js/ui/modal/permission/PermissionEditor.tsx b/shared/js/ui/modal/permission/PermissionEditor.tsx index 03bee227..ae3efcf5 100644 --- a/shared/js/ui/modal/permission/PermissionEditor.tsx +++ b/shared/js/ui/modal/permission/PermissionEditor.tsx @@ -283,8 +283,8 @@ const PermissionEntryRow = (props: { const [valueEditing, setValueEditing] = useState(false); const [valueApplying, setValueApplying] = useState(false); - const [flagNegated, setFlagNegated] = useState(props.value.flagNegate); - const [flagSkip, setFlagSkip] = useState(props.value.flagSkip); + const [flagNegated, setFlagNegated] = useState(props.value.flagNegate || false); + const [flagSkip, setFlagSkip] = useState(props.value.flagSkip || false); const [granted, setGranted] = useState(props.value.granted); const [forceGrantedUpdate, setForceGrantedUpdate] = useState(false); diff --git a/shared/js/ui/modal/settings/Microphone.tsx b/shared/js/ui/modal/settings/Microphone.tsx index 094d92f3..1fe07c87 100644 --- a/shared/js/ui/modal/settings/Microphone.tsx +++ b/shared/js/ui/modal/settings/Microphone.tsx @@ -19,10 +19,24 @@ export type MicrophoneSetting = export type MicrophoneDevice = { id: string, name: string, - driver: string + driver: string, + default: boolean }; - +export type SelectedMicrophone = { type: "default" } | { type: "none" } | { type: "device", deviceId: string }; +export type MicrophoneDevices = { + status: "error", + error: string +} | { + status: "audio-not-initialized" +} | { + status: "no-permissions", + shouldAsk: boolean +} | { + status: "success", + devices: MicrophoneDevice[] + selectedDevice: SelectedMicrophone; +}; export interface MicrophoneSettingsEvents { "query_devices": { refresh_list: boolean }, "query_help": {}, @@ -32,12 +46,12 @@ export interface MicrophoneSettingsEvents { "action_help_click": {}, "action_request_permissions": {}, - "action_set_selected_device": { deviceId: string }, + "action_set_selected_device": { target: SelectedMicrophone }, "action_set_selected_device_result": { - deviceId: string, /* on error it will contain the current selected device */ - status: "success" | "error", - - error?: string + status: "success", + } | { + status: "error", + reason: string }, "action_set_setting": { @@ -50,15 +64,8 @@ export interface MicrophoneSettingsEvents { value: any; } - "notify_devices": { - status: "success" | "error" | "audio-not-initialized" | "no-permissions", - - error?: string, - shouldAsk?: boolean, - - devices?: MicrophoneDevice[] - selectedDevice?: string; - }, + notify_devices: MicrophoneDevices, + notify_device_selected: { device: SelectedMicrophone }, notify_device_level: { level: { @@ -164,9 +171,22 @@ export function initialize_audio_microphone_controller(events: Registry { + let deviceId = defaultRecorder.getDeviceId(); + if(deviceId === IDevice.DefaultDeviceId) { + return { type: "default" }; + } else if(deviceId === IDevice.NoDeviceId) { + return { type: "none" }; + } else { + return { type: "device", deviceId: deviceId }; + } + }; + events.on("query_devices", event => { if (!aplayer.initialized()) { - events.fire_react("notify_devices", {status: "audio-not-initialized"}); + events.fire_react("notify_devices", { + status: "audio-not-initialized" + }); return; } @@ -180,46 +200,90 @@ export function initialize_audio_microphone_controller(events: Registry { - }); + deviceList.refresh().then(() => { }); } else { const devices = deviceList.getDevices(); + const defaultDeviceId = getRecorderBackend().getDeviceList().getDefaultDeviceId(); events.fire_react("notify_devices", { status: "success", - selectedDevice: defaultRecorder.getDeviceId(), devices: devices.map(e => { - return {id: e.deviceId, name: e.name, driver: e.driver} - }) + return { + id: e.deviceId, + name: e.name, + driver: e.driver, + default: defaultDeviceId === e.deviceId + } + }), + selectedDevice: currentSelectedDevice(), }); } }); events.on("action_set_selected_device", event => { - const device = recorderBackend.getDeviceList().getDevices().find(e => e.deviceId === event.deviceId); - if (!device && event.deviceId !== IDevice.NoDeviceId) { - events.fire_react("action_set_selected_device_result", { - status: "error", - error: tr("Invalid device id"), - deviceId: defaultRecorder.getDeviceId() - }); - return; + let promise; + + const target = event.target; + + let displayName: string; + switch (target.type) { + case "none": + promise = defaultRecorder.setDevice("none"); + displayName = tr("No device"); + break; + + case "default": + promise = defaultRecorder.setDevice("default"); + displayName = tr("Default device"); + break; + + case "device": + const device = recorderBackend.getDeviceList().getDevices().find(e => e.deviceId === target.deviceId); + if (!device) { + events.fire_react("action_set_selected_device_result", { + status: "error", + reason: tr("Invalid device id"), + }); + return; + } + + displayName = target.deviceId; + promise = defaultRecorder.setDevice(device); + break; + + default: + events.fire_react("action_set_selected_device_result", { + status: "error", + reason: tr("Invalid device target"), + }); + return; + } - defaultRecorder.setDevice(device).then(() => { - logTrace(LogCategory.GENERAL, tr("Changed default microphone device to %s"), event.deviceId); - events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId}); + promise.then(() => { + /* TODO: + * This isn't needed since the defaultRecorder might already fire a device change event which will update our ui. + * We only have this since we can't ensure that the recorder does so. + */ + events.fire_react("notify_device_selected", { device: currentSelectedDevice() }); + logTrace(LogCategory.GENERAL, tr("Changed default microphone device to %s"), displayName); }).catch((error) => { - logWarn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), device ? device.deviceId : IDevice.NoDeviceId, error); - events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId}); + logWarn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), displayName, error); + events.fire_react("action_set_selected_device_result", {status: "error", reason: error || tr("lookup the console") }); }); }); + + events.on("notify_destroy", defaultRecorder.events.on("notify_device_changed", () => { + events.fire_react("notify_device_selected", { device: currentSelectedDevice() }); + })); } /* settings */ diff --git a/shared/js/ui/modal/settings/MicrophoneRenderer.tsx b/shared/js/ui/modal/settings/MicrophoneRenderer.tsx index 0311485f..c264d12b 100644 --- a/shared/js/ui/modal/settings/MicrophoneRenderer.tsx +++ b/shared/js/ui/modal/settings/MicrophoneRenderer.tsx @@ -3,7 +3,7 @@ import {useEffect, useRef, useState} from "react"; import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; import {Button} from "tc-shared/ui/react-elements/Button"; import {Registry} from "tc-shared/events"; -import {MicrophoneDevice, MicrophoneSettingsEvents} from "tc-shared/ui/modal/settings/Microphone"; +import {MicrophoneDevice, MicrophoneSettingsEvents, SelectedMicrophone} from "tc-shared/ui/modal/settings/Microphone"; import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {ClientIcon} from "svg-sprites/client-icons"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; @@ -43,22 +43,26 @@ type ActivityBarStatus = | { mode: "error", message: string } | { mode: "loading" } | { mode: "uninitialized" }; -const ActivityBar = (props: { events: Registry, deviceId: string, disabled?: boolean }) => { +const ActivityBar = (props: { events: Registry, deviceId: string | "none", disabled?: boolean }) => { const refHider = useRef(); - const [status, setStatus] = useState({mode: "loading"}); + const [status, setStatus] = useState({ mode: "loading" }); - if(typeof props.deviceId === "undefined") { throw "invalid device id"; } + if(typeof props.deviceId === "undefined") { + throw "invalid device id"; + } props.events.reactUse("notify_device_level", event => { if (event.status === "uninitialized") { - if (status.mode === "uninitialized") + if (status.mode === "uninitialized") { return; + } setStatus({mode: "uninitialized"}); } else if (event.status === "no-permissions") { const noPermissionsMessage = tr("no permissions"); - if (status.mode === "error" && status.message === noPermissionsMessage) + if (status.mode === "error" && status.message === noPermissionsMessage) { return; + } setStatus({mode: "error", message: noPermissionsMessage}); } else { @@ -73,10 +77,12 @@ const ActivityBar = (props: { events: Registry, device if (status.mode !== "success") { setStatus({mode: "success"}); } + refHider.current.style.width = (100 - device.level) + "%"; } else { - if (status.mode === "error" && status.message === device.error) + if (status.mode === "error" && status.message === device.error) { return; + } setStatus({mode: "error", message: device.error + ""}); } @@ -117,7 +123,7 @@ const Microphone = (props: { events: Registry, device:
-
{props.device.driver}
+
{props.device.driver + (props.device.default ? " (Default Device)" : "")}
{props.device.name}
@@ -167,7 +173,10 @@ const MicrophoneList = (props: { events: Registry }) = props.events.fire("query_devices"); return {type: "loading"}; }); - const [selectedDevice, setSelectedDevice] = useState<{ deviceId: string, mode: "selected" | "selecting" }>(); + const [selectedDevice, setSelectedDevice] = useState<{ + selectedDevice: SelectedMicrophone, + selectingDevice: SelectedMicrophone | undefined + }>(); const [deviceList, setDeviceList] = useState([]); props.events.reactUse("notify_devices", event => { @@ -176,7 +185,10 @@ const MicrophoneList = (props: { events: Registry }) = case "success": setDeviceList(event.devices.slice(0)); setState({type: "normal"}); - setSelectedDevice({mode: "selected", deviceId: event.selectedDevice}); + setSelectedDevice({ + selectedDevice: event.selectedDevice, + selectingDevice: undefined + }); break; case "error": @@ -194,16 +206,48 @@ const MicrophoneList = (props: { events: Registry }) = }); props.events.reactUse("action_set_selected_device", event => { - setSelectedDevice({mode: "selecting", deviceId: event.deviceId}); + setSelectedDevice({ + selectedDevice: selectedDevice?.selectedDevice, + selectingDevice: event.target + }); }); props.events.reactUse("action_set_selected_device_result", event => { - if (event.status === "error") - createErrorModal(tr("Failed to select microphone"), tra("Failed to select microphone:\n{}", event.error)).open(); - - setSelectedDevice({mode: "selected", deviceId: event.deviceId}); + if (event.status === "error") { + createErrorModal(tr("Failed to select microphone"), tra("Failed to select microphone:\n{}", event.reason)).open(); + setSelectedDevice({ + selectedDevice: selectedDevice?.selectedDevice, + selectingDevice: undefined + }); + } }); + props.events.reactUse("notify_device_selected", event => { + setSelectedDevice({ selectedDevice: event.device, selectingDevice: undefined }); + }) + + const deviceSelectState = (device: MicrophoneDevice | "none" | "default"): MicrophoneSelectedState => { + let selected: SelectedMicrophone; + let mode: MicrophoneSelectedState; + if(typeof selectedDevice?.selectingDevice !== "undefined") { + selected = selectedDevice.selectingDevice; + mode = "applying"; + } else if(typeof selectedDevice?.selectedDevice !== "undefined") { + selected = selectedDevice.selectedDevice; + mode = "selected"; + } else { + return "unselected"; + } + + if(selected.type === "default") { + return device === "default" || (typeof device === "object" && device.default) ? mode : "unselected"; + } else if(selected.type === "none") { + return device === "none" ? mode : "unselected"; + } else { + return typeof device === "object" && device.id === selected.deviceId ? mode : "unselected"; + } + } + return (
}) = - { - if (state.type !== "normal" || selectedDevice?.mode === "selecting") + if (state.type !== "normal" || selectedDevice?.selectingDevice) { return; + } - props.events.fire("action_set_selected_device", {deviceId: IDevice.NoDeviceId}); + props.events.fire("action_set_selected_device", { target: { type: "none" } }); }} /> - {deviceList.map(e => { - if (state.type !== "normal" || selectedDevice?.mode === "selecting") + if (state.type !== "normal" || selectedDevice?.selectingDevice) { return; + } - props.events.fire("action_set_selected_device", {deviceId: e.id}); + if(device.default) { + props.events.fire("action_set_selected_device", { target: { type: "default" } }); + } else { + props.events.fire("action_set_selected_device", { target: { type: "device", deviceId: device.id } }); + } }} />)}
@@ -509,30 +564,60 @@ const ThresholdSelector = (props: { events: Registry } return "loading"; }); - const [currentDevice, setCurrentDevice] = useState(undefined); - const [isActive, setActive] = useState(false); + const [currentDevice, setCurrentDevice] = useState<{ type: "none" } | { type: "device", deviceId: string }>({ type: "none" }); + const defaultDeviceId = useRef(); + const [isVadActive, setVadActive] = useState(false); + + const changeCurrentDevice = (selected: SelectedMicrophone) => { + switch (selected.type) { + case "none": + setCurrentDevice({ type: "none" }); + break; + + case "device": + setCurrentDevice({ type: "device", deviceId: selected.deviceId }); + break; + + case "default": + if(defaultDeviceId.current) { + setCurrentDevice({ type: "device", deviceId: defaultDeviceId.current }); + } else { + setCurrentDevice({ type: "none" }); + } + break; + + default: + throw tr("invalid device type"); + } + } props.events.reactUse("notify_setting", event => { if (event.setting === "threshold-threshold") { refSlider.current?.setState({value: event.value}); setValue(event.value); } else if (event.setting === "vad-type") { - setActive(event.value === "threshold"); + setVadActive(event.value === "threshold"); } }); props.events.reactUse("notify_devices", event => { - setCurrentDevice(event.selectedDevice); + if(event.status === "success") { + const defaultDevice = event.devices.find(device => device.default); + defaultDeviceId.current = defaultDevice?.id; + changeCurrentDevice(event.selectedDevice); + } else { + defaultDeviceId.current = undefined; + setCurrentDevice({ type: "none" }); + } }); - props.events.reactUse("action_set_selected_device_result", event => { - setCurrentDevice(event.deviceId); - }); + props.events.reactUse("notify_device_selected", event => changeCurrentDevice(event.device)); + let isActive = isVadActive && currentDevice.type === "device"; return (
- +
img { height: 100%; width: 100%; + + align-self: center; + + &.emoji { + width: 1.25em; + height: 1.25em; + } } } diff --git a/shared/js/ui/react-elements/ChatBox.tsx b/shared/js/ui/react-elements/ChatBox.tsx index 852cfe4c..8f3b2ec1 100644 --- a/shared/js/ui/react-elements/ChatBox.tsx +++ b/shared/js/ui/react-elements/ChatBox.tsx @@ -3,9 +3,12 @@ import {useEffect, useRef, useState} from "react"; import {Registry} from "tc-shared/events"; import '!style-loader!css-loader!emoji-mart/css/emoji-mart.css' -import {Picker} from 'emoji-mart' +import {Picker, emojiIndex} from 'emoji-mart' import {settings, Settings} from "tc-shared/settings"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; +import {getTwenmojiHashFromNativeEmoji} from "tc-shared/text/bbcode/EmojiUtil"; +import {BaseEmoji} from "emoji-mart"; +import {useGlobalSetting} from "tc-shared/ui/react-elements/Helper"; const cssStyle = require("./ChatBox.scss"); @@ -24,6 +27,18 @@ interface ChatBoxEvents { notify_typing: {} } +const LastUsedEmoji = () => { + const settingValue = useGlobalSetting(Settings.KEY_CHAT_LAST_USED_EMOJI); + const lastEmoji: BaseEmoji = (emojiIndex.emojis[settingValue] || emojiIndex.emojis["joy"]) as any; + if(!lastEmoji?.native) { + return {""}; + } + + return ( + {lastEmoji.native} + ) +} + const EmojiButton = (props: { events: Registry }) => { const [ shown, setShown ] = useState(false); const [ enabled, setEnabled ] = useState(false); @@ -56,7 +71,7 @@ const EmojiButton = (props: { events: Registry }) => { return (
enabled && setShown(true)}> - {""} +
{!shown ? undefined : @@ -72,6 +87,7 @@ const EmojiButton = (props: { events: Registry }) => { onSelect={(emoji: any) => { if(enabled) { + settings.setValue(Settings.KEY_CHAT_LAST_USED_EMOJI, emoji.id as string); props.events.fire("action_insert_text", { text: emoji.native, focus: true }); } }} @@ -352,13 +368,15 @@ export class ChatBox extends React.Component { } render() { - return
-
- - + return ( +
+
+ + +
+
- -
+ ) } componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any): void { diff --git a/shared/js/voice/RecorderProfile.ts b/shared/js/voice/RecorderProfile.ts index d0d2f2b4..adc1596f 100644 --- a/shared/js/voice/RecorderProfile.ts +++ b/shared/js/voice/RecorderProfile.ts @@ -9,6 +9,7 @@ import * as ppt from "tc-backend/ppt"; import {getRecorderBackend, IDevice} from "../audio/recorder"; import {FilterType, StateFilter, ThresholdFilter} from "../voice/Filter"; import { tr } from "tc-shared/i18n/localize"; +import {Registry} from "tc-shared/events"; export type VadType = "threshold" | "push_to_talk" | "active"; export interface RecorderProfileConfig { @@ -35,12 +36,25 @@ export interface RecorderProfileConfig { } } +export interface DefaultRecorderEvents { + notify_default_recorder_changed: {} +} + export let defaultRecorder: RecorderProfile; /* needs initialize */ +export const defaultRecorderEvents: Registry = new Registry(); + export function setDefaultRecorder(recorder: RecorderProfile) { defaultRecorder = recorder; + (window as any).defaultRecorder = defaultRecorder; + defaultRecorderEvents.fire("notify_default_recorder_changed"); +} + +export interface RecorderProfileEvents { + notify_device_changed: { }, } export class RecorderProfile { + readonly events: Registry; readonly name; readonly volatile; /* not saving profile */ @@ -66,6 +80,7 @@ export class RecorderProfile { } constructor(name: string, volatile?: boolean) { + this.events = new Registry(); this.name = name; this.volatile = typeof(volatile) === "boolean" ? volatile : false; @@ -95,6 +110,7 @@ export class RecorderProfile { /* TODO */ this.input?.destroy(); this.input = undefined; + this.events.destroy(); } async initialize() : Promise { @@ -109,7 +125,7 @@ export class RecorderProfile { /* default values */ this.config = { version: 1, - device_id: undefined, + device_id: IDevice.DefaultDeviceId, volume: 100, vad_threshold: { @@ -306,10 +322,22 @@ export class RecorderProfile { this.save(); } - getDeviceId() : string { return this.config.device_id; } - setDevice(device: IDevice | undefined) : Promise { - this.config.device_id = device ? device.deviceId : IDevice.NoDeviceId; + getDeviceId() : string | typeof IDevice.DefaultDeviceId | typeof IDevice.NoDeviceId { return this.config.device_id; } + setDevice(device: IDevice | typeof IDevice.DefaultDeviceId | typeof IDevice.NoDeviceId) : Promise { + let deviceId; + if(typeof device === "object") { + deviceId = device.deviceId; + } else { + deviceId = device; + } + + if(this.config.device_id === deviceId) { + return; + } + this.config.device_id = deviceId; + this.save(); + this.events.fire("notify_device_changed"); return this.input?.setDeviceId(this.config.device_id) || Promise.resolve(); } diff --git a/web/app/connection/ServerConnection.ts b/web/app/connection/ServerConnection.ts index 696b6b42..d959dfb0 100644 --- a/web/app/connection/ServerConnection.ts +++ b/web/app/connection/ServerConnection.ts @@ -536,4 +536,9 @@ export class ServerConnection extends AbstractServerConnection { getControlStatistics(): ConnectionStatistics { return this.socket?.getControlStatistics() || { bytesSend: 0, bytesReceived: 0 }; } + + getServerType(): "teaspeak" | "teamspeak" | "unknown" { + /* It's simple. Only TeaSpeak support web clients */ + return "teaspeak"; + } } \ No newline at end of file diff --git a/web/app/voice/Connection.ts b/web/app/voice/Connection.ts index f0118e20..d5441137 100644 --- a/web/app/voice/Connection.ts +++ b/web/app/voice/Connection.ts @@ -1,3 +1,4 @@ +import * as aplayer from "../audio/player"; import { AbstractVoiceConnection, VoiceConnectionStatus, @@ -15,7 +16,6 @@ import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-shared/ import {AbstractServerConnection, ConnectionStatistics} from "tc-shared/connection/ConnectionBase"; import {VoicePlayerState} from "tc-shared/voice/VoicePlayer"; import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log"; -import * as aplayer from "../audio/player"; import {tr} from "tc-shared/i18n/localize"; import {RtpVoiceClient} from "tc-backend/web/voice/VoiceClient"; import {InputConsumerType} from "tc-shared/voice/RecorderBase"; @@ -279,9 +279,9 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { } const client = new RtpVoiceClient(clientId); - this.voiceClients[clientId] = client; - this.voiceClients[clientId].setGloballyMuted(this.speakerMuted); + client.setGloballyMuted(this.speakerMuted); client.events.on("notify_state_changed", this.voiceClientStateChangedEventListener); + this.voiceClients[clientId] = client; return client; } @@ -414,8 +414,9 @@ export class RtpVoiceConnection extends AbstractVoiceConnection { } private setConnectionState(state: VoiceConnectionStatus) { - if(this.connectionState === state) + if(this.connectionState === state) { return; + } const oldState = this.connectionState; this.connectionState = state; diff --git a/web/app/voice/VoiceClient.ts b/web/app/voice/VoiceClient.ts index 45a27fa1..86328f8e 100644 --- a/web/app/voice/VoiceClient.ts +++ b/web/app/voice/VoiceClient.ts @@ -1,5 +1,8 @@ import {VoiceClient} from "tc-shared/voice/VoiceClient"; import {VoicePlayer} from "./VoicePlayer"; +import {LogCategory, logTrace} from "tc-shared/log"; +import {tr} from "tc-shared/i18n/localize"; +import {RemoteRTPAudioTrack} from "tc-shared/connection/rtc/RemoteTrack"; export class RtpVoiceClient extends VoicePlayer implements VoiceClient { private readonly clientId: number; diff --git a/web/app/voice/VoicePlayer.ts b/web/app/voice/VoicePlayer.ts index 2f1fec1d..2f233f73 100644 --- a/web/app/voice/VoicePlayer.ts +++ b/web/app/voice/VoicePlayer.ts @@ -1,12 +1,8 @@ -import { - VoicePlayerEvents, - VoicePlayerLatencySettings, - VoicePlayerState -} from "tc-shared/voice/VoicePlayer"; +import {VoicePlayerEvents, VoicePlayerLatencySettings, VoicePlayerState} from "tc-shared/voice/VoicePlayer"; import {Registry} from "tc-shared/events"; -import {LogCategory, logWarn} from "tc-shared/log"; +import {LogCategory, logTrace, logWarn} from "tc-shared/log"; import {RemoteRTPAudioTrack, RemoteRTPTrackState} from "tc-shared/connection/rtc/RemoteTrack"; -import { tr } from "tc-shared/i18n/localize"; +import {tr} from "tc-shared/i18n/localize"; export interface RtpVoicePlayerEvents { notify_state_changed: { oldState: VoicePlayerState, newState: VoicePlayerState }