diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index fd58c2414..64afdb86b 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -90,6 +90,7 @@ "FoNl1e": "Check the troubleshooting section if the problem persists.", "FpgQ3M": "Avg Call Duration (minutes)", "G9EwJD": "Call recording quality", + "GNkXiL": "Switch to speaker view", "GSZYxc": "Call recordings", "Ga0yXg": "Call started", "GcvLBC": "Understood", @@ -197,6 +198,7 @@ "aVENjO": "By selecting Try free for 30 days, I agree to the Mattermost Software Evaluation Agreement, Privacy Policy, and receiving product emails.", "aaYQI7": "Enable IPv6 support (Experimental)", "aeJJMp": "Calls by Channel Type", + "aft1aI": "Switch to grid view", "axn5zT": "Here's the call recording. Transcription is processing and will be posted when ready.", "b2Wfwm": "Open in new window", "bBIj2W": "Recording has stopped. Processing…", diff --git a/webapp/src/client.ts b/webapp/src/client.ts index 2c8077c66..b1be0aa9f 100644 --- a/webapp/src/client.ts +++ b/webapp/src/client.ts @@ -39,12 +39,12 @@ export const DefaultVideoTrackOptions: MediaTrackConstraints = { frameRate: { ideal: 30, }, - aspectRatio: { - ideal: 4 / 3, - }, width: { ideal: 640, }, + height: { + ideal: 360, + }, }; const rtcMonitorInterval = 10000; diff --git a/webapp/src/components/avatar/avatar.tsx b/webapp/src/components/avatar/avatar.tsx index cf3f6d9b5..83dcce764 100644 --- a/webapp/src/components/avatar/avatar.tsx +++ b/webapp/src/components/avatar/avatar.tsx @@ -14,6 +14,7 @@ type Props = { icon?: string; border?: boolean; borderGlowWidth?: number; + borderGlowColor?: string; }; type Attrs = HTMLAttributes; @@ -29,6 +30,7 @@ const Avatar = ({ icon, border = true, borderGlowWidth = 0, + borderGlowColor = 'rgba(61, 184, 135, 0.56)', ...attrs }: Props & Attrs) => { if (text) { @@ -40,6 +42,7 @@ const Avatar = ({ $fontSize={fontSize} $border={border} $borderGlowWidth={borderGlowWidth} + $borderGlowColor={borderGlowColor} /> ); } @@ -52,6 +55,7 @@ const Avatar = ({ $fontSize={fontSize} $border={border} $borderGlowWidth={borderGlowWidth} + $borderGlowColor={borderGlowColor} > @@ -68,6 +72,7 @@ const Avatar = ({ $fontSize={fontSize} $border={border} $borderGlowWidth={borderGlowWidth} + $borderGlowColor={borderGlowColor} /> ); }; @@ -77,6 +82,7 @@ interface ProfileProps { $fontSize: number; $border?: boolean; $borderGlowWidth: number; + $borderGlowColor: string; } const Profile = styled.div` @@ -106,7 +112,7 @@ const Profile = styled.div` margin-left: -${({$size}) => $size * 0.25}px; } - box-shadow: ${({$borderGlowWidth}) => $borderGlowWidth > 0 ? `0px 0px 0px ${$borderGlowWidth}px rgba(61, 184, 135, 0.56)` : 'none'}; + box-shadow: ${({$borderGlowWidth, $borderGlowColor}) => $borderGlowWidth > 0 ? `0px 0px 0px ${$borderGlowWidth}px ${$borderGlowColor}` : 'none'}; `; const ProfilePlain = styled(Profile)` diff --git a/webapp/src/components/call_widget/component.tsx b/webapp/src/components/call_widget/component.tsx index 7c2cae0fc..523aef920 100644 --- a/webapp/src/components/call_widget/component.tsx +++ b/webapp/src/components/call_widget/component.tsx @@ -212,6 +212,13 @@ export default class CallWidget extends React.PureComponent { alignItems: 'center', cursor: 'move', }, + topBarNew: { + display: 'flex', + padding: '8px 8px 0px 12px', + width: '100%', + alignItems: 'center', + cursor: 'move', + }, bottomBar: { padding: '8px', display: 'flex', @@ -844,10 +851,13 @@ export default class CallWidget extends React.PureComponent { const state = {} as State; if (this.props.screenSharingSession?.session_id === this.props.currentSession?.session_id) { + logDebug('CallWidget.onShareScreenToggle: stopping screen share (user toggled off)'); window.callsClient?.unshareScreen(); state.screenStream = null; this.props.trackEvent(Telemetry.Event.UnshareScreen, Telemetry.Source.Widget, {initiator: fromShortcut ? 'shortcut' : 'button'}); } else if (!this.props.screenSharingSession) { + logDebug('CallWidget.onShareScreenToggle: starting screen share (user toggled on)'); + if (window.desktop && compareSemVer(window.desktop.version, '5.1.0') >= 0) { if (this.props.global) { if (window.desktopAPI?.openScreenShareModal) { @@ -883,8 +893,10 @@ export default class CallWidget extends React.PureComponent { } if (this.isMuted()) { + logDebug('CallWidget.onMicrophoneButtonClick: unmuting (user toggled on)'); window.callsClient.unmute(); } else { + logDebug('CallWidget.onMicrophoneButtonClick: muting (user toggled off)'); window.callsClient.mute(); } }; @@ -899,11 +911,13 @@ export default class CallWidget extends React.PureComponent { } if (this.isVideoOn()) { + logDebug('CallWidget.onVideoToggle: stopping video (user toggled off)'); window.callsClient.stopVideo(); this.setState({ selfVideoStream: null, }); } else { + logDebug('CallWidget.onVideoToggle: starting video (user toggled on)'); this.setState({ initializingSelfVideo: true, }); @@ -973,6 +987,7 @@ export default class CallWidget extends React.PureComponent { onAudioInputDeviceClick = (device: MediaDeviceInfo) => { if (device.deviceId !== this.state.currentAudioInputDevice?.deviceId) { + logDebug('CallWidget.onAudioInputDeviceClick: changing audio input device', device.label, device.deviceId); window.callsClient?.setAudioInputDevice(device); } this.setState({showAudioInputDevicesMenu: false, currentAudioInputDevice: device}); @@ -980,6 +995,7 @@ export default class CallWidget extends React.PureComponent { onVideoInputDeviceClick = (device: MediaDeviceInfo) => { if (device.deviceId !== this.state.currentVideoInputDevice?.deviceId) { + logDebug('CallWidget.onVideoInputDeviceClick: changing video input device', device.label, device.deviceId); window.callsClient?.setVideoInputDevice(device); } this.setState({showVideoInputDevicesMenu: false, currentVideoInputDevice: device}); @@ -987,6 +1003,7 @@ export default class CallWidget extends React.PureComponent { onAudioOutputDeviceClick = (device: MediaDeviceInfo) => { if (device.deviceId !== this.state.currentAudioOutputDevice?.deviceId) { + logDebug('CallWidget.onAudioOutputDeviceClick: changing audio output device', device.label, device.deviceId); window.callsClient?.setAudioOutputDevice(device); const ps = []; for (const audioEl of this.state.audioEls) { @@ -2040,39 +2057,6 @@ export default class CallWidget extends React.PureComponent { ); }; - renderVideoContainer = () => { - // 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 otherProfile = this.props.connectedDMUser; - const otherSession = this.props.otherSessions.find((s) => s.video); - - return ( -
- { selfProfile && this.props.currentSession && - - } - { otherProfile && this.props.otherSessions.length !== 0 && - - } -
- ); - }; - onMouseDown = (ev: React.MouseEvent) => { document.addEventListener('mousemove', this.onMouseMove, false); this.setState({ @@ -2260,6 +2244,197 @@ export default class CallWidget extends React.PureComponent { ); }; + renderTopBar = () => { + const {formatMessage} = this.props.intl; + const openPopOutLabel = formatMessage({defaultMessage: 'Open in new window'}); + const ShowIcon = window.desktop && !this.props.global ? ExpandIcon : PopOutIcon; + + const channelLink = ( + + + {isPublicChannel(this.props.channel) && } + {isPrivateChannel(this.props.channel) && } + {isDMChannel(this.props.channel) && } + {isGMChannel(this.props.channel) && } + + {this.props.channelDisplayName} + + + + ); + + return ( +
+ {/*
*/} + {/* {this.renderSpeaking()} */} + {/*
*/} + {/* {this.renderRecordingBadge()} */} + {/* */} + {/* {this.renderChannelName()} */} + {/*
*/} + {/*
*/} + + {/* TODO: add recording badge */} +
+ {channelLink} + +
{untranslatable('•')}
+ + +
+ + + } + /> +
+ ); + }; + + renderVideoContainer = () => { + // 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 otherProfile = this.props.connectedDMUser; + const otherSession = this.props.otherSessions.find((s) => s.video); + + return ( +
+ { selfProfile && this.props.currentSession && + + } + { otherProfile && this.props.otherSessions.length !== 0 && + + } +
+ ); + }; + + renderProfiles = () => { + // 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 otherProfile = this.props.connectedDMUser; + const otherSession = this.props.otherSessions[0]; + const selfSession = this.props.currentSession; + const videoView = (otherSession?.video || selfSession?.video) ?? false; + const selfOnly = this.props.otherSessions.length === 0; + + return ( +
+ + { otherProfile && otherSession && + + } + + { selfProfile && selfSession && + + } +
+ ); + }; + + renderMiddleBar = () => { + return ( +
+ {this.renderProfiles()} +
+ ); + }; + render() { if (!this.props.channel || !window.callsClient || !this.props.show) { return null; @@ -2298,7 +2473,7 @@ export default class CallWidget extends React.PureComponent { const MenuIcon = this.props.wider ? SettingsWheelIcon : HorizontalDotsIcon; - const VideoIcon = this.isVideoOn() || noVideoInputDevices || noVideoPermissions ? VideoOffIcon : VideoOnIcon; + const VideoIcon = this.isVideoOn() || noVideoInputDevices || noVideoPermissions ? VideoOnIcon : VideoOffIcon; let videoTooltipText = this.isVideoOn() ? formatMessage({defaultMessage: 'Turn camera off'}) : formatMessage({defaultMessage: 'Turn camera on'}); let videoTooltipSubtext = ''; @@ -2322,7 +2497,7 @@ export default class CallWidget extends React.PureComponent { const settingsButtonLabel = formatMessage({defaultMessage: 'Settings'}); const leaveMenuLabel = formatMessage({defaultMessage: 'Leave call'}); - const shouldRenderVideoContainer = this.props.currentSession?.video || this.state.initializingSelfVideo || this.props.otherSessions.some((s) => s.video); + // const shouldRenderVideoContainer = this.props.currentSession?.video || this.state.initializingSelfVideo || this.props.otherSessions.some((s) => s.video); return (
{
-
- {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.