Video now has to be manually activated in order to watch it

This commit is contained in:
WolverinDEV 2020-12-10 13:32:35 +01:00
parent e78833e534
commit 23414b7f31
10 changed files with 521 additions and 116 deletions

View file

@ -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 {

View file

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

View file

@ -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[] {

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -184,7 +184,7 @@
.infoDescription {
.value {
white-space: pre-wrap!important;
height: 3.2em!important;
min-height: 3.2em !important;
}
}