Merge pull request #468 from vector-im/SimonBrandner/feat/local-volume

This commit is contained in:
Šimon Brandner 2022-07-28 18:09:32 +02:00 committed by GitHub
commit 942800a2a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 257 additions and 28 deletions

View file

@ -30,6 +30,7 @@ import { ReactComponent as SettingsIcon } from "../icons/Settings.svg";
import { ReactComponent as AddUserIcon } from "../icons/AddUser.svg";
import { ReactComponent as ArrowDownIcon } from "../icons/ArrowDown.svg";
import { TooltipTrigger } from "../Tooltip";
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
export type ButtonVariant =
| "default"
@ -237,3 +238,14 @@ export function InviteButton({
</TooltipTrigger>
);
}
export function OptionsButton(props: Omit<Props, "variant">) {
return (
<TooltipTrigger>
<Button variant="icon" {...props}>
<OverflowIcon />
</Button>
{() => "Options"}
</TooltipTrigger>
);
}

View file

@ -20,6 +20,7 @@ import classNames from "classnames";
import styles from "./VideoTile.module.css";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
import { OptionsButton } from "../button/Button";
export const VideoTile = forwardRef(
(
@ -35,6 +36,8 @@ export const VideoTile = forwardRef(
name,
showName,
mediaRef,
onOptionsPress,
showOptions,
...rest
},
ref
@ -62,13 +65,18 @@ export const VideoTile = forwardRef(
</div>
) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={styles.memberName}>
<div className={classNames(styles.infoBubble, styles.memberName)}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />}
{videoMuted && !noVideo && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>}
</div>
)
)}
{showOptions && (
<div className={classNames(styles.infoBubble, styles.optionsButton)}>
<OptionsButton onPress={onOptionsPress} />
</div>
)}
<video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div>
);

View file

@ -44,10 +44,8 @@
object-fit: contain;
}
.memberName {
.infoBubble {
position: absolute;
bottom: 16px;
left: 16px;
height: 24px;
padding: 0 8px;
color: white;
@ -62,6 +60,21 @@
z-index: 1;
}
.optionsButton {
right: 16px;
top: 16px;
}
.optionsButton button svg {
height: 20px;
width: 20px;
}
.memberName {
left: 16px;
bottom: 16px;
}
.memberName > * {
margin-right: 6px;
}

View file

@ -20,6 +20,8 @@ import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { useModalTriggerState } from "../Modal";
export function VideoTileContainer({
item,
@ -37,6 +39,7 @@ export function VideoTileContainer({
isLocal,
audioMuted,
videoMuted,
localVolume,
noVideo,
speaking,
stream,
@ -49,26 +52,44 @@ export function VideoTileContainer({
audioOutputDevice,
audioContext,
audioDestination,
isLocal
isLocal,
localVolume
);
const {
modalState: videoTileSettingsModalState,
modalProps: videoTileSettingsModalProps,
} = useModalTriggerState();
const onOptionsPress = () => {
videoTileSettingsModalState.open();
};
// Firefox doesn't respect the disablePictureInPicture attribute
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
return (
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
noVideo={noVideo}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
showName={showName}
ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)}
{...rest}
/>
<>
<VideoTile
isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted}
noVideo={noVideo}
videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName}
showName={showName}
ref={tileRef}
mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)}
onOptionsPress={onOptionsPress}
showOptions={!item.callFeed.isLocal()}
{...rest}
/>
{videoTileSettingsModalState.isOpen && (
<VideoTileSettingsModal
{...videoTileSettingsModalProps}
feed={item.callFeed}
/>
)}
</>
);
}

View file

@ -0,0 +1,70 @@
.content {
margin: 27px 34px;
}
.localVolumePercentage {
width: 3ch;
}
.localVolumeSlider[type="range"] {
-ms-appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
width: 100%;
}
.localVolumeSlider[type="range"]::-moz-range-track {
-moz-appearance: none;
appearance: none;
height: 4px;
}
.localVolumeSlider[type="range"]::-ms-track {
-ms-appearance: none;
appearance: none;
height: 4px;
}
.localVolumeSlider[type="range"]::-webkit-slider-runnable-track {
-webkit-appearance: none;
appearance: none;
height: 4px;
}
.localVolumeSlider[type="range"]::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
height: 16px;
width: 16px;
margin-top: -6px;
border-radius: 100%;
background: var(--accent);
}
.localVolumeSlider[type="range"]::-ms-thumb {
-ms-appearance: none;
appearance: none;
height: 16px;
width: 16px;
margin-top: -6px;
border-radius: 100%;
background: var(--accent);
}
.localVolumeSlider[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 16px;
width: 16px;
margin-top: -6px;
border-radius: 100%;
background: var(--accent);
}

View file

