- {this.renderSpeakingProfile()}
-
-
- {this.renderSpeaking()}
-
- {this.renderRecordingBadge()}
-
- {this.renderChannelName()}
+
+ {!this.props.enableVideo &&
+
+ {this.renderSpeakingProfile()}
+
+
+ {this.renderSpeaking()}
+
+ {this.renderRecordingBadge()}
+
+ {this.renderChannelName()}
+
+
+
+ }
+ />
+ }
-
- }
- />
-
+ {this.props.enableVideo && this.renderTopBar() }
- {shouldRenderVideoContainer && this.renderVideoContainer()}
+ {/* {shouldRenderVideoContainer && this.renderVideoContainer()} */}
+
+ {this.props.enableVideo && this.renderMiddleBar() }
{
);
};
+
+const WidgetProfileContainer = styled.div<{$videoView: boolean, $singleSession?: boolean}>`
+ display: flex;
+ position: relative;
+ justify-content: center;
+ align-items: center;
+ background: #E4EBFA;
+ border-radius: 4px;
+ flex: 1;
+
+ ${({$videoView, $singleSession}) => $videoView && !$singleSession && css`
+ aspect-ratio: 1;
+ `}
+
+ ${({$videoView}) => !$videoView && css`
+ height: 75px;
+ `}
+`;
+
+const MuteState = styled.div<{ $isMuted: boolean }>`
+ position: absolute;
+ bottom: 4px;
+ left: 4px;
+ border-radius: 20px;
+ background: #14213E;
+ width: 20px;
+ height: 20px;
+ border-radius: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+`;
+
+const WidgetProfileVideoPlayer = styled.video<{$mirror: boolean}>`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 4px;
+
+ ${({$mirror}) => $mirror && css`
+ transform: scaleX(-1);
+ `}
+`;
+
+type CallsWidgetProfileProps = {
+ profile: UserProfile;
+ isSpeaking: boolean;
+ isMuted: boolean;
+ videoStream: MediaStream | null;
+ hasVideo: boolean;
+ videoView: boolean;
+ mirrorVideo: boolean;
+ singleSession?: boolean;
+}
+
+const CallsWidgetProfile = (props: CallsWidgetProfileProps) => {
+ const MuteIcon = props.isMuted ? MutedIcon : UnmutedIcon;
+
+ const [videoEl, setVideoEl] = useState
(null);
+ const videoElRefCb = (el: HTMLVideoElement | null) => {
+ if (el) {
+ setVideoEl(el);
+ }
+ };
+
+ useEffect(() => {
+ if (videoEl && props.videoStream) {
+ videoEl.srcObject = props.videoStream;
+ }
+ }, [props.videoStream, videoEl]);
+
+ return (
+
+
+ {!props.hasVideo &&
+
+ }
+
+ {props.hasVideo &&
+
+ }
+
+ {props.isMuted &&
+
+
+
+ }
+
+ );
+};
diff --git a/webapp/src/components/expanded_view/component.tsx b/webapp/src/components/expanded_view/component.tsx
index 072fb5e49..4f519802a 100644
--- a/webapp/src/components/expanded_view/component.tsx
+++ b/webapp/src/components/expanded_view/component.tsx
@@ -31,6 +31,7 @@ import {
import ChatThreadIcon from 'src/components/icons/chat_thread';
import CollapseIcon from 'src/components/icons/collapse';
import CompassIcon from 'src/components/icons/compassIcon';
+import GridViewIcon from 'src/components/icons/grid_view';
import LeaveCallIcon from 'src/components/icons/leave_call_icon';
import MutedIcon from 'src/components/icons/muted_icon';
import ParticipantsIcon from 'src/components/icons/participants';
@@ -38,13 +39,13 @@ import RecordCircleIcon from 'src/components/icons/record_circle';
import RecordSquareIcon from 'src/components/icons/record_square';
import ScreenIcon from 'src/components/icons/screen_icon';
import ShareScreenIcon from 'src/components/icons/share_screen';
+import SpeakerViewIcon from 'src/components/icons/speaker_view';
import UnmutedIcon from 'src/components/icons/unmuted_icon';
import UnshareScreenIcon from 'src/components/icons/unshare_screen';
import VideoOffIcon from 'src/components/icons/video_off';
import VideoOnIcon from 'src/components/icons/video_on';
import {ExpandedIncomingCallContainer} from 'src/components/incoming_calls/expanded_incoming_call_container';
import {LeaveCallMenu} from 'src/components/leave_call_menu';
-import {VideoLoadingOverlay} from 'src/components/loading_overlays';
import {ReactionStream} from 'src/components/reaction_stream/reaction_stream';
import {CallAlertConfigs, DEGRADED_CALL_QUALITY_ALERT_WAIT, STORAGE_CALLS_MIRROR_VIDEO_KEY} from 'src/constants';
import {logDebug, logErr} from 'src/log';
@@ -141,6 +142,7 @@ interface State {
showLiveCaptions: boolean,
alerts: CallAlertStates,
removeConfirmation: RemoveConfirmationData | null,
+ viewState: 'grid' | 'speaker',
}
const StyledMediaController = styled(MediaController)`
@@ -293,6 +295,7 @@ export default class ExpandedView extends React.PureComponent {
showLiveCaptions: false,
alerts: CallAlertStatesDefault,
removeConfirmation: null,
+ viewState: 'speaker',
};
if (window.opener) {
@@ -468,8 +471,10 @@ export default class ExpandedView extends React.PureComponent {
}
const callsClient = getCallsClient();
if (this.isMuted()) {
+ logDebug('ExpandedView.onMuteToggle: unmuting (user toggled on)');
callsClient?.unmute();
} else {
+ logDebug('ExpandedView.onMuteToggle: muting (user toggled off)');
callsClient?.mute();
}
};
@@ -477,8 +482,10 @@ export default class ExpandedView extends React.PureComponent {
onVideoToggle = () => {
const callsClient = getCallsClient();
if (this.isVideoOn()) {
+ logDebug('ExpandedView.onVideoToggle: stopping video (user toggled off)');
callsClient?.stopVideo();
} else {
+ logDebug('ExpandedView.onVideoToggle: starting video (user toggled on)');
callsClient?.startVideo();
}
};
@@ -587,6 +594,12 @@ export default class ExpandedView extends React.PureComponent {
this.props.hideExpandedView();
};
+ onSwitchViewClick = () => {
+ this.setState({
+ viewState: this.state.viewState === 'grid' ? 'speaker' : 'grid',
+ });
+ };
+
public componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.theme.type !== this.props.theme.type) {
this.style = this.genStyle();
@@ -876,48 +889,138 @@ export default class ExpandedView extends React.PureComponent {
return null;
};
- renderVideoContainer = () => {
+ renderTopVideoContainer = () => {
+ const {formatMessage} = this.props.intl;
+
// Here we are assuming this only renders in a DM which is the case
// right now.
const selfProfile = this.props.profiles[this.props.currentUserID];
+ const selfSession = this.props.currentSession;
const otherProfile = this.props.connectedDMUser;
- const otherSession = this.props.otherSessions.find((s) => s.video);
+ const otherSession = this.props.otherSessions[0];
return (
-
- { selfProfile && this.props.currentSession &&
-
+ { selfProfile && selfSession &&
+
+
+
}
- { otherProfile && this.props.otherSessions.length !== 0 &&
-
+
+
+ }
+
+ );
+ };
+
+ renderVideoContainer = () => {
+ const {formatMessage} = this.props.intl;
+
+ // Here we are assuming this only renders in a DM which is the case
+ // right now.
+ const selfProfile = this.props.profiles[this.props.currentUserID];
+ const selfSession = this.props.currentSession;
+ const otherProfile = this.props.connectedDMUser;
+ const otherSession = this.props.otherSessions[0];
+
+ // If current user is the only one in the call, we show their video, otherwise we show the other user's video.
+ const session = this.props.otherSessions.length === 0 ? selfSession : otherSession;
+ const profile = this.props.otherSessions.length === 0 ? selfProfile : otherProfile;
+ const stream = this.props.otherSessions.length === 0 ? this.state.selfVideoStream : this.state.otherVideoStream;
+ const mirrorSelfVideo = localStorage.getItem(STORAGE_CALLS_MIRROR_VIDEO_KEY) === 'true';
+
+ const shouldRenderTopVideoContainer = this.state.viewState === 'speaker' && ((this.props.currentSession?.video && this.props.otherSessions.length > 0) || this.props.otherSessions.some((s) => s.video));
+
+ const renderSpeakerView = () => {
+ if (!profile || !session) {
+ return null;
+ }
+ return (
+
+ );
+ };
+
+ const renderGridView = () => {
+ return (
+ <>
+ { otherProfile && otherSession &&
+
- }
-
+ }
+
+ { selfProfile && selfSession &&
+
+ }
+ >
+ );
+ };
+
+ return (
+
+ {this.state.viewState === 'speaker' && renderSpeakerView()}
+ {this.state.viewState === 'grid' && !this.props.screenSharingSession && renderGridView()}
+
);
};
renderScreenSharingPlayer = () => {
const isSharing = this.props.screenSharingSession?.session_id === this.props.currentSession?.session_id;
const {formatMessage} = this.props.intl;
- const shouldRenderVideoContainer = this.props.currentSession?.video || this.props.otherSessions.some((s) => s.video);
+ const shouldRenderTopVideoContainer = (this.props.currentSession?.video && this.props.otherSessions.length > 0) || this.props.otherSessions.some((s) => s.video);
let heightAllowance = this.shouldRenderAlertBanner() ? 164 : 124;
- if (shouldRenderVideoContainer) {
- heightAllowance += 156;
+ if (shouldRenderTopVideoContainer) {
+ heightAllowance += 96;
}
let profile;
@@ -1080,7 +1183,7 @@ export default class ExpandedView extends React.PureComponent
{
const noVideoInputDevices = this.state.alerts.missingVideoInput.active;
const noVideoPermissions = this.state.alerts.missingVideoInputPermissions.active;
const isVideoOn = this.isVideoOn();
- const VideoIcon = this.isVideoOn() || noVideoInputDevices || noVideoPermissions ? VideoOffIcon : VideoOnIcon;
+ const VideoIcon = this.isVideoOn() || noVideoInputDevices || noVideoPermissions ? VideoOnIcon : VideoOffIcon;
let videoTooltipText = isVideoOn ? formatMessage({defaultMessage: 'Turn camera off'}) : formatMessage({defaultMessage: 'Turn camera on'});
let videoTooltipSubtext = '';
if (noVideoInputDevices) {
@@ -1146,7 +1249,11 @@ export default class ExpandedView extends React.PureComponent {
const leaveCallTooltipText = formatMessage({defaultMessage: 'Leave call'});
const closeViewLabel = formatMessage({defaultMessage: 'Close window'});
+ const switchViewLabel = this.state.viewState === 'speaker' ? formatMessage({defaultMessage: 'Switch to grid view'}) : formatMessage({defaultMessage: 'Switch to speaker view'});
+ const SwitchViewIcon = this.state.viewState === 'speaker' ? SpeakerViewIcon : GridViewIcon;
+
const shouldRenderVideoContainer = this.props.currentSession?.video || this.props.otherSessions.some((s) => s.video);
+ const shouldRenderTopVideoContainer = (this.state.viewState === 'speaker' || this.props.screenSharingSession) && ((this.props.currentSession?.video && this.props.otherSessions.length > 0) || this.props.otherSessions.some((s) => s.video));
return (
{
+
+ { this.props.enableVideo && !this.props.screenSharingSession && shouldRenderVideoContainer && this.props.otherSessions.length > 0 &&
+
+ {switchViewLabel}
+
+ }
+ >
+
+
+
+
+ }
+
{
+ {shouldRenderTopVideoContainer && this.renderTopVideoContainer()}
+
{!this.props.screenSharingSession && !shouldRenderVideoContainer && this.props.currentSession && this.props.channel &&
{
/>
}
- {shouldRenderVideoContainer && this.renderVideoContainer()}
+ {!this.props.screenSharingSession && shouldRenderVideoContainer && this.renderVideoContainer()}
{this.props.screenSharingSession && this.renderScreenSharingPlayer()}
@@ -1620,6 +1755,35 @@ const CloseViewButton = styled.button`
}
`;
+const SwitchViewButton = styled.button`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ padding: 10px;
+ border-radius: 4px;
+
+ svg {
+ fill: rgba(255, 255, 255, 0.64);
+ }
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.08);
+
+ svg {
+ fill: rgba(255, 255, 255, 0.72);
+ }
+ }
+
+ &:active {
+ background: rgba(255, 255, 255, 0.16);
+
+ svg {
+ fill: rgba(255, 255, 255, 0.80);
+ }
+ }
+`;
+
const ReactionOverlay = styled.div`
position: absolute;
bottom: 96px;
@@ -1662,110 +1826,138 @@ const StyledDropdownMenu = styled(DropdownMenu)`
border-radius: 8px;
`;
-const VideoContainer = styled.div<{$screenSharing: boolean}>`
- display: flex;
- align-items: center;
- flex: 1;
- justify-content: center;
- max-width: calc(100% - 16px);
- background: rgba(var(--button-color-rgb), 0.08);
- border-radius: 8px;
- margin: 0 12px;
- padding: 24px;
- gap: 8px;
- max-height: calc(100% - 124px);
-
- ${({$screenSharing}) => $screenSharing && css`
- max-height: 140px;
- margin: 8px 12px;
- padding: 8px;
- `}
+const VideoProfilesTopContainer = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 0px 20px 8px 20px;
`;
-const VideoPlayerContainer = styled.div<{$screenSharing: boolean}>`
- position: relative;
+const VideoProfilesContainer = styled.div<{$height: string}>`
display: flex;
+ align-items: center;
justify-content: center;
- width: 50%;
- max-height: calc(100% - 24px);
+ gap: 12px;
+ flex: 1;
+ padding: 8px 20px;
+ max-width: 100vw;
- ${({$screenSharing}) => $screenSharing && css`
- max-height: 100%;
+ ${({$height}) => $height && css`
+ height: ${$height};
+ max-height: ${$height};
`}
`;
-const VideoPlayer = styled.video<{$selfView: boolean, $screenSharing: boolean, $mirror: boolean}>`
- width: 100%;
- border-radius: 8px;
- object-fit: cover;
+const VideoProfileContainer = styled.div<{$width?: string, $aspectRatio?: string}>`
+ display: flex;
+ position: relative;
+ align-items: center;
+ justify-content: center;
+ background: black;
+ border-radius: 8px;
+ height: 100%;
+ max-height: 100%;
+ max-width: 100%;
- ${({$selfView, $mirror}) => $selfView && $mirror && css`
- transform: scaleX(-1);
+ ${({$aspectRatio}) => $aspectRatio && css`
+ aspect-ratio: ${$aspectRatio};
`}
- ${({$screenSharing}) => $screenSharing && css`
- max-width: 220px;
+ ${({$width}) => $width && css`
+ width: ${$width};
`}
`;
-const VideoPlayerPlaceholder = styled.div`
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: 50%;
- max-height: calc(100% - 24px);
+const VideoProfilePlayer = styled.video<{$mirror: boolean}>`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 8px;
+
+ ${({$mirror}) => $mirror && css`
+ transform: scaleX(-1);
+ `}
`;
-type CallsDMVideoPlayerProps = {
+const VideoProfileState = styled.div`
+ display: flex;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0px 4px;
+ background: rgba(0, 0, 0, 0.80);
+ padding: 4px 6px;
+ gap: 2px;
+
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 16px;
+`;
+
+type VideoProfileProps = {
stream: MediaStream | null;
profile: UserProfile;
+ profileName: string;
+ isMuted: boolean;
hasVideo: boolean;
- selfView: boolean;
- screenSharing: boolean;
+ isSpeaking: boolean;
+ mirrorVideo: boolean;
+ width?: string;
+ aspectRatio?: string;
};
-const CallsDMVideoPlayer = (props: CallsDMVideoPlayerProps) => {
- const [isLoading, setIsLoading] = useState(false);
+const VideoProfile = (props: VideoProfileProps) => {
const [videoEl, setVideoEl] = useState(null);
+ const MuteIcon = props.isMuted ? MutedIcon : UnmutedIcon;
+
useEffect(() => {
if (videoEl && props.stream) {
videoEl.srcObject = props.stream;
}
+ }, [props.stream, videoEl]);
- if (!props.hasVideo) {
- setIsLoading(true);
- }
- }, [props.stream, isLoading, videoEl, props.hasVideo]);
-
- if (props.hasVideo) {
- return (
-
-
- setVideoEl(el)}
- data-testid={`calls-popout-video-player-${props.selfView ? 'self' : 'other'}`}
- onLoadedMetadata={() => setIsLoading(false)}
- autoPlay={true}
- muted={true}
- $selfView={props.selfView}
- $screenSharing={props.screenSharing}
- $mirror={props.selfView && localStorage.getItem(STORAGE_CALLS_MIRROR_VIDEO_KEY) === 'true'}
- />
-
- );
- }
return (
-
+
+ {!props.hasVideo &&
+ }
+
+ {props.hasVideo &&
+ setVideoEl(el)}
+ autoPlay={true}
+ muted={true}
+ $mirror={props.mirrorVideo}
/>
-
+ }
+
+ {props.isMuted &&
+
+
+ {props.profileName}
+
+ }
+
);
};
diff --git a/webapp/src/components/icons/grid_view.tsx b/webapp/src/components/icons/grid_view.tsx
new file mode 100644
index 000000000..e838562ed
--- /dev/null
+++ b/webapp/src/components/icons/grid_view.tsx
@@ -0,0 +1,25 @@
+// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {CSSProperties} from 'react';
+
+type Props = {
+ className?: string,
+ fill?: string,
+ style?: CSSProperties,
+}
+
+export default function GridViewIcon(props: Props) {
+ return (
+
+
+
+
+ );
+}
diff --git a/webapp/src/components/icons/speaker_view.tsx b/webapp/src/components/icons/speaker_view.tsx
new file mode 100644
index 000000000..cc72863f8
--- /dev/null
+++ b/webapp/src/components/icons/speaker_view.tsx
@@ -0,0 +1,25 @@
+// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {CSSProperties} from 'react';
+
+type Props = {
+ className?: string,
+ fill?: string,
+ style?: CSSProperties,
+}
+
+export default function SpeakerViewIcon(props: Props) {
+ return (
+
+
+
+
+ );
+}
diff --git a/webapp/src/components/icons/video_off.tsx b/webapp/src/components/icons/video_off.tsx
index c897e3899..047386a88 100644
--- a/webapp/src/components/icons/video_off.tsx
+++ b/webapp/src/components/icons/video_off.tsx
@@ -15,10 +15,12 @@ export default function VideoOn(props: Props) {
style={props.style}
className={props.className}
fill={props.fill}
- viewBox='0.51 0.96 14.34 14.34'
+ viewBox='1.99 1.72 24.02 22.92'
role='img'
>
-
+
);
}
diff --git a/webapp/src/components/icons/video_on.tsx b/webapp/src/components/icons/video_on.tsx
index 13ebf6f57..9873bbddf 100644
--- a/webapp/src/components/icons/video_on.tsx
+++ b/webapp/src/components/icons/video_on.tsx
@@ -15,10 +15,10 @@ export default function VideoOn(props: Props) {
style={props.style}
className={props.className}
fill={props.fill}
- viewBox='0 0.4 18 12'
+ viewBox='1.99 5.43 24.02 16.82'
role='img'
>
-
+
);
}
diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx
index e73b1845d..14e87bc1f 100644
--- a/webapp/src/index.tsx
+++ b/webapp/src/index.tsx
@@ -7,6 +7,7 @@ import {hasDCSignalingLockSupport} from '@mattermost/calls-common/lib/utils';
import WebSocketClient from '@mattermost/client/websocket';
import type {DesktopAPI} from '@mattermost/desktop-api';
import {PluginAnalyticsRow} from '@mattermost/types/admin';
+import {getChannel as getChannelAction} from 'mattermost-redux/actions/channels';
import {Client4} from 'mattermost-redux/client';
import {getChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {getConfig, getServerVersion} from 'mattermost-redux/selectors/entities/general';
@@ -150,6 +151,7 @@ import {
isLimitRestricted,
needsTURNCredentials,
ringingEnabled,
+ sessionsInCurrentCall,
} from './selectors';
import {JOIN_CALL, keyToAction} from './shortcuts';
import {convertStatsToPanels} from './stats';
@@ -1005,15 +1007,19 @@ export default class Plugin {
sections,
});
+ const currentCallChannelID = channelIDForCurrentCall(store.getState());
+
// We don't care about fetching other calls states in pop out.
// Current call state will be requested over websocket
// from the ExpandedView component itself.
if (isCallsPopOut()) {
+ await Promise.all([
+ store.dispatch(loadProfilesByIdsIfMissing(getUserIDsForSessions(sessionsInCurrentCall(store.getState())))),
+ store.dispatch(getChannelAction(currentCallChannelID)),
+ ]);
return;
}
- const currentCallChannelID = channelIDForCurrentCall(store.getState());
-
// We pass currentCallChannelID so that we
// can skip loading its state as a result of the HTTP calls in
// fetchChannels since it would be racy.