Added a connection status indicator and broke Firefox audio playback (not sure why).

canary
WolverinDEV 2020-11-16 21:02:18 +01:00
parent 10ed00dd19
commit ae83459c30
17 changed files with 1134 additions and 19 deletions

View File

@ -31,15 +31,7 @@
<div class="container-seperator horizontal" seperator-id="seperator-main-log"></div>
<div class="container-bottom">
<div class="container-server-log" id="server-log"></div>
<div class="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 class="container-footer" id="container-footer">
</div>
</div> <!-- Selection info -->
</div>

View File

@ -2,6 +2,9 @@ import {ConnectionHandler, DisconnectReason} from "./ConnectionHandler";
import {Registry} from "./events";
import * as loader 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;
@ -31,6 +34,7 @@ export class ConnectionManager {
private _container_hostbanner: JQuery;
private _container_chat: JQuery;
private containerChannelVideo: ReplaceableContainer;
private containerFooter: HTMLDivElement;
constructor() {
this.event_registry = new Registry<ConnectionManagerEvents>();
@ -41,10 +45,15 @@ export class ConnectionManager {
this._container_channel_tree = $("#channelTree");
this._container_hostbanner = $("#hostbanner");
this._container_chat = $("#chat");
this.containerFooter = document.getElementById("container-footer") as HTMLDivElement;
this.set_active_connection(undefined);
}
initializeFooter() {
ReactDOM.render(React.createElement(FooterRenderer), this.containerFooter);
}
events() : Registry<ConnectionManagerEvents> {
return this.event_registry;
}
@ -169,6 +178,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "server manager init",
function: async () => {
server_connections = new ConnectionManager();
server_connections.initializeFooter();
},
priority: 80
});

View File

@ -28,6 +28,8 @@ export interface ServerConnectionEvents {
}
export type ConnectionStateListener = (old_state: ConnectionState, new_state: ConnectionState) => any;
export type ConnectionStatistics = { bytesReceived: number, bytesSend: number };
export abstract class AbstractServerConnection {
readonly events: Registry<ServerConnectionEvents>;
@ -58,6 +60,7 @@ export abstract class AbstractServerConnection {
connectionProxyAddress() : ServerAddress | undefined { return undefined; };
abstract handshake_handler() : HandshakeHandler; /* only valid when connected */
abstract getControlStatistics() : ConnectionStatistics;
//FIXME: Remove this this is currently only some kind of hack
updateConnectionState(state: ConnectionState) {

View File

@ -1,5 +1,7 @@
import {VideoSource} from "tc-shared/video/VideoSource";
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";
@ -43,6 +45,7 @@ export interface VideoConnection {
getEvents() : Registry<VideoConnectionEvent>;
getStatus() : VideoConnectionStatus;
getConnectionStats() : Promise<ConnectionStatistics>;
isBroadcasting(type: VideoBroadcastType);
getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined;

View File

@ -1,5 +1,5 @@
import {RecorderProfile} from "../voice/RecorderProfile";
import {AbstractServerConnection} from "../connection/ConnectionBase";
import {AbstractServerConnection, ConnectionStatistics} from "../connection/ConnectionBase";
import {Registry} from "../events";
import {VoiceClient} from "../voice/VoiceClient";
import {WhisperSession, WhisperTarget} from "../voice/VoiceWhisper";
@ -62,6 +62,7 @@ export abstract class AbstractVoiceConnection {
abstract getConnectionState() : VoiceConnectionStatus;
abstract getFailedMessage() : string;
abstract getConnectionStats() : Promise<ConnectionStatistics>;
abstract encodingSupported(codec: number) : boolean;
abstract decodingSupported(codec: number) : boolean;

View File

@ -159,6 +159,27 @@ export namespace network {
export const GB = 1024 * MB;
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?: {
time?: string,
unit?: string,

View File

@ -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;
}
}
}

View File

@ -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>
);
};

View File

@ -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 });
}
}

View File

@ -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,
}
}

View File

@ -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>
)
};

View File

@ -1,7 +1,7 @@
import {
AbstractServerConnection,
CommandOptionDefaults,
CommandOptions,
CommandOptions, ConnectionStatistics,
ConnectionStateListener,
} from "tc-shared/connection/ConnectionBase";
import {ConnectionHandler, ConnectionState, DisconnectReason} from "tc-shared/ConnectionHandler";
@ -383,7 +383,7 @@ export class ServerConnection extends AbstractServerConnection {
return;
}
this.socket.socket.send(data);
this.socket.sendMessage(data);
}
private static commandDataToJson(input: any) : string {
@ -495,4 +495,8 @@ export class ServerConnection extends AbstractServerConnection {
native: this.pingStatistics.currentNativeValue
};
}
getControlStatistics(): ConnectionStatistics {
return this.socket?.getControlStatistics() || { bytesSend: 0, bytesReceived: 0 };
}
}

