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

Commit

Permalink
Implement voice broadcast device selection (#9572)
Browse files Browse the repository at this point in the history
  • Loading branch information
weeman1337 authored and Amy Walker committed Nov 28, 2022
1 parent d1f4581 commit 75efdd0
Show file tree
Hide file tree
Showing 15 changed files with 248 additions and 51 deletions.
1 change: 1 addition & 0 deletions res/css/compound/_Icon.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ limitations under the License.

.mx_Icon_16 {
height: 16px;
flex: 0 0 16px;
width: 16px;
}
4 changes: 4 additions & 0 deletions res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@ limitations under the License.
white-space: nowrap;
}
}

.mx_VoiceBroadcastHeader_mic--clickable {
cursor: pointer;
}
13 changes: 13 additions & 0 deletions src/MediaDeviceHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
*/
Expand Down
29 changes: 29 additions & 0 deletions src/components/structures/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,35 @@ export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">
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 & {
chevronFace: ChevronFace;
};
Expand Down
4 changes: 2 additions & 2 deletions src/components/views/context_menus/IconizedContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
}

interface IRadioProps extends React.ComponentProps<typeof MenuItemRadio> {
iconClassName: string;
iconClassName?: string;
}

export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
Expand All @@ -67,7 +67,7 @@ export const IconizedContextMenuRadio: React.FC<IRadioProps> = ({
active={active}
label={label}
>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
{ iconClassName && <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> }
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> }
</MenuItemRadio>;
Expand Down
14 changes: 1 addition & 13 deletions src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,6 @@ import SettingsFlag from '../../../elements/SettingsFlag';
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
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 {
mediaDevices: IMediaDevices;
[MediaDeviceKindEnum.AudioOutput]: string;
Expand Down Expand Up @@ -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 (
<Field
element="select"
Expand Down
2 changes: 1 addition & 1 deletion src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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|one": "Inviting %(user)s and 1 other",
"Empty room (was %(oldName)s)": "Empty room (was %(oldName)s)",
"Default Device": "Default Device",
"%(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 was not given permission to send notifications - please try again": "%(brand)s was not given permission to send notifications - please try again",
Expand Down Expand Up @@ -1619,7 +1620,6 @@
"Group all your people in one place.": "Group all your people in one place.",
"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.",
"Default Device": "Default Device",
"Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.",
"Request media permissions": "Request media permissions",
"Audio Output": "Audio Output",
Expand Down
29 changes: 22 additions & 7 deletions src/voice-broadcast/components/atoms/VoiceBroadcastHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ limitations under the License.
*/

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 { Icon as LiveIcon } from "../../../../res/img/element-icons/live.svg";
Expand All @@ -28,8 +29,9 @@ import { formatTimeLeft } from "../../../DateUtils";
interface VoiceBroadcastHeaderProps {
live?: boolean;
onCloseClick?: () => void;
onMicrophoneLineClick?: () => void;
room: Room;
sender: RoomMember;
microphoneLabel?: string;
showBroadcast?: boolean;
timeLeft?: number;
showClose?: boolean;
Expand All @@ -38,8 +40,9 @@ interface VoiceBroadcastHeaderProps {
export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({
live = false,
onCloseClick = () => {},
onMicrophoneLineClick,
room,
sender,
microphoneLabel,
showBroadcast = false,
showClose = false,
timeLeft,
Expand All @@ -66,16 +69,28 @@ export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({
</div>
: 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">
<RoomAvatar room={room} width={32} height={32} />
<div className="mx_VoiceBroadcastHeader_content">
<div className="mx_VoiceBroadcastHeader_room">
{ room.name }
</div>
<div className="mx_VoiceBroadcastHeader_line">
<MicrophoneIcon className="mx_Icon mx_Icon_16" />
<span>{ sender.name }</span>
</div>
{ microphoneLine }
{ timeLeftLine }
{ broadcast }
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const VoiceBroadcastPlaybackBody: React.FC<VoiceBroadcastPlaybackBodyProp
<div className="mx_VoiceBroadcastBody">
<VoiceBroadcastHeader
live={live}
sender={sender}
microphoneLabel={sender?.name}
room={room}
showBroadcast={true}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = ({
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
onCloseClick={voiceBroadcastPreRecording.cancel}
onMicrophoneLineClick={onMicrophoneLineClick}
room={voiceBroadcastPreRecording.room}
sender={voiceBroadcastPreRecording.sender}
microphoneLabel={state.device?.label || _t('Default Device')}
showClose={true}
/>
<AccessibleButton
Expand All @@ -44,5 +124,6 @@ export const VoiceBroadcastPreRecordingPip: React.FC<Props> = ({
<LiveIcon className="mx_Icon mx_Icon_16" />
{ _t("Go live") }
</AccessibleButton>
{ devicesMenu }
</div>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const VoiceBroadcastRecordingBody: React.FC<VoiceBroadcastRecordingBodyPr
<div className="mx_VoiceBroadcastBody">
<VoiceBroadcastHeader
live={live}
sender={sender}
microphoneLabel={sender?.name}
room={room}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
timeLeft,
recordingState,
room,
sender,
stopRecording,
toggleRecording,
} = useVoiceBroadcastRecording(recording);
Expand All @@ -57,7 +56,6 @@ export const VoiceBroadcastRecordingPip: React.FC<VoiceBroadcastRecordingPipProp
>
<VoiceBroadcastHeader
live={live}
sender={sender}
room={room}
timeLeft={timeLeft}
/>
Expand Down
Loading

0 comments on commit 75efdd0

Please sign in to comment.