Merge pull request #5992 from SimonBrandner/fix/12652/screen-share

Add support for screen sharing in 1:1 calls
This commit is contained in:
David Baker 2021-07-27 15:34:27 +01:00 committed by GitHub
commit 3e7aee3a87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 747 additions and 340 deletions

View file

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

View file

@ -16,28 +16,8 @@ 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 {
.mx_desktopCapturerSourcePicker_tab {
display: flex;
flex-wrap: wrap;
justify-content: center;
@ -46,27 +26,33 @@ limitations under the License.
overflow: overlay;
}
.mx_desktopCapturerSourcePicker_stream_button {
.mx_desktopCapturerSourcePicker_source {
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 {
.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_stream_name {
.mx_desktopCapturerSourcePicker_source_name {
margin: 0 4px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 312px;
}
}

View file

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

View file

@ -0,0 +1,52 @@
/*
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.
*/
.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;
}
}

View file

@ -14,32 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_VideoFeed_voice {
.mx_VideoFeed {
border-radius: 4px;
&.mx_VideoFeed_voice {
background-color: $inverted-bg-color;
}
.mx_VideoFeed_remote {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
&.mx_VideoFeed_video {
background-color: #000;
}
}
.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;
}

View file

@ -0,0 +1,18 @@
<svg width="50" height="49" viewBox="0 0 50 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="25" cy="20" r="20" fill="white"/>
</g>
<rect x="14.6008" y="12.8" width="20.8" height="14.4" rx="1.6" fill="white" stroke="#737D8C" stroke-width="1.6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.3617 23.36C24.3617 23.7135 24.6483 24 25.0017 24C25.3552 24 25.6417 23.7135 25.6417 23.36L25.6417 18.1851L27.6692 20.2125C27.9191 20.4625 28.3243 20.4625 28.5743 20.2125C28.8242 19.9626 28.8242 19.5574 28.5743 19.3075L25.4543 16.1875C25.2043 15.9375 24.7991 15.9375 24.5492 16.1875L21.4292 19.3075C21.1792 19.5574 21.1792 19.9626 21.4292 20.2125C21.6791 20.4625 22.0843 20.4625 22.3343 20.2125L24.3617 18.1851L24.3617 23.36Z" fill="#737D8C"/>
<defs>
<filter id="filter0_d" x="0.947663" y="0" width="48.1047" height="48.1047" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4.05234"/>
<feGaussianBlur stdDeviation="2.02617"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,18 @@
<svg width="50" height="49" viewBox="0 0 50 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="25" cy="20" r="20" fill="#0DBD8B"/>
</g>
<rect x="14.6008" y="12.8" width="20.8" height="14.4" rx="1.6" fill="#0DBD8B" stroke="white" stroke-width="1.6"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.3617 23.36C24.3617 23.7135 24.6483 24 25.0017 24C25.3552 24 25.6417 23.7135 25.6417 23.36L25.6417 18.1851L27.6692 20.2125C27.9191 20.4625 28.3243 20.4625 28.5743 20.2125C28.8242 19.9626 28.8242 19.5574 28.5743 19.3075L25.4543 16.1875C25.2043 15.9375 24.7991 15.9375 24.5492 16.1875L21.4292 19.3075C21.1792 19.5574 21.1792 19.9626 21.4292 20.2125C21.6791 20.4625 22.0843 20.4625 22.3343 20.2125L24.3617 18.1851L24.3617 23.36Z" fill="white"/>
<defs>
<filter id="filter0_d" x="0.947663" y="0" width="48.1047" height="48.1047" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="4.05234"/>
<feGaussianBlur stdDeviation="2.02617"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,20 @@
<svg width="48" height="47" viewBox="0 0 48 47" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="#737D8C"/>
</g>
<rect x="12.5618" y="12.8992" width="20.3525" height="14.4496" rx="2.43819" fill="white" stroke="#737D8C" stroke-width="1.12362"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.9132 20.5009C33.2675 20.5009 34.3655 19.4205 34.3655 18.0876C34.3655 16.7548 33.2675 15.6743 31.9132 15.6743C30.5589 15.6743 29.4609 16.7548 29.4609 18.0876C29.4609 19.4205 30.5589 20.5009 31.9132 20.5009ZM27.8242 26.132C27.8282 23.7187 28.976 21.3054 31.9113 21.3054C34.7818 21.3054 35.9984 23.7187 35.9984 26.132C35.9984 28.5453 32.7288 28.5453 31.9113 28.5453C31.0939 28.5453 27.8206 28.3403 27.8242 26.132Z" fill="white"/>
<path d="M27.8242 26.132L28.386 26.1329L27.8242 26.132ZM35.9984 26.132H35.4366H35.9984ZM33.8037 18.0876C33.8037 19.1017 32.9658 19.9391 31.9132 19.9391V21.0627C33.5693 21.0627 34.9273 19.7392 34.9273 18.0876H33.8037ZM31.9132 16.2361C32.9658 16.2361 33.8037 17.0735 33.8037 18.0876H34.9273C34.9273 16.4361 33.5693 15.1125 31.9132 15.1125V16.2361ZM30.0227 18.0876C30.0227 17.0735 30.8606 16.2361 31.9132 16.2361V15.1125C30.2571 15.1125 28.8991 16.4361 28.8991 18.0876H30.0227ZM31.9132 19.9391C30.8606 19.9391 30.0227 19.1017 30.0227 18.0876H28.8991C28.8991 19.7392 30.2571 21.0627 31.9132 21.0627V19.9391ZM31.9113 20.7436C30.2659 20.7436 29.0747 21.4314 28.3132 22.4845C27.5693 23.5133 27.2645 24.8471 27.2624 26.1311L28.386 26.1329C28.3879 25.0036 28.659 23.924 29.2238 23.1429C29.771 22.386 30.6214 21.8672 31.9113 21.8672V20.7436ZM36.5602 26.132C36.5602 24.8414 36.2364 23.5081 35.4845 22.4817C34.7168 21.4338 33.5275 20.7436 31.9113 20.7436V21.8672C33.1657 21.8672 34.0199 22.3836 34.5781 23.1457C35.1521 23.9293 35.4366 25.0093 35.4366 26.132H36.5602ZM31.9113 29.1071C32.3157 29.1071 33.4213 29.1105 34.4365 28.7775C34.9481 28.6096 35.4778 28.3438 35.8839 27.9122C36.3025 27.4673 36.5602 26.8767 36.5602 26.132H35.4366C35.4366 26.594 35.2857 26.9083 35.0656 27.1422C34.8331 27.3893 34.4943 27.576 34.0863 27.7098C33.2623 27.9801 32.3244 27.9835 31.9113 27.9835V29.1071ZM27.2624 26.1311C27.26 27.5996 28.3757 28.3418 29.3716 28.6961C30.3797 29.0547 31.4763 29.1071 31.9113 29.1071V27.9835C31.5289 27.9835 30.5802 27.9334 29.7482 27.6375C28.9039 27.3371 28.3848 26.8728 28.386 26.1329L27.2624 26.1311Z" fill="#737D8C"/>
<rect x="0.0339116" y="-0.787426" width="29.1443" height="3.36793" rx="1.68396" transform="matrix(0.681883 0.731461 -0.742244 0.670129 13.0943 8.71545)" fill="white" stroke="#737D8C" stroke-width="1.12362"/>
<defs>
<filter id="filter0_d" x="0.589744" y="0" width="46.8205" height="46.8205" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="3.41026"/>
<feGaussianBlur stdDeviation="1.70513"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -0,0 +1,19 @@
<svg width="48" height="47" viewBox="0 0 48 47" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d)">
<circle cx="24" cy="20" r="20" fill="white"/>
</g>
<rect x="12.5" y="12.5" width="20.4763" height="15.3319" rx="2.5" fill="#737D8C" stroke="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.912 20.5618C33.2664 20.5618 34.3643 19.4287 34.3643 18.0309C34.3643 16.6331 33.2664 15.5 31.912 15.5C30.5577 15.5 29.4598 16.6331 29.4598 18.0309C29.4598 19.4287 30.5577 20.5618 31.912 20.5618ZM27.8242 26.467C27.8282 23.9361 28.976 21.4052 31.9113 21.4052C34.7818 21.4052 35.9985 23.9361 35.9985 26.467C35.9985 28.9978 32.7288 28.9978 31.9114 28.9978C31.0939 28.9978 27.8206 28.7829 27.8242 26.467Z" fill="#737D8C"/>
<path d="M27.8242 26.467L27.3242 26.4662L27.8242 26.467ZM35.9985 26.467H36.4985H35.9985ZM33.8643 18.0309C33.8643 19.1675 32.9755 20.0618 31.912 20.0618V21.0618C33.5573 21.0618 34.8643 19.6898 34.8643 18.0309H33.8643ZM31.912 16C32.9755 16 33.8643 16.8943 33.8643 18.0309H34.8643C34.8643 16.372 33.5573 15 31.912 15V16ZM29.9598 18.0309C29.9598 16.8943 30.8486 16 31.912 16V15C30.2668 15 28.9598 16.372 28.9598 18.0309H29.9598ZM31.912 20.0618C30.8486 20.0618 29.9598 19.1675 29.9598 18.0309H28.9598C28.9598 19.6898 30.2668 21.0618 31.912 21.0618V20.0618ZM31.9113 20.9052C30.2753 20.9052 29.1023 21.622 28.3569 22.7032C27.6274 23.7612 27.3263 25.1361 27.3242 26.4662L28.3242 26.4677C28.3261 25.2669 28.6009 24.1109 29.1802 23.2708C29.7434 22.4538 30.612 21.9052 31.9113 21.9052V20.9052ZM36.4985 26.467C36.4985 25.1313 36.1789 23.7567 35.4412 22.7007C34.6893 21.6242 33.5177 20.9052 31.9113 20.9052V21.9052C33.1755 21.9052 34.0475 22.4516 34.6214 23.2733C35.2097 24.1154 35.4985 25.2717 35.4985 26.467H36.4985ZM31.9114 29.4978C32.3162 29.4978 33.416 29.5011 34.4241 29.1543C34.9326 28.9794 35.4519 28.7044 35.847 28.264C36.2515 27.8131 36.4985 27.2184 36.4985 26.467H35.4985C35.4985 26.9809 35.3367 27.3354 35.1026 27.5962C34.8591 27.8677 34.5099 28.0673 34.0988 28.2087C33.2677 28.4946 32.3239 28.4978 31.9114 28.4978V29.4978ZM27.3242 26.4662C27.3219 27.9345 28.3854 28.6964 29.3851 29.0693C30.3864 29.4429 31.4779 29.4978 31.9114 29.4978V28.4978C31.5274 28.4978 30.5735 28.4453 29.7346 28.1324C28.8943 27.8189 28.3229 27.3153 28.3242 26.4677L27.3242 26.4662Z" fill="white"/>
<defs>
<filter id="filter0_d" x="0.589744" y="0" width="46.8205" height="46.8205" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset dy="3.41026"/>
<feGaussianBlur stdDeviation="1.70513"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -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<DesktopCapturerSource> => {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
return source;
},
);
} else {
console.error("Unknown conf call type: " + type);
}

