TeaWeb/shared/js/connection/rtc/Connection.ts

1405 lines
55 KiB
TypeScript

import {AbstractServerConnection, ServerCommand, ServerConnectionEvents} from "tc-shared/connection/ConnectionBase";
import {ConnectionState} from "tc-shared/ConnectionHandler";
import {LogCategory, logDebug, logError, logGroupNative, logTrace, LogType, logWarn} from "tc-shared/log";
import {AbstractCommandHandler} from "tc-shared/connection/AbstractCommandHandler";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {tr, tra} from "tc-shared/i18n/localize";
import {Registry} from "tc-shared/events";
import {RemoteRTPAudioTrack, RemoteRTPTrackState, RemoteRTPVideoTrack, TrackClientInfo} from "./RemoteTrack";
import {SdpCompressor, SdpProcessor} from "./SdpUtils";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import {WhisperTarget} from "tc-shared/voice/VoiceWhisper";
import {globalAudioContext} from "tc-backend/audio/player";
import {VideoBroadcastConfig, VideoBroadcastType} from "tc-shared/connection/VideoConnection";
import {Settings, settings} from "tc-shared/settings";
const kSdpCompressionMode = 1;
declare global {
interface RTCIceCandidate {
/* Firefox has this */
address: string | undefined;
}
interface HTMLCanvasElement {
captureStream(framed: number) : MediaStream;
}
}
export type RtcVideoBroadcastStatistics = {
dimensions: { width: number, height: number },
frameRate: number,
codec?: { name: string, payloadType: number }
bandwidth?: {
/* bits per second */
currentBps: number,
/* bits per second */
maxBps: number
},
qualityLimitation: "cpu" | "bandwidth" | "none",
source: {
frameRate: number,
dimensions: { width: number, height: number },
},
};
class RetryTimeCalculator {
private readonly minTime: number;
private readonly maxTime: number;
private readonly increment: number;
private retryCount: number;
private currentTime: number;
constructor(minTime: number, maxTime: number, increment: number) {
this.minTime = minTime;
this.maxTime = maxTime;
this.increment = increment;
this.reset();
}
calculateRetryTime() {
if(this.retryCount >= 5) {
/* no more retries */
return 0;
}
this.retryCount++;
const time = this.currentTime;
this.currentTime = Math.min(this.currentTime + this.increment, this.maxTime);
return time;
}
reset() {
this.currentTime = this.minTime;
this.retryCount = 0;
}
}
class RTCStatsWrapper {
private readonly supplier: () => Promise<RTCStatsReport>;
private readonly statistics;
constructor(supplier: () => Promise<RTCStatsReport>) {
this.supplier = supplier;
this.statistics = {};
}
async initialize() {
for(const [key, value] of await this.supplier()) {
if(typeof this.statistics[key] !== "undefined") {
logWarn(LogCategory.WEBRTC, tr("Duplicated statistics entry for key %s. Dropping duplicate."), key);
continue;
}
this.statistics[key] = value;
}
}
getValues() : (RTCStats & {[T: string]: string | number})[] {
return Object.values(this.statistics);
}
getStatistic(key: string) : RTCStats & {[T: string]: string | number} {
return this.statistics[key];
}
getStatisticsByType(type: string) : (RTCStats & {[T: string]: string | number})[] {
return Object.values(this.statistics).filter((e: any) => e.type?.replace(/-/g, "") === type) as any;
}
getStatisticByType(type: string): RTCStats & {[T: string]: string | number} {
const entries = this.getStatisticsByType(type);
if(entries.length === 0) {
throw tra("missing statistic entry {}", type);
} else if(entries.length === 1) {
return entries[0];
} else {
throw tra("duplicated statistics entry of type {}", type);
}
}
}
let dummyVideoTrack: MediaStreamTrack | undefined;
let dummyAudioTrack: MediaStreamTrack | undefined;
/*
* For Firefox as soon we stop a sender we're never able to get the sender starting again...
* (This only applies after the initial negotiation. Before values of null are allowed)
* So we've to keep it alive with a dummy track.
*/
function getIdleTrack(kind: "video" | "audio") : MediaStreamTrack | null {
if(window.detectedBrowser?.name === "firefox") {
if(kind === "video") {
if(!dummyVideoTrack) {
const canvas = document.createElement("canvas");
canvas.getContext("2d");
const stream = canvas.captureStream(1);
dummyVideoTrack = stream.getVideoTracks()[0];
}
return dummyVideoTrack;
} else if(kind === "audio") {
if(!dummyAudioTrack) {
const dest = globalAudioContext().createMediaStreamDestination();
dummyAudioTrack = dest.stream.getAudioTracks()[0];
}
return dummyAudioTrack;
}
}
return null;
}
class CommandHandler extends AbstractCommandHandler {
private readonly handle: RTCConnection;
private readonly sdpProcessor: SdpProcessor;
constructor(connection: AbstractServerConnection, handle: RTCConnection, sdpProcessor: SdpProcessor) {
super(connection);
this.handle = handle;
this.sdpProcessor = sdpProcessor;
this.ignore_consumed = true;
}
handle_command(command: ServerCommand): boolean {
if(command.command === "notifyrtcsessiondescription") {
const data = command.arguments[0];
if(!this.handle["peer"]) {
logWarn(LogCategory.WEBRTC, tr("Received remote %s without an active peer"), data.mode);
return;
}
/* webrtc-sdp somehow places some empty lines into the sdp */
let sdp = data.sdp.replace(/\r?\n\r?\n/g, "\n");
try {
sdp = SdpCompressor.decompressSdp(sdp, 1);
} catch (error) {
logError(LogCategory.WEBRTC, tr("Failed to decompress remote SDP: %o"), error);
this.handle["handleFatalError"](tr("Failed to decompress remote SDP"), true);
return;
}
if(RTCConnection.kEnableSdpTrace) {
const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original remote SDP ({})", data.mode as string));
gr.collapsed(true);
gr.log("%s", data.sdp);
gr.end();
}
try {
sdp = this.sdpProcessor.processIncomingSdp(sdp, data.mode);
} catch (error) {
logError(LogCategory.WEBRTC, tr("Failed to reprocess SDP %s: %o"), data.mode, error);
this.handle["handleFatalError"](tra("Failed to preprocess SDP {}", data.mode as string), true);
return;
}
if(RTCConnection.kEnableSdpTrace) {
const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched remote SDP ({})", data.mode as string));
gr.collapsed(true);
gr.log("%s", sdp);
gr.end();
}
if(data.mode === "answer") {
this.handle["peer"].setRemoteDescription({
sdp: sdp,
type: "answer"
}).then(() => {
this.handle["cachedRemoteSessionDescription"] = sdp;
this.handle["peerRemoteDescriptionReceived"] = true;
setTimeout(() => this.handle.applyCachedRemoteIceCandidates(), 50);
}).catch(error => {
logError(LogCategory.WEBRTC, tr("Failed to set the remote description: %o"), error);
this.handle["handleFatalError"](tr("Failed to set the remote description (answer)"), true);
});
} else if(data.mode === "offer") {
this.handle["cachedRemoteSessionDescription"] = sdp;
this.handle["peer"].setRemoteDescription({
sdp: sdp,
type: "offer"
}).then(() => this.handle["peer"].createAnswer())
.then(async answer => {
if(RTCConnection.kEnableSdpTrace) {
const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original local SDP ({})", answer.type as string));
gr.collapsed(true);
gr.log("%s", answer.sdp);
gr.end();
}
answer.sdp = this.sdpProcessor.processOutgoingSdp(answer.sdp, "answer");
await this.handle["peer"].setLocalDescription(answer);
return answer;
})
.then(answer => {
answer.sdp = SdpCompressor.compressSdp(answer.sdp, kSdpCompressionMode);
if(RTCConnection.kEnableSdpTrace) {
const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched local SDP ({})", answer.type as string));
gr.collapsed(true);
gr.log("%s", answer.sdp);
gr.end();
}
return this.connection.send_command("rtcsessiondescribe", {
mode: "answer",
sdp: answer.sdp,
compression: kSdpCompressionMode
});
}).catch(error => {
logError(LogCategory.WEBRTC, tr("Failed to set the remote description and execute the renegotiation: %o"), error);
this.handle["handleFatalError"](tr("Failed to set the remote description (offer/renegotiation)"), true);
});
} else {
logWarn(LogCategory.NETWORKING, tr("Received invalid mode for rtc session description (%s)."), data.mode);
}
return true;
} else if(command.command === "notifyrtcicecandidate") {
const candidate = command.arguments[0]["candidate"];
const mediaLine = parseInt(command.arguments[0]["medialine"]);
if(Number.isNaN(mediaLine)) {
logError(LogCategory.WEBRTC, tr("Failed to parse ICE media line: %o"), command.arguments[0]["medialine"]);
return;
}
if(candidate) {
const parsedCandidate = new RTCIceCandidate({
candidate: "candidate:" + candidate,
sdpMLineIndex: mediaLine
});
this.handle.handleRemoteIceCandidate(parsedCandidate, mediaLine);
} else {
this.handle.handleRemoteIceCandidate(undefined, mediaLine);
}
} else if(command.command === "notifyrtcstreamassignment") {
const data = command.arguments[0];
const ssrc = parseInt(data["streamid"]) >>> 0;
if(parseInt(data["sclid"])) {
this.handle["doMapStream"](ssrc, {
client_id: parseInt(data["sclid"]),
client_database_id: parseInt(data["scldbid"]),
client_name: data["sclname"],
client_unique_id: data["scluid"],
media: parseInt(data["media"])
});
} else {
this.handle["doMapStream"](ssrc, undefined);
}
} else if(command.command === "notifyrtcstreamstate") {
const data = command.arguments[0];
const state = parseInt(data["state"]);
const ssrc = parseInt(data["streamid"]) >>> 0;
if(state === 0) {
/* stream stopped */
this.handle["handleStreamState"](ssrc, 0, undefined);
} else if(state === 1) {
this.handle["handleStreamState"](ssrc, 1, {
client_id: parseInt(data["sclid"]),
client_database_id: parseInt(data["scldbid"]),
client_name: data["sclname"],
client_unique_id: data["scluid"],
});
} else {
logWarn(LogCategory.WEBRTC, tr("Received unknown/invalid rtc track state: %d"), state);
}
}
return false;
}
}
export enum RTPConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
FAILED,
NOT_SUPPORTED
}
class InternalRemoteRTPAudioTrack extends RemoteRTPAudioTrack {
private muteTimeout;
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
super(ssrc, transceiver);
}
destroy() {
this.handleTrackEnded();
super.destroy();
}
handleAssignment(info: TrackClientInfo | undefined) {
if(Object.isSimilar(this.currentAssignment, info)) {
return;
}
this.currentAssignment = info;
if(info) {
logDebug(LogCategory.WEBRTC, tr("Remote RTP audio track %d mounted to client %o"), this.getSsrc(), info);
this.setState(RemoteRTPTrackState.Bound);
} else {
logDebug(LogCategory.WEBRTC, tr("Remote RTP audio track %d has been unmounted."), this.getSsrc());
this.setState(RemoteRTPTrackState.Unbound);
}
}
handleStateNotify(state: number, info: TrackClientInfo | undefined) {
if(!this.currentAssignment) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss info. Updating info."), this.getSsrc());
}
const validateInfo = () => {
if(info.client_id !== this.currentAssignment.client_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %d but received %d. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(info.client_unique_id !== this.currentAssignment.client_unique_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %s but received %s. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(this.currentAssignment.client_name !== info.client_name) {
this.currentAssignment.client_name = info.client_name;
/* TODO: Notify name update */
}
};
clearTimeout(this.muteTimeout);
this.muteTimeout = undefined;
if(state === 1) {
validateInfo();
this.shouldReplay = true;
this.updateGainNode();
this.setState(RemoteRTPTrackState.Started);
} else {
/* There wil be no info present */
this.setState(RemoteRTPTrackState.Bound);
/* since we're might still having some jitter stuff */
this.muteTimeout = setTimeout(() => {
this.shouldReplay = false;
this.updateGainNode();
}, 1000);
}
}
}
class InternalRemoteRTPVideoTrack extends RemoteRTPVideoTrack {
constructor(ssrc: number, transceiver: RTCRtpTransceiver) {
super(ssrc, transceiver);
}
destroy() {
this.handleTrackEnded();
super.destroy();
}
handleAssignment(info: TrackClientInfo | undefined) {
if(Object.isSimilar(this.currentAssignment, info)) {
return;
}
this.currentAssignment = info;
if(info) {
logDebug(LogCategory.WEBRTC, tr("Remote RTP video track %d mounted to client %o"), this.getSsrc(), info);
this.setState(RemoteRTPTrackState.Bound);
} else {
logDebug(LogCategory.WEBRTC, tr("Remote RTP video track %d has been unmounted."), this.getSsrc());
this.setState(RemoteRTPTrackState.Unbound);
}
}
handleStateNotify(state: number, info: TrackClientInfo | undefined) {
if(!this.currentAssignment) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss info. Updating info."), this.getSsrc());
}
const validateInfo = () => {
if(info.client_id !== this.currentAssignment.client_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %d but received %d. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(info.client_unique_id !== this.currentAssignment.client_unique_id) {
logWarn(LogCategory.WEBRTC, tr("Received stream state update for %d with miss matching client info. Expected client %s but received %s. Updating stream assignment."),
this.getSsrc(), this.currentAssignment.client_id, info.client_id);
this.currentAssignment = info;
/* TODO: Update the assignment via doMapStream */
} else if(this.currentAssignment.client_name !== info.client_name) {
this.currentAssignment.client_name = info.client_name;
/* TODO: Notify name update */
}
};
if(state === 1) {
validateInfo();
this.setState(RemoteRTPTrackState.Started);
} else {
/* There wil be no info present */
this.setState(RemoteRTPTrackState.Bound);
}
}
}
export type RTCSourceTrackType = "audio" | "audio-whisper" | "video" | "video-screen";
export type RTCBroadcastableTrackType = Exclude<RTCSourceTrackType, "audio-whisper">;
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,
ssrc: number,
status: number | undefined,
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 },
notify_video_assignment_changed: { track: RemoteRTPVideoTrack, info: TrackClientInfo | undefined },
}
export class RTCConnection {
public static readonly kEnableSdpTrace = true;
private readonly audioSupport: boolean;
private readonly events: Registry<RTCConnectionEvents>;
private readonly connection: AbstractServerConnection;
private readonly commandHandler: CommandHandler;
private readonly sdpProcessor: SdpProcessor;
private connectionState: RTPConnectionState;
private connectTimeout: number;
private failedReason: string;
private retryCalculator: RetryTimeCalculator;
private retryTimestamp: number;
private retryTimeout: number;
private peer: RTCPeerConnection;
private localCandidateCount: number;
private peerRemoteDescriptionReceived: boolean;
private cachedRemoteIceCandidates: { candidate: RTCIceCandidate, mediaLine: number }[];
private cachedRemoteSessionDescription: string;
private currentTracks: {[T in RTCSourceTrackType]: MediaStreamTrack | undefined} = {
"audio-whisper": undefined,
"video-screen": undefined,
audio: undefined,
video: undefined
};
private currentTransceiver: {[T in RTCSourceTrackType]: RTCRtpTransceiver | undefined} = {
"audio-whisper": undefined,
"video-screen": undefined,
audio: undefined,
video: undefined
};
private remoteAudioTracks: {[key: number]: InternalRemoteRTPAudioTrack};
private remoteVideoTracks: {[key: number]: InternalRemoteRTPVideoTrack};
private temporaryStreams: {[key: number]: TemporaryRtpStream} = {};
constructor(connection: AbstractServerConnection, audioSupport: boolean) {
this.events = new Registry<RTCConnectionEvents>();
this.connection = connection;
this.sdpProcessor = new SdpProcessor();
this.commandHandler = new CommandHandler(connection, this, this.sdpProcessor);
this.retryCalculator = new RetryTimeCalculator(5000, 30000, 10000);
this.audioSupport = audioSupport;
this.connection.command_handler_boss().register_handler(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);
}
isAudioEnabled() : boolean {
return this.audioSupport;
}
getConnection() : AbstractServerConnection {
return this.connection;
}
getEvents() {
return this.events;
}
getConnectionState() : RTPConnectionState {
return this.connectionState;
}
getFailReason() : string {
return this.failedReason;
}
getRetryTimestamp() : number | 0 {
return this.retryTimestamp;
}
restartConnection() {
if(this.connectionState === RTPConnectionState.DISCONNECTED) {
/* We've been disconnected on purpose */
return;
}
this.reset(true);
this.doInitialSetup();
}
reset(updateConnectionState: boolean) {
logTrace(LogCategory.WEBRTC, tr("Resetting the RTC connection (Updating connection state: %o)"), updateConnectionState);
if(this.peer) {
if(this.getConnection().connected()) {
this.getConnection().send_command("rtcsessionreset").catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to signal RTC session reset to server: %o"), error);
});
}
this.peer.onconnectionstatechange = undefined;
this.peer.ondatachannel = undefined;
this.peer.onicecandidate = undefined;
this.peer.onicecandidateerror = undefined;
this.peer.oniceconnectionstatechange = undefined;
this.peer.onicegatheringstatechange = undefined;
this.peer.onnegotiationneeded = undefined;
this.peer.onsignalingstatechange = undefined;
this.peer.onstatsended = undefined;
this.peer.ontrack = undefined;
this.peer.close();
this.peer = undefined;
}
this.peerRemoteDescriptionReceived = false;
this.cachedRemoteIceCandidates = [];
this.cachedRemoteSessionDescription = undefined;
clearTimeout(this.connectTimeout);
Object.keys(this.currentTransceiver).forEach(key => this.currentTransceiver[key] = undefined);
this.sdpProcessor.reset();
if(this.remoteAudioTracks) {
Object.values(this.remoteAudioTracks).forEach(track => track.destroy());
}
this.remoteAudioTracks = {};
if(this.remoteVideoTracks) {
Object.values(this.remoteVideoTracks).forEach(track => track.destroy());
}
this.remoteVideoTracks = {};
this.temporaryStreams = {};
this.localCandidateCount = 0;
clearTimeout(this.retryTimeout);
this.retryTimeout = 0;
this.retryTimestamp = 0;
/*
* We do not reset the retry timer here since we might get called when a fatal error occurs.
* Instead we're resetting it every time we've changed the server connection state.
*/
/* this.retryCalculator.reset(); */
if(updateConnectionState) {
this.updateConnectionState(RTPConnectionState.DISCONNECTED);
}
}
async setTrackSource(type: RTCSourceTrackType, source: MediaStreamTrack | null) : Promise<MediaStreamTrack> {
switch (type) {
case "audio":
case "audio-whisper":
if(!this.audioSupport) { throw tr("audio support isn't enabled"); }
if(source && source.kind !== "audio") { throw tr("invalid track type"); }
break;
case "video":
case "video-screen":
if(source && source.kind !== "video") { throw tr("invalid track type"); }
break;
}
if(this.currentTracks[type] === source) {
return;
}
const oldTrack = this.currentTracks[type] = source;
await this.updateTracks();
return oldTrack;
}
public async startVideoBroadcast(type: VideoBroadcastType, config: VideoBroadcastConfig) {
let track: RTCBroadcastableTrackType;
let broadcastType: number;
switch (type) {
case "camera":
broadcastType = 0;
track = "video";
break;
case "screen":
broadcastType = 1;
track = "video-screen";
break;
default:
throw tr("invalid video broadcast type");
}
let payload = {};
payload["broadcast_keyframe_interval"] = config.keyframeInterval;
payload["broadcast_bitrate_max"] = config.maxBandwidth;
payload["ssrc"] = this.sdpProcessor.getLocalSsrcFromFromMediaId(this.currentTransceiver[track].mid);
payload["type"] = broadcastType;
try {
await this.connection.send_command("broadcastvideo", payload);
} catch (error) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
throw tr("failed on permission") + " " + this.connection.client.permissions.getFailedPermission(error);
}
error = error.formattedMessage();
}
logError(LogCategory.WEBRTC, tr("failed to start %s broadcast: %o"), type, error);
throw tr("failed to signal broadcast start");
}
}
public async changeVideoBroadcastConfig(type: VideoBroadcastType, config: VideoBroadcastConfig) {
let track: RTCBroadcastableTrackType;
let broadcastType: number;
switch (type) {
case "camera":
broadcastType = 0;
track = "video";
break;
case "screen":
broadcastType = 1;
track = "video-screen";
break;
default:
throw tr("invalid video broadcast type");
}
let payload = {};
payload["broadcast_keyframe_interval"] = config.keyframeInterval;
payload["broadcast_bitrate_max"] = config.maxBandwidth;
payload["bt"] = broadcastType;
try {
await this.connection.send_command("broadcastvideoconfigure", payload);
} catch (error) {
if(error instanceof CommandResult) {
if(error.id === ErrorCode.SERVER_INSUFFICIENT_PERMISSIONS) {
throw tr("failed on permission") + " " + this.connection.client.permissions.getFailedPermission(error);
}
error = error.formattedMessage();
}
logError(LogCategory.WEBRTC, tr("failed to update %s broadcast: %o"), type, error);
throw tr("failed to update broadcast config");
}
}
public async startAudioBroadcast() {
try {
await this.connection.send_command("broadcastaudio", {
ssrc: this.sdpProcessor.getLocalSsrcFromFromMediaId(this.currentTransceiver["audio"].mid)
});
} catch (error) {
logError(LogCategory.WEBRTC, tr("failed to start %s broadcast: %o"), "audio", error);
throw tr("failed to signal broadcast start");
}
}
public async startWhisper(target: WhisperTarget) : Promise<void> {
if(!this.audioSupport) {
throw tr("audio support isn't enabled");
}
const transceiver = this.currentTransceiver["audio-whisper"];
if(typeof transceiver === "undefined") {
throw tr("missing transceiver");
}
if(target.target === "echo") {
await this.connection.send_command("whispersessioninitialize", {
ssrc: this.sdpProcessor.getLocalSsrcFromFromMediaId(transceiver.mid),
type: 0x10, /* self */
target: 0,
id: 0
}, { flagset: ["new"] });
} else if(target.target === "channel-clients") {
throw "target not yet supported";
} else if(target.target === "groups") {
throw "target not yet supported";
} else {
throw "target not yet supported";
}
}
public stopTrackBroadcast(type: RTCBroadcastableTrackType) {
let promise: Promise<any>;
switch (type) {
case "audio":
promise = this.connection.send_command("broadcastaudio", {
ssrc: 0
});
break;
case "video-screen":
promise = this.connection.send_command("broadcastvideo", {
type: 1,
ssrc: 0
});
break;
case "video":
promise = this.connection.send_command("broadcastvideo", {
type: 0,
ssrc: 0
});
break;
}
promise.catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to signal track broadcast stop: %o"), error);
});
}
public setNotSupported() {
this.reset(false);
this.updateConnectionState(RTPConnectionState.NOT_SUPPORTED);
}
private updateConnectionState(newState: RTPConnectionState) {
if(this.connectionState === newState) { return; }
const oldState = this.connectionState;
this.connectionState = newState;
this.events.fire("notify_state_changed", { oldState: oldState, newState: newState });
}
private handleFatalError(error: string, allowRetry: boolean) {
this.reset(false);
this.failedReason = error;
this.updateConnectionState(RTPConnectionState.FAILED);
const log = this.connection.client.log;
if(allowRetry) {
const time = this.retryCalculator.calculateRetryTime();
if(time > 0) {
this.retryTimestamp = Date.now() + time;
this.retryTimeout = setTimeout(() => {
this.doInitialSetup();
}, time);
log.log("webrtc.fatal.error", {
message: error,
retryTimeout: time
});
} else {
allowRetry = false;
}
}
if(!allowRetry) {
log.log("webrtc.fatal.error", {
message: error,
retryTimeout: 0
});
}
}
private static checkBrowserSupport() {
if(!window.RTCRtpSender || !RTCRtpSender.prototype) {
throw tr("Missing RTCRtpSender");
}
if(!RTCRtpSender.prototype.getParameters) {
throw tr("RTCRtpSender.getParameters");
}
if(!RTCRtpSender.prototype.replaceTrack) {
throw tr("RTCRtpSender.getParameters");
}
}
public doInitialSetup() {
if(!("RTCPeerConnection" in window)) {
this.handleFatalError(tr("WebRTC has been disabled (RTCPeerConnection is not defined)"), false);
return;
}
if(!("addTransceiver" in RTCPeerConnection.prototype)) {
this.handleFatalError(tr("WebRTC api incompatible (RTCPeerConnection.addTransceiver missing)"), false);
return;
}
this.peer = new RTCPeerConnection({
bundlePolicy: "max-bundle",
rtcpMuxPolicy: "require",
iceServers: [{ urls: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"] }]
});
if(this.audioSupport) {
this.currentTransceiver["audio"] = this.peer.addTransceiver("audio");
this.currentTransceiver["audio-whisper"] = this.peer.addTransceiver("audio");
if(window.detectedBrowser.name === "firefox") {
/*
* For some reason FF (<= 85.0) does not replay any audio from extra added transceivers.
* On the other hand, if the server is creating that track or we're using it for sending audio as well
* it works. So we just wait for the server to come up with new streams (even though we need to renegotiate...).
* For Chrome we only need to negotiate once in most cases.
* Side note: This does not apply to video channels!
*/
} else {
/* add some other transceivers for later use */
for(let i = 0; i < settings.getValue(Settings.KEY_RTC_EXTRA_AUDIO_CHANNELS); i++) {
this.peer.addTransceiver("audio", { direction: "recvonly" });
}
}
}
this.currentTransceiver["video"] = this.peer.addTransceiver("video");
this.currentTransceiver["video-screen"] = this.peer.addTransceiver("video");
/* add some other transceivers for later use */
for(let i = 0; i < settings.getValue(Settings.KEY_RTC_EXTRA_VIDEO_CHANNELS); i++) {
this.peer.addTransceiver("video", { direction: "recvonly" });
}
this.peer.onicecandidate = event => this.handleLocalIceCandidate(event.candidate);
this.peer.onicecandidateerror = event => this.handleIceCandidateError(event);
this.peer.oniceconnectionstatechange = () => this.handleIceConnectionStateChanged();
this.peer.onicegatheringstatechange = () => this.handleIceGatheringStateChanged();
this.peer.onsignalingstatechange = () => this.handleSignallingStateChanged();
this.peer.onconnectionstatechange = () => this.handlePeerConnectionStateChanged();
this.peer.ondatachannel = event => this.handleDataChannel(event.channel);
this.peer.ontrack = event => this.handleTrack(event);
this.updateConnectionState(RTPConnectionState.CONNECTING);
this.doInitialSetup0().catch(error => {
this.handleFatalError(tr("initial setup failed"), true);
logError(LogCategory.WEBRTC, tr("Connection setup failed: %o"), error);
});
}
private async updateTracks() {
for(const type of kRtcSourceTrackTypes) {
if(!this.currentTransceiver[type]?.sender) {
continue;
}
let fallback;
switch (type) {
case "audio":
case "audio-whisper":
fallback = getIdleTrack("audio");
break;
case "video":
case "video-screen":
fallback = getIdleTrack("video");
break;
}
let target = this.currentTracks[type] || fallback;
if(this.currentTransceiver[type].sender.track === target) {
continue;
}
await this.currentTransceiver[type].sender.replaceTrack(target);
/* Firefox has some crazy issues */
if(window.detectedBrowser.name !== "firefox") {
if(target) {
logTrace(LogCategory.NETWORKING, "Setting sendrecv from %o", this.currentTransceiver[type].direction, this.currentTransceiver[type].currentDirection);
this.currentTransceiver[type].direction = "sendrecv";
} else if(type === "video" || type === "video-screen") {
/*
* We don't need to stop & start the audio transceivers every time we're toggling the stream state.
* This would be a much overall cost than just keeping it going.
*
* The video streams instead are not toggling that much and since they split up the bandwidth between them,
* we've to shut them down if they're no needed. This not only allows the one stream to take full advantage
* of the bandwidth it also reduces resource usage.
*/
//this.currentTransceiver[type].direction = "recvonly";
}
}
logTrace(LogCategory.WEBRTC, "Replaced track for %o (Fallback: %o)", type, target === fallback);
}
}
private async doInitialSetup0() {
RTCConnection.checkBrowserSupport();
const peer = this.peer;
await this.updateTracks();
const offer = await peer.createOffer({ iceRestart: false, offerToReceiveAudio: this.audioSupport, offerToReceiveVideo: true });
if(offer.type !== "offer") { throw tr("created ofer isn't of type offer"); }
if(this.peer !== peer) { return; }
if(RTCConnection.kEnableSdpTrace) {
const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Original initial local SDP (offer)"));
gr.collapsed(true);
gr.log("%s", offer.sdp);
gr.end();
}
try {
offer.sdp = this.sdpProcessor.processOutgoingSdp(offer.sdp, "offer");
const gr = logGroupNative(LogType.TRACE, LogCategory.WEBRTC, tra("Patched initial local SDP (offer)"));
gr.collapsed(true);
gr.log("%s", offer.sdp);
gr.end();
} catch (error) {
logError(LogCategory.WEBRTC, tr("Failed to preprocess outgoing initial offer: %o"), error);
this.handleFatalError(tr("Failed to preprocess outgoing initial offer"), true);
return;
}
await peer.setLocalDescription(offer);
if(this.peer !== peer) { return; }
try {
await this.connection.send_command("rtcsessiondescribe", {
mode: "offer",
sdp: offer.sdp
});
} catch (error) {
if(this.peer !== peer) { return; }
if(error instanceof CommandResult) {
if(error.id === ErrorCode.COMMAND_NOT_FOUND) {
this.setNotSupported();
return;
}
error = error.formattedMessage();
}
logWarn(LogCategory.VOICE, tr("Failed to initialize RTP connection: %o"), error);
throw tr("server failed to accept our offer");
}
if(this.peer !== peer) { return; }
this.peer.onnegotiationneeded = () => this.handleNegotiationNeeded();
this.connectTimeout = setTimeout(() => {
this.handleFatalError("Connection initialize timeout", true);
}, 10_000);
/* Nothing left to do. Server should send a notifyrtcsessiondescription with mode answer */
}
private handleConnectionStateChanged(event: ServerConnectionEvents["notify_connection_state_changed"]) {
if(event.newState === ConnectionState.CONNECTED) {
/* will be called by the server connection handler */
} else {
this.reset(true);
this.retryCalculator.reset();
}
}
private handleLocalIceCandidate(candidate: RTCIceCandidate | undefined) {
if(candidate) {
if(candidate.address?.endsWith(".local")) {
logTrace(LogCategory.WEBRTC, tr("Skipping local fqdn ICE candidate %s"), candidate.toJSON().candidate);
return;
}
this.localCandidateCount++;
const json = candidate.toJSON();
logTrace(LogCategory.WEBRTC, tr("Received local ICE candidate %s"), json.candidate);
this.connection.send_command("rtcicecandidate", {
media_line: json.sdpMLineIndex,
candidate: json.candidate
}).catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to transmit local ICE candidate to server: %o"), error);
});
} else {
if(this.localCandidateCount === 0) {
logError(LogCategory.WEBRTC, tr("Received local ICE candidate finish, without having any candidates"));
this.handleFatalError(tr("Failed to gather any local ICE candidates."), false);
return;
} else {
logTrace(LogCategory.WEBRTC, tr("Received local ICE candidate finish"));
}
this.connection.send_command("rtcicecandidate", { }).catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to transmit local ICE candidate finish to server: %o"), error);
});
}
}
public handleRemoteIceCandidate(candidate: RTCIceCandidate | undefined, mediaLine: number) {
if(!this.peer) {
logWarn(LogCategory.WEBRTC, tr("Received remote ICE candidate without an active peer. Dropping candidate."));
return;
}
if(!this.peerRemoteDescriptionReceived) {
logTrace(LogCategory.WEBRTC, tr("Received remote ICE candidate but haven't yet received a remote description. Caching the candidate."));
this.cachedRemoteIceCandidates.push({ mediaLine: mediaLine, candidate: candidate });
return;
}
if(!candidate) {
/* candidates finished */
} else {
this.peer.addIceCandidate(candidate).then(() => {
logTrace(LogCategory.WEBRTC, tr("Successfully added a remote ice candidate for media line %d: %s"), mediaLine, candidate.candidate);
}).catch(error => {
logWarn(LogCategory.WEBRTC, tr("Failed to add a remote ice candidate for media line %d: %o (Candidate: %s)"), mediaLine, error, candidate.candidate);
});
}
}
public applyCachedRemoteIceCandidates() {
for(const { candidate, mediaLine } of this.cachedRemoteIceCandidates) {
this.handleRemoteIceCandidate(candidate, mediaLine);
}
this.handleRemoteIceCandidate(undefined, 0);
this.cachedRemoteIceCandidates = [];
}
private handleIceCandidateError(event: RTCPeerConnectionIceErrorEvent) {
if(this.peer.iceGatheringState === "gathering") {
logWarn(LogCategory.WEBRTC, tr("Received error while gathering the ice candidates: %d/%s for %s (url: %s)"),
event.errorCode, event.errorText, event.hostCandidate, event.url);
} else {
logTrace(LogCategory.WEBRTC, tr("Ice candidate %s (%s) errored: %d/%s"),
event.url, event.hostCandidate, event.errorCode, event.errorText);
}
}
private handleIceConnectionStateChanged() {
logTrace(LogCategory.WEBRTC, tr("ICE connection state changed to %s"), this.peer.iceConnectionState);
}
private handleIceGatheringStateChanged() {
logTrace(LogCategory.WEBRTC, tr("ICE gathering state changed to %s"), this.peer.iceGatheringState);
}
private handleSignallingStateChanged() {
logTrace(LogCategory.WEBRTC, tr("Peer signalling state changed to %s"), this.peer.signalingState);
}
private handleNegotiationNeeded() {
logWarn(LogCategory.WEBRTC, tr("Local peer needs negotiation, but that's not supported that."));
}
private handlePeerConnectionStateChanged() {
logTrace(LogCategory.WEBRTC, tr("Peer connection state changed to %s"), this.peer.connectionState);
switch (this.peer.connectionState) {
case "connecting":
this.updateConnectionState(RTPConnectionState.CONNECTING);
break;
case "connected":
clearTimeout(this.connectTimeout);
this.retryCalculator.reset();
this.updateConnectionState(RTPConnectionState.CONNECTED);
break;
case "failed":
if(this.connectionState !== RTPConnectionState.FAILED) {
this.handleFatalError(tr("peer connection failed"), true);
}
break;
case "closed":
case "disconnected":
case "new":
if(this.connectionState !== RTPConnectionState.FAILED) {
this.updateConnectionState(RTPConnectionState.DISCONNECTED);
}
break;
}
}
private handleDataChannel(_channel: RTCDataChannel) {
/* We're not doing anything with data channels */
}
private releaseTemporaryStream(ssrc: number) : TemporaryRtpStream | undefined {
if(this.temporaryStreams[ssrc]) {
const stream = this.temporaryStreams[ssrc];
clearTimeout(stream.timeoutId);
stream.timeoutId = 0;
delete this.temporaryStreams[ssrc];
return stream;
}
return undefined;
}
private handleTrack(event: RTCTrackEvent) {
const ssrc = this.sdpProcessor.getRemoteSsrcFromFromMediaId(event.transceiver.mid);
if(typeof ssrc !== "number") {
logError(LogCategory.WEBRTC, tr("Received track without knowing its ssrc. Ignoring track..."));
return;
}
const tempInfo = this.releaseTemporaryStream(ssrc);
if(event.track.kind === "audio") {
if(!this.audioSupport) {
logWarn(LogCategory.WEBRTC, tr("Received remote audio track %d but audio has been disabled. Dropping track."), ssrc);
return;
}
const track = new InternalRemoteRTPAudioTrack(ssrc, event.transceiver);
logDebug(LogCategory.WEBRTC, tr("Received remote audio track on ssrc %o"), ssrc);
if(tempInfo?.info !== undefined) {
track.handleAssignment(tempInfo.info);
this.events.fire("notify_audio_assignment_changed", {
info: tempInfo.info,
track: track
});
}
if(tempInfo?.status !== undefined) {
track.handleStateNotify(tempInfo.status, tempInfo.info);
}
this.remoteAudioTracks[ssrc] = track;
} else if(event.track.kind === "video") {
const track = new InternalRemoteRTPVideoTrack(ssrc, event.transceiver);
logDebug(LogCategory.WEBRTC, tr("Received remote video track on ssrc %o"), ssrc);
if(tempInfo?.info !== undefined) {
track.handleAssignment(tempInfo.info);
this.events.fire("notify_video_assignment_changed", {
info: tempInfo.info,
track: track
});
}
if(tempInfo?.status !== undefined) {
track.handleStateNotify(tempInfo.status, tempInfo.info);
}
this.remoteVideoTracks[ssrc] = track;
} else {
logWarn(LogCategory.WEBRTC, tr("Received track with unknown kind '%s'."), event.track.kind);
}
}
private getOrCreateTempStream(ssrc: number) : TemporaryRtpStream {
if(this.temporaryStreams[ssrc]) {
return this.temporaryStreams[ssrc];
}
const tempStream = this.temporaryStreams[ssrc] = {
ssrc: ssrc,
timeoutId: 0,
createTimestamp: Date.now(),
info: undefined,
status: undefined
};
tempStream.timeoutId = setTimeout(() => {
logWarn(LogCategory.WEBRTC, tr("Received stream mapping for invalid stream which hasn't been signalled after 5 seconds (ssrc: %o)."), ssrc);
delete this.temporaryStreams[ssrc];
}, 5000);
return tempStream;
}
private doMapStream(ssrc: number, target: TrackClientInfo | undefined) {
if(this.remoteAudioTracks[ssrc]) {
const track = this.remoteAudioTracks[ssrc];
track.handleAssignment(target);
this.events.fire("notify_audio_assignment_changed", {
info: target,
track: track
});
} else if(this.remoteVideoTracks[ssrc]) {
const track = this.remoteVideoTracks[ssrc];
track.handleAssignment(target);
this.events.fire("notify_video_assignment_changed", {
info: target,
track: track
});
} else {
let tempStream = this.getOrCreateTempStream(ssrc);
tempStream.info = target;
}
}
private handleStreamState(ssrc: number, state: number, info: TrackClientInfo | undefined) {
if(this.remoteAudioTracks[ssrc]) {
const track = this.remoteAudioTracks[ssrc];
track.handleStateNotify(state, info);
} else if(this.remoteVideoTracks[ssrc]) {
const track = this.remoteVideoTracks[ssrc];
track.handleStateNotify(state, info);
} else {
let tempStream = this.getOrCreateTempStream(ssrc);
if(typeof info.media === "undefined") {
/* the media will only be send on stream assignments, not on stream state changes */
info.media = tempStream.info?.media;
}
tempStream.info = info;
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
};
}
}
async getVideoBroadcastStatistics(type: RTCBroadcastableTrackType) : Promise<RtcVideoBroadcastStatistics | undefined> {
if(!this.currentTransceiver[type]?.sender) { return undefined; }
const senderStatistics = new RTCStatsWrapper(() => this.currentTransceiver[type].sender.getStats());
await senderStatistics.initialize();
if(senderStatistics.getValues().length === 0) { return undefined; }
const trackSettings = this.currentTransceiver[type].sender.track?.getSettings() || {};
const result = {} as RtcVideoBroadcastStatistics;
const outboundStream = senderStatistics.getStatisticByType("outboundrtp");
/* only available in chrome */
if("codecId" in outboundStream) {
if(typeof outboundStream.codecId !== "string") { throw tr("invalid codec id type"); }
if(senderStatistics[outboundStream.codecId]?.type !== "codec") { throw tra("invalid/missing codec statistic for codec {}", outboundStream.codecId); }
const codecInfo = senderStatistics[outboundStream.codecId];
if(typeof codecInfo.mimeType !== "string") { throw tr("codec statistic missing mine type"); }
if(typeof codecInfo.payloadType !== "number") { throw tr("codec statistic has invalid payloadType type"); }
result.codec = {
name: codecInfo.mimeType.startsWith("video/") ? codecInfo.mimeType.substr(6) : codecInfo.mimeType || tr("unknown"),
payloadType: codecInfo.payloadType
};
} else {
/* TODO: Get the only one video type from the sdp */
}
if("frameWidth" in outboundStream && "frameHeight" in outboundStream) {
if(typeof outboundStream.frameWidth !== "number") { throw tr("invalid frameWidth attribute of outboundrtp statistic"); }
if(typeof outboundStream.frameHeight !== "number") { throw tr("invalid frameHeight attribute of outboundrtp statistic"); }
result.dimensions = {
width: outboundStream.frameWidth,
height: outboundStream.frameHeight
};
} else if("height" in trackSettings && "width" in trackSettings) {
result.dimensions = {
height: trackSettings.height,
width: trackSettings.width
};
} else {
result.dimensions = {
width: 0,
height: 0
};
}
if("framesPerSecond" in outboundStream) {
if(typeof outboundStream.framesPerSecond !== "number") { throw tr("invalid framesPerSecond attribute of outboundrtp statistic"); }
result.frameRate = outboundStream.framesPerSecond;
} else if("frameRate" in trackSettings) {
result.frameRate = trackSettings.frameRate;
} else {
result.frameRate = 0;
}
if("qualityLimitationReason" in outboundStream) {
/* TODO: verify the value? */
if(typeof outboundStream.qualityLimitationReason !== "string") { throw tr("invalid qualityLimitationReason attribute of outboundrtp statistic"); }
result.qualityLimitation = outboundStream.qualityLimitationReason as any;
} else {
result.qualityLimitation = "none";
}
if("mediaSourceId" in outboundStream) {
if(typeof outboundStream.mediaSourceId !== "string") { throw tr("invalid media source type"); }
if(senderStatistics[outboundStream.mediaSourceId]?.type !== "media-source") { throw tra("invalid/missing media source statistic for source {}", outboundStream.mediaSourceId); }
const source = senderStatistics[outboundStream.mediaSourceId];
if(typeof source.width !== "number") { throw tr("invalid width attribute of media-source statistic"); }
if(typeof source.height !== "number") { throw tr("invalid height attribute of media-source statistic"); }
if(typeof source.framesPerSecond !== "number") { throw tr("invalid framesPerSecond attribute of media-source statistic"); }
result.source = {
dimensions: { height: source.height, width: source.width },
frameRate: source.framesPerSecond
};
} else {
result.source = {
dimensions: { width: 0, height: 0 },
frameRate: 0
};
if("height" in trackSettings && "width" in trackSettings) {
result.source.dimensions = {
height: trackSettings.height,
width: trackSettings.width
};
}
if("frameRate" in trackSettings) {
result.source.frameRate = trackSettings.frameRate;
}
}
return result;
}
}