Improved the video rendering and selection
parent
f3fb114115
commit
aada747f4e
|
@ -24,7 +24,6 @@ export type VideoBroadcastStatistics = {
|
|||
|
||||
export interface VideoConnectionEvent {
|
||||
notify_status_changed: { oldState: VideoConnectionStatus, newState: VideoConnectionStatus },
|
||||
notify_local_broadcast_state_changed: { broadcastType: VideoBroadcastType, oldState: VideoBroadcastState, newState: VideoBroadcastState },
|
||||
}
|
||||
|
||||
export enum VideoConnectionStatus {
|
||||
|
@ -64,6 +63,44 @@ export interface VideoClient {
|
|||
leaveBroadcast(broadcastType: VideoBroadcastType);
|
||||
}
|
||||
|
||||
export interface LocalVideoBroadcastEvents {
|
||||
notify_state_changed: { oldState: LocalVideoBroadcastState, newState: LocalVideoBroadcastState },
|
||||
}
|
||||
|
||||
export type LocalVideoBroadcastState = {
|
||||
state: "stopped",
|
||||
} | {
|
||||
state: "initializing"
|
||||
} | {
|
||||
state: "failed",
|
||||
reason: string
|
||||
} | {
|
||||
state: "broadcasting"
|
||||
}
|
||||
|
||||
export interface LocalVideoBroadcast {
|
||||
getEvents() : Registry<LocalVideoBroadcastEvents>;
|
||||
|
||||
getState() : LocalVideoBroadcastState;
|
||||
getSource() : VideoSource | undefined;
|
||||
getStatistics() : Promise<VideoBroadcastStatistics | undefined>;
|
||||
|
||||
//getBandwidthLimit() : number | undefined;
|
||||
//setBandwidthLimit(value: number);
|
||||
|
||||
/**
|
||||
* @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!)
|
||||
*/
|
||||
startBroadcasting(source: VideoSource) : Promise<void>;
|
||||
|
||||
/**
|
||||
* @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!)
|
||||
*/
|
||||
changeSource(source: VideoSource) : Promise<void>;
|
||||
|
||||
stopBroadcasting();
|
||||
}
|
||||
|
||||
export interface VideoConnection {
|
||||
getEvents() : Registry<VideoConnectionEvent>;
|
||||
|
||||
|
@ -73,17 +110,7 @@ export interface VideoConnection {
|
|||
|
||||
getConnectionStats() : Promise<ConnectionStatistics>;
|
||||
|
||||
isBroadcasting(type: VideoBroadcastType);
|
||||
getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined;
|
||||
getBroadcastingState(type: VideoBroadcastType) : VideoBroadcastState;
|
||||
getBroadcastStatistics(type: VideoBroadcastType) : Promise<VideoBroadcastStatistics | undefined>;
|
||||
|
||||
/**
|
||||
* @param type
|
||||
* @param source The source of the broadcast (No ownership will be taken. The voice connection must ref the source by itself!)
|
||||
*/
|
||||
startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise<void>;
|
||||
stopBroadcasting(type: VideoBroadcastType);
|
||||
getLocalBroadcast(channel: VideoBroadcastType) : LocalVideoBroadcast;
|
||||
|
||||
registerVideoClient(clientId: number);
|
||||
registeredVideoClients() : VideoClient[];
|
||||
|
|
|
@ -64,6 +64,7 @@ class RetryTimeCalculator {
|
|||
}
|
||||
|
||||
calculateRetryTime() {
|
||||
return 0;
|
||||
if(this.retryCount >= 5) {
|
||||
/* no more retries */
|
||||
return 0;
|
||||
|
@ -203,7 +204,7 @@ class CommandHandler extends AbstractCommandHandler {
|
|||
}).then(() => {
|
||||
this.handle["cachedRemoteSessionDescription"] = sdp;
|
||||
this.handle["peerRemoteDescriptionReceived"] = true;
|
||||
this.handle.applyCachedRemoteIceCandidates();
|
||||
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);
|
||||
|
@ -954,9 +955,6 @@ export class RTCConnection {
|
|||
logTrace(LogCategory.WEBRTC, tr("Skipping local fqdn ICE candidate %s"), candidate.toJSON().candidate);
|
||||
return;
|
||||
}
|
||||
if(candidate.protocol !== "tcp") {
|
||||
return;
|
||||
}
|
||||
this.localCandidateCount++;
|
||||
|
||||
const json = candidate.toJSON();
|
||||
|
@ -1009,6 +1007,7 @@ export class RTCConnection {
|
|||
this.handleRemoteIceCandidate(candidate, mediaLine);
|
||||
}
|
||||
|
||||
this.handleRemoteIceCandidate(undefined, 0);
|
||||
this.cachedRemoteIceCandidates = [];
|
||||
}
|
||||
|
||||
|
|
|
@ -81,11 +81,22 @@ export class SdpProcessor {
|
|||
sdpString = sdpString.replace(/profile-level-id=4325407/g, "profile-level-id=42e01f");
|
||||
|
||||
const sdp = sdpTransform.parse(sdpString);
|
||||
//sdp.media.forEach(media => media.candidates = []);
|
||||
//sdp.origin.address = "127.0.0.1";
|
||||
this.rtpRemoteChannelMapping = SdpProcessor.generateRtpSSrcMapping(sdp);
|
||||
|
||||
return sdpTransform.write(sdp);
|
||||
}
|
||||
|
||||
/*
|
||||
getCandidates(sdpString: string) : string[] {
|
||||
const sdp = sdpTransform.parse(sdpString);
|
||||
sdp.media = [sdp.media[0]];
|
||||
sdpTransform.write(sdp).split("\r\n")
|
||||
.filter(line => line.startsWith("a"))
|
||||
}
|
||||
*/
|
||||
|
||||
processOutgoingSdp(sdpString: string, _mode: "offer" | "answer") : string {
|
||||
const sdp = sdpTransform.parse(sdpString);
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import {
|
||||
VideoBroadcastState,
|
||||
LocalVideoBroadcast,
|
||||
LocalVideoBroadcastEvents,
|
||||
LocalVideoBroadcastState,
|
||||
VideoBroadcastStatistics,
|
||||
VideoBroadcastType,
|
||||
VideoClient,
|
||||
|
@ -9,19 +11,236 @@ import {
|
|||
} from "tc-shared/connection/VideoConnection";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {VideoSource} from "tc-shared/video/VideoSource";
|
||||
import {RTCConnection, RTCConnectionEvents, RTPConnectionState} from "../Connection";
|
||||
import {LogCategory, logDebug, logError, logWarn} from "tc-shared/log";
|
||||
import {RTCBroadcastableTrackType, RTCConnection, RTCConnectionEvents, RTPConnectionState} from "../Connection";
|
||||
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
import {RtpVideoClient} from "./VideoClient";
|
||||
import {tr} from "tc-shared/i18n/localize";
|
||||
import {ConnectionState} from "tc-shared/ConnectionHandler";
|
||||
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
||||
import * as _ from "lodash";
|
||||
|
||||
type VideoBroadcast = {
|
||||
readonly source: VideoSource;
|
||||
state: VideoBroadcastState,
|
||||
failedReason: string | undefined,
|
||||
active: boolean
|
||||
class LocalRtpVideoBroadcast implements LocalVideoBroadcast {
|
||||
private readonly handle: RtpVideoConnection;
|
||||
private readonly type: VideoBroadcastType;
|
||||
private readonly events: Registry<LocalVideoBroadcastEvents>;
|
||||
|
||||
private state: LocalVideoBroadcastState;
|
||||
private currentSource: VideoSource;
|
||||
private broadcastStartId: number;
|
||||
|
||||
private localStartPromise: Promise<void>;
|
||||
|
||||
constructor(handle: RtpVideoConnection, type: VideoBroadcastType) {
|
||||
this.handle = handle;
|
||||
this.type = type;
|
||||
this.broadcastStartId = 0;
|
||||
|
||||
this.events = new Registry<LocalVideoBroadcastEvents>();
|
||||
this.state = { state: "stopped" };
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.events.destroy();
|
||||
}
|
||||
|
||||
getEvents(): Registry<LocalVideoBroadcastEvents> {
|
||||
return this.events;
|
||||
}
|
||||
|
||||
getSource(): VideoSource | undefined {
|
||||
return this.currentSource;
|
||||
}
|
||||
|
||||
getState(): LocalVideoBroadcastState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private setState(newState: LocalVideoBroadcastState) {
|
||||
if(_.isEqual(this.state, newState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldState = this.state;
|
||||
this.state = newState;
|
||||
this.events.fire("notify_state_changed", { oldState: oldState, newState: newState });
|
||||
}
|
||||
|
||||
getStatistics(): Promise<VideoBroadcastStatistics | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
async changeSource(source: VideoSource): Promise<void> {
|
||||
const videoTracks = source.getStream().getVideoTracks();
|
||||
if(videoTracks.length === 0) {
|
||||
throw tr("missing video stream track");
|
||||
}
|
||||
|
||||
let sourceRef = source.ref();
|
||||
while(this.localStartPromise) {
|
||||
await this.localStartPromise;
|
||||
}
|
||||
|
||||
if(this.state.state !== "broadcasting") {
|
||||
sourceRef.deref();
|
||||
throw tr("not broadcasting anything");
|
||||
}
|
||||
|
||||
const startId = ++this.broadcastStartId;
|
||||
let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen";
|
||||
try {
|
||||
await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, videoTracks[0]);
|
||||
} catch (error) {
|
||||
if(this.broadcastStartId !== startId) {
|
||||
/* broadcast start has been canceled */
|
||||
return;
|
||||
}
|
||||
|
||||
sourceRef.deref();
|
||||
logError(LogCategory.WEBRTC, tr("Failed to change video track for broadcast %s: %o"), this.type, error);
|
||||
throw tr("failed to change video track");
|
||||
}
|
||||
|
||||
this.setCurrentSource(sourceRef);
|
||||
sourceRef.deref();
|
||||
}
|
||||
|
||||
private setCurrentSource(source: VideoSource | undefined) {
|
||||
if(this.currentSource) {
|
||||
this.currentSource.deref();
|
||||
}
|
||||
this.currentSource = source?.ref();
|
||||
}
|
||||
|
||||
async startBroadcasting(source: VideoSource): Promise<void> {
|
||||
const sourceRef = source.ref();
|
||||
while(this.localStartPromise) {
|
||||
await this.localStartPromise;
|
||||
}
|
||||
|
||||
const promise = this.doStartBroadcast(source);
|
||||
this.localStartPromise = promise.catch(() => {});
|
||||
this.localStartPromise.then(() => this.localStartPromise = undefined);
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
sourceRef.deref();
|
||||
}
|
||||
}
|
||||
|
||||
private async doStartBroadcast(source: VideoSource) {
|
||||
const videoTracks = source.getStream().getVideoTracks();
|
||||
if(videoTracks.length === 0) {
|
||||
throw tr("missing video stream track");
|
||||
}
|
||||
const startId = ++this.broadcastStartId;
|
||||
|
||||
this.setCurrentSource(source);
|
||||
this.setState({ state: "initializing" });
|
||||
|
||||
if(this.broadcastStartId !== startId) {
|
||||
/* broadcast start has been canceled */
|
||||
return;
|
||||
}
|
||||
|
||||
let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen";
|
||||
|
||||
try {
|
||||
await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, videoTracks[0]);
|
||||
} catch (error) {
|
||||
if(this.broadcastStartId !== startId) {
|
||||
/* broadcast start has been canceled */
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopBroadcasting(true, { state: "failed", reason: tr("Failed to set track source") });
|
||||
logError(LogCategory.WEBRTC, tr("Failed to setup video track for broadcast %s: %o"), this.type, error);
|
||||
throw tr("failed to initialize video track");
|
||||
}
|
||||
|
||||
if(this.broadcastStartId !== startId) {
|
||||
/* broadcast start has been canceled */
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.handle.getRTCConnection().startTrackBroadcast(rtcBroadcastType);
|
||||
} catch (error) {
|
||||
if(this.broadcastStartId !== startId) {
|
||||
/* broadcast start has been canceled */
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopBroadcasting(true, { state: "failed", reason: error });
|
||||
throw error;
|
||||
}
|
||||
|
||||
if(this.broadcastStartId !== startId) {
|
||||
/* broadcast start has been canceled */
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ state: "broadcasting" });
|
||||
}
|
||||
|
||||
stopBroadcasting(skipRtcStop?: boolean, stopState?: LocalVideoBroadcastState) {
|
||||
if(this.state.state === "stopped" && (!stopState || _.isEqual(stopState, this.state))) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.broadcastStartId++;
|
||||
|
||||
(async () => {
|
||||
while(this.localStartPromise) {
|
||||
await this.localStartPromise;
|
||||
}
|
||||
|
||||
let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen";
|
||||
if(!skipRtcStop && !(this.state.state === "failed" || this.state.state === "stopped")) {
|
||||
this.handle.getRTCConnection().stopTrackBroadcast(rtcBroadcastType);
|
||||
}
|
||||
|
||||
this.setCurrentSource(undefined);
|
||||
|
||||
try {
|
||||
await this.handle.getRTCConnection().setTrackSource(rtcBroadcastType, null);
|
||||
} catch (error) {
|
||||
logWarn(LogCategory.VIDEO, tr("Failed to change the RTC video track to null: %o"), error);
|
||||
}
|
||||
this.setState(stopState || { state: "stopped" });
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the broadcast after a channel switch.
|
||||
*/
|
||||
restartBroadcast() {
|
||||
(async () => {
|
||||
while(this.localStartPromise) {
|
||||
await this.localStartPromise;
|
||||
}
|
||||
|
||||
if(this.state.state !== "broadcasting") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ state: "initializing" });
|
||||
let rtcBroadcastType: RTCBroadcastableTrackType = this.type === "camera" ? "video" : "video-screen";
|
||||
const startId = ++this.broadcastStartId;
|
||||
|
||||
try {
|
||||
await this.handle.getRTCConnection().startTrackBroadcast(rtcBroadcastType);
|
||||
} catch (error) {
|
||||
if(this.broadcastStartId !== startId) {
|
||||
/* broadcast start has been canceled */
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopBroadcasting(true, { state: "failed", reason: error });
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
export class RtpVideoConnection implements VideoConnection {
|
||||
|
@ -30,9 +249,9 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
private readonly listener: (() => void)[];
|
||||
private connectionState: VideoConnectionStatus;
|
||||
|
||||
private broadcasts: {[T in VideoBroadcastType]: VideoBroadcast} = {
|
||||
camera: undefined,
|
||||
screen: undefined
|
||||
private broadcasts: {[T in VideoBroadcastType]: LocalRtpVideoBroadcast} = {
|
||||
camera: new LocalRtpVideoBroadcast(this, "camera"),
|
||||
screen: new LocalRtpVideoBroadcast(this, "screen")
|
||||
};
|
||||
private registeredClients: {[key: number]: RtpVideoClient} = {};
|
||||
|
||||
|
@ -55,12 +274,10 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
});
|
||||
|
||||
if(settings.static_global(Settings.KEY_STOP_VIDEO_ON_SWITCH)) {
|
||||
this.stopBroadcasting("camera", true);
|
||||
this.stopBroadcasting("screen", true);
|
||||
Object.values(this.broadcasts).forEach(broadcast => broadcast.stopBroadcasting());
|
||||
} else {
|
||||
/* The server stops broadcasting by default, we've to reenable it */
|
||||
this.restartBroadcast("screen");
|
||||
this.restartBroadcast("camera");
|
||||
Object.values(this.broadcasts).forEach(broadcast => broadcast.restartBroadcast());
|
||||
}
|
||||
} else if(parseInt("scid") === localClient.currentChannel().channelId) {
|
||||
const broadcast = this.registeredClients[clientId];
|
||||
|
@ -120,8 +337,7 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
|
||||
this.listener.push(this.rtcConnection.getConnection().events.on("notify_connection_state_changed", event => {
|
||||
if(event.newState !== ConnectionState.CONNECTED) {
|
||||
this.stopBroadcasting("camera");
|
||||
this.stopBroadcasting("screen");
|
||||
Object.values(this.broadcasts).forEach(broadcast => broadcast.stopBroadcasting(true));
|
||||
}
|
||||
}));
|
||||
|
||||
|
@ -157,30 +373,6 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
this.events.fire("notify_status_changed", { oldState: oldState, newState: state });
|
||||
}
|
||||
|
||||
private restartBroadcast(type: VideoBroadcastType) {
|
||||
if(!this.broadcasts[type]?.active) { return; }
|
||||
const broadcast = this.broadcasts[type];
|
||||
|
||||
if(broadcast.state !== VideoBroadcastState.Initializing) {
|
||||
const oldState = broadcast.state;
|
||||
broadcast.state = VideoBroadcastState.Initializing;
|
||||
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type });
|
||||
}
|
||||
|
||||
this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen").then(() => {
|
||||
if(!broadcast.active) { return; }
|
||||
|
||||
const oldState = broadcast.state;
|
||||
broadcast.state = VideoBroadcastState.Running;
|
||||
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Initializing, broadcastType: type });
|
||||
logDebug(LogCategory.VIDEO, tr("Successfully restarted video broadcast of type %s"), type);
|
||||
}).catch(error => {
|
||||
if(!broadcast.active) { return; }
|
||||
logWarn(LogCategory.VIDEO, tr("Failed to restart video broadcast %s: %o"), type, error);
|
||||
this.stopBroadcasting(type, true);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.listener.forEach(callback => callback());
|
||||
this.listener.splice(0, this.listener.length);
|
||||
|
@ -188,6 +380,10 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
this.events.destroy();
|
||||
}
|
||||
|
||||
getRTCConnection() : RTCConnection {
|
||||
return this.rtcConnection;
|
||||
}
|
||||
|
||||
getEvents(): Registry<VideoConnectionEvent> {
|
||||
return this.events;
|
||||
}
|
||||
|
@ -204,83 +400,6 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
return this.rtcConnection.getFailReason();
|
||||
}
|
||||
|
||||
getBroadcastingState(type: VideoBroadcastType): VideoBroadcastState {
|
||||
return this.broadcasts[type] ? this.broadcasts[type].state : VideoBroadcastState.Stopped;
|
||||
}
|
||||
|
||||
async getBroadcastStatistics(type: VideoBroadcastType): Promise<VideoBroadcastStatistics | undefined> {
|
||||
/* TODO! */
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getBroadcastingSource(type: VideoBroadcastType): VideoSource | undefined {
|
||||
return this.broadcasts[type]?.source;
|
||||
}
|
||||
|
||||
isBroadcasting(type: VideoBroadcastType) {
|
||||
return typeof this.broadcasts[type] !== "undefined";
|
||||
}
|
||||
|
||||
async startBroadcasting(type: VideoBroadcastType, source: VideoSource) : Promise<void> {
|
||||
const videoTracks = source.getStream().getVideoTracks();
|
||||
if(videoTracks.length === 0) {
|
||||
throw tr("missing video stream track");
|
||||
}
|
||||
|
||||
const broadcast = this.broadcasts[type] = {
|
||||
source: source.ref(),
|
||||
state: VideoBroadcastState.Initializing as VideoBroadcastState,
|
||||
failedReason: undefined,
|
||||
active: true
|
||||
};
|
||||
this.events.fire("notify_local_broadcast_state_changed", { oldState: this.broadcasts[type].state || VideoBroadcastState.Stopped, newState: VideoBroadcastState.Initializing, broadcastType: type });
|
||||
|
||||
try {
|
||||
await this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", videoTracks[0]);
|
||||
} catch (error) {
|
||||
this.stopBroadcasting(type);
|
||||
logError(LogCategory.WEBRTC, tr("Failed to setup video track for broadcast %s: %o"), type, error);
|
||||
throw tr("failed to initialize video track");
|
||||
}
|
||||
|
||||
if(!broadcast.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.rtcConnection.startTrackBroadcast(type === "camera" ? "video" : "video-screen");
|
||||
} catch (error) {
|
||||
this.stopBroadcasting(type);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if(!broadcast.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
broadcast.state = VideoBroadcastState.Running;
|
||||
this.events.fire("notify_local_broadcast_state_changed", { oldState: VideoBroadcastState.Initializing, newState: VideoBroadcastState.Running, broadcastType: type });
|
||||
}
|
||||
|
||||
stopBroadcasting(type: VideoBroadcastType, skipRtcStop?: boolean) {
|
||||
const broadcast = this.broadcasts[type];
|
||||
if(!broadcast) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(!skipRtcStop) {
|
||||
this.rtcConnection.stopTrackBroadcast(type === "camera" ? "video" : "video-screen");
|
||||
}
|
||||
|
||||
this.rtcConnection.setTrackSource(type === "camera" ? "video" : "video-screen", null).then(undefined);
|
||||
const oldState = this.broadcasts[type].state;
|
||||
this.broadcasts[type].active = false;
|
||||
this.broadcasts[type] = undefined;
|
||||
broadcast.source.deref();
|
||||
|
||||
this.events.fire("notify_local_broadcast_state_changed", { oldState: oldState, newState: VideoBroadcastState.Stopped, broadcastType: type });
|
||||
}
|
||||
|
||||
registerVideoClient(clientId: number) {
|
||||
if(typeof this.registeredClients[clientId] !== "undefined") {
|
||||
debugger;
|
||||
|
@ -353,4 +472,8 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
bytesSend: stats.videoBytesSent
|
||||
};
|
||||
}
|
||||
|
||||
getLocalBroadcast(channel: VideoBroadcastType): LocalVideoBroadcast {
|
||||
return this.broadcasts[channel];
|
||||
}
|
||||
}
|
|
@ -197,8 +197,24 @@ export function initialize(event_registry: Registry<ClientGlobalControlEvents>)
|
|||
if(!source) { return; }
|
||||
|
||||
try {
|
||||
connection.getServerConnection().getVideoConnection().startBroadcasting(event.broadcastType, source)
|
||||
.catch(error => {
|
||||
const broadcast = connection.getServerConnection().getVideoConnection().getLocalBroadcast(event.broadcastType);
|
||||
if(broadcast.getState().state === "initializing" || broadcast.getState().state === "broadcasting") {
|
||||
console.error("Change source");
|
||||
broadcast.changeSource(source).catch(error => {
|
||||
logError(LogCategory.VIDEO, tr("Failed to change broadcast source: %o"), event.broadcastType, error);
|
||||
if(typeof error !== "string") {
|
||||
error = tr("lookup the console for detail");
|
||||
}
|
||||
|
||||
if(event.broadcastType === "camera") {
|
||||
createErrorModal(tr("Failed to change video source"), tra("Failed to change video broadcasting source:\n{}", error)).open();
|
||||
} else {
|
||||
createErrorModal(tr("Failed to change screen sharing source"), tra("Failed to change screen sharing source:\n{}", error)).open();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("Start broadcast");
|
||||
broadcast.startBroadcasting(source).catch(error => {
|
||||
logError(LogCategory.VIDEO, tr("Failed to start %s broadcasting: %o"), event.broadcastType, error);
|
||||
if(typeof error !== "string") {
|
||||
error = tr("lookup the console for detail");
|
||||
|
@ -210,12 +226,15 @@ export function initialize(event_registry: Registry<ClientGlobalControlEvents>)
|
|||
createErrorModal(tr("Failed to start screen sharing"), tra("Failed to start screen sharing:\n{}", error)).open();
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
source.deref();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.connection.getServerConnection().getVideoConnection().stopBroadcasting(event.broadcastType);
|
||||
const connection = event.connection;
|
||||
const broadcast = connection.getServerConnection().getVideoConnection().getLocalBroadcast(event.broadcastType);
|
||||
broadcast.stopBroadcasting();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -29,13 +29,19 @@ export interface ClientGlobalControlEvents {
|
|||
videoUrl: string,
|
||||
handlerId: string
|
||||
},
|
||||
/* Start/open a new video broadcast */
|
||||
action_toggle_video_broadcasting: {
|
||||
connection: ConnectionHandler,
|
||||
enabled: boolean,
|
||||
broadcastType: VideoBroadcastType,
|
||||
enabled: boolean,
|
||||
quickSelect?: boolean,
|
||||
defaultDevice?: string
|
||||
}
|
||||
},
|
||||
/* Open the broadcast edit window */
|
||||
action_edit_video_broadcasting: {
|
||||
connection: ConnectionHandler,
|
||||
broadcastType: VideoBroadcastType,
|
||||
},
|
||||
|
||||
/* some more specific window openings */
|
||||
action_open_window_connect: {
|
||||
|
|
|
@ -22,9 +22,10 @@ import {
|
|||
} from "tc-shared/bookmarks";
|
||||
import {LogCategory, logWarn} from "tc-shared/log";
|
||||
import {createErrorModal, createInputModal} from "tc-shared/ui/elements/Modal";
|
||||
import {VideoBroadcastState, VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/VideoConnection";
|
||||
import {VideoBroadcastType, VideoConnectionStatus} from "tc-shared/connection/VideoConnection";
|
||||
import { tr } from "tc-shared/i18n/localize";
|
||||
import {getVideoDriver} from "tc-shared/video/VideoSource";
|
||||
import {kLocalBroadcastChannels} from "tc-shared/ui/frames/video/Definitions";
|
||||
|
||||
class InfoController {
|
||||
private readonly mode: ControlBarMode;
|
||||
|
@ -127,7 +128,11 @@ class InfoController {
|
|||
}));
|
||||
|
||||
const videoConnection = handler.getServerConnection().getVideoConnection();
|
||||
events.push(videoConnection.getEvents().on(["notify_local_broadcast_state_changed", "notify_status_changed"], () => {
|
||||
for(const channel of kLocalBroadcastChannels) {
|
||||
const broadcast = videoConnection.getLocalBroadcast(channel);
|
||||
events.push(broadcast.getEvents().on("notify_state_changed", () => this.sendVideoState(channel)));
|
||||
}
|
||||
events.push(videoConnection.getEvents().on("notify_status_changed", () => {
|
||||
this.sendVideoState("screen");
|
||||
this.sendVideoState("camera");
|
||||
}));
|
||||
|
@ -253,7 +258,8 @@ class InfoController {
|
|||
if(this.currentHandler?.connected) {
|
||||
const videoConnection = this.currentHandler.getServerConnection().getVideoConnection();
|
||||
if(videoConnection.getStatus() === VideoConnectionStatus.Connected) {
|
||||
if(videoConnection.getBroadcastingState(type) === VideoBroadcastState.Running) {
|
||||
const broadcast = videoConnection.getLocalBroadcast(type);
|
||||
if(broadcast.getState().state === "broadcasting" || broadcast.getState().state === "initializing") {
|
||||
state = "enabled";
|
||||
} else {
|
||||
state = "disabled";
|
||||
|
|
|
@ -247,7 +247,7 @@ const VideoButton = (props: { type: VideoBroadcastType }) => {
|
|||
let tooltip = props.type === "camera" ? tr("Video broadcasting not supported") : tr("Screen sharing not supported");
|
||||
let modalTitle = props.type === "camera" ? tr("Video broadcasting unsupported") : tr("Screen sharing unsupported");
|
||||
let modalBody = props.type === "camera" ? tr("Video broadcasting isn't supported by the target server.") : tr("Screen sharing isn't supported by the target server.");
|
||||
let dropdownText = props.type === "camera" ? tr("Start screen sharing") : tr("Start video broadcasting");
|
||||
let dropdownText = props.type === "camera" ? tr("Start video broadcasting") : tr("Start screen sharing");
|
||||
return (
|
||||
<Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={icon} tooltip={tooltip}
|
||||
key={"unsupported"}
|
||||
|
@ -262,7 +262,7 @@ const VideoButton = (props: { type: VideoBroadcastType }) => {
|
|||
let tooltip = props.type === "camera" ? tr("Video broadcasting not available") : tr("Screen sharing not available");
|
||||
let modalTitle = props.type === "camera" ? tr("Video broadcasting unavailable") : tr("Screen sharing unavailable");
|
||||
let modalBody = props.type === "camera" ? tr("Video broadcasting isn't available right now.") : tr("Screen sharing isn't available right now.");
|
||||
let dropdownText = props.type === "camera" ? tr("Start screen sharing") : tr("Start video broadcasting");
|
||||
let dropdownText = props.type === "camera" ? tr("Start video broadcasting") : tr("Start screen sharing");
|
||||
return (
|
||||
<Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={icon} tooltip={tooltip}
|
||||
key={"unavailable"}
|
||||
|
@ -275,7 +275,7 @@ const VideoButton = (props: { type: VideoBroadcastType }) => {
|
|||
case "disconnected":
|
||||
case "disabled": {
|
||||
let tooltip = props.type === "camera" ? tr("Start video broadcasting") : tr("Start screen sharing");
|
||||
let dropdownText = props.type === "camera" ? tr("Start screen sharing") : tr("Start video broadcasting");
|
||||
let dropdownText = props.type === "camera" ? tr("Start video broadcasting") : tr("Start screen sharing");
|
||||
return (
|
||||
<Button switched={true} colorTheme={"red"} autoSwitch={false} iconNormal={icon}
|
||||
onToggle={() => events.fire("action_toggle_video", {enable: true, broadcastType: props.type, quickStart: true})}
|
||||
|
@ -288,15 +288,14 @@ const VideoButton = (props: { type: VideoBroadcastType }) => {
|
|||
|
||||
case "enabled": {
|
||||
let tooltip = props.type === "camera" ? tr("Stop video broadcasting") : tr("Stop screen sharing");
|
||||
let dropdownTextManage = props.type === "camera" ? tr("Configure/change screen sharing") : tr("Configure/change video broadcasting");
|
||||
let dropdownTextStop = props.type === "camera" ? tr("Stop screen sharing") : tr("Stop video broadcasting");
|
||||
let dropdownTextManage = props.type === "camera" ? tr("Configure/change video broadcasting") : tr("Configure/change screen sharing");
|
||||
let dropdownTextStop = props.type === "camera" ? tr("Stop video broadcasting") : tr("Stop screen sharing");
|
||||
return (
|
||||
<Button switched={false} colorTheme={"red"} autoSwitch={false} iconNormal={icon}
|
||||
onToggle={() => events.fire("action_toggle_video", {enable: false, broadcastType: props.type})}
|
||||
tooltip={tooltip} key={"disable"}>
|
||||
{/* <DropdownEntry icon={icon} text={dropdownTextManage} onClick={() => events.fire("action_manage_video", { broadcastType: props.type })} /> TODO! */}
|
||||
<DropdownEntry icon={icon} text={dropdownTextStop} onClick={() => events.fire("action_toggle_video", {enable: false, broadcastType: props.type})} />
|
||||
<VideoDeviceList />
|
||||
{props.type === "camera" ? <VideoDeviceList key={"list"} /> : null}
|
||||
</Button>
|
||||
);
|
||||
|
|
|
@ -3,8 +3,9 @@ import * as React from "react";
|
|||
import * as ReactDOM from "react-dom";
|
||||
import {ChannelVideoRenderer} from "tc-shared/ui/frames/video/Renderer";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
|
||||
import {ChannelVideo, ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
|
||||
import {
|
||||
LocalVideoBroadcastState,
|
||||
VideoBroadcastState,
|
||||
VideoBroadcastType,
|
||||
VideoClient,
|
||||
|
@ -14,6 +15,7 @@ import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-sh
|
|||
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
||||
import {tr} from "tc-shared/i18n/localize";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
import * as _ from "lodash";
|
||||
|
||||
const cssStyle = require("./Renderer.scss");
|
||||
|
||||
|
@ -24,7 +26,7 @@ interface ClientVideoController {
|
|||
dismissVideo(type: VideoBroadcastType);
|
||||
|
||||
notifyVideoInfo();
|
||||
notifyVideo();
|
||||
notifyVideo(forceSend: boolean);
|
||||
notifyMuteState();
|
||||
}
|
||||
|
||||
|
@ -43,6 +45,8 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
camera: false
|
||||
};
|
||||
|
||||
private cachedVideoStatus: ChannelVideo;
|
||||
|
||||
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>, videoId?: string) {
|
||||
this.client = client;
|
||||
this.events = eventRegistry;
|
||||
|
@ -70,20 +74,11 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
|
||||
private updateVideoClient() {
|
||||
this.eventListenerVideoClient?.forEach(callback => callback());
|
||||
const events = this.eventListenerVideoClient = [];
|
||||
this.eventListenerVideoClient = [];
|
||||
|
||||
const videoClient = this.client.getVideoClient();
|
||||
if(videoClient) {
|
||||
this.initializeVideoClient(videoClient);
|
||||
events.push(videoClient.getEvents().on("notify_broadcast_state_changed", event => {
|
||||
console.error("Broadcast state changed: %o - %o - %o", event.broadcastType, VideoBroadcastState[event.oldState], VideoBroadcastState[event.newState]);
|
||||
if(event.newState === VideoBroadcastState.Stopped || event.oldState === VideoBroadcastState.Stopped) {
|
||||
/* we've a new broadcast which hasn't been dismissed yet */
|
||||
this.dismissed[event.broadcastType] = false;
|
||||
}
|
||||
this.notifyVideo();
|
||||
this.notifyMuteState();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -94,7 +89,7 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
/* we've a new broadcast which hasn't been dismissed yet */
|
||||
this.dismissed[event.broadcastType] = false;
|
||||
}
|
||||
this.notifyVideo();
|
||||
this.notifyVideo(false);
|
||||
this.notifyMuteState();
|
||||
}));
|
||||
}
|
||||
|
@ -132,7 +127,7 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
}
|
||||
|
||||
this.dismissed[type] = true;
|
||||
this.notifyVideo();
|
||||
this.notifyVideo(false);
|
||||
}
|
||||
|
||||
notifyVideoInfo() {
|
||||
|
@ -147,9 +142,10 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
});
|
||||
}
|
||||
|
||||
notifyVideo() {
|
||||
notifyVideo(forceSend: boolean) {
|
||||
let broadcasting = false;
|
||||
if(this.isVideoActive()) {
|
||||
let status: ChannelVideo;
|
||||
if(this.hasVideoSupport()) {
|
||||
let initializing = false;
|
||||
|
||||
let cameraStream, desktopStream;
|
||||
|
@ -174,41 +170,34 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
|
||||
if(cameraStream || desktopStream) {
|
||||
broadcasting = true;
|
||||
this.events.fire_react("notify_video", {
|
||||
videoId: this.videoId,
|
||||
status: {
|
||||
status: "connected",
|
||||
status = {
|
||||
status: "connected",
|
||||
|
||||
desktopStream: desktopStream,
|
||||
cameraStream: cameraStream,
|
||||
desktopStream: desktopStream,
|
||||
cameraStream: cameraStream,
|
||||
|
||||
dismissed: this.dismissed
|
||||
}
|
||||
});
|
||||
dismissed: this.dismissed
|
||||
};
|
||||
} else if(initializing) {
|
||||
broadcasting = true;
|
||||
this.events.fire_react("notify_video", {
|
||||
videoId: this.videoId,
|
||||
status: { status: "initializing" }
|
||||
});
|
||||
status = { status: "initializing" };
|
||||
} else {
|
||||
this.events.fire_react("notify_video", {
|
||||
videoId: this.videoId,
|
||||
status: {
|
||||
status: "connected",
|
||||
status = {
|
||||
status: "connected",
|
||||
|
||||
cameraStream: undefined,
|
||||
desktopStream: undefined,
|
||||
cameraStream: undefined,
|
||||
desktopStream: undefined,
|
||||
|
||||
dismissed: this.dismissed
|
||||
}
|
||||
});
|
||||
dismissed: this.dismissed
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this.events.fire_react("notify_video", {
|
||||
videoId: this.videoId,
|
||||
status: { status: "no-video" }
|
||||
});
|
||||
status = { status: "no-video" };
|
||||
}
|
||||
|
||||
if(forceSend || !_.isEqual(this.cachedVideoStatus, status)) {
|
||||
this.cachedVideoStatus = status;
|
||||
this.events.fire_react("notify_video", { videoId: this.videoId, status: status });
|
||||
}
|
||||
|
||||
if(broadcasting !== this.currentBroadcastState) {
|
||||
|
@ -229,7 +218,7 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
});
|
||||
}
|
||||
|
||||
protected isVideoActive() : boolean {
|
||||
protected hasVideoSupport() : boolean {
|
||||
return typeof this.client.getVideoClient() !== "undefined";
|
||||
}
|
||||
|
||||
|
@ -244,14 +233,19 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
}
|
||||
}
|
||||
|
||||
const kLocalBroadcastChannels: VideoBroadcastType[] = ["screen", "camera"];
|
||||
class LocalVideoController extends RemoteClientVideoController {
|
||||
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>) {
|
||||
super(client, eventRegistry, kLocalVideoId);
|
||||
|
||||
const videoConnection = client.channelTree.client.serverConnection.getVideoConnection();
|
||||
this.eventListener.push(videoConnection.getEvents().on("notify_local_broadcast_state_changed", () => {
|
||||
this.notifyVideo();
|
||||
}));
|
||||
|
||||
for(const broadcastType of kLocalBroadcastChannels) {
|
||||
const broadcast = videoConnection.getLocalBroadcast(broadcastType);
|
||||
this.eventListener.push(broadcast.getEvents().on("notify_state_changed", () => {
|
||||
this.notifyVideo(false);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
protected initializeVideoClient(videoClient: VideoClient) {
|
||||
|
@ -267,19 +261,52 @@ class LocalVideoController extends RemoteClientVideoController {
|
|||
|
||||
isBroadcasting() {
|
||||
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
|
||||
return videoConnection.isBroadcasting("camera") || videoConnection.isBroadcasting("screen");
|
||||
const isBroadcasting = (state: LocalVideoBroadcastState) => state.state === "initializing" || state.state === "broadcasting";
|
||||
|
||||
for(const broadcastType of kLocalBroadcastChannels) {
|
||||
const broadcast = videoConnection.getLocalBroadcast(broadcastType);
|
||||
if(isBroadcasting(broadcast.getState())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* the super should return false as well but just in case something went wrong we want to give the user the visual feedback */
|
||||
return super.isBroadcasting();
|
||||
}
|
||||
|
||||
protected isVideoActive(): boolean {
|
||||
protected hasVideoSupport(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
protected getBroadcastState(target: VideoBroadcastType): VideoBroadcastState {
|
||||
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
|
||||
return videoConnection.getBroadcastingState(target);
|
||||
const broadcast = videoConnection.getLocalBroadcast(target);
|
||||
|
||||
const receivingState = super.getBroadcastState(target);
|
||||
switch (broadcast.getState().state) {
|
||||
case "stopped":
|
||||
case "failed":
|
||||
if(receivingState !== VideoBroadcastState.Stopped) {
|
||||
/* this should never happen but just in case give the client a visual feedback */
|
||||
return receivingState;
|
||||
}
|
||||
return VideoBroadcastState.Stopped;
|
||||
|
||||
case "initializing":
|
||||
return VideoBroadcastState.Initializing;
|
||||
|
||||
case "broadcasting":
|
||||
const state = super.getBroadcastState(target);
|
||||
if(state === VideoBroadcastState.Stopped) {
|
||||
/* we should receive a stream in a few seconds */
|
||||
return VideoBroadcastState.Initializing;
|
||||
} else {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
protected getBroadcastStream(target: VideoBroadcastType) : MediaStream | undefined {
|
||||
const videoConnection = this.client.channelTree.client.serverConnection.getVideoConnection();
|
||||
return videoConnection.getBroadcastingSource(target)?.getStream();
|
||||
|
@ -391,7 +418,7 @@ class ChannelVideoController {
|
|||
return;
|
||||
}
|
||||
|
||||
controller.notifyVideo();
|
||||
controller.notifyVideo(true);
|
||||
});
|
||||
|
||||
this.events.on("query_video_mute_status", event => {
|
||||
|
|
|
@ -2,6 +2,7 @@ import {ClientIcon} from "svg-sprites/client-icons";
|
|||
import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
|
||||
|
||||
export const kLocalVideoId = "__local__video__";
|
||||
export const kLocalBroadcastChannels: VideoBroadcastType[] = ["screen", "camera"];
|
||||
|
||||
export type ChannelVideoInfo = { clientName: string, clientUniqueId: string, clientId: number, statusIcon: ClientIcon };
|
||||
export type ChannelVideoStream = "available" | MediaStream | undefined;
|
||||
|
|
|
@ -419,4 +419,10 @@ $small_height: 10em;
|
|||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Opera popout button fix (we've our own?) */
|
||||
html > div {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
|
@ -16,6 +16,7 @@ type SourceConstraints = { width?: number, height?: number, frameRate?: number }
|
|||
export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, selectMode: "quick" | "default" | "none", defaultDeviceId?: string) : Promise<VideoSource> {
|
||||
const controller = new VideoSourceController(type);
|
||||
|
||||
let defaultSelectSource = selectMode === "default";
|
||||
if(selectMode === "quick") {
|
||||
/* We need the modal itself for the native client in order to present the window selector */
|
||||
if(type === "camera" || __build.target === "web") {
|
||||
|
@ -26,6 +27,8 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele
|
|||
|
||||
return source;
|
||||
}
|
||||
} else {
|
||||
defaultSelectSource = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -33,7 +36,7 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele
|
|||
controller.events.on(["action_start", "action_cancel"], () => modal.destroy());
|
||||
|
||||
modal.show().then(() => {
|
||||
if(selectMode === "default" || selectMode === "quick") {
|
||||
if(defaultSelectSource) {
|
||||
if(type === "screen" && getVideoDriver().screenQueryAvailable()) {
|
||||
controller.events.fire_react("action_toggle_screen_capture_device_select", { shown: true });
|
||||
} else {
|
||||
|
@ -46,8 +49,8 @@ export async function spawnVideoSourceSelectModal(type: VideoBroadcastType, sele
|
|||
controller.events.on("action_start", () => refSource.source = controller.getCurrentSource()?.ref());
|
||||
|
||||
await new Promise(resolve => {
|
||||
if(type === "screen" && selectMode === "quick") {
|
||||
controller.events.on("notify_video_preview", event => {
|
||||
if(defaultSelectSource && selectMode === "quick") {
|
||||
controller.events.one("notify_video_preview", event => {
|
||||
if(event.status.status === "preview") {
|
||||
/* we've successfully selected something */
|
||||
modal.destroy();
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
margin-left: 1em;
|
||||
width: 20em;
|
||||
|
||||
.body .title {
|
||||
.sectionBody .title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
@ -245,7 +245,7 @@
|
|||
.bpsInfo {
|
||||
margin-top: auto;
|
||||
|
||||
.body {
|
||||
.sectionBody {
|
||||
font-size: .8em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import {ServerFeature} from "tc-shared/connection/ServerFeatures";
|
|||
import {RTCConnection} from "tc-shared/connection/rtc/Connection";
|
||||
import {RtpVideoConnection} from "tc-shared/connection/rtc/video/Connection";
|
||||
import { tr } from "tc-shared/i18n/localize";
|
||||
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
||||
|
||||
class ReturnListener<T> {
|
||||
resolve: (value?: T | PromiseLike<T>) => void;
|
||||
|
|
Loading…
Reference in New Issue