mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-04 14:45:40 +00:00
✨(frontend) add an audio gauge to the microphone select menu
Add an audio level gauge next to the selected microphone in the mic select menu, so users can see at a glance whether their microphone is actually picking up sound. Inspired by Google Meet's mic picker, and requested by users.
This commit is contained in:
committed by
aleb_the_flash
parent
751d029ac9
commit
b780d2845a
@@ -11,6 +11,7 @@ and this project adheres to
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 📈(frontend) capture media diagnostics on media errors
|
- 📈(frontend) capture media diagnostics on media errors
|
||||||
|
- ✨(frontend) add an audio gauge to the microphone select menu
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -729,6 +729,7 @@ export const Join = ({
|
|||||||
<SelectDevice
|
<SelectDevice
|
||||||
kind="audioinput"
|
kind="audioinput"
|
||||||
id={audioDeviceId}
|
id={audioDeviceId}
|
||||||
|
track={audioTrack}
|
||||||
onSubmit={async (id) => {
|
onSubmit={async (id) => {
|
||||||
try {
|
try {
|
||||||
saveAudioInputDeviceId(id)
|
saveAudioInputDeviceId(id)
|
||||||
|
|||||||
+13
-2
@@ -1,8 +1,12 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
import {
|
||||||
|
useLocalParticipant,
|
||||||
|
useTrackToggle,
|
||||||
|
UseTrackToggleProps,
|
||||||
|
} from '@livekit/components-react'
|
||||||
import { Button, Popover } from '@/primitives'
|
import { Button, Popover } from '@/primitives'
|
||||||
import { RiArrowUpSLine } from '@remixicon/react'
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
import { Track } from 'livekit-client'
|
import { LocalAudioTrack, Track } from 'livekit-client'
|
||||||
|
|
||||||
import { ToggleDevice } from './ToggleDevice'
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
@@ -51,6 +55,12 @@ export const AudioDevicesControl = ({
|
|||||||
...props,
|
...props,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { microphoneTrack } = useLocalParticipant()
|
||||||
|
const localAudioTrack =
|
||||||
|
microphoneTrack?.track instanceof LocalAudioTrack
|
||||||
|
? microphoneTrack.track
|
||||||
|
: undefined
|
||||||
|
|
||||||
const kind = 'audioinput'
|
const kind = 'audioinput'
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||||
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
|
|||||||
context="room"
|
context="room"
|
||||||
kind={kind}
|
kind={kind}
|
||||||
id={audioDeviceId}
|
id={audioDeviceId}
|
||||||
|
track={localAudioTrack}
|
||||||
onSubmit={saveAudioInputDeviceId}
|
onSubmit={saveAudioInputDeviceId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { LocalAudioTrack, TrackEvent } from 'livekit-client'
|
||||||
|
import { useTrackVolume } from '@livekit/components-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
const StyledContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.75rem',
|
||||||
|
padding: '0.75rem 0.25rem',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
borderTop: '1px solid',
|
||||||
|
minHeight: '2.5rem',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
theme: {
|
||||||
|
light: {
|
||||||
|
borderColor: 'gray.200',
|
||||||
|
color: 'greyscale.600',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
borderColor: 'primaryDark.300',
|
||||||
|
color: 'rgba(255 255 255 / 0.7)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledGaugeContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
flexGrow: 1,
|
||||||
|
height: '0.375rem',
|
||||||
|
borderRadius: '0.1875rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
theme: {
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'greyscale.250',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'rgba(255 255 255 / 0.25)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const StyledGauge = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
transformOrigin: 'left center',
|
||||||
|
transform: 'scaleX(0)',
|
||||||
|
transition: 'transform 0.06s linear',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
theme: {
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'primary.500',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'primaryDark.800',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
type AudioLevelGaugeProps = {
|
||||||
|
track?: LocalAudioTrack
|
||||||
|
variant?: Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
const useIsTrackMuted = (track?: LocalAudioTrack) => {
|
||||||
|
const [isMuted, setIsMuted] = useState(() => track?.isMuted ?? true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!track) {
|
||||||
|
setIsMuted(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setIsMuted(track.isMuted)
|
||||||
|
const onMuted = () => setIsMuted(true)
|
||||||
|
const onUnmuted = () => setIsMuted(false)
|
||||||
|
track.on(TrackEvent.Muted, onMuted)
|
||||||
|
track.on(TrackEvent.Unmuted, onUnmuted)
|
||||||
|
return () => {
|
||||||
|
track.off(TrackEvent.Muted, onMuted)
|
||||||
|
track.off(TrackEvent.Unmuted, onUnmuted)
|
||||||
|
}
|
||||||
|
}, [track])
|
||||||
|
|
||||||
|
return isMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
const LevelBar = ({
|
||||||
|
track,
|
||||||
|
theme,
|
||||||
|
}: {
|
||||||
|
track: LocalAudioTrack
|
||||||
|
theme: Theme
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
const volume = useTrackVolume(track, {
|
||||||
|
fftSize: 256,
|
||||||
|
smoothingTimeConstant: 0.7,
|
||||||
|
})
|
||||||
|
const level = Math.min(1, volume)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RiMicLine size={18} aria-hidden="true" />
|
||||||
|
<StyledGaugeContainer
|
||||||
|
theme={theme}
|
||||||
|
role="img"
|
||||||
|
aria-label={t('audioinput.level')}
|
||||||
|
>
|
||||||
|
<StyledGauge theme={theme} style={{ transform: `scaleX(${level})` }} />
|
||||||
|
</StyledGaugeContainer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AudioLevelGauge = ({
|
||||||
|
track,
|
||||||
|
variant = 'light',
|
||||||
|
}: AudioLevelGaugeProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
const isMuted = useIsTrackMuted(track)
|
||||||
|
const showMutedHint = !track || isMuted
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer theme={variant}>
|
||||||
|
{showMutedHint ? (
|
||||||
|
<>
|
||||||
|
<RiMicOffLine size={18} aria-hidden="true" />
|
||||||
|
<Text variant="bodyXsMedium">{t('audioinput.muteTest')}</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<LevelBar
|
||||||
|
key={track.mediaStreamTrack?.id}
|
||||||
|
track={track}
|
||||||
|
theme={variant}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { Select, SelectProps } from '@/primitives/Select'
|
|||||||
import type { Placement } from '@react-types/overlays'
|
import type { Placement } from '@react-types/overlays'
|
||||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
||||||
|
import type { LocalAudioTrack } from 'livekit-client'
|
||||||
|
import { AudioLevelGauge } from './AudioLevelGauge'
|
||||||
|
|
||||||
type DeviceItems = Array<{ value: string; label: string }>
|
type DeviceItems = Array<{ value: string; label: string }>
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ type SelectDeviceProps = {
|
|||||||
onSubmit?: (id: string) => void
|
onSubmit?: (id: string) => void
|
||||||
kind: MediaDeviceKind
|
kind: MediaDeviceKind
|
||||||
context?: 'join' | 'room'
|
context?: 'join' | 'room'
|
||||||
|
track?: LocalAudioTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||||
@@ -28,6 +31,7 @@ const SelectDevicePermissions = <T extends string | number>({
|
|||||||
kind,
|
kind,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
iconComponent,
|
iconComponent,
|
||||||
|
track,
|
||||||
...props
|
...props
|
||||||
}: SelectDevicePermissionsProps<T>) => {
|
}: SelectDevicePermissionsProps<T>) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
@@ -74,6 +78,11 @@ const SelectDevicePermissions = <T extends string | number>({
|
|||||||
await setActiveMediaDevice(key as string)
|
await setActiveMediaDevice(key as string)
|
||||||
onSubmit?.(key as string)
|
onSubmit?.(key as string)
|
||||||
}}
|
}}
|
||||||
|
menuFooter={
|
||||||
|
kind === 'audioinput' ? (
|
||||||
|
<AudioLevelGauge track={track} variant={props.variant} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -84,6 +93,7 @@ export const SelectDevice = ({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
kind,
|
kind,
|
||||||
context = 'join',
|
context = 'join',
|
||||||
|
track,
|
||||||
}: SelectDeviceProps) => {
|
}: SelectDeviceProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
@@ -116,6 +126,7 @@ export const SelectDevice = ({
|
|||||||
id={id}
|
id={id}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
|
track={track}
|
||||||
iconComponent={deviceIcons.select}
|
iconComponent={deviceIcons.select}
|
||||||
{...contextProps}
|
{...contextProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Mikrofon auswählen",
|
"choose": "Mikrofon auswählen",
|
||||||
|
"level": "Mikrofon-Eingangspegel",
|
||||||
|
"muteTest": "Aktivieren Sie Ihr Mikrofon, um es zu testen",
|
||||||
"permissionsNeeded": "Mikrofon auswählen – Berechtigung erforderlich",
|
"permissionsNeeded": "Mikrofon auswählen – Berechtigung erforderlich",
|
||||||
"disable": "Mikrofon deaktivieren",
|
"disable": "Mikrofon deaktivieren",
|
||||||
"enable": "Mikrofon aktivieren",
|
"enable": "Mikrofon aktivieren",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Select microphone",
|
"choose": "Select microphone",
|
||||||
|
"level": "Microphone input level",
|
||||||
|
"muteTest": "Turn on your microphone to test it",
|
||||||
"permissionsNeeded": "Select microphone - permission needed",
|
"permissionsNeeded": "Select microphone - permission needed",
|
||||||
"disable": "Disable microphone",
|
"disable": "Disable microphone",
|
||||||
"enable": "Enable microphone",
|
"enable": "Enable microphone",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Choisir le micro",
|
"choose": "Choisir le micro",
|
||||||
|
"level": "Niveau d'entrée du micro",
|
||||||
|
"muteTest": "Activez votre micro pour le tester",
|
||||||
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
|
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
|
||||||
"disable": "Désactiver le micro",
|
"disable": "Désactiver le micro",
|
||||||
"enable": "Activer le micro",
|
"enable": "Activer le micro",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Selecteer microfoon",
|
"choose": "Selecteer microfoon",
|
||||||
|
"level": "Microfoon-ingangsniveau",
|
||||||
|
"muteTest": "Zet je microfoon aan om deze te testen",
|
||||||
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||||
"disable": "Microfoon dempen",
|
"disable": "Microfoon dempen",
|
||||||
"enable": "Microfoon dempen opheffen",
|
"enable": "Microfoon dempen opheffen",
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export type SelectProps<T> = Omit<
|
|||||||
errors?: ReactNode
|
errors?: ReactNode
|
||||||
placement?: Placement
|
placement?: Placement
|
||||||
variant?: 'light' | 'dark'
|
variant?: 'light' | 'dark'
|
||||||
|
menuFooter?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Select = <T extends string | number>({
|
export const Select = <T extends string | number>({
|
||||||
@@ -108,56 +109,62 @@ export const Select = <T extends string | number>({
|
|||||||
errors,
|
errors,
|
||||||
placement,
|
placement,
|
||||||
variant = 'light',
|
variant = 'light',
|
||||||
|
menuFooter,
|
||||||
...props
|
...props
|
||||||
}: SelectProps<T>) => {
|
}: SelectProps<T>) => {
|
||||||
const IconComponent = iconComponent
|
const IconComponent = iconComponent
|
||||||
const { t } = useTranslation('global')
|
const { t } = useTranslation('global')
|
||||||
return (
|
return (
|
||||||
<RACSelect {...props}>
|
<RACSelect {...props}>
|
||||||
{label}
|
{({ isOpen }) => (
|
||||||
<StyledButton variant={variant}>
|
<>
|
||||||
{!!IconComponent && (
|
{label}
|
||||||
<StyledIcon>
|
<StyledButton variant={variant}>
|
||||||
<IconComponent size={18} />
|
{!!IconComponent && (
|
||||||
</StyledIcon>
|
<StyledIcon>
|
||||||
)}
|
<IconComponent size={18} />
|
||||||
<StyledSelectValue />
|
</StyledIcon>
|
||||||
<RiArrowDropDownLine
|
)}
|
||||||
aria-hidden="true"
|
<StyledSelectValue />
|
||||||
className={css({ flexShrink: 0 })}
|
<RiArrowDropDownLine
|
||||||
/>
|
aria-hidden="true"
|
||||||
</StyledButton>
|
className={css({ flexShrink: 0 })}
|
||||||
<StyledPopover placement={placement}>
|
/>
|
||||||
<Box size="sm" type="popover" variant={variant}>
|
</StyledButton>
|
||||||
<ListBox>
|
<StyledPopover placement={placement}>
|
||||||
{items.map((item) => (
|
<Box size="sm" type="popover" variant={variant}>
|
||||||
<ListBoxItem
|
<ListBox>
|
||||||
className={
|
{items.map((item) => (
|
||||||
menuRecipe({
|
<ListBoxItem
|
||||||
extraPadding: true,
|
className={
|
||||||
variant: variant,
|
menuRecipe({
|
||||||
}).item
|
extraPadding: true,
|
||||||
}
|
variant: variant,
|
||||||
id={item.value}
|
}).item
|
||||||
key={item.value}
|
}
|
||||||
textValue={
|
id={item.value}
|
||||||
typeof item.label === 'string' ? item.label : undefined
|
key={item.value}
|
||||||
}
|
textValue={
|
||||||
>
|
typeof item.label === 'string' ? item.label : undefined
|
||||||
{({ isSelected }) => (
|
}
|
||||||
<>
|
>
|
||||||
{item.label}
|
{({ isSelected }) => (
|
||||||
{isSelected && (
|
<>
|
||||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
{item.label}
|
||||||
|
{isSelected && (
|
||||||
|
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</ListBoxItem>
|
||||||
)}
|
))}
|
||||||
</ListBoxItem>
|
</ListBox>
|
||||||
))}
|
{isOpen && menuFooter}
|
||||||
</ListBox>
|
</Box>
|
||||||
</Box>
|
</StyledPopover>
|
||||||
</StyledPopover>
|
{errors}
|
||||||
{errors}
|
</>
|
||||||
|
)}
|
||||||
</RACSelect>
|
</RACSelect>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user