diff --git a/shared/CountryIconFixup.ts b/shared/CountryIconFixup.ts index bb3a0044..279a2ce4 100644 --- a/shared/CountryIconFixup.ts +++ b/shared/CountryIconFixup.ts @@ -3,7 +3,7 @@ import * as path from "path"; import * as fs from "fs-extra"; -import {getKnownCountries} from "./js/i18n/country"; +import {getKnownCountries} from "./js/i18n/CountryFlag"; const kIconsPath = path.join(__dirname, "img", "country-flags"); diff --git a/shared/js/SelectedClientInfo.ts b/shared/js/SelectedClientInfo.ts index a33ebafc..9cbde59a 100644 --- a/shared/js/SelectedClientInfo.ts +++ b/shared/js/SelectedClientInfo.ts @@ -7,7 +7,7 @@ import { } from "tc-shared/ui/frames/side/ClientInfoDefinitions"; import {ClientEntry, ClientType, LocalClientEntry} from "tc-shared/tree/Client"; import {Registry} from "tc-shared/events"; -import * as i18nc from "tc-shared/i18n/country"; +import * as i18nc from "./i18n/CountryFlag"; export type CachedClientInfoCategory = "name" | "description" | "online-state" | "country" | "volume" | "status" | "forum-account" | "group-channel" | "groups-server" | "version"; @@ -231,7 +231,7 @@ export class SelectedClientInfo { private initializeClientInfo(client: ClientEntry) { this.currentClientStatus = { - type: client instanceof LocalClientEntry ? "self" : client.properties.client_type === ClientType.CLIENT_QUERY ? "query" : "voice", + type: client instanceof LocalClientEntry ? "self" : client.getClientType() === ClientType.CLIENT_QUERY ? "query" : "voice", name: client.properties.client_nickname, databaseId: client.properties.client_database_id, uniqueId: client.properties.client_unique_identifier, diff --git a/shared/js/connection/AbstractCommandHandler.ts b/shared/js/connection/AbstractCommandHandler.ts index 2cf484e4..8eb4e654 100644 --- a/shared/js/connection/AbstractCommandHandler.ts +++ b/shared/js/connection/AbstractCommandHandler.ts @@ -20,14 +20,14 @@ export abstract class AbstractCommandHandler { abstract handle_command(command: ServerCommand) : boolean; } -export type ExplicitCommandHandler = (command: ServerCommand, consumed: boolean) => void | boolean; +export type CommandHandlerCallback = (command: ServerCommand, consumed: boolean) => void | boolean; export abstract class AbstractCommandHandlerBoss { readonly connection: AbstractServerConnection; protected command_handlers: AbstractCommandHandler[] = []; /* TODO: Timeout */ protected single_command_handler: SingleCommandHandler[] = []; - protected explicitHandlers: {[key: string]:ExplicitCommandHandler[]} = {}; + protected explicitHandlers: {[key: string]:CommandHandlerCallback[]} = {}; protected constructor(connection: AbstractServerConnection) { this.connection = connection; @@ -38,14 +38,14 @@ export abstract class AbstractCommandHandlerBoss { this.single_command_handler = undefined; } - register_explicit_handler(command: string, callback: ExplicitCommandHandler) : () => void { + registerCommandHandler(command: string, callback: CommandHandlerCallback) : () => void { this.explicitHandlers[command] = this.explicitHandlers[command] || []; this.explicitHandlers[command].push(callback); return () => this.explicitHandlers[command].remove(callback); } - unregister_explicit_handler(command: string, callback: ExplicitCommandHandler) { + unregisterCommandHandler(command: string, callback: CommandHandlerCallback) { if(!this.explicitHandlers[command]) return false; @@ -53,16 +53,17 @@ export abstract class AbstractCommandHandlerBoss { return true; } - register_handler(handler: AbstractCommandHandler) { - if(!handler.volatile_handler_boss && handler.handler_boss) + registerHandler(handler: AbstractCommandHandler) { + if(!handler.volatile_handler_boss && handler.handler_boss) { throw "handler already registered"; + } this.command_handlers.remove(handler); /* just to be sure */ this.command_handlers.push(handler); handler.handler_boss = this; } - unregister_handler(handler: AbstractCommandHandler) { + unregisterHandler(handler: AbstractCommandHandler) { if(!handler.volatile_handler_boss && handler.handler_boss !== this) { logWarn(LogCategory.NETWORKING, tr("Tried to unregister command handler which does not belong to the handler boss")); return; @@ -73,13 +74,13 @@ export abstract class AbstractCommandHandlerBoss { } - register_single_handler(handler: SingleCommandHandler) { + registerSingleHandler(handler: SingleCommandHandler) { if(typeof handler.command === "string") handler.command = [handler.command]; this.single_command_handler.push(handler); } - remove_single_handler(handler: SingleCommandHandler) { + removeSingleHandler(handler: SingleCommandHandler) { this.single_command_handler.remove(handler); } @@ -87,7 +88,7 @@ export abstract class AbstractCommandHandlerBoss { return this.command_handlers; } - invoke_handle(command: ServerCommand) : boolean { + invokeCommand(command: ServerCommand) : boolean { let flag_consumed = false; for(const handler of this.command_handlers) { diff --git a/shared/js/connection/ClientInfo.ts b/shared/js/connection/ClientInfo.ts index 8fd5c4af..3443c3bd 100644 --- a/shared/js/connection/ClientInfo.ts +++ b/shared/js/connection/ClientInfo.ts @@ -154,7 +154,7 @@ export class ClientInfoResolver { try { const requestDatabaseIds = Object.keys(this.requestDatabaseIds); if(requestDatabaseIds.length > 0) { - handlers.push(this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyclientgetnamefromdbid", command => { + handlers.push(this.handler.serverConnection.getCommandHandler().registerCommandHandler("notifyclientgetnamefromdbid", command => { ClientInfoResolver.parseClientInfo(command.arguments).forEach(info => { if(this.requestDatabaseIds[info.clientDatabaseId].fullFilled) { return; @@ -197,7 +197,7 @@ export class ClientInfoResolver { const requestUniqueIds = Object.keys(this.requestUniqueIds); if(requestUniqueIds.length > 0) { - handlers.push(this.handler.serverConnection.command_handler_boss().register_explicit_handler("notifyclientnamefromuid", command => { + handlers.push(this.handler.serverConnection.getCommandHandler().registerCommandHandler("notifyclientnamefromuid", command => { ClientInfoResolver.parseClientInfo(command.arguments).forEach(info => { if(this.requestUniqueIds[info.clientUniqueId].fullFilled) { return; diff --git a/shared/js/connection/CommandHandler.ts b/shared/js/connection/CommandHandler.ts index 34589d99..4935c25d 100644 --- a/shared/js/connection/CommandHandler.ts +++ b/shared/js/connection/CommandHandler.ts @@ -487,7 +487,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { client = tree.findClient(parseInt(entry["clid"])); if(!client) { - if(parseInt(entry["client_type_exact"]) == ClientType.CLIENT_MUSIC) { + if(parseInt(entry["client_type_exact"]) == 4) { client = new MusicClientEntry(parseInt(entry["clid"]), entry["client_nickname"]) as any; } else { client = new ClientEntry(parseInt(entry["clid"]), entry["client_nickname"]); @@ -501,7 +501,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { tree.moveClient(client, channel); } - if(this.connection_handler.areQueriesShown() || client.properties.client_type != ClientType.CLIENT_QUERY) { + if(this.connection_handler.areQueriesShown() || client.getClientType() !== ClientType.CLIENT_QUERY) { const own_channel = this.connection.client.getClient().currentChannel(); this.connection_handler.log.log(channel == own_channel ? "client.view.enter.own.channel" : "client.view.enter", { channel_from: old_channel ? old_channel.log_data() : undefined, @@ -584,7 +584,7 @@ export class ConnectionCommandHandler extends AbstractCommandHandler { } const targetChannelId = parseInt(entry["ctid"]); - if(this.connection_handler.areQueriesShown() || client.properties.client_type != ClientType.CLIENT_QUERY) { + if(this.connection_handler.areQueriesShown() || client.getClientType() !== ClientType.CLIENT_QUERY) { const own_channel = this.connection.client.getClient().currentChannel(); let channel_from = tree.findChannel(entry["cfid"]); let channel_to = tree.findChannel(targetChannelId); diff --git a/shared/js/connection/CommandHelper.ts b/shared/js/connection/CommandHelper.ts index e29db03c..1914d178 100644 --- a/shared/js/connection/CommandHelper.ts +++ b/shared/js/connection/CommandHelper.ts @@ -24,13 +24,13 @@ export class CommandHelper extends AbstractCommandHandler { } initialize() { - this.connection.command_handler_boss().register_handler(this); + this.connection.getCommandHandler().registerHandler(this); } destroy() { if(this.connection) { - const hboss = this.connection.command_handler_boss(); - hboss?.unregister_handler(this); + const hboss = this.connection.getCommandHandler(); + hboss?.unregisterHandler(this); } this.infoByUniqueIdRequest = undefined; @@ -173,7 +173,7 @@ export class CommandHelper extends AbstractCommandHandler { return true; } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); let data = {}; if(server_id !== undefined) { @@ -189,7 +189,7 @@ export class CommandHelper extends AbstractCommandHandler { } reject(error); }).then(() => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); }); }); } @@ -228,7 +228,7 @@ export class CommandHelper extends AbstractCommandHandler { return true; } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); this.connection.send_command("playlistlist").catch(error => { if(error instanceof CommandResult) { @@ -239,7 +239,7 @@ export class CommandHelper extends AbstractCommandHandler { } reject(error); }).then(() => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); }); }); } @@ -294,7 +294,7 @@ export class CommandHelper extends AbstractCommandHandler { } } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); this.connection.send_command("playlistsonglist", {playlist_id: playlist_id}, { process_result: process_result }).catch(error => { if(error instanceof CommandResult) { @@ -305,7 +305,7 @@ export class CommandHelper extends AbstractCommandHandler { } reject(error); }).catch(() => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); }); }); } @@ -332,7 +332,7 @@ export class CommandHelper extends AbstractCommandHandler { return true; } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); this.connection.send_command("playlistclientlist", {playlist_id: playlist_id}).catch(error => { if(error instanceof CommandResult && error.id == ErrorCode.DATABASE_EMPTY_RESULT) { @@ -341,7 +341,7 @@ export class CommandHelper extends AbstractCommandHandler { } reject(error); }).then(() => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); }); }); } @@ -378,10 +378,10 @@ export class CommandHelper extends AbstractCommandHandler { return true; } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); this.connection.send_command("servergroupclientlist", {sgid: group_id}).catch(reject).then(() => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); }); }); } @@ -422,10 +422,10 @@ export class CommandHelper extends AbstractCommandHandler { return true; } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); this.connection.send_command("playlistinfo", { playlist_id: playlist_id }).catch(reject).then(() => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); }); }); } @@ -451,10 +451,10 @@ export class CommandHelper extends AbstractCommandHandler { return true; } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); this.connection.send_command("whoami").catch(error => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); reject(error); }); }); diff --git a/shared/js/connection/ConnectionBase.ts b/shared/js/connection/ConnectionBase.ts index 985b8f2b..39703e41 100644 --- a/shared/js/connection/ConnectionBase.ts +++ b/shared/js/connection/ConnectionBase.ts @@ -63,7 +63,7 @@ export abstract class AbstractServerConnection { abstract getVoiceConnection() : AbstractVoiceConnection; abstract getVideoConnection() : VideoConnection; - abstract command_handler_boss() : AbstractCommandHandlerBoss; + abstract getCommandHandler() : AbstractCommandHandlerBoss; abstract send_command(command: string, data?: any | any[], options?: CommandOptions) : Promise; abstract remote_address() : ServerAddress; /* only valid when connected */ @@ -89,6 +89,36 @@ export abstract class AbstractServerConnection { export class ServerCommand { command: string; arguments: any[]; + switches: string[]; + + constructor(command: string, payload: any[], switches: string[]) { + this.command = command; + this.arguments = payload; + this.switches = switches; + } + + getString(key: string, index?: number) { + const value = this.arguments[index || 0][key]; + if(typeof value !== "string") { + throw "missing " + key + " at index " + (index || 0); + } + + return value; + } + + getInt(key: string, index?: number) { + const value = this.getString(key, index); + const intValue = parseInt(value); + if(isNaN(intValue)) { + throw "key " + key + " isn't an integer (value: " + value + ")"; + } + + return intValue; + } + + getUInt(key: string, index?: number) { + return this.getInt(key, index) >>> 0; + } } export interface SingleCommandHandler { diff --git a/shared/js/connection/PluginCmdHandler.ts b/shared/js/connection/PluginCmdHandler.ts index a77371ae..66f520f5 100644 --- a/shared/js/connection/PluginCmdHandler.ts +++ b/shared/js/connection/PluginCmdHandler.ts @@ -97,11 +97,11 @@ export class PluginCmdRegistry { this.connection = connection; this.handler = new PluginCmdRegistryCommandHandler(connection.serverConnection, this.handlePluginCommand.bind(this)); - this.connection.serverConnection.command_handler_boss().register_handler(this.handler); + this.connection.serverConnection.getCommandHandler().registerHandler(this.handler); } destroy() { - this.connection.serverConnection.command_handler_boss().unregister_handler(this.handler); + this.connection.serverConnection.getCommandHandler().unregisterHandler(this.handler); Object.keys(this.handlerMap).map(e => this.handlerMap[e]).forEach(handler => { handler["currentServerConnection"] = undefined; diff --git a/shared/js/connection/ServerFeatures.ts b/shared/js/connection/ServerFeatures.ts index be24b1db..e3dd41d1 100644 --- a/shared/js/connection/ServerFeatures.ts +++ b/shared/js/connection/ServerFeatures.ts @@ -3,7 +3,7 @@ import {Registry} from "../events"; import {CommandResult} from "../connection/ServerConnectionDeclaration"; import {ErrorCode} from "../connection/ErrorCode"; import {LogCategory, logDebug, logTrace, logWarn} from "../log"; -import {ExplicitCommandHandler} from "../connection/AbstractCommandHandler"; +import {CommandHandlerCallback} from "../connection/AbstractCommandHandler"; import { tr } from "tc-shared/i18n/localize"; export type ServerFeatureSupport = "unsupported" | "supported" | "experimental" | "deprecated"; @@ -28,7 +28,7 @@ export interface ServerFeatureEvents { export class ServerFeatures { readonly events: Registry; private readonly connection: ConnectionHandler; - private readonly explicitCommandHandler: ExplicitCommandHandler; + private readonly explicitCommandHandler: CommandHandlerCallback; private readonly stateChangeListener: () => void; private featureAwait: Promise; @@ -41,7 +41,7 @@ export class ServerFeatures { this.events = new Registry(); this.connection = connection; - this.connection.getServerConnection().command_handler_boss().register_explicit_handler("notifyfeaturesupport", this.explicitCommandHandler = command => { + this.connection.getServerConnection().getCommandHandler().registerCommandHandler("notifyfeaturesupport", this.explicitCommandHandler = command => { for(const set of command.arguments) { let support: ServerFeatureSupport; switch (parseInt(set["support"])) { @@ -96,7 +96,7 @@ export class ServerFeatures { destroy() { this.stateChangeListener(); - this.connection.getServerConnection()?.command_handler_boss()?.unregister_explicit_handler("notifyfeaturesupport", this.explicitCommandHandler); + this.connection.getServerConnection()?.getCommandHandler()?.unregisterCommandHandler("notifyfeaturesupport", this.explicitCommandHandler); if(this.featureAwaitCallback) { this.featureAwaitCallback(false); diff --git a/shared/js/connection/VideoConnection.ts b/shared/js/connection/VideoConnection.ts index bf9f129d..4c103ff1 100644 --- a/shared/js/connection/VideoConnection.ts +++ b/shared/js/connection/VideoConnection.ts @@ -69,8 +69,17 @@ export interface VideoClient { showPip(broadcastType: VideoBroadcastType) : Promise; } +export type VideoBroadcastViewer = { + clientId: number, + clientName: string, + clientUniqueId: string, + clientDatabaseId: number, +}; + export interface LocalVideoBroadcastEvents { notify_state_changed: { oldState: LocalVideoBroadcastState, newState: LocalVideoBroadcastState }, + notify_clients_joined: { clients: VideoBroadcastViewer[] }, + notify_clients_left: { clientIds: number[] }, } export type LocalVideoBroadcastState = { @@ -148,6 +157,8 @@ export interface LocalVideoBroadcast { getConstraints() : VideoBroadcastConfig | undefined; stopBroadcasting(); + + getViewer() : VideoBroadcastViewer[]; } export interface VideoConnection { diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index 478ea804..45d49150 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -449,19 +449,6 @@ export type RTCSourceTrackType = "audio" | "audio-whisper" | "video" | "video-sc export type RTCBroadcastableTrackType = Exclude; const kRtcSourceTrackTypes: RTCSourceTrackType[] = ["audio", "audio-whisper", "video", "video-screen"]; -function broadcastableTrackTypeToNumber(type: RTCBroadcastableTrackType) : number { - switch (type) { - case "video-screen": - return 3; - case "video": - return 2; - case "audio": - return 1; - default: - throw tr("invalid target type"); - } -} - type TemporaryRtpStream = { createTimestamp: number, timeoutId: number, @@ -534,16 +521,15 @@ export class RTCConnection { this.retryCalculator = new RetryTimeCalculator(5000, 30000, 10000); this.audioSupport = audioSupport; - this.connection.command_handler_boss().register_handler(this.commandHandler); + this.connection.getCommandHandler().registerHandler(this.commandHandler); this.reset(true); this.connection.events.on("notify_connection_state_changed", event => this.handleConnectionStateChanged(event)); - (window as any).rtp = this; } destroy() { - this.connection.command_handler_boss().unregister_handler(this.commandHandler); + this.connection.getCommandHandler().unregisterHandler(this.commandHandler); } isAudioEnabled() : boolean { @@ -680,6 +666,21 @@ export class RTCConnection { return result; } + getTrackTypeFromSsrc(ssrc: number) : RTCSourceTrackType | undefined { + const mediaId = this.sdpProcessor.getLocalMediaIdFromSsrc(ssrc); + if(!mediaId) { + return undefined; + } + + for(const type of kRtcSourceTrackTypes) { + if(this.currentTransceiver[type]?.mid === mediaId) { + return type; + } + } + + return undefined; + } + public async startVideoBroadcast(type: VideoBroadcastType, config: VideoBroadcastConfig) { let track: RTCBroadcastableTrackType; let broadcastType: number; diff --git a/shared/js/connection/rtc/SdpUtils.ts b/shared/js/connection/rtc/SdpUtils.ts index 81cfadb5..36efac86 100644 --- a/shared/js/connection/rtc/SdpUtils.ts +++ b/shared/js/connection/rtc/SdpUtils.ts @@ -83,6 +83,16 @@ export class SdpProcessor { return this.rtpLocalChannelMapping[mediaId]; } + getLocalMediaIdFromSsrc(ssrc: number) : string | undefined { + for(const key of Object.keys(this.rtpLocalChannelMapping)) { + if(this.rtpLocalChannelMapping[key] === ssrc) { + return key; + } + } + + return undefined; + } + processIncomingSdp(sdpString: string, _mode: "offer" | "answer") : string { /* The server somehow does not encode the level id in hex */ sdpString = sdpString.replace(/profile-level-id=4325407/g, "profile-level-id=4d0028"); diff --git a/shared/js/connection/rtc/video/Connection.ts b/shared/js/connection/rtc/video/Connection.ts index f0419eae..ca7b00e6 100644 --- a/shared/js/connection/rtc/video/Connection.ts +++ b/shared/js/connection/rtc/video/Connection.ts @@ -5,6 +5,7 @@ import { VideoBroadcastConfig, VideoBroadcastStatistics, VideoBroadcastType, + VideoBroadcastViewer, VideoClient, VideoConnection, VideoConnectionEvent, @@ -33,12 +34,14 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { private broadcastStartId: number; private localStartPromise: Promise; + private subscribedClients: VideoBroadcastViewer[]; constructor(handle: RtpVideoConnection, type: VideoBroadcastType) { this.handle = handle; this.type = type; this.broadcastStartId = 0; + this.subscribedClients = []; this.events = new Registry(); this.state = { state: "stopped" }; } @@ -67,12 +70,31 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { const oldState = this.state; this.state = newState; this.events.fire("notify_state_changed", { oldState: oldState, newState: newState }); + + if(this.subscribedClients.length > 0) { + switch (newState.state) { + case "broadcasting": + case "initializing": + break; + + case "failed": + case "stopped": + default: + const clientIds = this.subscribedClients.map(client => client.clientId); + this.subscribedClients = []; + this.events.fire("notify_clients_left", { clientIds: clientIds }); + } + } } getStatistics(): Promise { return Promise.resolve(undefined); } + getViewer(): VideoBroadcastViewer[] { + return this.subscribedClients; + } + async changeSource(source: VideoSource, constraints: VideoBroadcastConfig): Promise { let sourceRef = source.ref(); try { @@ -333,6 +355,26 @@ class LocalRtpVideoBroadcast implements LocalVideoBroadcast { getConstraints(): VideoBroadcastConfig | undefined { return this.currentConfig; } + + handleClientJoined(client: VideoBroadcastViewer) { + const index = this.subscribedClients.findIndex(client_ => client_.clientId === client.clientId); + if(index === -1) { + this.subscribedClients.push(client); + this.events.fire("notify_clients_joined", { clients: [ client ] }); + } else { + this.subscribedClients[index] = client; + } + } + + handleClientLeave(clientId: number) { + const index = this.subscribedClients.findIndex(client => client.clientId === clientId); + if(index === -1) { + return; + } + + this.subscribedClients.splice(index, 1); + this.events.fire("notify_clients_left", { clientIds: [ clientId ] }); + } } export class RtpVideoConnection implements VideoConnection { @@ -355,7 +397,7 @@ export class RtpVideoConnection implements VideoConnection { this.listener = []; /* We only have to listen for move events since if the client is leaving the broadcast will be terminated anyways */ - this.listener.push(this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => { + this.listener.push(this.rtcConnection.getConnection().getCommandHandler().registerCommandHandler("notifyclientmoved", event => { const localClient = this.rtcConnection.getConnection().client.getClient(); for(const data of event.arguments) { const clientId = parseInt(data["clid"]); @@ -380,7 +422,7 @@ export class RtpVideoConnection implements VideoConnection { } })); - this.listener.push(this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifybroadcastvideo", event => { + this.listener.push(this.rtcConnection.getConnection().getCommandHandler().registerCommandHandler("notifybroadcastvideo", event => { const assignedClients: { clientId: number, broadcastType: VideoBroadcastType }[] = []; for(const data of event.arguments) { if(!("bid" in data)) { @@ -428,6 +470,76 @@ export class RtpVideoConnection implements VideoConnection { }); })); + this.listener.push(this.rtcConnection.getConnection().getCommandHandler().registerCommandHandler("notifystreamjoined", command => { + const streamId = command.getUInt("streamid"); + const clientInfo: VideoBroadcastViewer = { + clientId: command.getUInt("clid"), + clientName: command.getString("clname"), + clientUniqueId: command.getString("cluid"), + clientDatabaseId: command.getUInt("cldbid") + }; + + if(clientInfo.clientId === this.rtcConnection.getConnection().client.getClientId()) { + /* Just our local video preview */ + return; + } + + const broadcastType = this.rtcConnection.getTrackTypeFromSsrc(streamId); + if(!broadcastType) { + logError(LogCategory.NETWORKING, tr("Received stream join notify for invalid stream (ssrc: %o)"), streamId); + return; + } + + let broadcast: LocalRtpVideoBroadcast; + switch (broadcastType) { + case "video": + broadcast = this.broadcasts["camera"]; + break; + + case "video-screen": + broadcast = this.broadcasts["screen"]; + break; + + case "audio": + case "audio-whisper": + default: + logError(LogCategory.NETWORKING, tr("Received stream join notify for invalid stream type: %s"), broadcastType); + return; + } + + broadcast.handleClientJoined(clientInfo); + })); + + this.listener.push(this.rtcConnection.getConnection().getCommandHandler().registerCommandHandler("notifystreamleft", command => { + const streamId = command.getUInt("streamid"); + const clientId = command.getUInt("clid"); + + const broadcastType = this.rtcConnection.getTrackTypeFromSsrc(streamId); + if(!broadcastType) { + logError(LogCategory.NETWORKING, tr("Received stream leave notify for invalid stream (ssrc: %o)"), streamId); + return; + } + + let broadcast: LocalRtpVideoBroadcast; + switch (broadcastType) { + case "video": + broadcast = this.broadcasts["camera"]; + break; + + case "video-screen": + broadcast = this.broadcasts["screen"]; + break; + + case "audio": + case "audio-whisper": + default: + logError(LogCategory.NETWORKING, tr("Received stream leave notify for invalid stream type: %s"), broadcastType); + return; + } + + broadcast.handleClientLeave(clientId); + })); + this.listener.push(this.rtcConnection.getConnection().events.on("notify_connection_state_changed", event => { if(event.newState !== ConnectionState.CONNECTED) { Object.values(this.broadcasts).forEach(broadcast => broadcast.stopBroadcasting(true)); diff --git a/shared/js/connection/rtc/video/VideoClient.ts b/shared/js/connection/rtc/video/VideoClient.ts index 1876ac26..a4b3e6ca 100644 --- a/shared/js/connection/rtc/video/VideoClient.ts +++ b/shared/js/connection/rtc/video/VideoClient.ts @@ -82,11 +82,6 @@ export class RtpVideoClient implements VideoClient { await this.handle.getConnection().send_command("broadcastvideojoin", { bid: this.broadcastIds[broadcastType], bt: broadcastType === "camera" ? 0 : 1 - }).then(() => { - /* the broadcast state should switch automatically to running since we got an RTP stream now */ - if(this.trackStates[broadcastType] === VideoBroadcastState.Initializing) { - throw tr("failed to receive stream"); - } }).catch(error => { this.joinedStates[broadcastType] = false; this.updateBroadcastState(broadcastType); diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index f38beea6..38b29ff6 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -395,9 +395,9 @@ export class ChannelConversationManager extends AbstractChatManager { this.queryUnreadFlags(); diff --git a/shared/js/file/FileManager.tsx b/shared/js/file/FileManager.tsx index 14a92574..e247b424 100644 --- a/shared/js/file/FileManager.tsx +++ b/shared/js/file/FileManager.tsx @@ -64,13 +64,13 @@ class FileCommandHandler extends AbstractCommandHandler { super(manager.connectionHandler.serverConnection); this.manager = manager; - this.connection.command_handler_boss().register_handler(this); + this.connection.getCommandHandler().registerHandler(this); } destroy() { if(this.connection) { - const hboss = this.connection.command_handler_boss(); - if(hboss) hboss.unregister_handler(this); + const hboss = this.connection.getCommandHandler(); + if(hboss) hboss.unregisterHandler(this); } } diff --git a/shared/js/i18n/country.ts b/shared/js/i18n/CountryFlag.ts similarity index 100% rename from shared/js/i18n/country.ts rename to shared/js/i18n/CountryFlag.ts diff --git a/shared/js/i18n/Repository.ts b/shared/js/i18n/Repository.ts new file mode 100644 index 00000000..9c2b45b5 --- /dev/null +++ b/shared/js/i18n/Repository.ts @@ -0,0 +1,51 @@ +/* +"key": "de_gt", +"country_code": "de", +"path": "de_google_translate.translation", + +"name": "German translation, based on Google Translate", +"contributors": [ +{ + "name": "Google Translate, via script by Markus Hadenfeldt", + "email": "gtr.i18n.client@teaspeak.de" +}, +{ + "name": "Markus Hadenfeldt", + "email": "i18n.client@teaspeak.de" +} +] + */ + +import {CountryFlag} from "svg-sprites/country-flags"; +import {AbstractTranslationResolver} from "tc-shared/i18n/Translation"; + +export type I18NContributor = { + name: string, + email: string +}; + +export type TranslationResolverCreateResult = { + status: "success", + resolver: AbstractTranslationResolver +} | { + status: "error", + message: string +}; + +export abstract class I18NTranslation { + abstract getId() : string; + abstract getName() : string; + + abstract getCountry() : CountryFlag; + abstract getDescription() : string; + abstract getContributors() : I18NContributor[]; + + abstract createTranslationResolver() : Promise; +} + +export abstract class I18NRepository { + abstract getName() : string; + abstract getDescription() : string; + + abstract getTranslations() : Promise; +} \ No newline at end of file diff --git a/shared/js/i18n/Translation.ts b/shared/js/i18n/Translation.ts new file mode 100644 index 00000000..e080f2bf --- /dev/null +++ b/shared/js/i18n/Translation.ts @@ -0,0 +1,47 @@ +import {LogCategory, logError} from "tc-shared/log"; + +export abstract class AbstractTranslationResolver { + private translationCache: { [key: string]: string }; + + protected constructor() { + this.translationCache = {}; + } + + /** + * Translate the target message. + * @param message + */ + public translateMessage(message: string) { + /* typeof is the fastest method */ + if(typeof this.translationCache === "string") { + return this.translationCache[message]; + } + + let result; + try { + result = this.translationCache[message] = this.resolveTranslation(message); + } catch (error) { + /* Don't translate this message because it could cause an infinite loop */ + logError(LogCategory.I18N, "Failed to resolve translation message: %o", error); + result = this.translationCache[message] = message; + } + + return result; + } + + protected invalidateCache() { + this.translationCache = {}; + } + + /** + * Register a translation into the cache. + * @param message + * @param translation + * @protected + */ + protected registerTranslation(message: string, translation: string) { + this.translationCache[message] = translation; + } + + protected abstract resolveTranslation(message: string) : string; +} \ No newline at end of file diff --git a/shared/js/media/Stream.ts b/shared/js/media/Stream.ts index ea1d8878..64e4dad3 100644 --- a/shared/js/media/Stream.ts +++ b/shared/js/media/Stream.ts @@ -22,7 +22,7 @@ export async function requestMediaStreamWithConstraints(constraints: MediaTrackC const beginTimestamp = Date.now(); try { logInfo(LogCategory.AUDIO, tr("Requesting a %s stream for device %s in group %s"), type, constraints.deviceId, constraints.groupId); - return await navigator.mediaDevices.getUserMedia(type === "audio" ? {audio: constraints} : {video: constraints}); + return await navigator.mediaDevices.getUserMedia(type === "audio" ? { audio: constraints } : { video: constraints }); } catch(error) { if('name' in error) { if(error.name === "NotAllowedError") { diff --git a/shared/js/music/PlaylistManager.ts b/shared/js/music/PlaylistManager.ts index 1f56c7cf..4cded206 100644 --- a/shared/js/music/PlaylistManager.ts +++ b/shared/js/music/PlaylistManager.ts @@ -201,7 +201,7 @@ class InternalSubscribedPlaylist extends SubscribedPlaylist { this.listenerConnection = []; const serverConnection = this.handle.connection.serverConnection; - this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongadd", command => { + this.listenerConnection.push(serverConnection.getCommandHandler().registerCommandHandler("notifyplaylistsongadd", command => { const playlistId = parseInt(command.arguments[0]["playlist_id"]); if(isNaN(playlistId)) { logWarn(LogCategory.NETWORKING, tr("Received a playlist song add notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]); @@ -220,7 +220,7 @@ class InternalSubscribedPlaylist extends SubscribedPlaylist { }); })); - this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongremove", command => { + this.listenerConnection.push(serverConnection.getCommandHandler().registerCommandHandler("notifyplaylistsongremove", command => { const playlistId = parseInt(command.arguments[0]["playlist_id"]); if(isNaN(playlistId)) { logWarn(LogCategory.NETWORKING, tr("Received a playlist song remove notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]); @@ -243,7 +243,7 @@ class InternalSubscribedPlaylist extends SubscribedPlaylist { }); })); - this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongreorder", command => { + this.listenerConnection.push(serverConnection.getCommandHandler().registerCommandHandler("notifyplaylistsongreorder", command => { const playlistId = parseInt(command.arguments[0]["playlist_id"]); if(isNaN(playlistId)) { logWarn(LogCategory.NETWORKING, tr("Received a playlist song reorder notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]); @@ -283,7 +283,7 @@ class InternalSubscribedPlaylist extends SubscribedPlaylist { }); })); - this.listenerConnection.push(serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsongloaded", command => { + this.listenerConnection.push(serverConnection.getCommandHandler().registerCommandHandler("notifyplaylistsongloaded", command => { const playlistId = parseInt(command.arguments[0]["playlist_id"]); if(isNaN(playlistId)) { logWarn(LogCategory.NETWORKING, tr("Received a playlist song loaded notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]); @@ -470,7 +470,7 @@ export class PlaylistManager { this.connection = connection; this.listenerConnection = []; - this.listenerConnection.push(connection.serverConnection.command_handler_boss().register_explicit_handler("notifyplaylistsonglist", command => { + this.listenerConnection.push(connection.serverConnection.getCommandHandler().registerCommandHandler("notifyplaylistsonglist", command => { const playlistId = parseInt(command.arguments[0]["playlist_id"]); if(isNaN(playlistId)) { logWarn(LogCategory.NETWORKING, tr("Received playlist song list notify with an invalid playlist id (%o)"), command.arguments[0]["playlist_id"]); diff --git a/shared/js/permission/GroupManager.ts b/shared/js/permission/GroupManager.ts index 388d5dd9..72c84b3b 100644 --- a/shared/js/permission/GroupManager.ts +++ b/shared/js/permission/GroupManager.ts @@ -168,7 +168,7 @@ export class GroupManager extends AbstractCommandHandler { } }; - client.serverConnection.command_handler_boss().register_handler(this); + client.serverConnection.getCommandHandler().registerHandler(this); client.events().on("notify_connection_state_changed", this.connectionStateListener); this.reset(); @@ -177,7 +177,7 @@ export class GroupManager extends AbstractCommandHandler { destroy() { this.reset(); this.connectionHandler.events().off("notify_connection_state_changed", this.connectionStateListener); - this.connectionHandler.serverConnection?.command_handler_boss().unregister_handler(this); + this.connectionHandler.serverConnection?.getCommandHandler().unregisterHandler(this); this.serverGroups = undefined; this.channelGroups = undefined; } diff --git a/shared/js/permission/PermissionManager.ts b/shared/js/permission/PermissionManager.ts index 2627b13b..345a2956 100644 --- a/shared/js/permission/PermissionManager.ts +++ b/shared/js/permission/PermissionManager.ts @@ -260,13 +260,13 @@ export class PermissionManager extends AbstractCommandHandler { //FIXME? Dont register the handler like this? this.volatile_handler_boss = true; - client.serverConnection.command_handler_boss().register_handler(this); + client.serverConnection.getCommandHandler().registerHandler(this); this.handle = client; } destroy() { - this.handle.serverConnection && this.handle.serverConnection.command_handler_boss().unregister_handler(this); + this.handle.serverConnection && this.handle.serverConnection.getCommandHandler().unregisterHandler(this); this.needed_permission_change_listener = {}; this.permissionList = undefined; @@ -712,10 +712,10 @@ export class PermissionManager extends AbstractCommandHandler { return true; } }; - this.handler_boss.register_single_handler(single_handler); + this.handler_boss.registerSingleHandler(single_handler); this.connection.send_command("permfind", permission_ids.map(e => { return {permid: e }})).catch(error => { - this.handler_boss.remove_single_handler(single_handler); + this.handler_boss.removeSingleHandler(single_handler); if(error instanceof CommandResult && error.id == ErrorCode.DATABASE_EMPTY_RESULT) { resolve([]); diff --git a/shared/js/profiles/identities/NameIdentity.ts b/shared/js/profiles/identities/NameIdentity.ts index 55200e7e..fb803026 100644 --- a/shared/js/profiles/identities/NameIdentity.ts +++ b/shared/js/profiles/identities/NameIdentity.ts @@ -23,7 +23,7 @@ class NameHandshakeHandler extends AbstractHandshakeIdentityHandler { } executeHandshake() { - this.connection.command_handler_boss().register_handler(this.handler); + this.connection.getCommandHandler().registerHandler(this.handler); this.connection.send_command("handshakebegin", { intention: 0, authentication_method: this.identity.type(), @@ -37,12 +37,12 @@ class NameHandshakeHandler extends AbstractHandshakeIdentityHandler { } protected trigger_fail(message: string) { - this.connection.command_handler_boss().unregister_handler(this.handler); + this.connection.getCommandHandler().unregisterHandler(this.handler); super.trigger_fail(message); } protected trigger_success() { - this.connection.command_handler_boss().unregister_handler(this.handler); + this.connection.getCommandHandler().unregisterHandler(this.handler); super.trigger_success(); } } diff --git a/shared/js/profiles/identities/TeaForumIdentity.ts b/shared/js/profiles/identities/TeaForumIdentity.ts index 74e58633..177dfad7 100644 --- a/shared/js/profiles/identities/TeaForumIdentity.ts +++ b/shared/js/profiles/identities/TeaForumIdentity.ts @@ -23,7 +23,7 @@ class TeaForumHandshakeHandler extends AbstractHandshakeIdentityHandler { } executeHandshake() { - this.connection.command_handler_boss().register_handler(this.handler); + this.connection.getCommandHandler().registerHandler(this.handler); this.connection.send_command("handshakebegin", { intention: 0, authentication_method: this.identity.type(), @@ -51,12 +51,12 @@ class TeaForumHandshakeHandler extends AbstractHandshakeIdentityHandler { } protected trigger_fail(message: string) { - this.connection.command_handler_boss().unregister_handler(this.handler); + this.connection.getCommandHandler().unregisterHandler(this.handler); super.trigger_fail(message); } protected trigger_success() { - this.connection.command_handler_boss().unregister_handler(this.handler); + this.connection.getCommandHandler().unregisterHandler(this.handler); super.trigger_success(); } } diff --git a/shared/js/profiles/identities/TeamSpeakIdentity.ts b/shared/js/profiles/identities/TeamSpeakIdentity.ts index 3e7f808e..2a980a60 100644 --- a/shared/js/profiles/identities/TeamSpeakIdentity.ts +++ b/shared/js/profiles/identities/TeamSpeakIdentity.ts @@ -237,7 +237,7 @@ export class TeaSpeakHandshakeHandler extends AbstractHandshakeIdentityHandler { } executeHandshake() { - this.connection.command_handler_boss().register_handler(this.handler); + this.connection.getCommandHandler().registerHandler(this.handler); this.connection.send_command("handshakebegin", { intention: 0, authentication_method: this.identity.type(), @@ -271,12 +271,12 @@ export class TeaSpeakHandshakeHandler extends AbstractHandshakeIdentityHandler { } protected trigger_fail(message: string) { - this.connection.command_handler_boss().unregister_handler(this.handler); + this.connection.getCommandHandler().unregisterHandler(this.handler); super.trigger_fail(message); } protected trigger_success() { - this.connection.command_handler_boss().unregister_handler(this.handler); + this.connection.getCommandHandler().unregisterHandler(this.handler); super.trigger_success(); } diff --git a/shared/js/tree/Client.ts b/shared/js/tree/Client.ts index 8e69dde9..a5b69d27 100644 --- a/shared/js/tree/Client.ts +++ b/shared/js/tree/Client.ts @@ -31,18 +31,19 @@ import {W2GPluginCmdHandler} from "tc-shared/ui/modal/video-viewer/W2GPlugin"; import {spawnServerGroupAssignments} from "tc-shared/ui/modal/group-assignment/Controller"; import {promptYesNo} from "tc-shared/ui/modal/yes-no/Controller"; +/* Must be the same as the TeaSpeak servers enum values */ export enum ClientType { - CLIENT_VOICE, - CLIENT_QUERY, - CLIENT_INTERNAL, - CLIENT_WEB, - CLIENT_MUSIC, - CLIENT_UNDEFINED + CLIENT_VOICE = 0, + CLIENT_QUERY = 1, + CLIENT_WEB = 3, + CLIENT_MUSIC = 4, + CLIENT_TEASPEAK = 5, + CLIENT_UNDEFINED = 5 } export class ClientProperties { client_type: ClientType = ClientType.CLIENT_VOICE; //TeamSpeaks type - client_type_exact: ClientType = ClientType.CLIENT_VOICE; + client_type_exact: ClientType = ClientType.CLIENT_UNDEFINED; client_database_id: number = 0; client_version: string = ""; @@ -912,6 +913,44 @@ export class ClientEntry extends Cha getAudioVolume() { return this.voiceVolume; } + + getClientType() : ClientType { + if(this.properties.client_type_exact === ClientType.CLIENT_UNDEFINED) { + /* We're on a TS3 server */ + switch(this.properties.client_type) { + case 0: + return ClientType.CLIENT_VOICE; + + case 1: + return ClientType.CLIENT_QUERY; + + default: + return ClientType.CLIENT_UNDEFINED; + } + } else { + switch(this.properties.client_type_exact) { + case 0: + return ClientType.CLIENT_VOICE; + + case 1: + return ClientType.CLIENT_QUERY; + + case 3: + return ClientType.CLIENT_WEB; + + case 4: + return ClientType.CLIENT_MUSIC; + + case 5: + return ClientType.CLIENT_TEASPEAK; + + case 2: + /* 2 is the internal client type which should never be visible for the target user */ + default: + return ClientType.CLIENT_UNDEFINED; + } + } + } } export class LocalClientEntry extends ClientEntry { diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts index a7f567e4..fc65f2e7 100644 --- a/shared/js/tree/Server.ts +++ b/shared/js/tree/Server.ts @@ -307,7 +307,7 @@ export class ServerEntry extends ChannelTreeEntry { const connection = this.channelTree.client.serverConnection; let result: ServerConnectionInfoResult = { status: "error", message: "missing notify" }; - const handlerUnregister = connection.command_handler_boss().register_explicit_handler("notifyserverconnectioninfo", command => { + const handlerUnregister = connection.getCommandHandler().registerCommandHandler("notifyserverconnectioninfo", command => { const payload = command.arguments[0]; const info = {} as any; diff --git a/shared/js/ui/frames/side/ChannelConversationController.ts b/shared/js/ui/frames/side/ChannelConversationController.ts index 58a027d4..fec8547e 100644 --- a/shared/js/ui/frames/side/ChannelConversationController.ts +++ b/shared/js/ui/frames/side/ChannelConversationController.ts @@ -1,5 +1,5 @@ import {ConnectionHandler} from "../../../ConnectionHandler"; -import {EventHandler} from "../../../events"; +import {EventHandler} from "tc-events"; import {LogCategory, logError} from "../../../log"; import {tr} from "../../../i18n/localize"; import {AbstractConversationUiEvents} from "./AbstractConversationDefinitions"; diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index a65c81f8..8a011d2e 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -447,6 +447,7 @@ class ChannelVideoController { this.events.on("query_videos", () => this.notifyVideoList()); this.events.on("query_spotlight", () => this.notifySpotlight()); this.events.on("query_subscribe_info", () => this.notifySubscribeInfo()); + this.events.on("query_viewer_count", () => this.notifyViewerCount()); this.events.on("query_video_info", event => { const controller = this.findVideoById(event.videoId); @@ -492,6 +493,14 @@ class ChannelVideoController { } }); + events.push(this.videoConnection.getLocalBroadcast("camera").getEvents().on([ "notify_clients_left", "notify_clients_joined", "notify_state_changed" ], () => { + this.notifyViewerCount(); + })); + + events.push(this.videoConnection.getLocalBroadcast("screen").getEvents().on([ "notify_clients_left", "notify_clients_joined", "notify_state_changed" ], () => { + this.notifyViewerCount(); + })); + const channelTree = this.connection.channelTree; events.push(channelTree.events.on("notify_tree_reset", () => { this.resetClientVideos(); @@ -512,6 +521,7 @@ class ChannelVideoController { this.notifyVideoList(); } } + if(event.newChannel.channelId === this.currentChannelId) { this.createClientVideo(event.client); this.notifyVideoList(); @@ -527,6 +537,7 @@ class ChannelVideoController { if(this.destroyClientVideo(event.client.clientId())) { this.notifyVideoList(); } + if(event.client instanceof LocalClientEntry) { this.resetClientVideos(); } @@ -541,6 +552,7 @@ class ChannelVideoController { this.createClientVideo(event.client); this.notifyVideoList(); } + if(event.client instanceof LocalClientEntry) { this.updateLocalChannel(event.client); } @@ -572,7 +584,7 @@ class ChannelVideoController { } private static shouldIgnoreClient(client: ClientEntry) { - return (client instanceof MusicClientEntry || client.properties.client_type_exact === ClientType.CLIENT_QUERY); + return (client instanceof MusicClientEntry || client.getClientType() === ClientType.CLIENT_QUERY); } private updateLocalChannel(localClient: ClientEntry) { @@ -658,25 +670,41 @@ class ChannelVideoController { const channel = this.connection.channelTree.findChannel(this.currentChannelId); if(channel) { - const clients = channel.channelClientsOrdered(); - for(const client of clients) { + const clients = channel.channelClientsOrdered().filter(client => { if(client instanceof LocalClientEntry) { - continue; + return false; } if(!this.clientVideos[client.clientId()]) { /* should not be possible (Is only possible for the local client) */ + return false; + } + + return true; + }); + + /* Firstly add all clients with video */ + for(const client of clients) { + const controller = this.clientVideos[client.clientId()]; + if(!controller.isBroadcasting()) { continue; } - const controller = this.clientVideos[client.clientId()]; - if(controller.isBroadcasting()) { - videoStreamingCount++; - } else if(!settings.getValue(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS)) { - continue; - } + videoStreamingCount++; videoIds.push(controller.videoId); } + + /* Secondly add all other clients (if wanted) */ + if(settings.getValue(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS)) { + for(const client of clients) { + const controller = this.clientVideos[client.clientId()]; + if(controller.isBroadcasting()) { + continue; + } + + videoIds.push(controller.videoId); + } + } } this.updateVisibility(videoStreamingCount !== 0); @@ -720,6 +748,41 @@ class ChannelVideoController { }); } + private notifyViewerCount() { + let cameraViewers, screenViewers; + { + let broadcast = this.videoConnection.getLocalBroadcast("camera"); + switch (broadcast.getState().state) { + case "initializing": + case "broadcasting": + cameraViewers = broadcast.getViewer().length; + break; + + case "stopped": + case "failed": + default: + cameraViewers = undefined; + break; + } + } + { + let broadcast = this.videoConnection.getLocalBroadcast("screen"); + switch (broadcast.getState().state) { + case "initializing": + case "broadcasting": + screenViewers = broadcast.getViewer().length; + break; + + case "stopped": + case "failed": + default: + screenViewers = undefined; + break; + } + } + this.events.fire_react("notify_viewer_count", { camera: cameraViewers, screen: screenViewers }); + } + private updateVisibility(target: boolean) { if(this.currentlyVisible === target) { return; } diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index b19b2d76..e7a0d6c7 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -87,7 +87,8 @@ export interface ChannelVideoEvents { query_video_statistics: { videoId: string, broadcastType: VideoBroadcastType }, query_spotlight: {}, query_video_stream: { videoId: string, broadcastType: VideoBroadcastType }, - query_subscribe_info: {} + query_subscribe_info: {}, + query_viewer_count: {}, notify_expended: { expended: boolean }, notify_videos: { @@ -107,10 +108,6 @@ export interface ChannelVideoEvents { videoId: string, statusIcon: ClientIcon }, - notify_video_arrows: { - left: boolean, - right: boolean - }, notify_spotlight: { videoId: string[] }, @@ -126,6 +123,10 @@ export interface ChannelVideoEvents { }, notify_subscribe_info: { info: VideoSubscribeInfo + }, + notify_viewer_count: { + camera: number | undefined, + screen: number | undefined, } } diff --git a/shared/js/ui/frames/video/Renderer.scss b/shared/js/ui/frames/video/Renderer.scss index 46d4f41c..ef7f3018 100644 --- a/shared/js/ui/frames/video/Renderer.scss +++ b/shared/js/ui/frames/video/Renderer.scss @@ -124,7 +124,9 @@ $small_height: 10em; position: relative; height: $small_height; - width: 100%; + + max-width: 100%; + min-width: 16em; flex-shrink: 0; flex-grow: 0; @@ -136,9 +138,6 @@ $small_height: 10em; margin-left: .5em; margin-right: .5em; - /* TODO: Min with of two video +4em for one of the arrows */ - min-width: 6em; - .videos { display: flex; flex-direction: row; @@ -244,6 +243,7 @@ $small_height: 10em; } } +/* FIXME: Unify the overlays (Bottom left, Bottom right, and Top right) */ .videoContainer, :global(.react-grid-item.react-grid-placeholder) { /* Note: don't use margin here since it might */ position: relative; @@ -401,13 +401,65 @@ $small_height: 10em; font-weight: normal!important; @include text-dotdotdot(); + } - &.local { + &.local { + .name { color: #147114; } } } + .videoViewerCount { + position: absolute; + + top: 0; + right: 0; + + display: flex; + flex-direction: row; + + border-bottom-left-radius: .2em; + background-color: #35353580; + + padding: .1em .3em; + cursor: pointer; + + max-width: 70%; + + .entry { + flex-shrink: 0; + align-self: center; + + color: #999; + + display: flex; + flex-direction: row; + justify-content: flex-start; + + &:not(:last-of-type) { + margin-right: .75em; + } + + .value { + margin-right: .25em; + } + } + + .icon { + flex-shrink: 0; + align-self: center; + } + + .name { + align-self: center; + margin-left: .25em; + font-weight: normal!important; + + @include text-dotdotdot(); + } + } + .actionIcons { position: absolute; diff --git a/shared/js/ui/frames/video/Renderer.tsx b/shared/js/ui/frames/video/Renderer.tsx index d5f5052b..d645423e 100644 --- a/shared/js/ui/frames/video/Renderer.tsx +++ b/shared/js/ui/frames/video/Renderer.tsx @@ -4,30 +4,30 @@ import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons"; import {ClientIcon} from "svg-sprites/client-icons"; import {Registry} from "tc-shared/events"; import { - ChannelVideoEvents, - ChannelVideoInfo, + ChannelVideoEvents, ChannelVideoInfo, ChannelVideoStreamState, - kLocalVideoId, - makeVideoAutoplay, + kLocalVideoId, makeVideoAutoplay, VideoStreamState, VideoSubscribeInfo } from "./Definitions"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots"; -import {ClientTag} from "tc-shared/ui/tree/EntryTags"; import ResizeObserver from "resize-observer-polyfill"; import {LogCategory, logTrace, logWarn} from "tc-shared/log"; import {spawnContextMenu} from "tc-shared/ui/ContextMenu"; import {VideoBroadcastType} from "tc-shared/connection/VideoConnection"; import {ErrorBoundary} from "tc-shared/ui/react-elements/ErrorBoundary"; -import {useTr} from "tc-shared/ui/react-elements/Helper"; +import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper"; import {Spotlight, SpotlightDimensions, SpotlightDimensionsContext} from "./RendererSpotlight"; import * as _ from "lodash"; +import {ClientTag} from "tc-shared/ui/tree/EntryTags"; +import {tra} from "tc-shared/i18n/localize"; const SubscribeContext = React.createContext(undefined); const EventContext = React.createContext>(undefined); const HandlerIdContext = React.createContext(undefined); +export const VideoIdContext = React.createContext(undefined); export const RendererVideoEventContext = EventContext; @@ -47,16 +47,64 @@ const ExpendArrow = React.memo(() => {
events.fire("action_toggle_expended", { expended: !expended })}>
+ ); +}); + +const VideoViewerCount = React.memo(() => { + const videoId = useContext(VideoIdContext); + const events = useContext(EventContext); + if(videoId !== kLocalVideoId) { + /* Currently one we can see our own video viewer */ + return null; + } + + const [ viewer, setViewer ] = useState<{ camera: number | undefined, screen: number | undefined }>(() => { + events.fire("query_viewer_count"); + return { screen: undefined, camera: undefined }; + }); + + events.reactUse("notify_viewer_count", event => setViewer({ camera: event.camera, screen: event.screen })); + + let info = []; + if(typeof viewer.camera === "number") { + info.push( +
+
{viewer.camera}
+ +
+ ); + } + + if(typeof viewer.screen === "number") { + info.push( +
+
{viewer.screen}
+ +
+ ); + } + + if(info.length === 0) { + /* We're not streaming any video */ + return null; + } + + return ( +
{ + /* TODO! */ + }} + > + {info} +
) }); -const VideoInfo = React.memo((props: { videoId: string }) => { +const VideoClientInfo = React.memo((props: { videoId: string }) => { const events = useContext(EventContext); const handlerId = useContext(HandlerIdContext); - const localVideo = props.videoId === kLocalVideoId; - const nameClassList = cssStyle.name + " " + (localVideo ? cssStyle.local : ""); - const [ info, setInfo ] = useState<"loading" | ChannelVideoInfo>(() => { events.fire("query_video_info", { videoId: props.videoId }); return "loading"; @@ -79,59 +127,23 @@ const VideoInfo = React.memo((props: { videoId: string }) => { let clientName; if(info === "loading") { - clientName =
loading {props.videoId}
; + clientName = ( +
+ loading {props.videoId} +
+ ); } else { - clientName = ; + clientName = ; } return ( -
+
{clientName}
); }); -const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined, className: string, streamType: VideoBroadcastType }) => { - const refVideo = useRef(); - - useEffect(() => { - let cancelAutoplay; - const video = refVideo.current; - if(props.stream) { - video.style.opacity = "1"; - video.srcObject = props.stream; - video.muted = true; - cancelAutoplay = makeVideoAutoplay(video); - } else { - video.style.opacity = "0"; - } - - return () => { - const video = refVideo.current; - if(video) { - video.onpause = undefined; - video.onended = undefined; - } - - if(cancelAutoplay) { - cancelAutoplay(); - } - } - }, [ props.stream ]); - - let title; - if(props.streamType === "camera") { - title = useTr("Camera"); - } else { - title = useTr("Screen"); - } - - return ( -