Implement voice broadcast device selection (#9572)

This commit is contained in:
Michael Weimann 2022-11-15 10:02:40 +01:00 committed by GitHub
parent 272aae0973
commit 436146105e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 248 additions and 51 deletions

View file

@ -27,5 +27,6 @@ limitations under the License.
.mx_Icon_16 { .mx_Icon_16 {
height: 16px; height: 16px;
flex: 0 0 16px;
width: 16px; width: 16px;
} }

View file

@ -50,3 +50,7 @@ limitations under the License.
white-space: nowrap; white-space: nowrap;
} }
} }
.mx_VoiceBroadcastHeader_mic--clickable {
cursor: pointer;
}

View file

@ -21,6 +21,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel"; import { SettingLevel } from "./settings/SettingLevel";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import { _t } from './languageHandler';
// XXX: MediaDeviceKind is a union type, so we make our own enum // XXX: MediaDeviceKind is a union type, so we make our own enum
export enum MediaDeviceKindEnum { export enum MediaDeviceKindEnum {
@ -79,6 +80,18 @@ export default class MediaDeviceHandler extends EventEmitter {
} }
} }
public static getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>): string => {
// Note we're looking for a device with deviceId 'default' but adding a device
// with deviceId == the empty string: this is because Chrome gives us a device
// with deviceId 'default', so we're looking for this, not the one we are adding.
if (!devices.some((i) => i.deviceId === 'default')) {
devices.unshift({ deviceId: '', label: _t('Default Device') });
return '';
} else {
return 'default';
}
};
/** /**
* Retrieves devices from the SettingsStore and tells the js-sdk to use them * Retrieves devices from the SettingsStore and tells the js-sdk to use them
*/ */

View file

@ -472,6 +472,35 @@ export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">
return { left, top, chevronOffset }; return { left, top, chevronOffset };
}; };
export type ToLeftOf = {
chevronOffset: number;
right: number;
top: number;
};
// Placement method for <ContextMenu /> to position context menu to left of elementRect with chevronOffset
export const toLeftOf = (elementRect: DOMRect, chevronOffset = 12): ToLeftOf => {
const right = UIStore.instance.windowWidth - elementRect.left + window.scrollX - 3;
let top = elementRect.top + (elementRect.height / 2) + window.scrollY;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
return { right, top, chevronOffset };
};
/**
* Placement method for <ContextMenu /> to position context menu of or right of elementRect
* depending on which side has more space.
*/
export const toLeftOrRightOf = (elementRect: DOMRect, chevronOffset = 12): ToRightOf | ToLeftOf => {
const spaceToTheLeft = elementRect.left;
const spaceToTheRight = UIStore.instance.windowWidth - elementRect.right;
if (spaceToTheLeft > spaceToTheRight) {
return toLeftOf(elementRect, chevronOffset);
}
return toRightOf(elementRect, chevronOffset);
};
export type AboveLeftOf = IPosition & { export type AboveLeftOf = IPosition & {
chevronFace: ChevronFace; chevronFace: ChevronFace;
}; };

View file

@ -48,7 +48,7 @@ interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
} }
interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> { interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> {
iconClassName: string; iconClassName?: string;
} }
export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({ export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
@ -67,7 +67,7 @@ export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
active={active} active={active}
label={label} label={label}
> >
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> { iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
<span className="mx_IconizedContextMenu_label">{ label }</span> <span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> } { active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
</MenuItemRadio>; </MenuItemRadio>;

View file

@ -27,18 +27,6 @@ import SettingsFlag from '../../../elements/SettingsFlag';
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { requestMediaPermissions } from '../../../../../utils/media/requestMediaPermissions'; import { requestMediaPermissions } from '../../../../../utils/media/requestMediaPermissions';
const getDefaultDevice = (devices: Array<Partial<MediaDeviceInfo>>) => {
// Note we're looking for a device with deviceId 'default' but adding a device
// with deviceId == the empty string: this is because Chrome gives us a device
// with deviceId 'default', so we're looking for this, not the one we are adding.
if (!devices.some((i) => i.deviceId === 'default')) {
devices.unshift({ deviceId: '', label: _t('Default Device') });
return '';
} else {
return 'default';
}
};
interface IState { interface IState {
mediaDevices: IMediaDevices; mediaDevices: IMediaDevices;
[MediaDeviceKindEnum.AudioOutput]: string; [MediaDeviceKindEnum.AudioOutput]: string;
@ -116,7 +104,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> {
const devices = this.state.mediaDevices[kind].slice(0); const devices = this.state.mediaDevices[kind].slice(0);
if (devices.length === 0) return null; if (devices.length === 0) return null;
const defaultDevice = getDefaultDevice(devices); const defaultDevice = MediaDeviceHandler.getDefaultDevice(devices);
return ( return (
<Field <Field
element="select" element="select"

View file

@ -107,6 +107,7 @@
"Inviting %(user)s and %(count)s others|other": "Inviting %(user)s and %(count)s others", "Inviting %(user)s and %(count)s others|other": "Inviting %(user)s and %(count)s others",
"Inviting %(user)s and %(count)s others|one": "Inviting %(user)s and 1 other", "Inviting %(user)s and %(count)s others|one": "Inviting %(user)s and 1 other",
"Empty room (was %(oldName)s)": "Empty room (was %(oldName)s)", "Empty room (was %(oldName)s)": "Empty room (was %(oldName)s)",
"Default Device": "Default Device",
"%(name)s is requesting verification": "%(name)s is requesting verification", "%(name)s is requesting verification": "%(name)s is requesting verification",
"%(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", "%(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",
"%(brand)s was not given permission to send notifications - please try again": "%(brand)s was not given permission to send notifications - please try again", "%(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 @@
"Group all your people in one place.": "Group all your people in one place.", "Group all your people in one place.": "Group all your people in one place.",
"Rooms outside of a space": "Rooms outside of a space", "Rooms outside of a space": "Rooms outside of a space",
"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.", "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.",
"Default Device": "Default Device",
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.", "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
"Request media permissions": "Request media permissions", "Request media permissions": "Request media permissions",
"Audio Output": "Audio Output", "Audio Output": "Audio Output",

View file

@ -12,7 +12,8 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { LiveBadge } from "../.."; import { LiveBadge } from "../..";
import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg";
@ -28,8 +29,9 @@ import { formatTimeLeft } from "../../../DateUtils";
interface VoiceBroadcastHeaderProps { interface VoiceBroadcastHeaderProps {
live?: boolean; live?: boolean;
onCloseClick?: () => void; onCloseClick?: () => void;
onMicrophoneLineClick?: () => void;
room: Room; room: Room;
sender: RoomMember; microphoneLabel?: string;
showBroadcast?: boolean; showBroadcast?: boolean;
timeLeft?: number; timeLeft?: number;
showClose?: boolean; showClose?: boolean;
@ -38,8 +40,9 @@ interface VoiceBroadcastHeaderProps {
export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({ export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({
live = false, live = false,
onCloseClick = () => {}, onCloseClick = () => {},
onMicrophoneLineClick,
room, room,
sender, microphoneLabel,
showBroadcast = false, showBroadcast = false,
showClose = false, showClose = false,
timeLeft, timeLeft,
@ -66,16 +69,28 @@ export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({
</div> </div>
: null; : null;
const microphoneLineClasses = classNames({
mx_VoiceBroadcastHeader_line: true,
["mx_VoiceBroadcastHeader_mic--clickable"]: onMicrophoneLineClick,
});
const microphoneLine = microphoneLabel
? <div
className={microphoneLineClasses}
onClick={onMicrophoneLineClick}
>
<MicrophoneIcon className="mx_Icon mx_Icon_16" />
<span>{ microphoneLabel }</span>
</div>
: null;
return <div className="mx_VoiceBroadcastHeader"> return <div className="mx_VoiceBroadcastHeader">
<RoomAvatar room={room} width={32} height={32} /> <RoomAvatar room={room} width={32} height={32} />
<div className="mx_VoiceBroadcastHeader_content"> <div className="mx_VoiceBroadcastHeader_content">
<div className="mx_VoiceBroadcastHeader_room"> <div className="mx_VoiceBroadcastHeader_room">
{ room.name } { room.name }
</div> </div>
<div className="mx_VoiceBroadcastHeader_line"> { microphoneLine }
<MicrophoneIcon className="mx_Icon mx_Icon_16" />
<span>{ sender.name }</span>
</div>
{ timeLeftLine } { timeLeftLine }
{ broadcast } { broadcast }
</div> </div>

View file

@ -80,7 +80,7 @@ export const VoiceBroadcastPlaybackBody: React.FC<VoiceBroadcastPlaybackBodyProp
<div className="mx_VoiceBroadcastBody"> <div className="mx_VoiceBroadcastBody">
<VoiceBroadcastHeader <VoiceBroadcastHeader
live={live} live={live}
sender={sender} microphoneLabel={sender?.name}
room={room} room={room}
showBroadcast={true} showBroadcast={true}
/> />

View file

@ -14,26 +14,106 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { useRef, useState } from "react";
import { VoiceBroadcastHeader } from "../.."; import { VoiceBroadcastHeader } from "../..";
import AccessibleButton from "../../../components/views/elements/AccessibleButton"; import AccessibleButton from "../../../components/views/elements/AccessibleButton";
import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording"; import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording";
import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import IconizedContextMenu, {
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../../../components/views/context_menus/IconizedContextMenu";
import { requestMediaPermissions } from "../../../utils/media/requestMediaPermissions";
import MediaDeviceHandler from "../../../MediaDeviceHandler";
import { toLeftOrRightOf } from "../../../components/structures/ContextMenu";
interface Props { interface Props {
voiceBroadcastPreRecording: VoiceBroadcastPreRecording; voiceBroadcastPreRecording: VoiceBroadcastPreRecording;
} }
interface State {
devices: MediaDeviceInfo[];
device: MediaDeviceInfo | null;
showDeviceSelect: boolean;
}
export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({ export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({
voiceBroadcastPreRecording, voiceBroadcastPreRecording,
}) => { }) => {
return <div className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip"> const shouldRequestPermissionsRef = useRef<boolean>(true);
const pipRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<State>({
devices: [],
device: null,
showDeviceSelect: false,
});
if (shouldRequestPermissionsRef.current) {
shouldRequestPermissionsRef.current = false;
requestMediaPermissions(false).then((stream: MediaStream | undefined) => {
MediaDeviceHandler.getDevices().then(({ audioinput }) => {
MediaDeviceHandler.getDefaultDevice(audioinput);
const deviceFromSettings = MediaDeviceHandler.getAudioInput();
const device = audioinput.find((d) => {
return d.deviceId === deviceFromSettings;
}) || audioinput[0];
setState({
...state,
devices: audioinput,
device,
});
stream?.getTracks().forEach(t => t.stop());
});
});
}
const onDeviceOptionClick = (device: MediaDeviceInfo) => {
setState({
...state,
device,
showDeviceSelect: false,
});
};
const onMicrophoneLineClick = () => {
setState({
...state,
showDeviceSelect: true,
});
};
const deviceOptions = state.devices.map((d: MediaDeviceInfo) => {
return <IconizedContextMenuRadio
key={d.deviceId}
active={d.deviceId === state.device?.deviceId}
onClick={() => onDeviceOptionClick(d)}
label={d.label}
/>;
});
const devicesMenu = state.showDeviceSelect && pipRef.current
? <IconizedContextMenu
mountAsChild={false}
onFinished={() => {}}
{...toLeftOrRightOf(pipRef.current.getBoundingClientRect(), 0)}
>
<IconizedContextMenuOptionList>
{ deviceOptions }
</IconizedContextMenuOptionList>
</IconizedContextMenu>
: null;
return <div
className="mx_VoiceBroadcastBody mx_VoiceBroadcastBody--pip"
ref={pipRef}
>
<VoiceBroadcastHeader <VoiceBroadcastHeader
onCloseClick={voiceBroadcastPreRecording.cancel} onCloseClick={voiceBroadcastPreRecording.cancel}
onMicrophoneLineClick={onMicrophoneLineClick}
room={voiceBroadcastPreRecording.room} room={voiceBroadcastPreRecording.room}
sender={voiceBroadcastPreRecording.sender} microphoneLabel={state.device?.label || _t('Default Device')}
showClose={true} showClose={true}
/> />
<AccessibleButton <AccessibleButton
@ -44,5 +124,6 @@ export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({
<LiveIcon className="mx_Icon mx_Icon_16" /> <LiveIcon className="mx_Icon mx_Icon_16" />
{ _t("Go live") } { _t("Go live") }
</AccessibleButton> </AccessibleButton>
{ devicesMenu }
</div>; </div>;
}; };

View file

@ -30,7 +30,7 @@ export const VoiceBroadcastRecordingBody: React.FC<VoiceBroadcastRecordingBodyPr
<div className="mx_VoiceBroadcastBody"> <div className="mx_VoiceBroadcastBody">
<VoiceBroadcastHeader <VoiceBroadcastHeader
live={live} live={live}
sender={sender} microphoneLabel={sender?.name}
room={room} room={room}
/> />
</div> </div>

View file

@ -38,7 +38,6 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
timeLeft, timeLeft,
recordingState, recordingState,
room, room,
sender,
stopRecording, stopRecording,
toggleRecording, toggleRecording,
} = useVoiceBroadcastRecording(recording); } = useVoiceBroadcastRecording(recording);
@ -57,7 +56,6 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
> >
<VoiceBroadcastHeader <VoiceBroadcastHeader
live={live} live={live}
sender={sender}
room={room} room={room}
timeLeft={timeLeft} timeLeft={timeLeft}
/> />

View file

@ -0,0 +1,88 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
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 { toLeftOf, toLeftOrRightOf, toRightOf } from "../../../src/components/structures/ContextMenu";
import UIStore from "../../../src/stores/UIStore";
describe("ContextMenu", () => {
const rect = new DOMRect();
// @ts-ignore
rect.left = 23;
// @ts-ignore
rect.right = 46;
// @ts-ignore
rect.top = 42;
rect.width = 640;
rect.height = 480;
beforeEach(() => {
window.scrollX = 31;
window.scrollY = 41;
UIStore.instance.windowWidth = 1280;
});
describe("toLeftOf", () => {
it("should return the correct positioning", () => {
expect(toLeftOf(rect)).toEqual({
chevronOffset: 12,
right: 1285, // 1280 - 23 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toRightOf", () => {
it("should return the correct positioning", () => {
expect(toRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("toLeftOrRightOf", () => {
describe("when there is more space to the right", () => {
// default case from test setup
it("should return a position to the right", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
left: 80, // 46 + 31 + 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
describe("when there is more space to the left", () => {
beforeEach(() => {
// @ts-ignore
rect.left = 500;
// @ts-ignore
rect.right = 1000;
});
it("should return a position to the left", () => {
expect(toLeftOrRightOf(rect)).toEqual({
chevronOffset: 12,
right: 808, // 1280 - 500 + 31 - 3
top: 303, // 42 + (480 / 2) + 41 - (12 + 8)
});
});
});
});
});

View file

@ -38,7 +38,7 @@ describe("VoiceBroadcastHeader", () => {
const renderHeader = (live: boolean, showBroadcast: boolean = undefined): RenderResult => { const renderHeader = (live: boolean, showBroadcast: boolean = undefined): RenderResult => {
return render(<VoiceBroadcastHeader return render(<VoiceBroadcastHeader
live={live} live={live}
sender={sender} microphoneLabel={sender.name}
room={room} room={room}
showBroadcast={showBroadcast} showBroadcast={showBroadcast}
/>); />);

View file

@ -22,16 +22,6 @@ exports[`VoiceBroadcastRecordingPip when rendering a paused recording should ren
> >
My room My room
</div> </div>
<div
class="mx_VoiceBroadcastHeader_line"
>
<div
class="mx_Icon mx_Icon_16"
/>
<span>
@userId:matrix.org
</span>
</div>
<div <div
class="mx_VoiceBroadcastHeader_line" class="mx_VoiceBroadcastHeader_line"
> >
@ -107,16 +97,6 @@ exports[`VoiceBroadcastRecordingPip when rendering a started recording should re
> >
My room My room
</div> </div>
<div
class="mx_VoiceBroadcastHeader_line"
>
<div
class="mx_Icon mx_Icon_16"
/>
<span>
@userId:matrix.org
</span>
</div>
<div <div
class="mx_VoiceBroadcastHeader_line" class="mx_VoiceBroadcastHeader_line"
> >