commit
477f4eefea
|
@ -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
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -58,6 +58,8 @@ export abstract class AbstractServerConnection {
|
|||
abstract connected() : boolean;
|
||||
abstract disconnect(reason?: string) : Promise<void>;
|
||||
|
||||
abstract getServerType() : "teaspeak" | "teamspeak" | "unknown";
|
||||
|
||||
abstract getVoiceConnection() : AbstractVoiceConnection;
|
||||
abstract getVideoConnection() : VideoConnection;
|
||||
|
||||
|
|
|
@ -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");
|
||||
|
||||
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 < 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";
|
||||
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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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" ]
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -18,7 +18,17 @@ async function requestDatabase() {
|
|||
} else if(databaseMode === "opening" || databaseMode === "updating") {
|
||||
await new Promise(resolve => databaseStateChangedCallbacks.push(resolve));
|
||||
} else if(databaseMode === "closed") {
|
||||
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);
|
||||
|
|
|
@ -282,6 +282,20 @@ export class PrivateConversation extends AbstractChat<PrivateConversationEvents>
|
|||
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");
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<FileTransferEvents>();
|
||||
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<void> {
|
||||
|
|
|
@ -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"));
|
||||
|
|
|
@ -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;
|
||||
|
||||
for(const listener of this.needed_permission_change_listener[entry.type.name] || []) {
|
||||
listener();
|
||||
}
|
||||
|
||||
if(entry.value == parseInt(e["permvalue"])) continue;
|
||||
entry.value = parseInt(e["permvalue"]);
|
||||
|
||||
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] || [])
|
||||
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");
|
||||
}
|
||||
|
|
|
@ -156,7 +156,7 @@ if(!JSON.map_field_to) {
|
|||
|
||||
if (!Array.prototype.remove) {
|
||||
Array.prototype.remove = function<T>(elem?: T): boolean {
|
||||
const index = this.indexOf(elem, 0);
|
||||
const index = this.indexOf(elem);
|
||||
if (index > -1) {
|
||||
this.splice(index, 1);
|
||||
return true;
|
||||
|
|
|
@ -79,7 +79,6 @@ function resolveKey<ValueType extends RegistryValueType, DefaultType>(
|
|||
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<ValueType extends RegistryValueType, DefaultType>(
|
|||
continue;
|
||||
}
|
||||
|
||||
if(!key.fallbackImports) {
|
||||
break;
|
||||
}
|
||||
|
||||
/* fallback key succeeded */
|
||||
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<string> = {
|
||||
key: "chat_last_used_emoji",
|
||||
defaultValue: ":joy:",
|
||||
valueType: "string",
|
||||
};
|
||||
|
||||
static readonly KEY_SWITCH_INSTANT_CHAT: ValuedRegistryKey<boolean> = {
|
||||
key: "switch_instant_chat",
|
||||
defaultValue: true,
|
||||
|
@ -595,10 +597,30 @@ export class Settings {
|
|||
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> = {
|
||||
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",
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
|
@ -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<TextElement, 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();
|
||||
}
|
||||
|
||||
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(<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;
|
||||
}
|
||||
|
|
|
@ -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<ControlBarEvents>, 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<ControlBar
|
|||
infoHandler.setConnectionHandler(handler);
|
||||
}
|
||||
|
||||
export function initializeClientControlBarController(events: Registry<ControlBarEvents>) {
|
||||
initializeControlBarController(events, "main");
|
||||
}
|
||||
|
||||
export function initializeControlBarController(events: Registry<ControlBarEvents>, mode: ControlBarMode) : InfoController {
|
||||
const infoHandler = new InfoController(events, mode);
|
||||
infoHandler.initialize();
|
||||
|
@ -318,6 +365,7 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
|
|||
events.on("query_bookmarks", () => 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<ControlBarEvents
|
|||
}
|
||||
});
|
||||
|
||||
events.on("action_toggle_microphone", event => {
|
||||
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<ControlBarEvents
|
|||
}
|
||||
});
|
||||
|
||||
events.on("action_open_microphone_settings", () => {
|
||||
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);
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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 <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Unmute microphone")}
|
||||
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"muted"} />;
|
||||
return (
|
||||
<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") {
|
||||
return <Button colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Mute microphone")}
|
||||
onToggle={() => events.fire("action_toggle_microphone", { enabled: false })} key={"enabled"} />;
|
||||
return (
|
||||
<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 {
|
||||
return <Button autoSwitch={false} iconNormal={ClientIcon.ActivateMicrophone} tooltip={tr("Enable your microphone on this server")}
|
||||
onToggle={() => events.fire("action_toggle_microphone", { enabled: true })} key={"disabled"} />;
|
||||
return (
|
||||
<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 events = useContext(Events);
|
||||
|
||||
|
|
|
@ -619,7 +619,7 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
|
|||
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++} />);
|
||||
unreadSet = true;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<Micropho
|
|||
|
||||
/* 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 => {
|
||||
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<Micropho
|
|||
return;
|
||||
|
||||
case "uninitialized":
|
||||
events.fire_react("notify_devices", {status: "audio-not-initialized"});
|
||||
events.fire_react("notify_devices", {
|
||||
status: "audio-not-initialized"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.refresh_list && deviceList.isRefreshAvailable()) {
|
||||
/* will automatically trigger a device list changed event if something has changed */
|
||||
deviceList.refresh().then(() => {
|
||||
});
|
||||
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) {
|
||||
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",
|
||||
error: tr("Invalid device id"),
|
||||
deviceId: defaultRecorder.getDeviceId()
|
||||
reason: tr("Invalid device id"),
|
||||
});
|
||||
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});
|
||||
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;
|
||||
|
||||
}
|
||||
|
||||
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 */
|
||||
|
|
|
@ -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<MicrophoneSettingsEvents>, deviceId: string, disabled?: boolean }) => {
|
||||
const ActivityBar = (props: { events: Registry<MicrophoneSettingsEvents>, deviceId: string | "none", disabled?: boolean }) => {
|
||||
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 => {
|
||||
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<MicrophoneSettingsEvents>, 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<MicrophoneSettingsEvents>, device:
|
|||
<MicrophoneStatus state={props.state}/>
|
||||
</div>
|
||||
<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>
|
||||
<div className={cssStyle.containerActivity}>
|
||||
|
@ -167,7 +173,10 @@ const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) =
|
|||
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<MicrophoneDevice[]>([]);
|
||||
|
||||
props.events.reactUse("notify_devices", event => {
|
||||
|
@ -176,7 +185,10 @@ const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) =
|
|||
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,15 +206,47 @@ const MicrophoneList = (props: { events: Registry<MicrophoneSettingsEvents> }) =
|
|||
});
|
||||
|
||||
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 (
|
||||
<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)}>
|
||||
<a><Translatable>Loading</Translatable> <LoadingDots/></a>
|
||||
</div>
|
||||
<Microphone key={"d-default"}
|
||||
device={{id: IDevice.NoDeviceId, driver: tr("No device"), name: tr("No device")}}
|
||||
<Microphone key={"d-no-device"}
|
||||
device={{
|
||||
id: "none",
|
||||
driver: tr("No device"),
|
||||
name: tr("No device"),
|
||||
default: false
|
||||
}}
|
||||
events={props.events}
|
||||
state={IDevice.NoDeviceId === selectedDevice?.deviceId ? selectedDevice.mode === "selecting" ? "applying" : "selected" : "unselected"}
|
||||
state={deviceSelectState("none")}
|
||||
onClick={() => {
|
||||
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 => <Microphone
|
||||
key={"d-" + e.id}
|
||||
device={e}
|
||||
{deviceList.map(device => <Microphone
|
||||
key={"d-" + device.id}
|
||||
device={device}
|
||||
events={props.events}
|
||||
state={e.id === selectedDevice?.deviceId ? selectedDevice.mode === "selecting" ? "applying" : "selected" : "unselected"}
|
||||
state={deviceSelectState(device)}
|
||||
onClick={() => {
|
||||
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 } });
|
||||
}
|
||||
}}
|
||||
/>)}
|
||||
</div>
|
||||
|
@ -509,30 +564,60 @@ const ThresholdSelector = (props: { events: Registry<MicrophoneSettingsEvents> }
|
|||
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<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 => {
|
||||
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 (
|
||||
<div className={cssStyle.containerSensitivity}>
|
||||
<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>
|
||||
<Slider
|
||||
ref={refSlider}
|
||||
|
|
|
@ -75,6 +75,13 @@ html:root {
|
|||
> img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
align-self: center;
|
||||
|
||||
&.emoji {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 <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 [ shown, setShown ] = useState(false);
|
||||
const [ enabled, setEnabled ] = useState(false);
|
||||
|
@ -56,7 +71,7 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
|
|||
return (
|
||||
<div className={cssStyle.containerEmojis} ref={refContainer}>
|
||||
<div className={cssStyle.button} onClick={() => enabled && setShown(true)}>
|
||||
<img alt={""} src={"img/smiley-smile.svg"} />
|
||||
<LastUsedEmoji />
|
||||
</div>
|
||||
<div className={cssStyle.picker} style={{ display: shown ? undefined : "none" }}>
|
||||
{!shown ? undefined :
|
||||
|
@ -72,6 +87,7 @@ const EmojiButton = (props: { events: Registry<ChatBoxEvents> }) => {
|
|||
|
||||
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<ChatBoxProperties, ChatBoxState> {
|
|||
}
|
||||
|
||||
render() {
|
||||
return <div className={cssStyle.container + " " + this.props.className}>
|
||||
return (
|
||||
<div className={cssStyle.container + " " + this.props.className}>
|
||||
<div className={cssStyle.chatbox}>
|
||||
<EmojiButton events={this.events} />
|
||||
<TextInput events={this.events} placeholder={tr("Type your message here...")} />
|
||||
</div>
|
||||
<MarkdownFormatHelper />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {
|
||||
|
|
|
@ -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<DefaultRecorderEvents> = new Registry<DefaultRecorderEvents>();
|
||||
|
||||
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<RecorderProfileEvents>;
|
||||
readonly name;
|
||||
readonly volatile; /* not saving profile */
|
||||
|
||||
|
@ -66,6 +80,7 @@ export class RecorderProfile {
|
|||
}
|
||||
|
||||
constructor(name: string, volatile?: boolean) {
|
||||
this.events = new Registry<RecorderProfileEvents>();
|
||||
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<void> {
|
||||
|
@ -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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }
|
||||
|
|
Loading…
Reference in New Issue