Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 3c36a7f

Browse files
Add ability to change audio and video devices during a call (#7173)
1 parent ce3bc9d commit 3c36a7f

File tree

12 files changed

+247
-24
lines changed

12 files changed

+247
-24
lines changed

res/css/_components.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
@import "./views/avatars/_WidgetAvatar.scss";
8181
@import "./views/beta/_BetaCard.scss";
8282
@import "./views/context_menus/_CallContextMenu.scss";
83+
@import "./views/context_menus/_DeviceContextMenu.scss";
8384
@import "./views/context_menus/_IconizedContextMenu.scss";
8485
@import "./views/context_menus/_MessageContextMenu.scss";
8586
@import "./views/dialogs/_AddExistingToSpaceDialog.scss";
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
Copyright 2021 Šimon Brandner <[email protected]>
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
.mx_DeviceContextMenu {
18+
max-width: 252px;
19+
20+
.mx_DeviceContextMenu_device_icon {
21+
display: none;
22+
}
23+
24+
.mx_IconizedContextMenu_label {
25+
padding-left: 0 !important;
26+
}
27+
}

res/css/views/context_menus/_IconizedContextMenu.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ limitations under the License.
2525
padding-right: 20px;
2626
}
2727

28+
.mx_IconizedContextMenu_optionList_label {
29+
font-size: $font-15px;
30+
font-weight: $font-semi-bold;
31+
}
32+
2833
// the notFirst class is for cases where the optionList might be under a header of sorts.
2934
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
3035
// This is a bit of a hack when we could just use a simple border-top property,

res/css/views/voip/CallView/_CallViewButtons.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ limitations under the License.
4646
justify-content: center;
4747
align-items: center;
4848

49+
position: relative;
50+
51+
box-shadow: 0px 4px 4px 0px #00000026; // Same on both themes
52+
4953
&::before {
5054
content: '';
5155
display: inline-block;
@@ -60,6 +64,25 @@ limitations under the License.
6064
width: 24px;
6165
}
6266

