Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add audio output level controls #831

Closed
wants to merge 32 commits into from
Closed
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a59fc73
feat(client): keep track of audio output level in call state and for …
MartinCupela Jul 20, 2023
6735037
feat(react-bindings): add useDefaultAudioOutputLevel hook
MartinCupela Jul 20, 2023
748ae42
feat(react-sdk): enable setting audio output volume to Audio component
MartinCupela Jul 20, 2023
4b0db50
feat(react-sdk): enable toggling master audio output add AudioOutputS…
MartinCupela Jul 20, 2023
f965240
feat(react-sdk): add AudioOutputLevelSlider to ParticipantActionsCont…
MartinCupela Jul 20, 2023
db315cd
feat(styling): add speaker-off icon
MartinCupela Jul 20, 2023
36636a8
feat(styling): add AudioLevelSlider styles
MartinCupela Jul 20, 2023
e0fc04a
feat(react-dogfood): keep showing indicator for local audio input eve…
MartinCupela Jul 20, 2023
8b0d0e6
feat(react-dogfood): add AudioOutputMenu with AudioLevelSlider to Tog…
MartinCupela Jul 20, 2023
bb41135
feat(react-dogfood): add AudioOutputMenu with AudioLevelSlider to Tog…
MartinCupela Jul 20, 2023
1bbbc06
Merge branch 'main' into feat/client/audio-output-level
MartinCupela Jul 20, 2023
c21b9a5
fix(client): validate the default audio output level value before it …
MartinCupela Jul 21, 2023
7128898
refactor(client): pass optionally sessionId to setAudioOutputLevel in…
MartinCupela Jul 21, 2023
a555204
refactor(client): do not update default audio output level once in ac…
MartinCupela Jul 21, 2023
4b581c8
refactor(react-sdk): use local participants audio output level as a m…
MartinCupela Jul 21, 2023
a9a382f
Merge branch 'main' into feat/client/audio-output-level
MartinCupela Jul 21, 2023
e78b263
refactor(client): do not update all participants audio output level i…
MartinCupela Jul 21, 2023
875608d
refactor(react-sdk): do not use local participant's audio output leve…
MartinCupela Jul 21, 2023
b904fec
refactor: rename default audio output level to master audio output level
MartinCupela Jul 21, 2023
49cc7d3
feat(react-sdk): add toggle functionality to AudioOutputLevelSlider
MartinCupela Jul 21, 2023
2fdb4bb
docs: apply suggestions to code documentation
MartinCupela Jul 21, 2023
e1fa904
fix(react-sdk): adjust only master audio output with ToggleAudioOutpu…
MartinCupela Jul 21, 2023
02ef9e8
docs(react-sdk): document the control of audio output level
MartinCupela Jul 21, 2023
4840f21
docs(react-native): document the control of audio output level
MartinCupela Jul 21, 2023
b70f7b7
Merge remote-tracking branch 'origin/feat/client/audio-output-level' …
MartinCupela Jul 21, 2023
0b676f1
style: fix lint issue
MartinCupela Jul 21, 2023
11ecff2
docs: incorrect property definitions
MartinCupela Jul 24, 2023
17cc269
Merge branch 'main' into feat/client/audio-output-level
MartinCupela Jul 24, 2023
44ca940
feat(react-sdk): add dragging functionality to AudioOutputLevelSlider
MartinCupela Jul 28, 2023
68334f0
Merge branch 'main' into feat/client/audio-output-level
MartinCupela Jul 28, 2023
cfec5d6
Merge branch 'main' into feat/client/audio-output-level
MartinCupela Jul 31, 2023
5381953
fix(react-sdk): prevent removal of event listeners when dragging part…
MartinCupela Aug 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/client/src/Call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1167,6 +1167,27 @@ export class Call {
});
};

/**
* Updates the audio output level for a given sessionId (participant session)
* or for all the participants in an active call
* of the default call audio output level outside the active call scenarion (e.g. lobby).
* @param level
* @param sessionId
*/
setAudioOutputLevel(level: number, sessionId?: string) {
MartinCupela marked this conversation as resolved.
Show resolved Hide resolved
if (level < 0 || level > 1) {
throw new Error(`Audio output level must be in the [0-1] range`);
}

if (sessionId) {
this.state.updateParticipant(sessionId, {
audioOutputLevel: level,
});
} else {
this.state.setMasterAudioOutputLevel(level);
}
}

