mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-12 19:56:53 +00:00
✨(frontend) add a silent-microphone watcher on join and room screens
Introduce a watcher that listens to the microphone stream and detects when it stays silent, which is often a sign of an underlying issue: missing OS permissions, a faulty device, or a hardware lock (e.g. a physical mute switch). Wire the watcher on both the join and room screens, so users get a signal that something is off before it turns into an actual meeting problem.
This commit is contained in:
committed by
aleb_the_flash
parent
e1a28f315d
commit
22ab89994b
@@ -17,6 +17,7 @@ and this project adheres to
|
||||
- ⚗️(frontend) capture console.error in PostHog
|
||||
- 📈(frontend) snapshot media devices on the happy path
|
||||
- 🚸(frontend) guide users when the OS blocks browser media access
|
||||
- ✨(frontend) add a silent-microphone watcher on join and room screens
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -136,6 +136,9 @@ export const captureMediaEvent = async (
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'permissions-denied'
|
||||
| 'silent-mic-detected'
|
||||
| 'silent-mic-analyser-unavailable'
|
||||
| 'silent-mic-recovered'
|
||||
| 'visit-room'
|
||||
| 'connection-event',
|
||||
props: Record<string, unknown>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
||||
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
||||
import { SilentMicDetector } from './SilentMicDetector'
|
||||
|
||||
const styles = {
|
||||
page: css({
|
||||
@@ -460,6 +461,7 @@ export const Join = ({
|
||||
|
||||
return (
|
||||
<Screen footer={false}>
|
||||
<SilentMicDetector track={audioTrack} context="join" />
|
||||
<div className={styles.page}>
|
||||
<div className={styles.previewColumn}>
|
||||
<div className={styles.previewStack}>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { createAudioAnalyser, LocalAudioTrack } from 'livekit-client'
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { reportMicSample, silentMicStore } from '@/stores/silentMic'
|
||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
import { useIsTrackMuted } from '../livekit/hooks/useIsTrackMuted'
|
||||
|
||||
// A live microphone always has a noise floor; only a signal pinned to
|
||||
// zero counts as silent (no audio data flowing at all).
|
||||
const SILENT_VOLUME_EPSILON = 0.0001
|
||||
const TICK_MS = 1_000
|
||||
|
||||
type SilentMicContext = 'join' | 'room'
|
||||
|
||||
const ActiveDetector = ({
|
||||
track,
|
||||
context,
|
||||
}: {
|
||||
track: LocalAudioTrack
|
||||
context: SilentMicContext
|
||||
}) => {
|
||||
const isMuted = useIsTrackMuted(track)
|
||||
|
||||
// The interval reads through refs so state updates never re-arm the
|
||||
// timer or the analyser.
|
||||
const mutedRef = useRef(isMuted)
|
||||
mutedRef.current = isMuted
|
||||
const contextRef = useRef(context)
|
||||
contextRef.current = context
|
||||
|
||||
useEffect(() => {
|
||||
let audioAnalyser: ReturnType<typeof createAudioAnalyser>
|
||||
try {
|
||||
audioAnalyser = createAudioAnalyser(track, {
|
||||
fftSize: 256,
|
||||
smoothingTimeConstant: 0.7,
|
||||
})
|
||||
} catch {
|
||||
void captureMediaEvent('silent-mic-analyser-unavailable', {
|
||||
context: contextRef.current,
|
||||
})
|
||||
return
|
||||
}
|
||||
const { analyser, calculateVolume, cleanup } = audioAnalyser
|
||||
|
||||
const tick = () => {
|
||||
// Zero volume is only evidence of silence when audio data is
|
||||
// actually flowing. Skip the sample when:
|
||||
// - the mic is intentionally muted;
|
||||
// - the tab is backgrounded (suspended AudioContext reads as zero);
|
||||
// - the AudioContext is not running yet — Chrome keeps it
|
||||
// 'suspended' until a user gesture on pages loaded without
|
||||
// activation, and a suspended analyser reports zeros for a
|
||||
// perfectly healthy microphone.
|
||||
if (
|
||||
mutedRef.current ||
|
||||
document.visibilityState !== 'visible' ||
|
||||
analyser.context.state !== 'running'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const result = reportMicSample({
|
||||
trackId: track.mediaStreamTrack?.id,
|
||||
silent: calculateVolume() <= SILENT_VOLUME_EPSILON,
|
||||
deltaMs: TICK_MS,
|
||||
})
|
||||
if (result === 'silent-detected') {
|
||||
void captureMediaEvent('silent-mic-detected', {
|
||||
context: contextRef.current,
|
||||
media_stream_track_muted: track.mediaStreamTrack?.muted ?? null,
|
||||
})
|
||||
} else if (result === 'recovered') {
|
||||
void captureMediaEvent('silent-mic-recovered', {
|
||||
context: contextRef.current,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const interval = window.setInterval(tick, TICK_MS)
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
void cleanup()
|
||||
}
|
||||
}, [track])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot silent-mic check (see stores/silentMic.ts). Renders nothing;
|
||||
* mounts the volume watcher only while the check is still undecided so
|
||||
* the analyser goes away as soon as the outcome is known.
|
||||
*/
|
||||
export const SilentMicDetector = ({
|
||||
track,
|
||||
context,
|
||||
}: {
|
||||
track?: LocalAudioTrack
|
||||
context: SilentMicContext
|
||||
}) => {
|
||||
const { status } = useSnapshot(silentMicStore)
|
||||
if ((status !== 'watching' && status !== 'silent') || !track) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ActiveDetector
|
||||
key={track.mediaStreamTrack?.id}
|
||||
track={track}
|
||||
context={context}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Room-side variant: watches the published local microphone track. */
|
||||
export const RoomSilentMicDetector = () => {
|
||||
const { microphoneTrack } = useLocalParticipant()
|
||||
const track =
|
||||
microphoneTrack?.track instanceof LocalAudioTrack
|
||||
? microphoneTrack.track
|
||||
: undefined
|
||||
return <SilentMicDetector track={track} context="room" />
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import {
|
||||
closeSilentMicDialog,
|
||||
discardSilentMicDetection,
|
||||
silentMicStore,
|
||||
} from '@/stores/silentMic'
|
||||
|
||||
/**
|
||||
* Opened from the "!" badge on the microphone toggle when the silent-mic
|
||||
* check tripped (see stores/silentMic.ts). Explains the likely causes
|
||||
* and lets the user opt out of the detection for good.
|
||||
*/
|
||||
export const SilentMicDialog = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'silentMic.dialog' })
|
||||
const { isDialogOpen } = useSnapshot(silentMicStore)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t('title')}
|
||||
onClose={closeSilentMicDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '500px',
|
||||
})}
|
||||
>
|
||||
<H lvl={1}>{t('title')}</H>
|
||||
<P>{t('intro')}</P>
|
||||
<ul className={css({ listStyle: 'disc', paddingLeft: '24px' })}>
|
||||
<li>{t('causes.system')}</li>
|
||||
<li>{t('causes.hardware')}</li>
|
||||
<li>{t('causes.wrongDevice')}</li>
|
||||
</ul>
|
||||
<P>{t('hint')}</P>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<Button variant="primary" size="sm" onPress={closeSilentMicDialog}>
|
||||
{t('close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onPress={discardSilentMicDetection}
|
||||
>
|
||||
{t('discard')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+2
-24
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LocalAudioTrack, TrackEvent } from 'livekit-client'
|
||||
import { LocalAudioTrack } 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'
|
||||
import { useIsTrackMuted } from '../../../hooks/useIsTrackMuted'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
@@ -77,28 +77,6 @@ type AudioLevelGaugeProps = {
|
||||
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,
|
||||
|
||||
@@ -17,6 +17,8 @@ import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceEr
|
||||
import type { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||
@@ -95,6 +97,12 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||
const silentMicWarning =
|
||||
kind === 'audioinput' &&
|
||||
silentMicStatus === 'silent' &&
|
||||
!cannotUseDevice &&
|
||||
!deviceMissing
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
@@ -178,6 +186,12 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{silentMicWarning && (
|
||||
<PermissionNeededButton
|
||||
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
||||
onPress={openSilentMicDialog}
|
||||
/>
|
||||
)}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
isDisabled={isDisabled}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type LocalAudioTrack, TrackEvent } from 'livekit-client'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export 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
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { StageLayout } from '@/features/layout/components/StageLayout'
|
||||
import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
||||
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -65,6 +66,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<RoomMetadataSynchronizer />
|
||||
<ConnectionObserver />
|
||||
<SyncDevicePreferences />
|
||||
<RoomSilentMicDetector />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<VideoResolutionSubscription />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUser } from '@/features/auth/api/useUser'
|
||||
import { Conference } from '../components/Conference'
|
||||
import { Join } from '../components/Join'
|
||||
import { Permissions } from '../components/Permissions'
|
||||
import { SilentMicDialog } from '../components/SilentMicDialog'
|
||||
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
||||
import {
|
||||
isRoomValid,
|
||||
@@ -21,6 +22,7 @@ const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<UserAware>
|
||||
<Permissions />
|
||||
<SilentMicDialog />
|
||||
{children}
|
||||
</UserAware>
|
||||
)
|
||||
|
||||
@@ -186,6 +186,21 @@
|
||||
},
|
||||
"openSettings": "Systemeinstellungen öffnen"
|
||||
},
|
||||
"silentMic": {
|
||||
"tooltip": "Ihr Mikrofon ist aktiviert, aber es wird kein Ton erkannt. Klicken Sie für weitere Informationen.",
|
||||
"dialog": {
|
||||
"title": "Kein Ton erkannt",
|
||||
"intro": "Ihr Mikrofon ist aktiviert, aber seit einiger Zeit wird kein Ton erkannt. Überprüfen Sie Folgendes:",
|
||||
"causes": {
|
||||
"system": "Der Zugriff auf das Mikrofon wird möglicherweise durch die Datenschutzeinstellungen Ihres Systems blockiert.",
|
||||
"hardware": "Möglicherweise ist ein Stummschalter aktiviert oder das Mikrofon ist nicht angeschlossen oder defekt.",
|
||||
"wrongDevice": "Möglicherweise ist das falsche Mikrofon ausgewählt. Wählen Sie im Mikrofonmenü ein anderes aus."
|
||||
},
|
||||
"hint": "Sprechen Sie erneut, nachdem Sie diese Punkte überprüft haben. Die Warnung verschwindet, sobald ein Ton erkannt wird.",
|
||||
"close": "Verstanden",
|
||||
"discard": "Erkennung eines stummen Mikrofons deaktivieren"
|
||||
}
|
||||
},
|
||||
"permissionsButton": {
|
||||
"tooltip": "Mehr Infos",
|
||||
"ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen"
|
||||
|
||||
@@ -186,6 +186,21 @@
|
||||
},
|
||||
"openSettings": "Open system settings"
|
||||
},
|
||||
"silentMic": {
|
||||
"tooltip": "Your microphone is on, but no sound is being detected. Click to learn more.",
|
||||
"dialog": {
|
||||
"title": "No sound detected",
|
||||
"intro": "Your microphone is on, but we haven't detected any sound for a while. Check the following:",
|
||||
"causes": {
|
||||
"system": "Your system's privacy settings may be blocking microphone access.",
|
||||
"hardware": "A mute switch may be turned on, or your microphone may be disconnected or faulty.",
|
||||
"wrongDevice": "The wrong microphone may be selected. Try choosing another one from the microphone menu."
|
||||
},
|
||||
"hint": "Speak again after checking these settings. This warning will disappear as soon as sound is detected.",
|
||||
"close": "Got it",
|
||||
"discard": "Turn off silent microphone detection"
|
||||
}
|
||||
},
|
||||
"permissionsButton": {
|
||||
"tooltip": "More info",
|
||||
"ariaLabel": "Permissions issue. Show more info"
|
||||
|
||||
@@ -186,6 +186,21 @@
|
||||
},
|
||||
"openSettings": "Ouvrir les réglages système"
|
||||
},
|
||||
"silentMic": {
|
||||
"tooltip": "Votre micro est activé, mais aucun son n'est détecté. Cliquez pour en savoir plus.",
|
||||
"dialog": {
|
||||
"title": "Aucun son détecté",
|
||||
"intro": "Votre micro est activé, mais aucun son n'est détecté depuis un moment. Vérifiez les points suivants :",
|
||||
"causes": {
|
||||
"system": "L'accès au micro est peut-être bloqué dans les réglages de confidentialité de votre système.",
|
||||
"hardware": "Un bouton ou interrupteur coupe peut-être le micro, ou celui-ci est débranché ou défectueux.",
|
||||
"wrongDevice": "Un autre micro est peut-être sélectionné. Essayez d'en choisir un autre dans le menu du micro."
|
||||
},
|
||||
"hint": "Parlez à nouveau après avoir vérifié ces points. L'avertissement disparaîtra dès qu'un son sera détecté.",
|
||||
"close": "Compris",
|
||||
"discard": "Désactiver cette détection"
|
||||
}
|
||||
},
|
||||
"permissionsButton": {
|
||||
"tooltip": "Plus d'infos",
|
||||
"ariaLabel": "Problème de permissions. Afficher plus d'infos"
|
||||
|
||||
@@ -186,6 +186,21 @@
|
||||
},
|
||||
"openSettings": "Systeeminstellingen openen"
|
||||
},
|
||||
"silentMic": {
|
||||
"tooltip": "Je microfoon staat aan, maar er wordt geen geluid gedetecteerd. Klik voor meer informatie.",
|
||||
"dialog": {
|
||||
"title": "Geen geluid gedetecteerd",
|
||||
"intro": "Je microfoon staat aan, maar er is al een tijdje geen geluid gedetecteerd. Controleer het volgende:",
|
||||
"causes": {
|
||||
"system": "De privacyinstellingen van je systeem blokkeren mogelijk de toegang tot je microfoon.",
|
||||
"hardware": "Mogelijk staat er een mute-schakelaar aan, of is je microfoon niet aangesloten of defect.",
|
||||
"wrongDevice": "Mogelijk is de verkeerde microfoon geselecteerd. Kies een andere microfoon in het microfoonmenu."
|
||||
},
|
||||
"hint": "Spreek opnieuw nadat je dit hebt gecontroleerd. De waarschuwing verdwijnt zodra er geluid wordt gedetecteerd.",
|
||||
"close": "Begrepen",
|
||||
"discard": "Detectie van een stille microfoon uitschakelen"
|
||||
}
|
||||
},
|
||||
"permissionsButton": {
|
||||
"tooltip": "Meer info",
|
||||
"ariaLabel": "Probleem met machtigingen. Meer info weergeven"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const DISCARD_STORAGE_KEY = 'silent-mic-detection-discarded'
|
||||
|
||||
/**
|
||||
* One-shot UX check: a live microphone always produces at least a noise
|
||||
* floor, so a mic that stays pinned to zero for a whole minute is not a
|
||||
* quiet room — it is an OS-level permission block, a hardware mute
|
||||
* switch, or a dead device. The check runs on the join screen and in
|
||||
* the room until it settles:
|
||||
*
|
||||
* watching → passed first non-zero sample (check is over, no UI)
|
||||
* watching → silent 45s of accumulated zero signal (warning badge)
|
||||
* silent → passed sound finally arrives (warning auto-clears)
|
||||
* any → discarded user opts out (persisted on this browser)
|
||||
*/
|
||||
export type SilentMicStatus = 'watching' | 'silent' | 'passed' | 'discarded'
|
||||
|
||||
const isDiscardPersisted = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(DISCARD_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const silentMicStore = proxy({
|
||||
status: (isDiscardPersisted() ? 'discarded' : 'watching') as SilentMicStatus,
|
||||
isDialogOpen: false,
|
||||
})
|
||||
|
||||
const SILENT_DURATION_MS = 10_000
|
||||
|
||||
// Accumulation is module state, not store state: it changes every tick
|
||||
// and must survive the join → room transition without re-rendering
|
||||
// anything.
|
||||
let silentMs = 0
|
||||
let watchedTrackId: string | undefined
|
||||
|
||||
export type SilentMicSampleResult = 'silent-detected' | 'recovered' | null
|
||||
|
||||
/**
|
||||
* Feed one detection sample. Returns the transition it caused, so the
|
||||
* caller can attach telemetry with its own context.
|
||||
*/
|
||||
export const reportMicSample = ({
|
||||
trackId,
|
||||
silent,
|
||||
deltaMs,
|
||||
}: {
|
||||
trackId?: string
|
||||
silent: boolean
|
||||
deltaMs: number
|
||||
}): SilentMicSampleResult => {
|
||||
const { status } = silentMicStore
|
||||
if (status === 'discarded' || status === 'passed') return null
|
||||
|
||||
if (trackId !== watchedTrackId) {
|
||||
// New capture (device switch, re-acquire): fresh window.
|
||||
watchedTrackId = trackId
|
||||
silentMs = 0
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
silentMs = 0
|
||||
silentMicStore.status = 'passed'
|
||||
silentMicStore.isDialogOpen = false
|
||||
return status === 'silent' ? 'recovered' : null
|
||||
}
|
||||
|
||||
if (status !== 'watching') return null
|
||||
silentMs += deltaMs
|
||||
if (silentMs >= SILENT_DURATION_MS) {
|
||||
silentMicStore.status = 'silent'
|
||||
return 'silent-detected'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const openSilentMicDialog = () => {
|
||||
silentMicStore.isDialogOpen = true
|
||||
}
|
||||
|
||||
export const closeSilentMicDialog = () => {
|
||||
silentMicStore.isDialogOpen = false
|
||||
}
|
||||
|
||||
export const discardSilentMicDetection = () => {
|
||||
silentMicStore.status = 'discarded'
|
||||
silentMicStore.isDialogOpen = false
|
||||
try {
|
||||
localStorage.setItem(DISCARD_STORAGE_KEY, '1')
|
||||
} catch {
|
||||
/* private mode: the opt-out just won't persist */
|
||||
}
|
||||
captureEvent('silent-mic-discarded')
|
||||
}
|
||||
Reference in New Issue
Block a user