diff --git a/shared/js/connection/VideoConnection.ts b/shared/js/connection/VideoConnection.ts index ac0d53fc..10a3dfa3 100644 --- a/shared/js/connection/VideoConnection.ts +++ b/shared/js/connection/VideoConnection.ts @@ -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; + + getState() : LocalVideoBroadcastState; + getSource() : VideoSource | undefined; + getStatistics() : Promise; + + //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; + + /** + * @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; + + stopBroadcasting(); +} + export interface VideoConnection { getEvents() : Registry; @@ -73,17 +110,7 @@ export interface VideoConnection { getConnectionStats() : Promise; - isBroadcasting(type: VideoBroadcastType); - getBroadcastingSource(type: VideoBroadcastType) : VideoSource | undefined; - getBroadcastingState(type: VideoBroadcastType) : VideoBroadcastState; - getBroadcastStatistics(type: VideoBroadcastType) : Promise; - - /** - * @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; - stopBroadcasting(type: VideoBroadcastType); + getLocalBroadcast(channel: VideoBroadcastType) : LocalVideoBroadcast; registerVideoClient(clientId: number); registeredVideoClients() : VideoClient[]; diff --git a/shared/js/connection/rtc/Connection.ts b/shared/js/connection/rtc/Connection.ts index 389719ce..be808098 100644 --- a/shared/js/connection/rtc/Connection.ts +++ b/shared/js/connection/rtc/Connection.ts @@ -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 = []; } diff --git a/shared/js/connection/rtc/SdpUtils.ts b/shared/js/connection/rtc/SdpUtils.ts index 6a429ebf..99a2878e 100644 --- a/shared/js/connection/rtc/SdpUtils.ts +++ b/shared/js/connection/rtc/SdpUtils.ts @@ -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); diff --git a/shared/js/connection/rtc/video/Connection.ts b/shared/js/connection/rtc/video/Connection.ts index 5d9453fd..9e1315a0 100644 --- a/shared/js/connection/rtc/video/Connection.ts +++ b/shared/js/connection/rtc/video/Connection.ts @@ -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; + + private state: LocalVideoBroadcastState; + private currentSource: VideoSource; + private broadcastStartId: number; + + private localStartPromise: Promise; + + constructor(handle: RtpVideoConnection, type: VideoBroadcastType) { + this.handle = handle; + this.type = type; + this.broadcastStartId = 0; + + this.events = new Registry(); + this.state = { state: "stopped" }; + } + + destroy() { + this.events.destroy(); + } + + getEvents(): Registry { + 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 { + return Promise.resolve(undefined); + } + + async changeSource(source: VideoSource): Promise { + 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 { + 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 { 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 { - /* 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 { - 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]; + } } \ No newline at end of file diff --git a/shared/js/events/ClientGlobalControlHandler.ts b/shared/js/events/ClientGlobalControlHandler.ts index 8a025667..8368ed96 100644 --- a/shared/js/events/ClientGlobalControlHandler.ts +++ b/shared/js/events/ClientGlobalControlHandler.ts @@ -197,8 +197,24 @@ export function initialize(event_registry: Registry) 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) 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(); } }); } \ No newline at end of file diff --git a/shared/js/events/GlobalEvents.ts b/shared/js/events/GlobalEvents.ts index a72c0b62..98f43a5b 100644 --- a/shared/js/events/GlobalEvents.ts +++ b/shared/js/events/GlobalEvents.ts @@ -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: { diff --git a/shared/js/ui/frames/control-bar/Controller.ts b/shared/js/ui/frames/control-bar/Controller.ts index d5d5dd94..f3f12515 100644 --- a/shared/js/ui/frames/control-bar/Controller.ts +++ b/shared/js/ui/frames/control-bar/Controller.ts @@ -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"; diff --git a/shared/js/ui/frames/control-bar/Renderer.tsx b/shared/js/ui/frames/control-bar/Renderer.tsx index e751c196..f2efa595 100644 --- a/shared/js/ui/frames/control-bar/Renderer.tsx +++ b/shared/js/ui/frames/control-bar/Renderer.tsx @@ -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 ( ); diff --git a/shared/js/ui/frames/video/Controller.ts b/shared/js/ui/frames/video/Controller.ts index 4b272c61..d748754c 100644 --- a/shared/js/ui/frames/video/Controller.ts +++ b/shared/js/ui/frames/video/Controller.ts @@ -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, 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) { 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 => { diff --git a/shared/js/ui/frames/video/Definitions.ts b/shared/js/ui/frames/video/Definitions.ts index 7904af21..f6f457c7 100644 --- a/shared/js/ui/frames/video/Definitions.ts +++ b/shared/js/ui/frames/video/Definitions.ts @@ -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; diff --git a/shared/js/ui/frames/video/Renderer.scss b/shared/js/ui/frames/video/Renderer.scss index e1961156..5621221b 100644 --- a/shared/js/ui/frames/video/Renderer.scss +++ b/shared/js/ui/frames/video/Renderer.scss @@ -419,4 +419,10 @@ $small_height: 10em; opacity: 1; } } +} + +/* Opera popout button fix (we've our own?) */ +html > div { + display: none; + pointer-events: none; } \ No newline at end of file diff --git a/shared/js/ui/modal/video-source/Controller.tsx b/shared/js/ui/modal/video-source/Controller.tsx index 8dea6720..0e4b6a05 100644 --- a/shared/js/ui/modal/video-source/Controller.tsx +++ b/shared/js/ui/modal/video-source/Controller.tsx @@ -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 { 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(); diff --git a/shared/js/ui/modal/video-source/Renderer.scss b/shared/js/ui/modal/video-source/Renderer.scss index 9a7eb04d..c323934a 100644 --- a/shared/js/ui/modal/video-source/Renderer.scss +++ b/shared/js/ui/modal/video-source/Renderer.scss @@ -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; } } diff --git a/web/app/connection/ServerConnection.ts b/web/app/connection/ServerConnection.ts index 26440406..6e87d03f 100644 --- a/web/app/connection/ServerConnection.ts +++ b/web/app/connection/ServerConnection.ts @@ -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 { resolve: (value?: T | PromiseLike) => void;