mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 12:17:24 +00:00
wip add an audio gauge in the input menu
This commit is contained in:
@@ -729,6 +729,7 @@ export const Join = ({
|
||||
<SelectDevice
|
||||
kind="audioinput"
|
||||
id={audioDeviceId}
|
||||
track={audioTrack}
|
||||
onSubmit={async (id) => {
|
||||
try {
|
||||
saveAudioInputDeviceId(id)
|
||||
|
||||
+13
-2
@@ -1,8 +1,12 @@
|
||||
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 { RiArrowUpSLine } from '@remixicon/react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { LocalAudioTrack, Track } from 'livekit-client'
|
||||
|
||||
import { ToggleDevice } from './ToggleDevice'
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -51,6 +55,12 @@ export const AudioDevicesControl = ({
|
||||
...props,
|
||||
})
|
||||
|
||||
const { microphoneTrack } = useLocalParticipant()
|
||||
const localAudioTrack =
|
||||
microphoneTrack?.track instanceof LocalAudioTrack
|
||||
? microphoneTrack.track
|
||||
: undefined
|
||||
|
||||
const kind = 'audioinput'
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||
@@ -111,6 +121,7 @@ export const AudioDevicesControl = ({
|
||||
context="room"
|
||||
kind={kind}
|
||||
id={audioDeviceId}
|
||||
track={localAudioTrack}
|
||||
onSubmit={saveAudioInputDeviceId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createAudioAnalyser,
|
||||
LocalAudioTrack,
|
||||
TrackEvent,
|
||||
} from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
/**
|
||||
* Boost factor applied to the raw analyser volume so that normal speech
|
||||
* animates the indicator visibly (Google Meet style sensitivity).
|
||||
*/
|
||||
const LEVEL_BOOST = 1.2
|
||||
|
||||
/** How fast the gauge falls back down between words (per frame). */
|
||||
const DECAY_PER_FRAME = 0.04
|
||||
|
||||
type AudioLevelGaugeProps = {
|
||||
track?: LocalAudioTrack
|
||||
variant?: 'light' | 'dark'
|
||||
}
|
||||
|
||||
export const AudioLevelGauge = ({
|
||||
track,
|
||||
variant = 'light',
|
||||
}: AudioLevelGaugeProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
const fillRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!track || isMuted || !track.mediaStreamTrack) return
|
||||
|
||||
let rafId: number
|
||||
let smoothed = 0
|
||||
let calculateVolume: (() => number) | undefined
|
||||
let cleanupAnalyser: (() => Promise<void>) | undefined
|
||||
|
||||
const setupAnalyser = () => {
|
||||
cleanupAnalyser?.()
|
||||
try {
|
||||
const analyser = createAudioAnalyser(track, {
|
||||
fftSize: 256,
|
||||
smoothingTimeConstant: 0.7,
|
||||
})
|
||||
calculateVolume = analyser.calculateVolume
|
||||
cleanupAnalyser = analyser.cleanup
|
||||
} catch (e) {
|
||||
console.error('Failed to create audio analyser', e)
|
||||
}
|
||||
}
|
||||
|
||||
setupAnalyser()
|
||||
|
||||
// Re-bind the analyser when the underlying MediaStreamTrack is replaced
|
||||
// (e.g. after switching to another input device).
|
||||
track.on(TrackEvent.Restarted, setupAnalyser)
|
||||
|
||||
const update = () => {
|
||||
const volume = calculateVolume?.() ?? 0
|
||||
// Fast attack, slow release, like Google Meet.
|
||||
smoothed =
|
||||
volume > smoothed ? volume : Math.max(0, smoothed - DECAY_PER_FRAME)
|
||||
const level = Math.min(1, smoothed * LEVEL_BOOST)
|
||||
if (fillRef.current) {
|
||||
fillRef.current.style.transform = `scaleX(${level})`
|
||||
}
|
||||
rafId = requestAnimationFrame(update)
|
||||
}
|
||||
rafId = requestAnimationFrame(update)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId)
|
||||
track.off(TrackEvent.Restarted, setupAnalyser)
|
||||
cleanupAnalyser?.()
|
||||
}
|
||||
}, [track, isMuted])
|
||||
|
||||
const showMutedHint = !track || isMuted
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
marginTop: '0.25rem',
|
||||
borderTop: '1px solid',
|
||||
minHeight: '2.5rem',
|
||||
})}
|
||||
style={{
|
||||
borderColor: variant === 'dark' ? 'rgba(255 255 255 / 0.2)' : '#e0e0e0',
|
||||
color: variant === 'dark' ? 'rgba(255 255 255 / 0.7)' : '#5f6368',
|
||||
}}
|
||||
>
|
||||
{showMutedHint ? (
|
||||
<>
|
||||
<RiMicOffLine size={18} aria-hidden="true" />
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{t('audioinput.muteTest')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiMicLine size={18} aria-hidden="true" />
|
||||
<div
|
||||
role="img"
|
||||
aria-label={t('audioinput.level')}
|
||||
className={css({
|
||||
flexGrow: 1,
|
||||
height: '0.375rem',
|
||||
borderRadius: '0.1875rem',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
style={{
|
||||
backgroundColor:
|
||||
variant === 'dark' ? 'rgba(255 255 255 / 0.25)' : '#e0e0e0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={fillRef}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 'inherit',
|
||||
transformOrigin: 'left center',
|
||||
transform: 'scaleX(0)',
|
||||
transition: 'transform 0.06s linear',
|
||||
})}
|
||||
style={{
|
||||
backgroundColor: variant === 'dark' ? '#CACAFB' : '#6A6AF4',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { Select, SelectProps } from '@/primitives/Select'
|
||||
import type { Placement } from '@react-types/overlays'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
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 }>
|
||||
|
||||
@@ -18,6 +20,7 @@ type SelectDeviceProps = {
|
||||
onSubmit?: (id: string) => void
|
||||
kind: MediaDeviceKind
|
||||
context?: 'join' | 'room'
|
||||
track?: LocalAudioTrack
|
||||
}
|
||||
|
||||
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||
@@ -28,6 +31,7 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
kind,
|
||||
onSubmit,
|
||||
iconComponent,
|
||||
track,
|
||||
...props
|
||||
}: SelectDevicePermissionsProps<T>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
@@ -74,6 +78,11 @@ const SelectDevicePermissions = <T extends string | number>({
|
||||
await setActiveMediaDevice(key as string)
|
||||
onSubmit?.(key as string)
|
||||
}}
|
||||
menuFooter={
|
||||
kind === 'audioinput' ? (
|
||||
<AudioLevelGauge track={track} variant={props.variant} />
|
||||
) : undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -84,6 +93,7 @@ export const SelectDevice = ({
|
||||
onSubmit,
|
||||
kind,
|
||||
context = 'join',
|
||||
track,
|
||||
}: SelectDeviceProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
|
||||
@@ -116,6 +126,7 @@ export const SelectDevice = ({
|
||||
id={id}
|
||||
onSubmit={onSubmit}
|
||||
kind={kind}
|
||||
track={track}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Mikrofon auswählen",
|
||||
"level": "Mikrofon-Eingangspegel",
|
||||
"muteTest": "Aktivieren Sie Ihr Mikrofon, um es zu testen",
|
||||
"permissionsNeeded": "Mikrofon auswählen – Berechtigung erforderlich",
|
||||
"disable": "Mikrofon deaktivieren",
|
||||
"enable": "Mikrofon aktivieren",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Select microphone",
|
||||
"level": "Microphone input level",
|
||||
"muteTest": "Turn on your microphone to test it",
|
||||
"permissionsNeeded": "Select microphone - permission needed",
|
||||
"disable": "Disable microphone",
|
||||
"enable": "Enable microphone",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"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",
|
||||
"disable": "Désactiver le micro",
|
||||
"enable": "Activer le micro",
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Selecteer microfoon",
|
||||
"level": "Microfoon-ingangsniveau",
|
||||
"muteTest": "Zet je microfoon aan om deze te testen",
|
||||
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||
"disable": "Microfoon dempen",
|
||||
"enable": "Microfoon dempen opheffen",
|
||||
|
||||
@@ -99,6 +99,7 @@ export type SelectProps<T> = Omit<
|
||||
errors?: ReactNode
|
||||
placement?: Placement
|
||||
variant?: 'light' | 'dark'
|
||||
menuFooter?: ReactNode
|
||||
}
|
||||
|
||||
export const Select = <T extends string | number>({
|
||||
@@ -108,6 +109,7 @@ export const Select = <T extends string | number>({
|
||||
errors,
|
||||
placement,
|
||||
variant = 'light',
|
||||
menuFooter,
|
||||
...props
|
||||
}: SelectProps<T>) => {
|
||||
const IconComponent = iconComponent
|
||||
@@ -155,6 +157,7 @@ export const Select = <T extends string | number>({
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
{menuFooter}
|
||||
</Box>
|
||||
</StyledPopover>
|
||||
{errors}
|
||||
|
||||
Reference in New Issue
Block a user