Adding support for viewing who's watching your video feed
parent
871231cbd5
commit
357a200e3b
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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: {},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {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>
|
||||
]
|
||||
}
|
|
@ -143,4 +143,10 @@ registerModal({
|
|||
modalId: "modal-server-bandwidth",
|
||||
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
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -427,4 +419,4 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
|
|||
|
||||
protected abstract doRequestVariable(variable: string, customData: any | undefined);
|
||||
protected abstract doEditVariable(variable: string, customData: any | undefined, value: any) : Promise<void> | void;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue