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

Commit df84c02

Browse files
weeman1337Amy Walker
authored and
Amy Walker
committed
Add input device selection during voice broadcast (#9620)
1 parent d2d8eaf commit df84c02

File tree

12 files changed

+486
-87
lines changed

12 files changed

+486
-87
lines changed

res/css/compound/_Icon.pcss

+4
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ limitations under the License.
2929
color: $accent;
3030
}
3131

32+
.mx_Icon_alert {
33+
color: $alert;
34+
}
35+
3236
.mx_Icon_8 {
3337
flex: 0 0 8px;
3438
height: 8px;

res/img/element-icons/Mic.svg

+1
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
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, { MutableRefObject } from "react";
18+
19+
import { toLeftOrRightOf } from "../../structures/ContextMenu";
20+
import IconizedContextMenu, {
21+
IconizedContextMenuOptionList,
22+
IconizedContextMenuRadio,
23+
} from "../context_menus/IconizedContextMenu";
24+
25+
interface Props {
26+
containerRef: MutableRefObject<HTMLElement | null>;
27+
currentDevice: MediaDeviceInfo | null;
28+
devices: MediaDeviceInfo[];
29+
onDeviceSelect: (device: MediaDeviceInfo) => void;
30+
}
31+
32+
export const DevicesContextMenu: React.FC<Props> = ({
33+
containerRef,
34+
currentDevice,
35+
devices,
36+
onDeviceSelect,
37+
}) => {
38+
const deviceOptions = devices.map((d: MediaDeviceInfo) => {
39+
return <IconizedContextMenuRadio
40+
key={d.deviceId}
41+
active={d.deviceId === currentDevice?.deviceId}
42+
onClick={() => onDeviceSelect(d)}
43+
label={d.label}
44+
/>;
45+
});
46+
47+
return <IconizedContextMenu
48+
mountAsChild={false}
49+
onFinished={() => {}}
50+
{...toLeftOrRightOf(containerRef.current.getBoundingClientRect(), 0)}
51+
>
52+
<IconizedContextMenuOptionList>
53+
{ deviceOptions }
54+
</IconizedContextMenuOptionList>
55+
</IconizedContextMenu>;
56+
};

src/hooks/useAudioDeviceSelection.ts

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { useRef, useState } from "react";
18+
19+
import { _t } from "../languageHandler";
20+
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
21+
import { requestMediaPermissions } from "../utils/media/requestMediaPermissions";
22+
23+
interface State {
24+
devices: MediaDeviceInfo[];
25+
device: MediaDeviceInfo | null;
26+
}
27+
28+
export const useAudioDeviceSelection = (
29+
onDeviceChanged?: (device: MediaDeviceInfo) => void,
30+
) => {
31+
const shouldRequestPermissionsRef = useRef<boolean>(true);
32+
const [state, setState] = useState<State>({
33+
devices: [],
34+
device: null,
35+
});
36+
37+
if (shouldRequestPermissionsRef.current) {
38+
shouldRequestPermissionsRef.current = false;
39+
requestMediaPermissions(false).then((stream: MediaStream | undefined) => {
40+
MediaDeviceHandler.getDevices().then(({ audioinput }) => {
41+
MediaDeviceHandler.getDefaultDevice(audioinput);
42+
const deviceFromSettings = MediaDeviceHandler.getAudioInput();
43+
const device = audioinput.find((d) => {
44+
return d.deviceId === deviceFromSettings;
45+
}) || audioinput[0];
46+
setState({
47+
...state,
48+
devices: audioinput,
49+
device,
50+
});
51+
stream?.getTracks().forEach(t => t.stop());
52+
});
53+
});
54+
}
55+
56+
const setDevice = (device: MediaDeviceInfo) => {
57+
const shouldNotify = device.deviceId !== state.device?.deviceId;
58+
MediaDeviceHandler.instance.setDevice(device.deviceId, MediaDeviceKindEnum.AudioInput);
59+
60+
setState({
61+
...state,
62+
device,
63+
});
64+
65+
if (shouldNotify) {
66+
onDeviceChanged?.(device);
67+
}
68+
};
69+
70+
return {
71+
currentDevice: state.device,
72+
currentDeviceLabel: state.device?.label || _t("Default Device"),
73+
devices: state.devices,
74+
setDevice,
75+
};
76+
};

src/i18n/strings/en_EN.json

+1
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@
654654
"30s backward": "30s backward",
655655
"30s forward": "30s forward",
656656
"Go live": "Go live",
657+
"Change input device": "Change input device",
657658
"Live": "Live",
658659
"Voice broadcast": "Voice broadcast",
659660
"Cannot reach homeserver": "Cannot reach homeserver",

src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx

+18-76
Original file line numberDiff line numberDiff line change
@@ -21,99 +21,34 @@ import AccessibleButton from "../../../components/views/elements/AccessibleButto
2121
import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording";
2222
import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg";
2323
import { _t } from "../../../languageHandler";
24-
import IconizedContextMenu, {
25-
IconizedContextMenuOptionList,
26-
IconizedContextMenuRadio,
27-
} from "../../../components/views/context_menus/IconizedContextMenu";
28-
import { requestMediaPermissions } from "../../../utils/media/requestMediaPermissions";
29-
import MediaDeviceHandler from "../../../MediaDeviceHandler";
30-
import { toLeftOrRightOf } from "../../../components/structures/ContextMenu";
24+
import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection";
25+
import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu";
3126

3227
interface Props {
3328
voiceBroadcastPreRecording: VoiceBroadcastPreRecording;
3429
}
3530

36-
interface State {
37-
devices: MediaDeviceInfo[];
38-
device: MediaDeviceInfo | null;
39-
showDeviceSelect: boolean;
40-
}
41-
4231
export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({
4332
voiceBroadcastPreRecording,
4433
}) => {
45-
const shouldRequestPermissionsRef = useRef<boolean>(true);
46-
const pipRef = useRef<HTMLDivElement>(null);
47-
const [state, setState] = useState<State>({
48-
devices: [],
49-
device: null,
50-
showDeviceSelect: false,
51-
});
52-
53-
if (shouldRequestPermissionsRef.current) {
54-
shouldRequestPermissionsRef.current = false;
55-
requestMediaPermissions(false).then((stream: MediaStream | undefined) => {
56-
MediaDeviceHandler.getDevices().then(({ audioinput }) => {
57-
MediaDeviceHandler.getDefaultDevice(audioinput);
58-
const deviceFromSettings = MediaDeviceHandler.getAudioInput();
59-
const device = audioinput.find((d) => {
60-
return d.deviceId === deviceFromSettings;
61-
}) || audioinput[0];
62-
setState({
63-
...state,
64-
devices: audioinput,
65-
device,
66-
});
67-
stream?.getTracks().forEach(t => t.stop());
68-
});
69-
});
70-
}
71-
72-
const onDeviceOptionClick = (device: MediaDeviceInfo) => {
73-
setState({
74-
...state,
75-
device,
76-
showDeviceSelect: false,
77-
});
78-
};
34+
const pipRef = useRef<HTMLDivElement | null>(null);
35+
const { currentDevice, currentDeviceLabel, devices, setDevice } = useAudioDeviceSelection();
36+
const [showDeviceSelect, setShowDeviceSelect] = useState<boolean>(false);
7937

80-
const onMicrophoneLineClick = () => {
81-
setState({
82-
...state,
83-
showDeviceSelect: true,
84-
});
38+
const onDeviceSelect = (device: MediaDeviceInfo | null) => {
39+
setShowDeviceSelect(false);
40+
setDevice(device);
8541
};
8642

87-
const deviceOptions = state.devices.map((d: MediaDeviceInfo) => {
88-
return <IconizedContextMenuRadio
89-
key={d.deviceId}
90-
active={d.deviceId === state.device?.deviceId}
91-
onClick={() => onDeviceOptionClick(d)}
92-
label={d.label}
93-
/>;
94-
});
95-
96-
const devicesMenu = state.showDeviceSelect && pipRef.current
97-
? <IconizedContextMenu
98-
mountAsChild={false}
99-
onFinished={() => {}}
100-
{...toLeftOrRightOf(pipRef.current.getBoundingClientRect(), 0)}
101-
>
102-
<IconizedContextMenuOptionList>
103-
{ deviceOptions }
104-
</IconizedContextMenuOptionList>
105-
</IconizedContextMenu>
106-
: null;
107-
10843
return <div
10944
className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip"
11045
ref={pipRef}
11146
>
11247
<VoiceBroadcastHeader
11348
onCloseClick={voiceBroadcastPreRecording.cancel}
114-
onMicrophoneLineClick={onMicrophoneLineClick}
49+
onMicrophoneLineClick={() => setShowDeviceSelect(true)}
11550
room={voiceBroadcastPreRecording.room}
116-
microphoneLabel={state.device?.label || _t('Default Device')}
51+
microphoneLabel={currentDeviceLabel}
11752
showClose={true}
11853
/>
11954
<AccessibleButton
@@ -124,6 +59,13 @@ export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({
12459
<LiveIcon className="mx_Icon mx_Icon_16" />
12560
{ _t("Go live") }
12661
</AccessibleButton>
127-
{ devicesMenu }
62+
{
63+
showDeviceSelect && <DevicesContextMenu
64+
containerRef={pipRef}
65+
currentDevice={currentDevice}
66+
devices={devices}
67+
onDeviceSelect={onDeviceSelect}
68+
/>
69+
}
12870
</div>;
12971
};

src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx

+44-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React from "react";
17+
import React, { useRef, useState } from "react";
1818

1919
import {
2020
VoiceBroadcastControl,
@@ -26,13 +26,18 @@ import { VoiceBroadcastHeader } from "../atoms/VoiceBroadcastHeader";
2626
import { Icon as StopIcon } from "../../../../res/img/element-icons/Stop.svg";
2727
import { Icon as PauseIcon } from "../../../../res/img/element-icons/pause.svg";
2828
import { Icon as RecordIcon } from "../../../../res/img/element-icons/Record.svg";
29+
import { Icon as MicrophoneIcon } from "../../../../res/img/element-icons/Mic.svg";
2930
import { _t } from "../../../languageHandler";
31+
import AccessibleButton from "../../../components/views/elements/AccessibleButton";
32+
import { useAudioDeviceSelection } from "../../../hooks/useAudioDeviceSelection";
33+
import { DevicesContextMenu } from "../../../components/views/audio_messages/DevicesContextMenu";
3034

3135
interface VoiceBroadcastRecordingPipProps {
3236
recording: VoiceBroadcastRecording;
3337
}
3438

3539
export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProps> = ({ recording }) => {
40+
const pipRef = useRef<HTMLDivElement | null>(null);
3641
const {
3742
live,
3843
timeLeft,
@@ -41,6 +46,29 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
4146
stopRecording,
4247
toggleRecording,
4348
} = useVoiceBroadcastRecording(recording);
49+
const { currentDevice, devices, setDevice } = useAudioDeviceSelection();
50+
51+
const onDeviceSelect = async (device: MediaDeviceInfo) => {
52+
setShowDeviceSelect(false);
53+
54+
if (currentDevice.deviceId === device.deviceId) {
55+
// device unchanged
56+
return;
57+
}
58+
59+
setDevice(device);
60+
61+
if ([VoiceBroadcastInfoState.Paused, VoiceBroadcastInfoState.Stopped].includes(recordingState)) {
62+
// Nothing to do in these cases. Resume will use the selected device.
63+
return;
64+
}
65+
66+
// pause and resume to switch the input device
67+
await recording.pause();
68+
await recording.resume();
69+
};
70+
71+
const [showDeviceSelect, setShowDeviceSelect] = useState<boolean>(false);
4472

4573
const toggleControl = recordingState === VoiceBroadcastInfoState.Paused
4674
? <VoiceBroadcastControl
@@ -53,6 +81,7 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
5381

5482
return <div
5583
className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip"
84+
ref={pipRef}
5685
>
5786
<VoiceBroadcastHeader
5887
live={live ? "live" : "grey"}
@@ -62,11 +91,25 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
6291
<hr className="mx_VoiceBroadcastBody_divider" />
6392
<div className="mx_VoiceBroadcastBody_controls">
6493
{ toggleControl }
94+
<AccessibleButton
95+
aria-label={_t("Change input device")}
96+
onClick={() => setShowDeviceSelect(true)}
97+
>
98+
<MicrophoneIcon className="mx_Icon mx_Icon_16 mx_Icon_alert" />
99+
</AccessibleButton>
65100
<VoiceBroadcastControl
66101
icon={StopIcon}
67102
label="Stop Recording"
68103
onClick={stopRecording}
69104
/>
70105
</div>
106+
{
107+
showDeviceSelect && <DevicesContextMenu
108+
containerRef={pipRef}
109+
currentDevice={currentDevice}
110+
devices={devices}
111+
onDeviceSelect={onDeviceSelect}
112+
/>
113+
}
71114
</div>;
72115
};

src/voice-broadcast/hooks/useVoiceBroadcastRecording.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ const showStopBroadcastingDialog = async (): Promise<boolean> => {
4747

4848
export const useVoiceBroadcastRecording = (recording: VoiceBroadcastRecording) => {
4949
const client = MatrixClientPeg.get();
50-
const room = client.getRoom(recording.infoEvent.getRoomId());
50+
const roomId = recording.infoEvent.getRoomId();
51+
const room = client.getRoom(roomId);
52+
53+
if (!room) {
54+
throw new Error("Unable to find voice broadcast room with Id: " + roomId);
55+
}
56+
5157
const stopRecording = async () => {
5258
const confirmed = await showStopBroadcastingDialog();
5359

0 commit comments

Comments
 (0)