/**
* Sets the `audioDeviceId` property of the [`localParticipant$`](./StreamVideoClient.md/#readonlystatestore)).
*
Expand Down
34 changes: 33 additions & 1 deletion packages/client/src/store/CallState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export enum CallingState {
* @react You don't have to use this class directly, as we are exposing the state through Hooks.
*/
export class CallState {
/**
* The speaker volume level that is set, if `StreamVideoParticipant.audioOutputLevel` is undefined.
*/
private masterAudioOutputLevelSubject = new BehaviorSubject<number>(1);
/**
* The raw call metadata object, as defined on the backend.
*
Expand Down Expand Up @@ -165,8 +169,13 @@ export class CallState {
*/
private callRecordingListSubject = new BehaviorSubject<CallRecording[]>([]);

// Derived state
/**
* Emits the default audio output level value in form of decimal number in range of 0-1.
* The default value is assigned to each newly joined StreamVideoParticipant's audioOutputLevel property.
MartinCupela marked this conversation as resolved.
Show resolved Hide resolved
*/
masterAudioOutputLevel$: Observable<number>;

// Derived state
/**
* The time the call session actually started.
* Useful for displaying the call duration.
Expand Down Expand Up @@ -303,6 +312,8 @@ export class CallState {
distinctUntilChanged(),
);

this.masterAudioOutputLevel$ =
this.masterAudioOutputLevelSubject.asObservable();
this.startedAt$ = this.startedAtSubject.asObservable();
this.participantCount$ = this.participantCountSubject.asObservable();
this.anonymousParticipantCount$ =
Expand Down Expand Up @@ -473,6 +484,27 @@ export class CallState {
return this.setCurrentValue(this.callingStateSubject, state);
};

/**
* Retrieves the current value of the default audio output level.
*
* @internal
*/
get masterAudioOutputLevel() {
return this.getCurrentValue(this.masterAudioOutputLevel$);
}

/**
* Sets the current value of the default audio output level.
*
* @internal
*/
setMasterAudioOutputLevel = (level: number) => {
if (level < 0 || level > 1) {
throw new Error(`Audio output level must be in the [0-1] range`);
}
return this.setCurrentValue(this.masterAudioOutputLevelSubject, level);
};

/**
* The list of call recordings.
*/
Expand Down
8 changes: 7 additions & 1 deletion packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ export enum DebounceType {

export interface StreamVideoParticipant extends Participant {
/**
* The participant's audio stream, if they are publishing audio and
* The speaker volume in range 0 - 1 set by the participant.
* If not set, then CallState.masterAudioOutputLevel should be reflected.
*/
audioOutputLevel?: number;

/**
* The participant's audio stream, if they are publishing audio, and
* we have subscribed to it.
*/
audioStream?: MediaStream;
Expand Down
11 changes: 11 additions & 0 deletions packages/react-bindings/src/hooks/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,14 @@ export const useCallStartedAt = () => {
const { startedAt$ } = useCallState();
return useObservableValue(startedAt$);
};

/**
* Utility hook providing the default audio output level in a given call
* Should be used to determine audio output level if the call is not joined yet.
*
MartinCupela marked this conversation as resolved.
Show resolved Hide resolved
* @category Call State
*/
export const useMasterAudioOutputLevel = () => {
const { masterAudioOutputLevel$ } = useCallState();
return useObservableValue(masterAudioOutputLevel$);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import clsx from 'clsx';
import {
useCall,
useMasterAudioOutputLevel,
} from '@stream-io/video-react-bindings';
import { MouseEventHandler, useCallback } from 'react';
import { Icon } from '../Icon';
import { StreamVideoParticipant } from '@stream-io/video-client';

export type AudioLevelControlProps = {
participant?: StreamVideoParticipant;
};

export const AudioOutputLevelSlider = ({
participant,
}: AudioLevelControlProps) => {
const call = useCall();
const masterAudioOutputLevel = useMasterAudioOutputLevel();

const audioLevel = participant?.audioOutputLevel ?? masterAudioOutputLevel;

const handleClick: MouseEventHandler = useCallback(
(event) => {
const { width, x } = event.currentTarget.getBoundingClientRect();

const volume = +((event.clientX - x) / width).toFixed(2);
const validatedVolume = volume < 0 ? 0 : volume > 1 ? 1 : volume;
call?.setAudioOutputLevel(validatedVolume, participant?.sessionId);
},
[call, participant],
);

return (
<div
className={clsx('str-video__audio-level-slider', {
'str-video__audio-level-slider--mute': !audioLevel,
})}
>
<span
onClick={() =>
call?.setAudioOutputLevel(
!audioLevel ? masterAudioOutputLevel : 0,
participant?.sessionId,
)
}
>
<Icon icon={audioLevel ? 'speaker' : 'speaker-off'} />
</span>
<div
className="str-video__audio-level-slider__track"
onClick={handleClick}
MartinCupela marked this conversation as resolved.
Show resolved Hide resolved
>
<div
className="str-video__audio-level-slider__level"
style={{ transform: `scaleX(${audioLevel})` }}
/>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { CompositeButton, IconButton } from '../Button';
import { DeviceSelectorAudioOutput } from '../DeviceSettings';
import { useI18n } from '@stream-io/video-react-bindings';
import {
useCall,
useMasterAudioOutputLevel,
useI18n,
useLocalParticipant,
} from '@stream-io/video-react-bindings';
import { ComponentType } from 'react';

export type ToggleAudioOutputButtonProps = {
Expand All @@ -11,12 +16,20 @@ export type ToggleAudioOutputButtonProps = {
export const ToggleAudioOutputButton = (
props: ToggleAudioOutputButtonProps,
) => {
const call = useCall();
const masterAudioOutputLevel = useMasterAudioOutputLevel();
const localParticipant = useLocalParticipant();
const { t } = useI18n();
const { caption = t('Speakers'), Menu = DeviceSelectorAudioOutput } = props;
const enabled =
(localParticipant?.audioOutputLevel ?? masterAudioOutputLevel) > 0;
MartinCupela marked this conversation as resolved.
Show resolved Hide resolved

return (
<CompositeButton Menu={Menu} active caption={caption}>
<IconButton icon="speaker" />
<CompositeButton Menu={Menu} active={!enabled} caption={caption}>
<IconButton
icon={enabled ? 'speaker' : 'speaker-off'}
onClick={() => call?.setAudioOutputLevel(enabled ? 0 : 1)}
/>
</CompositeButton>
);
};
1 change: 1 addition & 0 deletions packages/react-sdk/src/components/CallControls/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './AudioOutputLevelSlider';
export * from './AcceptCallButton';
export * from './CallControls';
export * from './CallStatsButton';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
SfuModels,
StreamVideoParticipant,
} from '@stream-io/video-client';
import { AudioOutputLevelSlider } from '../CallControls';
import { IconButton } from '../Button';
import {
GenericMenu,
Expand All @@ -25,6 +26,7 @@ import {
} from '../Menu';
import { WithTooltip } from '../Tooltip';
import { Icon } from '../Icon';
import { useMediaDevices } from '../../core';

type CallParticipantListingItemProps = {
/** Participant object be rendered */
Expand Down Expand Up @@ -139,6 +141,7 @@ export const ParticipantActionsContextMenu = ({
document.pictureInPictureElement,
);
const activeCall = useCall();
const { isAudioOutputChangeSupported } = useMediaDevices();

const blockUser = () => {
activeCall?.blockUser(participant.userId);
Expand Down Expand Up @@ -235,7 +238,7 @@ export const ParticipantActionsContextMenu = ({
};

return (
<GenericMenu>
<GenericMenu className="str-video__participant-actions-context-menu">
<GenericMenuButtonItem onClick={toggleParticipantPinnedAt}>
<Icon icon="pin" />
{participant.pinnedAt ? 'Unpin' : 'Pin'}
Expand Down Expand Up @@ -276,7 +279,7 @@ export const ParticipantActionsContextMenu = ({
}
onClick={muteAudio}
>
<Icon icon="no-audio" />
<Icon icon="mic-off" />
Mute audio
</GenericMenuButtonItem>
</Restricted>
Expand Down Expand Up @@ -324,6 +327,9 @@ export const ParticipantActionsContextMenu = ({
Disable screen sharing
</GenericMenuButtonItem>
</Restricted>
{isAudioOutputChangeSupported && (
<AudioOutputLevelSlider participant={participant} />
)}
</GenericMenu>
);
};
14 changes: 12 additions & 2 deletions packages/react-sdk/src/components/Menu/GenericMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { ComponentProps, PropsWithChildren } from 'react';
import clsx from 'clsx';

export const GenericMenu = ({ children }: PropsWithChildren) => {
return <ul className="str-video__generic-menu">{children}</ul>;
export type GenericMenuProps = {
className?: string;
};

export const GenericMenu = ({
className,
children,
}: PropsWithChildren<GenericMenuProps>) => {
return (
<ul className={clsx('str-video__generic-menu', className)}>{children}</ul>
);
};

export const GenericMenuButtonItem = ({
Expand Down
12 changes: 11 additions & 1 deletion packages/react-sdk/src/core/components/Audio/Audio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ export type AudioProps = DetailedHTMLProps<
> &
Pick<StreamVideoParticipant, 'audioStream'> & {
sinkId?: string;
/**
* Value applied to underlying audio element to control the audio output level.
*/
volume?: number;
};

// TODO: rename to BaseAudio
export const Audio = ({ audioStream, sinkId, ...rest }: AudioProps) => {
export const Audio = ({ audioStream, sinkId, volume, ...rest }: AudioProps) => {
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
const $el = audioRef.current;
Expand All @@ -31,10 +35,16 @@ export const Audio = ({ audioStream, sinkId, ...rest }: AudioProps) => {
const $el = audioRef.current;
if (!$el || !sinkId) return;

// HTMLMediaElement neither HTMLAudioElement in Typescript have prop setSinkId
if (($el as any).setSinkId) {
($el as any).setSinkId(sinkId);
}
}, [sinkId]);

useEffect(() => {
if (typeof volume !== 'number' || !audioRef.current) return;
audioRef.current.volume = volume;
}, [audioRef, volume]);

return <audio autoPlay ref={audioRef} {...rest} />;
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import {
useCall,
useMasterAudioOutputLevel,
useLocalParticipant,
useParticipants,
useRemoteParticipants,
Expand Down Expand Up @@ -89,6 +90,7 @@ export const PaginatedGridLayout = ({
// used to render audio elements
const remoteParticipants = useRemoteParticipants();
const localParticipant = useLocalParticipant();
const masterAudioOutputLevel = useMasterAudioOutputLevel();

// only used to render video elements
const participantGroups = useMemo(
Expand Down Expand Up @@ -121,6 +123,7 @@ export const PaginatedGridLayout = ({
sinkId={localParticipant?.audioOutputDeviceId}
key={participant.sessionId}
audioStream={participant.audioStream}
volume={participant?.audioOutputLevel ?? masterAudioOutputLevel}
/>
))}
<div className="str-video__paginated-grid-layout__wrapper">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ import {
StreamVideoLocalParticipant,
StreamVideoParticipant,
} from '@stream-io/video-client';
import {
useMasterAudioOutputLevel,
useLocalParticipant,
} from '@stream-io/video-react-bindings';

import { Audio } from '../Audio';
import { Video, VideoProps } from '../Video';
import { useTrackElementVisibility } from '../../hooks';
import { DefaultParticipantViewUI } from './DefaultParticipantViewUI';
import { applyElementToRef, isComponentType } from '../../../utilities';
import { useLocalParticipant } from '@stream-io/video-react-bindings';

export type ParticipantViewContextValue = Required<
Pick<ParticipantViewProps, 'participant' | 'videoMode'>
Expand Down Expand Up @@ -94,6 +97,7 @@ export const ParticipantView = forwardRef<HTMLDivElement, ParticipantViewProps>(
sessionId,
} = participant;
const localParticipant = useLocalParticipant();
const masterAudioOutputLevel = useMasterAudioOutputLevel();

const hasAudio = publishedTracks.includes(SfuModels.TrackType.AUDIO);
const hasVideo = publishedTracks.includes(SfuModels.TrackType.VIDEO);
Expand Down Expand Up @@ -165,6 +169,7 @@ export const ParticipantView = forwardRef<HTMLDivElement, ParticipantViewProps>(
muted={isLocalParticipant || muteAudio}
sinkId={localParticipant?.audioOutputDeviceId}
audioStream={audioStream}
volume={participant?.audioOutputLevel ?? masterAudioOutputLevel}
/>
<Video
VideoPlaceholder={VideoPlaceholder}
Expand Down
1 change: 1 addition & 0 deletions packages/styling/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
@use 'src/global-theme-variables';
@use 'src/utils';

@import 'src/AudioLevelSlider';
@import 'src/Avatar';
@import 'src/Button';
@import 'src/CallControls';
Expand Down
Loading