View file

@ -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<DesktopCapturerSourceIProps> {
constructor(props) {
export class ExistingSource extends React.Component<ExistingSourceIProps> {
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 (
<AccessibleButton
className="mx_desktopCapturerSourcePicker_stream_button"
className="mx_desktopCapturerSourcePicker_source"
title={this.props.source.name}
onClick={this.onClick}
>
<img
className="mx_desktopCapturerSourcePicker_stream_thumbnail"
className={thumbnailClasses}
src={this.props.source.thumbnailURL}
/>
<span className="mx_desktopCapturerSourcePicker_stream_name">{ this.props.source.name }</span>
<span className="mx_desktopCapturerSourcePicker_source_name">{ this.props.source.name }</span>
</AccessibleButton>
);
}
}
export interface DesktopCapturerSourcePickerIState {
export interface PickerIState {
selectedTab: Tabs;
sources: Array<DesktopCapturerSource>;
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
PickerIProps,
PickerIState
> {
interval;
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);
};
render() {
let sources;
if (this.state.selectedTab === Tabs.Screens) {
sources = this.state.sources
.filter((source) => {
return source.id.startsWith("screen");
})
.map((source) => {
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
});
} else {
sources = this.state.sources
.filter((source) => {
return source.id.startsWith("window");
})
.map((source) => {
return <ExistingSource source={source} onSelect={this.onSelect} key={source.id} />;
private getTab(type: "screen" | "window", label: string): Tab {
const sources = this.state.sources.filter((source) => source.id.startsWith(type)).map((source) => {
return (
<ExistingSource
selected={this.state.selectedSource?.id === source.id}
source={source}
onSelect={this.onSelect}
key={source.id}
/>
);
});
return new Tab(type, label, null, (
<div className="mx_desktopCapturerSourcePicker_tab">
{ sources }
</div>
));
}
const buttonStyle = "mx_desktopCapturerSourcePicker_tabLabel";
const screensButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Screens) ? "_selected" : "");
const windowsButtonStyle = buttonStyle + ((this.state.selectedTab === Tabs.Windows) ? "_selected" : "");
render() {
const tabs = [
this.getTab("screen", _t("Share entire screen")),
this.getTab("window", _t("Application window")),
];
return (
<BaseDialog
className="mx_desktopCapturerSourcePicker"
onFinished={this.onCloseClick}
title={_t("Share your screen")}
title={_t("Share content")}
>
<div className="mx_desktopCapturerSourcePicker_tabLabels">
<AccessibleButton
className={screensButtonStyle}
onClick={this.onScreensClick}
>
{ _t("Screens") }
</AccessibleButton>
<AccessibleButton
className={windowsButtonStyle}
onClick={this.onWindowsClick}
>
{ _t("Windows") }
</AccessibleButton>
</div>
<div className="mx_desktopCapturerSourcePicker_panel">
{ sources }
</div>
<TabbedView tabs={tabs} tabLocation={TabLocation.TOP} onChange={this.onTabChange} />
<DialogButtons
primaryButton={_t("Share")}
hasCancel={true}
onCancel={this.onCloseClick}
onPrimaryButtonClick={this.onShare}
primaryDisabled={!this.state.selectedSource}
/>
</BaseDialog>
);
}

View file

@ -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<IProps> {
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<IProps> {
videoCallButton =
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={(ev) => this.props.onCallPlaced(
ev.shiftKey ? PlaceCallType.ScreenSharing : PlaceCallType.Video)}
onClick={(ev) => ev.shiftKey ?
this.displayInfoDialogAboutScreensharing() : this.props.onCallPlaced(PlaceCallType.Video)}
title={_t("Video call")} />;
}

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

@ -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,14 @@ interface IState {
isRemoteOnHold: boolean;
micMuted: boolean;
vidMuted: boolean;
screensharing: boolean;
callState: CallState;
controlsVisible: boolean;
showMoreMenu: boolean;
showDialpad: boolean;
feeds: CallFeed[];
primaryFeed: CallFeed;
secondaryFeeds: Array<CallFeed>;
sidebarShown: boolean;
}
function getFullScreenElement() {
@ -110,16 +118,21 @@ export default class CallView extends React.Component<IProps, IState> {
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,
showMoreMenu: false,
showDialpad: false,
feeds: this.props.call.getFeeds(),
primaryFeed: primary,
secondaryFeeds: secondary,
sidebarShown: true,
};
this.updateCallListeners(null, this.props.call);
@ -194,7 +207,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 = () => {
@ -237,7 +254,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,7 +291,7 @@ 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);
@ -274,21 +314,37 @@ export default class CallView extends React.Component<IProps, IState> {
}
};
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 = () => {
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({
sidebarShown: true,
screensharing: isScreensharing,
});
};
private onMoreClick = (): void => {
if (this.controlsHideTimer) {
clearTimeout(this.controlsHideTimer);
this.controlsHideTimer = null;
@ -300,14 +356,14 @@ export default class CallView extends React.Component<IProps, IState> {
});
};
private closeDialpad = () => {
private closeDialpad = (): void => {
this.setState({
showDialpad: false,
});
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
};
private closeContextMenu = () => {
private closeContextMenu = (): void => {
this.setState({
showMoreMenu: false,
});
@ -317,7 +373,7 @@ export default class CallView extends React.Component<IProps, IState> {
// 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 +403,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
};
private onRoomAvatarClick = () => {
private onRoomAvatarClick = (): void => {
const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
dis.dispatch({
action: 'view_room',
@ -355,7 +411,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 +420,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 +456,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 +489,116 @@ 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
// 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}
/> : null;
/>
);
}
// 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" />;
const contextMenuButton = this.state.callState === CallState.Connected ? <ContextMenuButton
let dialpadButton;
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}
/> : <div className="mx_CallView_callControls_button mx_CallView_callControls_button_more_hidden" />;
/>
);
dialpadButton = (
<ContextMenuButton
className="mx_CallView_callControls_button mx_CallView_callControls_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
/>
);
}
// 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}>
return (
<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} />
{ screensharingButton }
{ sidebarButton }
{ contextMenuButton }
</div>;
<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 +644,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 +681,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 +706,7 @@ export default class CallView extends React.Component<IProps, IState> {
</div>
</div>
{ holdTransferContent }
{ callControls }
{ this.renderCallControls() }
</div>
);
}
@ -599,27 +720,19 @@ 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 }
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 }}>
<div
className="mx_CallView_voice_avatarContainer"
style={{ width: avatarSize, height: avatarSize }}
>
<RoomAvatar
room={callRoom}
height={avatarSize}
@ -628,48 +741,70 @@ export default class CallView extends React.Component<IProps, IState> {
</div>
</div>
<div className="mx_CallView_holdTransferContent">{ _t("Connecting") }</div>
{ callControls }
</div>;
{ this.renderCallControls() }
</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}
/>
{ this.renderCallControls() }
</div>
);
});
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{ feeds }
{ callControls }
</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
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;
@ -728,6 +863,32 @@ export default class CallView extends React.Component<IProps, IState> {
myClassName = 'mx_CallView_pip';
}
let dialPad;
if (this.state.showDialpad) {
dialPad = <DialpadContextMenu
{...alwaysAboveRightOf(
this.dialpadButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeDialpad}
call={this.props.call}
/>;
}
let contextMenu;
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
CONTEXT_MENU_VPADDING,
)}
onFinished={this.closeContextMenu}
call={this.props.call}
/>;
}
return <div className={"mx_CallView " + myClassName}>
{ header }
{ contentView }

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,7 +16,7 @@ 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';
@ -37,6 +37,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 {
@ -46,7 +48,7 @@ interface IState {
@replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps, IState> {
private element = createRef<HTMLVideoElement>();
private element: HTMLVideoElement;
constructor(props: IProps) {
super(props);
@ -58,19 +60,47 @@ export default class VideoFeed extends React.Component<IProps, IState> {
}
componentDidMount() {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.element.current?.addEventListener('resize', this.onResize);
this.updateFeed(null, this.props.feed);
this.playMedia();
}
componentWillUnmount() {
this.updateFeed(this.props.feed, null);
}
componentDidUpdate(prevProps: IProps) {
this.updateFeed(prevProps.feed, this.props.feed);
}
static getDerivedStateFromProps(props: IProps) {
return {
audioMuted: props.feed.isAudioMuted(),
videoMuted: props.feed.isVideoMuted(),
};
}
private setElementRef = (element: HTMLVideoElement): void => {
if (!element) element.removeEventListener('resize', this.onResize);
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.element.current?.removeEventListener('resize', this.onResize);
this.stopMedia();
}
if (newFeed) {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
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;
@ -93,7 +123,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();
@ -122,8 +152,6 @@ 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(),
mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted,
mx_VideoFeed_mirror: (
@ -132,9 +160,15 @@ export default class VideoFeed extends React.Component<IProps, IState> {
),
};
const { pipMode, primary } = this.props;
if (this.state.videoMuted) {
const member = this.props.feed.getMember();
const avatarSize = this.props.pipMode ? 76 : 160;
let avatarSize;
if (pipMode && primary) avatarSize = 76;
else if (pipMode && !primary) avatarSize = 16;
else if (!pipMode && primary) avatarSize = 160;
else; // TBD
return (
<div className={classnames(videoClasses)}>
@ -147,7 +181,7 @@ export default class VideoFeed extends React.Component<IProps, IState> {
);
} else {
return (
<video className={classnames(videoClasses)} ref={this.element} />
<video className={classnames(videoClasses)} ref={this.setElementRef} />
);
}
}

View file

@ -52,7 +52,6 @@
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam",
"Unable to capture screen": "Unable to capture screen",
"VoIP is unsupported": "VoIP is unsupported",
"You cannot place VoIP calls in this browser.": "You cannot place VoIP calls in this browser.",
"Too Many Calls": "Too Many Calls",
@ -903,6 +902,10 @@
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
"%(peerName)s held the call": "%(peerName)s held the call",
"Connecting": "Connecting",
"You are presenting": "You are presenting",
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
"Your camera is turned off": "Your camera is turned off",
"Your camera is still enabled": "Your camera is still enabled",
"Video Call": "Video Call",
"Voice Call": "Voice Call",
"Fill Screen": "Fill Screen",
@ -1570,6 +1573,8 @@
"Unnamed room": "Unnamed room",
"World readable": "World readable",
"Guests can join": "Guests can join",
"Screen sharing is here!": "Screen sharing is here!",
"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!": "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!",
"(~%(count)s results)|other": "(~%(count)s results)",
"(~%(count)s results)|one": "(~%(count)s result)",
"Join Room": "Join Room",
@ -1998,9 +2003,9 @@
"Use the <a>Desktop app</a> to search encrypted messages": "Use the <a>Desktop app</a> to search encrypted messages",
"This version of %(brand)s does not support viewing some encrypted files": "This version of %(brand)s does not support viewing some encrypted files",
"This version of %(brand)s does not support searching encrypted messages": "This version of %(brand)s does not support searching encrypted messages",
"Share your screen": "Share your screen",
"Screens": "Screens",
"Windows": "Windows",
"Share entire screen": "Share entire screen",
"Application window": "Application window",
"Share content": "Share content",
"Join": "Join",
"No results": "No results",
"Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.": "Please <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.",