Introduced Video to the web client. A lot of changes are still pending

canary
WolverinDEV 2020-11-07 13:16:07 +01:00
parent cde346a628
commit 658b44ed1d
90 changed files with 4766 additions and 439 deletions

View File

@ -1,4 +1,9 @@
# Changelog:
* **07.11.20**
- Added video broadcasting to the web client
- Added various new user interfaces related to video broadcasting
- Reworked the whole media transmission system (now using native audio en/decoding)
* **05.10.20**
- Reworked the top menu bar (now properly updates)
- Recoded the top menu bar renderer for the web client

11
package-lock.json generated
View File

@ -1535,6 +1535,12 @@
"integrity": "sha512-fsFfCxJt0C4DvAxdMR9JcnVY6FfAQrH8ia7NT0MStVbsgR73+a7XYFRhNqRHg2/FC2Sxfbg3ekuiFuY8eMOvMQ==",
"dev": true
},
"@types/sdp-transform": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/sdp-transform/-/sdp-transform-2.4.4.tgz",
"integrity": "sha512-cTpXVbNaN1YL++3mYArwlaujujeVe8ty66rbZPdFWx94fxM7jFjs5X621esca40yRe45htNdXROMIr2cgI+xGg==",
"dev": true
},
"@types/sha256": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@types/sha256/-/sha256-0.2.0.tgz",
@ -11643,6 +11649,11 @@
"resolved": "https://registry.npmjs.org/sdp/-/sdp-2.12.0.tgz",
"integrity": "sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw=="
},
"sdp-transform": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.0.tgz",
"integrity": "sha512-8ZYOau/o9PzRhY0aMuRzvmiM6/YVQR8yjnBScvZHSdBnywK5oZzAJK+412ZKkDq29naBmR3bRw8MFu0C01Gehg=="
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",

View File

@ -37,6 +37,7 @@
"@types/react-color": "^3.0.4",
"@types/react-dom": "^16.9.5",
"@types/remarkable": "^1.7.4",
"@types/sdp-transform": "^2.4.4",
"@types/sha256": "^0.2.0",
"@types/twemoji": "^12.1.1",
"@types/websocket": "0.0.40",
@ -105,6 +106,7 @@
"react-player": "^2.5.0",
"remarkable": "^2.0.1",
"resize-observer-polyfill": "^1.5.1",
"sdp-transform": "^2.14.0",
"simplebar-react": "^2.2.0",
"twemoji": "^13.0.0",
"webcrypto-liner": "^1.2.3",

View File

@ -15,8 +15,8 @@
</div>
</div>
<div class="container-connection-handlers" id="connection-handler-list"></div>
<div class="container-app-main">
<div class="container-channel-video" id="channel-video"></div>
<div class="container-channel-chat">
<!-- Channel tree -->
<div class="container-channel-tree">

View File

@ -0,0 +1,6 @@
<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.016149 0 0 .016149 -.16723 .74664)">
<path d="m535.92 431.52-398.04-398.04a25 25 0 00-35.355 0l-84.845 84.845a25 25 90 000 35.355l277.84 277.84a24.996 24.996 90.005 01-.003 35.353l-277.84 277.75a24.996 24.996 90.005 00-.0028 35.353l84.845 84.845a25.003 25.003.0033054 0035.357.002l398.04-397.95a24.997 24.997 90.003 00.002-35.353z" style="fill:#646568"/>
<path transform="translate(113.18)" d="m447.22 33.478-84.845 84.845a25 25 90 000 35.355l277.84 277.84a24.996 24.996 90.005 01-.003 35.353l-277.84 277.75a24.996 24.996 90.005 00-.003 35.353l84.845 84.845a25.003 25.003.0033054 0035.357.002l398.04-397.95a24.997 24.997 90.003 00.002-35.353l-398.04-398.04a25 25 0 00-35.355 0z" style="fill:#646568"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 817 B

View File

