From 658b44ed1d0f05e6fa90401364ae4e9a1bf808fe Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Sat, 7 Nov 2020 13:16:07 +0100 Subject: [PATCH] Introduced Video to the web client. A lot of changes are still pending --- ChangeLog.md | 5 + package-lock.json | 11 + package.json | 2 + shared/html/templates.html | 2 +- shared/img/client-icons/double_arrow.svg | 6 + shared/js/ConnectionHandler.ts | 58 +- shared/js/ConnectionManager.ts | 19 + shared/js/connection/CommandHandler.ts | 2 +- shared/js/connection/ConnectionBase.ts | 2 + shared/js/connection/VideoConnection.ts | 61 ++ shared/js/events.ts | 97 ++- .../js/events/ClientGlobalControlHandler.ts | 29 + shared/js/events/GlobalEvents.ts | 4 +- shared/js/file/FileManager.tsx | 2 +- shared/js/file/Transfer.ts | 2 +- shared/js/log.ts | 5 +- shared/js/main.tsx | 12 + shared/js/proto.ts | 25 + shared/js/settings.ts | 7 + shared/js/tree/ChannelTree.tsx | 60 +- shared/js/tree/Client.ts | 27 +- .../connection-handler-list/Controller.ts | 18 +- shared/js/ui/frames/control-bar/Button.tsx | 5 +- shared/js/ui/frames/control-bar/Controller.ts | 66 +- .../js/ui/frames/control-bar/Definitions.ts | 5 + shared/js/ui/frames/control-bar/Renderer.tsx | 48 +- shared/js/ui/frames/log/Renderer.tsx | 9 +- shared/js/ui/frames/log/ServerEventLog.tsx | 4 +- .../js/ui/frames/side/AbstractConversion.ts | 26 +- shared/js/ui/frames/side/ChatBox.tsx | 2 +- .../js/ui/frames/side/ConversationManager.ts | 2 +- shared/js/ui/frames/side/ConversationUI.tsx | 2 +- .../frames/side/PrivateConversationManager.ts | 6 +- shared/js/ui/frames/video/Controller.ts | 471 +++++++++++ shared/js/ui/frames/video/Definitions.ts | 49 ++ shared/js/ui/frames/video/Renderer.scss | 266 ++++++ shared/js/ui/frames/video/Renderer.tsx | 264 ++++++ shared/js/ui/modal/ModalChangeVolumeNew.tsx | 4 +- shared/js/ui/modal/ModalGroupCreate.tsx | 24 +- .../js/ui/modal/ModalGroupPermissionCopy.tsx | 18 +- shared/js/ui/modal/ModalNewcomer.tsx | 14 +- shared/js/ui/modal/ModalSettings.tsx | 34 +- shared/js/ui/modal/css-editor/Controller.ts | 20 +- shared/js/ui/modal/css-editor/Renderer.tsx | 2 +- shared/js/ui/modal/echo-test/Controller.tsx | 6 +- .../global-settings-editor/Controller.tsx | 7 +- .../permission/ModalPermissionEditor.tsx | 6 +- shared/js/ui/modal/permission/TabHandler.tsx | 2 +- shared/js/ui/modal/settings/Keymap.tsx | 6 +- shared/js/ui/modal/settings/Microphone.tsx | 22 +- shared/js/ui/modal/settings/Notifications.tsx | 10 +- shared/js/ui/modal/transfer/FileBrowser.tsx | 2 +- .../transfer/RemoteFileBrowserController.ts | 44 +- .../modal/transfer/TransferInfoController.ts | 2 +- .../js/ui/modal/video-source/Controller.tsx | 174 ++++ .../js/ui/modal/video-source/Definitions.ts | 37 + shared/js/ui/modal/video-source/Renderer.scss | 126 +++ shared/js/ui/modal/video-source/Renderer.tsx | 246 ++++++ shared/js/ui/react-elements/Button.scss | 2 +- shared/js/ui/react-elements/InputField.scss | 2 +- shared/js/ui/react-elements/InputField.tsx | 21 +- .../external-modal/IPCMessage.ts | 46 +- shared/js/ui/tree/Controller.tsx | 26 +- shared/js/ui/tree/RendererClient.tsx | 2 +- shared/js/ui/tree/RendererDataProvider.tsx | 2 +- shared/js/ui/tree/popout/Controller.ts | 2 +- shared/js/video-viewer/Controller.ts | 32 +- shared/js/video-viewer/Renderer.tsx | 2 +- shared/js/video-viewer/W2GPlugin.ts | 2 +- shared/js/video/VideoSource.ts | 62 ++ shared/js/voice/RecorderBase.ts | 6 +- shared/svg-sprites/client-icons.d.ts | 7 +- web/app/audio/Recorder.ts | 83 +- web/app/audio/RecorderDeviceList.ts | 22 +- web/app/connection/ServerConnection.ts | 32 +- web/app/hooks/Video.ts | 14 + web/app/index.ts | 1 + web/app/media/Stream.ts | 110 +++ web/app/media/Video.ts | 254 ++++++ web/app/rtc/Connection.ts | 765 ++++++++++++++++++ web/app/rtc/RemoteTrack.ts | 200 +++++ web/app/rtc/SdpUtils.ts | 189 +++++ web/app/rtc/video/Connection.ts | 241 ++++++ web/app/rtc/video/VideoClient.ts | 99 +++ web/app/rtc/voice/Connection.ts | 362 +++++++++ web/app/rtc/voice/VoiceClient.ts | 98 +++ web/app/rtc/voice/WhisperClient.ts | 0 web/app/ui/FaviconRenderer.tsx | 2 +- web/app/voice/VoiceHandler.ts | 28 +- .../voice/bridge/NativeWebRTCVoiceBridge.ts | 4 +- 90 files changed, 4766 insertions(+), 439 deletions(-) create mode 100644 shared/img/client-icons/double_arrow.svg create mode 100644 shared/js/connection/VideoConnection.ts create mode 100644 shared/js/ui/frames/video/Controller.ts create mode 100644 shared/js/ui/frames/video/Definitions.ts create mode 100644 shared/js/ui/frames/video/Renderer.scss create mode 100644 shared/js/ui/frames/video/Renderer.tsx create mode 100644 shared/js/ui/modal/video-source/Controller.tsx create mode 100644 shared/js/ui/modal/video-source/Definitions.ts create mode 100644 shared/js/ui/modal/video-source/Renderer.scss create mode 100644 shared/js/ui/modal/video-source/Renderer.tsx create mode 100644 shared/js/video/VideoSource.ts create mode 100644 web/app/hooks/Video.ts create mode 100644 web/app/media/Stream.ts create mode 100644 web/app/media/Video.ts create mode 100644 web/app/rtc/Connection.ts create mode 100644 web/app/rtc/RemoteTrack.ts create mode 100644 web/app/rtc/SdpUtils.ts create mode 100644 web/app/rtc/video/Connection.ts create mode 100644 web/app/rtc/video/VideoClient.ts create mode 100644 web/app/rtc/voice/Connection.ts create mode 100644 web/app/rtc/voice/VoiceClient.ts create mode 100644 web/app/rtc/voice/WhisperClient.ts diff --git a/ChangeLog.md b/ChangeLog.md index c45e8f15..b241800c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 560f5cfa..3160a682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ad60db3f..5db80909 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/html/templates.html b/shared/html/templates.html index 6c87b8bb..03907843 100644 --- a/shared/html/templates.html +++ b/shared/html/templates.html @@ -15,8 +15,8 @@
-
+
diff --git a/shared/img/client-icons/double_arrow.svg b/shared/img/client-icons/double_arrow.svg new file mode 100644 index 00000000..cce68e7f --- /dev/null +++ b/shared/img/client-icons/double_arrow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 98f5292e..920eee2e 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -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 */ diff --git a/shared/js/ConnectionManager.ts b/shared/js/ConnectionManager.ts index b83e7aa4..b61de4fc 100644 --- a/shared/js/ConnectionManager.ts +++ b/shared/js/ConnectionManager.ts @@ -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; 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(); 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); diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 3c00520c..0e2ec5e7 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -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); diff --git a/shared/js/connection/ConnectionBase.ts b/shared/js/connection/ConnectionBase.ts index b0d2ba15..e77c31ff 100644 --- a/shared/js/connection/ConnectionBase.ts +++ b/shared/js/connection/ConnectionBase.ts @@ -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; abstract getVoiceConnection() : AbstractVoiceConnection; + abstract getVideoConnection() : VideoConnection; abstract command_handler_boss() : AbstractCommandHandlerBoss; abstract send_command(command: string, data?: any | any[], options?: CommandOptions) : Promise; diff --git a/shared/js/connection/VideoConnection.ts b/shared/js/connection/VideoConnection.ts new file mode 100644 index 00000000..198fadac --- /dev/null +++ b/shared/js/connection/VideoConnection.ts @@ -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; + + getVideoState(broadcastType: VideoBroadcastType) : VideoBroadcastState; + getVideoStream(broadcastType: VideoBroadcastType) : MediaStream; +} + +export interface VideoConnection { + getEvents() : Registry; + + 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; + stopBroadcasting(type: VideoBroadcastType); + + registerVideoClient(clientId: number); + registeredVideoClients() : VideoClient[]; + unregisterVideoClient(client: VideoClient); +} \ No newline at end of file diff --git a/shared/js/events.ts b/shared/js/events.ts index 0092bd5d..dac74ebc 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -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 { readonly type: T; @@ -25,7 +24,23 @@ export class SingletonEvent implements Event { fire(event_type: T, data?: Events[T], overrideTypeKey?: boolean); - fire_async(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(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(event_type: T, data?: Events[T], callback?: () => void); } const event_annotation_key = guid(); @@ -41,8 +56,11 @@ export class Registry 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 { handlers.push(handler); - return () => handlers.remove(handler); + return () => { + handlers.remove(handler); + }; }, reactEffectDependencies); } @@ -204,25 +225,65 @@ export class Registry(event_type: T, data?: Events[T], callback?: () => void) { - if(!this.pendingCallbacksTimeout) { - this.pendingCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks()); - this.pendingCallbacks = []; + fire_later(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(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++; + } + }) }); } diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index 6e60025a..d6614e99 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -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) { @@ -173,4 +175,31 @@ export function initialize(event_registry: Registry) 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); + } + }); } \ No newline at end of file diff --git a/shared/js/events/GlobalEvents.ts b/shared/js/events/GlobalEvents.ts index 29d952ef..403595eb 100644 --- a/shared/js/events/GlobalEvents.ts +++ b/shared/js/events/GlobalEvents.ts @@ -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: { diff --git a/shared/js/file/FileManager.tsx b/shared/js/file/FileManager.tsx index de941250..110ef3a1 100644 --- a/shared/js/file/FileManager.tsx +++ b/shared/js/file/FileManager.tsx @@ -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); diff --git a/shared/js/file/Transfer.ts b/shared/js/file/Transfer.ts index 455db987..d23e7749 100644 --- a/shared/js/file/Transfer.ts +++ b/shared/js/file/Transfer.ts @@ -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 { diff --git a/shared/js/log.ts b/shared/js/log.ts index bca95085..3744c00f 100644 --- a/shared/js/log.ts +++ b/shared/js/log.ts @@ -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([ [LogCategory.FILE_TRANSFER, "File transfer "], [LogCategory.EVENT_REGISTRY, "Event registry"], [LogCategory.WEBRTC, "WebRTC "], + [LogCategory.VIDEO, "Video "], ]); export let enabled_mapping = new Map([ @@ -73,6 +75,7 @@ export let enabled_mapping = new Map([ [LogCategory.FILE_TRANSFER, true], [LogCategory.EVENT_REGISTRY, true], [LogCategory.WEBRTC, true], + [LogCategory.VIDEO, true], ]); //Values will be overridden by initialize() diff --git a/shared/js/main.tsx b/shared/js/main.tsx index d2d74915..ad33a905 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -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) => { diff --git a/shared/js/proto.ts b/shared/js/proto.ts index c4286b99..a3691ab4 100644 --- a/shared/js/proto.ts +++ b/shared/js/proto.ts @@ -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) { diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 97c6ea91..916e857c 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -505,6 +505,13 @@ export class Settings extends StaticSettings { valueType: "boolean", }; + static readonly KEY_STOP_VIDEO_ON_SWITCH: ValuedSettingsKey = { + 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 = category => { return { key: "log." + category.toLowerCase() + ".enabled", diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index 0fd3f09b..bc119c73 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -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 = []; diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index 1b8b75ca..5b96729d 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -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 { protected voiceMuted: boolean; private readonly voiceCallbackStateChanged; + protected videoHandle: VideoClient; + private promiseClientInfo: Promise; private promiseClientInfoTimestamp: number; @@ -213,11 +218,11 @@ export class ClientEntry extends ChannelTreeEntry { 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 { 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 { } } + 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 { return this.voiceHandle; } + getVideoClient() : VideoClient { + return this.videoHandle; + } + get properties() : ClientProperties { return this._properties; } diff --git a/shared/js/ui/frames/connection-handler-list/Controller.ts b/shared/js/ui/frames/connection-handler-list/Controller.ts index 2dd99f63..e83d26e3 100644 --- a/shared/js/ui/frames/connection-handler-list/Controller.ts +++ b/shared/js/ui/frames/connection-handler-list/Controller.ts @@ -25,38 +25,38 @@ function initializeController(events: Registry) { }); 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) { 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) { break; } - events.fire_async("notify_handler_status", { + events.fire_react("notify_handler_status", { handlerId: event.handlerId, status: { handlerName: handler.channelTree.server.properties.virtualserver_name, diff --git a/shared/js/ui/frames/control-bar/Button.tsx b/shared/js/ui/frames/control-bar/Button.tsx index da343771..24bbd15a 100644 --- a/shared/js/ui/frames/control-bar/Button.tsx +++ b/shared/js/ui/frames/control-bar/Button.tsx @@ -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; diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index fa7ee092..3818e634 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -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, handler: ConnectionHandler) { @@ -245,7 +279,7 @@ export function initializeControlBarController(events: Registry 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 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 { 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; } \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/Definitions.ts b/shared/js/ui/frames/control-bar/Definitions.ts index 43392599..2c5ba054 100644 --- a/shared/js/ui/frames/control-bar/Definitions.ts +++ b/shared/js/ui/frames/control-bar/Definitions.ts @@ -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: {} } \ No newline at end of file diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index 6d5e3cec..909a5c82 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -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(() => { + events.fire("query_camara_state"); + return "unsupported"; + }); + + events.on("notify_camara_state", event => setState(event.state)); + + switch (state) { + case "unsupported": + return +
+ ); +} + +const VideoPreview = () => { + const events = useContext(ModalEvents); + + const refVideo = useRef(); + const [ status, setStatus ] = useState(() => { + 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 = ; + break; + case "error": + if(status.reason === "no-permissions" || status.reason === "request-permissions") { + body = ; + } else { + body = ; + } + break; + + case "preview": + body = ( +