Introduced Video to the web client. A lot of changes are still pending
parent
cde346a628
commit
658b44ed1d
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 |
|
@ -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 */
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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++;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) => {
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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: {}
|
||||
}
|
|
@ -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"} />);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
};
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
|
|
|
@ -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});
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
});
|
||||
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}));
|
||||
}
|
|
@ -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)
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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: {}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>;
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ html:root {
|
|||
@include text-dotdotdot();
|
||||
|
||||
&:hover {
|
||||
background-color: #0a0a0a;
|
||||
background-color: #121212;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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"} />,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -114,6 +114,6 @@ export class ChannelTreePopoutController {
|
|||
break;
|
||||
}
|
||||
|
||||
this.uiEvents.fire_async("notify_title", { title: title });
|
||||
this.uiEvents.fire_react("notify_title", { title: title });
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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";
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
});
|
|
@ -10,6 +10,7 @@ import "./hooks/ServerConnection";
|
|||
import "./hooks/ExternalModal";
|
||||
import "./hooks/AudioRecorder";
|
||||
import "./hooks/MenuBar";
|
||||
import "./hooks/Video";
|
||||
|
||||
import "./UnloadHandler";
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -545,13 +531,3 @@ 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;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in New Issue