View File

@ -1,5 +1,6 @@
import * as log from "tc-shared/log";
import {LogCategory} from "tc-shared/log";
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
const kPreventOpeningWebSocketClosing = false;
@ -13,9 +14,10 @@ export type WebSocketUrl = {
};
export class WrappedWebSocket {
public readonly address: WebSocketUrl;
public socket: WebSocket;
public state: "unconnected" | "connecting" | "connected" | "errored";
private socket: WebSocket;
/* callbacks for events after the socket has successfully connected! */
public callbackMessage: (message) => void;
public callbackDisconnect: (code: number, reason?: string) => void;
@ -24,11 +26,21 @@ export class WrappedWebSocket {
private errorQueue = [];
private connectResultListener = [];
private bytesReceived;
private bytesSend;
constructor(addr: WebSocketUrl) {
this.address = addr;
this.state = "unconnected";
}
getControlStatistics() : ConnectionStatistics {
return {
bytesReceived: this.bytesReceived,
bytesSend: this.bytesSend
};
}
socketUrl() : string {
let result = "";
result += this.address.secure ? "wss://" : "ws://";
@ -64,17 +76,24 @@ export class WrappedWebSocket {
};
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.socket.onerror = () => {
if(this.state === "connected") {
this.state = "errored";
if(this.callbackErrored)
if(this.callbackErrored) {
this.callbackErrored();
}
} else if(this.state === "connecting") {
this.state = "errored";
this.fireConnectResult();
@ -88,8 +107,9 @@ export class WrappedWebSocket {
}
async awaitConnectResult() {
while (this.state === "connecting")
while (this.state === "connecting") {
await new Promise<void>(resolve => this.connectResultListener.push(resolve));
}
}
closeConnection() {
@ -134,6 +154,9 @@ export class WrappedWebSocket {
this.socket = undefined;
}
this.bytesReceived = 0;
this.bytesSend = 0;
this.errorQueue = [];
this.fireConnectResult();
}
@ -150,4 +173,14 @@ export class WrappedWebSocket {
popError() {
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);
}
}

View File

@ -38,7 +38,7 @@ let dummyAudioTrack: MediaStreamTrack | undefined;
* So we've to keep it alive with a dummy track.
*/
function getIdleTrack(kind: "video" | "audio") : MediaStreamTrack | null {
if(window.detectedBrowser?.name === "firefox" || true) {
if(window.detectedBrowser?.name === "firefox") {
if(kind === "video") {
if(!dummyVideoTrack) {
const canvas = document.createElement("canvas");
@ -343,6 +343,14 @@ type TemporaryRtpStream = {
info: TrackClientInfo | undefined
}
export type RTCConnectionStatistics = {
videoBytesReceived: number,
videoBytesSent: number,
voiceBytesReceived: number,
voiceBytesSent
}
export interface RTCConnectionEvents {
notify_state_changed: { oldState: RTPConnectionState, newState: RTPConnectionState },
notify_audio_assignment_changed: { track: RemoteRTPAudioTrack, info: TrackClientInfo | undefined },
@ -730,6 +738,11 @@ export class RTCConnection {
break;
case "failed":
if(this.connectionState !== RTPConnectionState.FAILED) {
this.handleFatalError(tr("peer connection failed"), 5000);
}
break;
case "closed":
case "disconnected":
case "new":
@ -851,4 +864,34 @@ export class RTCConnection {
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
};
}
}
}

314
web/app/rtc/Negotiation.ts Normal file
View File

@ -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";
*/

View File

@ -14,6 +14,7 @@ import {Settings, settings} from "tc-shared/settings";
import {RtpVideoClient} from "tc-backend/web/rtc/video/VideoClient";
import {tr} from "tc-shared/i18n/localize";
import {ConnectionState} from "tc-shared/ConnectionHandler";
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
type VideoBroadcast = {
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
};
}
}

View File

@ -7,7 +7,7 @@ import {RecorderProfile} from "tc-shared/voice/RecorderProfile";
import {VoiceClient} from "tc-shared/voice/VoiceClient";
import {WhisperSession, WhisperSessionState, WhisperTarget} from "tc-shared/voice/VoiceWhisper";
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 * as log 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
};
}
}