Merge pull request #171 from TeaSpeak/develop

Develop
master 477f4ee
WolverinDEV 2021-02-16 10:03:53 +01:00 committed by GitHub
commit 477f4eefea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 746 additions and 261 deletions

View File

@ -1,4 +1,13 @@
# Changelog: # 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** * **22.01.21**
- Allowing the user to easily change the channel name mode - Allowing the user to easily change the channel name mode
- Fixed channel name mode parsing - Fixed channel name mode parsing

View File

@ -138,6 +138,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "server manager init", name: "server manager init",
function: async () => { function: async () => {
server_connections = new ConnectionManager(); server_connections = new ConnectionManager();
(window as any).server_connections = server_connections;
}, },
priority: 80 priority: 80
}); });

View File

@ -58,6 +58,8 @@ export abstract class AbstractServerConnection {
abstract connected() : boolean; abstract connected() : boolean;
abstract disconnect(reason?: string) : Promise<void>; abstract disconnect(reason?: string) : Promise<void>;
abstract getServerType() : "teaspeak" | "teamspeak" | "unknown";
abstract getVoiceConnection() : AbstractVoiceConnection; abstract getVoiceConnection() : AbstractVoiceConnection;
abstract getVideoConnection() : VideoConnection; abstract getVideoConnection() : VideoConnection;

View File

@ -11,6 +11,7 @@ import {ErrorCode} from "tc-shared/connection/ErrorCode";
import {WhisperTarget} from "tc-shared/voice/VoiceWhisper"; import {WhisperTarget} from "tc-shared/voice/VoiceWhisper";
import {globalAudioContext} from "tc-backend/audio/player"; import {globalAudioContext} from "tc-backend/audio/player";
import {VideoBroadcastConfig, VideoBroadcastType} from "tc-shared/connection/VideoConnection"; import {VideoBroadcastConfig, VideoBroadcastType} from "tc-shared/connection/VideoConnection";
import {Settings, settings} from "tc-shared/settings";
const kSdpCompressionMode = 1; const kSdpCompressionMode = 1;
@ -372,9 +373,7 @@ class InternalRemoteRTPAudioTrack extends RemoteRTPAudioTrack {
if(state === 1) { if(state === 1) {
validateInfo(); validateInfo();
this.shouldReplay = true; this.shouldReplay = true;
if(this.gainNode) { this.updateGainNode();
this.gainNode.gain.value = this.gain;
}
this.setState(RemoteRTPTrackState.Started); this.setState(RemoteRTPTrackState.Started);
} else { } else {
/* There wil be no info present */ /* There wil be no info present */
@ -383,9 +382,7 @@ class InternalRemoteRTPAudioTrack extends RemoteRTPAudioTrack {
/* since we're might still having some jitter stuff */ /* since we're might still having some jitter stuff */
this.muteTimeout = setTimeout(() => { this.muteTimeout = setTimeout(() => {
this.shouldReplay = false; this.shouldReplay = false;
if(this.gainNode) { this.updateGainNode();
this.gainNode.gain.value = 0;
}
}, 1000); }, 1000);
} }
} }
@ -882,18 +879,23 @@ export class RTCConnection {
iceServers: [{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }] 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) { if(this.audioSupport) {
this.currentTransceiver["audio"] = this.peer.addTransceiver("audio"); this.currentTransceiver["audio"] = this.peer.addTransceiver("audio");
this.currentTransceiver["audio-whisper"] = this.peer.addTransceiver("audio"); this.currentTransceiver["audio-whisper"] = this.peer.addTransceiver("audio");
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 */ /* add some other transceivers for later use */
for(let i = 0; i < 8 && kAddGenericTransceiver; i++) { for(let i = 0; i < settings.getValue(Settings.KEY_RTC_EXTRA_AUDIO_CHANNELS); i++) {
const transceiver = this.peer.addTransceiver("audio"); this.peer.addTransceiver("audio", { direction: "recvonly" });
/* we only want to received on that and don't share any bandwidth limits */ }
transceiver.direction = "recvonly";
} }
} }
@ -901,10 +903,8 @@ export class RTCConnection {
this.currentTransceiver["video-screen"] = this.peer.addTransceiver("video"); this.currentTransceiver["video-screen"] = this.peer.addTransceiver("video");
/* add some other transceivers for later use */ /* add some other transceivers for later use */
for(let i = 0; i < 4 && kAddGenericTransceiver; i++) { for(let i = 0; i < settings.getValue(Settings.KEY_RTC_EXTRA_VIDEO_CHANNELS); i++) {
const transceiver = this.peer.addTransceiver("video"); this.peer.addTransceiver("video", { direction: "recvonly" });
/* we only want to received on that and don't share any bandwidth limits */
transceiver.direction = "recvonly";
} }
this.peer.onicecandidate = event => this.handleLocalIceCandidate(event.candidate); this.peer.onicecandidate = event => this.handleLocalIceCandidate(event.candidate);

View File

@ -65,7 +65,7 @@ export class RemoteRTPTrack {
} }
getSsrc() : number { getSsrc() : number {
return this.ssrc; return this.ssrc >>> 0;
} }
getTrack() : MediaStreamTrack { getTrack() : MediaStreamTrack {
@ -144,7 +144,20 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack {
this.htmlAudioNode.msRealTime = true; 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) { for(let key in this.htmlAudioNode) {
if(!key.startsWith("on")) { if(!key.startsWith("on")) {
continue; continue;
@ -153,7 +166,7 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack {
this.htmlAudioNode[key] = () => console.log("AudioElement %d: %s", this.getSsrc(), key); this.htmlAudioNode[key] = () => console.log("AudioElement %d: %s", this.getSsrc(), key);
this.htmlAudioNode.ontimeupdate = () => { this.htmlAudioNode.ontimeupdate = () => {
console.log("AudioElement %d: Time update. Current time: %d", this.getSsrc(), this.htmlAudioNode.currentTime, this.htmlAudioNode.buffered) 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(); const audioContext = globalAudioContext();
this.audioNode = audioContext.createMediaStreamSource(this.mediaStream); this.audioNode = audioContext.createMediaStreamSource(this.mediaStream);
this.gainNode = audioContext.createGain(); this.gainNode = audioContext.createGain();
this.updateGainNode();
this.gainNode.gain.value = this.shouldReplay ? this.gain : 0;
this.audioNode.connect(this.gainNode); this.audioNode.connect(this.gainNode);
this.gainNode.connect(audioContext.destination); this.gainNode.connect(audioContext.destination);
@ -195,10 +207,7 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack {
setGain(value: number) { setGain(value: number) {
this.gain = value; this.gain = value;
this.updateGainNode();
if(this.gainNode) {
this.gainNode.gain.value = this.shouldReplay ? this.gain : 0;
}
} }
/** /**
@ -209,4 +218,13 @@ export class RemoteRTPAudioTrack extends RemoteRTPTrack {
this.gainNode.gain.value = 0; 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);
}
} }

View File

@ -28,7 +28,7 @@ export class SdpProcessor {
rate: 48000, rate: 48000,
encoding: 2, encoding: 2,
fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, usedtx: 1, stereo: 0, "sprop-stereo": 0 }, 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 // Opus Stereo/Opus Music
@ -37,7 +37,7 @@ export class SdpProcessor {
rate: 48000, rate: 48000,
encoding: 2, encoding: 2,
fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, usedtx: 1, stereo: 1, "sprop-stereo": 1 }, fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, usedtx: 1, stereo: 1, "sprop-stereo": 1 },
rtcpFb: [ "transport-cc" ] rtcpFb: [ "transport-cc", "nack", "goog-remb" ]
}, },
]; ];

View File

@ -18,7 +18,17 @@ async function requestDatabase() {
} else if(databaseMode === "opening" || databaseMode === "updating") { } else if(databaseMode === "opening" || databaseMode === "updating") {
await new Promise(resolve => databaseStateChangedCallbacks.push(resolve)); await new Promise(resolve => databaseStateChangedCallbacks.push(resolve));
} else if(databaseMode === "closed") { } else if(databaseMode === "closed") {
try {
await doOpenDatabase(false); 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) { 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") { if(databaseMode === "closed") {
databaseMode = "opening"; databaseMode = "opening";
fireDatabaseStateChanged(); fireDatabaseStateChanged();
@ -231,13 +246,8 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
priority: 0, priority: 0,
name: "Chat history setup", name: "Chat history setup",
function: async () => { function: async () => {
if(!('indexedDB' in window)) {
loader.critical_error(tr("Missing Indexed DB support"));
throw tr("Missing Indexed DB support");
}
try { try {
await doOpenDatabase(false); await requestDatabase();
logDebug(LogCategory.CHAT, tr("Successfully initialized private conversation history database")); logDebug(LogCategory.CHAT, tr("Successfully initialized private conversation history database"));
} catch (error) { } catch (error) {
logError(LogCategory.CHAT, tr("Failed to initialize private conversation history database: %o"), 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); const storeName = clientUniqueId2StoreName(clientUniqueId);
await requestDatabase(); await requestDatabase();
if(!currentDatabase.objectStoreNames.contains(storeName)) if(!currentDatabase.objectStoreNames.contains(storeName)) {
return { events: [], hasMore: false }; return { events: [], hasMore: false };
}
const transaction = currentDatabase.transaction(storeName, "readonly"); const transaction = currentDatabase.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName); const store = transaction.objectStore(storeName);

View File

@ -282,6 +282,20 @@ export class PrivateConversation extends AbstractChat<PrivateConversationEvents>
this.presentMessages = result.events.filter(e => e.type === "message"); this.presentMessages = result.events.filter(e => e.type === "message");
this.setHistory(!!result.hasMore); 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"); this.setCurrentMode("normal");
}); });
} }

View File

@ -298,7 +298,8 @@ export class Registry<Events extends EventMap<Events> = EventMap<any>> implement
} }
} }
for(const handler of this.persistentEventHandler[event.type] || []) { const handlers = [...(this.persistentEventHandler[event.type] || [])];
for(const handler of handlers) {
handler(event); handler(event);
} }

View File

@ -558,8 +558,9 @@ export class FileManager {
"proto": 1 "proto": 1
}, {process_result: false}); }, {process_result: false});
if(transfer.transferState() === FileTransferState.INITIALIZING) if(transfer.transferState() === FileTransferState.INITIALIZING) {
throw tr("missing transfer start notify"); throw tr("missing transfer start notify");
}
} catch (error) { } catch (error) {
transfer.setFailed({ transfer.setFailed({
@ -620,8 +621,9 @@ export class FileManager {
transfer: transfer, transfer: transfer,
executeCallback: async () => { executeCallback: async () => {
await callbackInitialize(transfer); /* noexcept */ await callbackInitialize(transfer); /* noexcept */
if(transfer.transferState() !== FileTransferState.CONNECTING) if(transfer.transferState() !== FileTransferState.CONNECTING) {
return; return;
}
try { try {
const provider = TransferProvider.provider(); const provider = TransferProvider.provider();
@ -633,12 +635,13 @@ export class FileManager {
return; return;
} }
if(transfer instanceof FileDownloadTransfer) if(transfer instanceof FileDownloadTransfer) {
provider.executeFileDownload(transfer); provider.executeFileDownload(transfer);
else if(transfer instanceof FileUploadTransfer) } else if(transfer instanceof FileUploadTransfer) {
provider.executeFileUpload(transfer); provider.executeFileUpload(transfer);
else } else {
throw tr("unknown transfer type"); throw tr("unknown transfer type");
}
} catch (error) { } catch (error) {
const message = typeof error === "string" ? error : error instanceof Error ? error.message : tr("Unknown error"); const message = typeof error === "string" ? error : error instanceof Error ? error.message : tr("Unknown error");
transfer.setFailed({ transfer.setFailed({
@ -651,7 +654,7 @@ export class FileManager {
finishPromise: new Promise(resolve => { finishPromise: new Promise(resolve => {
const unregisterTransfer = () => { const unregisterTransfer = () => {
transfer.events.off("notify_state_updated", stateListener); 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); const index = this.registeredTransfers_.findIndex(e => e.transfer === transfer);
if(index === -1) { if(index === -1) {
@ -681,6 +684,9 @@ export class FileManager {
} else { } else {
logWarn(LogCategory.FILE_TRANSFER, tra("File transfer finished callback called with invalid transfer state ({0})", FileTransferState[state])); 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 = () => { 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("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() { private scheduleTransferUpdate() {
if(this.scheduledTransferUpdate) if(this.scheduledTransferUpdate) {
return; return;
}
this.scheduledTransferUpdate = setTimeout(() => { this.scheduledTransferUpdate = setTimeout(() => {
this.scheduledTransferUpdate = undefined; this.scheduledTransferUpdate = undefined;

View File

@ -113,8 +113,6 @@ export enum FileTransferDirection {
export interface FileTransferEvents { export interface FileTransferEvents {
"notify_state_updated": { oldState: FileTransferState, newState: FileTransferState }, "notify_state_updated": { oldState: FileTransferState, newState: FileTransferState },
"notify_progress": { progress: TransferProgress }, "notify_progress": { progress: TransferProgress },
"action_request_cancel": { reason: CancelReason },
"notify_transfer_canceled": {} "notify_transfer_canceled": {}
} }
@ -239,9 +237,14 @@ export class FileTransfer {
this.setTransferState(FileTransferState.PENDING); this.setTransferState(FileTransferState.PENDING);
this.events = new Registry<FileTransferEvents>(); this.events = new Registry<FileTransferEvents>();
this.events.on("notify_transfer_canceled", () => { }
destroy() {
if(!this.isFinished()) {
this.setTransferState(FileTransferState.CANCELED); this.setTransferState(FileTransferState.CANCELED);
}); }
this.events.destroy();
} }
isRunning() { isRunning() {
@ -253,7 +256,7 @@ export class FileTransfer {
} }
isFinished() { 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() { transferState() {
@ -297,16 +300,19 @@ export class FileTransfer {
} }
requestCancel(reason: CancelReason) { requestCancel(reason: CancelReason) {
if(this.isFinished()) if(this.isFinished()) {
throw tr("invalid transfer state"); throw tr("invalid transfer state");
}
this.cancelReason = reason; this.cancelReason = reason;
this.events.fire("action_request_cancel"); this.events.fire("notify_transfer_canceled");
this.setTransferState(FileTransferState.CANCELED);
} }
setTransferState(newState: FileTransferState) { setTransferState(newState: FileTransferState) {
if(this.transferState_ === newState) if(this.transferState_ === newState) {
return; return;
}
const newIsFinishedState = newState === FileTransferState.CANCELED || newState === FileTransferState.ERRORED || newState === FileTransferState.FINISHED; const newIsFinishedState = newState === FileTransferState.CANCELED || newState === FileTransferState.ERRORED || newState === FileTransferState.FINISHED;
try { try {
@ -335,8 +341,9 @@ export class FileTransfer {
case FileTransferState.FINISHED: case FileTransferState.FINISHED:
case FileTransferState.CANCELED: case FileTransferState.CANCELED:
case FileTransferState.ERRORED: case FileTransferState.ERRORED:
if(this.isFinished()) if(this.isFinished()) {
throw void 0; throw void 0;
}
this.timings.timestampEnd = Date.now(); this.timings.timestampEnd = Date.now();
break; break;
} }
@ -358,7 +365,6 @@ export class FileTransfer {
} }
} catch (e) { } catch (e) {
throw "invalid transfer state transform from " + this.transferState_ + " to " + newState; throw "invalid transfer state transform from " + this.transferState_ + " to " + newState;
return;
} }
const oldState = this.transferState_; const oldState = this.transferState_;
@ -368,7 +374,7 @@ export class FileTransfer {
updateProgress(progress: TransferProgress) { updateProgress(progress: TransferProgress) {
this.progress_ = progress; this.progress_ = progress;
this.events.fire_later("notify_progress", { progress: progress }); this.events.fire("notify_progress", { progress: progress });
} }
awaitFinished() : Promise<void> { awaitFinished() : Promise<void> {

View File

@ -73,10 +73,14 @@ async function initializeApp() {
aplayer.on_ready(() => aplayer.set_master_volume(settings.getValue(Settings.KEY_SOUND_MASTER) / 100)); aplayer.on_ready(() => aplayer.set_master_volume(settings.getValue(Settings.KEY_SOUND_MASTER) / 100));
setDefaultRecorder(new RecorderProfile("default")); const recorder = new RecorderProfile("default");
defaultRecorder.initialize().catch(error => { try {
await recorder.initialize();
} catch (error) {
/* TODO: Recover into a defined state? */
logError(LogCategory.AUDIO, tr("Failed to initialize default recorder: %o"), error); logError(LogCategory.AUDIO, tr("Failed to initialize default recorder: %o"), error);
}); }
setDefaultRecorder(recorder);
sound.initialize().then(() => { sound.initialize().then(() => {
logInfo(LogCategory.AUDIO, tr("Sounds initialized")); logInfo(LogCategory.AUDIO, tr("Sounds initialized"));

View File

@ -181,7 +181,7 @@ export class PermissionManager extends AbstractCommandHandler {
}[] = []; }[] = [];
initializedListener: ((initialized: boolean) => void)[] = []; initializedListener: ((initialized: boolean) => void)[] = [];
private _cacheNeededPermissions: any; private cacheNeededPermissions: any;
/* Static info mapping until TeaSpeak implements a detailed info */ /* Static info mapping until TeaSpeak implements a detailed info */
static readonly group_mapping: {name: string, deep: number}[] = [ static readonly group_mapping: {name: string, deep: number}[] = [
@ -280,7 +280,7 @@ export class PermissionManager extends AbstractCommandHandler {
delete this[key]; delete this[key];
this.initializedListener = undefined; this.initializedListener = undefined;
this._cacheNeededPermissions = undefined; this.cacheNeededPermissions = undefined;
} }
handle_command(command: ServerCommand): boolean { handle_command(command: ServerCommand): boolean {
@ -361,68 +361,95 @@ export class PermissionManager extends AbstractCommandHandler {
group.end(); group.end();
logInfo(LogCategory.PERMISSIONS, tr("Got %i permissions"), this.permissionList.length); logInfo(LogCategory.PERMISSIONS, tr("Got %i permissions"), this.permissionList.length);
if(this._cacheNeededPermissions) if(this.cacheNeededPermissions) {
this.onNeededPermissions(this._cacheNeededPermissions); this.onNeededPermissions(this.cacheNeededPermissions);
for(let listener of this.initializedListener) }
for(let listener of this.initializedListener) {
listener(true); listener(true);
} }
}
private onNeededPermissions(json) { private onNeededPermissions(json: any[]) {
if(this.permissionList.length == 0) { if(this.permissionList.length == 0) {
logWarn(LogCategory.PERMISSIONS, tr("Got needed permissions but don't have a permission list!")); logWarn(LogCategory.PERMISSIONS, tr("Got needed permissions but don't have a permission list!"));
this._cacheNeededPermissions = json; this.cacheNeededPermissions = json;
return; return;
} }
this._cacheNeededPermissions = undefined; this.cacheNeededPermissions = undefined;
let copy = this.neededPermissions.slice(); let permissionsCopy = this.neededPermissions.slice();
let addcount = 0; let permissionAddCount = 0;
let permissionRemoveCount = 0;
let group = log.group(log.LogType.TRACE, LogCategory.PERMISSIONS, tr("Got %d needed permissions."), json.length); 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; let entry: NeededPermissionValue = undefined;
for(let p of copy) { for(let permission of permissionsCopy) {
if(p.type.id == e["permid"]) { if(permission.type.id == notifyEntry["permid"]) {
entry = p; entry = permission;
copy.remove(p); permissionsCopy.remove(permission);
break; 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) { if(!entry) {
let info = this.resolveInfo(e["permid"]); let info = this.resolveInfo(notifyEntry["permid"]);
if(info) { if(info) {
entry = new NeededPermissionValue(info, -2); entry = new NeededPermissionValue(info, -2);
this.neededPermissions.push(entry); this.neededPermissions.push(entry);
} else { } 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; continue;
} }
addcount++; permissionAddCount++;
}
entry.value = permissionValue;
for(const listener of this.needed_permission_change_listener[entry.type.name] || []) {
listener();
} }
if(entry.value == parseInt(e["permvalue"])) continue; tableEntries.push({
entry.value = parseInt(e["permvalue"]);
for(const listener of this.needed_permission_change_listener[entry.type.name] || [])
listener();
table_entries.push({
"permission": entry.type.name, "permission": entry.type.name,
"value": entry.value "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(); group.end();
logDebug(LogCategory.PERMISSIONS, tr("Dropping %o needed permissions and added %o permissions."), copy.length, addcount); if(this.handle.serverConnection.getServerType() === "teamspeak" || json[0]["relative"] === "1") {
for(let e of copy) { /* We don't update the full list every time. Instead we're only propagating changes. */
e.value = -2; } else {
for(const listener of this.needed_permission_change_listener[e.type.name] || []) permissionRemoveCount = permissionsCopy.length;
for(let entry of permissionsCopy) {
entry.value = -2;
for(const listener of this.needed_permission_change_listener[entry.type.name] || []) {
listener(); listener();
} }
}
}
logDebug(LogCategory.PERMISSIONS, tr("Dropping %o needed permissions and added %o permissions."), permissionRemoveCount, permissionAddCount);
this.events.fire("client_permissions_changed"); this.events.fire("client_permissions_changed");
} }

View File

@ -156,7 +156,7 @@ if(!JSON.map_field_to) {
if (!Array.prototype.remove) { if (!Array.prototype.remove) {
Array.prototype.remove = function<T>(elem?: T): boolean { Array.prototype.remove = function<T>(elem?: T): boolean {
const index = this.indexOf(elem, 0); const index = this.indexOf(elem);
if (index > -1) { if (index > -1) {
this.splice(index, 1); this.splice(index, 1);
return true; return true;

View File

@ -79,7 +79,6 @@ function resolveKey<ValueType extends RegistryValueType, DefaultType>(
resolver: (key: string) => string | undefined, resolver: (key: string) => string | undefined,
defaultValue: DefaultType defaultValue: DefaultType
) : ValueType | DefaultType { ) : ValueType | DefaultType {
let value = resolver(key.key); let value = resolver(key.key);
if(typeof value === "string") { if(typeof value === "string") {
return decodeValueFromString(value, key.valueType); return decodeValueFromString(value, key.valueType);
@ -92,17 +91,14 @@ function resolveKey<ValueType extends RegistryValueType, DefaultType>(
continue; continue;
} }
if(!key.fallbackImports) { if(key.fallbackImports) {
break;
}
/* fallback key succeeded */
const fallbackValueImporter = key.fallbackImports[fallback]; const fallbackValueImporter = key.fallbackImports[fallback];
if(fallbackValueImporter) { if(fallbackValueImporter) {
return fallbackValueImporter(value); return fallbackValueImporter(value);
} }
}
break; return decodeValueFromString(value, key.valueType);
} }
return defaultValue; return defaultValue;
@ -499,6 +495,12 @@ export class Settings {
valueType: "string", valueType: "string",
}; };
static readonly KEY_CHAT_LAST_USED_EMOJI: ValuedRegistryKey<string> = {
key: "chat_last_used_emoji",
defaultValue: ":joy:",
valueType: "string",
};
static readonly KEY_SWITCH_INSTANT_CHAT: ValuedRegistryKey<boolean> = { static readonly KEY_SWITCH_INSTANT_CHAT: ValuedRegistryKey<boolean> = {
key: "switch_instant_chat", key: "switch_instant_chat",
defaultValue: true, defaultValue: true,
@ -595,10 +597,30 @@ export class Settings {
valueType: "boolean", valueType: "boolean",
}; };
static readonly KEY_RTC_EXTRA_VIDEO_CHANNELS: ValuedRegistryKey<number> = {
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<number> = {
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<boolean> = { static readonly KEY_RNNOISE_FILTER: ValuedRegistryKey<boolean> = {
key: "rnnoise_filter", key: "rnnoise_filter",
defaultValue: true, defaultValue: true,
description: "Enable the rnnoise filter for supressing background noise", description: "Enable the rnnoise filter for suppressing background noise",
valueType: "boolean", valueType: "boolean",
}; };

View File

@ -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
);
}

View File

@ -7,6 +7,7 @@ import ReactRenderer from "vendor/xbbcode/renderer/react";
import {Settings, settings} from "tc-shared/settings"; import {Settings, settings} from "tc-shared/settings";
import * as emojiRegex from "emoji-regex"; import * as emojiRegex from "emoji-regex";
import {getTwenmojiHashFromNativeEmoji} from "tc-shared/text/bbcode/EmojiUtil";
const emojiRegexInstance = (emojiRegex as any)() as RegExp; const emojiRegexInstance = (emojiRegex as any)() as RegExp;
@ -15,39 +16,11 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
function: async () => { function: async () => {
let reactId = 0; 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<TextElement, React.ReactNode> { rendererReact.setTextRenderer(new class extends ElementRenderer<TextElement, React.ReactNode> {
render(element: TextElement, renderer: ReactRenderer): React.ReactNode { 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(); return element.text();
}
let text = element.text(); let text = element.text();
emojiRegexInstance.lastIndex = 0; emojiRegexInstance.lastIndex = 0;
@ -59,13 +32,15 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
let match = emojiRegexInstance.exec(text); let match = emojiRegexInstance.exec(text);
const rawText = text.substring(lastIndex, match?.index); const rawText = text.substring(lastIndex, match?.index);
if(rawText) if(rawText) {
result.push(renderer.renderAsText(rawText, false)); result.push(renderer.renderAsText(rawText, false));
}
if(!match) if(!match) {
break; break;
}
let hash = grabTheRightIcon(match[0]); let hash = getTwenmojiHashFromNativeEmoji(match[0]);
result.push(<img key={"er-" + ++reactId} draggable={false} src={"https://twemoji.maxcdn.com/v/12.1.2/72x72/" + hash + ".png"} alt={match[0]} className={"chat-emoji"} />); result.push(<img key={"er-" + ++reactId} draggable={false} src={"https://twemoji.maxcdn.com/v/12.1.2/72x72/" + hash + ".png"} alt={match[0]} className={"chat-emoji"} />);
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }

View File

@ -27,6 +27,8 @@ import {VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/Vi
import {tr} from "tc-shared/i18n/localize"; import {tr} from "tc-shared/i18n/localize";
import {getVideoDriver} from "tc-shared/video/VideoSource"; import {getVideoDriver} from "tc-shared/video/VideoSource";
import {kLocalBroadcastChannels} from "tc-shared/ui/frames/video/Definitions"; 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 { class InfoController {
private readonly mode: ControlBarMode; private readonly mode: ControlBarMode;
@ -36,6 +38,7 @@ class InfoController {
private globalEvents: (() => void)[] = []; private globalEvents: (() => void)[] = [];
private globalHandlerRegisteredEvents: {[key: string]: (() => void)[]} = {}; private globalHandlerRegisteredEvents: {[key: string]: (() => void)[]} = {};
private handlerRegisteredEvents: (() => void)[] = []; private handlerRegisteredEvents: (() => void)[] = [];
private defaultRecorderListener: () => void;
constructor(events: Registry<ControlBarEvents>, mode: ControlBarMode) { constructor(events: Registry<ControlBarEvents>, mode: ControlBarMode) {
this.events = events; this.events = events;
@ -64,7 +67,13 @@ class InfoController {
this.sendVideoState("camera"); this.sendVideoState("camera");
})); }));
events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks())); 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") { if(this.mode === "main") {
events.push(server_connections.events().on("notify_active_handler_changed", event => this.setConnectionHandler(event.newHandler))); events.push(server_connections.events().on("notify_active_handler_changed", event => this.setConnectionHandler(event.newHandler)));
} }
@ -73,6 +82,8 @@ class InfoController {
} }
public destroy() { public destroy() {
this.unregisterDefaultRecorderEvents();
server_connections.getAllConnectionHandlers().forEach(handler => this.unregisterGlobalHandlerEvents(handler)); server_connections.getAllConnectionHandlers().forEach(handler => this.unregisterGlobalHandlerEvents(handler));
this.unregisterCurrentHandlerEvents(); this.unregisterCurrentHandlerEvents();
@ -80,6 +91,21 @@ class InfoController {
this.globalEvents = []; 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) { private registerGlobalHandlerEvents(handler: ConnectionHandler) {
const events = this.globalHandlerRegisteredEvents[handler.handlerId] = []; 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() { public sendSpeakerState() {
this.events.fire_react("notify_speaker_state", { this.events.fire_react("notify_speaker_state", {
enabled: !this.currentHandler?.isSpeakerMuted() enabled: !this.currentHandler?.isSpeakerMuted()
@ -303,10 +354,6 @@ export function initializePopoutControlBarController(events: Registry<ControlBar
infoHandler.setConnectionHandler(handler); infoHandler.setConnectionHandler(handler);
} }
export function initializeClientControlBarController(events: Registry<ControlBarEvents>) {
initializeControlBarController(events, "main");
}
export function initializeControlBarController(events: Registry<ControlBarEvents>, mode: ControlBarMode) : InfoController { export function initializeControlBarController(events: Registry<ControlBarEvents>, mode: ControlBarMode) : InfoController {
const infoHandler = new InfoController(events, mode); const infoHandler = new InfoController(events, mode);
infoHandler.initialize(); infoHandler.initialize();
@ -318,6 +365,7 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
events.on("query_bookmarks", () => infoHandler.sendBookmarks()); events.on("query_bookmarks", () => infoHandler.sendBookmarks());
events.on("query_away_state", () => infoHandler.sendAwayState()); events.on("query_away_state", () => infoHandler.sendAwayState());
events.on("query_microphone_state", () => infoHandler.sendMicrophoneState()); events.on("query_microphone_state", () => infoHandler.sendMicrophoneState());
events.on("query_microphone_list", () => infoHandler.sendMicrophoneList());
events.on("query_speaker_state", () => infoHandler.sendSpeakerState()); events.on("query_speaker_state", () => infoHandler.sendSpeakerState());
events.on("query_subscribe_state", () => infoHandler.sendSubscribeState()); events.on("query_subscribe_state", () => infoHandler.sendSubscribeState());
events.on("query_host_button", () => infoHandler.sendHostButton()); events.on("query_host_button", () => infoHandler.sendHostButton());
@ -373,10 +421,24 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
} }
}); });
events.on("action_toggle_microphone", event => { events.on("action_toggle_microphone", async event => {
/* change the default global setting */ /* change the default global setting */
settings.setValue(Settings.KEY_CLIENT_STATE_MICROPHONE_MUTED, !event.enabled); 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(); const current_connection_handler = infoHandler.getCurrentHandler();
if(current_connection_handler) { if(current_connection_handler) {
current_connection_handler.setMicrophoneMuted(!event.enabled); current_connection_handler.setMicrophoneMuted(!event.enabled);
@ -390,6 +452,10 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
} }
}); });
events.on("action_open_microphone_settings", () => {
global_client_actions.fire("action_open_window_settings", { defaultCategory: "audio-microphone" });
});
events.on("action_toggle_speaker", event => { events.on("action_toggle_speaker", event => {
/* change the default global setting */ /* change the default global setting */
settings.setValue(Settings.KEY_CLIENT_STATE_SPEAKER_MUTED, !event.enabled); settings.setValue(Settings.KEY_CLIENT_STATE_SPEAKER_MUTED, !event.enabled);

View File

@ -9,6 +9,7 @@ export type MicrophoneState = "enabled" | "disabled" | "muted";
export type VideoState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected"; export type VideoState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected";
export type HostButtonInfo = { title?: string, target?: string, url: string }; export type HostButtonInfo = { title?: string, target?: string, url: string };
export type VideoDeviceInfo = { name: string, id: string }; export type VideoDeviceInfo = { name: string, id: string };
export type MicrophoneDeviceInfo = { name: string, id: string, driver: string, selected: boolean };
export interface ControlBarEvents { export interface ControlBarEvents {
action_connection_connect: { newTab: boolean }, action_connection_connect: { newTab: boolean },
@ -17,19 +18,21 @@ export interface ControlBarEvents {
action_bookmark_manage: {}, action_bookmark_manage: {},
action_bookmark_add_current_server: {}, action_bookmark_add_current_server: {},
action_toggle_away: { away: boolean, globally: boolean, promptMessage?: boolean }, 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_speaker: { enabled: boolean },
action_toggle_subscribe: { subscribe: boolean }, action_toggle_subscribe: { subscribe: boolean },
action_toggle_query: { show: boolean }, action_toggle_query: { show: boolean },
action_query_manage: {}, action_query_manage: {},
action_toggle_video: { broadcastType: VideoBroadcastType, enable: boolean, quickStart?: boolean, deviceId?: string }, 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_mode: {},
query_connection_state: {}, query_connection_state: {},
query_bookmarks: {}, query_bookmarks: {},
query_away_state: {}, query_away_state: {},
query_microphone_state: {}, query_microphone_state: {},
query_microphone_list: {},
query_speaker_state: {}, query_speaker_state: {},
query_subscribe_state: {}, query_subscribe_state: {},
query_query_state: {}, query_query_state: {},
@ -42,6 +45,7 @@ export interface ControlBarEvents {
notify_bookmarks: { marks: Bookmark[] }, notify_bookmarks: { marks: Bookmark[] },
notify_away_state: { state: AwayState }, notify_away_state: { state: AwayState },
notify_microphone_state: { state: MicrophoneState }, notify_microphone_state: { state: MicrophoneState },
notify_microphone_list: { devices: MicrophoneDeviceInfo[] },
notify_speaker_state: { enabled: boolean }, notify_speaker_state: { enabled: boolean },
notify_subscribe_state: { subscribe: boolean }, notify_subscribe_state: { subscribe: boolean },
notify_query_state: { shown: boolean }, notify_query_state: { shown: boolean },

View File

@ -5,7 +5,7 @@ import {
ConnectionState, ConnectionState,
ControlBarEvents, ControlBarEvents,
ControlBarMode, ControlBarMode,
HostButtonInfo, HostButtonInfo, MicrophoneDeviceInfo,
MicrophoneState, MicrophoneState,
VideoDeviceInfo, VideoDeviceInfo,
VideoState VideoState
@ -316,17 +316,108 @@ const MicrophoneButton = () => {
events.on("notify_microphone_state", event => setState(event.state)); events.on("notify_microphone_state", event => setState(event.state));
if(state === "muted") { if(state === "muted") {
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Unmute microphone")} return (
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"muted"} />; <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Unmute microphone")}
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"muted"}>
<DropdownEntry
icon={ClientIcon.InputMuted}
text={<Translatable>Unmute microphone</Translatable>}
onClick={() => events.fire("action_toggle_microphone", { enabled: true })}
/>
<DropdownEntry
icon={ClientIcon.Settings}
text={<Translatable>Open microphone settings</Translatable>}
onClick={() => events.fire("action_open_microphone_settings", {})}
/>
<MicrophoneDeviceList />
</Button>
);
} else if(state === "enabled") { } else if(state === "enabled") {
return <Button colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Mute microphone")} return (
onToggle={() => events.fire("action_toggle_microphone", { enabled: false })} key={"enabled"} />; <Button colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Mute microphone")}
onToggle={() => events.fire("action_toggle_microphone", { enabled: false })} key={"enabled"}>
<DropdownEntry
icon={ClientIcon.InputMuted}
text={<Translatable>Mute microphone</Translatable>}
onClick={() => events.fire("action_toggle_microphone", { enabled: false })}
/>
<DropdownEntry
icon={ClientIcon.Settings}
text={<Translatable>Open microphone settings</Translatable>}
onClick={() => events.fire("action_open_microphone_settings", {})}
/>
<MicrophoneDeviceList />
</Button>
);
} else { } else {
return <Button autoSwitch={false} iconNormal={ClientIcon.ActivateMicrophone} tooltip={tr("Enable your microphone on this server")} return (
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"disabled"} />; <Button autoSwitch={false} iconNormal={ClientIcon.ActivateMicrophone} tooltip={tr("Enable your microphone on this server")}
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"disabled"}>
<DropdownEntry
icon={ClientIcon.ActivateMicrophone}
text={<Translatable>Enable your microphone</Translatable>}
onClick={() => events.fire("action_toggle_microphone", { enabled: true })}
/>
<DropdownEntry
icon={ClientIcon.Settings}
text={<Translatable>Open microphone settings</Translatable>}
onClick={() => events.fire("action_open_microphone_settings", {})}
/>
<MicrophoneDeviceList />
</Button>
);
} }
} }
/* 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<MicrophoneDeviceInfo[]>(() => {
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 (
<>
<hr key={"hr"} />
{Object.values(devices).map(({ device }) => (
<DropdownEntry
text={device.name || tr("Unknown device name")}
key={"m-" + device.id}
icon={device.selected ? ClientIcon.Apply : undefined}
onClick={() => events.fire("action_toggle_microphone", { enabled: true, targetDeviceId: device.id })}
/>
))}
</>
);
});
const SpeakerButton = () => { const SpeakerButton = () => {
const events = useContext(Events); const events = useContext(Events);

View File

@ -619,7 +619,7 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
timestampRefSet = true; timestampRefSet = true;
} }
if(event.timestamp >= this.unreadTimestamp && !unreadSet) { if(event.timestamp > this.unreadTimestamp && !unreadSet) {
this.viewEntries.push(<UnreadEntry refDiv={this.refUnread} key={"u" + this.viewElementIndex++} />); this.viewEntries.push(<UnreadEntry refDiv={this.refUnread} key={"u" + this.viewElementIndex++} />);
unreadSet = true; unreadSet = true;
} }

View File

@ -1,5 +1,5 @@
import {createModal} from "../../ui/elements/Modal"; import {createModal} from "../../ui/elements/Modal";
import {LogCategory, logError} from "../../log"; import {getBackend} from "tc-shared/backend";
import {tr} from "tc-shared/i18n/localize"; import {tr} from "tc-shared/i18n/localize";
function format_date(date: number) { function format_date(date: number) {
@ -30,11 +30,7 @@ export function spawnAbout() {
connectModal.open(); connectModal.open();
if (__build.target !== "web") { if (__build.target !== "web") {
(window as any).native.client_version().then(version => { const version = getBackend("native").getVersionInfo();
connectModal.htmlTag.find(".version-client").text(version); connectModal.htmlTag.find(".version-client").text(version.version);
}).catch(error => {
logError(LogCategory.GENERAL, tr("Failed to load client version: %o"), error);
connectModal.htmlTag.find(".version-client").text("unknown");
});
} }
} }

View File

@ -972,7 +972,7 @@ function initializePermissionEditor(connection: ConnectionHandler, modalEvents:
if (error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) { if (error instanceof CommandResult && error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
events.fire("action_set_mode", { events.fire("action_set_mode", {
mode: "no-permissions", mode: "no-permissions",
failedPermission: connection.permissions.resolveInfo(parseInt(error.json["failed_permid"]))?.name || tr("unknwon") failedPermission: connection.permissions.getFailedPermission(error)
}); });
return; return;
} }

View File

@ -283,8 +283,8 @@ const PermissionEntryRow = (props: {
const [valueEditing, setValueEditing] = useState(false); const [valueEditing, setValueEditing] = useState(false);
const [valueApplying, setValueApplying] = useState(false); const [valueApplying, setValueApplying] = useState(false);
const [flagNegated, setFlagNegated] = useState(props.value.flagNegate); const [flagNegated, setFlagNegated] = useState(props.value.flagNegate || false);
const [flagSkip, setFlagSkip] = useState(props.value.flagSkip); const [flagSkip, setFlagSkip] = useState(props.value.flagSkip || false);
const [granted, setGranted] = useState(props.value.granted); const [granted, setGranted] = useState(props.value.granted);
const [forceGrantedUpdate, setForceGrantedUpdate] = useState(false); const [forceGrantedUpdate, setForceGrantedUpdate] = useState(false);

View File

@ -19,10 +19,24 @@ export type MicrophoneSetting =
export type MicrophoneDevice = { export type MicrophoneDevice = {
id: string, id: string,
name: 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 { export interface MicrophoneSettingsEvents {
"query_devices": { refresh_list: boolean }, "query_devices": { refresh_list: boolean },
"query_help": {}, "query_help": {},
@ -32,12 +46,12 @@ export interface MicrophoneSettingsEvents {
"action_help_click": {}, "action_help_click": {},
"action_request_permissions": {}, "action_request_permissions": {},
"action_set_selected_device": { deviceId: string }, "action_set_selected_device": { target: SelectedMicrophone },
"action_set_selected_device_result": { "action_set_selected_device_result": {
deviceId: string, /* on error it will contain the current selected device */ status: "success",
status: "success" | "error", } | {
status: "error",
error?: string reason: string
}, },
"action_set_setting": { "action_set_setting": {
@ -50,15 +64,8 @@ export interface MicrophoneSettingsEvents {
value: any; value: any;
} }
"notify_devices": { notify_devices: MicrophoneDevices,
status: "success" | "error" | "audio-not-initialized" | "no-permissions", notify_device_selected: { device: SelectedMicrophone },
error?: string,
shouldAsk?: boolean,
devices?: MicrophoneDevice[]
selectedDevice?: string;
},
notify_device_level: { notify_device_level: {
level: { level: {
@ -164,9 +171,22 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
/* device list */ /* device list */
{ {
const currentSelectedDevice = (): SelectedMicrophone => {
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 => { events.on("query_devices", event => {
if (!aplayer.initialized()) { if (!aplayer.initialized()) {
events.fire_react("notify_devices", {status: "audio-not-initialized"}); events.fire_react("notify_devices", {
status: "audio-not-initialized"
});
return; return;
} }
@ -180,46 +200,90 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
return; return;
case "uninitialized": case "uninitialized":
events.fire_react("notify_devices", {status: "audio-not-initialized"}); events.fire_react("notify_devices", {
status: "audio-not-initialized"
});
return; return;
} }
if (event.refresh_list && deviceList.isRefreshAvailable()) { if (event.refresh_list && deviceList.isRefreshAvailable()) {
/* will automatically trigger a device list changed event if something has changed */ /* will automatically trigger a device list changed event if something has changed */
deviceList.refresh().then(() => { deviceList.refresh().then(() => { });
});
} else { } else {
const devices = deviceList.getDevices(); const devices = deviceList.getDevices();
const defaultDeviceId = getRecorderBackend().getDeviceList().getDefaultDeviceId();
events.fire_react("notify_devices", { events.fire_react("notify_devices", {
status: "success", status: "success",
selectedDevice: defaultRecorder.getDeviceId(),
devices: devices.map(e => { 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 => { events.on("action_set_selected_device", event => {
const device = recorderBackend.getDeviceList().getDevices().find(e => e.deviceId === event.deviceId); let promise;
if (!device && event.deviceId !== IDevice.NoDeviceId) {
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", { events.fire_react("action_set_selected_device_result", {
status: "error", status: "error",
error: tr("Invalid device id"), reason: tr("Invalid device id"),
deviceId: defaultRecorder.getDeviceId()
}); });
return; return;
} }
defaultRecorder.setDevice(device).then(() => { displayName = target.deviceId;
logTrace(LogCategory.GENERAL, tr("Changed default microphone device to %s"), event.deviceId); promise = defaultRecorder.setDevice(device);
events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId}); break;
default:
events.fire_react("action_set_selected_device_result", {
status: "error",
reason: tr("Invalid device target"),
});
return;
}
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) => { }).catch((error) => {
logWarn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), device ? device.deviceId : IDevice.NoDeviceId, error); logWarn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), displayName, error);
events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId}); 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 */ /* settings */

View File

@ -3,7 +3,7 @@ import {useEffect, useRef, useState} from "react";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n"; import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {Button} from "tc-shared/ui/react-elements/Button"; import {Button} from "tc-shared/ui/react-elements/Button";
import {Registry} from "tc-shared/events"; 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 {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons"; import {ClientIcon} from "svg-sprites/client-icons";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
@ -43,22 +43,26 @@ type ActivityBarStatus =
| { mode: "error", message: string } | { mode: "error", message: string }
| { mode: "loading" } | { mode: "loading" }
| { mode: "uninitialized" }; | { mode: "uninitialized" };
const ActivityBar = (props: { events: Registry<MicrophoneSettingsEvents>, deviceId: string, disabled?: boolean }) => { const ActivityBar = (props: { events: Registry<MicrophoneSettingsEvents>, deviceId: string | "none", disabled?: boolean }) => {
const refHider = useRef<HTMLDivElement>(); const refHider = useRef<HTMLDivElement>();
const [status, setStatus] = useState<ActivityBarStatus>({mode: "loading"}); const [status, setStatus] = useState<ActivityBarStatus>({ 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 => { props.events.reactUse("notify_device_level", event => {
if (event.status === "uninitialized") { if (event.status === "uninitialized") {
if (status.mode === "uninitialized") if (status.mode === "uninitialized") {
return; return;
}
setStatus({mode: "uninitialized"}); setStatus({mode: "uninitialized"});
} else if (event.status === "no-permissions") { } else if (event.status === "no-permissions") {
const noPermissionsMessage = tr("no permissions"); const noPermissionsMessage = tr("no permissions");
if (status.mode === "error" && status.message === noPermissionsMessage) if (status.mode === "error" && status.message === noPermissionsMessage) {
return; return;
}
setStatus({mode: "error", message: noPermissionsMessage}); setStatus({mode: "error", message: noPermissionsMessage});
} else { } else {
@ -73,10 +77,12 @@ const ActivityBar = (props: { events: Registry<MicrophoneSettingsEvents>, device
if (status.mode !== "success") { if (status.mode !== "success") {
setStatus({mode: "success"}); setStatus({mode: "success"});
} }
refHider.current.style.width = (100 - device.level) + "%"; refHider.current.style.width = (100 - device.level) + "%";
} else { } else {
if (status.mode === "error" && status.message === device.error) if (status.mode === "error" && status.message === device.error) {
return; return;
}
setStatus({mode: "error", message: device.error + ""}); setStatus({mode: "error", message: device.error + ""});
} }
@ -117,7 +123,7 @@ const Microphone = (props: { events: Registry<MicrophoneSettingsEvents>, device:
<MicrophoneStatus state={props.state}/> <MicrophoneStatus state={props.state}/>
</div> </div>
<div className={cssStyle.containerName}> <div className={cssStyle.containerName}>
<div className={cssStyle.driver}>{props.device.driver}</div> <div className={cssStyle.driver}>{props.device.driver + (props.device.default ? " (Default Device)" : "")}</div>
<div className={cssStyle.name}>{props.device.name}</div> <div className={cssStyle.name}>{props.device.name}</div>
</div> </div>
<div className={cssStyle.containerActivity}> <div className={cssStyle.containerActivity}>
@ -167,7 +173,10 @@ const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) =
props.events.fire("query_devices"); props.events.fire("query_devices");
return {type: "loading"}; 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<MicrophoneDevice[]>([]); const [deviceList, setDeviceList] = useState<MicrophoneDevice[]>([]);
props.events.reactUse("notify_devices", event => { props.events.reactUse("notify_devices", event => {
@ -176,7 +185,10 @@ const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) =
case "success": case "success":
setDeviceList(event.devices.slice(0)); setDeviceList(event.devices.slice(0));
setState({type: "normal"}); setState({type: "normal"});
setSelectedDevice({mode: "selected", deviceId: event.selectedDevice}); setSelectedDevice({
selectedDevice: event.selectedDevice,
selectingDevice: undefined
});
break; break;
case "error": case "error":
@ -194,15 +206,47 @@ const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) =
}); });
props.events.reactUse("action_set_selected_device", event => { 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 => { props.events.reactUse("action_set_selected_device_result", event => {
if (event.status === "error") if (event.status === "error") {
createErrorModal(tr("Failed to select microphone"), tra("Failed to select microphone:\n{}", event.error)).open(); createErrorModal(tr("Failed to select microphone"), tra("Failed to select microphone:\n{}", event.reason)).open();
setSelectedDevice({
setSelectedDevice({mode: "selected", deviceId: event.deviceId}); 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 ( return (
<div className={cssStyle.body + " " + cssStyle.containerDevices}> <div className={cssStyle.body + " " + cssStyle.containerDevices}>
@ -232,28 +276,39 @@ const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) =
<div className={cssStyle.overlay + " " + (state.type !== "loading" ? cssStyle.hidden : undefined)}> <div className={cssStyle.overlay + " " + (state.type !== "loading" ? cssStyle.hidden : undefined)}>
<a><Translatable>Loading</Translatable>&nbsp;<LoadingDots/></a> <a><Translatable>Loading</Translatable>&nbsp;<LoadingDots/></a>
</div> </div>
<Microphone key={"d-default"} <Microphone key={"d-no-device"}
device={{id: IDevice.NoDeviceId, driver: tr("No device"), name: tr("No device")}} device={{
id: "none",
driver: tr("No device"),
name: tr("No device"),
default: false
}}
events={props.events} events={props.events}
state={IDevice.NoDeviceId === selectedDevice?.deviceId ? selectedDevice.mode === "selecting" ? "applying" : "selected" : "unselected"} state={deviceSelectState("none")}
onClick={() => { onClick={() => {
if (state.type !== "normal" || selectedDevice?.mode === "selecting") if (state.type !== "normal" || selectedDevice?.selectingDevice) {
return; return;
}
props.events.fire("action_set_selected_device", {deviceId: IDevice.NoDeviceId}); props.events.fire("action_set_selected_device", { target: { type: "none" } });
}} }}
/> />
{deviceList.map(e => <Microphone {deviceList.map(device => <Microphone
key={"d-" + e.id} key={"d-" + device.id}
device={e} device={device}
events={props.events} events={props.events}
state={e.id === selectedDevice?.deviceId ? selectedDevice.mode === "selecting" ? "applying" : "selected" : "unselected"} state={deviceSelectState(device)}
onClick={() => { onClick={() => {
if (state.type !== "normal" || selectedDevice?.mode === "selecting") if (state.type !== "normal" || selectedDevice?.selectingDevice) {
return; 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 } });
}
}} }}
/>)} />)}
</div> </div>
@ -509,30 +564,60 @@ const ThresholdSelector = (props: { events: Registry<MicrophoneSettingsEvents> }
return "loading"; return "loading";
}); });
const [currentDevice, setCurrentDevice] = useState(undefined); const [currentDevice, setCurrentDevice] = useState<{ type: "none" } | { type: "device", deviceId: string }>({ type: "none" });
const [isActive, setActive] = useState(false); const defaultDeviceId = useRef<string | undefined>();
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 => { props.events.reactUse("notify_setting", event => {
if (event.setting === "threshold-threshold") { if (event.setting === "threshold-threshold") {
refSlider.current?.setState({value: event.value}); refSlider.current?.setState({value: event.value});
setValue(event.value); setValue(event.value);
} else if (event.setting === "vad-type") { } else if (event.setting === "vad-type") {
setActive(event.value === "threshold"); setVadActive(event.value === "threshold");
} }
}); });
props.events.reactUse("notify_devices", event => { 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 => { props.events.reactUse("notify_device_selected", event => changeCurrentDevice(event.device));
setCurrentDevice(event.deviceId);
});
let isActive = isVadActive && currentDevice.type === "device";
return ( return (
<div className={cssStyle.containerSensitivity}> <div className={cssStyle.containerSensitivity}>
<div className={cssStyle.containerBar}> <div className={cssStyle.containerBar}>
<ActivityBar events={props.events} deviceId={currentDevice || "none"} disabled={!isActive || !currentDevice} key={"activity-" + currentDevice} /> <ActivityBar events={props.events} deviceId={currentDevice.type === "device" ? currentDevice.deviceId : "none"} disabled={!isActive || !currentDevice} key={"activity-" + currentDevice} />
</div> </div>
<Slider <Slider
ref={refSlider} ref={refSlider}

View File

@ -75,6 +75,13 @@ html:root {
> img { > img {
height: 100%; height: 100%;
width: 100%; width: 100%;
align-self: center;
&.emoji {
width: 1.25em;
height: 1.25em;
}
} }
} }

View File

@ -3,9 +3,12 @@ import {useEffect, useRef, useState} from "react";
import {Registry} from "tc-shared/events"; import {Registry} from "tc-shared/events";
import '!style-loader!css-loader!emoji-mart/css/emoji-mart.css' 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 {settings, Settings} from "tc-shared/settings";
import {Translatable} from "tc-shared/ui/react-elements/i18n"; 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"); const cssStyle = require("./ChatBox.scss");
@ -24,6 +27,18 @@ interface ChatBoxEvents {
notify_typing: {} 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 <img key={"fallback"} alt={""} src={"img/smiley-smile.svg"} />;
}
return (
<img draggable={false} src={"https://twemoji.maxcdn.com/v/12.1.2/72x72/" + getTwenmojiHashFromNativeEmoji(lastEmoji.native) + ".png"} alt={lastEmoji.native} className={cssStyle.emoji} />
)
}
const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => { const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
const [ enabled, setEnabled ] = useState(false); const [ enabled, setEnabled ] = useState(false);
@ -56,7 +71,7 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
return ( return (
<div className={cssStyle.containerEmojis} ref={refContainer}> <div className={cssStyle.containerEmojis} ref={refContainer}>
<div className={cssStyle.button} onClick={() => enabled && setShown(true)}> <div className={cssStyle.button} onClick={() => enabled && setShown(true)}>
<img alt={""} src={"img/smiley-smile.svg"} /> <LastUsedEmoji />
</div> </div>
<div className={cssStyle.picker} style={{ display: shown ? undefined : "none" }}> <div className={cssStyle.picker} style={{ display: shown ? undefined : "none" }}>
{!shown ? undefined : {!shown ? undefined :
@ -72,6 +87,7 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
onSelect={(emoji: any) => { onSelect={(emoji: any) => {
if(enabled) { 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 }); props.events.fire("action_insert_text", { text: emoji.native, focus: true });
} }
}} }}
@ -352,13 +368,15 @@ export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
} }
render() { render() {
return <div className={cssStyle.container + " " + this.props.className}> return (
<div className={cssStyle.container + " " + this.props.className}>
<div className={cssStyle.chatbox}> <div className={cssStyle.chatbox}>
<EmojiButton events={this.events} /> <EmojiButton events={this.events} />
<TextInput events={this.events} placeholder={tr("Type your message here...")} /> <TextInput events={this.events} placeholder={tr("Type your message here...")} />
</div> </div>
<MarkdownFormatHelper /> <MarkdownFormatHelper />
</div> </div>
)
} }
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void { componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {

View File

@ -9,6 +9,7 @@ import * as ppt from "tc-backend/ppt";
import {getRecorderBackend, IDevice} from "../audio/recorder"; import {getRecorderBackend, IDevice} from "../audio/recorder";
import {FilterType, StateFilter, ThresholdFilter} from "../voice/Filter"; import {FilterType, StateFilter, ThresholdFilter} from "../voice/Filter";
import { tr } from "tc-shared/i18n/localize"; import { tr } from "tc-shared/i18n/localize";
import {Registry} from "tc-shared/events";
export type VadType = "threshold" | "push_to_talk" | "active"; export type VadType = "threshold" | "push_to_talk" | "active";
export interface RecorderProfileConfig { export interface RecorderProfileConfig {
@ -35,12 +36,25 @@ export interface RecorderProfileConfig {
} }
} }
export interface DefaultRecorderEvents {
notify_default_recorder_changed: {}
}
export let defaultRecorder: RecorderProfile; /* needs initialize */ export let defaultRecorder: RecorderProfile; /* needs initialize */
export const defaultRecorderEvents: Registry<DefaultRecorderEvents> = new Registry<DefaultRecorderEvents>();
export function setDefaultRecorder(recorder: RecorderProfile) { export function setDefaultRecorder(recorder: RecorderProfile) {
defaultRecorder = recorder; defaultRecorder = recorder;
(window as any).defaultRecorder = defaultRecorder;
defaultRecorderEvents.fire("notify_default_recorder_changed");
}
export interface RecorderProfileEvents {
notify_device_changed: { },
} }
export class RecorderProfile { export class RecorderProfile {
readonly events: Registry<RecorderProfileEvents>;
readonly name; readonly name;
readonly volatile; /* not saving profile */ readonly volatile; /* not saving profile */
@ -66,6 +80,7 @@ export class RecorderProfile {
} }
constructor(name: string, volatile?: boolean) { constructor(name: string, volatile?: boolean) {
this.events = new Registry<RecorderProfileEvents>();
this.name = name; this.name = name;
this.volatile = typeof(volatile) === "boolean" ? volatile : false; this.volatile = typeof(volatile) === "boolean" ? volatile : false;
@ -95,6 +110,7 @@ export class RecorderProfile {
/* TODO */ /* TODO */
this.input?.destroy(); this.input?.destroy();
this.input = undefined; this.input = undefined;
this.events.destroy();
} }
async initialize() : Promise<void> { async initialize() : Promise<void> {
@ -109,7 +125,7 @@ export class RecorderProfile {
/* default values */ /* default values */
this.config = { this.config = {
version: 1, version: 1,
device_id: undefined, device_id: IDevice.DefaultDeviceId,
volume: 100, volume: 100,
vad_threshold: { vad_threshold: {
@ -306,10 +322,22 @@ export class RecorderProfile {
this.save(); this.save();
} }
getDeviceId() : string { return this.config.device_id; } getDeviceId() : string | typeof IDevice.DefaultDeviceId | typeof IDevice.NoDeviceId { return this.config.device_id; }
setDevice(device: IDevice | undefined) : Promise<void> { setDevice(device: IDevice | typeof IDevice.DefaultDeviceId | typeof IDevice.NoDeviceId) : Promise<void> {
this.config.device_id = device ? device.deviceId : IDevice.NoDeviceId; 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.save();
this.events.fire("notify_device_changed");
return this.input?.setDeviceId(this.config.device_id) || Promise.resolve(); return this.input?.setDeviceId(this.config.device_id) || Promise.resolve();
} }

View File

@ -536,4 +536,9 @@ export class ServerConnection extends AbstractServerConnection {
getControlStatistics(): ConnectionStatistics { getControlStatistics(): ConnectionStatistics {
return this.socket?.getControlStatistics() || { bytesSend: 0, bytesReceived: 0 }; return this.socket?.getControlStatistics() || { bytesSend: 0, bytesReceived: 0 };
} }
getServerType(): "teaspeak" | "teamspeak" | "unknown" {
/* It's simple. Only TeaSpeak support web clients */
return "teaspeak";
}
} }

View File

@ -1,3 +1,4 @@
import * as aplayer from "../audio/player";
import { import {
AbstractVoiceConnection, AbstractVoiceConnection,
VoiceConnectionStatus, VoiceConnectionStatus,
@ -15,7 +16,6 @@ import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-shared/
import {AbstractServerConnection, ConnectionStatistics} from "tc-shared/connection/ConnectionBase"; import {AbstractServerConnection, ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer"; import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log"; 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 {tr} from "tc-shared/i18n/localize";
import {RtpVoiceClient} from "tc-backend/web/voice/VoiceClient"; import {RtpVoiceClient} from "tc-backend/web/voice/VoiceClient";
import {InputConsumerType} from "tc-shared/voice/RecorderBase"; import {InputConsumerType} from "tc-shared/voice/RecorderBase";
@ -279,9 +279,9 @@ export class RtpVoiceConnection extends AbstractVoiceConnection {
} }
const client = new RtpVoiceClient(clientId); const client = new RtpVoiceClient(clientId);
this.voiceClients[clientId] = client; client.setGloballyMuted(this.speakerMuted);
this.voiceClients[clientId].setGloballyMuted(this.speakerMuted);
client.events.on("notify_state_changed", this.voiceClientStateChangedEventListener); client.events.on("notify_state_changed", this.voiceClientStateChangedEventListener);
this.voiceClients[clientId] = client;
return client; return client;
} }
@ -414,8 +414,9 @@ export class RtpVoiceConnection extends AbstractVoiceConnection {
} }
private setConnectionState(state: VoiceConnectionStatus) { private setConnectionState(state: VoiceConnectionStatus) {
if(this.connectionState === state) if(this.connectionState === state) {
return; return;
}
const oldState = this.connectionState; const oldState = this.connectionState;
this.connectionState = state; this.connectionState = state;

View File

@ -1,5 +1,8 @@
import {VoiceClient} from "tc-shared/voice/VoiceClient"; import {VoiceClient} from "tc-shared/voice/VoiceClient";
import {VoicePlayer} from "./VoicePlayer"; 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 { export class RtpVoiceClient extends VoicePlayer implements VoiceClient {
private readonly clientId: number; private readonly clientId: number;

View File

@ -1,12 +1,8 @@
import { import {VoicePlayerEvents, VoicePlayerLatencySettings, VoicePlayerState} from "tc-shared/voice/VoicePlayer";
VoicePlayerEvents,
VoicePlayerLatencySettings,
VoicePlayerState
} from "tc-shared/voice/VoicePlayer";
import {Registry} from "tc-shared/events"; 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 {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 { export interface RtpVoicePlayerEvents {
notify_state_changed: { oldState: VoicePlayerState, newState: VoicePlayerState } notify_state_changed: { oldState: VoicePlayerState, newState: VoicePlayerState }