Merge remote-tracking branch 'upstream/develop' into feature/incoming-call-toast

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-08-04 09:21:45 +02:00
commit d479373af9
No known key found for this signature in database
GPG key ID: CC823428E9B582FB
182 changed files with 5872 additions and 2200 deletions

View file

@ -23,9 +23,21 @@ interface IProps {
feed: CallFeed;
}
export default class AudioFeed extends React.Component<IProps> {
interface IState {
audioMuted: boolean;
}
export default class AudioFeed extends React.Component<IProps, IState> {
private element = createRef<HTMLAudioElement>();
constructor(props: IProps) {
super(props);
this.state = {
audioMuted: this.props.feed.isAudioMuted(),
};
}
componentDidMount() {
MediaDeviceHandler.instance.addListener(
MediaDeviceHandlerEvent.AudioOutputChanged,
@ -62,6 +74,7 @@ export default class AudioFeed extends React.Component<IProps> {
private playMedia() {
const element = this.element.current;
if (!element) return;
this.onAudioOutputChanged(MediaDeviceHandler.getAudioOutput());
element.muted = false;
element.srcObject = this.props.feed.stream;
@ -85,6 +98,7 @@ export default class AudioFeed extends React.Component<IProps> {
private stopMedia() {
const element = this.element.current;
if (!element) return;
element.pause();
element.src = null;
@ -96,10 +110,16 @@ export default class AudioFeed extends React.Component<IProps> {
}
private onNewStream = () => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
});
this.playMedia();
};
render() {
// Do not render the audio element if there is no audio track
if (this.state.audioMuted) return null;
return (
<audio ref={this.element} />
);

View file

@ -146,7 +146,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
document.addEventListener("mousemove", this.onMoving);
document.addEventListener("mouseup", this.onEndMoving);
window.addEventListener("resize", this.snap);
window.addEventListener("resize", this.onResize);
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
}
@ -156,7 +156,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
document.removeEventListener("mousemove", this.onMoving);
document.removeEventListener("mouseup", this.onEndMoving);
window.removeEventListener("resize", this.snap);
window.removeEventListener("resize", this.onResize);
if (this.roomStoreToken) {
this.roomStoreToken.remove();
}
@ -164,6 +164,10 @@ export default class CallPreview extends React.Component<IProps, IState> {
SettingsStore.unwatchSetting(this.settingsWatcherRef);
}
private onResize = (): void => {
this.snap(false);
};
private animationCallback = () => {
// If the PiP isn't being dragged and there is only a tiny difference in
// the desiredTranslation and translation, quit the animationCallback
@ -207,7 +211,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
}
}
private snap = () => {
private snap(animate?: boolean): void {
const translationX = this.desiredTranslationX;
const translationY = this.desiredTranslationY;
// We subtract the PiP size from the window size in order to calculate
@ -236,10 +240,17 @@ export default class CallPreview extends React.Component<IProps, IState> {
this.desiredTranslationY = PADDING.top;
}
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
};
if (animate) {
// We start animating here because we want the PiP to move when we're
// resizing the window
this.scheduledUpdate.mark();
} else {
this.setState({
translationX: this.desiredTranslationX,
translationY: this.desiredTranslationY,
});
}
}
private onRoomViewStoreUpdate = () => {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
@ -310,7 +321,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
private onEndMoving = () => {
this.moving = false;
this.snap();
this.snap(true);
};
public render() {
@ -333,6 +344,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
secondaryCall={this.state.secondaryCall}
pipMode={true}
onMouseDownOnHeader={this.onStartMoving}
onResize={this.onResize}
/>
</div>
);

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -32,6 +33,10 @@ import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
import Modal from '../../../Modal';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import CallViewSidebar from './CallViewSidebar';
interface IProps {
// The call for us to display
@ -59,11 +64,15 @@ interface IState {
isRemoteOnHold: boolean;
micMuted: boolean;
vidMuted: boolean;
screensharing: boolean;
callState: CallState;
controlsVisible: boolean;
hoveringControls: boolean;
showMoreMenu: boolean;
showDialpad: boolean;
feeds: CallFeed[];
primaryFeed: CallFeed;
secondaryFeeds: Array<CallFeed>;
sidebarShown: boolean;
}
function getFullScreenElement() {
@ -94,7 +103,7 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
const CONTROLS_HIDE_DELAY = 1000;
const CONTROLS_HIDE_DELAY = 2000;
// Height of the header duplicated from CSS because we need to subtract it from our max
// height to get the max height of the video
const CONTEXT_MENU_VPADDING = 8; // How far the context menu sits above the button (px)
@ -106,20 +115,27 @@ export default class CallView extends React.Component<IProps, IState> {
private controlsHideTimer: number = null;
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private contextMenu = createRef<HTMLDivElement>();
constructor(props: IProps) {
super(props);
const { primary, secondary } = this.getOrderedFeeds(this.props.call.getFeeds());
this.state = {
isLocalOnHold: this.props.call.isLocalOnHold(),
isRemoteOnHold: this.props.call.isRemoteOnHold(),
micMuted: this.props.call.isMicrophoneMuted(),
vidMuted: this.props.call.isLocalVideoMuted(),
screensharing: this.props.call.isScreensharing(),
callState: this.props.call.state,
controlsVisible: true,
hoveringControls: false,
showMoreMenu: false,
showDialpad: false,
feeds: this.props.call.getFeeds(),
primaryFeed: primary,
secondaryFeeds: secondary,
sidebarShown: true,
};
this.updateCallListeners(null, this.props.call);
@ -194,7 +210,11 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onFeedsChanged = (newFeeds: Array<CallFeed>) => {
this.setState({ feeds: newFeeds });
const { primary, secondary } = this.getOrderedFeeds(newFeeds);
this.setState({
primaryFeed: primary,
secondaryFeeds: secondary,
});
};
private onCallLocalHoldUnhold = () => {
@ -227,6 +247,7 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onControlsHideTimer = () => {
if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
this.controlsHideTimer = null;
this.setState({
controlsVisible: false,
@ -237,7 +258,30 @@ export default class CallView extends React.Component<IProps, IState> {
this.showControls();
};
private showControls() {
private getOrderedFeeds(feeds: Array<CallFeed>): { primary: CallFeed, secondary: Array<CallFeed> } {
let primary;
// Try to use a screensharing as primary, a remote one if possible
const screensharingFeeds = feeds.filter((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare);
primary = screensharingFeeds.find((feed) => !feed.isLocal()) || screensharingFeeds[0];
// If we didn't find remote screen-sharing stream, try to find any remote stream
if (!primary) {
primary = feeds.find((feed) => !feed.isLocal());
}
const secondary = [...feeds];
// Remove the primary feed from the array
if (primary) secondary.splice(secondary.indexOf(primary), 1);
secondary.sort((a, b) => {
if (a.isLocal() && !b.isLocal()) return -1;
if (!a.isLocal() && b.isLocal()) return 1;
return 0;
});
return { primary, secondary };
}
private showControls(): void {
if (this.state.showMoreMenu || this.state.showDialpad) return;
if (!this.state.controlsVisible) {
@ -251,73 +295,62 @@ export default class CallView extends React.Component<IProps, IState> {
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
}
private onDialpadClick = () => {
private onDialpadClick = (): void => {
if (!this.state.showDialpad) {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
this.setState({
showDialpad: true,
controlsVisible: true,
});
this.setState({ showDialpad: true });
this.showControls();
} else {
if (this.controlsHideTimer !== null) {
clearTimeout(this.controlsHideTimer);
}
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
this.setState({
showDialpad: false,
});
this.setState({ showDialpad: false });
}
};
private onMicMuteClick = () => {
private onMicMuteClick = (): void => {
const newVal = !this.state.micMuted;
this.props.call.setMicrophoneMuted(newVal);
this.setState({ micMuted: newVal });
};
private onVidMuteClick = () => {
private onVidMuteClick = (): void => {
const newVal = !this.state.vidMuted;
this.props.call.setLocalVideoMuted(newVal);
this.setState({ vidMuted: newVal });
};
private onMoreClick = () => {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
}
private onScreenshareClick = async (): Promise<void> => {
const isScreensharing = await this.props.call.setScreensharingEnabled(
!this.state.screensharing,
async (): Promise<DesktopCapturerSource> => {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
return source;
},
);
this.setState({
showMoreMenu: true,
controlsVisible: true,
sidebarShown: true,
screensharing: isScreensharing,
});
};
private closeDialpad = () => {
this.setState({
showDialpad: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
private onMoreClick = (): void => {
this.setState({ showMoreMenu: true });
this.showControls();
};
private closeContextMenu = () => {
this.setState({
showMoreMenu: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
private closeDialpad = (): void => {
this.setState({ showDialpad: false });
};
private closeContextMenu = (): void => {
this.setState({ showMoreMenu: false });
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this
private onNativeKeyDown = ev => {
private onNativeKeyDown = (ev): void => {
let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
@ -347,7 +380,16 @@ export default class CallView extends React.Component<IProps, IState> {
}
};
private onRoomAvatarClick = () => {
private onCallControlsMouseEnter = (): void => {
this.setState({ hoveringControls: true });
this.showControls();
};
private onCallControlsMouseLeave = (): void => {
this.setState({ hoveringControls: false });
};
private onRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
@ -355,7 +397,7 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
private onSecondaryRoomAvatarClick = () => {
private onSecondaryRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
dis.dispatch({
@ -364,50 +406,30 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
private onCallResumeClick = () => {
private onCallResumeClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
};
private onTransferClick = () => {
private onTransferClick = (): void => {
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
this.props.call.transferToCall(transfereeCall);
};
public render() {
const client = MatrixClientPeg.get();
const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
const callRoom = client.getRoom(callRoomId);
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
private onHangupClick = (): void => {
dis.dispatch({
action: 'hangup',
room_id: CallHandler.sharedInstance().roomIdForCall(this.props.call),
});
};
let dialPad;
let contextMenu;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
private onToggleSidebar = (): void => {
this.setState({
sidebarShown: !this.state.sidebarShown,
});
};
private renderCallControls(): JSX.Element {
const micClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_micOn: !this.state.micMuted,
@ -420,6 +442,18 @@ export default class CallView extends React.Component<IProps, IState> {
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
});
const screensharingClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_screensharingOn: this.state.screensharing,
mx_CallView_callControls_button_screensharingOff: !this.state.screensharing,
});
const sidebarButtonClasses = classNames({
mx_CallView_callControls_button: true,
mx_CallView_callControls_button_sidebarOn: this.state.sidebarShown,
mx_CallView_callControls_button_sidebarOff: !this.state.sidebarShown,
});
// Put the other states of the mic/video icons in the document to make sure they're cached
// (otherwise the icon disappears briefly when toggled)
const micCacheClasses = classNames({
@ -441,59 +475,151 @@ export default class CallView extends React.Component<IProps, IState> {
mx_CallView_callControls_hidden: !this.state.controlsVisible,
});
const vidMuteButton = this.props.call.type === CallType.Video ? <AccessibleButton
className={vidClasses}
onClick={this.onVidMuteClick}
/> : null;
// We don't support call upgrades (yet) so hide the video mute button in voice calls
let vidMuteButton;
if (this.props.call.type === CallType.Video) {
vidMuteButton = (
<AccessibleButton
className={vidClasses}
onClick={this.onVidMuteClick}
/>
);
}
// Screensharing is possible, if we can send a second stream and
// identify it using SDPStreamMetadata or if we can replace the already
// existing usermedia track by a screensharing track. We also need to be
// connected to know the state of the other side
let screensharingButton;
if (
(this.props.call.opponentSupportsSDPStreamMetadata() || this.props.call.type === CallType.Video) &&
this.props.call.state === CallState.Connected
) {
screensharingButton = (
<AccessibleButton
className={screensharingClasses}
onClick={this.onScreenshareClick}
/>
);
}
// To show the sidebar we need secondary feeds, if we don't have them,
// we can hide this button. If we are in PiP, sidebar is also hidden, so
// we can hide the button too
let sidebarButton;
if (
!this.props.pipMode &&
(
this.state.primaryFeed?.purpose === SDPStreamMetadataPurpose.Screenshare ||
this.props.call.isScreensharing()
)
) {
sidebarButton = (
<AccessibleButton
className={sidebarButtonClasses}
onClick={this.onToggleSidebar}
/>
);
}
// The dial pad & 'more' button actions are only relevant in a connected call
// When not connected, we have to put something there to make the flexbox alignment correct
const dialpadButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_dialpad_hidden" />;
let contextMenuButton;
if (this.state.callState === CallState.Connected) {
contextMenuButton = (
<ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
/>
);
}
let dialpadButton;
if (this.state.callState === CallState.Connected && this.props.call.opponentSupportsDTMF()) {
dialpadButton = (
<ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
/>
);
}
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_more_hidden" />;
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
mountAsChild={true}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
// in the near future, the dial pad button will go on the left. For now, it's the nothing button
// because something needs to have margin-right: auto to make the alignment correct.
const callControls = <div className={callControlsClasses}>
{ dialpadButton }
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
/>
<AccessibleButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: callRoomId,
});
}}
/>
{ vidMuteButton }
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{ contextMenuButton }
</div>;
let contextMenu;
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
mountAsChild={true}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
return (
<div
className={callControlsClasses}
onMouseEnter={this.onCallControlsMouseEnter}
onMouseLeave={this.onCallControlsMouseLeave}
>
{ dialPad }
{ contextMenu }
{ dialpadButton }
<AccessibleButton
className={micClasses}
onClick={this.onMicMuteClick}
/>
{ vidMuteButton }
<div className={micCacheClasses} />
<div className={vidCacheClasses} />
{ screensharingButton }
{ sidebarButton }
{ contextMenuButton }
<AccessibleButton
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
onClick={this.onHangupClick}
/>
</div>
);
}
public render() {
const client = MatrixClientPeg.get();
const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
const callRoom = client.getRoom(callRoomId);
const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
const avatarSize = this.props.pipMode ? 76 : 160;
// The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg)
let contentView: React.ReactNode;
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
const isScreensharing = this.props.call.isScreensharing();
const sidebarShown = this.state.sidebarShown;
const someoneIsScreensharing = this.props.call.getFeeds().some((feed) => {
return feed.purpose === SDPStreamMetadataPurpose.Screenshare;
});
const isVideoCall = this.props.call.type === CallType.Video;
let contentView: React.ReactNode;
let holdTransferContent;
if (transfereeCall) {
const transferTargetRoom = MatrixClientPeg.get().getRoom(
CallHandler.sharedInstance().roomIdForCall(this.props.call),
@ -539,9 +665,25 @@ export default class CallView extends React.Component<IProps, IState> {
</div>;
}
let sidebar;
if (
!isOnHold &&
!transfereeCall &&
sidebarShown &&
(isVideoCall || someoneIsScreensharing)
) {
sidebar = (
<CallViewSidebar
feeds={this.state.secondaryFeeds}
call={this.props.call}
pipMode={this.props.pipMode}
/>
);
}
// This is a bit messy. I can't see a reason to have two onHold/transfer screens
if (isOnHold || transfereeCall) {
if (this.props.call.type === CallType.Video) {
if (isVideoCall) {
const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true,
@ -560,7 +702,7 @@ export default class CallView extends React.Component<IProps, IState> {
<div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{ onHoldBackground }
{ holdTransferContent }
{ callControls }
{ this.renderCallControls() }
</div>
);
} else {
@ -585,7 +727,7 @@ export default class CallView extends React.Component<IProps, IState> {
</div>
</div>
{ holdTransferContent }
{ callControls }
{ this.renderCallControls() }
</div>
);
}
@ -599,77 +741,91 @@ export default class CallView extends React.Component<IProps, IState> {
mx_CallView_voice: true,
});
const feeds = this.props.call.getLocalFeeds().map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted()) return;
return (
<VideoFeed
key={i}
feed={feed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
/>
);
});
// Saying "Connecting" here isn't really true, but the best thing
// I can come up with, but this might be subject to change as well
contentView = <div className={classes} onMouseMove={this.onMouseMove}>
{ feeds }
<div className="mx_CallView_voice_avatarsContainer">
<div className="mx_CallView_voice_avatarContainer" style={{ width: avatarSize, height: avatarSize }}>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
contentView = (
<div
className={classes}
onMouseMove={this.onMouseMove}
>
{ sidebar }
<div className="mx_CallView_voice_avatarsContainer">
<div
className="mx_CallView_voice_avatarContainer"
style={{ width: avatarSize, height: avatarSize }}
>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
</div>
</div>
<div className="mx_CallView_holdTransferContent">{ _t("Connecting") }</div>
{ this.renderCallControls() }
</div>
<div className="mx_CallView_holdTransferContent">{ _t("Connecting") }</div>
{ callControls }
</div>;
);
} else {
const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true,
});
// TODO: Later the CallView should probably be reworked to support
// any number of feeds but now we can always expect there to be two
// feeds. This is because the js-sdk ignores any new incoming streams
const feeds = this.state.feeds.map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted() && feed.isLocal()) return;
return (
let toast;
if (someoneIsScreensharing) {
const presentingClasses = classNames({
mx_CallView_presenting: true,
mx_CallView_presenting_hidden: !this.state.controlsVisible,
});
const sharerName = this.state.primaryFeed.getMember().name;
let text = isScreensharing
? _t("You are presenting")
: _t('%(sharerName)s is presenting', { sharerName });
if (!this.state.sidebarShown && isVideoCall) {
text += " • " + (this.props.call.isLocalVideoMuted()
? _t("Your camera is turned off")
: _t("Your camera is still enabled"));
}
toast = (
<div className={presentingClasses}>
{ text }
</div>
);
}
contentView = (
<div
className={containerClasses}
ref={this.contentRef}
onMouseMove={this.onMouseMove}
>
{ toast }
{ sidebar }
<VideoFeed
key={i}
feed={feed}
feed={this.state.primaryFeed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
primary={true}
/>
);
});
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{ feeds }
{ callControls }
</div>;
{ this.renderCallControls() }
</div>
);
}
const callTypeText = this.props.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call");
let myClassName;
let fullScreenButton;
if (this.props.call.type === CallType.Video && !this.props.pipMode) {
fullScreenButton = <div
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick}
title={_t("Fill Screen")}
/>;
if (!this.props.pipMode) {
fullScreenButton = (
<div
className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
onClick={this.onFullscreenClick}
title={_t("Fill Screen")}
/>
);
}
let expandButton;
@ -686,10 +842,15 @@ export default class CallView extends React.Component<IProps, IState> {
{ expandButton }
</div>;
const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
"mx_CallView_header_callTypeIcon_voice": !isVideoCall,
"mx_CallView_header_callTypeIcon_video": isVideoCall,
});
let header: React.ReactNode;
if (!this.props.pipMode) {
header = <div className="mx_CallView_header">
<div className="mx_CallView_header_phoneIcon" />
<div className={callTypeIconClassName} />
<span className="mx_CallView_header_callType">{ callTypeText }</span>
{ headerControls }
</div>;
@ -731,8 +892,6 @@ export default class CallView extends React.Component<IProps, IState> {
return <div className={"mx_CallView " + myClassName}>
{ header }
{ contentView }
{ dialPad }
{ contextMenu }
</div>;
}
}

View file

@ -0,0 +1,53 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import VideoFeed from "./VideoFeed";
import classNames from "classnames";
interface IProps {
feeds: Array<CallFeed>;
call: MatrixCall;
pipMode: boolean;
}
export default class CallViewSidebar extends React.Component<IProps> {
render() {
const feeds = this.props.feeds.map((feed) => {
return (
<VideoFeed
key={feed.stream.id}
feed={feed}
call={this.props.call}
primary={false}
pipMode={this.props.pipMode}
/>
);
});
const className = classNames("mx_CallViewSidebar", {
mx_CallViewSidebar_pipMode: this.props.pipMode,
});
return (
<div className={className}>
{ feeds }
</div>
);
}
}

View file

@ -16,12 +16,13 @@ limitations under the License.
import classnames from 'classnames';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React, { createRef } from 'react';
import React from 'react';
import SettingsStore from "../../../settings/SettingsStore";
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import MemberAvatar from "../avatars/MemberAvatar";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
interface IProps {
call: MatrixCall;
@ -37,6 +38,8 @@ interface IProps {
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize?: (e: Event) => void;
primary: boolean;
}
interface IState {
@ -45,8 +48,8 @@ interface IState {
}
@replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps, IState> {
private element = createRef<HTMLVideoElement>();
export default class VideoFeed extends React.PureComponent<IProps, IState> {
private element: HTMLVideoElement;
constructor(props: IProps) {
super(props);
@ -58,18 +61,59 @@ export default class VideoFeed extends React.Component<IProps, IState> {
}
componentDidMount() {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.updateFeed(null, this.props.feed);
this.playMedia();
}
componentWillUnmount() {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.element.current?.removeEventListener('resize', this.onResize);
this.stopMedia();
this.updateFeed(this.props.feed, null);
}
componentDidUpdate(prevProps: IProps, prevState: IState) {
this.updateFeed(prevProps.feed, this.props.feed);
// If the mutes state has changed, we try to playMedia()
if (
prevState.videoMuted !== this.state.videoMuted ||
prevProps.feed.stream !== this.props.feed.stream
) {
this.playMedia();
}
}
static getDerivedStateFromProps(props: IProps) {
return {
audioMuted: props.feed.isAudioMuted(),
videoMuted: props.feed.isVideoMuted(),
};
}
private setElementRef = (element: HTMLVideoElement): void => {
if (!element) {
this.element?.removeEventListener('resize', this.onResize);
return;
}
this.element = element;
element.addEventListener('resize', this.onResize);
};
private updateFeed(oldFeed: CallFeed, newFeed: CallFeed) {
if (oldFeed === newFeed) return;
if (oldFeed) {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
this.stopMedia();
}
if (newFeed) {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
this.playMedia();
}
}
private playMedia() {
const element = this.element.current;
const element = this.element;
if (!element) return;
// We play audio in AudioFeed, not here
element.muted = true;
@ -92,7 +136,7 @@ export default class VideoFeed extends React.Component<IProps, IState> {
}
private stopMedia() {
const element = this.element.current;
const element = this.element;
if (!element) return;
element.pause();
@ -109,7 +153,13 @@ export default class VideoFeed extends React.Component<IProps, IState> {
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
this.playMedia();
};
private onMuteStateChanged = () => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
};
private onResize = (e) => {
@ -119,35 +169,58 @@ export default class VideoFeed extends React.Component<IProps, IState> {
};
render() {
const videoClasses = {
mx_VideoFeed: true,
mx_VideoFeed_local: this.props.feed.isLocal(),
mx_VideoFeed_remote: !this.props.feed.isLocal(),
const { pipMode, primary, feed } = this.props;
const wrapperClasses = classnames("mx_VideoFeed", {
mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted,
mx_VideoFeed_mirror: (
this.props.feed.isLocal() &&
SettingsStore.getValue('VideoView.flipVideoHorizontally')
),
};
});
const micIconClasses = classnames("mx_VideoFeed_mic", {
mx_VideoFeed_mic_muted: this.state.audioMuted,
mx_VideoFeed_mic_unmuted: !this.state.audioMuted,
});
if (this.state.videoMuted) {
const member = this.props.feed.getMember();
const avatarSize = this.props.pipMode ? 76 : 160;
return (
<div className={classnames(videoClasses)}>
<MemberAvatar
member={member}
height={avatarSize}
width={avatarSize}
/>
</div>
);
} else {
return (
<video className={classnames(videoClasses)} ref={this.element} />
let micIcon;
if (feed.purpose !== SDPStreamMetadataPurpose.Screenshare && !pipMode) {
micIcon = (
<div className={micIconClasses} />
);
}
let content;
if (this.state.videoMuted) {
const member = this.props.feed.getMember();
let avatarSize;
if (pipMode && primary) avatarSize = 76;
else if (pipMode && !primary) avatarSize = 16;
else if (!pipMode && primary) avatarSize = 160;
else; // TBD
content =(
<MemberAvatar
member={member}
height={avatarSize}
width={avatarSize}
/>
);
} else {
const videoClasses = classnames("mx_VideoFeed_video", {
mx_VideoFeed_video_mirror: (
this.props.feed.isLocal() &&
SettingsStore.getValue('VideoView.flipVideoHorizontally')
),
});
content= (
<video className={videoClasses} ref={this.setElementRef} />
);
}
return (
<div className={wrapperClasses}>
{ micIcon }
{ content }
</div>
);
}
}