From 436146105e87ff9fbe7255f54846de733f3da80f Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Tue, 15 Nov 2022 10:02:40 +0100 Subject: [PATCH] Implement voice broadcast device selection (#9572) --- res/css/compound/_Icon.pcss | 1 + .../atoms/_VoiceBroadcastHeader.pcss | 4 + src/MediaDeviceHandler.ts | 13 +++ src/components/structures/ContextMenu.tsx | 29 ++++++ .../context_menus/IconizedContextMenu.tsx | 4 +- .../tabs/user/VoiceUserSettingsTab.tsx | 14 +-- src/i18n/strings/en_EN.json | 2 +- .../components/atoms/VoiceBroadcastHeader.tsx | 29 ++++-- .../molecules/VoiceBroadcastPlaybackBody.tsx | 2 +- .../VoiceBroadcastPreRecordingPip.tsx | 87 +++++++++++++++++- .../molecules/VoiceBroadcastRecordingBody.tsx | 2 +- .../molecules/VoiceBroadcastRecordingPip.tsx | 2 - .../components/structures/ContextMenu-test.ts | 88 +++++++++++++++++++ .../atoms/VoiceBroadcastHeader-test.tsx | 2 +- .../VoiceBroadcastRecordingPip-test.tsx.snap | 20 ----- 15 files changed, 248 insertions(+), 51 deletions(-) create mode 100644 test/components/structures/ContextMenu-test.ts diff --git a/res/css/compound/_Icon.pcss b/res/css/compound/_Icon.pcss index 88f49f9da0..a40558ccc0 100644 --- a/res/css/compound/_Icon.pcss +++ b/res/css/compound/_Icon.pcss @@ -27,5 +27,6 @@ limitations under the License. .mx_Icon_16 { height: 16px; + flex: 0 0 16px; width: 16px; } diff --git a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss index 0e2395cacb..1ff29bd985 100644 --- a/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss +++ b/res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss @@ -50,3 +50,7 @@ limitations under the License. white-space: nowrap; } } + +.mx_VoiceBroadcastHeader_mic--clickable { + cursor: pointer; +} diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index 6d60bc72f0..85cd893702 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -21,6 +21,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import SettingsStore from "./settings/SettingsStore"; import { SettingLevel } from "./settings/SettingLevel"; import { MatrixClientPeg } from "./MatrixClientPeg"; +import { _t } from './languageHandler'; // XXX: MediaDeviceKind is a union type, so we make our own enum export enum MediaDeviceKindEnum { @@ -79,6 +80,18 @@ export default class MediaDeviceHandler extends EventEmitter { } } + public static getDefaultDevice = (devices: Array>): 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 */ diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index cf9aacb808..d0061aee4e 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -472,6 +472,35 @@ export const toRightOf = (elementRect: Pick return { left, top, chevronOffset }; }; +export type ToLeftOf = { + chevronOffset: number; + right: number; + top: number; +}; + +// Placement method for 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 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 & { chevronFace: ChevronFace; }; diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index ad8d97edd4..dfb685e55c 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -48,7 +48,7 @@ interface ICheckboxProps extends React.ComponentProps { } interface IRadioProps extends React.ComponentProps { - iconClassName: string; + iconClassName?: string; } export const IconizedContextMenuRadio: React.FC = ({ @@ -67,7 +67,7 @@ export const IconizedContextMenuRadio: React.FC = ({ active={active} label={label} > - + { iconClassName && } { label } { active && } ; diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index 7da2ab3121..fe363ecff4 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -27,18 +27,6 @@ import SettingsFlag from '../../../elements/SettingsFlag'; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import { requestMediaPermissions } from '../../../../../utils/media/requestMediaPermissions'; -const getDefaultDevice = (devices: Array>) => { - // 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 { mediaDevices: IMediaDevices; [MediaDeviceKindEnum.AudioOutput]: string; @@ -116,7 +104,7 @@ export default class VoiceUserSettingsTab extends React.Component<{}, IState> { const devices = this.state.mediaDevices[kind].slice(0); if (devices.length === 0) return null; - const defaultDevice = getDefaultDevice(devices); + const defaultDevice = MediaDeviceHandler.getDefaultDevice(devices); return ( void; + onMicrophoneLineClick?: () => void; room: Room; - sender: RoomMember; + microphoneLabel?: string; showBroadcast?: boolean; timeLeft?: number; showClose?: boolean; @@ -38,8 +40,9 @@ interface VoiceBroadcastHeaderProps { export const VoiceBroadcastHeader: React.FC = ({ live = false, onCloseClick = () => {}, + onMicrophoneLineClick, room, - sender, + microphoneLabel, showBroadcast = false, showClose = false, timeLeft, @@ -66,16 +69,28 @@ export const VoiceBroadcastHeader: React.FC = ({ : null; + const microphoneLineClasses = classNames({ + mx_VoiceBroadcastHeader_line: true, + ["mx_VoiceBroadcastHeader_mic--clickable"]: onMicrophoneLineClick, + }); + + const microphoneLine = microphoneLabel + ?
+ + { microphoneLabel } +
+ : null; + return
{ room.name }
-
- - { sender.name } -
+ { microphoneLine } { timeLeftLine } { broadcast }
diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx index bb3de10c73..7851d99468 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx @@ -80,7 +80,7 @@ export const VoiceBroadcastPlaybackBody: React.FC diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx index b8dfd11811..e3a3b5f424 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPreRecordingPip.tsx @@ -14,26 +14,106 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useRef, useState } from "react"; import { VoiceBroadcastHeader } from "../.."; import AccessibleButton from "../../../components/views/elements/AccessibleButton"; import { VoiceBroadcastPreRecording } from "../../models/VoiceBroadcastPreRecording"; import { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg"; 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 { voiceBroadcastPreRecording: VoiceBroadcastPreRecording; } +interface State { + devices: MediaDeviceInfo[]; + device: MediaDeviceInfo | null; + showDeviceSelect: boolean; +} + export const VoiceBroadcastPreRecordingPip: React.FC = ({ voiceBroadcastPreRecording, }) => { - return
+ const shouldRequestPermissionsRef = useRef(true); + const pipRef = useRef(null); + const [state, setState] = useState({ + 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 onDeviceOptionClick(d)} + label={d.label} + />; + }); + + const devicesMenu = state.showDeviceSelect && pipRef.current + ? {}} + {...toLeftOrRightOf(pipRef.current.getBoundingClientRect(), 0)} + > + + { deviceOptions } + + + : null; + + return
= ({ { _t("Go live") } + { devicesMenu }
; }; diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx index 1b13377da9..ee982dd86d 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx @@ -30,7 +30,7 @@ export const VoiceBroadcastRecordingBody: React.FC
diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx index fdf0e7a224..7170e53a9b 100644 --- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx +++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip.tsx @@ -38,7 +38,6 @@ export const VoiceBroadcastRecordingPip: React.FC diff --git a/test/components/structures/ContextMenu-test.ts b/test/components/structures/ContextMenu-test.ts new file mode 100644 index 0000000000..dc2b3f74a2 --- /dev/null +++ b/test/components/structures/ContextMenu-test.ts @@ -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) + }); + }); + }); + }); +}); + diff --git a/test/voice-broadcast/components/atoms/VoiceBroadcastHeader-test.tsx b/test/voice-broadcast/components/atoms/VoiceBroadcastHeader-test.tsx index d3a3133ec6..3800b04713 100644 --- a/test/voice-broadcast/components/atoms/VoiceBroadcastHeader-test.tsx +++ b/test/voice-broadcast/components/atoms/VoiceBroadcastHeader-test.tsx @@ -38,7 +38,7 @@ describe("VoiceBroadcastHeader", () => { const renderHeader = (live: boolean, showBroadcast: boolean = undefined): RenderResult => { return render(); diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap index f17f59ef3d..3f6cd2544d 100644 --- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap +++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastRecordingPip-test.tsx.snap @@ -22,16 +22,6 @@ exports[`VoiceBroadcastRecordingPip when rendering a paused recording should ren > My room
-
-
- - @userId:matrix.org - -
@@ -107,16 +97,6 @@ exports[`VoiceBroadcastRecordingPip when rendering a started recording should re > My room
-
-
- - @userId:matrix.org - -