67+
&.mx_CallViewButtons_dropdownButton {
68+
width: 16px;
69+
height: 16px;
70+
71+
position: absolute;
72+
right: 0;
73+
bottom: 0;
74+
75+
&::before {
76+
width: 14px;
77+
height: 14px;
78+
mask-image: url('$(res)/img/element-icons/message/chevron-up.svg');
79+
}
80+
81+
&.mx_CallViewButtons_dropdownButton_collapsed::before {
82+
transform: rotate(180deg);
83+
}
84+
}
85+
6386
// State buttons
6487
&.mx_CallViewButtons_button_on {
6588
background-color: $call-view-button-on-background;

src/MediaDeviceHandler.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,12 @@ export default class MediaDeviceHandler extends EventEmitter {
7272
/**
7373
* Retrieves devices from the SettingsStore and tells the js-sdk to use them
7474
*/
75-
public static loadDevices(): void {
75+
public static async loadDevices(): Promise<void> {
7676
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
7777
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
7878

79-
MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
80-
MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
79+
await MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
80+
await MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
8181
}
8282

8383
public setAudioOutput(deviceId: string): void {
@@ -90,26 +90,26 @@ export default class MediaDeviceHandler extends EventEmitter {
9090
* need to be ended and started again for this change to take effect
9191
* @param {string} deviceId
9292
*/
93-
public setAudioInput(deviceId: string): void {
93+
public async setAudioInput(deviceId: string): Promise<void> {
9494
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
95-
MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
95+
return MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
9696
}
9797

9898
/**
9999
* This will not change the device that a potential call uses. The call will
100100
* need to be ended and started again for this change to take effect
101101
* @param {string} deviceId
102102
*/
103-
public setVideoInput(deviceId: string): void {
103+
public async setVideoInput(deviceId: string): Promise<void> {
104104
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
105-
MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
105+
return MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
106106
}
107107

108-
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {
108+
public async setDevice(deviceId: string, kind: MediaDeviceKindEnum): Promise<void> {
109109
switch (kind) {
110110
case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break;
111-
case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break;
112-
case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break;
111+
case MediaDeviceKindEnum.AudioInput: await this.setAudioInput(deviceId); break;
112+
case MediaDeviceKindEnum.VideoInput: await this.setVideoInput(deviceId); break;
113113
}
114114
}
115115

@@ -124,4 +124,17 @@ export default class MediaDeviceHandler extends EventEmitter {
124124
public static getVideoInput(): string {
125125
return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_videoinput");
126126
}
127+
128+
/**
129+
* Returns the current set deviceId for a device kind
130+
* @param {MediaDeviceKindEnum} kind of the device that will be returned
131+
* @returns {string} the deviceId
132+
*/
133+
public static getDevice(kind: MediaDeviceKindEnum): string {
134+
switch (kind) {
135+
case MediaDeviceKindEnum.AudioOutput: return this.getAudioOutput();
136+
case MediaDeviceKindEnum.AudioInput: return this.getAudioInput();
137+
case MediaDeviceKindEnum.VideoInput: return this.getVideoInput();
138+
}
139+
}
127140
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
Copyright 2021 Šimon Brandner <[email protected]>
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import React, { useEffect, useState } from "react";
18+
19+
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
20+
import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio } from "./IconizedContextMenu";
21+
import { IProps as IContextMenuProps } from "../../structures/ContextMenu";
22+
import { _t, _td } from "../../../languageHandler";
23+
24+
const SECTION_NAMES: Record<MediaDeviceKindEnum, string> = {
25+
[MediaDeviceKindEnum.AudioInput]: _td("Input devices"),
26+
[MediaDeviceKindEnum.AudioOutput]: _td("Output devices"),
27+
[MediaDeviceKindEnum.VideoInput]: _td("Cameras"),
28+
};
29+
30+
interface IDeviceContextMenuDeviceProps {
31+
label: string;
32+
selected: boolean;
33+
onClick: () => void;
34+
}
35+
36+
const DeviceContextMenuDevice: React.FC<IDeviceContextMenuDeviceProps> = ({ label, selected, onClick }) => {
37+
return <IconizedContextMenuRadio
38+
iconClassName="mx_DeviceContextMenu_device_icon"
39+
label={label}
40+
active={selected}
41+
onClick={onClick}
42+
/>;
43+
};
44+
45+
interface IDeviceContextMenuSectionProps {
46+
deviceKind: MediaDeviceKindEnum;
47+
}
48+
49+
const DeviceContextMenuSection: React.FC<IDeviceContextMenuSectionProps> = ({ deviceKind }) => {
50+
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
51+
const [selectedDevice, setSelectedDevice] = useState(MediaDeviceHandler.getDevice(deviceKind));
52+
53+
useEffect(() => {
54+
const getDevices = async () => {
55+
return setDevices((await MediaDeviceHandler.getDevices())[deviceKind]);
56+
};
57+
getDevices();
58+
}, [deviceKind]);
59+
60+
const onDeviceClick = (deviceId: string): void => {
61+
MediaDeviceHandler.instance.setDevice(deviceId, deviceKind);
62+
setSelectedDevice(deviceId);
63+
};
64+
65+
return <IconizedContextMenuOptionList label={_t(SECTION_NAMES[deviceKind])}>
66+
{ devices.map(({ label, deviceId }) => {
67+
return <DeviceContextMenuDevice
68+
key={deviceId}
69+
label={label}
70+
selected={selectedDevice === deviceId}
71+
onClick={() => onDeviceClick(deviceId)}
72+
/>;
73+
}) }
74+
</IconizedContextMenuOptionList>;
75+
};
76+
77+
interface IProps extends IContextMenuProps {
78+
deviceKinds: MediaDeviceKind[];
79+
}
80+
81+
const DeviceContextMenu: React.FC<IProps> = ({ deviceKinds, ...props }) => {
82+
return <IconizedContextMenu compact className="mx_DeviceContextMenu" {...props}>
83+
{ deviceKinds.map((kind) => {
84+
return <DeviceContextMenuSection key={kind} deviceKind={kind as MediaDeviceKindEnum} />;
85+
}) }
86+
</IconizedContextMenu>;
87+
};
88+
89+
export default DeviceContextMenu;

src/components/views/context_menus/IconizedContextMenu.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ interface IProps extends IContextMenuProps {
3333
interface IOptionListProps {
3434
first?: boolean;
3535
red?: boolean;
36+
label?: string;
3637
className?: string;
3738
}
3839

@@ -126,13 +127,20 @@ export const IconizedContextMenuOption: React.FC<IOptionProps> = ({
126127
</MenuItem>;
127128
};
128129

129-
export const IconizedContextMenuOptionList: React.FC<IOptionListProps> = ({ first, red, className, children }) => {
130+
export const IconizedContextMenuOptionList: React.FC<IOptionListProps> = ({
131+
first,
132+
red,
133+
className,
134+
label,
135+
children,
136+
}) => {
130137
const classes = classNames("mx_IconizedContextMenu_optionList", className, {
131138
mx_IconizedContextMenu_optionList_notFirst: !first,
132139
mx_IconizedContextMenu_optionList_red: red,
133140
});
134141

135142
return <div className={classes}>
143+
{ label && <div><span className="mx_IconizedContextMenu_optionList_label">{ label }</span></div> }
136144
{ children }
137145
</div>;
138146
};

src/components/views/elements/AccessibleTooltipButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
2828
forceHide?: boolean;
2929
yOffset?: number;
3030
alignment?: Alignment;
31+
onHover?: (hovering: boolean) => void;
3132
onHideTooltip?(ev: SyntheticEvent): void;
3233
}
3334

@@ -52,13 +53,15 @@ export default class AccessibleTooltipButton extends React.PureComponent<IProps,
5253
}
5354

5455
private showTooltip = () => {
56+
if (this.props.onHover) this.props.onHover(true);
5557
if (this.props.forceHide) return;
5658
this.setState({
5759
hover: true,
5860
});
5961
};
6062

6163
private hideTooltip = (ev: SyntheticEvent) => {
64+
if (this.props.onHover) this.props.onHover(false);
6265
this.setState({
6366
hover: false,
6467
});

0 commit comments

Comments
 (0)