@ -0,0 +1,74 @@
/*
Copyright 2022 New Vector Ltd
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 React, { ChangeEvent, useState } from "react";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import selectInputStyles from "../input/SelectInput.module.css";
import { FieldRow } from "../input/Input";
import { Modal } from "../Modal";
import styles from "./VideoTileSettingsModal.module.css";
interface LocalVolumeProps {
feed: CallFeed;
}
const LocalVolume: React.FC<LocalVolumeProps> = ({
feed,
}: LocalVolumeProps) => {
const [localVolume, setLocalVolume] = useState<number>(feed.getLocalVolume());
const onLocalVolumeChanged = (event: ChangeEvent<HTMLInputElement>) => {
const value: number = +event.target.value;
setLocalVolume(value);
feed.setLocalVolume(value);
};
return (
<>
<h4 className={selectInputStyles.label}> Local Volume </h4>
<FieldRow>
<span className={styles.localVolumePercentage}>
{`${Math.round(localVolume * 100)}%`}
</span>
<input
className={styles.localVolumeSlider}
type="range"
min="0"
max="1"
step="0.01"
value={localVolume}
onChange={onLocalVolumeChanged}
/>
</FieldRow>
</>
);
};
// TODO: Extend ModalProps
interface Props {
feed: CallFeed;
}
export const VideoTileSettingsModal = ({ feed, ...rest }: Props) => {
return (
<Modal title="Feed settings" isDismissable mobileFullScreen {...rest}>
<div className={styles.content}>
<LocalVolume feed={feed} />
</div>
</Modal>
);
};

View file

@ -27,6 +27,7 @@ function getCallFeedState(callFeed) {
: true,
videoMuted: callFeed ? callFeed.isVideoMuted() : true,
audioMuted: callFeed ? callFeed.isAudioMuted() : true,
localVolume: callFeed ? callFeed.getLocalVolume() : 0,
stream: callFeed ? callFeed.stream : undefined,
purpose: callFeed ? callFeed.purpose : undefined,
};
@ -44,6 +45,10 @@ export function useCallFeed(callFeed) {
setState((prevState) => ({ ...prevState, audioMuted, videoMuted }));
}
function onLocalVolumeChanged(localVolume) {
setState((prevState) => ({ ...prevState, localVolume }));
}
function onUpdateCallFeed() {
setState(getCallFeedState(callFeed));
}
@ -51,6 +56,7 @@ export function useCallFeed(callFeed) {
if (callFeed) {
callFeed.on(CallFeedEvent.Speaking, onSpeaking);
callFeed.on(CallFeedEvent.MuteStateChanged, onMuteStateChanged);
callFeed.on(CallFeedEvent.LocalVolumeChanged, onLocalVolumeChanged);
callFeed.on(CallFeedEvent.NewStream, onUpdateCallFeed);
}
@ -63,6 +69,10 @@ export function useCallFeed(callFeed) {
CallFeedEvent.MuteStateChanged,
onMuteStateChanged
);
callFeed.removeListener(
CallFeedEvent.LocalVolumeChanged,
onLocalVolumeChanged
);
callFeed.removeListener(CallFeedEvent.NewStream, onUpdateCallFeed);
}
};

View file

@ -33,7 +33,8 @@ declare global {
export const useMediaStream = (
stream: MediaStream,
audioOutputDevice: string,
mute = false
mute = false,
localVolume: number
): RefObject<MediaElement> => {
const mediaRef = useRef<MediaElement>();
@ -84,6 +85,13 @@ export const useMediaStream = (
}
}, [audioOutputDevice]);
useEffect(() => {
if (!mediaRef.current) return;
if (localVolume === null || localVolume === undefined) return;
mediaRef.current.volume = localVolume;
}, [localVolume]);
useEffect(() => {
const mediaEl = mediaRef.current;
@ -187,7 +195,8 @@ export const useSpatialMediaStream = (
audioOutputDevice: string,
audioContext: AudioContext,
audioDestination: AudioNode,
mute = false
mute = false,
localVolume: number
): [RefObject<Element>, RefObject<MediaElement>] => {
const tileRef = useRef<Element>();
const [spatialAudio] = useSpatialAudio();
@ -195,9 +204,11 @@ export const useSpatialMediaStream = (
const mediaRef = useMediaStream(
stream,
audioOutputDevice,
spatialAudio || mute
spatialAudio || mute,
localVolume
);
const gainNodeRef = useRef<GainNode>();
const pannerNodeRef = useRef<PannerNode>();
const sourceRef = useRef<MediaStreamAudioSourceNode>();
@ -214,12 +225,18 @@ export const useSpatialMediaStream = (
refDistance: 3,
});
}
if (!gainNodeRef.current) {
gainNodeRef.current = new GainNode(audioContext, {
gain: localVolume,
});
}
if (!sourceRef.current) {
sourceRef.current = audioContext.createMediaStreamSource(stream);
}
const tile = tileRef.current;
const source = sourceRef.current;
const gainNode = gainNodeRef.current;
const pannerNode = pannerNodeRef.current;
const updatePosition = () => {
@ -234,8 +251,9 @@ export const useSpatialMediaStream = (
pannerNodeRef.current.positionZ.value = -2;
};
gainNode.gain.value = localVolume;
updatePosition();
source.connect(pannerNode).connect(audioDestination);
source.connect(gainNode).connect(pannerNode).connect(audioDestination);
// HACK: We abuse the CSS transitionrun event to detect when the tile
// moves, because useMeasure, IntersectionObserver, etc. all have no
// ability to track changes in the CSS transform property
@ -244,10 +262,11 @@ export const useSpatialMediaStream = (
return () => {
tile.removeEventListener("transitionrun", updatePosition);
source.disconnect();
gainNode.disconnect();
pannerNode.disconnect();
};
}
}, [stream, spatialAudio, audioContext, audioDestination, mute]);
}, [stream, spatialAudio, audioContext, audioDestination, mute, localVolume]);
return [tileRef, mediaRef];
};