Video now has to be manually activated in order to watch it
This commit is contained in:
parent
e78833e534
commit
23414b7f31
10 changed files with 521 additions and 116 deletions
|
@ -41,9 +41,12 @@ export enum VideoConnectionStatus {
|
|||
}
|
||||
|
||||
export enum VideoBroadcastState {
|
||||
Stopped,
|
||||
Available, /* A stream is available but we've not joined it */
|
||||
Initializing,
|
||||
Running,
|
||||
Stopped,
|
||||
/* We've a stream but the stream does not replays anything */
|
||||
Buffering,
|
||||
}
|
||||
|
||||
export interface VideoClientEvents {
|
||||
|
@ -56,6 +59,9 @@ export interface VideoClient {
|
|||
|
||||
getVideoState(broadcastType: VideoBroadcastType) : VideoBroadcastState;
|
||||
getVideoStream(broadcastType: VideoBroadcastType) : MediaStream;
|
||||
|
||||
joinBroadcast(broadcastType: VideoBroadcastType) : Promise<void>;
|
||||
leaveBroadcast(broadcastType: VideoBroadcastType);
|
||||
}
|
||||
|
||||
export interface VideoConnection {
|
||||
|
|
|
@ -291,6 +291,8 @@ class CommandHandler extends AbstractCommandHandler {
|
|||
} else {
|
||||
logWarn(LogCategory.WEBRTC, tr("Received unknown/invalid rtc track state: %d"), state);
|
||||
}
|
||||
} else if(command.command === "notifybroadcastvideo") {
|
||||
/* FIXME: TODO! */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -920,10 +922,14 @@ export class RTCConnection {
|
|||
|
||||
private handleLocalIceCandidate(candidate: RTCIceCandidate | undefined) {
|
||||
if(candidate) {
|
||||
console.error(candidate.candidate);
|
||||
if(candidate.address?.endsWith(".local")) {
|
||||
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();
|
||||
|
|
|
@ -27,9 +27,7 @@ type VideoBroadcast = {
|
|||
export class RtpVideoConnection implements VideoConnection {
|
||||
private readonly rtcConnection: RTCConnection;
|
||||
private readonly events: Registry<VideoConnectionEvent>;
|
||||
private readonly listenerClientMoved;
|
||||
private readonly listenerRtcStateChanged;
|
||||
private readonly listenerConnectionStateChanged;
|
||||
private readonly listener: (() => void)[];
|
||||
private connectionState: VideoConnectionStatus;
|
||||
|
||||
private broadcasts: {[T in VideoBroadcastType]: VideoBroadcast} = {
|
||||
|
@ -43,10 +41,19 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
this.events = new Registry<VideoConnectionEvent>();
|
||||
this.setConnectionState(VideoConnectionStatus.Disconnected);
|
||||
|
||||
this.listenerClientMoved = this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => {
|
||||
const localClientId = this.rtcConnection.getConnection().client.getClientId();
|
||||
this.listener = [];
|
||||
|
||||
/* We only have to listen for move events since if the client is leaving the broadcast will be terminated anyways */
|
||||
this.listener.push(this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifyclientmoved", event => {
|
||||
const localClient = this.rtcConnection.getConnection().client.getClient();
|
||||
for(const data of event.arguments) {
|
||||
if(parseInt(data["clid"]) === localClientId) {
|
||||
const clientId = parseInt(data["clid"]);
|
||||
if(clientId === localClient.clientId()) {
|
||||
Object.values(this.registeredClients).forEach(client => {
|
||||
client.setBroadcastId("screen", undefined);
|
||||
client.setBroadcastId("camera", undefined);
|
||||
});
|
||||
|
||||
if(settings.static_global(Settings.KEY_STOP_VIDEO_ON_SWITCH)) {
|
||||
this.stopBroadcasting("camera", true);
|
||||
this.stopBroadcasting("screen", true);
|
||||
|
@ -55,18 +62,72 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
this.restartBroadcast("screen");
|
||||
this.restartBroadcast("camera");
|
||||
}
|
||||
} else if(parseInt("scid") === localClient.currentChannel().channelId) {
|
||||
const broadcast = this.registeredClients[clientId];
|
||||
broadcast?.setBroadcastId("screen", undefined);
|
||||
broadcast?.setBroadcastId("camera", undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.listenerConnectionStateChanged = this.rtcConnection.getConnection().events.on("notify_connection_state_changed", event => {
|
||||
}));
|
||||
|
||||
this.listener.push(this.rtcConnection.getConnection().command_handler_boss().register_explicit_handler("notifybroadcastvideo", event => {
|
||||
const assignedClients: { clientId: number, broadcastType: VideoBroadcastType }[] = [];
|
||||
for(const data of event.arguments) {
|
||||
if(!("bid" in data)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const broadcastId = parseInt(data["bid"]);
|
||||
const broadcastType = parseInt(data["bt"]);
|
||||
const sourceClientId = parseInt(data["sclid"]);
|
||||
|
||||
if(!this.registeredClients[sourceClientId]) {
|
||||
logWarn(LogCategory.VIDEO, tr("Received video broadcast info about a not registered client (%d)"), sourceClientId);
|
||||
/* TODO: Cache the value! */
|
||||
continue;
|
||||
}
|
||||
|
||||
let videoBroadcastType: VideoBroadcastType;
|
||||
switch(broadcastType) {
|
||||
case 0x00:
|
||||
videoBroadcastType = "camera";
|
||||
break;
|
||||
|
||||
case 0x01:
|
||||
videoBroadcastType = "screen";
|
||||
break;
|
||||
|
||||
default:
|
||||
logWarn(LogCategory.VIDEO, tr("Received video broadcast info with an invalid video broadcast type: %d."), broadcastType);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.registeredClients[sourceClientId].setBroadcastId(videoBroadcastType, broadcastId);
|
||||
assignedClients.push({ broadcastType: videoBroadcastType, clientId: sourceClientId });
|
||||
}
|
||||
|
||||
const broadcastTypes: VideoBroadcastType[] = ["screen", "camera"];
|
||||
Object.values(this.registeredClients).forEach(client => {
|
||||
for(const type of broadcastTypes) {
|
||||
if(assignedClients.findIndex(assignment => assignment.broadcastType === type && assignment.clientId === client.getClientId()) !== -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
client.setBroadcastId(type, undefined);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.listener.push(this.rtcConnection.getConnection().events.on("notify_connection_state_changed", event => {
|
||||
if(event.newState !== ConnectionState.CONNECTED) {
|
||||
this.stopBroadcasting("camera");
|
||||
this.stopBroadcasting("screen");
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
this.listenerRtcStateChanged = this.rtcConnection.getEvents().on("notify_state_changed", event => this.handleRtcConnectionStateChanged(event));
|
||||
this.rtcConnection.getEvents().on("notify_video_assignment_changed", event => {
|
||||
this.listener.push(this.rtcConnection.getEvents().on("notify_state_changed", event => this.handleRtcConnectionStateChanged(event)));
|
||||
|
||||
this.listener.push(this.rtcConnection.getEvents().on("notify_video_assignment_changed", event => {
|
||||
if(event.info) {
|
||||
switch (event.info.media) {
|
||||
case 2:
|
||||
|
@ -86,7 +147,7 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
this.handleVideoAssignmentChanged("screen", event);
|
||||
this.handleVideoAssignmentChanged("camera", event);
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
private setConnectionState(state: VideoConnectionStatus) {
|
||||
|
@ -121,9 +182,10 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
}
|
||||
|
||||
destroy() {
|
||||
this.listenerClientMoved();
|
||||
this.listenerRtcStateChanged();
|
||||
this.listenerConnectionStateChanged();
|
||||
this.listener.forEach(callback => callback());
|
||||
this.listener.splice(0, this.listener.length);
|
||||
|
||||
this.events.destroy();
|
||||
}
|
||||
|
||||
getEvents(): Registry<VideoConnectionEvent> {
|
||||
|
@ -229,7 +291,7 @@ export class RtpVideoConnection implements VideoConnection {
|
|||
throw tr("a video client with this id has already been registered");
|
||||
}
|
||||
|
||||
return this.registeredClients[clientId] = new RtpVideoClient(clientId);
|
||||
return this.registeredClients[clientId] = new RtpVideoClient(this.rtcConnection, clientId);
|
||||
}
|
||||
|
||||
registeredVideoClients(): VideoClient[] {
|
||||
|
|
|
@ -6,10 +6,12 @@ import {
|
|||
} from "tc-shared/connection/VideoConnection";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {RemoteRTPTrackState, RemoteRTPVideoTrack} from "../RemoteTrack";
|
||||
import {LogCategory, logWarn} from "tc-shared/log";
|
||||
import { tr } from "tc-shared/i18n/localize";
|
||||
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
||||
import {tr} from "tc-shared/i18n/localize";
|
||||
import {RTCConnection} from "tc-shared/connection/rtc/Connection";
|
||||
|
||||
export class RtpVideoClient implements VideoClient {
|
||||
private readonly handle: RTCConnection;
|
||||
private readonly clientId: number;
|
||||
private readonly events: Registry<VideoClientEvents>;
|
||||
|
||||
|
@ -28,7 +30,18 @@ export class RtpVideoClient implements VideoClient {
|
|||
screen: VideoBroadcastState.Stopped
|
||||
}
|
||||
|
||||
constructor(clientId: number) {
|
||||
private broadcastIds: {[T in VideoBroadcastType]: number | undefined} = {
|
||||
camera: undefined,
|
||||
screen: undefined
|
||||
};
|
||||
|
||||
private joinedStates: {[T in VideoBroadcastType]: boolean} = {
|
||||
camera: false,
|
||||
screen: false
|
||||
}
|
||||
|
||||
constructor(handle: RTCConnection, clientId: number) {
|
||||
this.handle = handle;
|
||||
this.clientId = clientId;
|
||||
this.events = new Registry<VideoClientEvents>();
|
||||
}
|
||||
|
@ -49,6 +62,50 @@ export class RtpVideoClient implements VideoClient {
|
|||
return this.trackStates[broadcastType];
|
||||
}
|
||||
|
||||
async joinBroadcast(broadcastType: VideoBroadcastType): Promise<void> {
|
||||
if(typeof this.broadcastIds[broadcastType] === "undefined") {
|
||||
throw tr("broadcast isn't available");
|
||||
}
|
||||
|
||||
this.joinedStates[broadcastType] = true;
|
||||
this.setBroadcastState(broadcastType, VideoBroadcastState.Initializing);
|
||||
await this.handle.getConnection().send_command("broadcastvideojoin", {
|
||||
bid: this.broadcastIds[broadcastType],
|
||||
bt: broadcastType === "camera" ? 0 : 1
|
||||
}).then(() => {
|
||||
/* the broadcast state should switch automatically to running since we got an RTP stream now */
|
||||
if(this.trackStates[broadcastType] === VideoBroadcastState.Initializing) {
|
||||
throw tr("failed to receive stream");
|
||||
}
|
||||
}).catch(error => {
|
||||
this.updateBroadcastState(broadcastType);
|
||||
this.joinedStates[broadcastType] = false;
|
||||
logError(LogCategory.VIDEO, tr("Failed to join video broadcast: %o"), error);
|
||||
throw tr("failed to join broadcast");
|
||||
});
|
||||
}
|
||||
|
||||
leaveBroadcast(broadcastType: VideoBroadcastType) {
|
||||
this.joinedStates[broadcastType] = false;
|
||||
this.setBroadcastState(broadcastType, typeof this.trackStates[broadcastType] === "number" ? VideoBroadcastState.Available : VideoBroadcastState.Stopped);
|
||||
|
||||
const connection = this.handle.getConnection();
|
||||
if(!connection.connected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(typeof this.broadcastIds[broadcastType] === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handle.getConnection().send_command("broadcastvideoleave", {
|
||||
bid: this.broadcastIds[broadcastType],
|
||||
bt: broadcastType === "camera" ? 0 : 1
|
||||
}).catch(error => {
|
||||
logWarn(LogCategory.VIDEO, tr("Failed to leave video broadcast: %o"), error);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.setRtpTrack("camera", undefined);
|
||||
this.setRtpTrack("screen", undefined);
|
||||
|
@ -66,8 +123,21 @@ export class RtpVideoClient implements VideoClient {
|
|||
this.currentTrack[type] = track;
|
||||
if(this.currentTrack[type]) {
|
||||
this.currentTrack[type].getEvents().on("notify_state_changed", this.listenerTrackStateChanged[type]);
|
||||
this.handleTrackStateChanged(type, this.currentTrack[type].getState());
|
||||
}
|
||||
this.updateBroadcastState(type);
|
||||
}
|
||||
|
||||
setBroadcastId(type: VideoBroadcastType, id: number | undefined) {
|
||||
if(this.broadcastIds[type] === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.broadcastIds[type] = id;
|
||||
if(typeof id === "undefined") {
|
||||
/* we've to join each video explicitly */
|
||||
this.joinedStates[type] = false;
|
||||
}
|
||||
this.updateBroadcastState(type);
|
||||
}
|
||||
|
||||
private setBroadcastState(type: VideoBroadcastType, state: VideoBroadcastState) {
|
||||
|
@ -80,21 +150,41 @@ export class RtpVideoClient implements VideoClient {
|
|||
this.events.fire("notify_broadcast_state_changed", { broadcastType: type, oldState: oldState, newState: state });
|
||||
}
|
||||
|
||||
private handleTrackStateChanged(type: VideoBroadcastType, newState: RemoteRTPTrackState) {
|
||||
switch (newState) {
|
||||
case RemoteRTPTrackState.Bound:
|
||||
case RemoteRTPTrackState.Unbound:
|
||||
this.setBroadcastState(type, VideoBroadcastState.Stopped);
|
||||
break;
|
||||
private handleTrackStateChanged(type: VideoBroadcastType, _newState: RemoteRTPTrackState) {
|
||||
this.updateBroadcastState(type);
|
||||
}
|
||||
|
||||
case RemoteRTPTrackState.Started:
|
||||
this.setBroadcastState(type, VideoBroadcastState.Running);
|
||||
break;
|
||||
private updateBroadcastState(type: VideoBroadcastType) {
|
||||
if(!this.broadcastIds[type]) {
|
||||
this.setBroadcastState(type, VideoBroadcastState.Stopped);
|
||||
} else if(!this.joinedStates[type]) {
|
||||
this.setBroadcastState(type, VideoBroadcastState.Available);
|
||||
} else {
|
||||
const rtpState = this.currentTrack[type]?.getState();
|
||||
switch (rtpState) {
|
||||
case undefined:
|
||||
/* We're initializing the broadcast */
|
||||
this.setBroadcastState(type, VideoBroadcastState.Initializing);
|
||||
break;
|
||||
|
||||
case RemoteRTPTrackState.Destroyed:
|
||||
logWarn(LogCategory.VIDEO, tr("Received new track state 'Destroyed' which should never happen."));
|
||||
this.setBroadcastState(type, VideoBroadcastState.Stopped);
|
||||
break;
|
||||
case RemoteRTPTrackState.Unbound:
|
||||
logWarn(LogCategory.VIDEO, tr("Updated video broadcast state and the track state is 'Unbound' which should never happen."));
|
||||
this.setBroadcastState(type, VideoBroadcastState.Stopped);
|
||||
break;
|
||||
|
||||
case RemoteRTPTrackState.Destroyed:
|
||||
logWarn(LogCategory.VIDEO, tr("Updated video broadcast state and the track state is 'Destroyed' which should never happen."));
|
||||
this.setBroadcastState(type, VideoBroadcastState.Stopped);
|
||||
break;
|
||||
|
||||
case RemoteRTPTrackState.Started:
|
||||
this.setBroadcastState(type, VideoBroadcastState.Running);
|
||||
break;
|
||||
|
||||
case RemoteRTPTrackState.Bound:
|
||||
this.setBroadcastState(type, VideoBroadcastState.Buffering);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -520,6 +520,27 @@ export class Settings extends StaticSettings {
|
|||
valueType: "boolean",
|
||||
};
|
||||
|
||||
static readonly KEY_VIDEO_SHOW_ALL_CLIENTS: ValuedSettingsKey<boolean> = {
|
||||
key: 'video_show_all_clients',
|
||||
defaultValue: false,
|
||||
description: "Show all clients within the video frame, even if they're not broadcasting video",
|
||||
valueType: "boolean",
|
||||
};
|
||||
|
||||
static readonly KEY_VIDEO_FORCE_SHOW_OWN_VIDEO: ValuedSettingsKey<boolean> = {
|
||||
key: 'video_force_show_own_video',
|
||||
defaultValue: true,
|
||||
description: "Show own video preview even if you're not broadcasting any video",
|
||||
valueType: "boolean",
|
||||
};
|
||||
|
||||
static readonly KEY_VIDEO_AUTO_SUBSCRIBE_MODE: ValuedSettingsKey<number> = {
|
||||
key: 'video_auto_subscribe_mode',
|
||||
defaultValue: 1,
|
||||
description: "Auto subscribe to incoming videos.\n0 := Do not auto subscribe.\n1 := Auto subscribe to the first video.\n2 := Subscribe to all incoming videos.",
|
||||
valueType: "number",
|
||||
};
|
||||
|
||||
static readonly FN_LOG_ENABLED: (category: string) => SettingsKey<boolean> = category => {
|
||||
return {
|
||||
key: "log." + category.toLowerCase() + ".enabled",
|
||||
|
|
|
@ -6,8 +6,9 @@ import {Registry} from "tc-shared/events";
|
|||
import {ChannelVideoEvents, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
|
||||
import {VideoBroadcastState, VideoBroadcastType, VideoConnection} from "tc-shared/connection/VideoConnection";
|
||||
import {ClientEntry, ClientType, LocalClientEntry, MusicClientEntry} from "tc-shared/tree/Client";
|
||||
import {LogCategory, logWarn} from "tc-shared/log";
|
||||
import { tr } from "tc-shared/i18n/localize";
|
||||
import {LogCategory, logError, logWarn} from "tc-shared/log";
|
||||
import {tr} from "tc-shared/i18n/localize";
|
||||
import {Settings, settings} from "tc-shared/settings";
|
||||
|
||||
const cssStyle = require("./Renderer.scss");
|
||||
|
||||
|
@ -15,6 +16,7 @@ let videoIdIndex = 0;
|
|||
interface ClientVideoController {
|
||||
destroy();
|
||||
toggleMuteState(type: VideoBroadcastType, state: boolean);
|
||||
dismissVideo(type: VideoBroadcastType);
|
||||
|
||||
notifyVideoInfo();
|
||||
notifyVideo();
|
||||
|
@ -30,13 +32,12 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
protected eventListener: (() => void)[];
|
||||
protected eventListenerVideoClient: (() => void)[];
|
||||
|
||||
protected mutedState: {[T in VideoBroadcastType]: boolean} = {
|
||||
private currentBroadcastState: boolean;
|
||||
private dismissed: {[T in VideoBroadcastType]: boolean} = {
|
||||
screen: false,
|
||||
camera: false
|
||||
};
|
||||
|
||||
private currentBroadcastState: boolean;
|
||||
|
||||
constructor(client: ClientEntry, eventRegistry: Registry<ChannelVideoEvents>, videoId?: string) {
|
||||
this.client = client;
|
||||
this.events = eventRegistry;
|
||||
|
@ -54,7 +55,10 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
this.events.fire_react("notify_video_info_status", { videoId: this.videoId, statusIcon: event.newIcon });
|
||||
}));
|
||||
|
||||
events.push(client.events.on("notify_video_handle_changed", () => this.updateVideoClient()));
|
||||
events.push(client.events.on("notify_video_handle_changed", () => {
|
||||
Object.keys(this.dismissed).forEach(key => this.dismissed[key] = false);
|
||||
this.updateVideoClient();
|
||||
}));
|
||||
|
||||
this.updateVideoClient();
|
||||
}
|
||||
|
@ -65,7 +69,12 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
|
||||
const videoClient = this.client.getVideoClient();
|
||||
if(videoClient) {
|
||||
events.push(videoClient.getEvents().on("notify_broadcast_state_changed", () => {
|
||||
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();
|
||||
}));
|
||||
|
@ -85,12 +94,27 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
return videoClient && (videoClient.getVideoState("camera") !== VideoBroadcastState.Stopped || videoClient.getVideoState("screen") !== VideoBroadcastState.Stopped);
|
||||
}
|
||||
|
||||
toggleMuteState(type: VideoBroadcastType, state: boolean) {
|
||||
if(this.mutedState[type] === state) { return; }
|
||||
toggleMuteState(type: VideoBroadcastType, muted: boolean) {
|
||||
if(muted) {
|
||||
this.client.getVideoClient().leaveBroadcast(type);
|
||||
} else {
|
||||
/* we explicitly specified that we don't want to have that */
|
||||
this.dismissed[type] = true;
|
||||
|
||||
this.mutedState[type] = state;
|
||||
this.client.getVideoClient().joinBroadcast(type).catch(error => {
|
||||
logError(LogCategory.VIDEO, tr("Failed to join video broadcast: %o"), error);
|
||||
/* TODO: Propagate error? */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dismissVideo(type: VideoBroadcastType) {
|
||||
if(this.dismissed[type] === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dismissed[type] = true;
|
||||
this.notifyVideo();
|
||||
this.notifyMuteState();
|
||||
}
|
||||
|
||||
notifyVideoInfo() {
|
||||
|
@ -113,21 +137,19 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
let cameraStream, desktopStream;
|
||||
|
||||
const stateCamera = this.getBroadcastState("camera");
|
||||
if(stateCamera === VideoBroadcastState.Running) {
|
||||
if(stateCamera === VideoBroadcastState.Available) {
|
||||
cameraStream = "available";
|
||||
} else if(stateCamera === VideoBroadcastState.Running) {
|
||||
cameraStream = this.getBroadcastStream("camera")
|
||||
if(cameraStream && this.mutedState["camera"]) {
|
||||
cameraStream = "muted";
|
||||
}
|
||||
} else if(stateCamera === VideoBroadcastState.Initializing) {
|
||||
initializing = true;
|
||||
}
|
||||
|
||||
const stateScreen = this.getBroadcastState("screen");
|
||||
if(stateScreen === VideoBroadcastState.Running) {
|
||||
if(stateScreen === VideoBroadcastState.Available) {
|
||||
desktopStream = "available";
|
||||
} else if(stateScreen === VideoBroadcastState.Running) {
|
||||
desktopStream = this.getBroadcastStream("screen");
|
||||
if(desktopStream && this.mutedState["screen"]) {
|
||||
desktopStream = "muted";
|
||||
}
|
||||
} else if(stateScreen === VideoBroadcastState.Initializing) {
|
||||
initializing = true;
|
||||
}
|
||||
|
@ -141,6 +163,8 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
|
||||
desktopStream: desktopStream,
|
||||
cameraStream: cameraStream,
|
||||
|
||||
dismissed: this.dismissed
|
||||
}
|
||||
});
|
||||
} else if(initializing) {
|
||||
|
@ -156,7 +180,9 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
status: "connected",
|
||||
|
||||
cameraStream: undefined,
|
||||
desktopStream: undefined
|
||||
desktopStream: undefined,
|
||||
|
||||
dismissed: this.dismissed
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -179,8 +205,8 @@ class RemoteClientVideoController implements ClientVideoController {
|
|||
this.events.fire_react("notify_video_mute_status", {
|
||||
videoId: this.videoId,
|
||||
status: {
|
||||
camera: this.getBroadcastStream("camera") ? this.mutedState["camera"] ? "muted" : "available" : "unset",
|
||||
screen: this.getBroadcastStream("screen") ? this.mutedState["screen"] ? "muted" : "available" : "unset",
|
||||
camera: this.getBroadcastState("camera") === VideoBroadcastState.Available ? "muted" : this.getBroadcastState("camera") === VideoBroadcastState.Stopped ? "unset" : "available",
|
||||
screen: this.getBroadcastState("screen") === VideoBroadcastState.Available ? "muted" : this.getBroadcastState("screen") === VideoBroadcastState.Stopped ? "unset" : "available",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -305,6 +331,16 @@ class ChannelVideoController {
|
|||
controller.toggleMuteState(event.broadcastType, event.muted);
|
||||
});
|
||||
|
||||
this.events.on("action_dismiss", event => {
|
||||
const controller = this.findVideoById(event.videoId);
|
||||
if(!controller) {
|
||||
logWarn(LogCategory.VIDEO, tr("Tried to dismiss video for a non existing video id (%s)."), event.videoId);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.dismissVideo(event.broadcastType);
|
||||
});
|
||||
|
||||
this.events.on("query_expended", () => this.events.fire_react("notify_expended", { expended: this.expended }));
|
||||
this.events.on("query_videos", () => this.notifyVideoList());
|
||||
this.events.on("query_spotlight", () => this.notifySpotlight());
|
||||
|
@ -398,6 +434,9 @@ class ChannelVideoController {
|
|||
this.notifyVideoList();
|
||||
}
|
||||
}));
|
||||
|
||||
events.push(settings.globalChangeListener(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS, () => this.notifyVideoList()));
|
||||
events.push(settings.globalChangeListener(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO, () => this.notifyVideoList()));
|
||||
}
|
||||
|
||||
setSpotlight(videoId: string | undefined) {
|
||||
|
@ -486,10 +525,15 @@ class ChannelVideoController {
|
|||
private notifyVideoList() {
|
||||
const videoIds = [];
|
||||
|
||||
let videoCount = 0;
|
||||
let videoStreamingCount = 0;
|
||||
if(this.localVideoController) {
|
||||
videoIds.push(this.localVideoController.videoId);
|
||||
if(this.localVideoController.isBroadcasting()) { videoCount++; }
|
||||
const localBroadcasting = this.localVideoController.isBroadcasting();
|
||||
if(localBroadcasting || settings.static_global(Settings.KEY_VIDEO_FORCE_SHOW_OWN_VIDEO)) {
|
||||
videoIds.push(this.localVideoController.videoId);
|
||||
if(localBroadcasting) {
|
||||
videoStreamingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const channel = this.connection.channelTree.findChannel(this.currentChannelId);
|
||||
|
@ -503,15 +547,15 @@ class ChannelVideoController {
|
|||
|
||||
const controller = this.clientVideos[client.clientId()];
|
||||
if(controller.isBroadcasting()) {
|
||||
videoCount++;
|
||||
} else {
|
||||
/* TODO: Filter if video is active */
|
||||
videoStreamingCount++;
|
||||
} else if(!settings.static_global(Settings.KEY_VIDEO_SHOW_ALL_CLIENTS)) {
|
||||
continue;
|
||||
}
|
||||
videoIds.push(controller.videoId);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateVisibility(videoCount !== 0);
|
||||
this.updateVisibility(videoStreamingCount !== 0);
|
||||
if(this.expended) {
|
||||
videoIds.remove(this.currentSpotlight);
|
||||
}
|
||||
|
|
|
@ -4,14 +4,17 @@ import {VideoBroadcastType} from "tc-shared/connection/VideoConnection";
|
|||
export const kLocalVideoId = "__local__video__";
|
||||
|
||||
export type ChannelVideoInfo = { clientName: string, clientUniqueId: string, clientId: number, statusIcon: ClientIcon };
|
||||
export type ChannelVideoStream = "available" | MediaStream | undefined;
|
||||
|
||||
export type ChannelVideo ={
|
||||
status: "initializing",
|
||||
} | {
|
||||
status: "connected",
|
||||
|
||||
cameraStream: "muted" | MediaStream | undefined,
|
||||
desktopStream: "muted" | MediaStream | undefined
|
||||
cameraStream: ChannelVideoStream,
|
||||
desktopStream: ChannelVideoStream,
|
||||
|
||||
dismissed: {[T in VideoBroadcastType]: boolean}
|
||||
} | {
|
||||
status: "error",
|
||||
message: string
|
||||
|
@ -61,6 +64,7 @@ export interface ChannelVideoEvents {
|
|||
action_focus_spotlight: {},
|
||||
action_set_fullscreen: { videoId: string | undefined },
|
||||
action_toggle_mute: { videoId: string, broadcastType: VideoBroadcastType, muted: boolean },
|
||||
action_dismiss: { videoId: string, broadcastType: VideoBroadcastType },
|
||||
|
||||
query_expended: {},
|
||||
query_videos: {},
|
||||
|
|
|
@ -193,6 +193,11 @@ $small_height: 10em;
|
|||
.videoContainer .actionIcons {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.videoSecondary {
|
||||
max-width: 25% !important;
|
||||
max-height: 25%!important;
|
||||
}
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
|
@ -237,6 +242,9 @@ $small_height: 10em;
|
|||
max-height: 50%;
|
||||
|
||||
border-bottom-left-radius: .2em;
|
||||
|
||||
background: #2e2e2e;
|
||||
box-shadow: inset 0 0 5px #00000040;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
@ -254,6 +262,65 @@ $small_height: 10em;
|
|||
&.error {
|
||||
/* TODO! */
|
||||
}
|
||||
|
||||
.videoAvailable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.button {
|
||||
width: 5em;
|
||||
align-self: center;
|
||||
margin-top: .5em;
|
||||
font-size: .8em;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
|
||||
align-self: center;
|
||||
|
||||
width: 8.5em;
|
||||
}
|
||||
|
||||
.button2 {
|
||||
width: 8em;
|
||||
min-width: 3em;
|
||||
|
||||
flex-shrink: 1;
|
||||
flex-grow: 0;
|
||||
|
||||
align-self: center;
|
||||
|
||||
background-color: #3d3d3d;
|
||||
|
||||
border-radius: .18em;
|
||||
|
||||
padding-right: .31em;
|
||||
padding-left: .31em;
|
||||
|
||||
transition: background-color 0.25s ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: .5em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.videoSecondary {
|
||||
font-size: .75em;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
|
|
|
@ -3,7 +3,13 @@ import {useCallback, useContext, useEffect, useRef, useState} from "react";
|
|||
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
|
||||
import {ClientIcon} from "svg-sprites/client-icons";
|
||||
import {Registry} from "tc-shared/events";
|
||||
import {ChannelVideo, ChannelVideoEvents, ChannelVideoInfo, kLocalVideoId} from "tc-shared/ui/frames/video/Definitions";
|
||||
import {
|
||||
ChannelVideo,
|
||||
ChannelVideoEvents,
|
||||
ChannelVideoInfo,
|
||||
ChannelVideoStream,
|
||||
kLocalVideoId
|
||||
} from "tc-shared/ui/frames/video/Definitions";
|
||||
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
|
||||
|
@ -88,20 +94,43 @@ const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined,
|
|||
video.autoplay = true;
|
||||
video.muted = true;
|
||||
|
||||
video.play().then(undefined).catch(() => {
|
||||
logWarn(LogCategory.VIDEO, tr("Failed to start video replay. Retrying in 500ms intervals."));
|
||||
refReplayTimeout.current = setInterval(() => {
|
||||
video.play().then(() => {
|
||||
clearInterval(refReplayTimeout.current);
|
||||
refReplayTimeout.current = undefined;
|
||||
}).catch(() => {});
|
||||
const executePlay = () => {
|
||||
if(refReplayTimeout.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.play().then(undefined).catch(() => {
|
||||
logWarn(LogCategory.VIDEO, tr("Failed to start video replay. Retrying in 500ms intervals."));
|
||||
refReplayTimeout.current = setInterval(() => {
|
||||
video.play().then(() => {
|
||||
clearInterval(refReplayTimeout.current);
|
||||
refReplayTimeout.current = undefined;
|
||||
}).catch(() => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
video.onpause = () => {
|
||||
logWarn(LogCategory.VIDEO, tr("Video replay paused. Executing play again."));
|
||||
executePlay();
|
||||
}
|
||||
|
||||
video.onended = () => {
|
||||
logWarn(LogCategory.VIDEO, tr("Video replay ended. Executing play again."));
|
||||
executePlay();
|
||||
}
|
||||
executePlay();
|
||||
} else {
|
||||
video.style.opacity = "0";
|
||||
}
|
||||
|
||||
return () => {
|
||||
const video = refVideo.current;
|
||||
if(video) {
|
||||
video.onpause = undefined;
|
||||
video.onended = undefined;
|
||||
}
|
||||
|
||||
clearInterval(refReplayTimeout.current);
|
||||
refReplayTimeout.current = undefined;
|
||||
}
|
||||
|
@ -112,12 +141,45 @@ const VideoStreamReplay = React.memo((props: { stream: MediaStream | undefined,
|
|||
)
|
||||
});
|
||||
|
||||
const VideoAvailableRenderer = (props: { callbackEnable: () => void, callbackIgnore?: () => void, className?: string }) => (
|
||||
<div className={cssStyle.text + " " + props.className} key={"video-muted"}>
|
||||
<div className={cssStyle.videoAvailable}>
|
||||
<Translatable>Video available</Translatable>
|
||||
<div className={cssStyle.buttons}>
|
||||
<div className={cssStyle.button2} onClick={props.callbackEnable}>
|
||||
<Translatable>Watch</Translatable>
|
||||
</div>
|
||||
{!props.callbackIgnore ? undefined :
|
||||
<div className={cssStyle.button2} key={"ignore"} onClick={props.callbackIgnore}>
|
||||
<Translatable>Ignore</Translatable>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const VideoStreamRenderer = (props: { stream: ChannelVideoStream, callbackEnable: () => void, callbackIgnore: () => void, videoTitle: string, className?: string }) => {
|
||||
if(props.stream === "available") {
|
||||
return <VideoAvailableRenderer callbackEnable={props.callbackEnable} callbackIgnore={props.callbackIgnore} className={props.className} key={"available"} />;
|
||||
} else if(props.stream === undefined) {
|
||||
return (
|
||||
<div className={cssStyle.text} key={"no-video-stream"}>
|
||||
<div><Translatable>No Video</Translatable></div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <VideoStreamReplay stream={props.stream} className={props.className} title={props.videoTitle} key={"video-renderer"} />;
|
||||
}
|
||||
}
|
||||
|
||||
const VideoPlayer = React.memo((props: { videoId: string }) => {
|
||||
const events = useContext(EventContext);
|
||||
const [ state, setState ] = useState<"loading" | ChannelVideo>(() => {
|
||||
events.fire("query_video", { videoId: props.videoId });
|
||||
return "loading";
|
||||
});
|
||||
|
||||
events.reactUse("notify_video", event => {
|
||||
if(event.videoId === props.videoId) {
|
||||
setState(event.status);
|
||||
|
@ -143,40 +205,83 @@ const VideoPlayer = React.memo((props: { videoId: string }) => {
|
|||
</div>
|
||||
);
|
||||
} else if(state.status === "connected") {
|
||||
const desktopStream = state.desktopStream === "muted" ? undefined : state.desktopStream;
|
||||
const cameraStream = state.cameraStream === "muted" ? undefined : state.cameraStream;
|
||||
const streamElements = [];
|
||||
const streamClasses = [cssStyle.videoPrimary, cssStyle.videoSecondary];
|
||||
|
||||
if(desktopStream && cameraStream) {
|
||||
return (
|
||||
<React.Fragment key={"replay-multi"}>
|
||||
<VideoStreamReplay stream={desktopStream} className={cssStyle.videoPrimary} title={tr("Screen")} />
|
||||
<VideoStreamReplay stream={cameraStream} className={cssStyle.videoSecondary} title={tr("Camera")} />
|
||||
</React.Fragment>
|
||||
if(state.desktopStream === "available" && (state.cameraStream === "available" || state.cameraStream === undefined) ||
|
||||
state.cameraStream === "available" && (state.desktopStream === "available" || state.desktopStream === undefined)
|
||||
) {
|
||||
/* One or both streams are available. Showing just one box. */
|
||||
streamElements.push(
|
||||
<VideoAvailableRenderer
|
||||
key={"video-available"}
|
||||
callbackEnable={() => {
|
||||
if(state.desktopStream === "available") {
|
||||
events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId })
|
||||
}
|
||||
|
||||
if(state.cameraStream === "available") {
|
||||
events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId })
|
||||
}
|
||||
}}
|
||||
className={streamClasses.pop_front()}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const stream = desktopStream || cameraStream;
|
||||
if(stream) {
|
||||
return (
|
||||
<VideoStreamReplay stream={stream} key={"replay-single"} className={cssStyle.videoPrimary} title={desktopStream ? tr("Screen") : tr("Camera")} />
|
||||
);
|
||||
} else if(state.desktopStream || state.cameraStream) {
|
||||
return (
|
||||
<div className={cssStyle.text} key={"video-muted"}>
|
||||
<div><Translatable>Video muted</Translatable></div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={cssStyle.text} key={"no-video-stream"}>
|
||||
<div><Translatable>No Video</Translatable></div>
|
||||
</div>
|
||||
);
|
||||
if(state.desktopStream) {
|
||||
if(!state.dismissed["screen"] || state.desktopStream !== "available") {
|
||||
streamElements.push(
|
||||
<VideoStreamRenderer
|
||||
key={"screen"}
|
||||
stream={state.desktopStream}
|
||||
callbackEnable={() => events.fire("action_toggle_mute", { broadcastType: "screen", muted: false, videoId: props.videoId })}
|
||||
callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "screen", videoId: props.videoId })}
|
||||
videoTitle={tr("Screen")}
|
||||
className={streamClasses.pop_front()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if(state.cameraStream) {
|
||||
if(!state.dismissed["camera"] || state.cameraStream !== "available") {
|
||||
streamElements.push(
|
||||
<VideoStreamRenderer
|
||||
key={"camera"}
|
||||
stream={state.cameraStream}
|
||||
callbackEnable={() => events.fire("action_toggle_mute", { broadcastType: "camera", muted: false, videoId: props.videoId })}
|
||||
callbackIgnore={() => events.fire("action_dismiss", { broadcastType: "camera", videoId: props.videoId })}
|
||||
videoTitle={tr("Camera")}
|
||||
className={streamClasses.pop_front()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(streamElements.length === 0){
|
||||
return (
|
||||
<div className={cssStyle.text} key={"no-video-stream"}>
|
||||
<div>
|
||||
{props.videoId === kLocalVideoId ?
|
||||
<Translatable key={"own"}>You're not broadcasting video</Translatable> :
|
||||
<Translatable key={"general"}>No Video</Translatable>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{streamElements}</>;
|
||||
} else if(state.status === "no-video") {
|
||||
return (
|
||||
<div className={cssStyle.text} key={"no-video"}>
|
||||
<div><Translatable>No Video</Translatable></div>
|
||||
<div>
|
||||
{props.videoId === kLocalVideoId ?
|
||||
<Translatable key={"own"}>You're not broadcasting video</Translatable> :
|
||||
<Translatable key={"general"}>No Video</Translatable>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -288,18 +393,11 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea
|
|||
<VideoPlayer videoId={props.videoId} />
|
||||
<VideoInfo videoId={props.videoId} />
|
||||
<div className={cssStyle.actionIcons}>
|
||||
<div className={cssStyle.iconContainer + " " + (!fullscreenCapable ? cssStyle.hidden : "")}
|
||||
onClick={() => {
|
||||
if(props.isSpotlight) {
|
||||
events.fire("action_set_fullscreen", { videoId: isFullscreen ? undefined : props.videoId });
|
||||
} else {
|
||||
events.fire("action_set_spotlight", { videoId: props.videoId, expend: true });
|
||||
events.fire("action_focus_spotlight", { });
|
||||
}
|
||||
}}
|
||||
title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")}
|
||||
<div className={cssStyle.iconContainer + " " + cssStyle.toggle + " " + toggleClass("screen")}
|
||||
onClick={() => events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "screen", muted: muteState.screen === "available" })}
|
||||
title={muteState["screen"] === "muted" ? tr("Unmute screen video") : tr("Mute screen video")}
|
||||
>
|
||||
<ClientIconRenderer className={cssStyle.icon} icon={ClientIcon.Fullscreen} />
|
||||
<ClientIconRenderer className={cssStyle.icon} icon={ClientIcon.ShareScreen} />
|
||||
</div>
|
||||
<div className={cssStyle.iconContainer + " " + cssStyle.toggle + " " + toggleClass("camera")}
|
||||
onClick={() => events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "camera", muted: muteState.camera === "available" })}
|
||||
|
@ -307,11 +405,18 @@ const VideoContainer = React.memo((props: { videoId: string, isSpotlight: boolea
|
|||
>
|
||||
<ClientIconRenderer className={cssStyle.icon} icon={ClientIcon.VideoMuted} />
|
||||
</div>
|
||||
<div className={cssStyle.iconContainer + " " + cssStyle.toggle + " " + toggleClass("screen")}
|
||||
onClick={() => events.fire("action_toggle_mute", { videoId: props.videoId, broadcastType: "screen", muted: muteState.screen === "available" })}
|
||||
title={muteState["screen"] === "muted" ? tr("Unmute screen video") : tr("Mute screen video")}
|
||||
<div className={cssStyle.iconContainer + " " + (!fullscreenCapable ? cssStyle.hidden : "")}
|
||||
onClick={() => {
|
||||
if(props.isSpotlight) {
|
||||
events.fire("action_set_fullscreen", { videoId: isFullscreen ? undefined : props.videoId });
|
||||
} else {
|
||||
events.fire("action_set_spotlight", { videoId: props.videoId, expend: true });
|
||||
events.fire("action_focus_spotlight", { });
|
||||
}
|
||||
}}
|
||||
title={props.isSpotlight ? tr("Toggle fullscreen") : tr("Toggle spotlight")}
|
||||
>
|
||||
<ClientIconRenderer className={cssStyle.icon} icon={ClientIcon.ShareScreen} />
|
||||
<ClientIconRenderer className={cssStyle.icon} icon={ClientIcon.Fullscreen} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -184,7 +184,7 @@
|
|||
.infoDescription {
|
||||
.value {
|
||||
white-space: pre-wrap!important;
|
||||
height: 3.2em!important;
|
||||
min-height: 3.2em !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue