Added a connection status indicator and broke Firefox audio playback (not sure why).
parent
10ed00dd19
commit
ae83459c30
|
@ -31,15 +31,7 @@
|
||||||
<div class="container-seperator horizontal" seperator-id="seperator-main-log"></div>
|
<div class="container-seperator horizontal" seperator-id="seperator-main-log"></div>
|
||||||
<div class="container-bottom">
|
<div class="container-bottom">
|
||||||
<div class="container-server-log" id="server-log"></div>
|
<div class="container-server-log" id="server-log"></div>
|
||||||
<div class="container-footer">
|
<div class="container-footer" id="container-footer">
|
||||||
<span>
|
|
||||||
<a>{{tr "Version:" /}} {{>app_version}}</a>
|
|
||||||
<div class="hide-small">
|
|
||||||
(Open source on
|
|
||||||
<a target="_blank" href="https://github.com/TeaSpeak/TeaSpeak-Web"
|
|
||||||
style="display: inline-block; position: relative">github.com</a>)
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- Selection info -->
|
</div> <!-- Selection info -->
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,6 +2,9 @@ import {ConnectionHandler, DisconnectReason} from "./ConnectionHandler";
|
||||||
import {Registry} from "./events";
|
import {Registry} from "./events";
|
||||||
import * as loader from "tc-loader";
|
import * as loader from "tc-loader";
|
||||||
import {Stage} from "tc-loader";
|
import {Stage} from "tc-loader";
|
||||||
|
import {FooterRenderer} from "tc-shared/ui/frames/footer/Renderer";
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ReactDOM from "react-dom";
|
||||||
|
|
||||||
export let server_connections: ConnectionManager;
|
export let server_connections: ConnectionManager;
|
||||||
|
|
||||||
|
@ -31,6 +34,7 @@ export class ConnectionManager {
|
||||||
private _container_hostbanner: JQuery;
|
private _container_hostbanner: JQuery;
|
||||||
private _container_chat: JQuery;
|
private _container_chat: JQuery;
|
||||||
private containerChannelVideo: ReplaceableContainer;
|
private containerChannelVideo: ReplaceableContainer;
|
||||||
|
private containerFooter: HTMLDivElement;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.event_registry = new Registry<ConnectionManagerEvents>();
|
this.event_registry = new Registry<ConnectionManagerEvents>();
|
||||||
|
@ -41,10 +45,15 @@ export class ConnectionManager {
|
||||||
this._container_channel_tree = $("#channelTree");
|
this._container_channel_tree = $("#channelTree");
|
||||||
this._container_hostbanner = $("#hostbanner");
|
this._container_hostbanner = $("#hostbanner");
|
||||||
this._container_chat = $("#chat");
|
this._container_chat = $("#chat");
|
||||||
|
this.containerFooter = document.getElementById("container-footer") as HTMLDivElement;
|
||||||
|
|
||||||
this.set_active_connection(undefined);
|
this.set_active_connection(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeFooter() {
|
||||||
|
ReactDOM.render(React.createElement(FooterRenderer), this.containerFooter);
|
||||||
|
}
|
||||||
|
|
||||||
events() : Registry<ConnectionManagerEvents> {
|
events() : Registry<ConnectionManagerEvents> {
|
||||||
return this.event_registry;
|
return this.event_registry;
|
||||||
}
|
}
|
||||||
|
@ -169,6 +178,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
name: "server manager init",
|
name: "server manager init",
|
||||||
function: async () => {
|
function: async () => {
|
||||||
server_connections = new ConnectionManager();
|
server_connections = new ConnectionManager();
|
||||||
|
server_connections.initializeFooter();
|
||||||
},
|
},
|
||||||
priority: 80
|
priority: 80
|
||||||
});
|
});
|
||||||
|
|
|
@ -28,6 +28,8 @@ export interface ServerConnectionEvents {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionStateListener = (old_state: ConnectionState, new_state: ConnectionState) => any;
|
export type ConnectionStateListener = (old_state: ConnectionState, new_state: ConnectionState) => any;
|
||||||
|
export type ConnectionStatistics = { bytesReceived: number, bytesSend: number };
|
||||||
|
|
||||||
export abstract class AbstractServerConnection {
|
export abstract class AbstractServerConnection {
|
||||||
readonly events: Registry<ServerConnectionEvents>;
|
readonly events: Registry<ServerConnectionEvents>;
|
||||||
|
|
||||||
|
@ -58,6 +60,7 @@ export abstract class AbstractServerConnection {
|
||||||
connectionProxyAddress() : ServerAddress | undefined { return undefined; };
|
connectionProxyAddress() : ServerAddress | undefined { return undefined; };
|
||||||
|
|
||||||
abstract handshake_handler() : HandshakeHandler; /* only valid when connected */
|
abstract handshake_handler() : HandshakeHandler; /* only valid when connected */
|
||||||
|
abstract getControlStatistics() : ConnectionStatistics;
|
||||||
|
|
||||||
//FIXME: Remove this this is currently only some kind of hack
|
//FIXME: Remove this this is currently only some kind of hack
|
||||||
updateConnectionState(state: ConnectionState) {
|
updateConnectionState(state: ConnectionState) {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import {VideoSource} from "tc-shared/video/VideoSource";
|
import {VideoSource} from "tc-shared/video/VideoSource";
|
||||||
import {Registry} from "tc-shared/events";
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {ConnectionStatus} from "tc-shared/ui/frames/footer/StatusDefinitions";
|
||||||
|
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
||||||
|
|
||||||
export type VideoBroadcastType = "camera" | "screen";
|
export type VideoBroadcastType = "camera" | "screen";
|
||||||
|
|
||||||
|
@ -43,6 +45,7 @@ export interface VideoConnection {
|
||||||
getEvents() : Registry<VideoConnectionEvent>;
|
getEvents() : Registry<VideoConnectionEvent>;
|
||||||
|
|
||||||
getStatus() : VideoConnectionStatus;
|
getStatus() : VideoConnectionStatus;
|
||||||
|
getConnectionStats() : Promise<ConnectionStatistics>;
|
||||||
|
|
||||||
isBroadcasting(type: VideoBroadcastType);
|
isBroadcasting(type: VideoBroadcastType);
|
||||||
getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined;
|
getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {RecorderProfile} from "../voice/RecorderProfile";
|
import {RecorderProfile} from "../voice/RecorderProfile";
|
||||||
import {AbstractServerConnection} from "../connection/ConnectionBase";
|
import {AbstractServerConnection, ConnectionStatistics} from "../connection/ConnectionBase";
|
||||||
import {Registry} from "../events";
|
import {Registry} from "../events";
|
||||||
import {VoiceClient} from "../voice/VoiceClient";
|
import {VoiceClient} from "../voice/VoiceClient";
|
||||||
import {WhisperSession, WhisperTarget} from "../voice/VoiceWhisper";
|
import {WhisperSession, WhisperTarget} from "../voice/VoiceWhisper";
|
||||||
|
@ -62,6 +62,7 @@ export abstract class AbstractVoiceConnection {
|
||||||
|
|
||||||
abstract getConnectionState() : VoiceConnectionStatus;
|
abstract getConnectionState() : VoiceConnectionStatus;
|
||||||
abstract getFailedMessage() : string;
|
abstract getFailedMessage() : string;
|
||||||
|
abstract getConnectionStats() : Promise<ConnectionStatistics>;
|
||||||
|
|
||||||
abstract encodingSupported(codec: number) : boolean;
|
abstract encodingSupported(codec: number) : boolean;
|
||||||
abstract decodingSupported(codec: number) : boolean;
|
abstract decodingSupported(codec: number) : boolean;
|
||||||
|
|
|
@ -159,6 +159,27 @@ export namespace network {
|
||||||
export const GB = 1024 * MB;
|
export const GB = 1024 * MB;
|
||||||
export const TB = 1024 * GB;
|
export const TB = 1024 * GB;
|
||||||
|
|
||||||
|
export function byteSizeToString(value: number) {
|
||||||
|
let v: number, unit;
|
||||||
|
if(value > 5 * TB) {
|
||||||
|
unit = "tb";
|
||||||
|
v = value / TB;
|
||||||
|
} else if(value > 5 * GB) {
|
||||||
|
unit = "gb";
|
||||||
|
v = value / GB;
|
||||||
|
} else if(value > 5 * MB) {
|
||||||
|
unit = "mb";
|
||||||
|
v = value / MB;
|
||||||
|
} else if(value > 5 * KB) {
|
||||||
|
unit = "kb";
|
||||||
|
v = value / KB;
|
||||||
|
} else {
|
||||||
|
return value + "b";
|
||||||
|
}
|
||||||
|
|
||||||
|
return v.toFixed(2) + unit;
|
||||||
|
}
|
||||||
|
|
||||||
export function format_bytes(value: number, options?: {
|
export function format_bytes(value: number, options?: {
|
||||||
time?: string,
|
time?: string,
|
||||||
unit?: string,
|
unit?: string,
|
||||||
|
|
|
@ -0,0 +1,127 @@
|
||||||
|
@import "../../../../css/static/mixin";
|
||||||
|
@import "../../../../css/static/properties";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
@include user-select(none);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.version {
|
||||||
|
margin-right: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rtcStatus {
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: right;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
min-width: 7.5em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-left: .5em;
|
||||||
|
|
||||||
|
&.healthy {
|
||||||
|
color: #247524;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.unhealthy {
|
||||||
|
color: #a63030;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.connecting {
|
||||||
|
color: #406d96;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disconnected {}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rtcStatusDetail {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
bottom: 100%;
|
||||||
|
right: -.4em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
width: 14em;
|
||||||
|
padding-bottom: .5em;
|
||||||
|
|
||||||
|
border-radius: .1em;
|
||||||
|
|
||||||
|
background: #19191b;
|
||||||
|
box-shadow: 0 8px 16px rgba(0,0,0,0.24);
|
||||||
|
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
@include transition(all $button_hover_animation_time ease-in-out);
|
||||||
|
|
||||||
|
&.shown {
|
||||||
|
pointer-events: all;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: .25em;
|
||||||
|
background-color: #222224;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
padding-left: .5em;
|
||||||
|
padding-right: .5em;
|
||||||
|
|
||||||
|
height: 1.4em;
|
||||||
|
|
||||||
|
.key { }
|
||||||
|
.value { }
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: .8em;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.title {
|
||||||
|
margin-top: .5em;
|
||||||
|
|
||||||
|
.key {
|
||||||
|
color: #557edc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.doubleSize {
|
||||||
|
height: 2.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
|
import {useEffect, useMemo, useState} from "react";
|
||||||
|
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
|
import {server_connections} from "tc-shared/ConnectionManager";
|
||||||
|
import {StatusController} from "tc-shared/ui/frames/footer/StatusController";
|
||||||
|
import {ConnectionStatusEvents} from "tc-shared/ui/frames/footer/StatusDefinitions";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {StatusDetailRenderer, StatusEvents, StatusTextRenderer} from "tc-shared/ui/frames/footer/StatusRenderer";
|
||||||
|
|
||||||
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
|
||||||
|
const VersionsRenderer = () => (
|
||||||
|
<React.Fragment>
|
||||||
|
<a className={cssStyle.version} key={"version"}>
|
||||||
|
<Translatable>Version:</Translatable> {__build.version}
|
||||||
|
</a>
|
||||||
|
<div className={cssStyle.source} key={"link"}>
|
||||||
|
<VariadicTranslatable text={"(Open source on {})"}>
|
||||||
|
<a target="_blank" href="https://github.com/TeaSpeak/TeaSpeak-Web">github.com</a>
|
||||||
|
</VariadicTranslatable>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RtcStatus = () => {
|
||||||
|
const statusController = useMemo(() => new StatusController(new Registry<ConnectionStatusEvents>()), []);
|
||||||
|
statusController.setConnectionHandler(server_connections.active_connection());
|
||||||
|
|
||||||
|
server_connections.events().reactUse("notify_active_handler_changed", event => {
|
||||||
|
statusController.setConnectionHandler(event.newHandler);
|
||||||
|
}, undefined, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusEvents.Provider value={statusController.getEvents()}>
|
||||||
|
<StatusTextRenderer />
|
||||||
|
<StatusDetailRenderer />
|
||||||
|
</StatusEvents.Provider>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const FooterRenderer = () => {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.container}>
|
||||||
|
<VersionsRenderer />
|
||||||
|
<RtcStatus />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,223 @@
|
||||||
|
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
|
||||||
|
import {
|
||||||
|
ConnectionComponent,
|
||||||
|
ConnectionStatus,
|
||||||
|
ConnectionStatusEvents
|
||||||
|
} from "./StatusDefinitions";
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {VoiceConnectionStatus} from "tc-shared/connection/VoiceConnection";
|
||||||
|
import {VideoConnectionStatus} from "tc-shared/connection/VideoConnection";
|
||||||
|
|
||||||
|
export class StatusController {
|
||||||
|
private readonly events: Registry<ConnectionStatusEvents>;
|
||||||
|
|
||||||
|
private currentConnectionHandler: ConnectionHandler;
|
||||||
|
private listenerHandler: (() => void)[];
|
||||||
|
|
||||||
|
private detailedInfoOpen = false;
|
||||||
|
private detailUpdateTimer: number;
|
||||||
|
|
||||||
|
constructor(events: Registry<ConnectionStatusEvents>) {
|
||||||
|
this.events = events;
|
||||||
|
|
||||||
|
this.events.on("query_component_detail_state", () => this.events.fire_react("notify_component_detail_state", { shown: this.detailedInfoOpen }));
|
||||||
|
this.events.on("query_component_status", event => this.notifyComponentStatus(event.component));
|
||||||
|
this.events.on("query_connection_status", () => this.notifyConnectionStatus());
|
||||||
|
|
||||||
|
this.events.on("action_toggle_component_detail", event => this.setDetailsShown(typeof event.shown === "boolean" ? event.shown : !this.detailedInfoOpen));
|
||||||
|
}
|
||||||
|
|
||||||
|
getEvents() : Registry<ConnectionStatusEvents> {
|
||||||
|
return this.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionHandler(handler: ConnectionHandler | undefined) {
|
||||||
|
if(this.currentConnectionHandler === handler) { return; }
|
||||||
|
|
||||||
|
this.unregisterHandlerEvents();
|
||||||
|
this.currentConnectionHandler = handler;
|
||||||
|
this.registerHandlerEvents(handler);
|
||||||
|
|
||||||
|
this.notifyConnectionStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetailsShown(flag: boolean) {
|
||||||
|
if(this.detailedInfoOpen === flag) { return; }
|
||||||
|
|
||||||
|
this.detailedInfoOpen = flag;
|
||||||
|
this.events.fire_react("notify_component_detail_state", { shown: this.detailedInfoOpen });
|
||||||
|
|
||||||
|
if(this.detailedInfoOpen) {
|
||||||
|
this.detailUpdateTimer = setInterval(() => {
|
||||||
|
this.notifyComponentStatus("video");
|
||||||
|
this.notifyComponentStatus("signaling");
|
||||||
|
this.notifyComponentStatus("voice");
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
clearInterval(this.detailUpdateTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlerEvents(handler: ConnectionHandler) {
|
||||||
|
this.unregisterHandlerEvents();
|
||||||
|
|
||||||
|
const events = this.listenerHandler = [];
|
||||||
|
events.push(handler.events().on("notify_connection_state_changed", () => {
|
||||||
|
this.notifyConnectionStatus();
|
||||||
|
if(this.detailedInfoOpen) {
|
||||||
|
this.notifyComponentStatus("signaling");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(handler.getServerConnection().getVoiceConnection().events.on("notify_connection_status_changed", () => {
|
||||||
|
this.notifyConnectionStatus();
|
||||||
|
if(this.detailedInfoOpen) {
|
||||||
|
this.notifyComponentStatus("voice");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
events.push(handler.getServerConnection().getVideoConnection().getEvents().on("notify_status_changed", () => {
|
||||||
|
this.notifyConnectionStatus();
|
||||||
|
if(this.detailedInfoOpen) {
|
||||||
|
this.notifyComponentStatus("video");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private unregisterHandlerEvents() {
|
||||||
|
this.listenerHandler?.forEach(callback => callback());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getComponentStatus(component: ConnectionComponent, detailed: boolean) : Promise<ConnectionStatus> {
|
||||||
|
if(!this.currentConnectionHandler) {
|
||||||
|
return { type: "disconnected" };
|
||||||
|
}
|
||||||
|
switch (component) {
|
||||||
|
case "video":
|
||||||
|
const videoConnection = this.currentConnectionHandler.getServerConnection().getVideoConnection();
|
||||||
|
switch (videoConnection.getStatus()) {
|
||||||
|
case VideoConnectionStatus.Failed:
|
||||||
|
/* FIXME: Reason! */
|
||||||
|
return { type: "unhealthy", reason: tr("Unknown") };
|
||||||
|
case VideoConnectionStatus.Connected:
|
||||||
|
if(detailed) {
|
||||||
|
const statistics = await videoConnection.getConnectionStats();
|
||||||
|
return {
|
||||||
|
type: "healthy",
|
||||||
|
bytesSend: statistics.bytesSend,
|
||||||
|
bytesReceived: statistics.bytesReceived
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: "healthy"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case VideoConnectionStatus.Disconnected:
|
||||||
|
return {
|
||||||
|
type: "disconnected"
|
||||||
|
};
|
||||||
|
|
||||||
|
case VideoConnectionStatus.Connecting:
|
||||||
|
return { type: "connecting-video" };
|
||||||
|
|
||||||
|
case VideoConnectionStatus.Unsupported:
|
||||||
|
return { type: "unsupported", side: "server" };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "voice":
|
||||||
|
const voiceConnection = this.currentConnectionHandler.getServerConnection().getVoiceConnection();
|
||||||
|
switch (voiceConnection.getConnectionState()) {
|
||||||
|
case VoiceConnectionStatus.Failed:
|
||||||
|
return { type: "unhealthy", reason: voiceConnection.getFailedMessage() };
|
||||||
|
|
||||||
|
case VoiceConnectionStatus.Connected:
|
||||||
|
if(detailed) {
|
||||||
|
const statistics = await voiceConnection.getConnectionStats();
|
||||||
|
return {
|
||||||
|
type: "healthy",
|
||||||
|
bytesSend: statistics.bytesSend,
|
||||||
|
bytesReceived: statistics.bytesReceived
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: "healthy"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case VoiceConnectionStatus.Disconnecting:
|
||||||
|
case VoiceConnectionStatus.Disconnected:
|
||||||
|
return { type: "disconnected" };
|
||||||
|
|
||||||
|
case VoiceConnectionStatus.Connecting:
|
||||||
|
return { type: "connecting-voice" };
|
||||||
|
|
||||||
|
case VoiceConnectionStatus.ServerUnsupported:
|
||||||
|
return { type: "unsupported", side: "server" };
|
||||||
|
|
||||||
|
case VoiceConnectionStatus.ClientUnsupported:
|
||||||
|
return { type: "unsupported", side: "client" };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "signaling":
|
||||||
|
switch (this.currentConnectionHandler.connection_state) {
|
||||||
|
case ConnectionState.INITIALISING:
|
||||||
|
return { type: "connecting-signalling", state: "initializing" };
|
||||||
|
case ConnectionState.CONNECTING:
|
||||||
|
return { type: "connecting-signalling", state: "connecting" };
|
||||||
|
case ConnectionState.AUTHENTICATING:
|
||||||
|
return { type: "connecting-signalling", state: "authentication" };
|
||||||
|
case ConnectionState.CONNECTED:
|
||||||
|
if(detailed) {
|
||||||
|
const statistics = this.currentConnectionHandler.getServerConnection().getControlStatistics();
|
||||||
|
return {
|
||||||
|
type: "healthy",
|
||||||
|
bytesSend: statistics.bytesSend,
|
||||||
|
bytesReceived: statistics.bytesReceived
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: "healthy"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case ConnectionState.UNCONNECTED:
|
||||||
|
case ConnectionState.DISCONNECTING:
|
||||||
|
return { type: "disconnected" };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async notifyComponentStatus(component: ConnectionComponent) {
|
||||||
|
this.events.fire_react("notify_component_status", { component: component, status: await this.getComponentStatus(component, true) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyConnectionStatus() {
|
||||||
|
let status: ConnectionStatus = { type: "healthy" };
|
||||||
|
|
||||||
|
for(const component of ["signaling", "voice", "video"] as ConnectionComponent[]) {
|
||||||
|
let componentState = await this.getComponentStatus(component, false);
|
||||||
|
if(componentState.type === "healthy" || componentState.type === "unsupported") {
|
||||||
|
continue;
|
||||||
|
} else if(componentState.type === "disconnected" && component !== "signaling") {
|
||||||
|
switch (component) {
|
||||||
|
case "voice":
|
||||||
|
componentState = { type: "unhealthy", reason: tr("No voice connection") };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
componentState = { type: "unhealthy", reason: tr("No video connection") };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status = componentState;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.events.fire_react("notify_connection_status", { status: status });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
export type ConnectionStatus = {
|
||||||
|
type: "healthy",
|
||||||
|
/* only for component stati */
|
||||||
|
bytesReceived?: number,
|
||||||
|
bytesSend?: number
|
||||||
|
} | {
|
||||||
|
type: "unhealthy",
|
||||||
|
reason: string,
|
||||||
|
/* try reconnect attribute */
|
||||||
|
} | {
|
||||||
|
type: "connecting-signalling",
|
||||||
|
state: "initializing" | "connecting" | "authentication"
|
||||||
|
} | {
|
||||||
|
type: "connecting-voice"
|
||||||
|
} | {
|
||||||
|
type: "connecting-video"
|
||||||
|
} | {
|
||||||
|
type: "disconnected"
|
||||||
|
} | {
|
||||||
|
type: "unsupported",
|
||||||
|
side: "server" | "client"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionComponent = "signaling" | "video" | "voice";
|
||||||
|
|
||||||
|
export interface ConnectionStatusEvents {
|
||||||
|
action_toggle_component_detail: { shown: boolean | undefined },
|
||||||
|
|
||||||
|
query_component_detail_state: {},
|
||||||
|
query_component_status: { component: ConnectionComponent },
|
||||||
|
query_connection_status: {},
|
||||||
|
|
||||||
|
notify_component_detail_state: {
|
||||||
|
shown: boolean
|
||||||
|
},
|
||||||
|
notify_component_status: {
|
||||||
|
component: ConnectionComponent,
|
||||||
|
status: ConnectionStatus
|
||||||
|
},
|
||||||
|
notify_connection_status: {
|
||||||
|
status: ConnectionStatus,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,229 @@
|
||||||
|
import {Registry} from "tc-shared/events";
|
||||||
|
import {
|
||||||
|
ConnectionComponent,
|
||||||
|
ConnectionStatus,
|
||||||
|
ConnectionStatusEvents
|
||||||
|
} from "tc-shared/ui/frames/footer/StatusDefinitions";
|
||||||
|
import * as React from "react";
|
||||||
|
import {useContext, useEffect, useRef, useState} from "react";
|
||||||
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
|
import {network} from "tc-shared/ui/frames/chat";
|
||||||
|
|
||||||
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
export const StatusEvents = React.createContext<Registry<ConnectionStatusEvents>>(undefined);
|
||||||
|
|
||||||
|
const ConnectionStateRenderer = React.memo((props: { state: ConnectionStatus, isGeneral: boolean, onClick?: () => void }) => {
|
||||||
|
let statusClass;
|
||||||
|
let statusBody;
|
||||||
|
let title;
|
||||||
|
switch (props.state.type) {
|
||||||
|
case "disconnected":
|
||||||
|
title = tr("Not connected");
|
||||||
|
statusClass = cssStyle.disconnected;
|
||||||
|
statusBody = <Translatable key={"disconnected"}>Disconnected</Translatable>;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "connecting-signalling":
|
||||||
|
statusClass = cssStyle.connecting;
|
||||||
|
|
||||||
|
switch (props.state.state) {
|
||||||
|
case "initializing":
|
||||||
|
title = tr("Initializing connection");
|
||||||
|
statusBody = <Translatable key={"connecting-signalling-initializing"}>Initializing</Translatable>;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "connecting":
|
||||||
|
title = tr("Connecting to target");
|
||||||
|
statusBody = <Translatable key={"connecting-signalling-connecting"}>Connecting</Translatable>;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "authentication":
|
||||||
|
title = tr("Authenticating");
|
||||||
|
statusBody = <Translatable key={"connecting-signalling-authenticating"}>Authenticating</Translatable>;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "connecting-voice":
|
||||||
|
title = tr("Establishing audio connection");
|
||||||
|
statusClass = cssStyle.connecting;
|
||||||
|
if(props.isGeneral) {
|
||||||
|
statusBody = <Translatable key={"connecting-voice-general"}>Audio connecting</Translatable>;
|
||||||
|
} else {
|
||||||
|
statusBody = <Translatable key={"connecting-voice-general"}>Connecting</Translatable>;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "connecting-video":
|
||||||
|
title = tr("Establishing video connection");
|
||||||
|
statusClass = cssStyle.connecting;
|
||||||
|
if(props.isGeneral) {
|
||||||
|
statusBody = <Translatable key={"connecting-video-general"}>Video connecting</Translatable>;
|
||||||
|
} else {
|
||||||
|
statusBody = <Translatable key={"connecting-video-general"}>Connecting</Translatable>;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unhealthy":
|
||||||
|
title = props.state.reason;
|
||||||
|
statusClass = cssStyle.unhealthy;
|
||||||
|
statusBody = <Translatable key={"unhealthy"}>Unhealthy</Translatable>;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "healthy":
|
||||||
|
title = tr("Connection is healthy");
|
||||||
|
statusClass = cssStyle.healthy;
|
||||||
|
statusBody = <Translatable key={"healthy"}>Healthy</Translatable>;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
statusClass = cssStyle.unhealthy;
|
||||||
|
statusBody = <Translatable key={"invalid-state"}>Invalid state</Translatable>;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cssStyle.status + " " + statusClass} title={title} onClick={props.onClick}>{statusBody}</div>;
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StatusTextRenderer = React.memo(() => {
|
||||||
|
const events = useContext(StatusEvents);
|
||||||
|
|
||||||
|
const [ status, setStatus ] = useState<ConnectionStatus>(() => {
|
||||||
|
events.fire("query_connection_status");
|
||||||
|
return { type: "disconnected" };
|
||||||
|
});
|
||||||
|
events.reactUse("notify_connection_status", event => setStatus(event.status), undefined, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.rtcStatus}>
|
||||||
|
<div><Translatable>Connection status:</Translatable></div>
|
||||||
|
<ConnectionStateRenderer
|
||||||
|
state={status}
|
||||||
|
isGeneral={true}
|
||||||
|
onClick={() => events.fire("action_toggle_component_detail")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ComponentStatusRenderer = React.memo((props: { component: ConnectionComponent }) => {
|
||||||
|
const events = useContext(StatusEvents);
|
||||||
|
const [ status, setStatus ] = useState<ConnectionStatus>(() => {
|
||||||
|
events.fire("query_component_status", { component: props.component });
|
||||||
|
return { type: "disconnected" };
|
||||||
|
});
|
||||||
|
events.reactUse("notify_component_status", event => event.component === props.component && setStatus(event.status),
|
||||||
|
undefined, []);
|
||||||
|
|
||||||
|
let title;
|
||||||
|
switch (props.component) {
|
||||||
|
case "signaling":
|
||||||
|
title = <Translatable key={"control"}>Control</Translatable>;
|
||||||
|
break;
|
||||||
|
case "video":
|
||||||
|
title = <Translatable key={"video"}>Video</Translatable>;
|
||||||
|
break;
|
||||||
|
case "voice":
|
||||||
|
title = <Translatable key={"voice"}>Voice</Translatable>;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body;
|
||||||
|
switch (status.type) {
|
||||||
|
case "healthy":
|
||||||
|
body = (
|
||||||
|
<React.Fragment key={"healthy"}>
|
||||||
|
<div className={cssStyle.row}>
|
||||||
|
<div className={cssStyle.key}><Translatable>Incoming:</Translatable></div>
|
||||||
|
<div className={cssStyle.value}>{network.byteSizeToString(status.bytesReceived)}</div>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.row}>
|
||||||
|
<div className={cssStyle.key}><Translatable>Outgoing:</Translatable></div>
|
||||||
|
<div className={cssStyle.value}>{network.byteSizeToString(status.bytesSend)}</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "connecting-signalling":
|
||||||
|
case "connecting-voice":
|
||||||
|
case "connecting-video":
|
||||||
|
case "disconnected":
|
||||||
|
case "unsupported":
|
||||||
|
let text;
|
||||||
|
switch (props.component) {
|
||||||
|
case "signaling":
|
||||||
|
text = <Translatable key={"signalling"}>The control component is the main server connection. All actions are transceived by this connection.</Translatable>;
|
||||||
|
break;
|
||||||
|
case "video":
|
||||||
|
text = <Translatable key={"video"}>The video component transmits and receives video data. It's used to transmit camara and screen data.</Translatable>;
|
||||||
|
break;
|
||||||
|
case "voice":
|
||||||
|
text = <Translatable key={"voice"}>The voice component transmits and receives audio data.</Translatable>;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
body = (
|
||||||
|
<div className={cssStyle.row + " " + cssStyle.doubleSize} key={"description"}>
|
||||||
|
<div className={cssStyle.text}>{text}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unhealthy":
|
||||||
|
body = (
|
||||||
|
<div className={cssStyle.row + " " + cssStyle.doubleSize} key={"error"}>
|
||||||
|
<div className={cssStyle.text}><Translatable>Some error occured</Translatable></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<div className={cssStyle.row + " " + cssStyle.title}>
|
||||||
|
<div className={cssStyle.key}>{title}</div>
|
||||||
|
<div className={cssStyle.value}>
|
||||||
|
<ConnectionStateRenderer state={status} isGeneral={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{body}
|
||||||
|
</>);
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StatusDetailRenderer = () => {
|
||||||
|
const events = useContext(StatusEvents);
|
||||||
|
|
||||||
|
const refContainer = useRef<HTMLDivElement>();
|
||||||
|
const refShowId = useRef(0);
|
||||||
|
|
||||||
|
const [ shown, setShown ] = useState(() => {
|
||||||
|
events.fire("query_component_detail_state");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
events.reactUse("notify_component_detail_state", event => {
|
||||||
|
if(event.shown) {
|
||||||
|
refShowId.current++;
|
||||||
|
}
|
||||||
|
setShown(event.shown);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if(!shown) { return; }
|
||||||
|
|
||||||
|
const listener = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLDivElement;
|
||||||
|
if(!refContainer.current?.contains(target)) {
|
||||||
|
events.fire("action_toggle_component_detail", { shown: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("click", listener);
|
||||||
|
return () => document.removeEventListener("click", listener);
|
||||||
|
}, [shown]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.rtcStatusDetail + " " + (shown ? cssStyle.shown : "")} ref={refContainer}>
|
||||||
|
<div className={cssStyle.header}><Translatable>Connection Details</Translatable></div>
|
||||||
|
<ComponentStatusRenderer component={"signaling"} key={"rev-0-" + refShowId.current} />
|
||||||
|
<ComponentStatusRenderer component={"voice"} key={"rev-1-" + refShowId.current} />
|
||||||
|
<ComponentStatusRenderer component={"video"} key={"rev-2-" + refShowId.current} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
AbstractServerConnection,
|
AbstractServerConnection,
|
||||||
CommandOptionDefaults,
|
CommandOptionDefaults,
|
||||||
CommandOptions,
|
CommandOptions, ConnectionStatistics,
|
||||||
ConnectionStateListener,
|
ConnectionStateListener,
|
||||||
} from "tc-shared/connection/ConnectionBase";
|
} from "tc-shared/connection/ConnectionBase";
|
||||||
import {ConnectionHandler, ConnectionState, DisconnectReason} from "tc-shared/ConnectionHandler";
|
import {ConnectionHandler, ConnectionState, DisconnectReason} from "tc-shared/ConnectionHandler";
|
||||||
|
@ -383,7 +383,7 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.socket.send(data);
|
this.socket.sendMessage(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static commandDataToJson(input: any) : string {
|
private static commandDataToJson(input: any) : string {
|
||||||
|
@ -495,4 +495,8 @@ export class ServerConnection extends AbstractServerConnection {
|
||||||
native: this.pingStatistics.currentNativeValue
|
native: this.pingStatistics.currentNativeValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getControlStatistics(): ConnectionStatistics {
|
||||||
|
return this.socket?.getControlStatistics() || { bytesSend: 0, bytesReceived: 0 };
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import * as log from "tc-shared/log";
|
import * as log from "tc-shared/log";
|
||||||
import {LogCategory} from "tc-shared/log";
|
import {LogCategory} from "tc-shared/log";
|
||||||
|
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
||||||
|
|
||||||
const kPreventOpeningWebSocketClosing = false;
|
const kPreventOpeningWebSocketClosing = false;
|
||||||
|
|
||||||
|
@ -13,9 +14,10 @@ export type WebSocketUrl = {
|
||||||
};
|
};
|
||||||
export class WrappedWebSocket {
|
export class WrappedWebSocket {
|
||||||
public readonly address: WebSocketUrl;
|
public readonly address: WebSocketUrl;
|
||||||
public socket: WebSocket;
|
|
||||||
public state: "unconnected" | "connecting" | "connected" | "errored";
|
public state: "unconnected" | "connecting" | "connected" | "errored";
|
||||||
|
|
||||||
|
private socket: WebSocket;
|
||||||
|
|
||||||
/* callbacks for events after the socket has successfully connected! */
|
/* callbacks for events after the socket has successfully connected! */
|
||||||
public callbackMessage: (message) => void;
|
public callbackMessage: (message) => void;
|
||||||
public callbackDisconnect: (code: number, reason?: string) => void;
|
public callbackDisconnect: (code: number, reason?: string) => void;
|
||||||
|
@ -24,11 +26,21 @@ export class WrappedWebSocket {
|
||||||
private errorQueue = [];
|
private errorQueue = [];
|
||||||
private connectResultListener = [];
|
private connectResultListener = [];
|
||||||
|
|
||||||
|
private bytesReceived;
|
||||||
|
private bytesSend;
|
||||||
|
|
||||||
constructor(addr: WebSocketUrl) {
|
constructor(addr: WebSocketUrl) {
|
||||||
this.address = addr;
|
this.address = addr;
|
||||||
this.state = "unconnected";
|
this.state = "unconnected";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getControlStatistics() : ConnectionStatistics {
|
||||||
|
return {
|
||||||
|
bytesReceived: this.bytesReceived,
|
||||||
|
bytesSend: this.bytesSend
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
socketUrl() : string {
|
socketUrl() : string {
|
||||||
let result = "";
|
let result = "";
|
||||||
result += this.address.secure ? "wss://" : "ws://";
|
result += this.address.secure ? "wss://" : "ws://";
|
||||||
|
@ -64,17 +76,24 @@ export class WrappedWebSocket {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.socket.onmessage = event => {
|
this.socket.onmessage = event => {
|
||||||
if(this.callbackMessage)
|
if(typeof event.data === "string") {
|
||||||
|
this.bytesReceived += event.data.length;
|
||||||
|
} else if(event.data instanceof ArrayBuffer) {
|
||||||
|
this.bytesReceived += event.data.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.callbackMessage) {
|
||||||
this.callbackMessage(event.data);
|
this.callbackMessage(event.data);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.socket.onerror = () => {
|
this.socket.onerror = () => {
|
||||||
if(this.state === "connected") {
|
if(this.state === "connected") {
|
||||||
this.state = "errored";
|
this.state = "errored";
|
||||||
|
|
||||||
if(this.callbackErrored)
|
if(this.callbackErrored) {
|
||||||
this.callbackErrored();
|
this.callbackErrored();
|
||||||
|
}
|
||||||
} else if(this.state === "connecting") {
|
} else if(this.state === "connecting") {
|
||||||
this.state = "errored";
|
this.state = "errored";
|
||||||
this.fireConnectResult();
|
this.fireConnectResult();
|
||||||
|
@ -88,8 +107,9 @@ export class WrappedWebSocket {
|
||||||
}
|
}
|
||||||
|
|
||||||
async awaitConnectResult() {
|
async awaitConnectResult() {
|
||||||
while (this.state === "connecting")
|
while (this.state === "connecting") {
|
||||||
await new Promise<void>(resolve => this.connectResultListener.push(resolve));
|
await new Promise<void>(resolve => this.connectResultListener.push(resolve));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeConnection() {
|
closeConnection() {
|
||||||
|
@ -134,6 +154,9 @@ export class WrappedWebSocket {
|
||||||
this.socket = undefined;
|
this.socket = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.bytesReceived = 0;
|
||||||
|
this.bytesSend = 0;
|
||||||
|
|
||||||
this.errorQueue = [];
|
this.errorQueue = [];
|
||||||
this.fireConnectResult();
|
this.fireConnectResult();
|
||||||
}
|
}
|
||||||
|
@ -150,4 +173,14 @@ export class WrappedWebSocket {
|
||||||
popError() {
|
popError() {
|
||||||
return this.errorQueue.pop_front();
|
return this.errorQueue.pop_front();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendMessage(message: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||||
|
if(typeof message === "string") {
|
||||||
|
this.bytesSend += message.length;
|
||||||
|
} else if(message instanceof ArrayBuffer) {
|
||||||
|
this.bytesSend += message.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket.send(message);
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -38,7 +38,7 @@ let dummyAudioTrack: MediaStreamTrack | undefined;
|
||||||
* So we've to keep it alive with a dummy track.
|
* So we've to keep it alive with a dummy track.
|
||||||
*/
|
*/
|
||||||
function getIdleTrack(kind: "video" | "audio") : MediaStreamTrack | null {
|
function getIdleTrack(kind: "video" | "audio") : MediaStreamTrack | null {
|
||||||
if(window.detectedBrowser?.name === "firefox" || true) {
|
if(window.detectedBrowser?.name === "firefox") {
|
||||||
if(kind === "video") {
|
if(kind === "video") {
|
||||||
if(!dummyVideoTrack) {
|
if(!dummyVideoTrack) {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
|
@ -343,6 +343,14 @@ type TemporaryRtpStream = {
|
||||||
info: TrackClientInfo | undefined
|
info: TrackClientInfo | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RTCConnectionStatistics = {
|
||||||
|
videoBytesReceived: number,
|
||||||
|
videoBytesSent: number,
|
||||||
|
|
||||||
|
voiceBytesReceived: number,
|
||||||
|
voiceBytesSent
|
||||||
|
}
|
||||||
|
|
||||||
export interface RTCConnectionEvents {
|
export interface RTCConnectionEvents {
|
||||||
notify_state_changed: { oldState: RTPConnectionState, newState: RTPConnectionState },
|
notify_state_changed: { oldState: RTPConnectionState, newState: RTPConnectionState },
|
||||||
notify_audio_assignment_changed: { track: RemoteRTPAudioTrack, info: TrackClientInfo | undefined },
|
notify_audio_assignment_changed: { track: RemoteRTPAudioTrack, info: TrackClientInfo | undefined },
|
||||||
|
@ -730,6 +738,11 @@ export class RTCConnection {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "failed":
|
case "failed":
|
||||||
|
if(this.connectionState !== RTPConnectionState.FAILED) {
|
||||||
|
this.handleFatalError(tr("peer connection failed"), 5000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "closed":
|
case "closed":
|
||||||
case "disconnected":
|
case "disconnected":
|
||||||
case "new":
|
case "new":
|
||||||
|
@ -851,4 +864,34 @@ export class RTCConnection {
|
||||||
tempStream.status = state;
|
tempStream.status = state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConnectionStatistics() : Promise<RTCConnectionStatistics> {
|
||||||
|
try {
|
||||||
|
if(!this.peer) {
|
||||||
|
throw "missing peer";
|
||||||
|
}
|
||||||
|
|
||||||
|
const statisticsInfo = await this.peer.getStats();
|
||||||
|
const statistics = [...statisticsInfo.entries()].map(e => e[1]) as RTCStats[];
|
||||||
|
const inboundStreams = statistics.filter(e => e.type.replace(/-/, "") === "inboundrtp" && 'bytesReceived' in e) as any[];
|
||||||
|
const outboundStreams = statistics.filter(e => e.type.replace(/-/, "") === "outboundrtp" && 'bytesSent' in e) as any[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
voiceBytesSent: outboundStreams.filter(e => e.mediaType === "audio").reduce((a, b) => a + b.bytesSent, 0),
|
||||||
|
voiceBytesReceived: inboundStreams.filter(e => e.mediaType === "audio").reduce((a, b) => a + b.bytesReceived, 0),
|
||||||
|
|
||||||
|
videoBytesSent: outboundStreams.filter(e => e.mediaType === "video").reduce((a, b) => a + b.bytesSent, 0),
|
||||||
|
videoBytesReceived: inboundStreams.filter(e => e.mediaType === "video").reduce((a, b) => a + b.bytesReceived, 0)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logWarn(LogCategory.WEBRTC, tr("Failed to calculate connection statistics: %o"), error);
|
||||||
|
return {
|
||||||
|
videoBytesReceived: 0,
|
||||||
|
videoBytesSent: 0,
|
||||||
|
|
||||||
|
voiceBytesReceived: 0,
|
||||||
|
voiceBytesSent: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,314 @@
|
||||||
|
import {MediaDescription, SessionDescription} from "sdp-transform";
|
||||||
|
import * as sdpTransform from "sdp-transform";
|
||||||
|
|
||||||
|
export interface RTCNegotiationMediaMapping {
|
||||||
|
direction: "sendrecv" | "recvonly" | "sendonly" | "inactive",
|
||||||
|
ssrc: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RTCNegotiationIceConfig {
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
fingerprint: string,
|
||||||
|
fingerprint_type: string,
|
||||||
|
setup: "active" | "passive" | "actpass",
|
||||||
|
candidates: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RTCNegotiationExtension {
|
||||||
|
id: number;
|
||||||
|
uri: string;
|
||||||
|
|
||||||
|
media?: "audio" | "video";
|
||||||
|
|
||||||
|
direction?: "recvonly" | "sendonly";
|
||||||
|
config?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RTCNegotiationCodec {
|
||||||
|
payload: number,
|
||||||
|
name: string,
|
||||||
|
|
||||||
|
channels?: number,
|
||||||
|
rate?: number,
|
||||||
|
|
||||||
|
fmtp?: string,
|
||||||
|
feedback?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The offer send by the client to the server */
|
||||||
|
export interface RTCNegotiationOffer {
|
||||||
|
type: "initial-offer" | "negotiation-offer",
|
||||||
|
sessionId: number,
|
||||||
|
ssrcs: number[],
|
||||||
|
ssrc_types: number[],
|
||||||
|
ice: RTCNegotiationIceConfig,
|
||||||
|
/* Only present in initial response */
|
||||||
|
extension: RTCNegotiationExtension | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The offer send by the server to the client */
|
||||||
|
export interface RTCNegotiationMessage {
|
||||||
|
type: "initial-offer" | "negotiation-offer" | "initial-answer" | "negotiation-answer",
|
||||||
|
sessionId: number,
|
||||||
|
sessionUsername: string,
|
||||||
|
|
||||||
|
ssrc: number[],
|
||||||
|
ssrc_flags: number[],
|
||||||
|
|
||||||
|
ice: RTCNegotiationIceConfig,
|
||||||
|
|
||||||
|
/* Only present in initial answer */
|
||||||
|
extension: RTCNegotiationExtension[] | undefined,
|
||||||
|
|
||||||
|
/* Only present in initial answer */
|
||||||
|
audio_codecs: RTCNegotiationCodec[] | undefined,
|
||||||
|
|
||||||
|
/* Only present in initial answer */
|
||||||
|
video_codecs: RTCNegotiationCodec[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type RTCDirection = "sendonly" | "recvonly" | "sendrecv" | "inactive";
|
||||||
|
|
||||||
|
type SsrcFlags = {
|
||||||
|
type: "audio",
|
||||||
|
mode: RTCDirection
|
||||||
|
} | {
|
||||||
|
type: "video",
|
||||||
|
mode: RTCDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace SsrcFlags {
|
||||||
|
function parseRtcDirection(direction: number) : RTCDirection {
|
||||||
|
switch (direction) {
|
||||||
|
case 0: return "sendrecv";
|
||||||
|
case 1: return "sendonly";
|
||||||
|
case 2: return "recvonly";
|
||||||
|
case 3: return "inactive";
|
||||||
|
default: throw tra("invalid rtc direction type {}", direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(flags: number) : SsrcFlags {
|
||||||
|
switch (flags & 0x7) {
|
||||||
|
case 0:
|
||||||
|
return {
|
||||||
|
type: "audio",
|
||||||
|
mode: parseRtcDirection((flags >> 3) & 0x3)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
return {
|
||||||
|
type: "video",
|
||||||
|
mode: parseRtcDirection((flags >> 3) & 0x3)
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw tr("invalid ssrc flags");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSdp(session: RTCNegotiationMessage) {
|
||||||
|
const sdp = {} as SessionDescription;
|
||||||
|
sdp.version = 0;
|
||||||
|
sdp.origin = {
|
||||||
|
username: session.sessionUsername,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
ipVer: 4,
|
||||||
|
netType: "IN",
|
||||||
|
address: "127.0.0.1",
|
||||||
|
sessionVersion: 2
|
||||||
|
};
|
||||||
|
sdp.name = "-";
|
||||||
|
sdp.timing = { start: 0, stop: 0 };
|
||||||
|
sdp.groups = [{
|
||||||
|
type: "BUNDLE",
|
||||||
|
mids: [...session.ssrc].map((_, idx) => idx).join(" ")
|
||||||
|
}];
|
||||||
|
|
||||||
|
sdp.media = [];
|
||||||
|
const generateMedia = (ssrc: number, flags: SsrcFlags) => {
|
||||||
|
let formats: RTCNegotiationCodec[];
|
||||||
|
if(flags.type === "audio") {
|
||||||
|
formats = session.audio_codecs;
|
||||||
|
} else if(flags.type === "video") {
|
||||||
|
formats = session.video_codecs;
|
||||||
|
}
|
||||||
|
const index = sdp.media.push({
|
||||||
|
type: flags.type,
|
||||||
|
port: 9,
|
||||||
|
protocol: "UDP/TLS/RTP/SAVPF",
|
||||||
|
payloads: formats.map(e => e.payload).join(" "),
|
||||||
|
|
||||||
|
fmtp: [],
|
||||||
|
rtp: [],
|
||||||
|
rtcpFb: [],
|
||||||
|
ext: [],
|
||||||
|
ssrcs: [],
|
||||||
|
invalid: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const tMedia = sdp.media[index - 1];
|
||||||
|
|
||||||
|
/* basic properties */
|
||||||
|
tMedia.mid = (index - 1).toString();
|
||||||
|
tMedia.direction = flags.mode;
|
||||||
|
tMedia.rtcpMux = "rtcp-mux";
|
||||||
|
|
||||||
|
/* ice */
|
||||||
|
tMedia.iceUfrag = session.ice.username;
|
||||||
|
tMedia.icePwd = session.ice.password;
|
||||||
|
tMedia.invalid.push({ value: "ice-options:trickle" });
|
||||||
|
tMedia.fingerprint = {
|
||||||
|
hash: session.ice.fingerprint,
|
||||||
|
type: session.ice.fingerprint_type,
|
||||||
|
};
|
||||||
|
tMedia.setup = session.ice.setup;
|
||||||
|
|
||||||
|
/* codecs */
|
||||||
|
for(const codec of formats) {
|
||||||
|
tMedia.rtp.push({
|
||||||
|
codec: codec.name,
|
||||||
|
payload: codec.payload,
|
||||||
|
rate: codec.rate,
|
||||||
|
encoding: codec.channels
|
||||||
|
});
|
||||||
|
|
||||||
|
for(const feedback of codec.feedback || []) {
|
||||||
|
tMedia.rtcpFb.push({
|
||||||
|
payload: codec.payload,
|
||||||
|
type: feedback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if(codec.fmtp) {
|
||||||
|
tMedia.fmtp.push({
|
||||||
|
payload: codec.payload,
|
||||||
|
config: codec.fmtp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* extensions */
|
||||||
|
for(const extension of session.extension || []) {
|
||||||
|
if(extension.media && extension.media !== tMedia.type) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tMedia.ext.push({
|
||||||
|
value: extension.id,
|
||||||
|
config: extension.config,
|
||||||
|
direction: extension.direction,
|
||||||
|
uri: extension.uri
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ssrc */
|
||||||
|
tMedia.ssrcs.push({
|
||||||
|
id: ssrc >>> 0,
|
||||||
|
attribute: "cname",
|
||||||
|
value: (ssrc >>> 0).toString(16)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for(let index = 0; index < session.ssrc.length; index++) {
|
||||||
|
const flags = SsrcFlags.parse(session.ssrc_flags[index]);
|
||||||
|
generateMedia(session.ssrc[index], flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(JSON.stringify(session));
|
||||||
|
return sdpTransform.write(sdp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RTCNegotiator {
|
||||||
|
private readonly peer: RTCPeerConnection;
|
||||||
|
|
||||||
|
public callbackData: (data: string) => void;
|
||||||
|
public callbackFailed: (reason: string) => void;
|
||||||
|
|
||||||
|
private sessionCodecs: RTCNegotiationCodec | undefined;
|
||||||
|
private sessionExtensions: RTCNegotiationExtension | undefined;
|
||||||
|
|
||||||
|
constructor(peer: RTCPeerConnection) {
|
||||||
|
this.peer = peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
doInitialNegotiation() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRemoteData(dataString: string) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* FIXME: mozilla...THIS_IS_SDPARTA-82.0.3 (Needs to be parsed from the offer) */
|
||||||
|
/*
|
||||||
|
console.error("XYX\n%s",
|
||||||
|
generateSdp({
|
||||||
|
sessionId: "1234",
|
||||||
|
audio_media: [
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
video_media: [
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
direction: "sendrecv",
|
||||||
|
ssrc: 123885,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
audio_codecs: [{
|
||||||
|
payload: 88,
|
||||||
|
channels: 2,
|
||||||
|
feedback: ["nack"],
|
||||||
|
fmtp: "minptime=10;useinbandfec=1",
|
||||||
|
name: "opus",
|
||||||
|
rate: 48000
|
||||||
|
}],
|
||||||
|
video_codecs: [{
|
||||||
|
payload: 89,
|
||||||
|
name: "VP8",
|
||||||
|
feedback: ["nack", "nack pli"]
|
||||||
|
}],
|
||||||
|
extension: [{
|
||||||
|
id: 1,
|
||||||
|
uri: "urn:ietf:params:rtp-hdrext:ssrc-audio-level"
|
||||||
|
}],
|
||||||
|
ice: {
|
||||||
|
candidates: [],
|
||||||
|
fingerprint: "E6:C3:F3:17:71:11:4B:E5:1A:DD:EC:3C:AA:F2:BB:48:08:3B:A5:69:18:44:4A:97:59:62:BF:B4:43:F1:5D:00",
|
||||||
|
fingerprint_type: "sha-256",
|
||||||
|
password: "passwd",
|
||||||
|
username: "uname",
|
||||||
|
setup: "actpass"
|
||||||
|
}
|
||||||
|
}, "-")
|
||||||
|
);
|
||||||
|
throw "dummy load error";
|
||||||
|
*/
|
|
@ -14,6 +14,7 @@ import {Settings, settings} from "tc-shared/settings";
|
||||||
import {RtpVideoClient} from "tc-backend/web/rtc/video/VideoClient";
|
import {RtpVideoClient} from "tc-backend/web/rtc/video/VideoClient";
|
||||||
import {tr} from "tc-shared/i18n/localize";
|
import {tr} from "tc-shared/i18n/localize";
|
||||||
import {ConnectionState} from "tc-shared/ConnectionHandler";
|
import {ConnectionState} from "tc-shared/ConnectionHandler";
|
||||||
|
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
||||||
|
|
||||||
type VideoBroadcast = {
|
type VideoBroadcast = {
|
||||||
readonly source: VideoSource;
|
readonly source: VideoSource;
|
||||||
|
@ -249,4 +250,13 @@ export class RtpVideoConnection implements VideoConnection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConnectionStats(): Promise<ConnectionStatistics> {
|
||||||
|
const stats = await this.rtcConnection.getConnectionStatistics();
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytesReceived: stats.videoBytesReceived,
|
||||||
|
bytesSend: stats.videoBytesSent
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -7,7 +7,7 @@ import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
|
||||||
import {VoiceClient} from "tc-shared/voice/VoiceClient";
|
import {VoiceClient} from "tc-shared/voice/VoiceClient";
|
||||||
import {WhisperSession, WhisperSessionState, WhisperTarget} from "tc-shared/voice/VoiceWhisper";
|
import {WhisperSession, WhisperSessionState, WhisperTarget} from "tc-shared/voice/VoiceWhisper";
|
||||||
import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-backend/web/rtc/Connection";
|
import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "tc-backend/web/rtc/Connection";
|
||||||
import {AbstractServerConnection} from "tc-shared/connection/ConnectionBase";
|
import {AbstractServerConnection, ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
||||||
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
|
import {VoicePlayerState} from "tc-shared/voice/VoicePlayer";
|
||||||
import * as log from "tc-shared/log";
|
import * as log from "tc-shared/log";
|
||||||
import {LogCategory, logError, logTrace, logWarn} from "tc-shared/log";
|
import {LogCategory, logError, logTrace, logWarn} from "tc-shared/log";
|
||||||
|
@ -361,4 +361,13 @@ export class RtpVoiceConnection extends AbstractVoiceConnection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConnectionStats(): Promise<ConnectionStatistics> {
|
||||||
|
const stats = await this.rtcConnection.getConnectionStatistics();
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytesReceived: stats.voiceBytesReceived,
|
||||||
|
bytesSend: stats.voiceBytesSent
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue