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:
* **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

View File

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

View File

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

View File

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

View File

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

View File

@ -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" ]
},
];

View File

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

View File

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

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

View File

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

View File

@ -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> {

View File

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

View File

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

View File

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

View File

@ -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",
};

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 * 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;
}

View File

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

View File

@ -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 },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 */

View File

@ -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>&nbsp;<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}

View File

@ -75,6 +75,13 @@ html:root {
> img {
height: 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 '!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 {

View File

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

View File

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

View File

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

View File

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

View File

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