Adding support for viewing who's watching your video feed
parent
871231cbd5
commit
357a200e3b
|
@ -19993,9 +19993,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"version": "3.8.3",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz",
|
||||||
"integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==",
|
"integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"uglify-js": {
|
"uglify-js": {
|
||||||
|
|
|
@ -78,7 +78,7 @@
|
||||||
"terser-webpack-plugin": "4.2.3",
|
"terser-webpack-plugin": "4.2.3",
|
||||||
"ts-loader": "^6.2.2",
|
"ts-loader": "^6.2.2",
|
||||||
"tsd": "^0.13.1",
|
"tsd": "^0.13.1",
|
||||||
"typescript": "^3.7.0",
|
"typescript": "^4.2",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"wabt": "^1.0.13",
|
"wabt": "^1.0.13",
|
||||||
"webpack": "^5.26.1",
|
"webpack": "^5.26.1",
|
||||||
|
|
|
@ -213,7 +213,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
private subscribed: boolean;
|
private subscribed: boolean;
|
||||||
private subscriptionMode: ChannelSubscribeMode;
|
private subscriptionMode: ChannelSubscribeMode;
|
||||||
|
|
||||||
private client_list: ClientEntry[] = []; /* this list is sorted correctly! */
|
private clientList: ClientEntry[] = []; /* this list is sorted correctly! */
|
||||||
private readonly clientPropertyChangedListener;
|
private readonly clientPropertyChangedListener;
|
||||||
|
|
||||||
constructor(channelTree: ChannelTree, channelId: number, channelName: string) {
|
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.forEach(callback => callback(false));
|
||||||
this.channelDescriptionCallback = [];
|
this.channelDescriptionCallback = [];
|
||||||
|
|
||||||
this.client_list.forEach(e => this.unregisterClient(e, true));
|
this.clientList.forEach(e => this.unregisterClient(e, true));
|
||||||
this.client_list = [];
|
this.clientList = [];
|
||||||
|
|
||||||
this.channel_previous = undefined;
|
this.channel_previous = undefined;
|
||||||
this.parent = undefined;
|
this.parent = undefined;
|
||||||
|
@ -363,38 +363,44 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
|
|
||||||
registerClient(client: ClientEntry) {
|
registerClient(client: ClientEntry) {
|
||||||
client.events.on("notify_properties_updated", this.clientPropertyChangedListener);
|
client.events.on("notify_properties_updated", this.clientPropertyChangedListener);
|
||||||
this.client_list.push(client);
|
this.clientList.push(client);
|
||||||
this.reorderClientList(false);
|
this.reorderClientList(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
unregisterClient(client: ClientEntry, noEvent?: boolean) {
|
unregisterClient(client: ClientEntry, noEvent?: boolean) {
|
||||||
client.events.off("notify_properties_updated", this.clientPropertyChangedListener);
|
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());
|
logWarn(LogCategory.CHANNEL, tr("Unregistered unknown client from channel %s"), this.channelName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private reorderClientList(fire_event: boolean) {
|
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) => {
|
this.clientList.sort((a, b) => {
|
||||||
if(a.properties.client_talk_power < b.properties.client_talk_power)
|
if(a.properties.client_talk_power < b.properties.client_talk_power) {
|
||||||
return 1;
|
return 1;
|
||||||
if(a.properties.client_talk_power > b.properties.client_talk_power)
|
}
|
||||||
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;
|
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;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
if(fire_event) {
|
if(fire_event) {
|
||||||
/* only fire if really something has changed ;) */
|
/* only fire if really something has changed ;) */
|
||||||
for(let index = 0; index < this.client_list.length; index++) {
|
for(let index = 0; index < this.clientList.length; index++) {
|
||||||
if(this.client_list[index] !== original_list[index]) {
|
if(this.clientList[index] !== originalList[index]) {
|
||||||
this.channelTree.events.fire("notify_channel_client_order_changed", { channel: this });
|
this.channelTree.events.fire("notify_channel_client_order_changed", { channel: this });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -420,7 +426,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
clients(deep = false) : ClientEntry[] {
|
clients(deep = false) : ClientEntry[] {
|
||||||
const result: ClientEntry[] = this.client_list.slice(0);
|
const result: ClientEntry[] = this.clientList.slice(0);
|
||||||
if(!deep) return result;
|
if(!deep) return result;
|
||||||
|
|
||||||
return this.children(true).map(e => e.clients(false)).reduce((prev, cur) => {
|
return this.children(true).map(e => e.clients(false)).reduce((prev, cur) => {
|
||||||
|
@ -430,7 +436,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
channelClientsOrdered() : ClientEntry[] {
|
channelClientsOrdered() : ClientEntry[] {
|
||||||
return this.client_list;
|
return this.clientList;
|
||||||
}
|
}
|
||||||
|
|
||||||
calculate_family_index(enforce_recalculate: boolean = false) : number {
|
calculate_family_index(enforce_recalculate: boolean = false) : number {
|
||||||
|
@ -490,7 +496,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
|
|
||||||
let trigger_close = true;
|
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;
|
const bold = text => contextmenu.get_provider().html_format_enabled() ? "<b>" + text + "</b>" : text;
|
||||||
contextmenu.spawn_context_menu(x, y, {
|
contextmenu.spawn_context_menu(x, y, {
|
||||||
type: contextmenu.MenuEntryType.ENTRY,
|
type: contextmenu.MenuEntryType.ENTRY,
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {Settings, settings} from "tc-shared/settings";
|
||||||
import * as _ from "lodash";
|
import * as _ from "lodash";
|
||||||
import PermissionType from "tc-shared/permission/PermissionType";
|
import PermissionType from "tc-shared/permission/PermissionType";
|
||||||
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||||
|
import {spawnVideoViewerInfo} from "tc-shared/ui/modal/video-viewers/Controller";
|
||||||
|
|
||||||
const cssStyle = require("./Renderer.scss");
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
|
||||||
|
@ -443,6 +444,8 @@ class ChannelVideoController {
|
||||||
controller.dismissVideo(event.broadcastType);
|
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_expended", () => this.events.fire_react("notify_expended", { expended: this.expended }));
|
||||||
this.events.on("query_videos", () => this.notifyVideoList());
|
this.events.on("query_videos", () => this.notifyVideoList());
|
||||||
this.events.on("query_spotlight", () => this.notifySpotlight());
|
this.events.on("query_spotlight", () => this.notifySpotlight());
|
||||||
|
|
|
@ -79,6 +79,7 @@ export interface ChannelVideoEvents {
|
||||||
action_set_pip: { videoId: string | undefined, broadcastType: VideoBroadcastType },
|
action_set_pip: { videoId: string | undefined, broadcastType: VideoBroadcastType },
|
||||||
action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType | undefined, muted: boolean },
|
action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType | undefined, muted: boolean },
|
||||||
action_dismiss: { videoId: string, broadcastType: VideoBroadcastType },
|
action_dismiss: { videoId: string, broadcastType: VideoBroadcastType },
|
||||||
|
action_show_viewers: {},
|
||||||
|
|
||||||
query_expended: {},
|
query_expended: {},
|
||||||
query_videos: {},
|
query_videos: {},
|
||||||
|
|
|
@ -92,9 +92,8 @@ const VideoViewerCount = React.memo(() => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cssStyle.videoViewerCount}
|
className={cssStyle.videoViewerCount}
|
||||||
onClick={() => {
|
onClick={() => events.fire("action_show_viewers")}
|
||||||
/* TODO! */
|
onDoubleClick={event => event.preventDefault()}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{info}
|
{info}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -26,6 +26,7 @@ import {ModalAboutVariables} from "tc-shared/ui/modal/about/Definitions";
|
||||||
import {ModalServerBandwidthEvents} from "tc-shared/ui/modal/server-bandwidth/Definitions";
|
import {ModalServerBandwidthEvents} from "tc-shared/ui/modal/server-bandwidth/Definitions";
|
||||||
import {ModalYesNoEvents, ModalYesNoVariables} from "tc-shared/ui/modal/yes-no/Definitions";
|
import {ModalYesNoEvents, ModalYesNoVariables} from "tc-shared/ui/modal/yes-no/Definitions";
|
||||||
import {ModalChannelInfoEvents, ModalChannelInfoVariables} from "tc-shared/ui/modal/channel-info/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 ModalType = "error" | "warning" | "info" | "none";
|
||||||
export type ModalRenderType = "page" | "dialog";
|
export type ModalRenderType = "page" | "dialog";
|
||||||
|
@ -239,5 +240,9 @@ export interface ModalConstructorArguments {
|
||||||
],
|
],
|
||||||
"modal-server-bandwidth": [
|
"modal-server-bandwidth": [
|
||||||
/* events */ IpcRegistryDescription<ModalServerBandwidthEvents>
|
/* events */ IpcRegistryDescription<ModalServerBandwidthEvents>
|
||||||
|
],
|
||||||
|
"modal-video-viewers": [
|
||||||
|
/* events */ IpcRegistryDescription<ModalVideoViewersEvents>,
|
||||||
|
/* variables */ IpcVariableDescriptor<ModalVideoViewersVariables>
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -143,4 +143,10 @@ registerModal({
|
||||||
modalId: "modal-server-bandwidth",
|
modalId: "modal-server-bandwidth",
|
||||||
classLoader: async () => await import("tc-shared/ui/modal/server-bandwidth/Renderer"),
|
classLoader: async () => await import("tc-shared/ui/modal/server-bandwidth/Renderer"),
|
||||||
popoutSupported: true
|
popoutSupported: true
|
||||||
|
});
|
||||||
|
|
||||||
|
registerModal({
|
||||||
|
modalId: "modal-video-viewers",
|
||||||
|
classLoader: async () => await import("tc-shared/ui/modal/video-viewers/Renderer"),
|
||||||
|
popoutSupported: true
|
||||||
});
|
});
|
|
@ -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]> {
|
async getVariable<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Promise<Variables[T]> {
|
||||||
const providers = this.variableProvider[variable as any];
|
const result = this.resolveVariable(variable as any, customData);
|
||||||
if(!providers) {
|
|
||||||
throw tra("missing provider for {}", variable as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = providers(customData);
|
|
||||||
if(result instanceof Promise) {
|
if(result instanceof Promise) {
|
||||||
return await result;
|
return await result;
|
||||||
} else {
|
} 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] {
|
getVariableSync<T extends keyof Variables>(variable: T, customData?: any, ignoreCache?: boolean) : Variables[T] {
|
||||||
const providers = this.variableProvider[variable as any];
|
const result = this.resolveVariable(variable as any, customData);
|
||||||
if(!providers) {
|
|
||||||
throw tr("missing provider");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = providers(customData);
|
|
||||||
if(result instanceof Promise) {
|
if(result instanceof Promise) {
|
||||||
throw tr("tried to get an async variable synchronous");
|
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");
|
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 {
|
try {
|
||||||
let result = editor(newValue, customData);
|
let result = editor(newValue, customData);
|
||||||
if(result instanceof Promise) {
|
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 {
|
} else {
|
||||||
handleEditResult(result);
|
/* We were able to instantly edit the variable. Handle result. */
|
||||||
|
this.handleEditResult(variable, customData, result);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
protected abstract doSendVariable(variable: string, customData: any, value: any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -427,4 +419,4 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
|
||||||
|
|
||||||
protected abstract doRequestVariable(variable: string, customData: any | undefined);
|
protected abstract doRequestVariable(variable: string, customData: any | undefined);
|
||||||
protected abstract doEditVariable(variable: string, customData: any | undefined, value: any) : Promise<void> | void;
|
protected abstract doEditVariable(variable: string, customData: any | undefined, value: any) : Promise<void> | void;
|
||||||
}
|
}
|
Loading…
Reference in New Issue