@ -10,7 +10,7 @@ import {createErrorModal, createInfoModal, createInputModal, Modal} from "./ui/e
import {hashPassword} from "./utils/helpers";
import {HandshakeHandler} from "./connection/HandshakeHandler";
import * as htmltags from "./ui/htmltags";
import {FilterMode, InputStartResult, InputState} from "./voice/RecorderBase";
import {FilterMode, InputState, MediaStreamRequestResult} from "./voice/RecorderBase";
import {CommandResult} from "./connection/ServerConnectionDeclaration";
import {defaultRecorder, RecorderProfile} from "./voice/RecorderProfile";
import {Frame} from "./ui/frames/chat_frame";
@ -37,6 +37,7 @@ import {ServerFeature, ServerFeatures} from "./connection/ServerFeatures";
import {ChannelTree} from "./tree/ChannelTree";
import {LocalClientEntry} from "./tree/Client";
import {ServerAddress} from "./tree/Server";
import {ChannelVideoFrame} from "tc-shared/ui/frames/video/Controller";
export enum InputHardwareState {
MISSING,
@ -142,6 +143,7 @@ export class ConnectionHandler {
groups: GroupManager;
side_bar: Frame;
video_frame: ChannelVideoFrame;
settings: ServerSettings;
sound: SoundManager;
@ -153,7 +155,7 @@ export class ConnectionHandler {
serverFeatures: ServerFeatures;
private _clientId: number = 0;
private _local_client: LocalClientEntry;
private localClient: LocalClientEntry;
private _reconnect_timer: number;
private _reconnect_attempt: boolean = false;
@ -192,7 +194,12 @@ export class ConnectionHandler {
this.setInputHardwareState(this.getVoiceRecorder() ? InputHardwareState.VALID : InputHardwareState.MISSING);
this.update_voice_status();
});
this.serverConnection.getVoiceConnection().events.on("notify_connection_status_changed", () => this.update_voice_status());
this.serverConnection.getVoiceConnection().events.on("notify_connection_status_changed", event => {
this.update_voice_status();
if(event.newStatus === VoiceConnectionStatus.Failed) {
createErrorModal(tr("Voice connection failed"), tra("Failed to establish a voice connection:\n{}", this.serverConnection.getVoiceConnection().getFailedMessage() || tr("Lookup the console for more detail"))).open();
}
});
this.serverConnection.getVoiceConnection().setWhisperSessionInitializer(this.initializeWhisperSession.bind(this));
this.serverFeatures = new ServerFeatures(this);
@ -203,13 +210,15 @@ export class ConnectionHandler {
this.permissions = new PermissionManager(this);
this.pluginCmdRegistry = new PluginCmdRegistry(this);
this.video_frame = new ChannelVideoFrame(this);
this.log = new ServerEventLog(this);
this.side_bar = new Frame(this);
this.sound = new SoundManager(this);
this.hostbanner = new Hostbanner(this);
this._local_client = new LocalClientEntry(this);
this.localClient = new LocalClientEntry(this);
this.localClient.channelTree = this.channelTree;
this.event_registry.register_handler(this);
this.events().fire("notify_handler_initialized");
@ -333,15 +342,15 @@ export class ConnectionHandler {
this.log.log(EventType.DISCONNECTED, {});
}
getClient() : LocalClientEntry { return this._local_client; }
getClient() : LocalClientEntry { return this.localClient; }
getClientId() { return this._clientId; }
initializeLocalClient(clientId: number, acceptedName: string) {
this._clientId = clientId;
this._local_client["_clientId"] = clientId;
this.localClient["_clientId"] = clientId;
this.channelTree.registerClient(this._local_client);
this._local_client.updateVariables( { key: "client_nickname", value: acceptedName });
this.channelTree.registerClient(this.localClient);
this.localClient.updateVariables( { key: "client_nickname", value: acceptedName });
}
getServerConnection() : AbstractServerConnection { return this.serverConnection; }
@ -631,7 +640,7 @@ export class ConnectionHandler {
break;
}
this.channelTree.unregisterClient(this._local_client); /* if we dont unregister our client here the client will be destroyed */
this.channelTree.unregisterClient(this.localClient); /* if we dont unregister our client here the client will be destroyed */
this.channelTree.reset();
if(this.serverConnection)
this.serverConnection.disconnect();
@ -679,7 +688,7 @@ export class ConnectionHandler {
}
private updateVoiceStatus() {
if(!this._local_client) {
if(!this.localClient) {
/* we've been destroyed */
return;
}
@ -832,7 +841,7 @@ export class ConnectionHandler {
if(input.currentState() === InputState.PAUSED && this.connection_state === ConnectionState.CONNECTED) {
try {
const result = await input.start();
if(result !== InputStartResult.EOK) {
if(result !== true) {
throw result;
}
@ -844,13 +853,13 @@ export class ConnectionHandler {
this.update_voice_status();
let errorMessage;
if(error === InputStartResult.ENOTSUPPORTED) {
if(error === MediaStreamRequestResult.ENOTSUPPORTED) {
errorMessage = tr("Your browser does not support voice recording");
} else if(error === InputStartResult.EBUSY) {
} else if(error === MediaStreamRequestResult.EBUSY) {
errorMessage = tr("The input device is busy");
} else if(error === InputStartResult.EDEVICEUNKNOWN) {
} else if(error === MediaStreamRequestResult.EDEVICEUNKNOWN) {
errorMessage = tr("Invalid input device");
} else if(error === InputStartResult.ENOTALLOWED) {
} else if(error === MediaStreamRequestResult.ENOTALLOWED) {
errorMessage = tr("No permissions");
} else if(error instanceof Error) {
errorMessage = error.message;
@ -993,15 +1002,22 @@ export class ConnectionHandler {
this.pluginCmdRegistry?.destroy();
this.pluginCmdRegistry = undefined;
if(this._local_client) {
const voiceHandle = this._local_client.getVoiceClient();
if(this.localClient) {
const voiceHandle = this.localClient.getVoiceClient();
if(voiceHandle) {
this._local_client.setVoiceClient(undefined);
logWarn(LogCategory.GENERAL, tr("Local voice client has received a voice handle. This should never happen!"));
this.localClient.setVoiceClient(undefined);
this.serverConnection.getVoiceConnection().unregisterVoiceClient(voiceHandle);
}
this._local_client.destroy();
const videoHandle = this.localClient.getVideoClient();
if(videoHandle) {
logWarn(LogCategory.GENERAL, tr("Local voice client has received a video handle. This should never happen!"));
this.localClient.setVoiceClient(undefined);
this.serverConnection.getVideoConnection().unregisterVideoClient(videoHandle);
}
this.localClient.destroy();
}
this._local_client = undefined;
this.localClient = undefined;
this.channelTree?.destroy();
this.channelTree = undefined;
@ -1033,7 +1049,7 @@ export class ConnectionHandler {
this.serverConnection = undefined;
this.sound = undefined;
this._local_client = undefined;
this.localClient = undefined;
}
/* state changing methods */

View File

@ -5,6 +5,22 @@ import {Stage} from "tc-loader";
export let server_connections: ConnectionManager;
class ReplaceableContainer {
placeholder: HTMLDivElement;
container: HTMLDivElement;
constructor(container: HTMLDivElement, placeholder?: HTMLDivElement) {
this.container = container;
this.placeholder = placeholder || document.createElement("div");
}
replaceWith(target: HTMLDivElement | undefined) {
target = target || this.placeholder;
this.container.replaceWith(target);
this.container = target;
}
}
export class ConnectionManager {
private readonly event_registry: Registry<ConnectionManagerEvents>;
private connection_handlers: ConnectionHandler[] = [];
@ -14,11 +30,13 @@ export class ConnectionManager {
private _container_channel_tree: JQuery;
private _container_hostbanner: JQuery;
private _container_chat: JQuery;
private containerChannelVideo: ReplaceableContainer;
constructor() {
this.event_registry = new Registry<ConnectionManagerEvents>();
this.event_registry.enableDebug("connection-manager");
this.containerChannelVideo = new ReplaceableContainer(document.getElementById("channel-video") as HTMLDivElement);
this._container_log_server = $("#server-log");
this._container_channel_tree = $("#channelTree");
this._container_hostbanner = $("#hostbanner");
@ -88,6 +106,7 @@ export class ConnectionManager {
this._container_chat.children().detach();
this._container_log_server.children().detach();
this._container_hostbanner.children().detach();
this.containerChannelVideo.replaceWith(handler?.video_frame.getContainer());
if(handler) {
this._container_hostbanner.append(handler.hostbanner.html_tag);

View File

@ -357,7 +357,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler {
handleCommandChannelListFinished() {
this.connection.client.channelTree.channelsInitialized = true;
this.connection.client.channelTree.events.fire_async("notify_channel_list_received");
this.connection.client.channelTree.events.fire_react("notify_channel_list_received");
if(this.batch_update_finished_timeout) {
clearTimeout(this.batch_update_finished_timeout);

View File

@ -6,6 +6,7 @@ import {ConnectionHandler, ConnectionState} from "../ConnectionHandler";
import {AbstractCommandHandlerBoss} from "../connection/AbstractCommandHandler";
import {Registry} from "../events";
import {AbstractVoiceConnection} from "../connection/VoiceConnection";
import {VideoConnection} from "tc-shared/connection/VideoConnection";
export interface CommandOptions {
flagset?: string[]; /* default: [] */
@ -48,6 +49,7 @@ export abstract class AbstractServerConnection {
abstract disconnect(reason?: string) : Promise<void>;
abstract getVoiceConnection() : AbstractVoiceConnection;
abstract getVideoConnection() : VideoConnection;
abstract command_handler_boss() : AbstractCommandHandlerBoss;
abstract send_command(command: string, data?: any | any[], options?: CommandOptions) : Promise<CommandResult>;

View File

@ -0,0 +1,61 @@
import {VideoSource} from "tc-shared/video/VideoSource";
import {Registry} from "tc-shared/events";
export type VideoBroadcastType = "camera" | "screen";
export interface VideoConnectionEvent {
notify_status_changed: { oldState: VideoConnectionStatus, newState: VideoConnectionStatus },
notify_local_broadcast_state_changed: { broadcastType: VideoBroadcastType, oldState: VideoBroadcastState, newState: VideoBroadcastState },
}
export enum VideoConnectionStatus {
/** We're currently not connected to the target server */
Disconnected,
/** We're trying to connect to the target server */
Connecting,
/** We're connected */
Connected,
/** The connection has failed for the current server connection */
Failed,
/** Video connection is not supported (the server dosn't support it) */
Unsupported
}
export enum VideoBroadcastState {
Initializing,
Running,
Stopped,
}
export interface VideoClientEvents {
notify_broadcast_state_changed: { broadcastType: VideoBroadcastType, oldState: VideoBroadcastState, newState: VideoBroadcastState }
}
export interface VideoClient {
getClientId() : number;
getEvents() : Registry<VideoClientEvents>;
getVideoState(broadcastType: VideoBroadcastType) : VideoBroadcastState;
getVideoStream(broadcastType: VideoBroadcastType) : MediaStream;
}
export interface VideoConnection {
getEvents() : Registry<VideoConnectionEvent>;
getStatus() : VideoConnectionStatus;
isBroadcasting(type: VideoBroadcastType);
getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined;
getBroadcastingState(type: VideoBroadcastType) : VideoBroadcastState;
/**
* @param type
* @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!)
*/
startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise<void>;
stopBroadcasting(type: VideoBroadcastType);
registerVideoClient(clientId: number);
registeredVideoClients() : VideoClient[];
unregisterVideoClient(client: VideoClient);
}

View File

@ -4,7 +4,6 @@ import {guid} from "./crypto/uid";
import * as React from "react";
import {useEffect} from "react";
import {unstable_batchedUpdates} from "react-dom";
import {ext} from "twemoji";
export interface Event<Events, T = keyof Events> {
readonly type: T;
@ -25,7 +24,23 @@ export class SingletonEvent implements Event<SingletonEvents, "singletone-instan
export interface EventReceiver<Events extends { [key: string]: any } = { [key: string]: any }> {
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
/**
* Fire an event later by using setTimeout(..)
* @param event_type The target event to be fired
* @param data The payload of the event
* @param callback The callback will be called after the event has been successfully dispatched
*/
fire_later<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
/**
* Fire an event, which will be delayed until the next animation frame.
* This ensures that all react components have been successfully mounted/unmounted.
* @param event_type The target event to be fired
* @param data The payload of the event
* @param callback The callback will be called after the event has been successfully dispatched
*/
fire_react<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
}
const event_annotation_key = guid();
@ -41,8 +56,11 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
private debugPrefix = undefined;
private warnUnhandledEvents = false;
private pendingCallbacks: { type: any, data: any }[] = [];
private pendingCallbacksTimeout: number = 0;
private pendingAsyncCallbacks: { type: any, data: any, callback: () => void }[];
private pendingAsyncCallbacksTimeout: number = 0;
private pendingReactCallbacks: { type: any, data: any, callback: () => void }[];
private pendingReactCallbacksFrame: number = 0;
constructor() {
this.registryUuid = "evreg_data_" + guid();
@ -131,9 +149,12 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
return;
}
const handlers = this.handler[event as any] || (this.handler[event as any] = []);
useEffect(() => {
handlers.push(handler);
return () => handlers.remove(handler);
return () => {
handlers.remove(handler);
};
}, reactEffectDependencies);
}
@ -204,25 +225,65 @@ export class Registry<Events extends { [key: string]: any } = { [key: string]: a
}
}
fire_async<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
if(!this.pendingCallbacksTimeout) {
this.pendingCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks());
this.pendingCallbacks = [];
fire_later<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
if(!this.pendingAsyncCallbacksTimeout) {
this.pendingAsyncCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks());
this.pendingAsyncCallbacks = [];
}
this.pendingCallbacks.push({ type: event_type, data: data });
this.pendingAsyncCallbacks.push({ type: event_type, data: data, callback: callback });
}
fire_react<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void) {
if(!this.pendingReactCallbacks) {
this.pendingReactCallbacksFrame = requestAnimationFrame(() => this.invokeReactCallbacks());
this.pendingReactCallbacks = [];
}
this.pendingReactCallbacks.push({ type: event_type, data: data, callback: callback });
}
private invokeAsyncCallbacks() {
const callbacks = this.pendingCallbacks;
this.pendingCallbacksTimeout = 0;
this.pendingCallbacks = undefined;
const callbacks = this.pendingAsyncCallbacks;
this.pendingAsyncCallbacksTimeout = 0;
this.pendingAsyncCallbacks = undefined;
unstable_batchedUpdates(() => {
let index = 0;
while(index < callbacks.length) {
this.fire(callbacks[index].type, callbacks[index].data);
index++;
let index = 0;
while(index < callbacks.length) {
this.fire(callbacks[index].type, callbacks[index].data);
try {
if(callbacks[index].callback) {
callbacks[index].callback();
}
} catch (error) {
console.error(error);
/* TODO: Improve error logging? */
}
index++;
}
}
private invokeReactCallbacks() {
const callbacks = this.pendingReactCallbacks;
this.pendingReactCallbacksFrame = 0;
this.pendingReactCallbacks = undefined;
/* run this after the requestAnimationFrame has been finished */
setTimeout(() => {
/* batch all react updates */
unstable_batchedUpdates(() => {
let index = 0;
while(index < callbacks.length) {
this.fire(callbacks[index].type, callbacks[index].data);
try {
if(callbacks[index].callback) {
callbacks[index].callback();
}
} catch (error) {
console.error(error);
/* TODO: Improve error logging? */
}
index++;
}
})
});
}

View File

@ -16,6 +16,8 @@ import {spawnGlobalSettingsEditor} from "tc-shared/ui/modal/global-settings-edit
import {spawnModalCssVariableEditor} from "tc-shared/ui/modal/css-editor/Controller";
import {server_connections} from "tc-shared/ConnectionManager";
import {spawnAbout} from "tc-shared/ui/modal/ModalAbout";
import {spawnVideoSourceSelectModal} from "tc-shared/ui/modal/video-source/Controller";
import {LogCategory, logError} from "tc-shared/log";
/*
function initialize_sounds(event_registry: Registry<ClientGlobalControlEvents>) {
@ -173,4 +175,31 @@ export function initialize(event_registry: Registry<ClientGlobalControlEvents>)
event_registry.on("action_open_window_permissions", event => {
spawnPermissionEditorModal(event.connection ? event.connection : server_connections.active_connection(), event.defaultTab);
});
event_registry.on("action_toggle_video_broadcasting", event => {
if(event.enabled) {
if(event.broadcastType === "camera") {
spawnVideoSourceSelectModal().then(source => {
if(!source) { return; }
try {
event.connection.getServerConnection().getVideoConnection().startBroadcasting("camera", source)
.catch(error => {
logError(LogCategory.VIDEO, tr("Failed to start video broadcasting: %o"), error);
if(typeof error !== "string") {
error = tr("lookup the console for detail");
}
createErrorModal(tr("Failed to start video broadcasting"), tra("Failed to start video broadcasting:\n{}", error)).open();
});
} finally {
}
});
} else {
/* TODO! */
}
} else {
event.connection.getServerConnection().getVideoConnection().stopBroadcasting(event.broadcastType);
}
});
}

View File

@ -1,5 +1,6 @@
import {ConnectionHandler} from "../ConnectionHandler";
import {Registry} from "../events";
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
export type PermissionEditorTab = "groups-server" | "groups-channel" | "channel" | "client" | "client-channel";
export interface ClientGlobalControlEvents {
@ -26,7 +27,8 @@ export interface ClientGlobalControlEvents {
} | {
videoUrl: string,
handlerId: string
}
},
action_toggle_video_broadcasting: { connection: ConnectionHandler, enabled: boolean, broadcastType: VideoBroadcastType }
/* some more specific window openings */
action_open_window_connect: {

View File

@ -684,7 +684,7 @@ export class FileManager {
const cancelListener = () => {
unregisterTransfer();
transfer.events.fire_async("notify_transfer_canceled", {}, resolve);
transfer.events.fire_later("notify_transfer_canceled", {}, resolve);
};
transfer.events.on("notify_state_updated", stateListener);

View File

@ -368,7 +368,7 @@ export class FileTransfer {
updateProgress(progress: TransferProgress) {
this.progress_ = progress;
this.events.fire_async("notify_progress", { progress: progress });
this.events.fire_later("notify_progress", { progress: progress });
}
awaitFinished() : Promise<void> {

View File

@ -20,7 +20,8 @@ export enum LogCategory {
DNS,
FILE_TRANSFER,
EVENT_REGISTRY,
WEBRTC
WEBRTC,
VIDEO
}
export enum LogType {
@ -51,6 +52,7 @@ let category_mapping = new Map<number, string>([
[LogCategory.FILE_TRANSFER, "File transfer "],
[LogCategory.EVENT_REGISTRY, "Event registry"],
[LogCategory.WEBRTC, "WebRTC "],
[LogCategory.VIDEO, "Video "],
]);
export let enabled_mapping = new Map<number, boolean>([
@ -73,6 +75,7 @@ export let enabled_mapping = new Map<number, boolean>([
[LogCategory.FILE_TRANSFER, true],
[LogCategory.EVENT_REGISTRY, true],
[LogCategory.WEBRTC, true],
[LogCategory.VIDEO, true],
]);
//Values will be overridden by initialize()

View File

@ -52,6 +52,8 @@ import {Registry} from "tc-shared/events";
import {ControlBarEvents} from "tc-shared/ui/frames/control-bar/Definitions";
import {ControlBar2} from "tc-shared/ui/frames/control-bar/Renderer";
import {initializeControlBarController} from "tc-shared/ui/frames/control-bar/Controller";
import {spawnVideoSourceSelectModal} from "tc-shared/ui/modal/video-source/Controller";
import {getVideoDriver} from "tc-shared/video/VideoSource";
let preventWelcomeUI = false;
async function initialize() {
@ -245,7 +247,17 @@ function main() {
/* schedule it a bit later then the main because the main function is still within the loader */
setTimeout(() => {
(window as any).spawnVideo = async () => {
const source = await spawnVideoSourceSelectModal();
if(!source) { return; }
await server_connections.active_connection().getServerConnection().getVideoConnection().startBroadcasting("camera", source);
};
(window as any).videoDriver = getVideoDriver();
const connection = server_connections.active_connection();
//(window as any).spawnVideo();
/*
Modals.createChannelModal(connection, undefined, undefined, connection.permissions, (cb, perms) => {

View File

@ -1,5 +1,6 @@
/* setup jsrenderer */
import "jsrender";
if(__build.target === "web") {
(window as any).$ = require("jquery");
(window as any).jQuery = $;
@ -64,6 +65,30 @@ declare global {
mozGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void;
webkitGetUserMedia(constraints: MediaStreamConstraints, successCallback: NavigatorUserMediaSuccessCallback, errorCallback: NavigatorUserMediaErrorCallback): void;
}
interface ObjectConstructor {
isSimilar(a: any, b: any): boolean;
}
}
if(!Object.isSimilar) {
Object.isSimilar = function (a, b) {
const aType = typeof a;
const bType = typeof b;
if (aType !== bType) {
return false;
}
if (aType === "object") {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if(aKeys.length != bKeys.length) { return false; }
if(aKeys.findIndex(key => bKeys.indexOf(key) !== -1) !== -1) { return false; }
return aKeys.findIndex(key => !Object.isSimilar(a[key], b[key])) === -1;
} else {
return a === b;
}
};
}
if(!JSON.map_to) {

View File

@ -505,6 +505,13 @@ export class Settings extends StaticSettings {
valueType: "boolean",
};
static readonly KEY_STOP_VIDEO_ON_SWITCH: ValuedSettingsKey<boolean> = {
key: 'stop_video_on_channel_switch',
defaultValue: true,
description: 'Stop video broadcasting on channel switch',
valueType: "boolean",
};
static readonly FN_LOG_ENABLED: (category: string) => SettingsKey<boolean> = category => {
return {
key: "log." + category.toLowerCase() + ".enabled",

View File

@ -1,8 +1,7 @@
import * as contextmenu from "tc-shared/ui/elements/ContextMenu";
import {MenuEntryType} from "tc-shared/ui/elements/ContextMenu";
import * as log from "tc-shared/log";
import {LogCategory, logWarn} from "tc-shared/log";
import {Settings, settings} from "tc-shared/settings";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {PermissionType} from "tc-shared/permission/PermissionType";
import {SpecialKey} from "tc-shared/PPTListener";
import {Sound} from "tc-shared/sound/Sounds";
@ -384,8 +383,8 @@ export class ChannelTree {
this.reset();
}
tag_tree() : JQuery {
return this.tagContainer;
tag_tree() : HTMLDivElement {
return this.tagContainer[0] as HTMLDivElement;
}
channelsOrdered() : ChannelEntry[] {
@ -598,33 +597,50 @@ export class ChannelTree {
logWarn(LogCategory.CHANNEL, tr("Deleting client %s from channel tree which hasn't a channel."), client.clientId());
}
const voice_connection = this.client.serverConnection.getVoiceConnection();
const voiceConnection = this.client.serverConnection.getVoiceConnection();
if(client.getVoiceClient()) {
const voiceClient = client.getVoiceClient();
voiceConnection.unregisterVoiceClient(client.getVoiceClient());
client.setVoiceClient(undefined);
}
if(!voice_connection) {
log.warn(LogCategory.VOICE, tr("Deleting client with a voice handle, but we haven't a voice connection!"));
} else {
voice_connection.unregisterVoiceClient(voiceClient);
}
const videoConnection = this.client.serverConnection.getVideoConnection();
if(client.getVideoClient()) {
videoConnection.unregisterVideoClient(client.getVideoClient());
client.setVideoClient(undefined);
}
client.destroy();
}
registerClient(client: ClientEntry) {
this.clients.push(client);
client.channelTree = this;
const voiceConnection = this.client.serverConnection.getVoiceConnection();
if(voiceConnection) {
client.setVoiceClient(voiceConnection.registerVoiceClient(client.clientId()));
if(client instanceof LocalClientEntry) {
if(client.channelTree !== this) {
throw tr("client channel tree missmatch");
}
} else {
client.channelTree = this;
const voiceConnection = this.client.serverConnection.getVoiceConnection();
try {
client.setVoiceClient(voiceConnection.registerVoiceClient(client.clientId()));
} catch (error) {
logError(LogCategory.AUDIO, tr("Failed to register a voice client for %d: %o"), client.clientId(), error);
}
const videoConnection = this.client.serverConnection.getVideoConnection();
try {
client.setVideoClient(videoConnection.registerVideoClient(client.clientId()));
} catch (error) {
logError(LogCategory.VIDEO, tr("Failed to register a video client for %d: %o"), client.clientId(), error);
}
}
}
unregisterClient(client: ClientEntry) {
if(!this.clients.remove(client))
if(!this.clients.remove(client)) {
return;
}
}
insertClient(client: ClientEntry, channel: ChannelEntry, reason: { reason: ViewReasonId, isServerJoin: boolean }) : ClientEntry {
@ -964,12 +980,18 @@ export class ChannelTree {
try {
this.selection.reset();
const voice_connection = this.client.serverConnection ? this.client.serverConnection.getVoiceConnection() : undefined;
const voiceConnection = this.client.serverConnection ? this.client.serverConnection.getVoiceConnection() : undefined;
const videoConnection = this.client.serverConnection ? this.client.serverConnection.getVideoConnection() : undefined;
for(const client of this.clients) {
if(client.getVoiceClient() && voice_connection) {
voice_connection.unregisterVoiceClient(client.getVoiceClient());
if(client.getVoiceClient() && videoConnection) {
voiceConnection.unregisterVoiceClient(client.getVoiceClient());
client.setVoiceClient(undefined);
}
if(client.getVideoClient()) {
videoConnection.unregisterVideoClient(client.getVideoClient());
client.setVideoClient(undefined);
}
client.destroy();
}
this.clients = [];

View File

@ -29,6 +29,7 @@ import {ClientIcon} from "svg-sprites/client-icons";
import {VoiceClient} from "../voice/VoiceClient";
import {VoicePlayerEvents, VoicePlayerState} from "../voice/VoicePlayer";
import {ChannelTreeUIEvents} from "tc-shared/ui/tree/Definitions";
import {VideoClient} from "tc-shared/connection/VideoConnection";
export enum ClientType {
CLIENT_VOICE,
@ -151,6 +152,8 @@ export interface ClientEvents extends ChannelTreeEntryEvents {
notify_audio_level_changed: { newValue: number },
notify_status_icon_changed: { newIcon: ClientIcon },
notify_video_handle_changed: { oldHandle: VideoClient | undefined, newHandle: VideoClient | undefined },
music_status_update: {
player_buffered_index: number,
player_replay_index: number
@ -193,6 +196,8 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
protected voiceMuted: boolean;
private readonly voiceCallbackStateChanged;
protected videoHandle: VideoClient;
private promiseClientInfo: Promise<void>;
private promiseClientInfoTimestamp: number;
@ -213,11 +218,11 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
this.voiceCallbackStateChanged = this.handleVoiceStateChange.bind(this);
this.events.on(["notify_speak_state_change", "notify_mute_state_change"], () => this.events.fire_async("notify_status_icon_changed", { newIcon: this.getStatusIcon() }));
this.events.on(["notify_speak_state_change", "notify_mute_state_change"], () => this.events.fire_later("notify_status_icon_changed", { newIcon: this.getStatusIcon() }));
this.events.on("notify_properties_updated", event => {
for (const key of StatusIconUpdateKeys) {
if (key in event.updated_properties) {
this.events.fire_async("notify_status_icon_changed", { newIcon: this.getStatusIcon() })
this.events.fire_later("notify_status_icon_changed", { newIcon: this.getStatusIcon() })
return;
}
}
@ -229,6 +234,10 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
log.error(LogCategory.AUDIO, tr("Destroying client with an active audio handle. This could cause memory leaks!"));
this.setVoiceClient(undefined);
}
if(this.videoHandle) {
log.error(LogCategory.AUDIO, tr("Destroying client with an active video handle. This could cause memory leaks!"));
this.setVideoClient(undefined);
}
this._channel = undefined;
this.events.destroy();
@ -249,6 +258,16 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
}
}
setVideoClient(handle: VideoClient) {
if(this.videoHandle === handle) {
return;
}
const oldHandle = this.videoHandle;
this.videoHandle = handle;
this.events.fire("notify_video_handle_changed", { oldHandle: oldHandle, newHandle: handle });
}
private handleVoiceStateChange(event: VoicePlayerEvents["notify_state_changed"]) {
switch (event.newState) {
case VoicePlayerState.PLAYING:
@ -274,6 +293,10 @@ export class ClientEntry extends ChannelTreeEntry<ClientEvents> {
return this.voiceHandle;
}
getVideoClient() : VideoClient {
return this.videoHandle;
}
get properties() : ClientProperties {
return this._properties;
}

View File

@ -25,38 +25,38 @@ function initializeController(events: Registry<ConnectionListUIEvents>) {
});
events.on("query_handler_list", () => {
events.fire_async("notify_handler_list", { handlerIds: server_connections.all_connections().map(e => e.handlerId), activeHandlerId: server_connections.active_connection()?.handlerId });
events.fire_react("notify_handler_list", { handlerIds: server_connections.all_connections().map(e => e.handlerId), activeHandlerId: server_connections.active_connection()?.handlerId });
});
events.on("notify_destroy", server_connections.events().on("notify_handler_created", event => {
let listeners = [];
const handlerId = event.handlerId;
listeners.push(event.handler.events().on("notify_connection_state_changed", () => events.fire_async("query_handler_status", { handlerId: handlerId })));
listeners.push(event.handler.events().on("notify_connection_state_changed", () => events.fire_react("query_handler_status", { handlerId: handlerId })));
/* register to icon and name change updates */
listeners.push(event.handler.channelTree.server.events.on("notify_properties_updated", event => {
if("virtualserver_name" in event.updated_properties || "virtualserver_icon_id" in event.updated_properties) {
events.fire_async("query_handler_status", { handlerId: handlerId });
events.fire_react("query_handler_status", { handlerId: handlerId });
}
}));
/* register to voice playback change events */
listeners.push(event.handler.getServerConnection().getVoiceConnection().events.on("notify_voice_replay_state_change", () => {
events.fire_async("query_handler_status", { handlerId: handlerId });
events.fire_react("query_handler_status", { handlerId: handlerId });
}));
registeredHandlerEvents[event.handlerId] = listeners;
events.fire_async("query_handler_list");
events.fire_react("query_handler_list");
}));
events.on("notify_destroy", server_connections.events().on("notify_handler_deleted", event => {
(registeredHandlerEvents[event.handlerId] || []).forEach(callback => callback());
delete registeredHandlerEvents[event.handlerId];
events.fire_async("query_handler_list");
events.fire_react("query_handler_list");
}));
events.on("notify_destroy", server_connections.events().on("notify_handler_order_changed", () => events.fire_async("query_handler_list")));
events.on("notify_destroy", server_connections.events().on("notify_handler_order_changed", () => events.fire_react("query_handler_list")));
events.on("action_swap_handler", event => {
const handlerA = server_connections.findConnection(event.handlerIdOne);
const handlerB = server_connections.findConnection(event.handlerIdTwo);
@ -80,7 +80,7 @@ function initializeController(events: Registry<ConnectionListUIEvents>) {
server_connections.set_active_connection(handler);
});
events.on("notify_destroy", server_connections.events().on("notify_active_handler_changed", event => {
events.fire_async("notify_active_handler", { handlerId: event.newHandlerId });
events.fire_react("notify_active_handler", { handlerId: event.newHandlerId });
}));
events.on("action_destroy_handler", event => {
@ -118,7 +118,7 @@ function initializeController(events: Registry<ConnectionListUIEvents>) {
break;
}
events.fire_async("notify_handler_status", {
events.fire_react("notify_handler_status", {
handlerId: event.handlerId,
status: {
handlerName: handler.channelTree.server.properties.virtualserver_name,

View File

@ -1,6 +1,7 @@
import * as React from "react";
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
import {DropdownContainer} from "./DropDown";
import {ClientIcon} from "svg-sprites/client-icons";
const cssStyle = require("./Button.scss");
export interface ButtonState {
@ -16,8 +17,8 @@ export interface ButtonProperties {
tooltip?: string;
iconNormal: string;
iconSwitched?: string;
iconNormal: string | ClientIcon;
iconSwitched?: string | ClientIcon;
onToggle?: (state: boolean) => boolean | void;

View File

@ -3,7 +3,8 @@ import {
Bookmark,
ControlBarEvents,
ControlBarMode,
HostButtonInfo
HostButtonInfo,
VideoCamaraState
} from "tc-shared/ui/frames/control-bar/Definitions";
import {server_connections} from "tc-shared/ConnectionManager";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
@ -11,7 +12,8 @@ import {Settings, settings} from "tc-shared/settings";
import {global_client_actions} from "tc-shared/events/GlobalEvents";
import {
add_server_to_bookmarks,
Bookmark as ServerBookmark, bookmarkEvents,
Bookmark as ServerBookmark,
bookmarkEvents,
bookmarks,
bookmarks_flat,
BookmarkType,
@ -19,7 +21,8 @@ import {
DirectoryBookmark
} from "tc-shared/bookmarks";
import {LogCategory, logWarn} from "tc-shared/log";
import {createInputModal} from "tc-shared/ui/elements/Modal";
import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal";
import {VideoBroadcastState, VideoConnectionStatus} from "tc-shared/connection/VideoConnection";
class InfoController {
private readonly mode: ControlBarMode;
@ -46,11 +49,13 @@ class InfoController {
this.registerGlobalHandlerEvents(event.handler);
this.sendConnectionState();
this.sendAwayState();
this.sendCamaraState();
}));
events.push(server_connections.events().on("notify_handler_deleted", event => {
this.unregisterGlobalHandlerEvents(event.handler);
this.sendConnectionState();
this.sendAwayState();
this.sendCamaraState();
}));
events.push(bookmarkEvents.on("notify_bookmarks_updated", () => this.sendBookmarks()));
@ -92,6 +97,7 @@ class InfoController {
events.push(handler.events().on("notify_connection_state_changed", event => {
if(event.old_state === ConnectionState.CONNECTED || event.new_state === ConnectionState.CONNECTED) {
this.sendHostButton();
this.sendCamaraState();
}
}));
@ -114,6 +120,11 @@ class InfoController {
this.sendSubscribeState();
}
}));
const videoConnection = handler.getServerConnection().getVideoConnection();
events.push(videoConnection.getEvents().on(["notify_local_broadcast_state_changed", "notify_status_changed"], () => {
this.sendCamaraState();
}));
}
private unregisterCurrentHandlerEvents() {
@ -138,6 +149,7 @@ class InfoController {
this.sendSubscribeState();
this.sendQueryState();
this.sendHostButton();
this.sendCamaraState();
}
public sendConnectionState() {
@ -145,7 +157,7 @@ class InfoController {
const locallyConnected = this.currentHandler?.connected;
const multisession = !settings.static_global(Settings.KEY_DISABLE_MULTI_SESSION);
this.events.fire_async("notify_connection_state", {
this.events.fire_react("notify_connection_state", {
state: {
currentlyConnected: locallyConnected,
generallyConnected: globallyConnected,
@ -171,7 +183,7 @@ class InfoController {
}
};
this.events.fire_async("notify_bookmarks", {
this.events.fire_react("notify_bookmarks", {
marks: bookmarks().content.map(buildInfo)
});
}
@ -180,7 +192,7 @@ class InfoController {
const globalAwayCount = server_connections.all_connections().filter(handler => handler.isAway()).length;
const awayLocally = !!this.currentHandler?.isAway();
this.events.fire_async("notify_away_state", {
this.events.fire_react("notify_away_state", {
state: {
globallyAway: globalAwayCount === server_connections.all_connections().length ? "full" : globalAwayCount > 0 ? "partial" : "none",
locallyAway: awayLocally
@ -189,25 +201,25 @@ class InfoController {
}
public sendMicrophoneState() {
this.events.fire_async("notify_microphone_state", {
this.events.fire_react("notify_microphone_state", {
state: this.currentHandler?.isMicrophoneDisabled() ? "disabled" : this.currentHandler?.isMicrophoneMuted() ? "muted" : "enabled"
});
}
public sendSpeakerState() {
this.events.fire_async("notify_speaker_state", {
this.events.fire_react("notify_speaker_state", {
enabled: !this.currentHandler?.isSpeakerMuted()
});
}
public sendSubscribeState() {
this.events.fire_async("notify_subscribe_state", {
this.events.fire_react("notify_subscribe_state", {
subscribe: !!this.currentHandler?.isSubscribeToAllChannels()
});
}
public sendQueryState() {
this.events.fire_async("notify_query_state", {
this.events.fire_react("notify_query_state", {
shown: !!this.currentHandler?.areQueriesShown()
});
}
@ -224,10 +236,32 @@ class InfoController {
} : undefined;
}
this.events.fire_async("notify_host_button", {
this.events.fire_react("notify_host_button", {
button: info
});
}
public sendCamaraState() {
let status: VideoCamaraState;
if(this.currentHandler?.connected) {
const videoConnection = this.currentHandler.getServerConnection().getVideoConnection();
if(videoConnection.getStatus() === VideoConnectionStatus.Connected) {
if(videoConnection.getBroadcastingState("camera") === VideoBroadcastState.Running) {
status = "enabled";
} else {
status = "disabled";
}
} else if(videoConnection.getStatus() === VideoConnectionStatus.Unsupported) {
status = "unsupported";
} else {
status = "unavailable";
}
} else {
status = "disconnected";
}
this.events.fire_react("notify_camara_state", { state: status });
}
}
export function initializePopoutControlBarController(events: Registry<ControlBarEvents>, handler: ConnectionHandler) {
@ -245,7 +279,7 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
events.on("notify_destroy", () => infoHandler.destroy());
events.on("query_mode", () => events.fire_async("notify_mode", { mode: infoHandler.getMode() }));
events.on("query_mode", () => events.fire_react("notify_mode", { mode: infoHandler.getMode() }));
events.on("query_connection_state", () => infoHandler.sendConnectionState());
events.on("query_bookmarks", () => infoHandler.sendBookmarks());
events.on("query_away_state", () => infoHandler.sendAwayState());
@ -253,6 +287,7 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
events.on("query_speaker_state", () => infoHandler.sendSpeakerState());
events.on("query_subscribe_state", () => infoHandler.sendSubscribeState());
events.on("query_host_button", () => infoHandler.sendHostButton());
events.on("query_camara_state", () => infoHandler.sendCamaraState());
events.on("action_connection_connect", event => global_client_actions.fire("action_open_window_connect", { newTab: event.newTab }));
events.on("action_connection_disconnect", event => {
@ -335,6 +370,13 @@ export function initializeControlBarController(events: Registry<ControlBarEvents
events.on("action_query_manage", () => {
global_client_actions.fire("action_open_window", { window: "query-manage" });
});
events.on("action_toggle_video", event => {
if(infoHandler.getCurrentHandler()) {
global_client_actions.fire("action_toggle_video_broadcasting", { connection: infoHandler.getCurrentHandler(), broadcastType: event.broadcastType, enabled: event.enable });
} else {
createErrorModal(tr("Missing connection handler"), tr("Cannot start video broadcasting with a missing connection handler")).open();
}
});
return infoHandler;
}

View File

@ -1,10 +1,12 @@
import {RemoteIconInfo} from "tc-shared/file/Icons";
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
export type ControlBarMode = "main" | "channel-popout";
export type ConnectionState = { currentlyConnected: boolean, generallyConnected: boolean, multisession: boolean };
export type Bookmark = { uniqueId: string, label: string, icon: RemoteIconInfo | undefined, children?: Bookmark[] };
export type AwayState = { locallyAway: boolean, globallyAway: "partial" | "full" | "none" };
export type MicrophoneState = "enabled" | "disabled" | "muted";
export type VideoCamaraState = "enabled" | "disabled" | "unavailable" | "unsupported" | "disconnected";
export type HostButtonInfo = { title?: string, target?: string, url: string };
export interface ControlBarEvents {
@ -19,6 +21,7 @@ export interface ControlBarEvents {
action_toggle_subscribe: { subscribe: boolean },
action_toggle_query: { show: boolean },
action_query_manage: {},
action_toggle_video: { broadcastType: VideoBroadcastType, enable: boolean }
query_mode: {},
query_connection_state: {},
@ -29,6 +32,7 @@ export interface ControlBarEvents {
query_subscribe_state: {},
query_query_state: {},
query_host_button: {},
query_camara_state: {},
notify_mode: { mode: ControlBarMode }
notify_connection_state: { state: ConnectionState },
@ -39,6 +43,7 @@ export interface ControlBarEvents {
notify_subscribe_state: { subscribe: boolean },
notify_query_state: { shown: boolean },
notify_host_button: { button: HostButtonInfo | undefined },
notify_camara_state: { state: VideoCamaraState },
notify_destroy: {}
}

View File

@ -2,9 +2,12 @@ import {Registry} from "tc-shared/events";
import {
AwayState,
Bookmark,
ControlBarEvents,
ConnectionState,
ControlBarMode, HostButtonInfo, MicrophoneState
ControlBarEvents,
ControlBarMode,
HostButtonInfo,
MicrophoneState,
VideoCamaraState
} from "tc-shared/ui/frames/control-bar/Definitions";
import * as React from "react";
import {useContext, useRef, useState} from "react";
@ -13,6 +16,7 @@ import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {Button} from "tc-shared/ui/frames/control-bar/Button";
import {spawnContextMenu} from "tc-shared/ui/ContextMenu";
import {ClientIcon} from "svg-sprites/client-icons";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
const cssStyle = require("./Renderer.scss");
const cssButtonStyle = require("./Button.scss");
@ -200,6 +204,39 @@ const AwayButton = () => {
);
};
const VideoButton = () => {
const events = useContext(Events);
const [ state, setState ] = useState<VideoCamaraState>(() => {
events.fire("query_camara_state");
return "unsupported";
});
events.on("notify_camara_state", event => setState(event.state));
switch (state) {
case "unsupported":
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted} tooltip={tr("Video broadcasting not supported")} key={"unsupported"}
onToggle={() => createErrorModal(tr("Video broadcasting unsupported"), tr("Video broadcasting isn't supported by the target server.")).open()}
/>;
case "unavailable":
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted} tooltip={tr("Video broadcasting not available")} key={"unavailable"}
onToggle={() => createErrorModal(tr("Video broadcasting unavailable"), tr("Video broadcasting isn't available right now.")).open()} />;
case "disconnected":
case "disabled":
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted}
onToggle={() => events.fire("action_toggle_video", { enable: true, broadcastType: "camera" })}
tooltip={tr("Start video broadcasting")} key={"enable"}/>;
case "enabled":
return <Button switched={false} colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.VideoMuted}
onToggle={() => events.fire("action_toggle_video", { enable: false, broadcastType: "camera" })}
tooltip={tr("Stop video broadcasting")} key={"disable"}/>;
}
}
const MicrophoneButton = () => {
const events = useContext(Events);
@ -211,13 +248,13 @@ const MicrophoneButton = () => {
events.on("notify_microphone_state", event => setState(event.state));
if(state === "muted") {
return <Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={"client-input_muted"} tooltip={tr("Unmute microphone")}
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"} />;
} else if(state === "enabled") {
return <Button colorTheme={"red"} autoSwitch={false} iconNormal={"client-input_muted"} tooltip={tr("Mute microphone")}
return <Button colorTheme={"red"} autoSwitch={false} iconNormal={ClientIcon.InputMuted} tooltip={tr("Mute microphone")}
onToggle={() => events.fire("action_toggle_microphone", { enabled: false })} key={"enabled"} />;
} else {
return <Button autoSwitch={false} iconNormal={"client-activate_microphone"} tooltip={tr("Enable your microphone on this server")}
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"} />;
}
}
@ -351,6 +388,7 @@ export const ControlBar2 = (props: { events: Registry<ControlBarEvents>, classNa
items.push(<div className={cssStyle.divider + " " + cssStyle.hideSmallPopout} key={"divider-1"} />);
}
items.push(<AwayButton key={"away"} />);
items.push(<VideoButton key={"video"} />);
items.push(<MicrophoneButton key={"microphone"} />);
items.push(<SpeakerButton key={"speaker"} />);
items.push(<div className={cssStyle.divider + " " + cssStyle.hideSmallPopout} key={"divider-2"} />);

View File

@ -37,7 +37,7 @@ const LogEntryRenderer = React.memo((props: { entry: LogMessage, handlerId: stri
export const ServerLogRenderer = (props: { events: Registry<ServerLogUIEvents>, handlerId: string }) => {
const [ logs, setLogs ] = useState<LogMessage[] | "loading">(() => {
props.events.fire_async("query_log");
props.events.fire_react("query_log");
return "loading";
});
@ -58,13 +58,6 @@ export const ServerLogRenderer = (props: { events: Registry<ServerLogUIEvents>,
return;
}
if(__build.mode === "debug") {
const index = logs.findIndex(e => e.uniqueId === event.event.uniqueId);
if(index !== -1) {
debugger;
}
}
logs.push(event.event);
logs.splice(0, Math.max(0, logs.length - 100));
logs.sort((a, b) => a.timestamp - b.timestamp);

View File

@ -29,7 +29,7 @@ export class ServerEventLog {
this.htmlTag.classList.add(cssStyle.htmlTag);
this.uiEvents.on("query_log", () => {
this.uiEvents.fire_async("notify_log", { log: this.eventLog });
this.uiEvents.fire_react("notify_log", { log: this.eventLog.slice() });
});
ReactDOM.render(<ServerLogRenderer events={this.uiEvents} handlerId={this.connection.handlerId} />, this.htmlTag);
@ -54,7 +54,7 @@ export class ServerEventLog {
while(this.eventLog.length > this.maxHistoryLength)
this.eventLog.pop_front();
this.uiEvents.fire_async("notify_log_add", { event: event });
this.uiEvents.fire_react("notify_log_add", { event: event });
}
if(isNotificationEnabled(type as any)) {

View File

@ -69,7 +69,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
}
/* let all other events run before */
this.events.fire_async("notify_chat_event", {
this.events.fire_react("notify_chat_event", {
chatId: this.chatId,
triggerUnread: triggerUnread,
event: event
@ -112,7 +112,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
switch (this.mode) {
case "normal":
if(this.conversationPrivate && !this.canClientAccessChat()) {
this.events.fire_async("notify_conversation_state", {
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "private",
crossChannelChatSupported: this.crossChannelChatSupported
@ -120,7 +120,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
return;
}
this.events.fire_async("notify_conversation_state", {
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "normal",
@ -140,14 +140,14 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
case "loading":
case "unloaded":
this.events.fire_async("notify_conversation_state", {
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "loading"
});
break;
case "error":
this.events.fire_async("notify_conversation_state", {
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "error",
errorMessage: this.errorMessage
@ -155,7 +155,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
break;
case "no-permissions":
this.events.fire_async("notify_conversation_state", {
this.events.fire_react("notify_conversation_state", {
chatId: this.chatId,
state: "no-permissions",
failedPermission: this.failedPermission
@ -225,7 +225,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
return;
this.unreadTimestamp = timestamp;
this.events.fire_async("notify_unread_timestamp_changed", { chatId: this.chatId, timestamp: timestamp });
this.events.fire_react("notify_unread_timestamp_changed", { chatId: this.chatId, timestamp: timestamp });
}
public jumpToPresent() {
@ -244,7 +244,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
switch (result.status) {
case "success":
this.events.fire_async("notify_conversation_history", {
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "success",
@ -256,7 +256,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
break;
case "private":
this.events.fire_async("notify_conversation_history", {
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = tr("chat is private"),
@ -265,7 +265,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
break;
case "no-permission":
this.events.fire_async("notify_conversation_history", {
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = tra("failed on {}", result.failedPermission || tr("unknown permission")),
@ -274,7 +274,7 @@ export abstract class AbstractChat<Events extends ConversationUIEvents> {
break;
case "error":
this.events.fire_async("notify_conversation_history", {
this.events.fire_react("notify_conversation_history", {
chatId: this.chatId,
state: "error",
errorMessage: this.historyErrorMessage = result.errorMessage,
@ -325,7 +325,7 @@ export abstract class AbstractChatManager<Events extends ConversationUIEvents> {
protected handleQueryConversationState(event: ConversationUIEvents["query_conversation_state"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
this.uiEvents.fire_async("notify_conversation_state", {
this.uiEvents.fire_react("notify_conversation_state", {
state: "error",
errorMessage: tr("Unknown conversation"),
@ -345,7 +345,7 @@ export abstract class AbstractChatManager<Events extends ConversationUIEvents> {
protected handleQueryHistory(event: ConversationUIEvents["query_conversation_history"]) {
const conversation = this.findChat(event.chatId);
if(!conversation) {
this.uiEvents.fire_async("notify_conversation_history", {
this.uiEvents.fire_react("notify_conversation_history", {
state: "error",
errorMessage: tr("Unknown conversation"),
retryTimestamp: Date.now() + 10 * 1000,

View File

@ -324,6 +324,6 @@ export class ChatBox extends React.Component<ChatBoxProperties, ChatBoxState> {
componentDidUpdate(prevProps: Readonly<ChatBoxProperties>, prevState: Readonly<ChatBoxState>, snapshot?: any): void {
if(prevState.enabled !== this.state.enabled)
this.events.fire_async("action_set_enabled", { enabled: this.state.enabled });
this.events.fire_react("action_set_enabled", { enabled: this.state.enabled });
}
}

View File

@ -468,7 +468,7 @@ export class ConversationManager extends AbstractChatManager<ConversationUIEvent
@EventHandler<ConversationUIEvents>("query_selected_chat")
private handleQuerySelectedChat() {
this.uiEvents.fire_async("notify_selected_chat", { chatId: isNaN(this.selectedConversation_) ? "unselected" : this.selectedConversation_ + ""})
this.uiEvents.fire_react("notify_selected_chat", { chatId: isNaN(this.selectedConversation_) ? "unselected" : this.selectedConversation_ + ""})
}
@EventHandler<ConversationUIEvents>("notify_selected_chat")

View File

@ -517,7 +517,7 @@ class ConversationMessages extends React.PureComponent<ConversationMessagesPrope
/* only load history when we're in an upwards scroll move */
if(this.scrollOffset === "bottom" || this.scrollOffset > top) {
this.scrollHistoryAutoLoadThrottle = Date.now() + 500; /* don't spam events */
this.props.events.fire_async("query_conversation_history", { chatId: this.currentChatId, timestamp: firstMessageTimestamp });
this.props.events.fire_react("query_conversation_history", { chatId: this.currentChatId, timestamp: firstMessageTimestamp });
}
}

View File

@ -162,7 +162,7 @@ export class PrivateConversation extends AbstractChat<PrivateConversationUIEvent
error: "error",
errorMessage: tr("target client is offline/invisible")
});
this.events.fire_async("notify_chat_event", {
this.events.fire_react("notify_chat_event", {
chatId: this.chatId,
triggerUnread: false,
event: this.presentEvents.last()
@ -406,7 +406,7 @@ export class PrivateConversationManager extends AbstractChatManager<PrivateConve
this.activeConversation = conversation;
/* fire this after all other events have been processed, maybe reportConversationList has been called before */
this.uiEvents.fire_async("notify_selected_chat", { chatId: this.activeConversation ? this.activeConversation.clientUniqueId : "unselected" });
this.uiEvents.fire_react("notify_selected_chat", { chatId: this.activeConversation ? this.activeConversation.clientUniqueId : "unselected" });
}
@EventHandler<PrivateConversationUIEvents>("action_select_chat")
@ -439,7 +439,7 @@ export class PrivateConversationManager extends AbstractChatManager<PrivateConve
}
private reportConversationList() {
this.uiEvents.fire_async("notify_private_conversations", {
this.uiEvents.fire_react("notify_private_conversations", {
conversations: this.conversations.map(conversation => conversation.generateUIInfo()),
selected: this.activeConversation?.clientUniqueId || "unselected"
});

View File

@ -0,0 +1,471 @@
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import * as React from "react";
import * as ReactDOM from "react-dom";
import {ChannelVideoRenderer} from "tc-shared/ui/frames/video/Renderer";
import {Registry} from "tc-shared/events";
import {ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
import {VideoBroadcastState, VideoBroadcastType, VideoConnection} from "tc-shared/connection/VideoConnection";
import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client";
import {LogCategory, logWarn} from "tc-shared/log";
const cssStyle = require("./Renderer.scss");
let videoIdIndex = 0;
interface ClientVideoController {
destroy();
notifyVideoInfo();
notifyVideo();
}
class RemoteClientVideoController implements ClientVideoController {
readonly videoId: string;
readonly client: ClientEntry;
callbackBroadcastStateChanged: (broadcasting: boolean) => void;
protected readonly events: Registry<ChannelVideoEvents>;
protected eventListener: (() => void)[];
protected eventListenerVideoClient: (() => void)[];
private currentBroadcastState: boolean;
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>, videoId?: string) {
this.client = client;
this.events = eventRegistry;
this.videoId = videoId || ("client-video-" + (++videoIdIndex));
this.currentBroadcastState = false;
const events = this.eventListener = [];
events.push(client.events.on("notify_properties_updated", event => {
if("client_nickname" in event.updated_properties) {
this.notifyVideoInfo();
}
}));
events.push(client.events.on("notify_status_icon_changed", event => {
this.events.fire_react("notify_video_info_status", { videoId: this.videoId, statusIcon: event.newIcon });
}));
events.push(client.events.on("notify_video_handle_changed", () => this.updateVideoClient()));
this.updateVideoClient();
}
private updateVideoClient() {
this.eventListenerVideoClient?.forEach(callback => callback());
const events = this.eventListenerVideoClient = [];
const videoClient = this.client.getVideoClient();
if(videoClient) {
events.push(videoClient.getEvents().on("notify_broadcast_state_changed", () => this.notifyVideo()));
}
}
destroy() {
this.eventListenerVideoClient?.forEach(callback => callback());
this.eventListenerVideoClient = undefined;
this.eventListener?.forEach(callback => callback());
this.eventListener = undefined;
}
isBroadcasting() {
const videoClient = this.client.getVideoClient();
return videoClient && (videoClient.getVideoState("camera") !== VideoBroadcastState.Stopped || videoClient.getVideoState("screen") !== VideoBroadcastState.Stopped);
}
notifyVideoInfo() {
this.events.fire_react("notify_video_info", {
videoId: this.videoId,
info: {
clientId: this.client.clientId(),
clientUniqueId: this.client.properties.client_unique_identifier,
clientName: this.client.clientNickName(),
statusIcon: this.client.getStatusIcon()
}
});
}
notifyVideo() {
let broadcasting = false;
if(this.isVideoActive()) {
let streams = [];
let initializing = false;
const stateCamera = this.getBroadcastState("camera");
if(stateCamera === VideoBroadcastState.Running) {
streams.push(this.getBroadcastStream("camera"));
} else if(stateCamera === VideoBroadcastState.Initializing) {
initializing = true;
}
const stateScreen = this.getBroadcastState("screen");
if(stateScreen === VideoBroadcastState.Running) {
streams.push(this.getBroadcastStream("screen"));
} else if(stateScreen === VideoBroadcastState.Initializing) {
initializing = true;
}
if(streams.length > 0) {
broadcasting = true;
this.events.fire_react("notify_video", {
videoId: this.videoId,
status: {
status: "connected",
desktopStream: streams[1],
cameraStream: streams[0]
}
});
} else if(initializing) {
broadcasting = true;
this.events.fire_react("notify_video", {
videoId: this.videoId,
status: { status: "initializing" }
});
} else {
this.events.fire_react("notify_video", {
videoId: this.videoId,
status: {
status: "connected",
cameraStream: undefined,
desktopStream: undefined
}
});
}
} else {
this.events.fire_react("notify_video", {
videoId: this.videoId,
status: { status: "no-video" }
});
}
if(broadcasting !== this.currentBroadcastState) {
this.currentBroadcastState = broadcasting;
if(this.callbackBroadcastStateChanged) {
this.callbackBroadcastStateChanged(broadcasting);
}
}
}
protected isVideoActive() : boolean {
return typeof this.client.getVideoClient() !== "undefined";
}
protected getBroadcastState(target: VideoBroadcastType) : VideoBroadcastState {
const videoClient = this.client.getVideoClient();
return videoClient ? videoClient.getVideoState(target) : VideoBroadcastState.Stopped;
}
protected getBroadcastStream(target: VideoBroadcastType) : MediaStream | undefined {
const videoClient = this.client.getVideoClient();
return videoClient ? videoClient.getVideoStream(target) : undefined;
}
}
class LocalVideoController extends RemoteClientVideoController {
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>) {
super(client, eventRegistry, kLocalVideoId);
const videoConnection = client.channelTree.client.serverConnection.getVideoConnection();
this.eventListener.push(videoConnection.getEvents().on("notify_local_broadcast_state_changed", () => this.notifyVideo()));
}
isBroadcasting() {
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
return videoConnection.isBroadcasting("camera") || videoConnection.isBroadcasting("screen");
}
protected isVideoActive(): boolean {
return true;
}
protected getBroadcastState(target: VideoBroadcastType): VideoBroadcastState {
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
return videoConnection.getBroadcastingState(target);
}
protected getBroadcastStream(target: VideoBroadcastType) : MediaStream | undefined {
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
return videoConnection.getBroadcastingSource(target)?.getStream();
}
}
class ChannelVideoController {
callbackVisibilityChanged: (visible: boolean) => void;
private readonly connection: ConnectionHandler;
private readonly videoConnection: VideoConnection;
private readonly events: Registry<ChannelVideoEvents>;
private eventListener: (() => void)[];
private expended: boolean;
private currentlyVisible: boolean;
private currentChannelId: number;
private localVideoController: LocalVideoController;
private clientVideos: {[key: number]: RemoteClientVideoController} = {};
constructor(events: Registry<ChannelVideoEvents>, connection: ConnectionHandler) {
this.events = events;
this.connection = connection;
this.videoConnection = this.connection.serverConnection.getVideoConnection();
this.connection.events().one("notify_handler_initialized", () => {
this.localVideoController = new LocalVideoController(connection.getClient(), this.events);
this.localVideoController.callbackBroadcastStateChanged = () => this.notifyVideoList();
});
this.currentlyVisible = false;
this.expended = false;
}
isExpended() : boolean { return this.expended; }
destroy() {
this.eventListener?.forEach(callback => callback());
this.eventListener = undefined;
if(this.localVideoController) {
this.localVideoController.callbackBroadcastStateChanged = undefined;
this.localVideoController.destroy();
this.localVideoController = undefined;
}
this.resetClientVideos();
}
initialize() {
const events = this.eventListener = [];
this.events.on("action_toggle_expended", event => {
if(event.expended === this.expended) { return; }
this.expended = event.expended;
this.events.fire_react("notify_expended", { expended: this.expended });
});
this.events.on("query_expended", () => this.events.fire_react("notify_expended", { expended: this.expended }));
this.events.on("query_videos", () => this.notifyVideoList());
this.events.on("query_video_info", event => {
const controller = this.findVideoById(event.videoId);
if(!controller) {
logWarn(LogCategory.VIDEO, tr("Tried to query video info for a non existing video id (%s)."), event.videoId);
return;
}
controller.notifyVideoInfo();
});
this.events.on("query_video", event => {
const controller = this.findVideoById(event.videoId);
if(!controller) {
logWarn(LogCategory.VIDEO, tr("Tried to query video for a non existing video id (%s)."), event.videoId);
return;
}
controller.notifyVideo();
});
const channelTree = this.connection.channelTree;
events.push(channelTree.events.on("notify_tree_reset", () => {
this.resetClientVideos();
this.currentChannelId = undefined;
this.notifyVideoList();
}));
events.push(channelTree.events.on("notify_client_moved", event => {
if(ChannelVideoController.shouldIgnoreClient(event.client)) {
return;
}
if(event.client instanceof LocalClientEntry) {
this.updateLocalChannel(event.client);
} else {
if(event.oldChannel.channelId === this.currentChannelId) {
if(this.destroyClientVideo(event.client.clientId())) {
this.notifyVideoList();
}
}
if(event.newChannel.channelId === this.currentChannelId) {
this.createClientVideo(event.client);
this.notifyVideoList();
}
}
}));
events.push(channelTree.events.on("notify_client_leave_view", event => {
if(ChannelVideoController.shouldIgnoreClient(event.client)) {
return;
}
if(this.destroyClientVideo(event.client.clientId())) {
this.notifyVideoList();
}
if(event.client instanceof LocalClientEntry) {
this.resetClientVideos();
}
}));
events.push(channelTree.events.on("notify_client_enter_view", event => {
if(ChannelVideoController.shouldIgnoreClient(event.client)) {
return;
}
if(event.targetChannel.channelId === this.currentChannelId) {
this.createClientVideo(event.client);
this.notifyVideoList();
}
if(event.client instanceof LocalClientEntry) {
this.updateLocalChannel(event.client);
}
}));
events.push(channelTree.events.on("notify_channel_client_order_changed", event => {
if(event.channel.channelId == this.currentChannelId) {
this.notifyVideoList();
}
}));
}
private static shouldIgnoreClient(client: ClientEntry) {
return (client instanceof MusicClientEntry || client.properties.client_type_exact === ClientType.CLIENT_QUERY);
}
private updateLocalChannel(localClient: ClientEntry) {
this.resetClientVideos();
if(localClient.currentChannel()) {
this.currentChannelId = localClient.currentChannel().channelId;
localClient.currentChannel().channelClientsOrdered().forEach(client => {
if(client instanceof LocalClientEntry || ChannelVideoController.shouldIgnoreClient(client)) {
return;
}
this.createClientVideo(client);
});
this.notifyVideoList();
} else {
this.currentChannelId = undefined;
}
}
private findVideoById(videoId: string) : ClientVideoController | undefined {
if(this.localVideoController?.videoId === videoId) {
return this.localVideoController;
}
return Object.values(this.clientVideos).find(e => e.videoId === videoId);
}
private resetClientVideos() {
for(const clientId of Object.keys(this.clientVideos)) {
this.destroyClientVideo(parseInt(clientId));
}
this.notifyVideoList();
}
private destroyClientVideo(clientId: number) : boolean {
if(this.clientVideos[clientId]) {
const video = this.clientVideos[clientId];
video.callbackBroadcastStateChanged = undefined;
video.destroy();
delete this.clientVideos[clientId];
return true;
} else {
return false;
}
}
private createClientVideo(client: ClientEntry) {
this.destroyClientVideo(client.clientId());
const controller = new RemoteClientVideoController(client, this.events);
/* update our video list and the visibility */
controller.callbackBroadcastStateChanged = () => this.notifyVideoList();
this.clientVideos[client.clientId()] = controller;
}
private notifyVideoList() {
const videoIds = [];
let videoCount = 0;
if(this.localVideoController) {
videoIds.push(this.localVideoController.videoId);
if(this.localVideoController.isBroadcasting()) { videoCount++; }
}
const channel = this.connection.channelTree.findChannel(this.currentChannelId);
if(channel) {
const clients = channel.channelClientsOrdered();
for(const client of clients) {
if(!this.clientVideos[client.clientId()]) {
/* should not be possible (Is only possible for the local client) */
continue;
}
const controller = this.clientVideos[client.clientId()];
if(controller.isBroadcasting()) {
videoCount++;
} else {
/* TODO: Filter if video is active */
}
videoIds.push(controller.videoId);
}
}
this.updateVisibility(videoCount !== 0);
this.events.fire_react("notify_videos", {
videoIds: videoIds
});
}
private updateVisibility(target: boolean) {
if(this.currentlyVisible === target) { return; }
this.currentlyVisible = target;
if(this.callbackVisibilityChanged) {
this.callbackVisibilityChanged(target);
}
}
}
export class ChannelVideoFrame {
private readonly handle: ConnectionHandler;
private readonly events: Registry<ChannelVideoEvents>;
private container: HTMLDivElement;
private controller: ChannelVideoController;
constructor(handle: ConnectionHandler) {
this.handle = handle;
this.events = new Registry<ChannelVideoEvents>();
this.controller = new ChannelVideoController(this.events, handle);
this.controller.initialize();
this.container = document.createElement("div");
this.container.classList.add(cssStyle.container, cssStyle.hidden);
ReactDOM.render(React.createElement(ChannelVideoRenderer, { handlerId: handle.handlerId, events: this.events }), this.container);
this.events.on("notify_expended", event => this.container.classList.toggle(cssStyle.expended, event.expended));
this.controller.callbackVisibilityChanged = flag => {
this.container.classList.toggle(cssStyle.hidden, !flag);
if(!flag) {
this.events.fire("action_toggle_expended", { expended: false })
}
};
}
destroy() {
this.controller?.destroy();
this.controller = undefined;
if(this.container) {
this.container.remove();
ReactDOM.unmountComponentAtNode(this.container);
this.container = undefined;
}
this.events.destroy();
}
getContainer() : HTMLDivElement {
return this.container;
}
}

View File

@ -0,0 +1,49 @@
import {ClientIcon} from "svg-sprites/client-icons";
export const kLocalVideoId = "__local__video__";
export type ChannelVideoInfo = { clientName: string, clientUniqueId: string, clientId: number, statusIcon: ClientIcon };
export type ChannelVideo ={
status: "initializing",
} | {
status: "connected",
cameraStream: MediaStream | undefined,
desktopStream: MediaStream | undefined,
} | {
status: "error",
message: string
} | {
status: "no-video"
};
export interface ChannelVideoEvents {
action_toggle_expended: { expended: boolean },
action_video_scroll: { direction: "left" | "right" },
query_expended: {},
query_videos: {},
query_video: { videoId: string },
query_video_info: { videoId: string },
notify_expended: { expended: boolean },
notify_videos: {
videoIds: string[]
},
notify_video: {
videoId: string,
status: ChannelVideo
},
notify_video_info: {
videoId: string,
info: ChannelVideoInfo
},
notify_video_info_status: {
videoId: string,
statusIcon: ClientIcon
},
notify_video_arrows: {
left: boolean,
right: boolean
}
}

View File

@ -0,0 +1,266 @@
@import "../../../../css/static/properties";
@import "../../../../css/static/mixin";
/* Using a general video format of 16:9 */
$small_height: 10em;
.container {
@include user-select(none);
overflow: visible;
height: $small_height;
flex-shrink: 0;
margin-bottom: 5px;
z-index: 10;
@include transition(all .3s ease-in-out);
&.hidden {
height: 0;
margin-bottom: 0;
.panel {
height: 0;
}
}
&.expended {
.panel {
height: calc(100% - 1.5em); /* the footer size (version etc) */
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.expendArrow .icon {
@include transform(rotate(90deg)!important);
}
}
}
.panel {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
flex-direction: row;
justify-content: stretch;
height: $small_height;
flex-shrink: 0;
background-color: #353535;
border-radius: 5px;
overflow: hidden;
@include transition(all .3s ease-in-out);
}
.expendArrow {
position: absolute;
top: .5em;
right: .5em;
padding: .2em;
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
border-radius: .25em;
@include transition(all $button_hover_animation_time ease-in-out);
&:hover {
background-color: #3c3d3e;
}
.icon {
align-self: center;
font-size: 2em;
@include transition(all .3s ease-in-out);
@include transform(rotate(180deg));
}
}
.videoBar {
position: relative;
height: $small_height;
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-shrink: 1;
flex-grow: 1;
margin-left: .5em;
margin-right: .5em;
/* TODO: Min with of two video +4em for one of the arrows */
min-width: 6em;
.videos {
display: flex;
flex-direction: row;
justify-content: flex-start;
overflow: hidden;
}
.arrow {
position: absolute;
top: 0;
bottom: 0;
width: 4em;
background: linear-gradient(90deg, #35353500 0%, #353535 50%);
opacity: 1;
display: flex;
flex-direction: column;
justify-content: center;
@include transition(all $button_hover_animation_time ease-in-out);
&.hidden {
pointer-events: none;
opacity: 0;
}
.iconContainer {
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-self: flex-end;
padding: .1em;
border-radius: .25em;
&:hover {
background-color: #3c3d3e;
}
.icon {
align-self: center;
font-size: 2em;
}
}
&.right {
right: 0;
}
&.left {
left: 0;
@include transform(rotate(180deg));
}
}
}
.videoContainer {
position: relative;
margin-top: .5em;
margin-bottom: .5em;
flex-shrink: 0;
flex-grow: 0;
height: ($small_height - 1em);
width: ($small_height * 16 / 9);
background-color: #2e2e2e;
box-shadow: inset 0 0 5px #00000040;
border-radius: .2em;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
&:not(:last-of-type) {
margin-right: .5em;
}
.video {
opacity: 1;
max-height: 100%;
max-width: 100%;
align-self: center;
}
.videoPrimary {
}
.videoSecondary {
}
.text {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
font-size: 1.25em;
color: #999;
&.error {
/* TODO! */
}
}
.info {
position: absolute;
bottom: 0;
left: 0;
display: flex;
flex-direction: row;
border-top-right-radius: .2em;
background-color: #35353580;
padding: .1em .3em;
max-width: 70%;
.icon {
flex-shrink: 0;
align-self: center;
}
.name {
align-self: center;
color: #999;
margin-left: .25em;
font-weight: normal!important;
@include text-dotdotdot();
&.local {
color: #147114;
}
}
}
}

View File

@ -0,0 +1,264 @@
import * as React from "react";
import {useCallback, useContext, useEffect, useRef, useState} from "react";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
import {Registry} from "tc-shared/events";
import {ChannelVideo, ChannelVideoEvents, ChannelVideoInfo, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
import ResizeObserver from "resize-observer-polyfill";
const EventContext = React.createContext<Registry<ChannelVideoEvents>>(undefined);
const HandlerIdContext = React.createContext<string>(undefined);
const cssStyle = require("./Renderer.scss");
const ExpendArrow = () => {
const events = useContext(EventContext);
const [ expended, setExpended ] = useState(() => {
events.fire("query_expended");
return false;
});
events.reactUse("notify_expended", event => setExpended(event.expended), undefined, [ setExpended ]);
return (
<div className={cssStyle.expendArrow} onClick={() => events.fire("action_toggle_expended", { expended: !expended })}>
<ClientIconRenderer icon={ClientIcon.DoubleArrow} className={cssStyle.icon} />
</div>
)
};
const VideoInfo = React.memo((props: { videoId: string }) => {
const events = useContext(EventContext);
const handlerId = useContext(HandlerIdContext);
const localVideo = props.videoId === kLocalVideoId;
const nameClassList = cssStyle.name + " " + (localVideo ? cssStyle.local : "");
const [ info, setInfo ] = useState<"loading" | ChannelVideoInfo>(() => {
events.fire("query_video_info", { videoId: props.videoId });
return "loading";
});
const [ statusIcon, setStatusIcon ] = useState<ClientIcon>(ClientIcon.PlayerOff);
events.reactUse("notify_video_info", event => {
if(event.videoId === props.videoId) {
setInfo(event.info);
setStatusIcon(event.info.statusIcon);
}
});
events.reactUse("notify_video_info_status", event => {
if(event.videoId === props.videoId) {
setStatusIcon(event.statusIcon);
}
});
let clientName;
if(info === "loading") {
clientName = <div className={nameClassList} key={"loading"}><Translatable>loading</Translatable> {props.videoId} <LoadingDots /></div>;
} else {
clientName = <ClientTag clientName={info.clientName} clientUniqueId={info.clientUniqueId} clientId={info.clientId} handlerId={handlerId} className={nameClassList} key={"loaded"} />;
}
return (
<div className={cssStyle.info}>
<ClientIconRenderer icon={statusIcon} className={cssStyle.icon} />
{clientName}
</div>
);
});
const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined, className: string }) => {
const refVideo = useRef<HTMLVideoElement>();
useEffect(() => {
if(props.stream) {
refVideo.current.style.opacity = "1";
refVideo.current.srcObject = props.stream;
} else {
refVideo.current.style.opacity = "0";
}
}, [ props.stream ]);
return (
<video ref={refVideo} autoPlay={true} className={cssStyle.video + " " + props.className} />
)
});
const VideoPlayer = React.memo((props: { videoId: string }) => {
const events = useContext(EventContext);
const [ state, setState ] = useState<"loading" | ChannelVideo>(() => {
events.fire("query_video", { videoId: props.videoId });
return "loading";
});
events.reactUse("notify_video", event => {
if(event.videoId === props.videoId) {
setState(event.status);
}
});
if(state === "loading") {
return (
<div className={cssStyle.text} key={"info-loading"}>
<div><Translatable>loading</Translatable> <LoadingDots /></div>
</div>
);
} else if(state.status === "initializing") {
return (
<div className={cssStyle.text} key={"info-initializing"}>
<div><Translatable>connecting</Translatable> <LoadingDots /></div>
</div>
);
} else if(state.status === "error") {
return (
<div className={cssStyle.error + " " + cssStyle.text} key={"info-error"}>
<div>{state.message}</div>
</div>
);
} else if(state.status === "connected") {
if(state.desktopStream && state.cameraStream) {
/* TODO: Select primary and secondary and display them */
return (
<VideoStreamReplay stream={state.desktopStream} key={"replay-multi"} className={cssStyle.videoPrimary} />
);
} else {
const stream = state.desktopStream || state.cameraStream;
if(stream) {
return (
<VideoStreamReplay stream={stream} key={"replay-single"} className={cssStyle.videoPrimary} />
);
} else {
return (
<div className={cssStyle.text} key={"no-video-stream"}>
<div><Translatable>No Video</Translatable></div>
</div>
);
}
}
} else if(state.status === "no-video") {
return (
<div className={cssStyle.text} key={"no-video"}>
<div><Translatable>No Video</Translatable></div>
</div>
);
}
return null;
});
const VideoContainer = React.memo((props: { videoId: string }) => (
<div className={cssStyle.videoContainer}>
<VideoPlayer videoId={props.videoId} />
<VideoInfo videoId={props.videoId} />
</div>
));
const VideoBarArrow = React.memo((props: { direction: "left" | "right", containerRef: React.RefObject<HTMLDivElement> }) => {
const events = useContext(EventContext);
const [ shown, setShown ] = useState(false);
events.reactUse("notify_video_arrows", event => setShown(event[props.direction]));
return (
<div className={cssStyle.arrow + " " + cssStyle[props.direction] + " " + (shown ? "" : cssStyle.hidden)} ref={props.containerRef}>
<div className={cssStyle.iconContainer} onClick={() => events.fire("action_video_scroll", { direction: props.direction })}>
<ClientIconRenderer icon={ClientIcon.SimpleArrow} className={cssStyle.icon} />
</div>
</div>
);
})
const VideoBar = () => {
const events = useContext(EventContext);
const refVideos = useRef<HTMLDivElement>();
const refArrowRight = useRef<HTMLDivElement>();
const refArrowLeft = useRef<HTMLDivElement>();
const [ videos, setVideos ] = useState<"loading" | string[]>(() => {
events.fire("query_videos");
return "loading";
});
events.reactUse("notify_videos", event => setVideos(event.videoIds));
const updateScrollButtons = useCallback(() => {
const container = refVideos.current;
if(!container) { return; }
const rightEndReached = container.scrollLeft + container.clientWidth + 1 >= container.scrollWidth;
const leftEndReached = container.scrollLeft <= .9;
events.fire("notify_video_arrows", { left: !leftEndReached, right: !rightEndReached });
}, [ refVideos ]);
events.reactUse("action_video_scroll", event => {
const container = refVideos.current;
const arrowLeft = refArrowLeft.current;
const arrowRight = refArrowRight.current;
if(container && arrowLeft && arrowRight) {
const children = [...container.children] as HTMLElement[];
if(event.direction === "left") {
const currentCutOff = container.scrollLeft;
const element = children.filter(element => element.offsetLeft >= currentCutOff)
.sort((a, b) => a.offsetLeft - b.offsetLeft)[0];
container.scrollLeft = (element.offsetLeft + element.clientWidth) - (container.clientWidth - arrowRight.clientWidth);
} else {
const currentCutOff = container.scrollLeft + container.clientWidth;
const element = children.filter(element => element.offsetLeft <= currentCutOff)
.sort((a, b) => a.offsetLeft - b.offsetLeft)
.last();
container.scrollLeft = element.offsetLeft - arrowLeft.clientWidth;
}
}
updateScrollButtons();
}, undefined, [ updateScrollButtons ]);
useEffect(() => {
updateScrollButtons();
}, [ videos ]);
useEffect(() => {
const animationRequest = { current: 0 };
const observer = new ResizeObserver(() => {
if(animationRequest.current) {
return;
}
animationRequest.current = requestAnimationFrame(() => {
animationRequest.current = 0;
updateScrollButtons();
})
});
observer.observe(refVideos.current);
return () => observer.disconnect();
}, [ refVideos ]);
return (
<div className={cssStyle.videoBar}>
<div className={cssStyle.videos} ref={refVideos}>
{videos === "loading" ? undefined :
videos.map(videoId => <VideoContainer videoId={videoId} key={videoId} />)
}
</div>
<VideoBarArrow direction={"left"} containerRef={refArrowLeft} />
<VideoBarArrow direction={"right"} containerRef={refArrowRight} />
</div>
)
};
export const ChannelVideoRenderer = (props: { handlerId: string, events: Registry<ChannelVideoEvents> }) => {
return (
<EventContext.Provider value={props.events}>
<HandlerIdContext.Provider value={props.handlerId}>
<div className={cssStyle.panel}>
<VideoBar />
<ExpendArrow />
</div>
</HandlerIdContext.Provider>
</EventContext.Provider>
)
};

View File

@ -231,7 +231,7 @@ export function spawnClientVolumeChange(client: ClientEntry) {
const events = new Registry<VolumeChangeEvents>();
events.on("query-volume", () => {
events.fire_async("query-volume-response", {
events.fire_react("query-volume-response", {
volume: client.getAudioVolume()
});
});
@ -263,7 +263,7 @@ export function spawnMusicBotVolumeChange(client: MusicClientEntry, maxValue: nu
const events = new Registry<VolumeChangeEvents>();
events.on("query-volume", () => {
events.fire_async("query-volume-response", {
events.fire_react("query-volume-response", {
volume: client.properties.player_volume
});
});

View File

@ -1,7 +1,7 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {FlatInputField, FlatSelect} from "tc-shared/ui/react-elements/InputField";
import {FlatInputField, Select} from "tc-shared/ui/react-elements/InputField";
import * as React from "react";
import {useEffect, useRef, useState} from "react";
import {GroupType} from "tc-shared/permission/GroupManager";
@ -118,7 +118,7 @@ const GroupNameInput = (props: { events: Registry<GroupCreateModalEvents>, defau
const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> }) => {
const [ selectedType, setSelectedType ] = useState<"query" | "template" | "normal" | "loading">("loading");
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
const refSelect = useRef<FlatSelect>();
const refSelect = useRef<Select>();
props.events.reactUse("notify_client_permissions", event => {
setPermissions({
@ -133,7 +133,7 @@ const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> })
props.events.reactUse("action_set_type", event => setSelectedType(event.target));
return (
<FlatSelect
<Select
ref={refSelect}
label={<Translatable>Target group type</Translatable>}
className={cssStyle.groupType}
@ -153,7 +153,7 @@ const GroupTypeSelector = (props: { events: Registry<GroupCreateModalEvents> })
<option
value={"normal"}
>{tr("Regular group")}</option>
</FlatSelect>
</Select>
)
};
@ -162,7 +162,7 @@ const SourceGroupSelector = (props: { events: Registry<GroupCreateModalEvents>,
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
const refSelect = useRef<FlatSelect>();
const refSelect = useRef<Select>();
props.events.reactUse("notify_client_permissions", event => setPermissions({
createQuery: event.createQueryGroup,
@ -178,12 +178,12 @@ const SourceGroupSelector = (props: { events: Registry<GroupCreateModalEvents>,
const isLoading = exitingGroups === "loading" || permissions === "loading";
if(!isLoading && selectedGroup === undefined)
props.events.fire_async("action_set_source", {
props.events.fire_react("action_set_source", {
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultSource) === -1 ? 0 : props.defaultSource
});
return (
<FlatSelect
<Select
ref={refSelect}
label={<Translatable>Create group using this template</Translatable>}
className={cssStyle.groupSource}
@ -214,7 +214,7 @@ const SourceGroupSelector = (props: { events: Registry<GroupCreateModalEvents>,
))
}
</optgroup>
</FlatSelect>
</Select>
)
};
@ -249,8 +249,8 @@ class ModalGroupCreate extends InternalModal {
}
protected onInitialize() {
this.events.fire_async("query_available_groups");
this.events.fire_async("query_client_permissions");
this.events.fire_react("query_available_groups");
this.events.fire_react("query_client_permissions");
}
protected onDestroy() {
@ -305,7 +305,7 @@ function initializeGroupCreateController(connection: ConnectionHandler, events:
events.on("query_available_groups", event => {
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
events.fire_async("query_available_groups_result", {
events.fire_react("query_available_groups_result", {
groups: groups.map(e => {
return {
name: e.name,
@ -316,7 +316,7 @@ function initializeGroupCreateController(connection: ConnectionHandler, events:
});
});
const notifyClientPermissions = () => events.fire_async("notify_client_permissions", {
const notifyClientPermissions = () => events.fire_react("notify_client_permissions", {
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
});

View File

@ -2,7 +2,7 @@ import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-shared/events";
import {useRef, useState} from "react";
import {FlatSelect} from "tc-shared/ui/react-elements/InputField";
import {Select} from "tc-shared/ui/react-elements/InputField";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import * as React from "react";
import {Button} from "tc-shared/ui/react-elements/Button";
@ -51,7 +51,7 @@ const GroupSelector = (props: { events: Registry<GroupPermissionCopyModalEvents>
const [ permissions, setPermissions ] = useState<"loading" | { createTemplate, createQuery }>("loading");
const [ exitingGroups, setExitingGroups ] = useState<"loading" | GroupInfo[]>("loading");
const refSelect = useRef<FlatSelect>();
const refSelect = useRef<Select>();
props.events.reactUse("notify_client_permissions", event => setPermissions({
createQuery: event.createQueryGroup,
@ -67,12 +67,12 @@ const GroupSelector = (props: { events: Registry<GroupPermissionCopyModalEvents>
const isLoading = exitingGroups === "loading" || permissions === "loading";
if(!isLoading && selectedGroup === undefined)
props.events.fire_async(props.updateEvent, {
props.events.fire_react(props.updateEvent, {
group: (exitingGroups as GroupInfo[]).findIndex(e => e.id === props.defaultGroup) === -1 ? 0 : props.defaultGroup
});
return (
<FlatSelect
<Select
ref={refSelect}
label={props.label}
className={props.className}
@ -103,7 +103,7 @@ const GroupSelector = (props: { events: Registry<GroupPermissionCopyModalEvents>
))
}
</optgroup>
</FlatSelect>
</Select>
)
};
@ -138,8 +138,8 @@ class ModalGroupPermissionCopy extends InternalModal {
}
protected onInitialize() {
this.events.fire_async("query_available_groups");
this.events.fire_async("query_client_permissions");
this.events.fire_react("query_available_groups");
this.events.fire_react("query_client_permissions");
}
protected onDestroy() {
@ -191,7 +191,7 @@ function initializeGroupPermissionCopyController(connection: ConnectionHandler,
events.on("query_available_groups", event => {
const groups = target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
events.fire_async("query_available_groups_result", {
events.fire_react("query_available_groups_result", {
groups: groups.map(e => {
return {
name: e.name,
@ -202,7 +202,7 @@ function initializeGroupPermissionCopyController(connection: ConnectionHandler,
});
});
const notifyClientPermissions = () => events.fire_async("notify_client_permissions", {
const notifyClientPermissions = () => events.fire_react("notify_client_permissions", {
createQueryGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_QUERYGROUP).granted(1),
createTemplateGroup: connection.permissions.neededPermission(PermissionType.B_SERVERINSTANCE_MODIFY_TEMPLATES).granted(1)
});

View File

@ -75,7 +75,7 @@ export function openModalNewcomer(): Modal {
event_registry.fire("show_step", {step: "welcome"});
modal.open();
event_registry.fire_async("modal-shown");
event_registry.fire_react("modal-shown");
return modal;
}
@ -138,7 +138,7 @@ function initializeStepWelcome(tag: JQuery, event_registry: Registry<EventModalN
event_registry.on("show_step", e => {
if (e.step !== "welcome") return;
event_registry.fire_async("step-status", {allowNextStep: true, allowPreviousStep: true});
event_registry.fire_react("step-status", {allowNextStep: true, allowPreviousStep: true});
});
}
@ -146,7 +146,7 @@ function initializeStepFinish(tag: JQuery, event_registry: Registry<EventModalNe
event_registry.on("show_step", e => {
if (e.step !== "finish") return;
event_registry.fire_async("step-status", {allowNextStep: true, allowPreviousStep: true});
event_registry.fire_react("step-status", {allowNextStep: true, allowPreviousStep: true});
});
}
@ -159,7 +159,7 @@ function initializeStepIdentity(tag: JQuery, event_registry: Registry<EventModal
let stepShown = false;
let help_animation_done = false;
const update_step_status = () => {
event_registry.fire_async("step-status", {
event_registry.fire_react("step-status", {
allowNextStep: help_animation_done,
allowPreviousStep: help_animation_done
});
@ -344,7 +344,7 @@ function initializeStepMicrophone(tag: JQuery, event_registry: Registry<EventMod
let stepShown = false;
const settingEvents = new Registry<MicrophoneSettingsEvents>();
settingEvents.on("query_help", () => settingEvents.fire_async("notify_highlight", {field: helpStep <= 2 ? ("hs-" + helpStep) as any : undefined}));
settingEvents.on("query_help", () => settingEvents.fire_react("notify_highlight", {field: helpStep <= 2 ? ("hs-" + helpStep) as any : undefined}));
settingEvents.on("action_help_click", () => {
if (!stepShown) {
return;
@ -353,7 +353,7 @@ function initializeStepMicrophone(tag: JQuery, event_registry: Registry<EventMod
helpStep++;
settingEvents.fire("query_help");
event_registry.fire_async("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2})
event_registry.fire_react("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2})
});
event_registry.on("action-next-help", () => settingEvents.fire("action_help_click"));
@ -372,6 +372,6 @@ function initializeStepMicrophone(tag: JQuery, event_registry: Registry<EventMod
return;
}
event_registry.fire_async("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2});
event_registry.fire_react("step-status", {allowNextStep: helpStep > 2, allowPreviousStep: helpStep > 2});
});
}

View File

@ -694,7 +694,7 @@ export namespace modal_settings {
}
export function initialize_identity_profiles_controller(event_registry: Registry<events.modal.settings.profiles>) {
const send_error = (event, profile, text) => event_registry.fire_async(event, {
const send_error = (event, profile, text) => event_registry.fire_react(event, {
status: "error",
profile_id: profile,
error: text
@ -702,7 +702,7 @@ export namespace modal_settings {
event_registry.on("create-profile", event => {
const profile = profiles.createConnectProfile(event.name);
profiles.mark_need_save();
event_registry.fire_async("create-profile-result", {
event_registry.fire_react("create-profile-result", {
status: "success",
name: event.name,
profile_id: profile.id
@ -718,7 +718,7 @@ export namespace modal_settings {
}
profiles.delete_profile(profile);
event_registry.fire_async("delete-profile-result", {status: "success", profile_id: event.profile_id});
event_registry.fire_react("delete-profile-result", {status: "success", profile_id: event.profile_id});
});
const build_profile_info = (profile: ConnectionProfile) => {
@ -746,7 +746,7 @@ export namespace modal_settings {
}
};
event_registry.on("query-profile-list", event => {
event_registry.fire_async("query-profile-list-result", {
event_registry.fire_react("query-profile-list-result", {
status: "success",
profiles: profiles.availableConnectProfiles().map(e => build_profile_info(e))
});
@ -760,7 +760,7 @@ export namespace modal_settings {
return;
}
event_registry.fire_async("query-profile-result", {
event_registry.fire_react("query-profile-result", {
status: "success",
profile_id: event.profile_id,
info: build_profile_info(profile)
@ -776,7 +776,7 @@ export namespace modal_settings {
}
const old = profiles.set_default_profile(profile);
event_registry.fire_async("set-default-profile-result", {
event_registry.fire_react("set-default-profile-result", {
status: "success",
old_profile_id: event.profile_id,
new_profile_id: old.id
@ -793,7 +793,7 @@ export namespace modal_settings {
profile.profileName = event.name;
profiles.mark_need_save();
event_registry.fire_async("set-profile-name-result", {
event_registry.fire_react("set-profile-name-result", {
name: event.name,
profile_id: event.profile_id,
status: "success"
@ -810,7 +810,7 @@ export namespace modal_settings {
profile.defaultUsername = event.name;
profiles.mark_need_save();
event_registry.fire_async("set-default-name-result", {
event_registry.fire_react("set-default-name-result", {
name: event.name,
profile_id: event.profile_id,
status: "success"
@ -831,7 +831,7 @@ export namespace modal_settings {
identity.set_name(event.name);
profiles.mark_need_save();
event_registry.fire_async("set-identity-name-name-result", {
event_registry.fire_react("set-identity-name-name-result", {
name: event.name,
profile_id: event.profile_id,
status: "success"
@ -846,7 +846,7 @@ export namespace modal_settings {
return;
}
event_registry.fire_async("query-profile-validity-result", {
event_registry.fire_react("query-profile-validity-result", {
status: "success",
profile_id: event.profile_id,
valid: profile.valid()
@ -863,7 +863,7 @@ export namespace modal_settings {
const ts = profile.selectedIdentity(IdentitifyType.TEAMSPEAK) as TeaSpeakIdentity;
if (!ts) {
event_registry.fire_async("query-identity-teamspeak-result", {
event_registry.fire_react("query-identity-teamspeak-result", {
status: "error",
profile_id: event.profile_id,
error: tr("Missing identity")
@ -872,7 +872,7 @@ export namespace modal_settings {
}
ts.level().then(level => {
event_registry.fire_async("query-identity-teamspeak-result", {
event_registry.fire_react("query-identity-teamspeak-result", {
status: "success",
level: level,
profile_id: event.profile_id
@ -911,7 +911,7 @@ export namespace modal_settings {
profiles.mark_need_save();
identity.level().then(level => {
event_registry.fire_async("generate-identity-teamspeak-result", {
event_registry.fire_react("generate-identity-teamspeak-result", {
status: "success",
profile_id: event.profile_id,
unique_id: identity.uid(),
@ -942,7 +942,7 @@ export namespace modal_settings {
console.error(tr("Failed to calculate level for a new imported identity. Error object: %o"), error);
return Promise.resolve(undefined);
}).then(level => {
event_registry.fire_async("import-identity-teamspeak-result", {
event_registry.fire_react("import-identity-teamspeak-result", {
profile_id: event.profile_id,
unique_id: identity.uid(),
level: level
@ -965,7 +965,7 @@ export namespace modal_settings {
profiles.mark_need_save();
identity.level().then(level => {
event_registry.fire_async("improve-identity-teamspeak-level-update", {
event_registry.fire_react("improve-identity-teamspeak-level-update", {
profile_id: event.profile_id,
new_level: level
});
@ -1533,7 +1533,7 @@ export namespace modal_settings {
event_registry.on("import-identity-teamspeak-result", event => {
if (event.profile_id !== current_profile) return;
event_registry.fire_async("query-profile", {profile_id: event.profile_id}); /* we do it like this so the default nickname changes as well */
event_registry.fire_react("query-profile", {profile_id: event.profile_id}); /* we do it like this so the default nickname changes as well */
createInfoModal(tr("Identity imported"), tr("Your identity had been successfully imported generated")).open();
});
}
@ -1582,7 +1582,7 @@ export namespace modal_settings {
container_invalid.toggle(!valid);
});
button_setup.on('click', event => event_registry.fire_async("setup-forum-connection"));
button_setup.on('click', event => event_registry.fire_react("setup-forum-connection"));
button_setup.toggle(settings.forum_setuppable);
}

View File

@ -177,7 +177,7 @@ export function spawnModalCssVariableEditor() {
function cssVariableEditorController(events: Registry<CssEditorEvents>) {
events.on("query_css_variables", () => {
events.fire_async("notify_css_variables", {
events.fire_react("notify_css_variables", {
variables: cssVariableManager.getAllCssVariables()
})
});
@ -191,7 +191,7 @@ function cssVariableEditorController(events: Registry<CssEditorEvents>) {
});
events.on("action_export", event => {
events.fire_async("notify_export_result", {
events.fire_react("notify_export_result", {
config: cssVariableManager.exportConfig(event.allValues)
});
});
@ -199,25 +199,25 @@ function cssVariableEditorController(events: Registry<CssEditorEvents>) {
events.on("action_import", event => {
try {
cssVariableManager.importConfig(event.config);
events.fire_async("notify_import_result", {success: true});
events.fire_async("action_select_entry", {variable: undefined});
events.fire_async("query_css_variables");
events.fire_react("notify_import_result", {success: true});
events.fire_react("action_select_entry", {variable: undefined});
events.fire_react("query_css_variables");
} catch (error) {
console.warn("Failed to import CSS variable values: %o", error);
events.fire_async("notify_import_result", {success: false});
events.fire_react("notify_import_result", {success: false});
}
});
events.on("action_reset", () => {
cssVariableManager.reset();
events.fire_async("action_select_entry", {variable: undefined});
events.fire_async("query_css_variables");
events.fire_react("action_select_entry", {variable: undefined});
events.fire_react("query_css_variables");
});
events.on("action_randomize", () => {
cssVariableManager.randomize();
events.fire_async("action_select_entry", {variable: undefined});
events.fire_async("query_css_variables");
events.fire_react("action_select_entry", {variable: undefined});
events.fire_react("query_css_variables");
});
}

View File

@ -63,7 +63,7 @@ const CssVariableRenderer = React.memo((props: { events: Registry<CssEditorEvent
const CssVariableListBodyRenderer = (props: { events: Registry<CssEditorEvents> }) => {
const [variables, setVariables] = useState<"loading" | CssVariable[]>(() => {
props.events.fire_async("query_css_variables");
props.events.fire_react("query_css_variables");
return "loading";
});

View File

@ -61,11 +61,11 @@ function initializeController(connection: ConnectionHandler, events: Registry<Ec
});
events.on("query_test_state", () => {
events.fire_async("notify_tests_toggle", {enabled: settings.global(Settings.KEY_VOICE_ECHO_TEST_ENABLED)});
events.fire_react("notify_tests_toggle", {enabled: settings.global(Settings.KEY_VOICE_ECHO_TEST_ENABLED)});
});
events.on("notify_destroy", settings.globalChangeListener(Settings.KEY_VOICE_ECHO_TEST_ENABLED, value => {
events.fire_async("notify_tests_toggle", {enabled: value});
events.fire_react("notify_tests_toggle", {enabled: value});
}));
events.on("action_test_result", event => {
@ -127,7 +127,7 @@ function initializeController(connection: ConnectionHandler, events: Registry<Ec
events.on("query_voice_connection_state", () => reportVoiceConnectionState(connection.getServerConnection().getVoiceConnection().getConnectionState()));
events.on("query_test_state", () => {
events.fire_async("notify_test_state", {state: testState});
events.fire_react("notify_test_state", {state: testState});
});
events.on("action_start_test", () => {

View File

@ -3,7 +3,6 @@ import {ModalGlobalSettingsEditor} from "tc-shared/ui/modal/global-settings-edit
import {Registry} from "tc-shared/events";
import {ModalGlobalSettingsEditorEvents, Setting} from "tc-shared/ui/modal/global-settings-editor/Definitions";
import {ConfigValueTypes, settings, Settings, SettingsKey} from "tc-shared/settings";
import {key} from "tc-shared/KeyControl";
export function spawnGlobalSettingsEditor() {
const events = new Registry<ModalGlobalSettingsEditorEvents>();
@ -31,7 +30,7 @@ function initializeController(events: Registry<ModalGlobalSettingsEditorEvents>)
});
}
events.fire_async("notify_settings", { settings: settingsList });
events.fire_react("notify_settings", { settings: settingsList });
});
events.on("action_select_setting", event => {
@ -70,12 +69,12 @@ function initializeController(events: Registry<ModalGlobalSettingsEditorEvents>)
/* the change will may already trigger a notify_setting_value, but just to ensure we're fiering it later as well */
settings.changeGlobal(setting, event.value);
events.fire_async("notify_setting_value", { setting: event.setting, value: event.value });
events.fire_react("notify_setting_value", { setting: event.setting, value: event.value });
});
events.on("notify_destroy", settings.events.on("notify_setting_changed", event => {
if(event.mode === "global") {
events.fire_async("notify_setting_value", { setting: event.setting, value: event.newValue });
events.fire_react("notify_setting_value", { setting: event.setting, value: event.newValue });
}
}));
}

View File

@ -410,7 +410,7 @@ const stringifyError = error => {
function initializePermissionModalController(connection: ConnectionHandler, events: Registry<PermissionModalEvents>) {
events.on("query_groups", event => {
const groups = event.target === "server" ? connection.groups.serverGroups : connection.groups.channelGroups;
events.fire_async("query_groups_result", {
events.fire_react("query_groups_result", {
target: event.target, groups: groups.map(group => {
return {
id: group.id,
@ -706,7 +706,7 @@ function initializePermissionModalController(connection: ConnectionHandler, even
}));
events.on("query_channels", () => {
events.fire_async("query_channels_result", {
events.fire_react("query_channels_result", {
channels: connection.channelTree.channelsOrdered().map(e => {
return {
id: e.channelId,
@ -888,7 +888,7 @@ function initializePermissionEditor(connection: ConnectionHandler, modalEvents:
};
};
events.fire_async("query_permission_list_result", {
events.fire_react("query_permission_list_result", {
hideSenselessPermissions: !settings.static_global(Settings.KEY_PERMISSIONS_SHOW_ALL),
permissions: (groups || []).map(visitGroup)
});

View File

@ -1025,7 +1025,7 @@ const ClientSelect = (props: { events: Registry<PermissionModalEvents>, tabTarge
refInput.current.setValue(typeof event.id === "undefined" ? "" : event.id.toString());
if (typeof event.id === "number" || typeof event.id === "string") {
/* first do the state update */
props.events.fire_async("query_client_info", {client: event.id});
props.events.fire_react("query_client_info", {client: event.id});
} else {
refInput.current?.setValue(undefined);
resetInfoFields(undefined);

View File

@ -356,7 +356,7 @@ function initialize_timeouts(event_registry: Registry<KeyMapEvents>) {
function initialize_controller(event_registry: Registry<KeyMapEvents>) {
event_registry.on("query_keymap", event => {
event_registry.fire_async("query_keymap_result", {
event_registry.fire_react("query_keymap_result", {
status: "success",
action: event.action,
key: keycontrol.key(event.action)
@ -366,10 +366,10 @@ function initialize_controller(event_registry: Registry<KeyMapEvents>) {
event_registry.on("set_keymap", event => {
try {
keycontrol.set_key(event.action, event.key);
event_registry.fire_async("set_keymap_result", {status: "success", action: event.action, key: event.key});
event_registry.fire_react("set_keymap_result", {status: "success", action: event.action, key: event.key});
} catch (error) {
console.warn("Failed to change key for action %s: %o", event.action, error);
event_registry.fire_async("set_keymap_result", {
event_registry.fire_react("set_keymap_result", {
status: "error",
action: event.action,
error: error instanceof Error ? error.message : error?.toString()

View File

@ -159,21 +159,21 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
{
events.on("query_devices", event => {
if (!aplayer.initialized()) {
events.fire_async("notify_devices", {status: "audio-not-initialized"});
events.fire_react("notify_devices", {status: "audio-not-initialized"});
return;
}
const deviceList = recorderBackend.getDeviceList();
switch (deviceList.getStatus()) {
case "no-permissions":
events.fire_async("notify_devices", {
events.fire_react("notify_devices", {
status: "no-permissions",
shouldAsk: deviceList.getPermissionState() === "denied"
});
return;
case "uninitialized":
events.fire_async("notify_devices", {status: "audio-not-initialized"});
events.fire_react("notify_devices", {status: "audio-not-initialized"});
return;
}
@ -184,7 +184,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
} else {
const devices = deviceList.getDevices();
events.fire_async("notify_devices", {
events.fire_react("notify_devices", {
status: "success",
selectedDevice: defaultRecorder.getDeviceId(),
devices: devices.map(e => {
@ -197,7 +197,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
events.on("action_set_selected_device", event => {
const device = recorderBackend.getDeviceList().getDevices().find(e => e.deviceId === event.deviceId);
if (!device && event.deviceId !== IDevice.NoDeviceId) {
events.fire_async("action_set_selected_device_result", {
events.fire_react("action_set_selected_device_result", {
status: "error",
error: tr("Invalid device id"),
deviceId: defaultRecorder.getDeviceId()
@ -207,10 +207,10 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
defaultRecorder.setDevice(device).then(() => {
console.debug(tr("Changed default microphone device to %s"), event.deviceId);
events.fire_async("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
}).catch((error) => {
log.warn(LogCategory.AUDIO, tr("Failed to change microphone to device %s: %o"), device ? device.deviceId : IDevice.NoDeviceId, error);
events.fire_async("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
events.fire_react("action_set_selected_device_result", {status: "success", deviceId: event.deviceId});
});
});
}
@ -252,7 +252,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
return;
}
events.fire_async("notify_setting", {setting: event.setting, value: value});
events.fire_react("notify_setting", {setting: event.setting, value: value});
});
events.on("action_set_setting", event => {
@ -310,7 +310,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
default:
return;
}
events.fire_async("notify_setting", {setting: event.setting, value: event.value});
events.fire_react("notify_setting", {setting: event.setting, value: event.value});
});
}
@ -320,7 +320,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
if (result === "granted") {
/* we've nothing to do, the device change event will already update out list */
} else {
events.fire_async("notify_devices", {status: "no-permissions", shouldAsk: result === "denied"});
events.fire_react("notify_devices", {status: "no-permissions", shouldAsk: result === "denied"});
return;
}
}));
@ -335,7 +335,7 @@ export function initialize_audio_microphone_controller(events: Registry<Micropho
if (!aplayer.initialized()) {
aplayer.on_ready(() => {
events.fire_async("query_devices");
events.fire_react("query_devices");
});
}
}

View File

@ -100,7 +100,7 @@ const EventTableHeader = (props: { focus: boolean }) => {
const EventTableEntry = React.memo((props: { events: Registry<NotificationSettingsEvents>, event: string, depth: number, focusEnabled: boolean }) => {
const [name, setName] = useState(() => {
props.events.fire_async("query_event_info", {key: props.event});
props.events.fire_react("query_event_info", {key: props.event});
return undefined;
});
const [notificationState, setNotificationState] = useState<NotificationState>("unavailable");
@ -306,7 +306,7 @@ const EventFilter = (props: { events: Registry<NotificationSettingsEvents> }) =>
className={cssStyle.input}
label={<Translatable>Filter Events</Translatable>}
labelType={"floating"}
onChange={text => props.events.fire_async("action_set_filter", {filter: text})}
onChange={text => props.events.fire_react("action_set_filter", {filter: text})}
/>
</div>
)
@ -437,13 +437,13 @@ function initializeController(events: Registry<NotificationSettingsEvents>) {
return result;
};
events.fire_async("notify_events", {
events.fire_react("notify_events", {
groups: knownEventGroups.map(groupMapper).filter(e => !!e),
focusEnabled: __build.target === "client"
});
});
events.on("query_event_info", event => {
events.fire_async("notify_event_info", {
events.fire_react("notify_event_info", {
key: event.key,
name: groupNames[event.key] || event.key,
log: settings.global(Settings.FN_EVENTS_LOG_ENABLED(event.key), true) ? "enabled" : "disabled",
@ -467,7 +467,7 @@ function initializeController(events: Registry<NotificationSettingsEvents>) {
break;
}
events.fire_async("notify_set_state_result", {
events.fire_react("notify_set_state_result", {
key: event.key,
state: event.state,
value: event.value

View File

@ -1000,7 +1000,7 @@ export class FileBrowser extends ReactComponentBase<FileListTableProperties, Fil
});
/* fire_async because our children have to render first in order to have the row selected! */
this.forceUpdate(() => this.props.events.fire_async("action_select_files", {
this.forceUpdate(() => this.props.events.fire_react("action_select_files", {
files: [{
name: name,
type: FileType.DIRECTORY

View File

@ -79,13 +79,13 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
try {
const info = parsePath(event.path, connection);
events.fire_async("action_navigate_to_result", {
events.fire_react("action_navigate_to_result", {
path: event.path || "/",
status: "success",
pathInfo: info
});
} catch (error) {
events.fire_async("action_navigate_to_result", {
events.fire_react("action_navigate_to_result", {
path: event.path,
status: "error",
error: error
@ -98,7 +98,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
try {
path = parsePath(event.path, connection);
} catch (error) {
events.fire_async("query_files_result", {
events.fire_react("query_files_result", {
path: event.path,
status: "error",
error: error
@ -213,7 +213,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
} as ListedFileInfo;
}));
} else {
events.fire_async("query_files_result", {
events.fire_react("query_files_result", {
path: event.path,
status: "error",
error: tr("Unknown parsed path type")
@ -222,7 +222,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
}
request.then(files => {
events.fire_async("query_files_result", {
events.fire_react("query_files_result", {
path: event.path,
status: "success",
files: files.map(e => {
@ -235,14 +235,14 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
if (error instanceof CommandResult) {
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
events.fire_async("query_files_result", {
events.fire_react("query_files_result", {
path: event.path,
status: "no-permissions",
error: permission ? permission.name : "unknown"
});
return;
} else if (error.id === 781) { //Invalid password
events.fire_async("query_files_result", {
events.fire_react("query_files_result", {
path: event.path,
status: "invalid-password"
});
@ -257,7 +257,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
message = tr("lookup the console");
}
events.fire_async("query_files_result", {
events.fire_react("query_files_result", {
path: event.path,
status: "error",
error: message
@ -267,7 +267,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
events.on("action_rename_file", event => {
if (event.newPath === event.oldPath && event.newName === event.oldName) {
events.fire_async("action_rename_file_result", {
events.fire_react("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
@ -285,7 +285,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
if (sourcePath.type !== "channel")
throw tr("Icon/avatars could not be renamed");
} catch (error) {
events.fire_async("action_rename_file_result", {
events.fire_react("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
@ -298,7 +298,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
if (sourcePath.type !== "channel")
throw tr("Target path isn't a channel");
} catch (error) {
events.fire_async("action_rename_file_result", {
events.fire_react("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
@ -335,7 +335,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
if (error instanceof CommandResult) {
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
events.fire_async("action_rename_file_result", {
events.fire_react("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
@ -343,7 +343,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
});
return;
} else if (error.id === 781) { //Invalid password
events.fire_async("action_rename_file_result", {
events.fire_react("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
@ -359,7 +359,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
log.error(LogCategory.FILE_TRANSFER, tr("Failed to rename/move files: %o"), error);
message = tr("lookup the console");
}
events.fire_async("action_rename_file_result", {
events.fire_react("action_rename_file_result", {
oldPath: event.oldPath,
oldName: event.oldName,
status: "error",
@ -532,7 +532,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
let message;
if (result instanceof CommandResult) {
if (result.bulks.length !== fileInfos.length) {
events.fire_async("action_delete_file_result", {
events.fire_react("action_delete_file_result", {
results: fileInfos.map((e) => {
return {
error: result.bulks.length === 1 ? (result.message + (result.extra_message ? " (" + result.extra_message + ")" : "")) : tr("Response contained invalid bulk length"),
@ -582,7 +582,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
return;
});
events.fire_async("action_delete_file_result", {
events.fire_react("action_delete_file_result", {
results: results
});
return;
@ -593,7 +593,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
message = tr("lookup the console");
}
events.fire_async("action_delete_file_result", {
events.fire_react("action_delete_file_result", {
results: files.map((e) => {
return {
error: message,
@ -605,7 +605,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
});
});
} catch (error) {
events.fire_async("action_delete_file_result", {
events.fire_react("action_delete_file_result", {
results: files.map((e) => {
return {
error: tr("Failed to parse path for one or more entries ") + " (" + error + ")",
@ -625,7 +625,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
if (path.type !== "channel")
throw tr("Directories could only created for channels");
} catch (error) {
events.fire_async("action_create_directory_result", {
events.fire_react("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",
@ -646,7 +646,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
if (error instanceof CommandResult) {
if (error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
const permission = connection.permissions.resolveInfo(error.json["failed_permid"] as number);
events.fire_async("action_create_directory_result", {
events.fire_react("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",
@ -654,7 +654,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
});
return;
} else if (error.id === 781) { //Invalid password
events.fire_async("action_create_directory_result", {
events.fire_react("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",
@ -670,7 +670,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
log.error(LogCategory.FILE_TRANSFER, tr("Failed to create directory: %o"), error);
message = tr("lookup the console");
}
events.fire_async("action_create_directory_result", {
events.fire_react("action_create_directory_result", {
name: event.name,
path: event.path,
status: "error",

View File

@ -90,7 +90,7 @@ export const initializeTransferInfoController = (connection: ConnectionHandler,
} as TransferInfoData;
}));
events.fire_async("query_transfer_result", {
events.fire_react("query_transfer_result", {
status: "success",
transfers: transfers,
showFinished: settings.global(Settings.KEY_TRANSFERS_SHOW_FINISHED)

View File

@ -0,0 +1,174 @@
import {Registry} from "tc-shared/events";
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {ModalVideoSourceEvents} from "tc-shared/ui/modal/video-source/Definitions";
import {ModalVideoSource} from "tc-shared/ui/modal/video-source/Renderer";
import {getVideoDriver, VideoPermissionStatus, VideoSource} from "tc-shared/video/VideoSource";
import {LogCategory, logError} from "tc-shared/log";
type VideoSourceRef = { source: VideoSource };
export async function spawnVideoSourceSelectModal() : Promise<VideoSource> {
const refSource: VideoSourceRef = {
source: undefined
};
const events = new Registry<ModalVideoSourceEvents>();
events.enableDebug("video-source-select");
initializeController(events, refSource);
const modal = spawnReactModal(ModalVideoSource, events);
modal.events.on("destroy", () => {
events.fire("notify_destroy");
events.destroy();
});
events.on(["action_start", "action_cancel"], () => {
modal.destroy();
});
modal.show().then(undefined);
await new Promise(resolve => {
modal.events.one(["destroy", "close"], resolve);
});
return refSource.source;
}
function initializeController(events: Registry<ModalVideoSourceEvents>, currentSourceRef: VideoSourceRef) {
let currentSource: VideoSource | string;
let currentSourceId: string;
let fallbackCurrentSourceName: string;
const notifyStartButton = () => {
events.fire_react("notify_start_button", { enabled: typeof currentSource === "object" })
};
const notifyDeviceList = () => {
const driver = getVideoDriver();
driver.getDevices().then(devices => {
if(devices === false) {
if(driver.getPermissionStatus() === VideoPermissionStatus.SystemDenied) {
events.fire_react("notify_device_list", { status: { status: "error", reason: "no-permissions" } });
} else {
events.fire_react("notify_device_list", { status: { status: "error", reason: "request-permissions" } });
}
} else {
events.fire_react("notify_device_list", {
status: {
status: "success",
devices: devices.map(e => { return { id: e.id, displayName: e.name }}),
selectedDeviceId: currentSourceId,
fallbackSelectedDeviceName: fallbackCurrentSourceName
}
});
}
});
}
const notifyCurrentSource = () => {
const driver = getVideoDriver();
switch (driver.getPermissionStatus()) {
case VideoPermissionStatus.SystemDenied:
events.fire_react("notify_video_preview", { status: { status: "error", reason: "no-permissions" }});
break;
case VideoPermissionStatus.UserDenied:
events.fire_react("notify_video_preview", { status: { status: "error", reason: "request-permissions" }});
break;
case VideoPermissionStatus.Granted:
if(typeof currentSource === "string") {
events.fire_react("notify_video_preview", { status: {
status: "error",
reason: "custom",
message: currentSource
}});
} else if(currentSource) {
events.fire_react("notify_video_preview", { status: {
status: "preview",
stream: currentSource.getStream()
}});
} else {
events.fire_react("notify_video_preview", { status: { status: "none" }});
}
break;
}
};
const setCurrentSource = (source: VideoSource | string | undefined) => {
if(typeof currentSource === "object") {
currentSource.deref();
}
if(typeof source === "object") {
currentSourceRef.source = source;
}
currentSource = source;
notifyCurrentSource();
notifyStartButton();
}
events.on("query_device_list", () => notifyDeviceList());
events.on("query_video_preview", () => notifyCurrentSource());
events.on("query_start_button", () => notifyStartButton());
events.on("action_request_permissions", () => {
getVideoDriver().requestPermissions().then(result => {
if(typeof result === "object") {
currentSourceId = result.getId() + " --";
fallbackCurrentSourceName = result.getName();
notifyDeviceList();
setCurrentSource(result);
} else {
/* the device list will already be updated due to the notify_permissions_changed event */
}
});
});
events.on("action_select_source", event => {
const driver = getVideoDriver();
currentSourceId = event.id;
fallbackCurrentSourceName = tr("loading...");
notifyDeviceList();
driver.createVideoSource(event.id).then(stream => {
setCurrentSource(stream);
fallbackCurrentSourceName = stream.getName();
}).catch(error => {
fallbackCurrentSourceName = "invalid device";
if(typeof error === "string") {
setCurrentSource(error);
} else {
logError(LogCategory.GENERAL, tr("Failed to open video device %s: %o"), event.id, error);
setCurrentSource(tr("Failed to open video device (Lookup the console)"));
}
});
});
events.on("action_cancel", () => {
if(typeof currentSource === "object") {
currentSourceRef.source = undefined;
currentSource.deref();
}
});
events.on("notify_destroy", getVideoDriver().getEvents().on("notify_permissions_changed", () => {
if(getVideoDriver().getPermissionStatus() !== VideoPermissionStatus.Granted) {
currentSourceId = undefined;
fallbackCurrentSourceName = undefined;
notifyDeviceList();
/* implicitly updates the start button */
setCurrentSource(undefined);
} else {
notifyDeviceList();
notifyCurrentSource();
notifyStartButton();
}
}));
events.on("notify_destroy", () => {
if(typeof currentSource === "object" && currentSourceRef.source !== currentSource) {
currentSource.deref();
}
});
}

View File

@ -0,0 +1,37 @@
export type DeviceListResult = {
status: "success",
devices: { id: string, displayName: string }[],
selectedDeviceId: string | undefined,
fallbackSelectedDeviceName: string | undefined
} | {
status: "error",
reason: "no-permissions" | "request-permissions" | "custom"
};
export type VideoPreviewStatus = {
status: "preview",
stream: MediaStream /* Attention: This makes this window non popoutable! */
} | {
status: "error",
reason: "no-permissions" | "request-permissions" | "custom",
message?: string
} | {
status: "none";
}
export interface ModalVideoSourceEvents {
action_cancel: {},
action_start: {},
action_request_permissions: {},
action_select_source: { id: string },
query_device_list: {},
query_video_preview: {},
query_start_button: {},
notify_device_list: { status: DeviceListResult },
notify_video_preview: { status: VideoPreviewStatus },
notify_start_button: { enabled: boolean },
notify_destroy: {}
}

View File

@ -0,0 +1,126 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 1em;
@include user-select(none);
.content {
position: relative;
.overlay {
z-index: 10;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--modal-content-background);
&.noPermissions {
}
}
.section {
margin-bottom: 1em;
.head {
flex-grow: 1;
flex-shrink: 1;
align-self: flex-end;
font-weight: bold;
color: #e0e0e0;
@include text-dotdotdot();
}
.body {
.selectError {
color: #a10000;
}
.videoContainer {
position: relative;
width: 37.5em; /* 600px for 16px/em */
height: 25em; /* 400px for 16px/em */
border-radius: .2em;
border: 1px solid var(--boxed-input-field-border);
background-color: var(--boxed-input-field-background);
display: flex;
flex-direction: column;
justify-content: center;
video {
max-height: 100%;
max-width: 100%;
align-self: center;
}
.overlay {
z-index: 10;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: none;
flex-direction: column;
justify-content: center;
text-align: center;
background-color: var(--boxed-input-field-background);
&.shown {
display: flex;
}
&.permissions {
.text {
font-size: 1.2em;
padding-bottom: 1em;
}
.button {
width: min-content;
align-self: center;
}
}
.error {
font-size: 1.2em;
color: #a10000;
padding-bottom: 1em;
}
.info {
font-size: 1.8em;
padding-bottom: 1em;
font-weight: 600;
color: #4d4d4d;
}
}
}
}
}
}
.buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
}
}

View File

@ -0,0 +1,246 @@
import {Registry} from "tc-shared/events";
import * as React from "react";
import {
DeviceListResult,
ModalVideoSourceEvents,
VideoPreviewStatus
} from "tc-shared/ui/modal/video-source/Definitions";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {Select} from "tc-shared/ui/react-elements/InputField";
import {Button} from "tc-shared/ui/react-elements/Button";
import {useContext, useEffect, useRef, useState} from "react";
const cssStyle = require("./Renderer.scss");
const ModalEvents = React.createContext<Registry<ModalVideoSourceEvents>>(undefined);
const kNoDeviceId = "__no__device";
const VideoSourceBody = () => {
const events = useContext(ModalEvents);
const [ deviceList, setDeviceList ] = useState<DeviceListResult | "loading">(() => {
events.fire("query_device_list");
return "loading";
});
events.reactUse("notify_device_list", event => setDeviceList(event.status));
if(deviceList === "loading") {
return (
<div className={cssStyle.body} key={"loading"}>
<Select type={"boxed"} disabled={true}>
<option>{tr("loading ...")}</option>
</Select>
</div>
);
} else if(deviceList.status === "error") {
let message;
switch (deviceList.reason) {
case "no-permissions":
message = tr("Missing device query permissions");
break;
case "request-permissions":
message = tr("Please grant video device permissions");
break;
case "custom":
message = tr("An error happened");
break;
}
return (
<div className={cssStyle.body} key={"error"}>
<Select type={"boxed"} disabled={true} className={cssStyle.selectError}>
<option>{message}</option>
</Select>
</div>
);
} else {
return (
<div className={cssStyle.body} key={"normal"}>
<Select
type={"boxed"}
value={deviceList.selectedDeviceId || kNoDeviceId}
onChange={event => events.fire("action_select_source", { id: event.target.value })}
>
<option key={kNoDeviceId} value={kNoDeviceId} style={{ display: "none" }}>{tr("No device")}</option>
{deviceList.devices.map(device => <option value={device.id} key={device.id}>{device.displayName}</option>)}
{deviceList.devices.findIndex(device => device.id === deviceList.selectedDeviceId) === -1 ?
<option key={"selected-device-" + deviceList.selectedDeviceId} style={{ display: "none" }} value={deviceList.selectedDeviceId}>
{deviceList.fallbackSelectedDeviceName}
</option> :
undefined
}
</Select>
</div>
);
}
};
const VideoPreviewMessage = (props: { message: any, kind: "info" | "error" }) => {
return (
<div className={cssStyle.overlay + " " + (props.message ? cssStyle.shown : "")}>
<div className={cssStyle[props.kind]}>
{props.message}
</div>
</div>
);
}
const VideoRequestPermissions = (props: { systemDenied: boolean }) => {
const events = useContext(ModalEvents);
let body;
let button;
if(props.systemDenied) {
body = (
<div className={cssStyle.text} key={"system-denied"}>
<Translatable>Camara access has been denied by your browser.<br />Please allow camara access in order to broadcast video.</Translatable>
</div>
);
button = <Translatable key={"retry"}>Retry to query</Translatable>;
} else {
body = (
<div className={cssStyle.text} key={"user-denied"}>
<Translatable>In order to be able to broadcast video,<br /> you have to allow camara access.</Translatable>
</div>
);
button = <Translatable key={"request"}>Request permissions</Translatable>;
}
return (
<div className={cssStyle.overlay + " " + cssStyle.shown + " " + cssStyle.permissions}>
{body}
<Button
type={"normal"}
color={"green"}
className={cssStyle.button}
onClick={() => events.fire("action_request_permissions")}
>
{button}
</Button>
</div>
);
}
const VideoPreview = () => {
const events = useContext(ModalEvents);
const refVideo = useRef<HTMLVideoElement>();
const [ status, setStatus ] = useState<VideoPreviewStatus | "loading">(() => {
events.fire("query_video_preview");
return "loading";
});
events.reactUse("notify_video_preview", event => {
setStatus(event.status);
});
let body;
if(status === "loading") {
/* Nothing to show */
} else {
switch (status.status) {
case "none":
body = <VideoPreviewMessage message={tr("No video source")} kind={"info"} key={"none"} />;
break;
case "error":
if(status.reason === "no-permissions" || status.reason === "request-permissions") {
body = <VideoRequestPermissions systemDenied={status.reason === "no-permissions"} key={"permissions"} />;
} else {
body = <VideoPreviewMessage message={status.message} kind={"error"} key={"error"} />;
}
break;
case "preview":
body = (
<video
key={"preview"}
ref={refVideo}
autoPlay={true}
/>
)
break;
}
}
useEffect(() => {
const stream = status !== "loading" && status.status === "preview" && status.stream;
if(stream && refVideo.current) {
refVideo.current.srcObject = stream;
}
}, [status !== "loading" && status.status === "preview" && status.stream])
return (
<div className={cssStyle.body}>
<div className={cssStyle.videoContainer}>
{body}
</div>
</div>
);
}
const ButtonStart = () => {
const events = useContext(ModalEvents);
const [ enabled, setEnabled ] = useState(() => {
events.fire("query_start_button");
return false;
});
events.reactUse("notify_start_button", event => setEnabled(event.enabled));
return (
<Button
type={"small"}
color={"green"}
disabled={!enabled}
onClick={() => enabled && events.fire("action_start")}
>
<Translatable>Start</Translatable>
</Button>
);
}
export class ModalVideoSource extends InternalModal {
protected readonly events: Registry<ModalVideoSourceEvents>;
constructor(events: Registry<ModalVideoSourceEvents>) {
super();
this.events = events;
}
renderBody(): React.ReactElement {
return (
<ModalEvents.Provider value={this.events}>
<div className={cssStyle.container}>
<div className={cssStyle.content}>
<div className={cssStyle.section}>
<div className={cssStyle.head}>
<Translatable>Select your source</Translatable>
</div>
<VideoSourceBody />
</div>
<div className={cssStyle.section}>
<div className={cssStyle.head}>
<Translatable>Video preview</Translatable>
</div>
<div className={cssStyle.body}>
<VideoPreview />
</div>
</div>
{ /* TODO: All the overlays */ }
</div>
<div className={cssStyle.buttons}>
<Button type={"small"} color={"red"} onClick={() => this.events.fire("action_cancel")}>
<Translatable>Cancel</Translatable>
</Button>
<ButtonStart />
</div>
</div>
</ModalEvents.Provider>
);
}
title(): string | React.ReactElement<Translatable> {
return <Translatable>Start video Broadcasting</Translatable>;
}
}

View File

@ -35,7 +35,7 @@ html:root {
@include text-dotdotdot();
&:hover {
background-color: #0a0a0a;
background-color: #121212;
}
&:disabled {

View File

@ -69,7 +69,7 @@ html:root {
padding-left: 0;
}
&.is-invalid {
&.isInvalid {
background-color: var(--boxed-input-field-invalid-background);
border-color: var(--boxed-input-field-invalid-border);
color: var(--boxed-input-field-invalid-text);

View File

@ -1,5 +1,6 @@
import * as React from "react";
import {ReactElement} from "react";
import {AST_Export} from "terser";
const cssStyle = require("./InputField.scss");
@ -223,7 +224,9 @@ export class FlatInputField extends React.Component<FlatInputFieldProperties, Fl
}
export interface FlatSelectProperties {
export interface SelectProperties {
type?: "flat" | "boxed";
defaultValue?: string;
value?: string;
@ -246,14 +249,14 @@ export interface FlatSelectProperties {
onChange?: (event?: React.ChangeEvent<HTMLSelectElement>) => void;
}
export interface FlatSelectFieldState {
export interface SelectFieldState {
disabled?: boolean;
isInvalid: boolean;
invalidMessage: string | React.ReactElement;
}
export class FlatSelect extends React.Component<FlatSelectProperties, FlatSelectFieldState> {
export class Select extends React.Component<SelectProperties, SelectFieldState> {
private refSelect = React.createRef<HTMLSelectElement>();
constructor(props) {
@ -268,7 +271,7 @@ export class FlatSelect extends React.Component<FlatSelectProperties, FlatSelect
render() {
const disabled = typeof this.state.disabled === "boolean" ? this.state.disabled : typeof this.props.disabled === "boolean" ? this.props.disabled : false;
return (
<div className={cssStyle.containerFlat + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " + (this.props.className || "")}>
<div className={(this.props.type === "boxed" ? cssStyle.containerBoxed : cssStyle.containerFlat) + " " + (this.state.isInvalid ? cssStyle.isInvalid : "") + " " + (this.props.className || "") + " " + cssStyle.noLeftIcon + " " + cssStyle.noRightIcon}>
{this.props.label ?
<label className={cssStyle["type-static"] + " " + (this.props.labelClassName || "")}>{this.props.label}</label> : undefined}
<select
@ -308,16 +311,6 @@ export class FlatSelect extends React.Component<FlatSelectProperties, FlatSelect

View File

@ -6,7 +6,8 @@ export interface PopoutIPCMessage {
"hello-controller": { accepted: boolean, message?: string, userData?: any, registries?: string[] },
"fire-event": {
type: string;
type: "sync" | "react" | "later";
eventType: string;
payload: any;
callbackId: string;
registry: string;
@ -62,6 +63,23 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
private createEventReceiver(key: string) : EventReceiver {
let refThis = this;
const fireEvent = (type: "react" | "later", eventType: any, data?: any[], callback?: () => void) => {
const callbackId = callback ? (++callbackIdIndex) + "-ev-cb" : undefined;
refThis.sendIPCMessage("fire-event", { type: type, eventType: eventType, payload: data, callbackId: callbackId, registry: key });
if(callbackId) {
const timeout = setTimeout(() => {
delete refThis.eventFiredListeners[callbackId];
callback();
}, 2500);
refThis.eventFiredListeners[callbackId] = {
callback: callback,
timeout: timeout
}
}
};
return new class implements EventReceiver {
fire<T extends keyof {}>(eventType: T, data?: any[T], overrideTypeKey?: boolean) {
if(refThis.omitEventType === eventType && refThis.omitEventData === data) {
@ -69,23 +87,15 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
return;
}
refThis.sendIPCMessage("fire-event", { type: eventType, payload: data, callbackId: undefined, registry: key });
refThis.sendIPCMessage("fire-event", { type: "sync", eventType: eventType, payload: data, callbackId: undefined, registry: key });
}
fire_async<T extends keyof {}>(eventType: T, data?: any[T], callback?: () => void) {
const callbackId = callback ? (++callbackIdIndex) + "-ev-cb" : undefined;
refThis.sendIPCMessage("fire-event", { type: eventType, payload: data, callbackId: callbackId, registry: key });
if(callbackId) {
const timeout = setTimeout(() => {
delete refThis.eventFiredListeners[callbackId];
callback();
}, 2500);
fire_later<T extends keyof { [p: string]: any }>(eventType: T, data?: { [p: string]: any }[T], callback?: () => void) {
fireEvent("later", eventType, data, callback);
}
refThis.eventFiredListeners[callbackId] = {
callback: callback,
timeout: timeout
}
}
fire_react<T extends keyof {}>(eventType: T, data?: any[T], callback?: () => void) {
fireEvent("react", eventType, data, callback);
}
};
}
@ -107,9 +117,11 @@ export abstract class EventControllerBase<Type extends "controller" | "popout">
switch (type) {
case "fire-event": {
const tpayload = payload as PopoutIPCMessage["fire-event"];
/* FIXME: Pay respect to the different event types and may bundle react updates! */
this.omitEventData = tpayload.payload;
this.omitEventType = tpayload.type;
this.localRegistries[tpayload.registry].fire(tpayload.type as any, tpayload.payload);
this.omitEventType = tpayload.eventType;
this.localRegistries[tpayload.registry].fire(tpayload.eventType, tpayload.payload);
if(tpayload.callbackId)
this.sendIPCMessage("fire-event-callback", { callbackId: tpayload.callbackId });
break;

View File

@ -345,7 +345,7 @@ class ChannelTreeController {
/* notify state update methods */
public sendPopoutState() {
this.events.fire_async("notify_popout_state", {
this.events.fire_react("notify_popout_state", {
showButton: this.options.popoutButton,
shown: this.channelTree.popoutController.hasBeenPopedOut()
});
@ -378,11 +378,11 @@ class ChannelTreeController {
this.channelTree.rootChannel().forEach(entry => buildSubTree(entry, 1));
this.events.fire_async("notify_tree_entries", { entries: entries });
this.events.fire_react("notify_tree_entries", { entries: entries });
}
public sendChannelInfo(channel: ChannelEntry) {
this.events.fire_async("notify_channel_info", {
this.events.fire_react("notify_channel_info", {
treeEntryId: channel.uniqueEntryId,
info: {
collapsedState: channel.child_channel_head || channel.channelClientsOrdered().length > 0 ? channel.collapsed ? "collapsed" : "expended" : "unset",
@ -393,7 +393,7 @@ class ChannelTreeController {
}
public sendChannelStatusIcon(channel: ChannelEntry) {
this.events.fire_async("notify_channel_icon", { icon: channel.getStatusIcon(), treeEntryId: channel.uniqueEntryId });
this.events.fire_react("notify_channel_icon", { icon: channel.getStatusIcon(), treeEntryId: channel.uniqueEntryId });
}
public sendChannelIcons(channel: ChannelEntry) {
@ -421,7 +421,7 @@ class ChannelTreeController {
icons.codecUnsupported = true;
}
this.events.fire_async("notify_channel_icons", { icons: icons, treeEntryId: channel.uniqueEntryId });
this.events.fire_react("notify_channel_icons", { icons: icons, treeEntryId: channel.uniqueEntryId });
}
public sendClientNameInfo(client: ClientEntry) {
@ -450,7 +450,7 @@ class ChannelTreeController {
}
const afkMessage = client.properties.client_away ? client.properties.client_away_message : undefined;
this.events.fire_async("notify_client_name", {
this.events.fire_react("notify_client_name", {
info: {
name: client.clientNickName(),
awayMessage: afkMessage,
@ -476,7 +476,7 @@ class ChannelTreeController {
.map(group => { return { iconId: group.properties.iconid, groupName: group.name, groupId: group.id, serverUniqueId: uniqueServerId }; });
const clientIcon = client.properties.client_icon_id === 0 ? [] : [client.properties.client_icon_id];
this.events.fire_async("notify_client_icons", {
this.events.fire_react("notify_client_icons", {
icons: {
serverGroupIcons: serverGroupIcons,
channelGroupIcon: channelGroupIcon[0],
@ -499,7 +499,7 @@ class ChannelTreeController {
}
}
this.events.fire_async("notify_client_talk_status", { treeEntryId: client.uniqueEntryId, requestMessage: client.properties.client_talk_request_msg, status: status });
this.events.fire_react("notify_client_talk_status", { treeEntryId: client.uniqueEntryId, requestMessage: client.properties.client_talk_request_msg, status: status });
}
public sendServerStatus(serverEntry: ServerEntry) {
@ -529,7 +529,7 @@ class ChannelTreeController {
break;
}
this.events.fire_async("notify_server_state", { treeEntryId: serverEntry.uniqueEntryId, state: status });
this.events.fire_react("notify_server_state", { treeEntryId: serverEntry.uniqueEntryId, state: status });
}
}
@ -549,7 +549,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
return;
}
events.fire_async("notify_unread_state", { treeEntryId: event.treeEntryId, unread: entry.isUnread() });
events.fire_react("notify_unread_state", { treeEntryId: event.treeEntryId, unread: entry.isUnread() });
});
events.on("query_select_state", event => {
@ -559,7 +559,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
return;
}
events.fire_async("notify_select_state", { treeEntryId: event.treeEntryId, selected: entry.isSelected() });
events.fire_react("notify_select_state", { treeEntryId: event.treeEntryId, selected: entry.isSelected() });
});
events.on("notify_destroy", channelTree.client.events().on("notify_visibility_changed", event => events.fire("notify_visibility_changed", event)));
@ -599,7 +599,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
return;
}
events.fire_async("notify_client_status", { treeEntryId: entry.uniqueEntryId, status: entry.getStatusIcon() });
events.fire_react("notify_client_status", { treeEntryId: entry.uniqueEntryId, status: entry.getStatusIcon() });
});
events.on("query_client_name", event => {
const entry = channelTree.findEntryId(event.treeEntryId);
@ -785,7 +785,7 @@ export function initializeChannelTreeController(events: Registry<ChannelTreeUIEv
if(selection.findIndex(element => !(element instanceof ClientEntry)) !== -1) { return; }
moveSelection = selection as any;
events.fire_async("notify_entry_move", { entries: selection.map(client => (client as ClientEntry).clientNickName()).join(", "), begin: event.start, current: event.current });
events.fire_react("notify_entry_move", { entries: selection.map(client => (client as ClientEntry).clientNickName()).join(", "), begin: event.start, current: event.current });
});
events.on("action_move_entries", event => {

View File

@ -203,7 +203,7 @@ export class RendererClient extends React.Component<{ client: RDPClient }, {}> {
<ClientStatus client={client} ref={client.refStatus} />
{...(client.rename ? [
<ClientNameEdit initialName={client.renameDefault} editFinished={value => {
events.fire_async("action_client_name_submit", { treeEntryId: client.entryId, name: value });
events.fire_react("action_client_name_submit", { treeEntryId: client.entryId, name: value });
}} key={"rename"} />
] : [
<ClientName client={client} ref={client.refName} key={"name"} />,

View File

@ -267,7 +267,7 @@ export class RDPChannelTree {
@EventHandler<ChannelTreeUIEvents>("notify_entry_move")
private handleNotifyEntryMove(event: ChannelTreeUIEvents["notify_entry_move"]) {
if(!this.refMove.current) {
this.events.fire_async("action_move_entries", { treeEntryId: 0 });
this.events.fire_react("action_move_entries", { treeEntryId: 0 });
return;
}

View File

@ -114,6 +114,6 @@ export class ChannelTreePopoutController {
break;
}
this.uiEvents.fire_async("notify_title", { title: title });
this.uiEvents.fire_react("notify_title", { title: title });
}
}

View File

@ -67,7 +67,7 @@ class VideoViewer {
return;
this.plugin.setLocalWatcherStatus(url, { status: "paused" });
this.events.fire_async("notify_video", { url: url }); /* notify the new url */
this.events.fire_react("notify_video", { url: url }); /* notify the new url */
}
setFollowing(target: W2GWatcher) {
@ -75,7 +75,7 @@ class VideoViewer {
return;
this.plugin.setLocalFollowing(target, { status: "paused" });
this.events.fire_async("notify_video", { url: target.getCurrentVideo() }); /* notify the new url */
this.events.fire_react("notify_video", { url: target.getCurrentVideo() }); /* notify the new url */
}
async open() {
@ -84,7 +84,7 @@ class VideoViewer {
private notifyWatcherList() {
const watchers = this.plugin.getCurrentWatchers().filter(e => e.getCurrentVideo() === this.currentVideoUrl);
this.events.fire_async("notify_watcher_list", {
this.events.fire_react("notify_watcher_list", {
followingWatcher: this.plugin.getLocalFollowingWatcher() ? this.plugin.getLocalFollowingWatcher().clientId + " - " + this.plugin.getLocalFollowingWatcher().clientUniqueId : undefined,
watcherIds: watchers.map(e => e.clientId + " - " + e.clientUniqueId)
})
@ -108,7 +108,7 @@ class VideoViewer {
}));
this.events.on("notify_destroy", this.plugin.events.on("notify_following_url", event => {
this.events.fire_async("notify_video", { url: event.newUrl });
this.events.fire_react("notify_video", { url: event.newUrl });
}));
this.events.on("notify_destroy", this.plugin.events.on("notify_following_watcher_status", event => {
@ -138,7 +138,7 @@ class VideoViewer {
}));
watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_nickname_changed", () => {
this.events.fire_async("notify_watcher_info", {
this.events.fire_react("notify_watcher_info", {
watcherId: watcherId,
clientId: watcher.clientId,
@ -150,21 +150,21 @@ class VideoViewer {
}));
watcherUnregisterCallbacks.push(watcher.events.on("notify_watcher_status_changed", event => {
this.events.fire_async("notify_watcher_status", {
this.events.fire_react("notify_watcher_status", {
watcherId: watcherId,
status: event.newStatus
});
}));
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_added", event => {
this.events.fire_async("notify_follower_added", {
this.events.fire_react("notify_follower_added", {
watcherId: watcherId,
followerId: followerWatcherId(event.follower)
});
}));
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_nickname_changed", event => {
this.events.fire_async("notify_watcher_info", {
this.events.fire_react("notify_watcher_info", {
watcherId: followerWatcherId(event.follower),
clientId: event.follower.clientId,
@ -176,14 +176,14 @@ class VideoViewer {
}));
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_status_changed", event => {
this.events.fire_async("notify_watcher_status", {
this.events.fire_react("notify_watcher_status", {
watcherId: followerWatcherId(event.follower),
status: event.newStatus
});
}));
watcherUnregisterCallbacks.push(watcher.events.on("notify_follower_removed", event => {
this.events.fire_async("notify_follower_removed", {
this.events.fire_react("notify_follower_removed", {
watcherId: watcherId,
followerId: followerWatcherId(event.follower)
});
@ -200,13 +200,13 @@ class VideoViewer {
const info = parseWatcherId(event.watcherId);
for(const watcher of this.plugin.getCurrentWatchers()) {
if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) {
this.events.fire_async("notify_watcher_status", { watcherId: event.watcherId, status: watcher.getWatcherStatus() });
this.events.fire_react("notify_watcher_status", { watcherId: event.watcherId, status: watcher.getWatcherStatus() });
return;
}
for(const follower of watcher.getFollowers()) {
if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) {
this.events.fire_async("notify_watcher_status", { watcherId: event.watcherId, status: follower.status });
this.events.fire_react("notify_watcher_status", { watcherId: event.watcherId, status: follower.status });
return;
}
}
@ -220,7 +220,7 @@ class VideoViewer {
const info = parseWatcherId(event.watcherId);
for(const watcher of this.plugin.getCurrentWatchers()) {
if(watcher.clientId === info.clientId && watcher.clientUniqueId === info.clientUniqueId) {
this.events.fire_async("notify_watcher_info", {
this.events.fire_react("notify_watcher_info", {
watcherId: event.watcherId,
clientName: watcher.getWatcherName(),
clientUniqueId: watcher.clientUniqueId,
@ -232,7 +232,7 @@ class VideoViewer {
for(const follower of watcher.getFollowers()) {
if(follower.clientUniqueId === info.clientUniqueId && follower.clientId === info.clientId) {
this.events.fire_async("notify_watcher_info", {
this.events.fire_react("notify_watcher_info", {
watcherId: event.watcherId,
clientName: follower.clientNickname,
clientUniqueId: follower.clientUniqueId,
@ -254,7 +254,7 @@ class VideoViewer {
if(watcher.clientId !== info.clientId || watcher.clientUniqueId !== info.clientUniqueId)
continue;
this.events.fire_async("notify_follower_list", {
this.events.fire_react("notify_follower_list", {
followerIds: watcher.getFollowers().map(e => followerWatcherId(e)),
watcherId: event.watcherId
});
@ -266,7 +266,7 @@ class VideoViewer {
@EventHandler<VideoViewerEvents>("query_video")
private handleQueryVideo() {
this.events.fire_async("notify_video", { url: this.currentVideoUrl });
this.events.fire_react("notify_video", { url: this.currentVideoUrl });
}
@EventHandler<VideoViewerEvents>("notify_local_status")

View File

@ -295,7 +295,7 @@ const PlayerController = React.memo((props: { events: Registry<VideoViewerEvents
const [ mode, setMode ] = useState<"watcher" | "follower">("watcher");
const [ videoUrl, setVideoUrl ] = useState<"querying" | string>(() => {
props.events.fire_async("query_video");
props.events.fire_react("query_video");
return "querying";
});

View File

@ -432,7 +432,7 @@ export class W2GPluginCmdHandler extends PluginCmdHandler {
case "notify_destroyed":
const oldWatcher = this.localFollowing;
this.localFollowing = undefined;
this.events.fire_async("notify_following_changed", { newWatcher: undefined, oldWatcher: oldWatcher });
this.events.fire_react("notify_following_changed", { newWatcher: undefined, oldWatcher: oldWatcher });
this.notifyLocalStatus();
break;
}

View File

@ -0,0 +1,62 @@
import {Registry} from "tc-shared/events";
export interface VideoSource {
getId() : string;
getName() : string;
getStream() : MediaStream;
/** Add a new reference to this stream */
ref() : this;
/** Decrease the reference count. If it's zero, it will be automatically destroyed. */
deref();
}
export enum VideoPermissionStatus {
Granted,
UserDenied,
SystemDenied
}
export interface VideoDriverEvents {
notify_permissions_changed: { oldStatus: VideoPermissionStatus, newStatus: VideoPermissionStatus },
notify_device_list_changed: { devices: string[] }
}
export type VideoDevice = { id: string, name: string }
export interface VideoDriver {
getEvents() : Registry<VideoDriverEvents>;
getPermissionStatus() : VideoPermissionStatus;
/**
* Request permissions to access the video camara and device list.
* When requesting permissions, we're actually requesting a media stream.
* If the request succeeds, we're returning that media stream.
*/
requestPermissions() : Promise<VideoSource | boolean>;
getDevices() : Promise<VideoDevice[] | false>;
/**
* @throws a string if an error occurs
* @returns A VideoSource on success with an initial ref count of one
* Will throw a string on error
*/
createVideoSource(id: string) : Promise<VideoSource>;
createScreenSource() : Promise<VideoSource>;
}
let driverInstance: VideoDriver;
export function getVideoDriver() {
return driverInstance;
}
export function setVideoDriver(driver: VideoDriver) {
if(typeof driverInstance !== "undefined") {
throw tr("A video driver has already be initiated");
}
driverInstance = driver;
}

View File

@ -40,12 +40,12 @@ export enum InputState {
RECORDING
}
export enum InputStartResult {
EOK = "eok",
export enum MediaStreamRequestResult {
EUNKNOWN = "eunknown",
EDEVICEUNKNOWN = "edeviceunknown",
EBUSY = "ebusy",
ENOTALLOWED = "enotallowed",
ESYSTEMDENIED = "esystemdenied",
ENOTSUPPORTED = "enotsupported"
}
@ -77,7 +77,7 @@ export interface AbstractInput {
currentState() : InputState;
destroy();
start() : Promise<InputStartResult>;
start() : Promise<MediaStreamRequestResult | true>;
stop() : Promise<void>;
/*

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,7 @@ import {
InputConsumer,
InputConsumerType,
InputEvents,
InputStartResult,
MediaStreamRequestResult,
InputState,
LevelMeter,
NodeInputConsumer
@ -17,6 +17,7 @@ import * as aplayer from "./player";
import {JAbstractFilter, JStateFilter, JThresholdFilter} from "./RecorderFilter";
import {Filter, FilterType, FilterTypeClass} from "tc-shared/voice/Filter";
import {inputDeviceList} from "tc-backend/web/audio/RecorderDeviceList";
import {requestMediaStream} from "tc-backend/web/media/Stream";
declare global {
interface MediaStream {
@ -28,66 +29,6 @@ export interface WebIDevice extends IDevice {
groupId: string;
}
async function requestMicrophoneMediaStream(constraints: MediaTrackConstraints, updateDeviceList: boolean) : Promise<InputStartResult | MediaStream> {
try {
log.info(LogCategory.AUDIO, tr("Requesting a microphone stream for device %s in group %s"), constraints.deviceId, constraints.groupId);
const stream = await navigator.mediaDevices.getUserMedia({ audio: constraints });
if(updateDeviceList && inputDeviceList.getStatus() === "no-permissions") {
inputDeviceList.refresh().then(() => {}); /* added the then body to avoid a inspection warning... */
}
return stream;
} catch(error) {
if('name' in error) {
if(error.name === "NotAllowedError") {
log.warn(LogCategory.AUDIO, tr("Microphone request failed (No permissions). Browser message: %o"), error.message);
return InputStartResult.ENOTALLOWED;
} else {
log.warn(LogCategory.AUDIO, tr("Microphone request failed. Request resulted in error: %o: %o"), error.name, error);
}
} else {
log.warn(LogCategory.AUDIO, tr("Failed to initialize recording stream (%o)"), error);
}
return InputStartResult.EUNKNOWN;
}
}
/* request permission for devices only one per time! */
let currentMediaStreamRequest: Promise<MediaStream | InputStartResult>;
async function requestMediaStream(deviceId: string, groupId: string) : Promise<MediaStream | InputStartResult> {
/* wait for the current media stream requests to finish */
while(currentMediaStreamRequest) {
try {
await currentMediaStreamRequest;
} catch(error) { }
}
const audioConstrains: MediaTrackConstraints = {};
if(window.detectedBrowser?.name === "firefox") {
/*
* Firefox only allows to open one mic as well deciding whats the input device it.
* It does not respect the deviceId nor the groupId
*/
} else {
audioConstrains.deviceId = deviceId;
audioConstrains.groupId = groupId;
}
audioConstrains.echoCancellation = true;
audioConstrains.autoGainControl = true;
audioConstrains.noiseSuppression = true;
const promise = (currentMediaStreamRequest = requestMicrophoneMediaStream(audioConstrains, true));
try {
return await currentMediaStreamRequest;
} finally {
if(currentMediaStreamRequest === promise)
currentMediaStreamRequest = undefined;
}
}
export class WebAudioRecorder implements AudioRecorderBacked {
createInput(): AbstractInput {
return new JavascriptInput();
@ -133,7 +74,7 @@ class JavascriptInput implements AbstractInput {
private inputFiltered: boolean = false;
private filterMode: FilterMode = FilterMode.Block;
private startPromise: Promise<InputStartResult>;
private startPromise: Promise<MediaStreamRequestResult | true>;
private volumeModifier: number = 1;
@ -211,7 +152,7 @@ class JavascriptInput implements AbstractInput {
}
}
async start() : Promise<InputStartResult> {
async start() : Promise<MediaStreamRequestResult | true> {
while(this.startPromise) {
try {
await this.startPromise;
@ -225,7 +166,7 @@ class JavascriptInput implements AbstractInput {
return await (this.startPromise = Promise.resolve().then(() => this.doStart()));
}
private async doStart() : Promise<InputStartResult> {
private async doStart() : Promise<MediaStreamRequestResult | true> {
try {
if(this.state != InputState.PAUSED) {
throw tr("recorder already started");
@ -244,7 +185,7 @@ class JavascriptInput implements AbstractInput {
return;
}
const requestResult = await requestMediaStream(deviceId, undefined);
const requestResult = await requestMediaStream(deviceId, undefined, "audio");
if(!(requestResult instanceof MediaStream)) {
this.state = InputState.PAUSED;
return requestResult;
@ -265,7 +206,7 @@ class JavascriptInput implements AbstractInput {
this.state = InputState.RECORDING;
this.updateFilterStatus(true);
return InputStartResult.EOK;
return true;
} catch(error) {
if(this.state == InputState.INITIALIZING) {
this.state = InputState.PAUSED;
@ -563,15 +504,15 @@ class JavascriptLevelMeter implements LevelMeter {
this._analyse_buffer = new Uint8Array(this._analyser_node.fftSize);
/* starting stream */
const _result = await requestMediaStream(this._device.deviceId, this._device.groupId);
const _result = await requestMediaStream(this._device.deviceId, this._device.groupId, "audio");
if(!(_result instanceof MediaStream)){
if(_result === InputStartResult.ENOTALLOWED)
if(_result === MediaStreamRequestResult.ENOTALLOWED)
throw tr("No permissions");
if(_result === InputStartResult.ENOTSUPPORTED)
if(_result === MediaStreamRequestResult.ENOTSUPPORTED)
throw tr("Not supported");
if(_result === InputStartResult.EBUSY)
if(_result === MediaStreamRequestResult.EBUSY)
throw tr("Device busy");
if(_result === InputStartResult.EUNKNOWN)
if(_result === MediaStreamRequestResult.EUNKNOWN)
throw tr("an error occurred");
throw _result;
}

View File

@ -10,6 +10,7 @@ import {LogCategory, logWarn} from "tc-shared/log";
import {Registry} from "tc-shared/events";
import {WebIDevice} from "tc-backend/web/audio/Recorder";
import * as loader from "tc-loader";
import {queryMediaPermissions} from "tc-backend/web/media/Stream";
async function requestMicrophonePermissions() : Promise<PermissionState> {
const begin = Date.now();
@ -37,24 +38,9 @@ class WebInputDeviceList extends AbstractDeviceList {
}
async initialize() {
if('permissions' in navigator && 'query' in navigator.permissions) {
try {
const result = await navigator.permissions.query({ name: "microphone" });
switch (result.state) {
case "denied":
this.setPermissionState("denied");
break;
case "granted":
this.setPermissionState("granted");
break;
default:
return "unknown";
}
} catch (error) {
logWarn(LogCategory.GENERAL, tr("Failed to query for microphone permissions: %s"), error);
}
const result = await queryMediaPermissions("audio");
if(typeof result === "boolean") {
this.setPermissionState(result ? "granted" : "denied");
}
}

View File

@ -13,13 +13,15 @@ import * as log from "tc-shared/log";
import {LogCategory, logTrace} from "tc-shared/log";
import {Regex} from "tc-shared/ui/modal/ModalConnect";
import {AbstractCommandHandlerBoss} from "tc-shared/connection/AbstractCommandHandler";
import {VoiceConnection} from "../voice/VoiceHandler";
import {EventType} from "tc-shared/ui/frames/log/Definitions";
import {WrappedWebSocket} from "tc-backend/web/connection/WrappedWebSocket";
import {AbstractVoiceConnection} from "tc-shared/connection/VoiceConnection";
import {DummyVoiceConnection} from "tc-shared/connection/DummyVoiceConnection";
import {parseCommand} from "tc-backend/web/connection/CommandParser";
import {ServerAddress} from "tc-shared/tree/Server";
import {RTCConnection} from "tc-backend/web/rtc/Connection";
import {RtpVoiceConnection} from "tc-backend/web/rtc/voice/Connection";
import {RtpVideoConnection} from "tc-backend/web/rtc/video/Connection";
import {VideoConnection} from "tc-shared/connection/VideoConnection";
class ReturnListener<T> {
resolve: (value?: T | PromiseLike<T>) => void;
@ -44,8 +46,9 @@ export class ServerConnection extends AbstractServerConnection {
private _connection_state_listener: ConnectionStateListener;
private dummyVoiceConnection: DummyVoiceConnection;
private voiceConnection: VoiceConnection;
private rtcConnection: RTCConnection;
private voiceConnection: RtpVoiceConnection;
private videoConnection: RtpVideoConnection;
private pingStatistics = {
thread_id: 0,
@ -71,11 +74,9 @@ export class ServerConnection extends AbstractServerConnection {
this.commandHandlerBoss.register_handler(this.defaultCommandHandler);
this.command_helper.initialize();
if(!settings.static_global(Settings.KEY_DISABLE_VOICE, false)) {
this.voiceConnection = new VoiceConnection(this);
} else {
this.dummyVoiceConnection = new DummyVoiceConnection(this);
}
this.rtcConnection = new RTCConnection(this);
this.voiceConnection = new RtpVoiceConnection(this, this.rtcConnection);
this.videoConnection = new RtpVideoConnection(this.rtcConnection);
}
destroy() {
@ -97,6 +98,7 @@ export class ServerConnection extends AbstractServerConnection {
}
this.returnListeners = undefined;
this.rtcConnection.destroy();
this.command_helper.destroy();
this.defaultCommandHandler && this.commandHandlerBoss.unregister_handler(this.defaultCommandHandler);
@ -263,10 +265,10 @@ export class ServerConnection extends AbstractServerConnection {
this.sendData(JSON.stringify({
type: "enable-raw-commands"
}))
this.start_handshake();
this.startHandshake();
}
private start_handshake() {
private startHandshake() {
this.updateConnectionState(ConnectionState.INITIALISING);
this.client.log.log(EventType.CONNECTION_LOGIN, {});
this.handshakeHandler.initialize();
@ -352,8 +354,6 @@ export class ServerConnection extends AbstractServerConnection {
this.doNextPing();
this.updateConnectionState(ConnectionState.CONNECTED);
}
} else if(json["type"] === "WebRTC") {
this.voiceConnection?.handleControlPacket(json);
} else if(json["type"] === "ping") {
this.sendData(JSON.stringify({
type: 'pong',
@ -444,7 +444,11 @@ export class ServerConnection extends AbstractServerConnection {
}
getVoiceConnection(): AbstractVoiceConnection {
return this.voiceConnection || this.dummyVoiceConnection;
return this.voiceConnection /* || this.dummyVoiceConnection; */
}
getVideoConnection(): VideoConnection {
return this.videoConnection;
}
command_handler_boss(): AbstractCommandHandlerBoss {

14
web/app/hooks/Video.ts Normal file
View File

@ -0,0 +1,14 @@
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {setVideoDriver} from "tc-shared/video/VideoSource";
import {WebVideoDriver} from "tc-backend/web/media/Video";
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
priority: 10,
function: async () => {
const instance = new WebVideoDriver();
await instance.initialize();
setVideoDriver(instance);
},
name: "Video init"
});

View File

@ -10,6 +10,7 @@ import "./hooks/ServerConnection";
import "./hooks/ExternalModal";
import "./hooks/AudioRecorder";
import "./hooks/MenuBar";
import "./hooks/Video";
import "./UnloadHandler";

110
web/app/media/Stream.ts Normal file
View File

@ -0,0 +1,110 @@
import {MediaStreamRequestResult} from "tc-shared/voice/RecorderBase";
import * as log from "tc-shared/log";
import {LogCategory, logWarn} from "tc-shared/log";
import {inputDeviceList} from "tc-backend/web/audio/RecorderDeviceList";
import {Registry} from "tc-shared/events";
export type MediaStreamType = "audio" | "video";
export enum MediaPermissionStatus {
Unknown,
Granted,
Denied
}
export interface MediaStreamEvents {
notify_permissions_changed: { type: MediaStreamType, newState: MediaPermissionStatus },
}
export const mediaStreamEvents = new Registry<MediaStreamEvents>();
async function requestMediaStream0(constraints: MediaTrackConstraints, type: MediaStreamType, updateDeviceList: boolean) : Promise<MediaStreamRequestResult | MediaStream> {
const beginTimestamp = Date.now();
try {
log.info(LogCategory.AUDIO, tr("Requesting a %s stream for device %s in group %s"), type, constraints.deviceId, constraints.groupId);
const stream = await navigator.mediaDevices.getUserMedia(type === "audio" ? { audio: constraints } : { video: constraints });
if(updateDeviceList && inputDeviceList.getStatus() === "no-permissions") {
inputDeviceList.refresh().then(() => {}); /* added the then body to avoid a inspection warning... */
}
return stream;
} catch(error) {
if('name' in error) {
if(error.name === "NotAllowedError") {
if(Date.now() - beginTimestamp < 250) {
log.warn(LogCategory.AUDIO, tr("Media stream request failed (System denied). Browser message: %o"), error.message);
return MediaStreamRequestResult.ESYSTEMDENIED;
} else {
log.warn(LogCategory.AUDIO, tr("Media stream request failed (No permissions). Browser message: %o"), error.message);
return MediaStreamRequestResult.ENOTALLOWED;
}
} else {
log.warn(LogCategory.AUDIO, tr("Media stream request failed. Request resulted in error: %o: %o"), error.name, error);
}
} else {
log.warn(LogCategory.AUDIO, tr("Failed to initialize media stream (%o)"), error);
}
return MediaStreamRequestResult.EUNKNOWN;
}
}
/* request permission for devices only one per time! */
let currentMediaStreamRequest: Promise<MediaStream | MediaStreamRequestResult>;
export async function requestMediaStream(deviceId: string, groupId: string | undefined, type: MediaStreamType) : Promise<MediaStream | MediaStreamRequestResult> {
/* wait for the current media stream requests to finish */
while(currentMediaStreamRequest) {
try {
await currentMediaStreamRequest;
} catch(error) { }
}
const constrains: MediaTrackConstraints = {};
if(window.detectedBrowser?.name === "firefox") {
/*
* Firefox only allows to open one mic/video as well deciding whats the input device it.
* It does not respect the deviceId nor the groupId
*/
} else {
constrains.deviceId = deviceId;
constrains.groupId = groupId;
}
constrains.echoCancellation = true;
constrains.autoGainControl = true;
constrains.noiseSuppression = true;
const promise = (currentMediaStreamRequest = requestMediaStream0(constrains, type, true));
try {
return await currentMediaStreamRequest;
} finally {
if(currentMediaStreamRequest === promise)
currentMediaStreamRequest = undefined;
}
}
export async function queryMediaPermissions(type: MediaStreamType, changeListener?: (value: PermissionState) => void) : Promise<PermissionState> {
if('permissions' in navigator && 'query' in navigator.permissions) {
try {
const result = await navigator.permissions.query({ name: type === "audio" ? "microphone" : "camera" });
if(changeListener) {
result.addEventListener("change", () => {
changeListener(result.state);
});
}
return result.state;
} catch (error) {
logWarn(LogCategory.GENERAL, tr("Failed to query for %s permissions: %s"), type, error);
}
}
return "prompt";
}
export function stopMediaStream(stream: MediaStream) {
stream.getVideoTracks().forEach(track => track.stop());
stream.getAudioTracks().forEach(track => track.stop());
if('stop' in stream) {
stream.stop();
}
}

254
web/app/media/Video.ts Normal file
View File

@ -0,0 +1,254 @@
import {
VideoDevice,
VideoDriver,
VideoDriverEvents,
VideoPermissionStatus,
VideoSource
} from "tc-shared/video/VideoSource";
import {Registry} from "tc-shared/events";
import {queryMediaPermissions, requestMediaStream, stopMediaStream} from "tc-backend/web/media/Stream";
import {MediaStreamRequestResult} from "tc-shared/voice/RecorderBase";
import {LogCategory, logError, logWarn} from "tc-shared/log";
function getStreamVideoDeviceId(stream: MediaStream) : string | undefined {
const track = stream.getVideoTracks()[0];
if(typeof track !== "object" || !("getCapabilities" in track)) { return undefined; }
return track.getCapabilities()?.deviceId;
}
export class WebVideoDriver implements VideoDriver {
private readonly events: Registry<VideoDriverEvents>;
private currentPermissionStatus: VideoPermissionStatus;
constructor() {
this.events = new Registry<VideoDriverEvents>();
this.currentPermissionStatus = VideoPermissionStatus.UserDenied;
}
private setPermissionStatus(status: VideoPermissionStatus) {
if(this.currentPermissionStatus === status) {
return;
}
const oldState = this.currentPermissionStatus;
this.currentPermissionStatus = status;
this.events.fire("notify_permissions_changed", { newStatus: status, oldStatus: oldState });
}
private async handleSystemPermissionState(state: PermissionState | undefined) {
switch(state) {
case "denied":
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
break;
case "prompt":
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
break;
case "granted":
this.setPermissionStatus(VideoPermissionStatus.Granted);
break;
default:
/* this will query the initial permission state */
if(await this.getDevices() === false) {
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
} else {
this.setPermissionStatus(VideoPermissionStatus.Granted);
}
break;
}
}
async initialize() {
if(window.detectedBrowser?.name === "firefox") {
/* We've to do a normal request every time we want to access your camera. */
this.setPermissionStatus(VideoPermissionStatus.Granted);
} else {
const permissionState = await queryMediaPermissions("video", newState => this.handleSystemPermissionState(newState));
await this.handleSystemPermissionState(permissionState);
}
}
async getDevices(): Promise<VideoDevice[] | false> {
if(window.detectedBrowser?.name === "firefox") {
return [{
name: tr("Default Firefox device"),
id: "default"
}];
}
/* TODO: Cache query response */
let devices = await navigator.mediaDevices.enumerateDevices();
let hasPermissions = devices.findIndex(e => e.kind === "videoinput" && e.label !== "") !== -1 || devices.findIndex(e => e.kind === "videoinput") === -1;
if(!hasPermissions) {
return false;
}
const inputDevices = devices.filter(e => e.kind === "videoinput");
/*
const oldDeviceList = this.devices;
this.devices = [];
let devicesAdded = 0;
for(const device of inputDevices) {
const oldIndex = oldDeviceList.findIndex(e => e.deviceId === device.deviceId);
if(oldIndex === -1) {
devicesAdded++;
} else {
oldDeviceList.splice(oldIndex, 1);
}
this.devices.push({
deviceId: device.deviceId,
driver: "WebAudio",
groupId: device.groupId,
name: device.label
});
}
*/
return inputDevices.map(info => {
return {
id: info.deviceId,
name: info.label
}
});
}
async requestPermissions(): Promise<VideoSource | boolean> {
const result = await requestMediaStream("default", undefined, "video");
if(result === MediaStreamRequestResult.ENOTALLOWED) {
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
return false;
} else if(result === MediaStreamRequestResult.ESYSTEMDENIED) {
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
return false;
}
/* TODO: May update the device list? */
this.setPermissionStatus(VideoPermissionStatus.Granted);
if(result instanceof MediaStream) {
let deviceId = getStreamVideoDeviceId(result);
if(deviceId === undefined) {
if(window.detectedBrowser?.name === "firefox") {
/*
* Firefox does not support "getCapabilities".
* Since FF also just support one device, we know how the device id;
*/
deviceId = "default";
} else {
/* We can't identify the underlying device. It's better to close than returning an unknown stream. */
stopMediaStream(result);
return true;
}
}
const devices = await this.getDevices();
const deviceIndex = devices === false ? -1 : devices.findIndex(e => e.id === deviceId);
return new WebVideoSource(deviceId, deviceIndex === -1 ? tr("Unknown source") : (devices[deviceIndex] as VideoDevice).name, result);
} else {
return true;
}
}
getEvents(): Registry<VideoDriverEvents> {
return this.events;
}
getPermissionStatus(): VideoPermissionStatus {
return this.currentPermissionStatus;
}
async createVideoSource(id: string): Promise<VideoSource> {
const result = await requestMediaStream(id, undefined, "video");
/*
* If we've got denied of requesting a stream reset the state to not allowed.
* This also applies to Firefox since the user has to manually update the flag after that.
* Only the initial state for Firefox is and should be "Granted".
*/
if(result === MediaStreamRequestResult.ENOTALLOWED) {
this.setPermissionStatus(VideoPermissionStatus.UserDenied);
throw tr("Device access has been denied");
} else if(result === MediaStreamRequestResult.ESYSTEMDENIED) {
this.setPermissionStatus(VideoPermissionStatus.SystemDenied);
throw tr("Device access has been denied");
}
if(this.currentPermissionStatus !== VideoPermissionStatus.Granted) {
/* TODO: May update the device list? */
this.setPermissionStatus(VideoPermissionStatus.Granted);
}
if(result instanceof MediaStream) {
const deviceId = getStreamVideoDeviceId(result);
if(deviceId === undefined) {
/* Do nothing. We've to trust that the given track origins from the requested id. */
} else if(deviceId !== id) {
logWarn(LogCategory.GENERAL, tr("Requested video source %s but received %s"), id, deviceId);
} else {
/* We're fine. We received the device we wanted. */
}
const devices = await this.getDevices();
const deviceIndex = devices === false ? -1 : devices.findIndex(e => e.id === deviceId);
return new WebVideoSource(id, deviceIndex === -1 ? tr("Unknown source") : (devices[deviceIndex] as VideoDevice).name, result);
} else {
throw tra("An unknown error happened while opening the device ({})", result);
}
}
createScreenSource(): Promise<VideoSource> {
return Promise.resolve(undefined);
}
}
class WebVideoSource implements VideoSource {
private readonly deviceId: string;
private readonly displayName: string;
private readonly stream: MediaStream;
private referenceCount = 1;
constructor(deviceId: string, displayName: string, stream: MediaStream) {
this.deviceId = deviceId;
this.displayName = displayName;
this.stream = stream;
}
destroy() {
stopMediaStream(this.stream);
}
getId(): string {
return this.deviceId;
}
getName(): string {
return this.displayName;
}
getStream(): MediaStream {
return this.stream;
}
deref() {
this.referenceCount -= 1;
if(this.referenceCount === 0) {
this.destroy();
} else if(this.referenceCount < 0) {
logError(LogCategory.GENERAL, tr("Video source reference count went bellow zero! This indicates a critical system flaw."));
}
}
ref() {
if(this.referenceCount <= 0) {
throw tr("the video stream has already been destroyed");
}
this.referenceCount++;
return this;
}
}

765
web/app/rtc/Connection.ts Normal file
View File

@ -0,0 +1,765 @@
import {ServerConnection} from "tc-backend/web/connection/ServerConnection";
import {AbstractServerConnection, ServerCommand, ServerConnectionEvents} from "tc-shared/connection/ConnectionBase";
import {ConnectionState} from "tc-shared/ConnectionHandler";
import * as log from "tc-shared/log";
import {LogCategory, logDebug, logError, logTrace, logWarn} from "tc-shared/log";
import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {tr} from "tc-shared/i18n/localize";
import {Registry} from "tc-shared/events";
import {
RemoteRTPAudioTrack,
RemoteRTPTrackState,
RemoteRTPVideoTrack,
TrackClientInfo
} from "tc-backend/web/rtc/RemoteTrack";
import {SdpCompressor, SdpProcessor} from "tc-backend/web/rtc/SdpUtils";
const kSdpCompressionMode = 1;
class CommandHandler extends AbstractCommandHandler {
private readonly handle: RTCConnection;
private readonly sdpProcessor: SdpProcessor;
constructor(connection: AbstractServerConnection, handle: RTCConnection, sdpProcessor: SdpProcessor) {
super(connection);
this.handle = handle;
this.sdpProcessor = sdpProcessor;
this.ignore_consumed = true;
}
handle_command(command: ServerCommand): boolean {
if(command.command === "notifyrtcsessiondescription") {
const data = command.arguments[0];
if(!this.handle["peer"]) {
logWarn(LogCategory.WEBRTC, tr("Received remote %s without an active peer"), data.mode);
return;
}
/* webrtc-sdp somehow places some empty lines into the sdp */
let sdp = data.sdp.replace(/\r?\n\r?\n/g, "\n");
try {
sdp = SdpCompressor.decompressSdp(sdp, 1);
} catch (error) {
logError(LogCategory.WEBRTC, tr("Failed to decompress remote SDP: %o"), error);
this.handle["handleFatalError"](tr("Failed to decompress remote SDP"), 5000);
return;
}
if(RTCConnection.kEnableSdpTrace) {
logTrace(LogCategory.WEBRTC, tr("Received remote %s:\n%s"), data.mode, data.sdp);
}
try {
sdp = this.sdpProcessor.processIncomingSdp(sdp, data.mode);
} catch (error) {
logError(LogCategory.WEBRTC, tr("Failed to reprocess SDP %s: %o"), data.mode, error);
this.handle["handleFatalError"](tra("Failed to preprocess SDP {}", data.mode as string), 5000);
return;
}
if(RTCConnection.kEnableSdpTrace) {
logTrace(LogCategory.WEBRTC, tr("Patched remote %s:\n%s"), data.mode, data.sdp);
}
if(data.mode === "answer") {
this.handle["peer"].setRemoteDescription({
sdp: sdp,
type: "answer"
}).catch(error => {
logError(LogCategory.WEBRTC, tr("Failed to set the remote description: %o"), error);
this.handle["handleFatalError"](tr("Failed to set the remote description (answer)"), 5000);
})
} else if(data.mode === "offer") {
this.handle["peer"].setRemoteDescription({
sdp: sdp,
type: "offer"
}).then(() => this.handle["peer"].createAnswer())
.then(answer => {
answer.sdp = this.sdpProcessor.processOutgoingSdp(answer.sdp, "answer");
answer.sdp = SdpCompressor.compressSdp(answer.sdp, kSdpCompressionMode);
return this.connection.send_command("rtcsessiondescribe", {
mode: "answer",
sdp: answer.sdp,
compression: kSdpCompressionMode
});
}).catch(error => {
logError(LogCategory.WEBRTC, tr("Failed to set the remote description and execute the renegotiation: %o"), error);
this.handle["handleFatalError"](tr("Failed to set the remote description (offer/renegotiation)"), 5000);
});
} else {
logWarn(LogCategory.NETWORKING, tr("Received invalid mode for rtc session description (%s)."), data.mode);
}
return true;
} else if(command.command === "notifyrtcstreamassignment") {
const data = command.arguments[0];
const ssrc = parseInt(data["streamid"]) >>> 0;
if(parseInt(data["sclid"])) {
this.handle["doMapStream"](ssrc, {
client_id: parseInt(data["sclid"]),
client_database_id: parseInt(data["scldbid"]),
client_name: data["sclname"],
client_unique_id: data["scluid"]
});
} else {
this.handle["doMapStream"](ssrc, undefined);
}
} else if(command.command === "notifyrtcstateaudio") {
const data = command.arguments[0];
const state = parseInt(data["state"]);
const ssrc = parseInt(data["streamid"]) >>> 0;
if(state === 0) {
/* stream stopped */
this.handle["handleStreamState"](ssrc, 0, undefined);
} else if(state === 1) {
this.handle["handleStreamState"](ssrc, 1, {
client_id: parseInt(data["sclid"]),
client_database_id: parseInt(data["scldbid"]),
client_name: data["sclname"],
client_unique_id: data["scluid"]
});
} else {
logWarn(LogCategory.WEBRTC, tr("Received unknown/invalid rtc track state: %d"), state);
}
}
return false;
}
}
export enum RTPConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
FAILED
}
class InternalRemoteRTPAudioTrack extends RemoteRTPAudioTrack {
private muteTimeout;
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
super(ssrc, transceiver);
}
destroy() {
this.handleTrackEnded();
super.destroy();
}
handleAssignment(info: TrackClientInfo | undefined) {
if(Object.isSimilar(this.currentAssignment, info)) {
return;
}
this.currentAssignment = info;
if(info) {
logDebug(LogCategory.WEBRTC, tr("Remote RTP audio track %d mounted to client %o"), this.getSsrc(), info);
this.setState(RemoteRTPTrackState.Bound);
} else {
logDebug(LogCategory.WEBRTC, tr("Remote RTP audio track %d has been unmounted."), this.getSsrc());
this.setState(RemoteRTPTrackState.Unbound);
}
}
handleStateNotify(state: number, info: TrackClientInfo | undefined) {
if(!this.currentAssignment) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss info. Updating info."), this.getSsrc());
}
const validateInfo = () => {
if(info.client_id !== this.currentAssignment.client_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %d but received %d. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(info.client_unique_id !== this.currentAssignment.client_unique_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %s but received %s. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(this.currentAssignment.client_name !== info.client_name) {
this.currentAssignment.client_name = info.client_name;
/* TODO: Notify name update */
}
};
clearTimeout(this.muteTimeout);
this.muteTimeout = undefined;
if(state === 1) {
validateInfo();
this.shouldReplay = true;
if(this.gainNode) {
this.gainNode.gain.value = this.gain;
}
this.setState(RemoteRTPTrackState.Started);
} else {
/* There wil be no info present */
this.setState(RemoteRTPTrackState.Bound);
/* since we're might still having some jitter stuff */
this.muteTimeout = setTimeout(() => {
this.shouldReplay = false;
if(this.gainNode) {
this.gainNode.gain.value = 0;
}
}, 1000);
}
}
}
class InternalRemoteRTPVideoTrack extends RemoteRTPVideoTrack {
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
super(ssrc, transceiver);
}
destroy() {
this.handleTrackEnded();
super.destroy();
}
handleAssignment(info: TrackClientInfo | undefined) {
if(Object.isSimilar(this.currentAssignment, info)) {
return;
}
this.currentAssignment = info;
if(info) {
logDebug(LogCategory.WEBRTC, tr("Remote RTP video track %d mounted to client %o"), this.getSsrc(), info);
this.setState(RemoteRTPTrackState.Bound);
} else {
logDebug(LogCategory.WEBRTC, tr("Remote RTP video track %d has been unmounted."), this.getSsrc());
this.setState(RemoteRTPTrackState.Unbound);
}
}
handleStateNotify(state: number, info: TrackClientInfo | undefined) {
if(!this.currentAssignment) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss info. Updating info."), this.getSsrc());
}
const validateInfo = () => {
if(info.client_id !== this.currentAssignment.client_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %d but received %d. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(info.client_unique_id !== this.currentAssignment.client_unique_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %s but received %s. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(this.currentAssignment.client_name !== info.client_name) {
this.currentAssignment.client_name = info.client_name;
/* TODO: Notify name update */
}
};
if(state === 1) {
validateInfo();
this.setState(RemoteRTPTrackState.Started);
} else {
/* There wil be no info present */
this.setState(RemoteRTPTrackState.Bound);
}
}
}
export type RTCSourceTrackType = "audio" | "audio-whisper" | "video" | "video-screen";
export type RTCBroadcastableTrackType = Exclude<RTCSourceTrackType, "audio-whisper">;
const kRtcSourceTrackTypes: RTCSourceTrackType[] = ["audio", "audio-whisper", "video", "video-screen"];
function broadcastableTrackTypeToNumber(type: RTCBroadcastableTrackType) : number {
switch (type) {
case "video-screen":
return 3;
case "video":
return 2;
case "audio":
return 1;
default:
throw tr("invalid target type");
}
}
type TemporaryRtpStream = {
createTimestamp: number,
timeoutId: number,
ssrc: number,
status: number | undefined,
info: TrackClientInfo | undefined
}
export interface RTCConnectionEvents {
notify_state_changed: { oldState: RTPConnectionState, newState: RTPConnectionState },
notify_audio_assignment_changed: { track: RemoteRTPAudioTrack, info: TrackClientInfo | undefined },
notify_video_assignment_changed: { track: RemoteRTPVideoTrack, info: TrackClientInfo | undefined },
}
export class RTCConnection {
public static readonly kEnableSdpTrace = false;
private readonly events: Registry<RTCConnectionEvents>;
private readonly connection: ServerConnection;
private readonly commandHandler: CommandHandler;
private readonly sdpProcessor: SdpProcessor;
private connectionState: RTPConnectionState;
private failedReason: string;
private peer: RTCPeerConnection;
private localCandidateCount: number;
private currentTracks: {[T in RTCSourceTrackType]: MediaStreamTrack | undefined} = {
"audio-whisper": undefined,
"video-screen": undefined,
audio: undefined,
video: undefined
};
private currentTransceiver: {[T in RTCSourceTrackType]: RTCRtpTransceiver | undefined} = {
"audio-whisper": undefined,
"video-screen": undefined,
audio: undefined,
video: undefined
};
private remoteAudioTracks: {[key: number]: InternalRemoteRTPAudioTrack};
private remoteVideoTracks: {[key: number]: InternalRemoteRTPVideoTrack};
private temporaryStreams: {[key: number]: TemporaryRtpStream} = {};
constructor(connection: ServerConnection) {
this.events = new Registry<RTCConnectionEvents>();
this.connection = connection;
this.sdpProcessor = new SdpProcessor();
this.commandHandler = new CommandHandler(connection, this, this.sdpProcessor);
this.connection.command_handler_boss().register_handler(this.commandHandler);
this.reset(true);
this.connection.events.on("notify_connection_state_changed", event => this.handleConnectionStateChanged(event));
}
destroy() {
this.connection.command_handler_boss().unregister_handler(this.commandHandler);
}
getConnection() : ServerConnection {
return this.connection;
}
getEvents() {
return this.events;
}
getConnectionState() : RTPConnectionState {
return this.connectionState;
}
getFailReason() : string {
return this.failedReason;
}
reset(updateConnectionState: boolean) {
if(this.peer) {
if(this.getConnection().connected()) {
this.getConnection().send_command("rtcsessionreset").catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to signal RTC session reset to server: %o"), error);
});
}
this.peer.close();
this.peer = undefined;
}
Object.keys(this.currentTransceiver).forEach(key => this.currentTransceiver[key] = undefined);
this.sdpProcessor.reset();
if(this.remoteAudioTracks) {
Object.values(this.remoteAudioTracks).forEach(track => track.destroy());
}
this.remoteAudioTracks = {};
if(this.remoteVideoTracks) {
Object.values(this.remoteVideoTracks).forEach(track => track.destroy());
}
this.remoteVideoTracks = {};
this.temporaryStreams = {};
this.localCandidateCount = 0;
if(updateConnectionState) {
this.updateConnectionState(RTPConnectionState.DISCONNECTED);
}
}
async setTrackSource(type: RTCSourceTrackType, source: MediaStreamTrack | null) {
switch (type) {
case "audio":
case "audio-whisper":
if(source && source.kind !== "audio") { throw tr("invalid track type"); }
break;
case "video":
case "video-screen":
if(source && source.kind !== "video") { throw tr("invalid track type"); }
break;
}
if(this.currentTracks[type] === source) {
return;
}
this.currentTracks[type] = source;
await this.updateTracks();
}
/**
* @param type
* @throws a string on error
*/
public async startTrackBroadcast(type: RTCBroadcastableTrackType) : Promise<void> {
if(typeof this.currentTransceiver[type] !== "object") {
throw tr("missing transceiver");
}
try {
await this.connection.send_command("rtcbroadcast", {
type: broadcastableTrackTypeToNumber(type),
ssrc: this.sdpProcessor.getLocalSsrcFromFromMediaId(this.currentTransceiver[type].mid)
});
} catch (error) {
logError(LogCategory.WEBRTC, tr("failed to start %s broadcast: %o"), type, error);
throw tr("failed to signal broadcast start");
}
}
public stopTrackBroadcast(type: RTCBroadcastableTrackType) {
this.connection.send_command("rtcbroadcast", {
type: broadcastableTrackTypeToNumber(type),
ssrc: 0
}).catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to signal track broadcast stop: %o"), error);
});
}
private updateConnectionState(newState: RTPConnectionState) {
if(this.connectionState === newState) { return; }
const oldState = this.connectionState;
this.connectionState = newState;
this.events.fire("notify_state_changed", { oldState: oldState, newState: newState });
}
private handleFatalError(error: string, retryThreshold: number) {
/* TODO: Reset for the server as well! */
this.reset(false);
this.failedReason = error;
this.updateConnectionState(RTPConnectionState.FAILED);
/* FIXME: Generate a log message! */
if(retryThreshold > 0) {
setTimeout(() => {
console.error("XXXX Retry");
this.doInitialSetup();
}, 5000);
/* TODO: Schedule a retry? */
}
}
private static checkBrowserSupport() {
if(!window.RTCRtpSender || !RTCRtpSender.prototype) {
throw tr("Missing RTCRtpSender");
}
if(!RTCRtpSender.prototype.getParameters) {
throw tr("RTCRtpSender.getParameters");
}
if(!RTCRtpSender.prototype.replaceTrack) {
throw tr("RTCRtpSender.getParameters");
}
}
private enableDtx(_sender: RTCRtpSender) { }
private doInitialSetup() {
this.peer = new RTCPeerConnection({
bundlePolicy: "max-bundle",
rtcpMuxPolicy: "require",
iceServers: [{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }]
});
this.currentTransceiver["audio"] = this.peer.addTransceiver("audio", { sendEncodings: [{ }] });
this.enableDtx(this.currentTransceiver["audio"].sender);
this.currentTransceiver["audio-whisper"] = this.peer.addTransceiver("audio");
this.enableDtx(this.currentTransceiver["audio-whisper"].sender);
this.currentTransceiver["video"] = this.peer.addTransceiver("video");
this.currentTransceiver["video-screen"] = this.peer.addTransceiver("video");
/* add some other transceivers for later use */
for(let i = 0; i < 8; i++) {
this.peer.addTransceiver("audio");
}
for(let i = 0; i < 4; i++) {
this.peer.addTransceiver("video");
}
this.peer.onicecandidate = event => this.handleIceCandidate(event.candidate);
this.peer.onicecandidateerror = event => this.handleIceCandidateError(event);
this.peer.oniceconnectionstatechange = () => this.handleIceConnectionStateChanged();
this.peer.onicegatheringstatechange = () => this.handleIceGatheringStateChanged();
this.peer.onsignalingstatechange = () => this.handleSignallingStateChanged();
this.peer.onconnectionstatechange = () => this.handlePeerConnectionStateChanged();
this.peer.ondatachannel = event => this.handleDataChannel(event.channel);
this.peer.ontrack = event => this.handleTrack(event);
/* FIXME: Remove this debug! */
(window as any).rtp = this;
this.updateConnectionState(RTPConnectionState.CONNECTING);
this.doInitialSetup0().catch(error => {
this.handleFatalError(tr("initial setup failed"), 5000);
logError(LogCategory.WEBRTC, tr("Connection setup failed: %o"), error);
});
}
private async updateTracks() {
for(const type of kRtcSourceTrackTypes) {
await this.currentTransceiver[type]?.sender.replaceTrack(this.currentTracks[type]);
}
}
private async doInitialSetup0() {
RTCConnection.checkBrowserSupport();
const peer = this.peer;
await this.updateTracks();
const offer = await peer.createOffer({ iceRestart: false, offerToReceiveAudio: true, offerToReceiveVideo: true });
if(offer.type !== "offer") { throw tr("created ofer isn't of type offer"); }
if(this.peer !== peer) { return; }
if(RTCConnection.kEnableSdpTrace) {
logTrace(LogCategory.WEBRTC, tr("Generated initial local offer:\n%s"), offer.sdp);
}
try {
offer.sdp = this.sdpProcessor.processOutgoingSdp(offer.sdp, "offer");
logTrace(LogCategory.WEBRTC, tr("Patched initial local offer:\n%s"), offer.sdp);
} catch (error) {
logError(LogCategory.WEBRTC, tr("Failed to preprocess outgoing initial offer: %o"), error);
this.handleFatalError(tr("Failed to preprocess outgoing initial offer"), 10000);
return;
}
await peer.setLocalDescription(offer);
if(this.peer !== peer) { return; }
try {
await this.connection.send_command("rtcsessiondescribe", {
mode: "offer",
sdp: offer.sdp
});
} catch (error) {
if(this.peer !== peer) { return; }
if(error instanceof CommandResult) {
error = error.formattedMessage();
}
logWarn(LogCategory.VOICE, tr("Failed to initialize RTP connection: %o"), error);
throw tr("server failed to accept our offer");
}
if(this.peer !== peer) { return; }
this.peer.onnegotiationneeded = () => this.handleNegotiationNeeded();
/* Nothing left to do. Server should send a notifyrtcsessiondescription with mode answer */
}
private handleConnectionStateChanged(event: ServerConnectionEvents["notify_connection_state_changed"]) {
if(event.newState === ConnectionState.CONNECTED) {
/* initialize rtc connection */
this.doInitialSetup();
} else {
this.reset(true);
}
}
private handleIceCandidate(candidate: RTCIceCandidate | undefined) {
if(candidate) {
this.localCandidateCount++;
const json = candidate.toJSON();
logTrace(LogCategory.WEBRTC, tr("Received ICE candidate %s"), json.candidate);
this.connection.send_command("rtcicecandidate", {
media_line: json.sdpMLineIndex,
candidate: json.candidate
}).catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to transmit local ICE candidate to server: %o"), error);
});
} else {
if(this.localCandidateCount === 0) {
logError(LogCategory.WEBRTC, tr("Received local ICE candidate finish, without having any candidates."));
this.handleFatalError(tr("Failed to gather any ICE candidates"), 5000);
return;
} else {
logTrace(LogCategory.WEBRTC, tr("Received ICE candidate finish"));
}
this.connection.send_command("rtcicecandidate", { }).catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to transmit local ICE candidate finish to server: %o"), error);
});
}
}
private handleIceCandidateError(event: RTCPeerConnectionIceErrorEvent) {
if(this.peer.iceGatheringState === "gathering") {
log.warn(LogCategory.WEBRTC, tr("Received error while gathering the ice candidates: %d/%s for %s (url: %s)"),
event.errorCode, event.errorText, event.hostCandidate, event.url);
} else {
log.trace(LogCategory.WEBRTC, tr("Ice candidate %s (%s) errored: %d/%s"),
event.url, event.hostCandidate, event.errorCode, event.errorText);
}
}
private handleIceConnectionStateChanged() {
log.trace(LogCategory.WEBRTC, tr("ICE connection state changed to %s"), this.peer.iceConnectionState);
}
private handleIceGatheringStateChanged() {
log.trace(LogCategory.WEBRTC, tr("ICE gathering state changed to %s"), this.peer.iceGatheringState);
}
private handleSignallingStateChanged() {
logTrace(LogCategory.WEBRTC, tr("Peer signalling state changed to %s"), this.peer.signalingState);
}
private handleNegotiationNeeded() {
logWarn(LogCategory.WEBRTC, tr("Local peer needs negotiation, but we don't support client sideded negotiation."));
}
private handlePeerConnectionStateChanged() {
logTrace(LogCategory.WEBRTC, tr("Peer connection state changed to %s"), this.peer.connectionState);
switch (this.peer.connectionState) {
case "connecting":
this.updateConnectionState(RTPConnectionState.CONNECTING);
break;
case "connected":
this.updateConnectionState(RTPConnectionState.CONNECTED);
break;
case "failed":
case "closed":
case "disconnected":
case "new":
if(this.connectionState !== RTPConnectionState.FAILED) {
this.updateConnectionState(RTPConnectionState.DISCONNECTED);
}
break;
}
}
private handleDataChannel(_channel: RTCDataChannel) {
/* We're not doing anything with data channels */
}
private releaseTemporaryStream(ssrc: number) : TemporaryRtpStream | undefined {
if(this.temporaryStreams[ssrc]) {
const stream = this.temporaryStreams[ssrc];
clearTimeout(stream.timeoutId);
stream.timeoutId = 0;
delete this.temporaryStreams[ssrc];
return stream;
}
return undefined;
}
private handleTrack(event: RTCTrackEvent) {
const ssrc = this.sdpProcessor.getRemoteSsrcFromFromMediaId(event.transceiver.mid);
if(typeof ssrc !== "number") {
logError(LogCategory.WEBRTC, tr("Received track without knowing its ssrc. Ignoring track..."));
return;
}
const tempInfo = this.releaseTemporaryStream(ssrc);
if(event.track.kind === "audio") {
const track = new InternalRemoteRTPAudioTrack(ssrc, event.transceiver);
logDebug(LogCategory.WEBRTC, tr("Received remote audio track on ssrc %d"), ssrc);
if(tempInfo?.info !== undefined) {
track.handleAssignment(tempInfo.info);
this.events.fire("notify_audio_assignment_changed", {
info: tempInfo.info,
track: track
});
}
if(tempInfo?.status !== undefined) {
track.handleStateNotify(tempInfo.status, tempInfo.info);
}
this.remoteAudioTracks[ssrc] = track;
} else if(event.track.kind === "video") {
const track = new InternalRemoteRTPVideoTrack(ssrc, event.transceiver);
logDebug(LogCategory.WEBRTC, tr("Received remote video track on ssrc %d"), ssrc);
if(tempInfo?.info !== undefined) {
track.handleAssignment(tempInfo.info);
this.events.fire("notify_video_assignment_changed", {
info: tempInfo.info,
track: track
});
}
if(tempInfo?.status !== undefined) {
track.handleStateNotify(tempInfo.status, tempInfo.info);
}
this.remoteVideoTracks[ssrc] = track;
} else {
logWarn(LogCategory.WEBRTC, tr("Received track with unknown kind '%s'."), event.track.kind);
}
}
private getOrCreateTempStream(ssrc: number) : TemporaryRtpStream {
if(this.temporaryStreams[ssrc]) {
return this.temporaryStreams[ssrc];
}
const tempStream = this.temporaryStreams[ssrc] = {
ssrc: ssrc,
timeoutId: 0,
createTimestamp: Date.now(),
info: undefined,
status: undefined
};
tempStream.timeoutId = setTimeout(() => {
logWarn(LogCategory.WEBRTC, tr("Received stream mapping for invalid stream which hasn't been signalled after 5 seconds (ssrc: %o)."), ssrc);
delete this.temporaryStreams[ssrc];
}, 5000);
return tempStream;
}
private doMapStream(ssrc: number, target: TrackClientInfo | undefined) {
if(this.remoteAudioTracks[ssrc]) {
const track = this.remoteAudioTracks[ssrc];
track.handleAssignment(target);
this.events.fire("notify_audio_assignment_changed", {
info: target,
track: track
});
} else if(this.remoteVideoTracks[ssrc]) {
const track = this.remoteVideoTracks[ssrc];
track.handleAssignment(target);
this.events.fire("notify_video_assignment_changed", {
info: target,
track: track
});
} else {
let tempStream = this.getOrCreateTempStream(ssrc);
tempStream.info = target;
}
}
private handleStreamState(ssrc: number, state: number, info: TrackClientInfo | undefined) {
if(this.remoteAudioTracks[ssrc]) {
const track = this.remoteAudioTracks[ssrc];
track.handleStateNotify(state, info);
} else if(this.remoteVideoTracks[ssrc]) {
const track = this.remoteVideoTracks[ssrc];
track.handleStateNotify(state, info);
} else {
let tempStream = this.getOrCreateTempStream(ssrc);
tempStream.info = info;
tempStream.status = state;
}
}
}

200
web/app/rtc/RemoteTrack.ts Normal file
View File

@ -0,0 +1,200 @@
import {Registry} from "tc-shared/events";
import {LogCategory, logWarn} from "tc-shared/log";
import {tr} from "tc-shared/i18n/localize";
import * as aplayer from "tc-backend/web/audio/player";
export interface TrackClientInfo {
client_id: number,
client_database_id: number,
client_unique_id: string,
client_name: string
}
export enum RemoteRTPTrackState {
/** The track isn't bound to any client */
Unbound,
/** The track is bound to a client, but isn't replaying anything */
Bound,
/** The track is currently replaying something (inherits the Bound characteristics) */
Started,
/** The track has been destroyed */
Destroyed
}
export interface RemoteRTPTrackEvents {
notify_state_changed: { oldState: RemoteRTPTrackState, newState: RemoteRTPTrackState }
}
declare global {
interface RTCRtpReceiver {
/* Works currently only for Chrome */
playoutDelayHint: number;
}
}
export class RemoteRTPTrack {
protected readonly events: Registry<RemoteRTPTrackEvents>;
private readonly ssrc: number;
private readonly transceiver: RTCRtpTransceiver;
private currentState: RemoteRTPTrackState;
protected currentAssignment: TrackClientInfo;
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
this.events = new Registry<RemoteRTPTrackEvents>();
this.ssrc = ssrc;
this.transceiver = transceiver;
this.currentState = RemoteRTPTrackState.Unbound;
transceiver.receiver.playoutDelayHint = 0.06;
}
protected destroy() {
this.events.destroy();
}
getEvents() : Registry<RemoteRTPTrackEvents> {
return this.events;
}
getState() : RemoteRTPTrackState {
return this.currentState;
}
getSsrc() : number {
return this.ssrc;
}
getTrack() : MediaStreamTrack {
return this.transceiver.receiver.track;
}
getTransceiver() : RTCRtpTransceiver {
return this.transceiver;
}
getCurrentAssignment() : TrackClientInfo | undefined {
return this.currentAssignment;
}
protected setState(state: RemoteRTPTrackState) {
if(this.currentState === state) {
return;
} else if(this.currentState === RemoteRTPTrackState.Destroyed) {
logWarn(LogCategory.WEBRTC, tr("Tried to change the track state for track %d from destroyed to %s."), this.getSsrc(), RemoteRTPTrackState[state]);
return;
}
const oldState = this.currentState;
this.currentState = state;
this.events.fire("notify_state_changed", { oldState: oldState, newState: state });
}
}
export class RemoteRTPVideoTrack extends RemoteRTPTrack {
protected mediaStream: MediaStream;
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
super(ssrc, transceiver);
this.mediaStream = new MediaStream();
this.mediaStream.addTrack(transceiver.receiver.track);
}
getMediaStream() : MediaStream {
return this.mediaStream;
}
protected handleTrackEnded() {
}
}
export class RemoteRTPAudioTrack extends RemoteRTPTrack {
protected htmlAudioNode: HTMLAudioElement;
protected mediaStream: MediaStream;
protected audioNode: MediaStreamAudioSourceNode;
protected gainNode: GainNode;
protected shouldReplay: boolean;
protected gain: number;
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
super(ssrc, transceiver);
this.gain = 0;
this.shouldReplay = false;
this.mediaStream = new MediaStream();
this.mediaStream.addTrack(transceiver.receiver.track);
this.htmlAudioNode = document.createElement("audio");
this.htmlAudioNode.srcObject = this.mediaStream;
this.htmlAudioNode.autoplay = true;
this.htmlAudioNode.muted = true;
this.htmlAudioNode.msRealTime = true;
/*
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;
}
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)
}
}
*/
aplayer.on_ready(() => {
const audioContext = aplayer.context();
this.audioNode = audioContext.createMediaStreamSource(this.mediaStream);
this.gainNode = audioContext.createGain();
this.gainNode.gain.value = this.shouldReplay ? this.gain : 0;
this.audioNode.connect(this.gainNode);
this.gainNode.connect(audioContext.destination);
});
const track = transceiver.receiver.track;
track.onended = () => this.handleTrackEnded();
/* Audio tracks do not fire muted/unmuted events */
}
protected handleTrackEnded() {
const track = this.getTransceiver().receiver.track;
track.onended = undefined;
this.htmlAudioNode?.remove();
this.htmlAudioNode = undefined;
this.mediaStream = undefined;
this.setState(RemoteRTPTrackState.Destroyed);
}
getGain() : GainNode | undefined {
return this.gainNode;
}
setGain(value: number) {
this.gain = value;
if(this.gainNode) {
this.gainNode.gain.value = this.shouldReplay ? this.gain : 0;
}
}
/**
* Mutes this track until the next setGain(..) call or a new sequence begins (state update)
*/
abortCurrentReplay() {
if(this.gainNode) {
this.gainNode.gain.value = 0;
}
}
}

189
web/app/rtc/SdpUtils.ts Normal file
View File

@ -0,0 +1,189 @@
import * as sdpTransform from "sdp-transform";
import {MediaDescription} from "sdp-transform";
import {guid} from "tc-shared/crypto/uid";
interface SdpCodec {
payload: number;
codec: string;
rate?: number;
encoding?: number;
fmtp?: { [key: string]: string | number },
rtcpFb?: string[]
}
/* These MUST be the payloads used by the remote as well */
const OPUS_VOICE_PAYLOAD_TYPE = 111;
const OPUS_MUSIC_PAYLOAD_TYPE = 112;
const VP8_PAYLOAD_TYPE = 96;
type SdpMedia = {
type: string;
port: number;
protocol: string;
payloads?: string;
} & MediaDescription;
export class SdpProcessor {
private static readonly kAudioCodecs: SdpCodec[] = [
{
/* Opus Mono/Opus Voice */
payload: OPUS_VOICE_PAYLOAD_TYPE,
codec: "opus",
rate: 48000,
encoding: 2,
fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, stereo: 1 },
rtcpFb: [ "transport-cc" ]
},
{
/* Opus Stereo/Opus Music */
payload: OPUS_MUSIC_PAYLOAD_TYPE,
codec: "opus",
rate: 48000,
encoding: 2,
fmtp: { minptime: 1, maxptime: 20, useinbandfec: 1, stereo: 1 },
rtcpFb: [ "transport-cc" ]
}
];
private static readonly kVideoCodecs: SdpCodec[] = [
{
payload: VP8_PAYLOAD_TYPE,
codec: "VP8",
rate: 90000,
rtcpFb: [ "nack", "nack pli", "ccm fir", "transport-cc" ]
}
];
private rtpRemoteChannelMapping: {[key: string]: number};
private rtpLocalChannelMapping: {[key: string]: number};
constructor() {
this.reset();
}
reset() {
this.rtpRemoteChannelMapping = {};
this.rtpLocalChannelMapping = {};
}
getRemoteSsrcFromFromMediaId(mediaId: string) : number | undefined {
return this.rtpRemoteChannelMapping[mediaId];
}
getLocalSsrcFromFromMediaId(mediaId: string) : number | undefined {
return this.rtpLocalChannelMapping[mediaId];
}
processIncomingSdp(sdpString: string, _mode: "offer" | "answer") : string {
const sdp = sdpTransform.parse(sdpString);
this.rtpRemoteChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp);
return sdpTransform.write(sdp);
}
processOutgoingSdp(sdpString: string, mode: "offer" | "answer") : string {
const sdp = sdpTransform.parse(sdpString);
/* apply the "root" fingerprint to each media, FF fix */
if(sdp.fingerprint) {
sdp.media.forEach(media => media.fingerprint = sdp.fingerprint);
}
/* remove the FID groups for video (We don't support that) */
for(const media of sdp.media) {
if(!media.ssrcGroups) { continue; }
for(const group of media.ssrcGroups.slice()) {
if(group.semantics === "FID") {
/* Keep the first ssrc which is the primary source. The other is for FID */
const ids = group.ssrcs.split(" ").map(ssrc => parseInt(ssrc) >>> 0).slice(1);
media.ssrcs = media.ssrcs.filter(ssrc => ids.indexOf(parseInt(ssrc.id as string) >>> 0) === -1);
media.ssrcGroups.remove(group);
}
}
}
this.rtpLocalChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp);
SdpProcessor.patchLocalCodecs(sdp);
return sdpTransform.write(sdp);
}
private static generateRtpSSrcMapping(sdp: sdpTransform.SessionDescription) {
const mapping = {};
for(let media of sdp.media) {
if(typeof media.mid === "undefined") {
throw tra("missing media id for line {}", sdp.media.indexOf(media));
}
/* every ssrc MUST have a cname */
const ssrcs = (media.ssrcs || []).filter(e => e.attribute === "cname");
ssrcs.forEach(ssrc => {
if(typeof mapping[media.mid] === "undefined") {
mapping[media.mid] = ssrc.id as number >>> 0;
}
});
}
return mapping;
}
private static patchLocalCodecs(sdp: sdpTransform.SessionDescription) {
for(let media of sdp.media) {
if(media.type !== "video" && media.type !== "audio") {
continue;
}
media.fmtp = [];
media.rtp = [];
media.rtcpFb = [];
media.rtcpFbTrrInt = [];
for(let codec of (media.type === "audio" ? this.kAudioCodecs : this.kVideoCodecs)) {
media.rtp.push({
payload: codec.payload,
codec: codec.codec,
encoding: codec.encoding,
rate: codec.rate
});
codec.rtcpFb?.forEach(fb => media.rtcpFb.push({
payload: codec.payload,
type: fb
}));
if(codec.fmtp && Object.keys(codec.fmtp).length > 0) {
media.fmtp.push({
payload: codec.payload,
config: Object.keys(codec.fmtp).map(e => e + "=" + codec.fmtp[e]).join(";")
});
media.maxptime = media.fmtp["maxptime"];
}
}
media.payloads = media.rtp.map(e => e.payload).join(" ");
}
}
}
export namespace SdpCompressor {
export function decompressSdp(sdp: string, mode: number) : string {
if(mode === 0) {
return sdp;
} else if(mode === 1) {
/* TODO! */
return sdp;
} else {
throw tr("unsupported compression mode");
}
}
export function compressSdp(sdp: string, mode: number) : string {
if(mode === 0) {
return sdp;
} else if(mode === 1) {
/* TODO! */
return sdp;
} else {
throw tr("unsupported compression mode");
}
}
}

View File

@ -0,0 +1,241 @@
import {
VideoBroadcastState,
VideoBroadcastType,
VideoClient,
VideoConnection,
VideoConnectionEvent,
VideoConnectionStatus
} from "tc-shared/connection/VideoConnection";
import {Registry} from "tc-shared/events";
import {VideoSource} from "tc-shared/video/VideoSource";
import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-backend/web/rtc/Connection";
import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log";
import {Settings, settings} from "tc-shared/settings";
import {RtpVideoClient} from "tc-backend/web/rtc/video/VideoClient";
import {tr} from "tc-shared/i18n/localize";
type VideoBroadcast = {
readonly source: VideoSource;
state: VideoBroadcastState,
failedReason: string | undefined,
active: boolean
}
export class RtpVideoConnection implements VideoConnection {
private readonly rtcConnection: RTCConnection;
private readonly events: Registry<VideoConnectionEvent>;
private readonly listenerClientMoved;
private readonly listenerRtcStateChanged;
private connectionState: VideoConnectionStatus;
private broadcasts: {[T in VideoBroadcastType]: VideoBroadcast} = {
camera: undefined,
screen: undefined
};
private registeredClients: {[key: number]: RtpVideoClient} = {};
constructor(rtcConnection: RTCConnection) {
this.rtcConnection = rtcConnection;
this.events = new Registry<VideoConnectionEvent>();
this.setConnectionState(VideoConnectionStatus.Disconnected);
this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => {
const localClientId = this.rtcConnection.getConnection().client.getClientId();
for(const data of event.arguments) {
if(parseInt(data["clid"]) === localClientId) {
if(settings.static_global(Settings.KEY_STOP_VIDEO_ON_SWITCH)) {
this.stopBroadcasting("camera", true);
this.stopBroadcasting("screen", true);
} else {
/* The server stops broadcasting by default, we've to reenable it */
this.restartBroadcast("screen");
this.restartBroadcast("camera");
}
}
}
});
this.listenerRtcStateChanged = this.rtcConnection.getEvents().on("notify_state_changed", event => this.handleRtcConnectionStateChanged(event));
/* TODO: Screen share?! */
this.rtcConnection.getEvents().on("notify_video_assignment_changed", event => this.handleVideoAssignmentChanged("camera", event));
}
private setConnectionState(state: VideoConnectionStatus) {
if(this.connectionState === state) { return; }
const oldState = this.connectionState;
this.connectionState = state;
this.events.fire("notify_status_changed", { oldState: oldState, newState: state });
}
private restartBroadcast(type: VideoBroadcastType) {
if(!this.broadcasts[type]?.active) { return; }
const broadcast = this.broadcasts[type];
if(broadcast.state !== VideoBroadcastState.Initializing) {
const oldState = broadcast.state;
broadcast.state = VideoBroadcastState.Initializing;
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type });
}
this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen").then(() => {
if(!broadcast.active) { return; }
const oldState = broadcast.state;
broadcast.state = VideoBroadcastState.Running;
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type });
logDebug(LogCategory.VIDEO, tr("Successfully restarted video broadcast of type %s"), type);
}).catch(error => {
if(!broadcast.active) { return; }
logWarn(LogCategory.VIDEO, tr("Failed to restart video broadcast %s: %o"), type, error);
this.stopBroadcasting(type, true);
});
}
destroy() {
this.listenerClientMoved();
this.listenerRtcStateChanged();
}
getEvents(): Registry<VideoConnectionEvent> {
return this.events;
}
getStatus(): VideoConnectionStatus {
return this.connectionState;
}
getBroadcastingState(type: VideoBroadcastType): VideoBroadcastState {
return this.broadcasts[type] ? this.broadcasts[type].state : VideoBroadcastState.Stopped;
}
getBroadcastingSource(type: VideoBroadcastType): VideoSource | undefined {
return this.broadcasts[type]?.source;
}
isBroadcasting(type: VideoBroadcastType) {
return typeof this.broadcasts[type] !== "undefined";
}
async startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise<void> {
if(this.broadcasts[type]) {
this.stopBroadcasting(type);
}
const videoTracks = source.getStream().getVideoTracks();
if(videoTracks.length === 0) {
throw tr("missing video stream track");
}
const broadcast = this.broadcasts[type] = {
source: source.ref(),
state: VideoBroadcastState.Initializing as VideoBroadcastState,
failedReason: undefined,
active: true
};
this.events.fire("notify_local_broadcast_state_changed", { oldState: VideoBroadcastState.Stopped, newState: VideoBroadcastState.Initializing, broadcastType: type });
try {
await this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", videoTracks[0]);
} catch (error) {
this.stopBroadcasting(type);
logError(LogCategory.WEBRTC, tr("Failed to setup video track for broadcast %s: %o"), type, error);
throw tr("failed to initialize video track");
}
if(!broadcast.active) {
return;
}
try {
await this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen");
} catch (error) {
this.stopBroadcasting(type);
throw error;
}
if(!broadcast.active) {
return;
}
broadcast.state = VideoBroadcastState.Running;
this.events.fire("notify_local_broadcast_state_changed", { oldState: VideoBroadcastState.Initializing, newState: VideoBroadcastState.Running, broadcastType: type });
}
stopBroadcasting(type: VideoBroadcastType, skipRtcStop?: boolean) {
if(!skipRtcStop) {
this.rtcConnection.stopTrackBroadcast(type === "camera" ? "video" : "video-screen");
}
this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", null).then(undefined);
if(this.broadcasts[type]) {
const broadcast = this.broadcasts[type];
const oldState = this.broadcasts[type].state;
this.broadcasts[type].active = false;
this.broadcasts[type] = undefined;
broadcast.source.deref();
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Stopped, broadcastType: type });
}
}
registerVideoClient(clientId: number) {
if(typeof this.registeredClients[clientId] !== "undefined") {
debugger;
throw tr("a video client with this id has already been registered");
}
return this.registeredClients[clientId] = new RtpVideoClient(clientId);
}
registeredVideoClients(): VideoClient[] {
return Object.values(this.registeredClients);
}
unregisterVideoClient(client: VideoClient) {
const clientId = client.getClientId();
if(this.registeredClients[clientId] !== client) {
debugger;
return;
}
this.registeredClients[clientId].destroy();
delete this.registeredClients[clientId];
}
private handleRtcConnectionStateChanged(event: RTCConnectionEvents["notify_state_changed"]) {
switch (event.newState) {
case RTPConnectionState.CONNECTED:
this.setConnectionState(VideoConnectionStatus.Connected);
break;
case RTPConnectionState.CONNECTING:
this.setConnectionState(VideoConnectionStatus.Connecting);
break;
case RTPConnectionState.DISCONNECTED:
this.setConnectionState(VideoConnectionStatus.Disconnected);
break;
case RTPConnectionState.FAILED:
this.setConnectionState(VideoConnectionStatus.Failed);
break;
}
}
private handleVideoAssignmentChanged(type: VideoBroadcastType, event: RTCConnectionEvents["notify_video_assignment_changed"]) {
const oldClient = Object.values(this.registeredClients).find(client => client.getRtpTrack(type) === event.track);
if(oldClient) {
oldClient.setRtpTrack(type, undefined);
}
if(event.info) {
const newClient = this.registeredClients[event.info.client_id];
if(newClient) {
newClient.setRtpTrack(type, event.track);
} else {
logWarn(LogCategory.VIDEO, tr("Received video track assignment for unknown video client (%o)."), event.info);
}
}
}
}

View File

@ -0,0 +1,99 @@
import {
VideoBroadcastState,
VideoBroadcastType,
VideoClient,
VideoClientEvents
} from "tc-shared/connection/VideoConnection";
import {Registry} from "tc-shared/events";
import {RemoteRTPTrackState, RemoteRTPVideoTrack} from "tc-backend/web/rtc/RemoteTrack";
import {LogCategory, logWarn} from "tc-shared/log";
export class RtpVideoClient implements VideoClient {
private readonly clientId: number;
private readonly events: Registry<VideoClientEvents>;
private readonly listenerTrackStateChanged: {[T in VideoBroadcastType]} = {
screen: event => this.handleTrackStateChanged("screen", event.newState),
camera: event => this.handleTrackStateChanged("camera", event.newState)
};
private currentTrack: {[T in VideoBroadcastType]: RemoteRTPVideoTrack} = {
camera: undefined,
screen: undefined
};
private trackStates: {[T in VideoBroadcastType]: VideoBroadcastState} = {
camera: VideoBroadcastState.Stopped,
screen: VideoBroadcastState.Stopped
}
constructor(clientId: number) {
this.clientId = clientId;
this.events = new Registry<VideoClientEvents>();
}
getClientId(): number {
return this.clientId;
}
getEvents(): Registry<VideoClientEvents> {
return this.events;
}
getVideoStream(broadcastType: VideoBroadcastType): MediaStream {
return this.currentTrack[broadcastType]?.getMediaStream();
}
getVideoState(broadcastType: VideoBroadcastType): VideoBroadcastState {
return this.trackStates[broadcastType];
}
destroy() {
this.setRtpTrack("camera", undefined);
this.setRtpTrack("screen", undefined);
}
getRtpTrack(type: VideoBroadcastType) : RemoteRTPVideoTrack | undefined {
return this.currentTrack[type];
}
setRtpTrack(type: VideoBroadcastType, track: RemoteRTPVideoTrack | undefined) {
if(this.currentTrack[type]) {
this.currentTrack[type].getEvents().off("notify_state_changed", this.listenerTrackStateChanged[type]);
}
this.currentTrack[type] = track;
if(this.currentTrack[type]) {
this.currentTrack[type].getEvents().on("notify_state_changed", this.listenerTrackStateChanged[type]);
this.handleTrackStateChanged(type, this.currentTrack[type].getState());
}
}
private setBroadcastState(type: VideoBroadcastType, state: VideoBroadcastState) {
if(this.trackStates[type] === state) {
return;
}
const oldState = this.trackStates[type];
this.trackStates[type] = state;
this.events.fire("notify_broadcast_state_changed", { broadcastType: type, oldState: oldState, newState: state });
}
private handleTrackStateChanged(type: VideoBroadcastType, newState: RemoteRTPTrackState) {
switch (newState) {
case RemoteRTPTrackState.Bound:
case RemoteRTPTrackState.Unbound:
this.setBroadcastState(type, VideoBroadcastState.Stopped);
break;
case RemoteRTPTrackState.Started:
this.setBroadcastState(type, VideoBroadcastState.Running);
break;
case RemoteRTPTrackState.Destroyed:
logWarn(LogCategory.VIDEO, tr("Received new track state 'Destroyed' which should never happen."));
this.setBroadcastState(type, VideoBroadcastState.Stopped);
break;
}
}
}

View File

@ -0,0 +1,362 @@
import {
AbstractVoiceConnection,
VoiceConnectionStatus,
WhisperSessionInitializer
} from "tc-shared/connection/VoiceConnection";
import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
import {VoiceClient} from "tc-shared/voice/VoiceClient";
import {WhisperSession, WhisperSessionState, WhisperTarget} from "tc-shared/voice/VoiceWhisper";
import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-backend/web/rtc/Connection";
import {AbstractServerConnection} from "tc-shared/connection/ConnectionBase";
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
import * as log from "tc-shared/log";
import {LogCategory, logError, 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/rtc/voice/VoiceClient";
import {InputConsumerType} from "tc-shared/voice/RecorderBase";
export class RtpVoiceConnection extends AbstractVoiceConnection {
private readonly rtcConnection: RTCConnection;
private readonly listenerRtcAudioAssignment;
private readonly listenerRtcStateChanged;
private listenerClientMoved;
private connectionState: VoiceConnectionStatus;
private localFailedReason: string;
private localAudioDestination: MediaStreamAudioDestinationNode;
private currentAudioSourceNode: AudioNode;
private currentAudioSource: RecorderProfile;
private voiceClients: RtpVoiceClient[] = [];
private currentlyReplayingVoice: boolean = false;
private readonly voiceClientStateChangedEventListener;
private readonly whisperSessionStateChangedEventListener;
constructor(connection: AbstractServerConnection, rtcConnection: RTCConnection) {
super(connection);
this.rtcConnection = rtcConnection;
this.voiceClientStateChangedEventListener = this.handleVoiceClientStateChange.bind(this);
this.rtcConnection.getEvents().on("notify_audio_assignment_changed",
this.listenerRtcAudioAssignment = event => this.handleAudioAssignmentChanged(event));
this.rtcConnection.getEvents().on("notify_state_changed",
this.listenerRtcStateChanged = event => this.handleRtcConnectionStateChanged(event));
this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => {
const localClientId = this.rtcConnection.getConnection().client.getClientId();
for(const data of event.arguments) {
if(parseInt(data["clid"]) === localClientId) {
/* TODO: Error handling if we failed to start */
this.rtcConnection.startTrackBroadcast("audio");
}
}
});
this.setConnectionState(VoiceConnectionStatus.Disconnected);
aplayer.on_ready(() => {
this.localAudioDestination = aplayer.context().createMediaStreamDestination();
if(this.currentAudioSourceNode) {
this.currentAudioSourceNode.connect(this.localAudioDestination);
}
});
}
destroy() {
if(this.listenerClientMoved) {
this.listenerClientMoved();
this.listenerClientMoved = undefined;
}
this.rtcConnection.getEvents().off("notify_audio_assignment_changed", this.listenerRtcAudioAssignment);
this.rtcConnection.getEvents().off("notify_state_changed", this.listenerRtcStateChanged);
this.acquireVoiceRecorder(undefined, true).catch(error => {
log.warn(LogCategory.VOICE, tr("Failed to release voice recorder: %o"), error);
}).then(() => {
for(const client of Object.values(this.voiceClients)) {
client.abortReplay();
}
this.voiceClients = undefined;
this.currentAudioSource = undefined;
});
if(Object.keys(this.voiceClients).length !== 0) {
logWarn(LogCategory.AUDIO, tr("Voice connection will be destroyed, but some voice clients are still left (%d)."), Object.keys(this.voiceClients).length);
}
/*
const whisperSessions = Object.keys(this.whisperSessions);
whisperSessions.forEach(session => this.whisperSessions[session].destroy());
this.whisperSessions = {};
*/
this.events.destroy();
}
getConnectionState(): VoiceConnectionStatus {
return this.connectionState;
}
getFailedMessage(): string {
return this.localFailedReason || this.rtcConnection.getFailReason();
}
voiceRecorder(): RecorderProfile {
return this.currentAudioSource;
}
async acquireVoiceRecorder(recorder: RecorderProfile | undefined, enforce?: boolean): Promise<void> {
if(this.currentAudioSource === recorder && !enforce) {
return;
}
if(this.currentAudioSource) {
this.currentAudioSourceNode?.disconnect(this.localAudioDestination);
this.currentAudioSourceNode = undefined;
this.currentAudioSource.callback_unmount = undefined;
await this.currentAudioSource.unmount();
}
/* unmount our target recorder */
await recorder?.unmount();
this.handleRecorderStop();
this.currentAudioSource = recorder;
if(recorder) {
recorder.current_handler = this.connection.client;
recorder.callback_unmount = this.handleRecorderUnmount.bind(this);
recorder.callback_start = this.handleRecorderStart.bind(this);
recorder.callback_stop = this.handleRecorderStop.bind(this);
recorder.callback_input_initialized = async input => {
await input.setConsumer({
type: InputConsumerType.NODE,
callbackDisconnect: node => {
this.currentAudioSourceNode = undefined;
node.disconnect(this.localAudioDestination);
},
callbackNode: node => {
this.currentAudioSourceNode = node;
if(this.localAudioDestination) {
node.connect(this.localAudioDestination);
}
}
});
};
if(recorder.input) {
recorder.callback_input_initialized(recorder.input);
}
if(!recorder.input || recorder.input.isFiltered()) {
this.handleRecorderStop();
} else {
this.handleRecorderStart();
}
}
this.events.fire("notify_recorder_changed");
}
private handleRecorderStop() {
const chandler = this.connection.client;
const ch = chandler.getClient();
if(ch) ch.speaking = false;
if(!chandler.connected)
return false;
if(chandler.isMicrophoneMuted())
return false;
log.info(LogCategory.VOICE, tr("Local voice ended"));
this.rtcConnection.setTrackSource("audio", null).catch(error => {
logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error);
});
}
private handleRecorderStart() {
const chandler = this.connection.client;
if(chandler.isMicrophoneMuted()) {
log.warn(LogCategory.VOICE, tr("Received local voice started event, even thou we're muted!"));
return;
}
log.info(LogCategory.VOICE, tr("Local voice started"));
const ch = chandler.getClient();
if(ch) ch.speaking = true;
this.rtcConnection.setTrackSource("audio", this.localAudioDestination.stream.getAudioTracks()[0])
.catch(error => {
logError(LogCategory.AUDIO, tr("Failed to set current audio track: %o"), error);
});
}
private handleRecorderUnmount() {
log.info(LogCategory.VOICE, "Lost recorder!");
this.currentAudioSource = undefined;
this.acquireVoiceRecorder(undefined, true); /* we can ignore the promise because we should finish this directly */
}
isReplayingVoice(): boolean {
return this.currentlyReplayingVoice;
}
decodingSupported(codec: number): boolean {
return codec === 4 || codec === 5;
}
encodingSupported(codec: number): boolean {
return codec === 4 || codec === 5;
}
getEncoderCodec(): number {
return 5;
}
setEncoderCodec(codec: number) { }
availableVoiceClients(): VoiceClient[] {
return Object.values(this.voiceClients);
}
registerVoiceClient(clientId: number) {
if(typeof this.voiceClients[clientId] !== "undefined") {
throw tr("voice client already registered");
}
const client = new RtpVoiceClient(clientId);
this.voiceClients[clientId] = client;
client.events.on("notify_state_changed", this.voiceClientStateChangedEventListener);
return client;
}
unregisterVoiceClient(client: VoiceClient) {
if(!(client instanceof RtpVoiceClient))
throw "Invalid client type";
console.error("Destroy voice client %d", client.getClientId());
client.events.off("notify_state_changed", this.voiceClientStateChangedEventListener);
delete this.voiceClients[client.getClientId()];
client.destroy();
}
stopAllVoiceReplays() {
}
getWhisperSessionInitializer(): WhisperSessionInitializer | undefined {
return undefined;
}
setWhisperSessionInitializer(initializer: WhisperSessionInitializer | undefined) {
}
getWhisperSessions(): WhisperSession[] {
return [];
}
getWhisperTarget(): WhisperTarget | undefined {
return undefined;
}
dropWhisperSession(session: WhisperSession) {
}
startWhisper(target: WhisperTarget): Promise<void> {
return Promise.resolve(undefined);
}
stopWhisper() { }
private handleVoiceClientStateChange(/* event: VoicePlayerEvents["notify_state_changed"] */) {
this.updateVoiceReplaying();
}
private handleWhisperSessionStateChange() {
this.updateVoiceReplaying();
}
private updateVoiceReplaying() {
let replaying = false;
/* if(this.connectionState === VoiceConnectionStatus.Connected) */ {
let index = this.availableVoiceClients().findIndex(client => client.getState() === VoicePlayerState.PLAYING || client.getState() === VoicePlayerState.BUFFERING);
replaying = index !== -1;
if(!replaying) {
index = this.getWhisperSessions().findIndex(session => session.getSessionState() === WhisperSessionState.PLAYING);
replaying = index !== -1;
}
}
if(this.currentlyReplayingVoice !== replaying) {
this.currentlyReplayingVoice = replaying;
this.events.fire_later("notify_voice_replay_state_change", { replaying: replaying });
}
}
private setConnectionState(state: VoiceConnectionStatus) {
if(this.connectionState === state)
return;
const oldState = this.connectionState;
this.connectionState = state;
this.events.fire("notify_connection_status_changed", { newStatus: state, oldStatus: oldState });
}
private handleRtcConnectionStateChanged(event: RTCConnectionEvents["notify_state_changed"]) {
switch (event.newState) {
case RTPConnectionState.CONNECTED:
this.rtcConnection.startTrackBroadcast("audio").then(() => {
logTrace(LogCategory.VOICE, tr("Local audio broadcasting has been started successfully"));
this.setConnectionState(VoiceConnectionStatus.Connected);
}).catch(error => {
logError(LogCategory.VOICE, tr("Failed to start voice audio broadcasting: %o"), error);
this.localFailedReason = tr("Failed to start audio broadcasting");
this.setConnectionState(VoiceConnectionStatus.Failed);
});
break;
case RTPConnectionState.CONNECTING:
this.setConnectionState(VoiceConnectionStatus.Connecting);
break;
case RTPConnectionState.DISCONNECTED:
this.setConnectionState(VoiceConnectionStatus.Disconnected);
break;
case RTPConnectionState.FAILED:
this.localFailedReason = undefined;
this.setConnectionState(VoiceConnectionStatus.Failed);
break;
}
}
private handleAudioAssignmentChanged(event: RTCConnectionEvents["notify_audio_assignment_changed"]) {
const oldClient = Object.values(this.voiceClients).find(client => client.getRtpTrack() === event.track);
if(oldClient) {
oldClient.setRtpTrack(undefined);
}
if(event.info) {
const newClient = this.voiceClients[event.info.client_id];
if(newClient) {
newClient.setRtpTrack(event.track);
} else {
logWarn(LogCategory.AUDIO, tr("Received audio track assignment for unknown voice client (%o)."), event.info);
}
}
}
}

View File

@ -0,0 +1,98 @@
import {VoiceClient} from "tc-shared/voice/VoiceClient";
import {VoicePlayerEvents, VoicePlayerLatencySettings, VoicePlayerState} from "tc-shared/voice/VoicePlayer";
import {Registry} from "tc-shared/events";
import {LogCategory, logWarn} from "tc-shared/log";
import {RemoteRTPAudioTrack, RemoteRTPTrackState} from "tc-backend/web/rtc/RemoteTrack";
export class RtpVoiceClient implements VoiceClient {
readonly events: Registry<VoicePlayerEvents>;
private readonly listenerTrackStateChanged;
private readonly clientId: number;
private volume: number;
private currentState: VoicePlayerState;
private currentRtpTrack: RemoteRTPAudioTrack;
constructor(clientId: number) {
this.clientId = clientId;
this.listenerTrackStateChanged = event => this.handleTrackStateChanged(event.newState);
this.events = new Registry<VoicePlayerEvents>();
this.currentState = VoicePlayerState.STOPPED;
}
destroy() {
this.events.destroy();
}
getClientId(): number {
return this.clientId;
}
abortReplay() {
this.currentRtpTrack?.abortCurrentReplay();
this.setState(VoicePlayerState.STOPPED);
}
flushBuffer() { /* not possible */ }
getState(): VoicePlayerState {
return this.currentState;
}
protected setState(state: VoicePlayerState) {
if(this.currentState === state) { return; }
const oldState = this.currentState;
this.currentState = state;
this.events.fire("notify_state_changed", { oldState: oldState, newState: state });
}
getVolume(): number {
return this.volume;
}
setVolume(volume: number) {
this.volume = volume;
this.currentRtpTrack?.setGain(volume);
}
getLatencySettings(): Readonly<VoicePlayerLatencySettings> {
return { minBufferTime: 0, maxBufferTime: 0 };
}
resetLatencySettings() { }
setLatencySettings(settings: VoicePlayerLatencySettings) { }
setRtpTrack(track: RemoteRTPAudioTrack | undefined) {
if(this.currentRtpTrack) {
this.currentRtpTrack.setGain(0);
this.currentRtpTrack.getEvents().off("notify_state_changed", this.listenerTrackStateChanged);
}
this.currentRtpTrack = track;
if(this.currentRtpTrack) {
this.currentRtpTrack.setGain(this.volume);
this.currentRtpTrack.getEvents().on("notify_state_changed", this.listenerTrackStateChanged);
this.handleTrackStateChanged(this.currentRtpTrack.getState());
}
}
getRtpTrack() {
return this.currentRtpTrack;
}
private handleTrackStateChanged(newState: RemoteRTPTrackState) {
switch (newState) {
case RemoteRTPTrackState.Bound:
case RemoteRTPTrackState.Unbound:
this.setState(VoicePlayerState.STOPPED);
break;
case RemoteRTPTrackState.Started:
this.setState(VoicePlayerState.PLAYING);
break;
case RemoteRTPTrackState.Destroyed:
logWarn(LogCategory.AUDIO, tr("Received new track state 'Destroyed' which should never happen."));
this.setState(VoicePlayerState.STOPPED);
break;
}
}
}

View File

View File

@ -85,7 +85,7 @@ function initializeFaviconController(events: Registry<FaviconEvents>) {
icon = currentHandler.getClient().getStatusIcon();
}
events.fire_async("notify_icon", { icon: icon })
events.fire_later("notify_icon", { icon: icon })
};
setCurrentHandler(server_connections.active_connection());

View File

@ -4,7 +4,6 @@ import * as aplayer from "../audio/player";
import {ServerConnection} from "../connection/ServerConnection";
import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
import {VoiceClientController} from "./VoiceClient";
import {settings, ValuedSettingsKey} from "tc-shared/settings";
import {tr} from "tc-shared/i18n/localize";
import {
AbstractVoiceConnection,
@ -27,24 +26,12 @@ import {VoiceClient} from "tc-shared/voice/VoiceClient";
import {WebWhisperSession} from "tc-backend/web/voice/VoiceWhisper";
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
export enum VoiceEncodeType {
JS_ENCODE,
NATIVE_ENCODE
}
const KEY_VOICE_CONNECTION_TYPE: ValuedSettingsKey<number> = {
key: "voice_connection_type",
valueType: "number",
defaultValue: VoiceEncodeType.NATIVE_ENCODE
};
type CancelableWhisperTarget = WhisperTarget & { canceled: boolean };
export class VoiceConnection extends AbstractVoiceConnection {
readonly connection: ServerConnection;
private readonly serverConnectionStateListener;
private connectionType: VoiceEncodeType = VoiceEncodeType.NATIVE_ENCODE;
private connectionState: VoiceConnectionStatus;
private failedConnectionMessage: string;
@ -81,8 +68,6 @@ export class VoiceConnection extends AbstractVoiceConnection {
this.connectionState = VoiceConnectionStatus.Disconnected;
this.connection = connection;
this.connectionType = settings.static_global(KEY_VOICE_CONNECTION_TYPE, this.connectionType);
this.connection.events.on("notify_connection_state_changed",
this.serverConnectionStateListener = this.handleServerConnectionStateChanged.bind(this));
@ -172,6 +157,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
}
private startVoiceBridge() {
return; /* We're not doing this currently */
if(!aplayer.initialized()) {
logDebug(LogCategory.VOICE, tr("Audio player isn't initialized yet. Waiting for it to initialize."));
if(!this.awaitingAudioInitialize) {
@ -420,7 +406,7 @@ export class VoiceConnection extends AbstractVoiceConnection {
if(this.currentlyReplayingVoice !== replaying) {
this.currentlyReplayingVoice = replaying;
this.events.fire_async("notify_voice_replay_state_change", { replaying: replaying });
this.events.fire_later("notify_voice_replay_state_change", { replaying: replaying });
}
}
@ -544,14 +530,4 @@ export class VoiceConnection extends AbstractVoiceConnection {
}
this.voiceBridge?.stopWhispering();
}
}
/* funny fact that typescript dosn't find this */
declare global {
interface RTCPeerConnection {
addStream(stream: MediaStream): void;
getLocalStreams(): MediaStream[];
getStreamById(streamId: string): MediaStream | null;
removeStream(stream: MediaStream): void;
}
}

View File

@ -43,8 +43,8 @@ export class NativeWebRTCVoiceBridge extends WebRTCVoiceBridge {
}
protected initializeRtpConnection(connection: RTCPeerConnection) {
connection.addStream(this.localVoiceDestinationNode.stream);
connection.addStream(this.localWhisperDestinationNode.stream);
connection.addTrack(this.localVoiceDestinationNode.stream.getAudioTracks()[0]);
connection.addTrack(this.localWhisperDestinationNode.stream.getAudioTracks()[0]);
}
protected handleVoiceDataChannelMessage(message: MessageEvent) {