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"; import { tr } from "tc-shared/i18n/localize"; const cssStyle = require("./Renderer.scss"); let videoIdIndex = 0; interface ClientVideoController { destroy(); toggleMuteState(type: VideoBroadcastType, state: boolean); notifyVideoInfo(); notifyVideo(); notifyMuteState(); } class RemoteClientVideoController implements ClientVideoController { readonly videoId: string; readonly client: ClientEntry; callbackBroadcastStateChanged: (broadcasting: boolean) => void; protected readonly events: Registry; protected eventListener: (() => void)[]; protected eventListenerVideoClient: (() => void)[]; protected mutedState: {[T in VideoBroadcastType]: boolean} = { screen: false, camera: false }; private currentBroadcastState: boolean; constructor(client: ClientEntry, eventRegistry: Registry, 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(); this.notifyMuteState(); })); } } 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); } toggleMuteState(type: VideoBroadcastType, state: boolean) { if(this.mutedState[type] === state) { return; } this.mutedState[type] = state; this.notifyVideo(); this.notifyMuteState(); } 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 initializing = false; let cameraStream, desktopStream; const stateCamera = this.getBroadcastState("camera"); if(stateCamera === VideoBroadcastState.Running) { cameraStream = this.getBroadcastStream("camera") if(cameraStream && this.mutedState["camera"]) { cameraStream = "muted"; } } else if(stateCamera === VideoBroadcastState.Initializing) { initializing = true; } const stateScreen = this.getBroadcastState("screen"); if(stateScreen === VideoBroadcastState.Running) { desktopStream = this.getBroadcastStream("screen"); if(desktopStream && this.mutedState["screen"]) { desktopStream = "muted"; } } else if(stateScreen === VideoBroadcastState.Initializing) { initializing = true; } if(cameraStream || desktopStream) { broadcasting = true; this.events.fire_react("notify_video", { videoId: this.videoId, status: { status: "connected", desktopStream: desktopStream, cameraStream: cameraStream, } }); } 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); } } } notifyMuteState() { this.events.fire_react("notify_video_mute_status", { videoId: this.videoId, status: { camera: this.getBroadcastStream("camera") ? this.mutedState["camera"] ? "muted" : "available" : "unset", screen: this.getBroadcastStream("screen") ? this.mutedState["screen"] ? "muted" : "available" : "unset", } }); } 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) { 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"); } async getStatistics(target: VideoBroadcastType) { } 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; private eventListener: (() => void)[]; private expended: boolean; private currentlyVisible: boolean; private currentChannelId: number; private localVideoController: LocalVideoController; private clientVideos: {[key: number]: RemoteClientVideoController} = {}; private currentSpotlight: string; constructor(events: Registry, connection: ConnectionHandler) { this.events = events; this.events.enableDebug("vc-panel"); 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.notifyVideoList(); this.events.fire_react("notify_expended", { expended: this.expended }); }); this.events.on("action_set_spotlight", event => { this.setSpotlight(event.videoId); if(!this.isExpended()) { this.events.fire("action_toggle_expended", { expended: true }); } }); this.events.on("action_toggle_mute", event => { const controller = this.findVideoById(event.videoId); if(!controller) { logWarn(LogCategory.VIDEO, tr("Tried to toggle video mute state for a non existing video id (%s)."), event.videoId); return; } controller.toggleMuteState(event.broadcastType, event.muted); }); 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_spotlight", () => this.notifySpotlight()); 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(); }); this.events.on("query_video_mute_status", event => { const controller = this.findVideoById(event.videoId); if(!controller) { logWarn(LogCategory.VIDEO, tr("Tried to query mute state for a non existing video id (%s)."), event.videoId); return; } controller.notifyMuteState(); }); 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(); } })); } setSpotlight(videoId: string | undefined) { if(this.currentSpotlight === videoId) { return; } /* TODO: test if the video event exists? */ this.currentSpotlight = videoId; this.notifySpotlight() 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 => { /* in some instances the server might return our own stream for debug purposes */ if(client instanceof LocalClientEntry && __build.mode !== "debug") { return; } if(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() { this.currentSpotlight = undefined; for(const clientId of Object.keys(this.clientVideos)) { this.destroyClientVideo(parseInt(clientId)); } this.notifyVideoList(); this.notifySpotlight(); } private destroyClientVideo(clientId: number) : boolean { if(this.clientVideos[clientId]) { const video = this.clientVideos[clientId]; video.callbackBroadcastStateChanged = undefined; video.destroy(); delete this.clientVideos[clientId]; if(video.videoId === this.currentSpotlight) { this.currentSpotlight = undefined; this.notifySpotlight(); } 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 notifySpotlight() { this.events.fire_react("notify_spotlight", { videoId: this.currentSpotlight }); } 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); if(this.expended) { videoIds.remove(this.currentSpotlight); } 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; private container: HTMLDivElement; private controller: ChannelVideoController; constructor(handle: ConnectionHandler) { this.handle = handle; this.events = new Registry(); 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; } }