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

Commit 4361461

Browse files
authored
Implement voice broadcast device selection (#9572)
1 parent 272aae0 commit 4361461

File tree

15 files changed

+248
-51
lines changed

15 files changed

+248
-51
lines changed

res/css/compound/_Icon.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ limitations under the License.
2727

2828
.mx_Icon_16 {
2929
height: 16px;
30+
flex: 0 0 16px;
3031
width: 16px;
3132
}

res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ limitations under the License.
5050
white-space: nowrap;
5151
}
5252
}
53+
54+
.mx_VoiceBroadcastHeader_mic--clickable {
55+
cursor: pointer;
56+
}

src/MediaDeviceHandler.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { logger } from "matrix-js-sdk/src/logger";
2121
import SettingsStore from "./settings/SettingsStore";
2222
import { SettingLevel } from "./settings/SettingLevel";
2323
import { MatrixClientPeg } from "./MatrixClientPeg";
24+
import { _t } from './languageHandler';
2425

2526
// XXX: MediaDeviceKind is a union type, so we make our own enum
2627
export enum MediaDeviceKindEnum {
@@ -79,6 +80,18 @@ export default class MediaDeviceHandler extends EventEmitter {
7980
}
8081
}
8182

83+
public static getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>): string => {
84+
// Note we're looking for a device with deviceId 'default' but adding a device
85+
// with deviceId == the empty string: this is because Chrome gives us a device
86+
// with deviceId 'default', so we're looking for this, not the one we are adding.
87+
if (!devices.some((i) => i.deviceId === 'default')) {
88+
devices.unshift({ deviceId: '', label: _t('Default Device') });
89+
return '';
90+
} else {
91+
return 'default';
92+
}
93+
};
94+
8295
/**
8396
* Retrieves devices from the SettingsStore and tells the js-sdk to use them
8497
*/

src/components/structures/ContextMenu.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,35 @@ export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">
472472
return { left, top, chevronOffset };
473473
};
474474

475+
export type ToLeftOf = {
476+
chevronOffset: number;
477+
right: number;
478+
top: number;
479+
};
480+
481+
// Placement method for <ContextMenu /> to position context menu to left of elementRect with chevronOffset
482+
export const toLeftOf = (elementRect: DOMRect, chevronOffset = 12): ToLeftOf => {
483+
const right = UIStore.instance.windowWidth - elementRect.left + window.scrollX - 3;
484+
let top = elementRect.top + (elementRect.height / 2) + window.scrollY;
485+
top -= chevronOffset + 8; // where 8 is half the height of the chevron
486+
return { right, top, chevronOffset };
487+
};
488+
489+
/**
490+
* Placement method for <ContextMenu /> to position context menu of or right of elementRect
491+
* depending on which side has more space.
492+
*/
493+
export const toLeftOrRightOf = (elementRect: DOMRect, chevronOffset = 12): ToRightOf | ToLeftOf => {
494+
const spaceToTheLeft = elementRect.left;
495+
const spaceToTheRight = UIStore.instance.windowWidth - elementRect.right;
496+
497+
if (spaceToTheLeft > spaceToTheRight) {
498+
return toLeftOf(elementRect, chevronOffset);
499+
}
500+
501+
return toRightOf(elementRect, chevronOffset);
502+
};
503+
475504
export type AboveLeftOf = IPosition & {
476505
chevronFace: ChevronFace;
477506
};

src/components/views/context_menus/IconizedContextMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
4848
}
4949

5050
interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> {
51-
iconClassName: string;
51+
iconClassName?: string;
5252
}
5353

5454
export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
@@ -67,7 +67,7 @@ export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
6767
active={active}
6868
label={label}
6969
>
70-
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
70+
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
7171
<span className="mx_IconizedContextMenu_label">{ label }</span>
7272
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
7373
</MenuItemRadio>;

src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,6 @@ import SettingsFlag from '../../../elements/SettingsFlag';
2727
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
2828
import { requestMediaPermissions } from '../../../../../utils/media/requestMediaPermissions';
2929

