diff --git a/res/css/_components.scss b/res/css/_components.scss index f9e3ab11607..20b24619603 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -269,6 +269,7 @@ @import "./views/voip/_CallPreview.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; +@import "./views/voip/_CallViewSidebar.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/views/elements/_DesktopCapturerSourcePicker.scss b/res/css/views/elements/_DesktopCapturerSourcePicker.scss index 69dde5925e7..49a0a44417e 100644 --- a/res/css/views/elements/_DesktopCapturerSourcePicker.scss +++ b/res/css/views/elements/_DesktopCapturerSourcePicker.scss @@ -16,57 +16,43 @@ limitations under the License. .mx_desktopCapturerSourcePicker { overflow: hidden; -} - -.mx_desktopCapturerSourcePicker_tabLabels { - display: flex; - padding: 0 0 8px 0; -} - -.mx_desktopCapturerSourcePicker_tabLabel, -.mx_desktopCapturerSourcePicker_tabLabel_selected { - width: 100%; - text-align: center; - border-radius: 8px; - padding: 8px 0; - font-size: $font-13px; -} - -.mx_desktopCapturerSourcePicker_tabLabel_selected { - background-color: $tab-label-active-bg-color; - color: $tab-label-active-fg-color; -} - -.mx_desktopCapturerSourcePicker_panel { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: flex-start; - height: 500px; - overflow: overlay; -} - -.mx_desktopCapturerSourcePicker_stream_button { - display: flex; - flex-direction: column; - margin: 8px; - border-radius: 4px; -} - -.mx_desktopCapturerSourcePicker_stream_button:hover, -.mx_desktopCapturerSourcePicker_stream_button:focus { - background: $roomtile-selected-bg-color; -} - -.mx_desktopCapturerSourcePicker_stream_thumbnail { - margin: 4px; - width: 312px; -} -.mx_desktopCapturerSourcePicker_stream_name { - margin: 0 4px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - width: 312px; + .mx_desktopCapturerSourcePicker_tab { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + height: 500px; + overflow: overlay; + } + + .mx_desktopCapturerSourcePicker_source { + display: flex; + flex-direction: column; + margin: 8px; + } + + .mx_desktopCapturerSourcePicker_source_thumbnail { + margin: 4px; + padding: 4px; + width: 312px; + border-width: 2px; + border-radius: 8px; + border-style: solid; + border-color: transparent; + + &.mx_desktopCapturerSourcePicker_source_thumbnail_selected, + &:hover, + &:focus { + border-color: $accent-color; + } + } + + .mx_desktopCapturerSourcePicker_source_name { + margin: 0 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 312px; + } } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 205d4317526..59298ef8e62 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -67,7 +67,26 @@ limitations under the License. .mx_CallView_content { position: relative; display: flex; + justify-content: center; border-radius: 8px; + + > .mx_VideoFeed { + width: 100%; + height: 100%; + + &.mx_VideoFeed_voice { + // We don't want to collide with the call controls that have 52px of height + padding-bottom: 52px; + background-color: $inverted-bg-color; + display: flex; + justify-content: center; + align-items: center; + } + + &.mx_VideoFeed_video { + background-color: #000; + } + } } .mx_CallView_voice { @@ -290,6 +309,7 @@ limitations under the License. width: 100%; opacity: 1; transition: opacity 0.5s; + z-index: 200; // To be above _all_ feeds } .mx_CallView_callControls_hidden { @@ -297,10 +317,29 @@ limitations under the License. pointer-events: none; } +.mx_CallView_presenting { + opacity: 1; + transition: opacity 0.5s; + + position: absolute; + margin-top: 18px; + padding: 4px 8px; + border-radius: 4px; + + // Same on both themes + color: white; + background-color: #17191c; +} + +.mx_CallView_presenting_hidden { + opacity: 0.001; // opacity 0 can cause a re-layout + pointer-events: none; +} + .mx_CallView_callControls_button { cursor: pointer; - margin-left: 8px; - margin-right: 8px; + margin-left: 2px; + margin-right: 2px; &::before { @@ -317,17 +356,11 @@ limitations under the License. } .mx_CallView_callControls_dialpad { - margin-right: auto; &::before { background-image: url('$(res)/img/voip/dialpad.svg'); } } -.mx_CallView_callControls_button_dialpad_hidden { - margin-right: auto; - cursor: initial; -} - .mx_CallView_callControls_button_micOn { &::before { background-image: url('$(res)/img/voip/mic-on.svg'); @@ -352,6 +385,30 @@ limitations under the License. } } +.mx_CallView_callControls_button_screensharingOn { + &::before { + background-image: url('$(res)/img/voip/screensharing-on.svg'); + } +} + +.mx_CallView_callControls_button_screensharingOff { + &::before { + background-image: url('$(res)/img/voip/screensharing-off.svg'); + } +} + +.mx_CallView_callControls_button_sidebarOn { + &::before { + background-image: url('$(res)/img/voip/sidebar-on.svg'); + } +} + +.mx_CallView_callControls_button_sidebarOff { + &::before { + background-image: url('$(res)/img/voip/sidebar-off.svg'); + } +} + .mx_CallView_callControls_button_hangup { &::before { background-image: url('$(res)/img/voip/hangup.svg'); @@ -359,17 +416,11 @@ limitations under the License. } .mx_CallView_callControls_button_more { - margin-left: auto; &::before { background-image: url('$(res)/img/voip/more.svg'); } } -.mx_CallView_callControls_button_more_hidden { - margin-left: auto; - cursor: initial; -} - .mx_CallView_callControls_button_invisible { visibility: hidden; pointer-events: none; diff --git a/res/css/views/voip/_CallViewSidebar.scss b/res/css/views/voip/_CallViewSidebar.scss new file mode 100644 index 00000000000..79bf3cbf098 --- /dev/null +++ b/res/css/views/voip/_CallViewSidebar.scss @@ -0,0 +1,52 @@ +/* +Copyright 2021 Šimon Brandner + +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. +*/ + +.mx_CallViewSidebar { + position: absolute; + right: 16px; + bottom: 16px; + z-index: 100; // To be above the primary feed + + overflow: auto; + + height: calc(100% - 32px); // Subtract the top and bottom padding + width: 20%; + + display: flex; + flex-direction: column-reverse; + justify-content: flex-start; + align-items: flex-end; + gap: 12px; + + > .mx_VideoFeed { + width: 100%; + + &.mx_VideoFeed_voice { + display: flex; + align-items: center; + justify-content: center; + + aspect-ratio: 16 / 9; + } + } + + &.mx_CallViewSidebar_pipMode { + top: 16px; + bottom: unset; + justify-content: flex-end; + gap: 4px; + } +} diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 4a3fbdf5972..07a4a0e5309 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -14,31 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_VideoFeed_voice { - background-color: $inverted-bg-color; -} - +.mx_VideoFeed { + border-radius: 4px; -.mx_VideoFeed_remote { - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - &.mx_VideoFeed_video { - background-color: #000; + &.mx_VideoFeed_voice { + background-color: $inverted-bg-color; } -} - -.mx_VideoFeed_local { - max-width: 25%; - max-height: 25%; - position: absolute; - right: 10px; - top: 10px; - z-index: 100; - border-radius: 4px; &.mx_VideoFeed_video { background-color: transparent; diff --git a/res/img/voip/screensharing-off.svg b/res/img/voip/screensharing-off.svg new file mode 100644 index 00000000000..dc19e9892e4 --- /dev/null +++ b/res/img/voip/screensharing-off.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/screensharing-on.svg b/res/img/voip/screensharing-on.svg new file mode 100644 index 00000000000..a8e7fe308e4 --- /dev/null +++ b/res/img/voip/screensharing-on.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/sidebar-off.svg b/res/img/voip/sidebar-off.svg new file mode 100644 index 00000000000..7637a9ab559 --- /dev/null +++ b/res/img/voip/sidebar-off.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/res/img/voip/sidebar-on.svg b/res/img/voip/sidebar-on.svg new file mode 100644 index 00000000000..a625334be4e --- /dev/null +++ b/res/img/voip/sidebar-on.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 72be3f8e67c..e7c1dda54f3 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -56,7 +56,6 @@ limitations under the License. import React from 'react'; import { MatrixClientPeg } from './MatrixClientPeg'; -import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; import dis from './dispatcher/dispatcher'; @@ -80,7 +79,6 @@ import CountlyAnalytics from "./CountlyAnalytics"; import { UIFeature } from "./settings/UIFeature"; import { CallError } from "matrix-js-sdk/src/webrtc/call"; import { logger } from 'matrix-js-sdk/src/logger'; -import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker"; import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; @@ -129,14 +127,9 @@ interface ThirdpartyLookupResponse { fields: ThirdpartyLookupResponseFields; } -// Unlike 'CallType' in js-sdk, this one includes screen sharing -// (because a screen sharing call is only a screen sharing call to the caller, -// to the callee it's just a video call, at least as far as the current impl -// is concerned). export enum PlaceCallType { Voice = 'voice', Video = 'video', - ScreenSharing = 'screensharing', } export enum CallHandlerEvent { @@ -728,25 +721,6 @@ export default class CallHandler extends EventEmitter { call.placeVoiceCall(); } else if (type === 'video') { call.placeVideoCall(); - } else if (type === PlaceCallType.ScreenSharing) { - const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); - if (screenCapErrorString) { - this.removeCallForRoom(roomId); - console.log("Can't capture screen: " + screenCapErrorString); - Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { - title: _t('Unable to capture screen'), - description: screenCapErrorString, - }); - return; - } - - call.placeScreenSharingCall( - async (): Promise => { - const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); - const [source] = await finished; - return source; - }, - ); } else { console.error("Unknown conf call type: " + type); } diff --git a/src/components/views/elements/DesktopCapturerSourcePicker.tsx b/src/components/views/elements/DesktopCapturerSourcePicker.tsx index 20ecf08a5f6..1f00353aeb9 100644 --- a/src/components/views/elements/DesktopCapturerSourcePicker.tsx +++ b/src/components/views/elements/DesktopCapturerSourcePicker.tsx @@ -17,9 +17,12 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; import BaseDialog from "..//dialogs/BaseDialog"; +import DialogButtons from "./DialogButtons"; +import classNames from 'classnames'; import AccessibleButton from './AccessibleButton'; import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView'; export interface DesktopCapturerSource { id: string; @@ -28,62 +31,70 @@ export interface DesktopCapturerSource { } export enum Tabs { - Screens = "screens", - Windows = "windows", + Screens = "screen", + Windows = "window", } -export interface DesktopCapturerSourceIProps { +export interface ExistingSourceIProps { source: DesktopCapturerSource; onSelect(source: DesktopCapturerSource): void; + selected: boolean; } -export class ExistingSource extends React.Component { - constructor(props) { +export class ExistingSource extends React.Component { + constructor(props: ExistingSourceIProps) { super(props); } - onClick = (ev) => { + private onClick = (): void => { this.props.onSelect(this.props.source); }; render() { + const thumbnailClasses = classNames({ + mx_desktopCapturerSourcePicker_source_thumbnail: true, + mx_desktopCapturerSourcePicker_source_thumbnail_selected: this.props.selected, + }); + return ( - { this.props.source.name } + { this.props.source.name } ); } } -export interface DesktopCapturerSourcePickerIState { +export interface PickerIState { selectedTab: Tabs; sources: Array; + selectedSource: DesktopCapturerSource | null; } -export interface DesktopCapturerSourcePickerIProps { +export interface PickerIProps { onFinished(source: DesktopCapturerSource): void; } @replaceableComponent("views.elements.DesktopCapturerSourcePicker") export default class DesktopCapturerSourcePicker extends React.Component< - DesktopCapturerSourcePickerIProps, - DesktopCapturerSourcePickerIState - > { - interval; + PickerIProps, + PickerIState +> { + interval: number; - constructor(props) { + constructor(props: PickerIProps) { super(props); this.state = { selectedTab: Tabs.Screens, sources: [], + selectedSource: null, }; } @@ -107,69 +118,61 @@ export default class DesktopCapturerSourcePicker extends React.Component< clearInterval(this.interval); } - onSelect = (source) => { - this.props.onFinished(source); + private onSelect = (source: DesktopCapturerSource): void => { + this.setState({ selectedSource: source }); }; - onScreensClick = (ev) => { - this.setState({ selectedTab: Tabs.Screens }); + private onShare = (): void => { + this.props.onFinished(this.state.selectedSource); }; - onWindowsClick = (ev) => { - this.setState({ selectedTab: Tabs.Windows }); + private onTabChange = (): void => { + this.setState({ selectedSource: null }); }; - onCloseClick = (ev) => { + private onCloseClick = (): void => { this.props.onFinished(null); }; + private getTab(type: "screen" | "window", label: string): Tab { + const sources = this.state.sources.filter((source) => source.id.startsWith(type)).map((source) => { + return ( + + ); + }); + + return new Tab(type, label, null, ( +
+ { sources } +
+ )); + } + render() { - let sources; - if (this.state.selectedTab === Tabs.Screens) { - sources = this.state.sources - .filter((source) => { - return source.id.startsWith("screen"); - }) - .map((source) => { - return ; - }); - } else { - sources = this.state.sources - .filter((source) => { - return source.id.startsWith("window"); - }) - .map((source) => { - return ; - }); - } - - const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel"; - const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : ""); - const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : ""); + const tabs = [ + this.getTab("screen", _t("Share entire screen")), + this.getTab("window", _t("Application window")), + ]; return ( -
- - { _t("Screens") } - - - { _t("Windows") } - -
-
- { sources } -
+ +
); } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index c79b5bddd56..15b25ed64b5 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -29,6 +29,8 @@ import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; import { PlaceCallType } from "../../../CallHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Modal from '../../../Modal'; +import InfoDialog from "../dialogs/InfoDialog"; import { throttle } from 'lodash'; import { MatrixEvent, Room, RoomState } from 'matrix-js-sdk/src'; import { E2EStatus } from '../../../utils/ShieldUtils'; @@ -87,6 +89,14 @@ export default class RoomHeader extends React.Component { this.forceUpdate(); }, 500, { leading: true, trailing: true }); + private displayInfoDialogAboutScreensharing() { + Modal.createDialog(InfoDialog, { + title: _t("Screen sharing is here!"), + description: _t("You can now share your screen by pressing the \"screen share\" " + + "button during a call. You can even do this in audio calls if both sides support it!"), + }); + } + public render() { let searchStatus = null; @@ -185,8 +195,8 @@ export default class RoomHeader extends React.Component { videoCallButton = this.props.onCallPlaced( - ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video)} + onClick={(ev) => ev.shiftKey ? + this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)} title={_t("Video call")} />; } diff --git a/src/components/views/voip/AudioFeed.tsx b/src/components/views/voip/AudioFeed.tsx index a2ab760c864..3049d80c727 100644 --- a/src/components/views/voip/AudioFeed.tsx +++ b/src/components/views/voip/AudioFeed.tsx @@ -23,9 +23,21 @@ interface IProps { feed: CallFeed; } -export default class AudioFeed extends React.Component { +interface IState { + audioMuted: boolean; +} + +export default class AudioFeed extends React.Component { private element = createRef(); + 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 { 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 { 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 { } 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 (