TeaWeb/shared/js/ui/frames/video/Controller.ts

778 lines
No EOL
29 KiB
TypeScript

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,
ChannelVideoStreamState,
kLocalVideoId,
VideoStreamState
} from "tc-shared/ui/frames/video/Definitions";
import {
LocalVideoBroadcastState,
VideoBroadcastState,
VideoBroadcastType,
VideoClient,
VideoConnection
} from "tc-shared/connection/VideoConnection";
import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client";
import {LogCategory, logError, logWarn} from "tc-shared/log";
import {tr, tra} from "tc-shared/i18n/localize";
import {Settings, settings} from "tc-shared/settings";
import * as _ from "lodash";
import PermissionType from "tc-shared/permission/PermissionType";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
const cssStyle = require("./Renderer.scss");
let videoIdIndex = 0;
interface ClientVideoController {
destroy();
isSubscribed(type: VideoBroadcastType);
subscribeVideo(type: VideoBroadcastType);
muteVideo(type: VideoBroadcastType);
dismissVideo(type: VideoBroadcastType);
showPip(type: VideoBroadcastType) : Promise<void>;
notifyVideoInfo();
notifyVideo();
notifyVideoStream(type: VideoBroadcastType);
}
type VideoStreamStates = {[T in VideoBroadcastType]: ChannelVideoStreamState};
type SubscriptionStates = {[T in VideoBroadcastType]: boolean};
class RemoteClientVideoController implements ClientVideoController {
readonly videoId: string;
readonly client: ClientEntry;
callbackBroadcastStateChanged: (broadcasting: boolean) => void;
callbackSubscriptionStateChanged: () => void;
protected readonly events: Registry<ChannelVideoEvents>;
protected eventListener: (() => void)[];
protected eventListenerVideoClient: (() => void)[];
private currentBroadcastState: boolean;
private currentSubscriptionState: SubscriptionStates = {
screen: false,
camera: false
};
private currentStreamStates: VideoStreamStates = {
screen: "none",
camera: "none"
};
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(true);
}));
this.updateVideoClient(false);
}
private updateVideoClient(notifyChanged: boolean) {
this.eventListenerVideoClient?.forEach(callback => callback());
this.eventListenerVideoClient = [];
const videoClient = this.client.getVideoClient();
if(videoClient) {
this.initializeVideoClient(videoClient);
}
this.updateVideoState(notifyChanged);
}
protected initializeVideoClient(videoClient: VideoClient) {
this.eventListenerVideoClient.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => {
this.updateVideoState(true);
this.notifyVideoStream(event.broadcastType);
}));
this.eventListenerVideoClient.push(videoClient.getEvents().on("notify_dismissed_state_changed", () => {
this.updateVideoState(true);
}));
this.eventListenerVideoClient.push(videoClient.getEvents().on("notify_broadcast_stream_changed", event => {
this.notifyVideoStream(event.broadcastType);
}));
}
destroy() {
this.eventListenerVideoClient?.forEach(callback => callback());
this.eventListenerVideoClient = undefined;
this.eventListener?.forEach(callback => callback());
this.eventListener = undefined;
}
isBroadcasting() {
return this.currentBroadcastState;
}
isSubscribed(type: VideoBroadcastType) {
const videoClient = this.client.getVideoClient();
const videoState = videoClient?.getVideoState(type);
return typeof videoState !== "undefined" && videoState !== VideoBroadcastState.Stopped && videoState !== VideoBroadcastState.Available;
}
subscribeVideo(type: VideoBroadcastType) {
const videoClient = this.client.getVideoClient();
if(!videoClient) {
return;
}
if(videoClient.getVideoState(type) === VideoBroadcastState.Stopped) {
/* There is no video we could join */
return;
}
videoClient.joinBroadcast(type).catch(error => {
logError(LogCategory.VIDEO, tr("Failed to join video broadcast: %o"), error);
/* TODO: Propagate error? */
});
}
muteVideo(type: VideoBroadcastType) {
const videoClient = this.client.getVideoClient();
if(!videoClient) {
return;
}
videoClient.leaveBroadcast(type);
videoClient.dismissBroadcast(type);
}
dismissVideo(type: VideoBroadcastType) {
this.client.getVideoClient()?.dismissBroadcast(type);
}
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()
}
});
}
protected updateVideoState(notifyChanged: boolean) {
let subscriptionState: SubscriptionStates = {} as any;
let streamStates: VideoStreamStates = {} as any;
let broadcasting = false;
for(const videoChannel of kLocalBroadcastChannels) {
const state = this.getBroadcastState(videoChannel);
if(state === VideoBroadcastState.Available) {
streamStates[videoChannel] = this.client.getVideoClient()?.isBroadcastDismissed(videoChannel) ? "ignored" : "available";
broadcasting = true;
} else if(state === VideoBroadcastState.Running || state === VideoBroadcastState.Initializing || state === VideoBroadcastState.Buffering) {
streamStates[videoChannel] = "streaming";
subscriptionState[videoChannel] = true;
broadcasting = true;
} else {
streamStates[videoChannel] = "none";
}
}
if(!_.isEqual(this.currentStreamStates, streamStates)) {
this.currentStreamStates = streamStates;
this.notifyVideo();
}
if(broadcasting !== this.currentBroadcastState) {
this.currentBroadcastState = broadcasting;
if(this.callbackBroadcastStateChanged && notifyChanged) {
this.callbackBroadcastStateChanged(broadcasting);
}
}
if(!_.isEqual(this.currentSubscriptionState, subscriptionState)) {
this.currentSubscriptionState = subscriptionState;
if(this.callbackSubscriptionStateChanged && notifyChanged) {
this.callbackSubscriptionStateChanged();
}
}
}
notifyVideo() {
this.events.fire_react("notify_video", {
videoId: this.videoId,
cameraStream: this.currentStreamStates["camera"],
screenStream: this.currentStreamStates["screen"]
});
}
notifyVideoStream(type: VideoBroadcastType) {
let state: VideoStreamState;
const streamState = this.getBroadcastState(type);
if(streamState === VideoBroadcastState.Stopped) {
state = { state: "disconnected" };
} else if(streamState === VideoBroadcastState.Initializing) {
state = { state: "connecting" };
} else if(streamState === VideoBroadcastState.Available) {
state = { state: "available" };
} else if(streamState === VideoBroadcastState.Buffering || streamState === VideoBroadcastState.Running) {
const stream = this.getBroadcastStream(type);
if(!stream) {
state = { state: "failed", reason: tr("Missing video stream") };
} else {
state = { state: "connected", stream: stream };
}
}
this.events.fire_react("notify_video_stream", {
videoId: this.videoId,
broadcastType: type,
state: state
});
}
protected hasVideoSupport() : 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;
}
async showPip(type: VideoBroadcastType) {
const client = this.client.getVideoClient();
if(!client) {
return;
}
await client.showPip(type);
}
}
const kLocalBroadcastChannels: VideoBroadcastType[] = ["screen", "camera"];
class LocalVideoController extends RemoteClientVideoController {
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>) {
super(client, eventRegistry, kLocalVideoId);
const videoConnection = client.channelTree.client.serverConnection.getVideoConnection();
for(const broadcastType of kLocalBroadcastChannels) {
const broadcast = videoConnection.getLocalBroadcast(broadcastType);
this.eventListener.push(broadcast.getEvents().on("notify_state_changed", () => {
this.updateVideoState(true);
}));
}
/* TODO: Auto join local broadcast if one is active */
}
protected initializeVideoClient(videoClient: VideoClient) {
super.initializeVideoClient(videoClient);
this.eventListenerVideoClient.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => {
if(event.newState === VideoBroadcastState.Available) {
/* we want to watch our own broadcast */
videoClient.joinBroadcast(event.broadcastType).then(undefined);
}
}))
}
isBroadcasting() {
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
const isBroadcasting = (state: LocalVideoBroadcastState) => state.state === "initializing" || state.state === "broadcasting";
for(const broadcastType of kLocalBroadcastChannels) {
const broadcast = videoConnection.getLocalBroadcast(broadcastType);
if(isBroadcasting(broadcast.getState())) {
return true;
}
}
/* the super should return false as well but just in case something went wrong we want to give the user the visual feedback */
return super.isBroadcasting();
}
protected hasVideoSupport(): boolean {
return true;
}
protected getBroadcastState(target: VideoBroadcastType): VideoBroadcastState {
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
const broadcast = videoConnection.getLocalBroadcast(target);
const receivingState = super.getBroadcastState(target);
switch (broadcast.getState().state) {
case "stopped":
case "failed":
if(receivingState !== VideoBroadcastState.Stopped) {
/* this should never happen but just in case give the client a visual feedback */
return receivingState;
}
return VideoBroadcastState.Stopped;
case "initializing":
return VideoBroadcastState.Initializing;
case "broadcasting":
const state = super.getBroadcastState(target);
if(state === VideoBroadcastState.Stopped) {
/* we should receive a stream in a few seconds */
return VideoBroadcastState.Initializing;
} else {
return state;
}
}
}
}
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} = {};
private currentSpotlights: string[];
constructor(events: Registry<ChannelVideoEvents>, 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.currentSpotlights = [];
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_toggle_spotlight", event => {
this.toggleSpotlight(event.videoIds, event.enabled);
if(event.expend && !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;
}
if(event.muted) {
if(event.broadcastType === undefined) {
controller.muteVideo("camera");
controller.muteVideo("screen");
} else {
controller.muteVideo(event.broadcastType);
}
} else {
if(event.broadcastType === undefined) {
controller.subscribeVideo("camera");
controller.subscribeVideo("screen");
} else {
controller.subscribeVideo(event.broadcastType);
}
}
});
this.events.on("action_dismiss", event => {
const controller = this.findVideoById(event.videoId);
if(!controller) {
logWarn(LogCategory.VIDEO, tr("Tried to dismiss video for a non existing video id (%s)."), event.videoId);
return;
}
controller.dismissVideo(event.broadcastType);
});
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_subscribe_info", () => this.notifySubscribeInfo());
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_stream", event => {
const controller = this.findVideoById(event.videoId);
if(!controller) {
logWarn(LogCategory.VIDEO, tr("Tried to query video stream for a non existing video id (%s)."), event.videoId);
return;
}
controller.notifyVideoStream(event.broadcastType);
});
this.events.on("action_set_pip", async event => {
const controller = this.findVideoById(event.videoId);
if (!controller) {
logWarn(LogCategory.VIDEO, tr("Tried to enable video pip for a non existing video id (%s)."), event.videoId);
return;
}
try {
await controller.showPip(event.broadcastType);
} catch (error) {
createErrorModal(tr("Failed to start PIP"), tra("Failed to popout video:\n{}", error)).open();
}
});
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();
}
}));
/* TODO: Unify update if all three changed? */
events.push(this.connection.permissions.register_needed_permission(PermissionType.I_VIDEO_MAX_STREAMS, () => this.notifySubscribeInfo()));
events.push(this.connection.permissions.register_needed_permission(PermissionType.I_VIDEO_MAX_CAMERA_STREAMS, () => this.notifySubscribeInfo()));
events.push(this.connection.permissions.register_needed_permission(PermissionType.I_VIDEO_MAX_SCREEN_STREAMS, () => this.notifySubscribeInfo()));
events.push(settings.globalChangeListener(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS, () => this.notifyVideoList()));
events.push(settings.globalChangeListener(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO, () => this.notifyVideoList()));
}
toggleSpotlight(videoId: string[], enabled: boolean) {
const updated = videoId.map(entry => this.currentSpotlights.toggle(entry, enabled)).find(updated => updated);
if(!updated) {
return;
}
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 => {
if(client instanceof LocalClientEntry) {
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.currentSpotlights = [];
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.callbackSubscriptionStateChanged = undefined;
video.destroy();
delete this.clientVideos[clientId];
this.toggleSpotlight([ video.videoId ], false);
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();
controller.callbackSubscriptionStateChanged = () => this.notifySubscribeInfo();
this.clientVideos[client.clientId()] = controller;
}
private notifySpotlight() {
this.events.fire_react("notify_spotlight", { videoId: this.currentSpotlights });
}
private notifyVideoList() {
const videoIds = [];
let videoStreamingCount = 0;
if(this.localVideoController) {
const localBroadcasting = this.localVideoController.isBroadcasting();
if(localBroadcasting || settings.getValue(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO)) {
videoIds.push(this.localVideoController.videoId);
if(localBroadcasting) {
videoStreamingCount++;
}
}
}
const channel = this.connection.channelTree.findChannel(this.currentChannelId);
if(channel) {
const clients = channel.channelClientsOrdered();
for(const client of clients) {
if(client instanceof LocalClientEntry) {
continue;
}
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()) {
videoStreamingCount++;
} else if(!settings.getValue(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS)) {
continue;
}
videoIds.push(controller.videoId);
}
}
this.updateVisibility(videoStreamingCount !== 0);
if(this.expended) {
this.currentSpotlights.forEach(entry => videoIds.remove(entry));
}
this.events.fire_react("notify_videos", {
videoIds: videoIds
});
}
private notifySubscribeInfo() {
const permissionMaxStreams = this.connection.permissions.neededPermission(PermissionType.I_VIDEO_MAX_STREAMS);
const permissionMaxScreenStreams = this.connection.permissions.neededPermission(PermissionType.I_VIDEO_MAX_SCREEN_STREAMS);
const permissionMaxCameraStreams = this.connection.permissions.neededPermission(PermissionType.I_VIDEO_MAX_CAMERA_STREAMS);
let subscriptionsCamera = 0, subscriptionsScreen = 0;
for(const client of Object.values(this.clientVideos)) {
if(client.isSubscribed("screen")) {
subscriptionsScreen++;
}
if(client.isSubscribed("camera")) {
subscriptionsCamera++;
}
}
this.events.fire_react("notify_subscribe_info", {
info: {
totalSubscriptions: subscriptionsCamera + subscriptionsScreen,
maxSubscriptions: permissionMaxStreams.valueNormalOr(undefined),
subscribeLimits: {
screen: permissionMaxScreenStreams.valueNormalOr(undefined),
camera: permissionMaxCameraStreams.valueNormalOr(undefined)
},
subscribedStreams: {
camera: subscriptionsCamera,
screen: subscriptionsScreen,
}
}
});
}
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;
}
}