Adding support for viewing who's watching your video feed

master
WolverinDEV 2021-04-27 15:52:49 +02:00
parent 871231cbd5
commit 357a200e3b
13 changed files with 729 additions and 59 deletions

6
package-lock.json generated
View File

@ -19993,9 +19993,9 @@
}
},
"typescript": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
"integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz",
"integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==",
"dev": true
},
"uglify-js": {

View File

@ -78,7 +78,7 @@
"terser-webpack-plugin": "4.2.3",
"ts-loader": "^6.2.2",
"tsd": "^0.13.1",
"typescript": "^3.7.0",
"typescript": "^4.2",
"url-loader": "^4.1.1",
"wabt": "^1.0.13",
"webpack": "^5.26.1",

View File

@ -213,7 +213,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
private subscribed: boolean;
private subscriptionMode: ChannelSubscribeMode;
private client_list: ClientEntry[] = []; /* this list is sorted correctly! */
private clientList: ClientEntry[] = []; /* this list is sorted correctly! */
private readonly clientPropertyChangedListener;
constructor(channelTree: ChannelTree, channelId: number, channelName: string) {
@ -255,8 +255,8 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
this.channelDescriptionCallback.forEach(callback => callback(false));
this.channelDescriptionCallback = [];
this.client_list.forEach(e => this.unregisterClient(e, true));
this.client_list = [];
this.clientList.forEach(e => this.unregisterClient(e, true));
this.clientList = [];
this.channel_previous = undefined;
this.parent = undefined;
@ -363,38 +363,44 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
registerClient(client: ClientEntry) {
client.events.on("notify_properties_updated", this.clientPropertyChangedListener);
this.client_list.push(client);
this.clientList.push(client);
this.reorderClientList(false);
}
unregisterClient(client: ClientEntry, noEvent?: boolean) {
client.events.off("notify_properties_updated", this.clientPropertyChangedListener);
if(!this.client_list.remove(client)) {
if(!this.clientList.remove(client)) {
logWarn(LogCategory.CHANNEL, tr("Unregistered unknown client from channel %s"), this.channelName());
}
}
private reorderClientList(fire_event: boolean) {
const original_list = this.client_list.slice(0);
const originalList = this.clientList.slice(0);
this.client_list.sort((a, b) => {
if(a.properties.client_talk_power < b.properties.client_talk_power)
this.clientList.sort((a, b) => {
if(a.properties.client_talk_power < b.properties.client_talk_power) {
return 1;
if(a.properties.client_talk_power > b.properties.client_talk_power)
return -1;
}
if(a.properties.client_nickname > b.properties.client_nickname)
return 1;
if(a.properties.client_nickname < b.properties.client_nickname)
if(a.properties.client_talk_power > b.properties.client_talk_power) {
return -1;
}
if(a.properties.client_nickname > b.properties.client_nickname) {
return 1;
}
if(a.properties.client_nickname < b.properties.client_nickname) {
return -1;
}
return 0;
});
if(fire_event) {
/* only fire if really something has changed ;) */
for(let index = 0; index < this.client_list.length; index++) {
if(this.client_list[index] !== original_list[index]) {
for(let index = 0; index < this.clientList.length; index++) {
if(this.clientList[index] !== originalList[index]) {
this.channelTree.events.fire("notify_channel_client_order_changed", { channel: this });
break;
}
@ -420,7 +426,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
}
clients(deep = false) : ClientEntry[] {
const result: ClientEntry[] = this.client_list.slice(0);
const result: ClientEntry[] = this.clientList.slice(0);
if(!deep) return result;
return this.children(true).map(e => e.clients(false)).reduce((prev, cur) => {
@ -430,7 +436,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
}
channelClientsOrdered() : ClientEntry[] {
return this.client_list;
return this.clientList;
}
calculate_family_index(enforce_recalculate: boolean = false) : number {
@ -490,7 +496,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
let trigger_close = true;
const collapse_expendable = !!this.child_channel_head || this.client_list.length > 0;
const collapse_expendable = !!this.child_channel_head || this.clientList.length > 0;
const bold = text => contextmenu.get_provider().html_format_enabled() ? "<b>" + text + "</b>" : text;
contextmenu.spawn_context_menu(x, y, {
type: contextmenu.MenuEntryType.ENTRY,

View File

@ -23,6 +23,7 @@ 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";
import {spawnVideoViewerInfo} from "tc-shared/ui/modal/video-viewers/Controller";
const cssStyle = require("./Renderer.scss");
@ -443,6 +444,8 @@ class ChannelVideoController {
controller.dismissVideo(event.broadcastType);
});
this.events.on("action_show_viewers", () => spawnVideoViewerInfo(this.connection));
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());

View File

@ -79,6 +79,7 @@ export interface ChannelVideoEvents {
action_set_pip: { videoId: string | undefined, broadcastType: VideoBroadcastType },
action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType | undefined, muted: boolean },
action_dismiss: { videoId: string, broadcastType: VideoBroadcastType },
action_show_viewers: {},
query_expended: {},
query_videos: {},

View File

@ -92,9 +92,8 @@ const VideoViewerCount = React.memo(() => {
return (
<div
className={cssStyle.videoViewerCount}
onClick={() => {
/* TODO! */
}}
onClick={() => events.fire("action_show_viewers")}
onDoubleClick={event => event.preventDefault()}
>
{info}
</div>

View File

@ -0,0 +1,268 @@
import {Registry} from "tc-events";
import {
ModalVideoViewersEvents,
ModalVideoViewersVariables,
VideoViewerInfo, VideoViewerList
} from "tc-shared/ui/modal/video-viewers/Definitions";
import {IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {ClientEntry} from "tc-shared/tree/Client";
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
import {CallOnce, ignorePromise} from "tc-shared/proto";
import {VideoBroadcastType, VideoBroadcastViewer} from "tc-shared/connection/VideoConnection";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
class Controller {
readonly handler: ConnectionHandler;
readonly events: Registry<ModalVideoViewersEvents>;
readonly variables: IpcUiVariableProvider<ModalVideoViewersVariables>;
private registeredEvents: (() => void)[];
private registeredClientEvents: { [key: number]: (() => void)[] } = {};
/* Active video viewers */
private videoViewerInfo: { [key: number]: VideoViewerInfo & { talkPower: number } };
private videoViewer: VideoViewerList;
constructor(handler: ConnectionHandler) {
this.handler = handler;
this.events = new Registry<ModalVideoViewersEvents>();
this.variables = new IpcUiVariableProvider<ModalVideoViewersVariables>();
this.videoViewerInfo = {};
this.videoViewer = { __internal_client_order: [] };
}
@CallOnce
destroy() {
this.videoViewerInfo = {};
this.videoViewer = { __internal_client_order: [] };
this.registeredEvents?.forEach(callback => callback());
this.registeredEvents = undefined;
Object.keys(this.registeredClientEvents).forEach(clientId => this.unregisterClientEvents(parseInt(clientId)));
this.events.destroy();
this.variables.destroy();
}
@CallOnce
initialize() {
this.variables.setVariableProvider("viewerInfo", clientId => this.videoViewerInfo[clientId]);
this.variables.setVariableProvider("videoViewers", () => this.videoViewer);
this.registeredEvents = [];
this.registeredEvents.push(this.handler.channelTree.events.on("notify_client_leave_view", event => {
this.unregisterClientEvents(event.client.clientId());
}));
this.registeredEvents.push(this.handler.channelTree.events.on("notify_client_enter_view", event => {
if(this.videoViewerInfo[event.client.clientId()]) {
this.registerClientEvents(event.client);
this.updateViewerInfo(event.client);
}
}));
this.registerBroadcastListener("camera");
this.registerBroadcastListener("screen");
this.updateViewerList();
}
private registerBroadcastListener(broadcastType: VideoBroadcastType) {
const videoConnection = this.handler.getServerConnection().getVideoConnection();
const broadcast = videoConnection.getLocalBroadcast(broadcastType);
this.registeredEvents.push(broadcast.getEvents().on("notify_clients_joined", event => {
const viewers = this.videoViewer[broadcastType] = this.videoViewer[broadcastType] || [];
for(const viewer of event.clients) {
viewers.push(viewer.clientId);
this.registerNewViewer(viewer);
}
this.updateViewerList();
}));
this.registeredEvents.push(broadcast.getEvents().on("notify_clients_left", event => {
/* We can't remove the client listeners here since the client might also be in other broadcasts */
for(const clientId of event.clientIds) {
this.videoViewer[broadcastType]?.remove(clientId);
}
this.updateViewerList();
}));
this.registeredEvents.push(broadcast.getEvents().on("notify_state_changed", event => {
switch (event.newState.state) {
case "broadcasting":
case "initializing":
this.videoViewer[broadcastType] = this.videoViewer[broadcastType] || [];
break;
case "failed":
case "stopped":
default:
delete this.videoViewer[broadcastType];
break;
}
this.updateViewerList();
}));
switch (broadcast.getState().state) {
case "initializing":
case "broadcasting":
const viewers = this.videoViewer[broadcastType] = [];
for(const viewer of broadcast.getViewer()) {
this.registerNewViewer(viewer);
viewers.push(viewer.clientId);
}
break;
case "failed":
case "stopped":
default:
delete this.videoViewer[broadcastType];
break;
}
}
private registerNewViewer(viewer: VideoBroadcastViewer) {
if(typeof this.registeredClientEvents[viewer.clientId] !== "undefined") {
/* We've up2date data for that viewer */
return;
}
/* Store newest viewer data */
this.videoViewerInfo[viewer.clientId] = {
handlerId: this.handler.handlerId,
clientName: viewer.clientName,
clientUniqueId: viewer.clientUniqueId,
clientStatus: undefined,
talkPower: 0
};
const client = this.handler.channelTree.findClient(viewer.clientId);
if(!client) {
/* Target client isn't in our view */
return;
}
this.registerClientEvents(client);
this.updateViewerInfo(client);
}
private unregisterClientEvents(clientId: number) {
this.registeredClientEvents[clientId]?.forEach(callback => callback());
delete this.registeredClientEvents[clientId];
}
private registerClientEvents(client: ClientEntry) {
const clientId = client.clientId();
const events = this.registeredClientEvents[clientId] = [];
events.push(client.events.on("notify_status_icon_changed", () => this.updateViewerInfo(client)));
events.push(client.events.on("notify_properties_updated", event => {
if("client_talk_power" in event.updated_properties || "client_nickname" in event.updated_properties) {
/* Fill update incl. resort since the client order might has changed */
this.updateViewerInfo(client);
this.updateViewerList();
}
}));
}
/**
* Update the viewer cache for the target client.
* @param client
* @private
*/
private updateViewerInfo(client: ClientEntry) {
this.videoViewerInfo[client.clientId()] = {
handlerId: this.handler.handlerId,
clientName: client.clientNickName(),
clientStatus: client.getStatusIcon(),
clientUniqueId: client.properties.client_unique_identifier,
talkPower: client.properties.client_talk_power
};
/* The variables handler will only send an update if the variable really has changed. */
this.variables.sendVariable("viewerInfo", client.clientId());
}
private updateViewerList() {
const uniqueTotalViewers = new Set<number>();
for(const key of Object.keys(this.videoViewer)) {
if(key === "__internal_client_order") {
continue;
}
for(const clientId of this.videoViewer[key]) {
uniqueTotalViewers.add(clientId);
}
}
Object.keys(this.registeredClientEvents).forEach(clientIdString => {
const clientId = parseInt(clientIdString);
if(uniqueTotalViewers.has(clientId)) {
return;
}
/* We're not any more subscribed */
this.unregisterClientEvents(clientId);
});
Object.keys(this.videoViewerInfo).forEach(clientIdString => {
const clientId = parseInt(clientIdString);
if(uniqueTotalViewers.has(clientId)) {
return;
}
delete this.videoViewerInfo[clientIdString];
});
this.videoViewer["__internal_client_order"] = [...uniqueTotalViewers];
this.videoViewer["__internal_client_order"].sort((clientAId, clientBId) => {
const clientA = this.videoViewerInfo[clientAId];
const clientB = this.videoViewerInfo[clientBId];
if(!clientA) {
return -1;
} else if(!clientB) {
return 1;
}
if(clientA.talkPower < clientB.talkPower) {
return 1;
}
if(clientA.talkPower > clientB.talkPower) {
return -1;
}
if(clientA.clientName > clientB.clientName) {
return 1;
}
if(clientA.clientName < clientB.clientName) {
return -1;
}
return 0;
});
this.variables.sendVariable("videoViewers");
}
}
export function spawnVideoViewerInfo(handler: ConnectionHandler) {
const controller = new Controller(handler);
controller.initialize();
const modal = spawnModal("modal-video-viewers", [
controller.events.generateIpcDescription(),
controller.variables.generateConsumerDescription()
], {
popoutable: true
});
modal.getEvents().on("destroy", () => controller.destroy());
ignorePromise(modal.show());
}

View File

@ -0,0 +1,25 @@
import {ClientIcon} from "svg-sprites/client-icons";
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
export type VideoViewerInfo = {
handlerId: string,
clientName: string,
clientUniqueId: string,
/* If undefined we don't know the client status */
clientStatus: ClientIcon | undefined,
}
export type VideoViewerList = {
[T in VideoBroadcastType ]?: number[]
} & {
__internal_client_order: number[]
};
export interface ModalVideoViewersVariables {
viewerInfo: VideoViewerInfo | undefined,
videoViewers: VideoViewerList,
}
export interface ModalVideoViewersEvents {
}

View File

@ -0,0 +1,165 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
display: flex;
flex-direction: column;
justify-content: stretch;
user-select: none;
padding: 1em;
width: 25em;
height: 40em;
min-width: 10em;
min-height: 10em;
&.windowed {
height: 100%;
width: 100%;
}
}
.viewerSummary {
margin-bottom: .25em;
display: flex;
flex-direction: row;
justify-content: stretch;
.left {
flex-grow: 1;
flex-shrink: 1;
min-width: 2em;
color: #557edc;
text-transform: uppercase;
@include text-dotdotdot();
}
.right {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
.entry {
display: flex;
flex-direction: row;
justify-content: flex-end;
> * {
align-self: center;
}
.icon {
margin-left: .25em;
}
&:not(:last-of-type) {
margin-bottom: .25em;
}
}
}
}
.viewerList {
flex-grow: 1;
flex-shrink: 1;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
min-height: 6em;
background-color: #28292b;
border: 1px #161616 solid;
border-radius: 0.2em;
overflow-y: auto;
overflow-x: hidden;
padding: .25em .5em;
@include chat-scrollbar();
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
.text {
text-align: center;
font-size: 1.6em;
color: var(--modal-permission-loading);
}
}
}
.viewerEntry {
display: flex;
flex-direction: row;
justify-content: stretch;
> * {
align-self: center;
}
.statusIcon {
flex-shrink: 0;
flex-grow: 0;
margin-right: .25em;
}
.nameContainer {
flex-shrink: 1;
flex-grow: 1;
min-width: 2em;
display: flex;
flex-direction: row;
justify-content: flex-start;
.name {
align-self: center;
color: #999;
@include text-dotdotdot();
}
}
.videoStatus {
flex-shrink: 0;
flex-grow: 0;
display: flex;
flex-direction: row;
justify-content: flex-end;
min-width: 2.5em;
.subscribeIcon {
&:not(:last-of-type) {
margin-right: .25em;
}
}
}
}

View File

@ -0,0 +1,200 @@
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
import React, {useContext} from "react";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {IpcRegistryDescription, Registry} from "tc-events";
import {ModalVideoViewersEvents, ModalVideoViewersVariables} from "tc-shared/ui/modal/video-viewers/Definitions";
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
import {joinClassList} from "tc-shared/ui/react-elements/Helper";
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
import {ClientIcon} from "svg-sprites/client-icons";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
const cssStyle = require("./Renderer.scss");
const EventContext = React.createContext<Registry<ModalVideoViewersEvents>>(undefined);
const VariablesContext = React.createContext<UiVariableConsumer<ModalVideoViewersVariables>>(undefined);
const ViewerRenderer = React.memo((props: { clientId: number, screen: boolean, camera: boolean }) => {
const variables = useContext(VariablesContext);
const clientInfo = variables.useReadOnly("viewerInfo", props.clientId, undefined);
let clientName, clientIcon;
if(clientInfo) {
clientName = (
<ClientTag
key={"name-loaded"}
clientName={clientInfo.clientName}
clientUniqueId={clientInfo.clientUniqueId}
handlerId={clientInfo.handlerId}
className={cssStyle.name}
/>
);
clientIcon = clientInfo.clientStatus || ClientIcon.PlayerOff;
} else {
clientName = (
<React.Fragment key={"name-loading"}>
<Translatable>loading</Translatable> <LoadingDots />
</React.Fragment>
)
clientIcon = ClientIcon.PlayerOff;
}
let videoStatus = [];
if(props.camera) {
videoStatus.push(
<ClientIconRenderer
icon={ClientIcon.VideoMuted}
className={cssStyle.subscribeIcon}
title={tr("Client is viewing your camera stream")}
key={"camera"}
/>
);
}
if(props.screen) {
videoStatus.push(
<ClientIconRenderer
icon={ClientIcon.ShareScreen}
className={cssStyle.subscribeIcon}
title={tr("Client is viewing your screen stream")}
key={"screen"}
/>
);
}
return (
<div className={cssStyle.viewerEntry}>
<ClientIconRenderer icon={clientIcon} className={cssStyle.statusIcon} />
<div className={cssStyle.nameContainer}>
{clientName}
</div>
<div className={cssStyle.videoStatus}>
{videoStatus}
</div>
</div>
)
});
const ViewerList = React.memo(() => {
const variables = useContext(VariablesContext);
const viewers = variables.useReadOnly("videoViewers", undefined, { __internal_client_order: [ ] });
let body;
if(typeof viewers.screen === "undefined" && typeof viewers.camera === "undefined") {
body = (
<div className={cssStyle.overlay} key={"not-sharing"}>
<div className={cssStyle.text}>
<Translatable>You're not sharing any video</Translatable>
</div>
</div>
);
} else if(viewers.__internal_client_order.length) {
body = viewers.__internal_client_order.map(clientId => (
<ViewerRenderer
screen={viewers.screen?.indexOf(clientId) >= 0}
camera={viewers.camera?.indexOf(clientId) >= 0}
clientId={clientId}
key={"viewer-" + clientId}
/>
));
} else {
body = (
<div className={cssStyle.overlay} key={"nobody-watching"}>
<div className={cssStyle.text}>
<Translatable>Nobody is watching your video feed :(</Translatable>
</div>
</div>
);
}
return (
<div className={cssStyle.viewerList}>
{body}
</div>
)
});
const ViewerCount = React.memo((props: { viewer: number[] | undefined }) => {
if(!Array.isArray(props.viewer)) {
return (
<Translatable key={"not-enabled"}>Not Enabled</Translatable>
);
}
if(props.viewer.length === 1) {
return (
<Translatable key={"one"}>1 Viewer</Translatable>
);
}
return (
<VariadicTranslatable text={"{} Viewers"} key={"multi"}>
{props.viewer.length}
</VariadicTranslatable>
);
});
const ViewerSummary = React.memo(() => {
const variables = useContext(VariablesContext);
const viewers = variables.useReadOnly("videoViewers", undefined, { __internal_client_order: [ ] });
return (
<div className={cssStyle.viewerSummary}>
<div className={cssStyle.left}>
<Translatable>Video viewers</Translatable>
</div>
<div className={cssStyle.right}>
<div className={cssStyle.entry}>
<ViewerCount viewer={viewers?.camera} /> <ClientIconRenderer icon={ClientIcon.VideoMuted} className={cssStyle.icon} />
</div>
<div className={cssStyle.entry}>
<ViewerCount viewer={viewers?.screen} /> <ClientIconRenderer icon={ClientIcon.ShareScreen} className={cssStyle.icon} />
</div>
</div>
</div>
);
})
class Modal extends AbstractModal {
private readonly events: Registry<ModalVideoViewersEvents>;
private readonly variables: UiVariableConsumer<ModalVideoViewersVariables>;
constructor(events: IpcRegistryDescription<ModalVideoViewersEvents>, variables: IpcVariableDescriptor<ModalVideoViewersVariables>) {
super();
this.events = Registry.fromIpcDescription(events);
this.variables = createIpcUiVariableConsumer(variables);
}
protected onDestroy() {
super.onDestroy();
this.events.destroy();
this.variables.destroy();
}
renderBody(): React.ReactElement {
return (
<VariablesContext.Provider value={this.variables}>
<EventContext.Provider value={this.events}>
<div className={joinClassList(cssStyle.container, this.properties.windowed && cssStyle.windowed)}>
<ViewerSummary />
<ViewerList />
</div>
</EventContext.Provider>
</VariablesContext.Provider>
);
}
renderTitle(): string | React.ReactElement {
return (
<Translatable>Video Viewers</Translatable>
);
}
}
export default Modal;

View File

@ -26,6 +26,7 @@ import {ModalAboutVariables} from "tc-shared/ui/modal/about/Definitions";
import {ModalServerBandwidthEvents} from "tc-shared/ui/modal/server-bandwidth/Definitions";
import {ModalYesNoEvents, ModalYesNoVariables} from "tc-shared/ui/modal/yes-no/Definitions";
import {ModalChannelInfoEvents, ModalChannelInfoVariables} from "tc-shared/ui/modal/channel-info/Definitions";
import {ModalVideoViewersEvents, ModalVideoViewersVariables} from "tc-shared/ui/modal/video-viewers/Definitions";
export type ModalType = "error" | "warning" | "info" | "none";
export type ModalRenderType = "page" | "dialog";
@ -239,5 +240,9 @@ export interface ModalConstructorArguments {
],
"modal-server-bandwidth": [
/* events */ IpcRegistryDescription<ModalServerBandwidthEvents>
],
"modal-video-viewers": [
/* events */ IpcRegistryDescription<ModalVideoViewersEvents>,
/* variables */ IpcVariableDescriptor<ModalVideoViewersVariables>
]
}

View File

@ -144,3 +144,9 @@ registerModal({
classLoader: async () => await import("tc-shared/ui/modal/server-bandwidth/Renderer"),
popoutSupported: true
});
registerModal({
modalId: "modal-video-viewers",
classLoader: async () => await import("tc-shared/ui/modal/video-viewers/Renderer"),
popoutSupported: true
});

View File

@ -97,12 +97,7 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
}
async getVariable<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Promise<Variables[T]> {
const providers = this.variableProvider[variable as any];
if(!providers) {
throw tra("missing provider for {}", variable as string);
}
const result = providers(customData);
const result = this.resolveVariable(variable as any, customData);
if(result instanceof Promise) {
return await result;
} else {
@ -111,12 +106,7 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
}
getVariableSync<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Variables[T] {
const providers = this.variableProvider[variable as any];
if(!providers) {
throw tr("missing provider");
}
const result = providers(customData);
const result = this.resolveVariable(variable as any, customData);
if(result instanceof Promise) {
throw tr("tried to get an async variable synchronous");
}
@ -139,36 +129,38 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
throw tr("variable is read only");
}
const handleEditResult = result => {
if(typeof result === "undefined") {
/* change succeeded, no need to notify any variable since the consumer already has the newest value */
} else if(result === true || result === false) {
/* The new variable has been accepted/rejected and the variable should be updated on the remote side. */
/* TODO: Use cached value if the result is `false` */
this.sendVariable(variable, customData, true);
} else {
/* The new value hasn't been accepted. Instead a new value has been returned. */
this.doSendVariable(variable, customData, result);
}
}
const handleEditError = error => {
console.error("Failed to change variable %s: %o", variable, error);
this.sendVariable(variable, customData, true);
}
try {
let result = editor(newValue, customData);
if(result instanceof Promise) {
return result.then(handleEditResult).catch(handleEditError);
/* Variable editor returns a promise. Await it and return a promise as well. */
return result.then(result => this.handleEditResult(variable, customData, result)).catch(error => this.handleEditError(variable, customData, error));
} else {
handleEditResult(result);
/* We were able to instantly edit the variable. Handle result. */
this.handleEditResult(variable, customData, result);
}
} catch (error) {
handleEditError(error);
this.handleEditError(variable, customData, error);
}
}
private handleEditResult(variable: string, customData: any, result: any) {
if(typeof result === "undefined") {
/* change succeeded, no need to notify any variable since the consumer already has the newest value */
} else if(result === true || result === false) {
/* The new variable has been accepted/rejected and the variable should be updated on the remote side. */
/* TODO: Use cached value if the result is `false` */
this.sendVariable(variable, customData, true);
} else {
/* The new value hasn't been accepted. Instead a new value has been returned. */
this.doSendVariable(variable, customData, result);
}
}
private handleEditError(variable: string, customData: any, error: any) {
console.error("Failed to change variable %s: %o", variable, error);
this.sendVariable(variable, customData, true);
}
protected abstract doSendVariable(variable: string, customData: any, value: any);
}