30-
const getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>) => {
31-
// Note we're looking for a device with deviceId 'default' but adding a device
32-
// with deviceId == the empty string: this is because Chrome gives us a device
33-
// with deviceId 'default', so we're looking for this, not the one we are adding.
34-
if (!devices.some((i) => i.deviceId === 'default')) {
35-
devices.unshift({ deviceId: '', label: _t('Default Device') });
36-
return '';
37-
} else {
38-
return 'default';
39-
}
40-
};
41-
4230
interface IState {
4331
mediaDevices: IMediaDevices;
4432
[MediaDeviceKindEnum.AudioOutput]: string;
@@ -116,7 +104,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
116104
const devices = this.state.mediaDevices[kind].slice(0);
117105
if (devices.length === 0) return null;
118106

119-
const defaultDevice = getDefaultDevice(devices);
107+
const defaultDevice = MediaDeviceHandler.getDefaultDevice(devices);
120108
return (
121109
<Field
122110
element="select"

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"Inviting %(user)s and %(count)s others|other": "Inviting %(user)s and %(count)s others",
108108
"Inviting %(user)s and %(count)s others|one": "Inviting %(user)s and 1 other",
109109
"Empty room (was %(oldName)s)": "Empty room (was %(oldName)s)",
110+
"Default Device": "Default Device",
110111
"%(name)s is requesting verification": "%(name)s is requesting verification",
111112
"%(brand)s does not have permission to send you notifications - please check your browser settings": "%(brand)s does not have permission to send you notifications - please check your browser settings",
112113
"%(brand)s was not given permission to send notifications - please try again": "%(brand)s was not given permission to send notifications - please try again",
@@ -1619,7 +1620,6 @@
16191620
"Group all your people in one place.": "Group all your people in one place.",
16201621
"Rooms outside of a space": "Rooms outside of a space",
16211622
"Group all your rooms that aren't part of a space in one place.": "Group all your rooms that aren't part of a space in one place.",
1622-
"Default Device": "Default Device",
16231623
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
16241624
"Request media permissions": "Request media permissions",
16251625
"Audio Output": "Audio Output",

src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ limitations under the License.
1212
*/
1313

1414
import React from "react";
15-
import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
15+
import { Room } from "matrix-js-sdk/src/matrix";
16+
import classNames from "classnames";
1617

1718
import { LiveBadge } from "../..";
1819
import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg";
@@ -28,8 +29,9 @@ import { formatTimeLeft } from "../../../DateUtils";
2829
interface VoiceBroadcastHeaderProps {
2930
live?: boolean;
3031
onCloseClick?: () => void;
32+
onMicrophoneLineClick?: () => void;
3133
room: Room;
32-
sender: RoomMember;
34+
microphoneLabel?: string;
3335
showBroadcast?: boolean;
3436
timeLeft?: number;
3537
showClose?: boolean;
@@ -38,8 +40,9 @@ interface VoiceBroadcastHeaderProps {
3840
export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({
3941
live = false,
4042
onCloseClick = () => {},
43+
onMicrophoneLineClick,
4144
room,
42-
sender,
45+
microphoneLabel,
4346
showBroadcast = false,
4447
showClose = false,
4548
timeLeft,
@@ -66,16 +69,28 @@ export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({
6669
</div>
6770
: null;
6871

72+
const microphoneLineClasses = classNames({
73+
mx_VoiceBroadcastHeader_line: true,
74+
["mx_VoiceBroadcastHeader_mic--clickable"]: onMicrophoneLineClick,
75+
});
76+
77+
const microphoneLine = microphoneLabel
78+
? <div
79+
className={microphoneLineClasses}
80+
onClick={onMicrophoneLineClick}
81+
>
82+
<MicrophoneIcon className="mx_Icon mx_Icon_16" />
83+
<span>{ microphoneLabel }</span>
84+
</div>
85+
: null;
86+
6987
return <div className="mx_VoiceBroadcastHeader">
7088
<RoomAvatar room={room} width={32} height={32} />
7189
<div className="mx_VoiceBroadcastHeader_content">
7290
<div className="mx_VoiceBroadcastHeader_room">
7391
{ room.name }
7492
</div>
75-
<div className="mx_VoiceBroadcastHeader_line">
76-
<MicrophoneIcon className="mx_Icon mx_Icon_16" />
77-
<span>{ sender.name }</span>
78-
</div>
93+
{ microphoneLine }
7994
{ timeLeftLine }
8095
{ broadcast }
8196
</div>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const VoiceBroadcastPlaybackBody: React.FC<VoiceBroadcastPlaybackBodyProp
8080
<div className="mx_VoiceBroadcastBody">
8181
<VoiceBroadcastHeader
8282
live={live}
83-
sender={sender}
83+
microphoneLabel={sender?.name}
8484
room={room}
8585
showBroadcast={true}
8686
/>

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

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,106 @@ 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 { VoiceBroadcastHeader } from "../..";
2020
import AccessibleButton from "../../../components/views/elements/AccessibleButton";
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";
2431

2532
interface Props {
2633
voiceBroadcastPreRecording: VoiceBroadcastPreRecording;
2734
}
2835

36+
interface State {
37+
devices: MediaDeviceInfo[];
38+
device: MediaDeviceInfo | null;
39+
showDeviceSelect: boolean;
40+
}
41+
2942
export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({
3043
voiceBroadcastPreRecording,
3144
}) => {
32-
return <div className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip">
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+
};
79+
80+
const onMicrophoneLineClick = () => {
81+
setState({
82+
...state,
83+
showDeviceSelect: true,
84+
});
85+
};
86+
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+
108+
return <div
109+
className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip"
110+
ref={pipRef}
111+
>
33112
<VoiceBroadcastHeader
34113
onCloseClick={voiceBroadcastPreRecording.cancel}
114+
onMicrophoneLineClick={onMicrophoneLineClick}
35115
room={voiceBroadcastPreRecording.room}
36-
sender={voiceBroadcastPreRecording.sender}
116+
microphoneLabel={state.device?.label || _t('Default Device')}
37117
showClose={true}
38118
/>
39119
<AccessibleButton
@@ -44,5 +124,6 @@ export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({
44124
<LiveIcon className="mx_Icon mx_Icon_16" />
45125
{ _t("Go live") }
46126
</AccessibleButton>
127+
{ devicesMenu }
47128
</div>;
48129
};

0 commit comments

Comments
 (0)