mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-14 12:43:54 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18ef94dc10 | |||
| a9be74a5a4 | |||
| dff8c5696e | |||
| db1010ce29 | |||
| ded3e17dcb | |||
| 977abadcc0 | |||
| d559d53deb | |||
| ac5da2a3df | |||
| 7efc6b3fdd | |||
| 1684ef85ec | |||
| 38530facba | |||
| 1fca767855 | |||
| 71ea6421dd | |||
| e93bc91b24 | |||
| d4df0df027 | |||
| ddcc4062b9 | |||
| c7889c14b5 | |||
| dd4a104e3a | |||
| 7514bff7c1 | |||
| 993efec027 | |||
| 46a4955fe0 | |||
| b01a46b345 | |||
| 45e1a68fec | |||
| 261ab8c7cf | |||
| 7022becc78 |
@@ -8,51 +8,6 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(frontend) drop unused vendored ConnectionObserver
|
||||
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📈(frontend) downgrade unreachable external home URL from error to event
|
||||
- 🐛(frontend) handle 401 responses when syncing user preferences
|
||||
- 🐛(frontend) harden speaker test against missing sinks and play errors
|
||||
- 🐛(frontend) implement hysteresis band for the control bar layout
|
||||
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
||||
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
|
||||
- 🐛(frontend) stop reporting screen-share denials as errors
|
||||
- 🐛(frontend) generalize screen-share error modal beyond macOS
|
||||
- 📈(frontend) stop double-reporting media device failures
|
||||
|
||||
## [1.26.0] - 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(frontend) capture media diagnostics on media errors
|
||||
- ✨(frontend) add an audio gauge to the microphone select menu
|
||||
- ✨(frontend) add a sound tester to the output select menu
|
||||
- ✨(frontend) prompt for permissions when toggling a denied device
|
||||
- ⚗️(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
|
||||
|
||||
- ♻️(frontend) encapsulate error tracking behind a telemetry module
|
||||
- ♻️(frontend) encapsulate PostHog capture calls in the telemetry module
|
||||
- 🔧(frontend) sync persisted device ids with the actual selected devices
|
||||
- 💄(frontend) hide the ProConnect button on narrow viewports
|
||||
- ♻️(frontend) prefer captureMediaEvent over reportError when no-op
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) drop exact deviceId constraint on dynamic track creation
|
||||
- 🐛(frontend) fix permission store regression
|
||||
- 🐛(frontend) handle missing device errors gracefully
|
||||
- 🐛(frontend) display the meeting id in the join screen page title
|
||||
|
||||
## [1.25.2] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.26.0"
|
||||
version = "1.25.2"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.26.0"
|
||||
version = "1.25.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.26.0"
|
||||
version = "1.25.2"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.26.0"
|
||||
version = "1.25.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -3,30 +3,27 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
|
||||
export const SoundTester = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
|
||||
const { devices, activeDeviceId } = useMediaDeviceSelect({
|
||||
kind: 'audiooutput',
|
||||
})
|
||||
const { activeDeviceId } = useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!canTestAudioOutput() || !activeDeviceId) return
|
||||
if (!devices.some((device) => device.deviceId === activeDeviceId)) return
|
||||
audioRef.current?.setSinkId(activeDeviceId).catch((error) => {
|
||||
if (error instanceof DOMException && error.name === 'NotFoundError') {
|
||||
return
|
||||
const updateActiveId = async (deviceId: string) => {
|
||||
try {
|
||||
await audioRef?.current?.setSinkId(deviceId)
|
||||
} catch (error) {
|
||||
reportError(
|
||||
'device_switch_failure',
|
||||
new Error(`Error setting sinkId: ${error}`)
|
||||
)
|
||||
}
|
||||
reportError(
|
||||
'device_switch_failure',
|
||||
new Error(`Error setting sinkId: ${error}`)
|
||||
)
|
||||
})
|
||||
}, [devices, activeDeviceId])
|
||||
}
|
||||
updateActiveId(activeDeviceId)
|
||||
}, [activeDeviceId])
|
||||
|
||||
// prevent pausing the sound
|
||||
navigator.mediaSession.setActionHandler('pause', function () {})
|
||||
@@ -35,13 +32,9 @@ export const SoundTester = () => {
|
||||
<>
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
onPress={async () => {
|
||||
try {
|
||||
await audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
} catch {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
onPress={() => {
|
||||
audioRef?.current?.play()
|
||||
setIsPlaying(true)
|
||||
}}
|
||||
size="sm"
|
||||
isDisabled={isPlaying}
|
||||
@@ -55,7 +48,7 @@ export const SoundTester = () => {
|
||||
{/* eslint-disable jsx-a11y/media-has-caption */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/sounds/uprise.mp3"
|
||||
src="sounds/uprise.mp3"
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { CaptureResult } from 'posthog-js'
|
||||
|
||||
const IGNORED_EXCEPTION_PATTERNS = [
|
||||
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
||||
]
|
||||
|
||||
const shouldIgnoreException = (value: unknown): boolean =>
|
||||
typeof value === 'string' &&
|
||||
IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
|
||||
|
||||
export const filterExceptions = (
|
||||
event: CaptureResult | null
|
||||
): CaptureResult | null => {
|
||||
if (event?.event !== '$exception') return event
|
||||
|
||||
const exceptionList = event.properties?.['$exception_list']
|
||||
const values: unknown[] = Array.isArray(exceptionList)
|
||||
? exceptionList.map((exception) => exception?.value)
|
||||
: []
|
||||
|
||||
values.push(event.properties?.['$exception_message'])
|
||||
|
||||
return values.some(shouldIgnoreException) ? null : event
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { getPosthog } from '../utils'
|
||||
import { filterExceptions } from '../exceptionFilters'
|
||||
|
||||
export const startAnalyticsSession = (data: ApiUser) => {
|
||||
getPosthog().then((ph) => {
|
||||
@@ -31,6 +31,7 @@ export const useAnalytics = ({
|
||||
flags_api_host,
|
||||
isDisabled,
|
||||
}: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,14 +42,6 @@ export const useAnalytics = ({
|
||||
api_host: host,
|
||||
flags_api_host: flags_api_host,
|
||||
person_profiles: 'always',
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: true,
|
||||
capture_exceptions: {
|
||||
capture_unhandled_errors: true,
|
||||
capture_unhandled_rejections: true,
|
||||
capture_console_errors: true,
|
||||
},
|
||||
before_send: filterExceptions,
|
||||
})
|
||||
})
|
||||
}, [id, host, flags_api_host, isDisabled])
|
||||
@@ -58,5 +51,12 @@ export const useAnalytics = ({
|
||||
startAnalyticsSession(user)
|
||||
}, [user])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
getPosthog().then((ph) => {
|
||||
ph.capture('$pageview')
|
||||
})
|
||||
}, [location])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -19,11 +19,9 @@ export const captureEvent = (
|
||||
export type LogCode =
|
||||
// media
|
||||
| 'join_preview_failure'
|
||||
| 'room_media_failure'
|
||||
| 'livekit_room_error'
|
||||
| 'device_switch_failure'
|
||||
| 'permission_poll_failure'
|
||||
| 'media_devices_error_event'
|
||||
// non-media families
|
||||
| 'participant_mute_api_failure'
|
||||
| 'permissions_api_failure'
|
||||
@@ -135,15 +133,7 @@ export const captureMediaEvent = async (
|
||||
| 'media-device-error'
|
||||
| 'media-acquisition'
|
||||
| 'media-device-topology'
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'permissions-denied'
|
||||
| 'screen-share-permission-denied'
|
||||
| 'silent-mic-detected'
|
||||
| 'silent-mic-analyser-unavailable'
|
||||
| 'silent-mic-recovered'
|
||||
| 'visit-room'
|
||||
| 'connection-event',
|
||||
| 'media-device-success',
|
||||
props: Record<string, unknown>
|
||||
) => {
|
||||
captureEvent(event, { ...props, ...(await deviceSnapshot()) })
|
||||
|
||||
@@ -6,8 +6,6 @@ import { queryClient } from '@/api/queryClient'
|
||||
import { updateUserPreferences } from './updateUserPreferences'
|
||||
import { convertToBackendLanguage } from '@/utils/languages'
|
||||
import { useUser } from './useUser'
|
||||
import { ApiError } from '@/api/ApiError.ts'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
/**
|
||||
* Hook that synchronizes user browser preferences (language, timezone) with backend user settings.
|
||||
@@ -44,11 +42,6 @@ export const useSyncUserPreferencesWithBackend = () => {
|
||||
}
|
||||
}
|
||||
|
||||
syncBrowserPreferencesToBackend().catch((error) => {
|
||||
if (error instanceof ApiError && error.statusCode === 401) return
|
||||
reportError('generic_failure', error, {
|
||||
context: '[useSyncUserPreferencesWithBackend] Failed to sync:',
|
||||
})
|
||||
})
|
||||
syncBrowserPreferencesToBackend()
|
||||
}, [i18n.language, isLoggedIn, user, mutateAsync])
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChatRow } from '@/stores/chat'
|
||||
import React, { useMemo } from 'react'
|
||||
import { formatChatMessageLinks } from '../utils'
|
||||
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { tokenize, createDefaultGrammar } from '@livekit/components-core'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const defaultGrammar = Object.freeze(createDefaultGrammar())
|
||||
|
||||
export function formatChatMessageLinks(message: string): ReactNode {
|
||||
const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
|
||||
return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
|
||||
if (typeof tok === `string`) {
|
||||
return tok
|
||||
} else {
|
||||
const content = tok.content.toString()
|
||||
const href =
|
||||
tok.type === `url`
|
||||
? /^http(s?):\/\//.test(content)
|
||||
? content
|
||||
: `https://${content}`
|
||||
: `mailto:${content}`
|
||||
return (
|
||||
<a
|
||||
className="lk-chat-link"
|
||||
key={i}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { LoadingScreen } from '@/components/LoadingScreen'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -161,10 +161,8 @@ const Home = () => {
|
||||
window.location.replace(data.external_home_url)
|
||||
} catch (error) {
|
||||
setRedirectFailed(true)
|
||||
captureEvent('external-home-unreachable', {
|
||||
error_name: error instanceof Error ? error.name : 'Unknown',
|
||||
error_message:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
reportError('generic_failure', error, {
|
||||
context: 'Site is not reachable:',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { NotificationType } from '@/features/notifications/NotificationType
|
||||
// fixme - handle dynamic audio output changes
|
||||
export const useNotificationSound = () => {
|
||||
const notificationsSnap = useSnapshot(notificationsStore)
|
||||
const [play] = useSound('/sounds/notifications.mp3', {
|
||||
const [play] = useSound('./sounds/notifications.mp3', {
|
||||
sprite: {
|
||||
participantJoined: [0, 1150],
|
||||
handRaised: [1400, 180],
|
||||
|
||||
@@ -61,20 +61,14 @@ export const usePictureInPicture = () => {
|
||||
if (!IS_PIP_SUPPORTED) return null
|
||||
if (isOpen) return null
|
||||
|
||||
let pipWindow: Window
|
||||
try {
|
||||
pipWindow =
|
||||
const pipWindow =
|
||||
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).documentPictureInPicture.requestWindow({
|
||||
width,
|
||||
height,
|
||||
})
|
||||
} catch {
|
||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
initializeTitleAndLanguage(pipWindow, t('title'))
|
||||
initializePortalContainer(pipWindow)
|
||||
syncStyles(pipWindow)
|
||||
@@ -92,10 +86,10 @@ export const usePictureInPicture = () => {
|
||||
})
|
||||
documentPictureInPictureStore.window = ref(pipWindow)
|
||||
} catch (error) {
|
||||
// Avoid unhandled rejections if the user blocks or closes the request.
|
||||
reportError('generic_failure', error, {
|
||||
context: 'pip_init_failure',
|
||||
context: 'Failed to open Picture-in-Picture window',
|
||||
})
|
||||
pipWindow.close()
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
|
||||
backgroundColor: 'primaryDark.100',
|
||||
maxWidth: '100%',
|
||||
opacity: 0,
|
||||
translate: '0 3.25rem',
|
||||
transition: 'opacity, translate',
|
||||
transform: 'translateY(3.25rem)',
|
||||
transition: 'opacity, transform',
|
||||
transitionDuration: '0.5s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pointerEvents: 'none',
|
||||
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
|
||||
isVisible: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
translate: '0 0',
|
||||
transform: 'translateY(0)',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
|
||||
shouldBeCenteredWithToggleButton,
|
||||
setShouldBeCenteredWithToggleButton,
|
||||
] = useState(false)
|
||||
const [offsetX, setOffsetX] = useState(0)
|
||||
const [rightOffset, setRightOffset] = useState(0)
|
||||
|
||||
const updateArrows = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldBeCenteredWithToggleButton || isMobile) {
|
||||
setOffsetX(0)
|
||||
setRightOffset(0)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
|
||||
const containerCenterX = containerRect.left + containerRect.width / 2
|
||||
const shift = toggleCenterX - containerCenterX
|
||||
if (Math.abs(shift) < 0.5) return
|
||||
setOffsetX((prev) => prev + shift)
|
||||
setRightOffset((prev) => prev - shift * 2)
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
|
||||
isVisible={isVisible}
|
||||
style={
|
||||
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
|
||||
? { transform: `translateX(${offsetX}px)` }
|
||||
? { marginRight: `${rightOffset}px` }
|
||||
: { margin: '0 15px' }
|
||||
}
|
||||
>
|
||||
|
||||
@@ -25,17 +25,26 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import {
|
||||
captureEvent,
|
||||
reportError,
|
||||
captureMediaEvent,
|
||||
} from '@/features/analytics/telemetry'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
|
||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
import {
|
||||
PERMISSION_BY_DEVICE_KIND,
|
||||
notePermissionDeniedFromGum,
|
||||
} from '@/stores/permissions'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -55,7 +64,7 @@ export const Conference = ({
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
useEffect(() => {
|
||||
void captureMediaEvent('visit-room', { slug: roomId })
|
||||
captureEvent('visit-room', { slug: roomId })
|
||||
}, [roomId])
|
||||
const fetchKey = [keys.room, roomId]
|
||||
|
||||
@@ -168,6 +177,14 @@ export const Conference = ({
|
||||
prepareConnection()
|
||||
}, [room, apiConfig, isConnectionWarmedUp])
|
||||
|
||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}>({
|
||||
error: null,
|
||||
kind: null,
|
||||
})
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const hasAutoMutedRef = useRef(false)
|
||||
@@ -225,10 +242,9 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
const failure = MediaDeviceFailure.getFailure(e)
|
||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||
reportError('livekit_room_error', e, {
|
||||
path: 'connect_publish',
|
||||
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
|
||||
})
|
||||
}}
|
||||
onConnected={async () => {
|
||||
@@ -247,8 +263,23 @@ export const Conference = ({
|
||||
onDisconnected={(e) => {
|
||||
const metadata = {
|
||||
room_id: roomId,
|
||||
pc_publisher: connectionObserverStore.publisher && {
|
||||
...connectionObserverStore.publisher,
|
||||
},
|
||||
pc_subscriber: connectionObserverStore.subscriber && {
|
||||
...connectionObserverStore.subscriber,
|
||||
},
|
||||
pc_publisher_changes_count:
|
||||
connectionObserverStore.publisherChangesCount,
|
||||
pc_subscriber_changes_count:
|
||||
connectionObserverStore.subscriberChangesCount,
|
||||
}
|
||||
|
||||
connectionObserverStore.publisher = null
|
||||
connectionObserverStore.publisherChangesCount = 0
|
||||
connectionObserverStore.subscriber = null
|
||||
connectionObserverStore.subscriberChangesCount = 0
|
||||
|
||||
switch (e) {
|
||||
case DisconnectReason.CLIENT_INITIATED:
|
||||
navigateTo(
|
||||
@@ -274,10 +305,32 @@ export const Conference = ({
|
||||
return
|
||||
}
|
||||
}}
|
||||
onMediaDeviceFailure={(e, kind) => {
|
||||
if (!e || !kind) return
|
||||
void captureMediaEvent('media-device-error', {
|
||||
log_code: 'media_devices_error_event',
|
||||
path: 'connect_publish',
|
||||
failure: e,
|
||||
kind,
|
||||
})
|
||||
switch (e) {
|
||||
case MediaDeviceFailure.DeviceInUse:
|
||||
setMediaDeviceError({ error: e, kind })
|
||||
break
|
||||
case MediaDeviceFailure.PermissionDenied:
|
||||
notePermissionDeniedFromGum(PERMISSION_BY_DEVICE_KIND[kind])
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WatchMediaDeviceErrors />
|
||||
<VideoConference />
|
||||
{!isMobile && <InviteDialog mode={mode} />}
|
||||
<MediaDeviceErrorAlert
|
||||
{...mediaDeviceError}
|
||||
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
||||
/>
|
||||
<PictureInPictureConference />
|
||||
</LiveKitRoom>
|
||||
</Screen>
|
||||
|
||||
@@ -28,9 +28,7 @@ import {
|
||||
userChoicesStore,
|
||||
} from '@/stores/userChoices'
|
||||
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({
|
||||
@@ -53,16 +51,16 @@ const styles = {
|
||||
lg: { height: '540px', flexGrow: 1 },
|
||||
}),
|
||||
previewStack: css({
|
||||
display: 'flex',
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
flexShrink: { base: 0, sm: 1 },
|
||||
}),
|
||||
previewFrame: css({
|
||||
borderRadius: '1rem',
|
||||
minWidth: { base: 0, sm: '320px' },
|
||||
maxWidth: '100%',
|
||||
flex: '0 1',
|
||||
minWidth: '320px',
|
||||
margin: { base: '0.5rem', sm: '1rem', lg: '1rem 0.5rem 1rem 1rem' },
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
@@ -91,6 +89,7 @@ const styles = {
|
||||
previewAspect: css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'fit-content',
|
||||
aspectRatio: '16 / 9',
|
||||
}),
|
||||
previewSurface: css({
|
||||
@@ -193,21 +192,18 @@ const toggleTrack =
|
||||
(enabled: boolean, save: (v: boolean) => void, track?: PreviewTrack) =>
|
||||
async () => {
|
||||
save(!enabled)
|
||||
try {
|
||||
await (enabled ? track?.mute() : track?.unmute())
|
||||
} catch (err) {
|
||||
save(enabled)
|
||||
reportError('join_preview_failure', err as Error, {
|
||||
path: 'join_preview',
|
||||
})
|
||||
if (enabled) {
|
||||
await track?.mute()
|
||||
} else {
|
||||
await track?.unmute()
|
||||
}
|
||||
}
|
||||
|
||||
const switchTrackDevice =
|
||||
(save: (id: string) => void, track?: PreviewTrack) => async (id: string) => {
|
||||
try {
|
||||
await track?.setDeviceId({ exact: id })
|
||||
save(id)
|
||||
await track?.setDeviceId({ exact: id })
|
||||
} catch (err) {
|
||||
reportError('join_preview_failure', err as Error, {
|
||||
path: 'join_preview',
|
||||
@@ -216,21 +212,16 @@ const switchTrackDevice =
|
||||
}
|
||||
|
||||
function getPreviewMessages({
|
||||
cameraFound,
|
||||
cameraDenied,
|
||||
micDenied,
|
||||
videoEnabled,
|
||||
videoStarted,
|
||||
}: {
|
||||
cameraFound: boolean
|
||||
cameraDenied: boolean
|
||||
micDenied: boolean
|
||||
videoEnabled: boolean
|
||||
videoStarted: boolean
|
||||
}): { hint: string | null; permissionsButtonLabel: string | null } {
|
||||
if (!cameraFound) {
|
||||
return { hint: 'cameraNotFound', permissionsButtonLabel: null }
|
||||
}
|
||||
if (cameraDenied) {
|
||||
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
||||
return { hint: key, permissionsButtonLabel: key }
|
||||
@@ -266,7 +257,7 @@ function useAttachedVideo(
|
||||
element.addEventListener('loadedmetadata', handleLoaded)
|
||||
|
||||
return () => {
|
||||
videoTrack.detach(element)
|
||||
videoTrack.detach()
|
||||
element.removeEventListener('loadedmetadata', handleLoaded)
|
||||
element.style.opacity = '0'
|
||||
setVideoStarted(false)
|
||||
@@ -327,20 +318,16 @@ const VideoPreview = ({
|
||||
|
||||
const cameraDenied = useCannotUseDevice('videoinput')
|
||||
const micDenied = useCannotUseDevice('audioinput')
|
||||
const cameraMissing = useDeviceMissing('videoinput')
|
||||
|
||||
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
||||
|
||||
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
||||
cameraFound: !cameraMissing,
|
||||
cameraDenied,
|
||||
micDenied,
|
||||
videoEnabled,
|
||||
videoStarted,
|
||||
})
|
||||
|
||||
const isError = cameraMissing || cameraDenied
|
||||
|
||||
return (
|
||||
<div className={styles.previewFrame}>
|
||||
<div className={styles.gradientTop} />
|
||||
@@ -369,13 +356,8 @@ const VideoPreview = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.hintOverlay}>
|
||||
<p role="alert" className={styles.hintText}>
|
||||
{isError && hint ? t(hint) : null}
|
||||
</p>
|
||||
<p role="status" className={styles.hintText}>
|
||||
{!isError && hint ? t(hint) : null}
|
||||
</p>
|
||||
<div role="alert" className={styles.hintOverlay}>
|
||||
<p className={styles.hintText}>{hint && t(hint)}</p>
|
||||
{permissionsButtonLabel && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -468,7 +450,6 @@ export const Join = ({
|
||||
|
||||
return (
|
||||
<Screen footer={false}>
|
||||
<SilentMicDetector track={audioTrack} context="join" />
|
||||
<div className={styles.page}>
|
||||
<div className={styles.previewColumn}>
|
||||
<div className={styles.previewStack}>
|
||||
|
||||
@@ -149,7 +149,6 @@ export const Lobby = ({
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
aria-label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username || user?.full_name}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
|
||||
@@ -1,114 +1,13 @@
|
||||
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, Dialog, H, P } from '@/primitives'
|
||||
import { Dialog, H } from '@/primitives'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
closePermissionsDialog,
|
||||
closeSystemPermissionsDialog,
|
||||
permissionsStore,
|
||||
} from '@/stores/permissions'
|
||||
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { injectIconIntoTranslation } from '@/utils/translation'
|
||||
import { isSafari } from '@/utils/livekit'
|
||||
import { type OS, getOS } from '@/utils/os'
|
||||
|
||||
type StepsOs = 'macos' | 'windows' | 'android' | 'other'
|
||||
|
||||
const STEPS_OS: Record<OS, StepsOs> = {
|
||||
macos: 'macos',
|
||||
windows: 'windows',
|
||||
android: 'android',
|
||||
linux: 'other',
|
||||
other: 'other',
|
||||
}
|
||||
|
||||
const getSystemSettingsUrl = (os: OS, label: string): string | null => {
|
||||
if (os === 'macos') {
|
||||
if (label === 'camera')
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'
|
||||
if (label === 'microphone')
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone'
|
||||
return 'x-apple.systempreferences:com.apple.preference.security?Privacy'
|
||||
}
|
||||
if (os === 'windows') {
|
||||
if (label === 'camera') return 'ms-settings:privacy-webcam'
|
||||
if (label === 'microphone') return 'ms-settings:privacy-microphone'
|
||||
return 'ms-settings:privacy'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SystemPermissions = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'systemPermissionDialog' })
|
||||
const permissions = useSnapshot(permissionsStore)
|
||||
const os = useMemo(() => getOS() || 'other', [])
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (permissions.microphoneSystemDenied && permissions.cameraSystemDenied) {
|
||||
return 'cameraAndMicrophone'
|
||||
}
|
||||
if (permissions.cameraSystemDenied) return 'camera'
|
||||
return 'microphone'
|
||||
}, [permissions])
|
||||
|
||||
const isOpen = permissions.isSystemPermissionDialogOpen
|
||||
|
||||
// Auto-close once access works again (the user fixed the OS settings).
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOpen &&
|
||||
!permissions.microphoneSystemDenied &&
|
||||
!permissions.cameraSystemDenied
|
||||
) {
|
||||
closeSystemPermissionsDialog()
|
||||
}
|
||||
}, [isOpen, permissions])
|
||||
|
||||
const device = t(`device.${label}`)
|
||||
const settingsUrl = getSystemSettingsUrl(os, label)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${label}`)}
|
||||
onClose={closeSystemPermissionsDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '500px',
|
||||
})}
|
||||
>
|
||||
<H lvl={1}>{t(`heading.${label}`)}</H>
|
||||
<P>{t('intro', { device })}</P>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<li key={index}>
|
||||
{t(`steps.${STEPS_OS[os] || 'other'}.${index + 1}`, { device })}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{settingsUrl && (
|
||||
<div className={css({ marginTop: '2rem' })}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onPress={() => {
|
||||
window.open(settingsUrl, '_blank')
|
||||
}}
|
||||
>
|
||||
{t('openSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton component - ensures permissions sync runs only once across the app.
|
||||
@@ -166,74 +65,68 @@ export const Permissions = () => {
|
||||
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemPermissions />
|
||||
<Dialog
|
||||
isOpen={permissions.isPermissionDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
<Dialog
|
||||
isOpen={permissions.isPermissionDialogOpen}
|
||||
role="dialog"
|
||||
type="flex"
|
||||
title=""
|
||||
aria-label={t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
onClose={closePermissionsDialog}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
})}
|
||||
onClose={closePermissionsDialog}
|
||||
>
|
||||
<img
|
||||
src="/assets/camera_mic_permission.svg"
|
||||
alt=""
|
||||
className={css({
|
||||
width: '100%',
|
||||
minHeight: '290px',
|
||||
maxWidth: '290px',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
md: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
maxWidth: '400px',
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src="/assets/camera_mic_permission.svg"
|
||||
alt=""
|
||||
className={css({
|
||||
width: '100%',
|
||||
minHeight: '290px',
|
||||
maxWidth: '290px',
|
||||
<H lvl={2}>
|
||||
{t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '400px',
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>
|
||||
{t(`heading.${permissionLabel}`, {
|
||||
appTitle,
|
||||
})}
|
||||
</H>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
<li>
|
||||
{isSafari() ? (
|
||||
t('body.openMenu.safari', {
|
||||
appDomain: window.origin.replace('https://', ''),
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{descriptionBeforeIcon}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line />
|
||||
</span>
|
||||
{descriptionAfterIcon}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</H>
|
||||
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||
<li>
|
||||
{isSafari() ? (
|
||||
t('body.openMenu.safari', {
|
||||
appDomain: window.origin.replace('https://', ''),
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{descriptionBeforeIcon}
|
||||
<span
|
||||
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||
>
|
||||
<RiEqualizer2Line />
|
||||
</span>
|
||||
{descriptionAfterIcon}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { CandidateInfo } from '@/stores/connectionObserver'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const Card = styled('div', {
|
||||
@@ -239,6 +240,10 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
|
||||
type RatingMetadata = {
|
||||
room_id?: string
|
||||
pc_publisher?: CandidateInfo
|
||||
pc_subscriber?: CandidateInfo
|
||||
pc_publisher_changes_count?: number
|
||||
pc_subscriber_changes_count?: number
|
||||
}
|
||||
|
||||
export const Rating = ({
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
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" />
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||
import { useWatchMediaDeviceErrors } from '../livekit/hooks/useWatchMediaDeviceErrors'
|
||||
|
||||
/**
|
||||
* Single place responsible for the room's media device errors — mounts the
|
||||
* watcher and renders the resulting user-facing alert.
|
||||
*/
|
||||
export const WatchMediaDeviceErrors = () => {
|
||||
const { error, kind, clear } = useWatchMediaDeviceErrors()
|
||||
return <MediaDeviceErrorAlert error={error} kind={kind} onClose={clear} />
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||
|
||||
export function useWatchDeviceAvailability() {
|
||||
useEffect(() => {
|
||||
if (!navigator.mediaDevices) return
|
||||
syncDeviceAvailability()
|
||||
navigator.mediaDevices.addEventListener(
|
||||
'devicechange',
|
||||
syncDeviceAvailability
|
||||
)
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
'devicechange',
|
||||
syncDeviceAvailability
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -9,16 +9,28 @@ import { useSnapshot } from 'valtio'
|
||||
import { DisconnectReason, RoomEvent } from 'livekit-client'
|
||||
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
|
||||
import { captureEvent, captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
|
||||
import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { isMobileBrowser } from '@livekit/components-core'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
const CANDIDATE_POLL_INTERVAL_MS = 5000
|
||||
|
||||
export const ConnectionObserver = () => {
|
||||
const room = useRoomContext()
|
||||
const connectionStartTimeRef = useRef<number | null>(null)
|
||||
|
||||
const { data } = useConfig()
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
|
||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling)
|
||||
const isMobile = isMobileBrowser()
|
||||
|
||||
const isAdvancedConnectionObserverEnabled =
|
||||
!isMobile && isAnalyticsEnabled && featureEnabled
|
||||
|
||||
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
||||
|
||||
const idleDisconnectModalTimeoutRef = useRef<ReturnType<
|
||||
@@ -68,6 +80,100 @@ export const ConnectionObserver = () => {
|
||||
userPreferencesSnap.is_idle_disconnect_modal_enabled,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdvancedConnectionObserverEnabled) return
|
||||
if (!room) return
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const pollCandidate = async (
|
||||
label: 'publisher' | 'subscriber',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pc?: any
|
||||
) => {
|
||||
if (!pc) return
|
||||
|
||||
let stats: RTCStatsReport
|
||||
try {
|
||||
stats = await pc.getStats()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
stats.forEach((report: any) => {
|
||||
if (
|
||||
report.type === 'candidate-pair' &&
|
||||
report.state === 'succeeded' &&
|
||||
report.nominated
|
||||
) {
|
||||
const remoteCandidate = stats.get(report.remoteCandidateId)
|
||||
if (!remoteCandidate) return
|
||||
|
||||
const next = {
|
||||
type: remoteCandidate.candidateType,
|
||||
address: remoteCandidate.address,
|
||||
protocol: remoteCandidate.protocol,
|
||||
}
|
||||
|
||||
const current = connectionObserverStore[label]
|
||||
|
||||
const hasChanged =
|
||||
current?.type !== next.type ||
|
||||
current?.address !== next.address ||
|
||||
current?.protocol !== next.protocol
|
||||
|
||||
if (hasChanged) {
|
||||
connectionObserverStore[label] = next
|
||||
const key = `${label}ChangesCount` as const
|
||||
connectionObserverStore[key] =
|
||||
(connectionObserverStore[key] || 0) + 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
const publisher = room.engine?.pcManager?.publisher
|
||||
const subscriber = room.engine?.pcManager?.subscriber
|
||||
|
||||
await Promise.all([
|
||||
pollCandidate('publisher', publisher),
|
||||
pollCandidate('subscriber', subscriber),
|
||||
])
|
||||
}
|
||||
|
||||
const startPolling = async () => {
|
||||
if (interval) return // prevent duplicates
|
||||
|
||||
// Initial snapshot
|
||||
await poll()
|
||||
|
||||
interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (!interval) return
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
|
||||
room.on(RoomEvent.Connected, startPolling)
|
||||
room.on(RoomEvent.Reconnected, startPolling)
|
||||
|
||||
room.on(RoomEvent.Reconnecting, stopPolling)
|
||||
room.on(RoomEvent.Disconnected, stopPolling)
|
||||
|
||||
return () => {
|
||||
stopPolling()
|
||||
|
||||
room.off(RoomEvent.Connected, startPolling)
|
||||
room.off(RoomEvent.Reconnected, startPolling)
|
||||
room.off(RoomEvent.Reconnecting, stopPolling)
|
||||
room.off(RoomEvent.Disconnected, stopPolling)
|
||||
}
|
||||
}, [room, isAdvancedConnectionObserverEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAnalyticsEnabled) return
|
||||
|
||||
@@ -76,7 +182,7 @@ export const ConnectionObserver = () => {
|
||||
// total session duration from first connect to final disconnect.
|
||||
if (connectionStartTimeRef.current != null) return
|
||||
connectionStartTimeRef.current = Date.now()
|
||||
void captureMediaEvent('connection-event', {})
|
||||
captureEvent('connection-event')
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { A, Button, Dialog, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { getOS, type OS } from '@/utils/os'
|
||||
|
||||
const SCREEN_CAPTURE_SETTINGS_LINKS: Partial<Record<OS, string>> = {
|
||||
macos:
|
||||
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
|
||||
windows: 'ms-settings:privacy-graphicscaptureprogrammatic',
|
||||
}
|
||||
|
||||
// todo - refactor it into a generic system
|
||||
export const ScreenShareErrorModal = ({
|
||||
@@ -18,8 +11,7 @@ export const ScreenShareErrorModal = ({
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||
const os = getOS()
|
||||
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
|
||||
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -34,16 +26,15 @@ export const ScreenShareErrorModal = ({
|
||||
<>
|
||||
<P>
|
||||
{t('message')}{' '}
|
||||
{settingsHref && (
|
||||
{isMac && (
|
||||
<>
|
||||
{t('settingsInstructions')}{' '}
|
||||
{t('macInstructions')}{' '}
|
||||
<A
|
||||
href={settingsHref}
|
||||
target="_blank"
|
||||
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
||||
color="primary"
|
||||
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
|
||||
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
||||
>
|
||||
{t(`settingsLabel.${os}`)}
|
||||
{t('macSystemPreferences')}
|
||||
</A>
|
||||
.{' '}
|
||||
</>
|
||||
|
||||
+29
-7
@@ -1,29 +1,29 @@
|
||||
import { LocalAudioTrack } from 'livekit-client'
|
||||
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'
|
||||
import { useIsTrackMuted } from '../../../hooks/useIsTrackMuted'
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.75rem 0.25rem',
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.625rem 0.75rem',
|
||||
marginTop: '0.25rem',
|
||||
borderTop: '1px solid',
|
||||
minHeight: '2.5rem',
|
||||
},
|
||||
variants: {
|
||||
theme: {
|
||||
light: {
|
||||
borderColor: 'gray.200',
|
||||
borderColor: 'greyscale.250',
|
||||
color: 'greyscale.600',
|
||||
},
|
||||
dark: {
|
||||
borderColor: 'primaryDark.300',
|
||||
borderColor: 'rgba(255 255 255 / 0.2)',
|
||||
color: 'rgba(255 255 255 / 0.7)',
|
||||
},
|
||||
},
|
||||
@@ -77,6 +77,28 @@ 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,
|
||||
@@ -118,7 +140,7 @@ export const AudioLevelGauge = ({
|
||||
{showMutedHint ? (
|
||||
<>
|
||||
<RiMicOffLine size={18} aria-hidden="true" />
|
||||
<Text variant="bodyXsMedium">{t('audioinput.muteTest')}</Text>
|
||||
<Text variant="sm">{t('audioinput.muteTest')}</Text>
|
||||
</>
|
||||
) : (
|
||||
<LevelBar
|
||||
|
||||
+16
-61
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiVolumeUpLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
|
||||
|
||||
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
|
||||
@@ -22,7 +23,6 @@ const StyledContainer = styled('div', {
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
paddingTop: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
borderTop: '1px solid',
|
||||
},
|
||||
variants: {
|
||||
@@ -37,21 +37,6 @@ const StyledContainer = styled('div', {
|
||||
},
|
||||
})
|
||||
|
||||
const StyledButtonContent = styled('span', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 'full',
|
||||
paddingX: '1.625rem',
|
||||
'& > svg': {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type OutputSoundTesterProps = {
|
||||
/** The device the test should play through (the select's current key). */
|
||||
sinkId?: string
|
||||
@@ -66,33 +51,15 @@ export const OutputSoundTester = ({
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
|
||||
const latestSinkIdRef = useRef(sinkId)
|
||||
latestSinkIdRef.current = sinkId
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
}
|
||||
setIsPlaying(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sinkId || !canTestAudioOutput()) return
|
||||
audioRef.current?.setSinkId(sinkId).catch(() => {
|
||||
// Re-routing failed (stale or unplugged device): stop the test rather
|
||||
// than keep playing through the previous sink.
|
||||
if (latestSinkIdRef.current === sinkId) {
|
||||
stopPlayback()
|
||||
}
|
||||
audioRef.current?.setSinkId(sinkId).catch((error) => {
|
||||
reportError('device_switch_failure', error, {
|
||||
kind: 'audiooutput',
|
||||
context: 'test sound setSinkId',
|
||||
})
|
||||
})
|
||||
}, [sinkId, stopPlayback])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
return () => audio?.pause()
|
||||
}, [])
|
||||
}, [sinkId])
|
||||
|
||||
return (
|
||||
<StyledContainer theme={variant}>
|
||||
@@ -101,32 +68,20 @@ export const OutputSoundTester = ({
|
||||
size="sm"
|
||||
fullWidth
|
||||
isDisabled={isPlaying}
|
||||
onPress={async () => {
|
||||
const audio = audioRef.current
|
||||
if (!audio) return
|
||||
try {
|
||||
// Confirm routing before starting: a no-op when already routed,
|
||||
// but rejects on a stale device id, so the test never plays
|
||||
// through the wrong sink.
|
||||
if (sinkId && canTestAudioOutput()) {
|
||||
await audio.setSinkId(sinkId)
|
||||
}
|
||||
await audio.play()
|
||||
setIsPlaying(true)
|
||||
} catch {
|
||||
stopPlayback()
|
||||
}
|
||||
onPress={() => {
|
||||
audioRef.current
|
||||
?.play()
|
||||
.then(() => setIsPlaying(true))
|
||||
.catch(() => {})
|
||||
}}
|
||||
>
|
||||
<StyledButtonContent>
|
||||
<RiVolumeUpLine size={18} aria-hidden />
|
||||
{isPlaying ? t('audiooutput.testing') : t('audiooutput.test')}
|
||||
</StyledButtonContent>
|
||||
<RiVolumeUpLine size={18} aria-hidden />
|
||||
{isPlaying ? t('audiooutput.testing') : t('audiooutput.test')}
|
||||
</Button>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src="/sounds/uprise.mp3"
|
||||
src="sounds/uprise.mp3"
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
</StyledContainer>
|
||||
|
||||
+4
-13
@@ -4,17 +4,8 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type PermissionNeededButtonProps = {
|
||||
tooltip?: string
|
||||
onPress?: () => void
|
||||
}
|
||||
|
||||
export const PermissionNeededButton = ({
|
||||
tooltip,
|
||||
onPress,
|
||||
}: PermissionNeededButtonProps) => {
|
||||
export const PermissionNeededButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||
const label = tooltip ?? t('tooltip')
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -26,9 +17,9 @@ export const PermissionNeededButton = ({
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
aria-label={tooltip ? label : t('ariaLabel')}
|
||||
tooltip={label}
|
||||
onPress={onPress ?? (() => openPermissionsDialog())}
|
||||
aria-label={t('ariaLabel')}
|
||||
tooltip={t('tooltip')}
|
||||
onPress={() => openPermissionsDialog()}
|
||||
variant="permission"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useMemo } from 'react'
|
||||
import { Select, SelectProps } from '@/primitives/Select'
|
||||
import type { Placement } from '@react-types/overlays'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
||||
import type { LocalAudioTrack } from 'livekit-client'
|
||||
import { AudioLevelGauge } from './AudioLevelGauge'
|
||||
@@ -114,25 +113,6 @@ export const SelectDevice = ({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
|
||||
if (deviceMissing) {
|
||||
return (
|
||||
<Select
|
||||
aria-label={t(`NotFound.title.${kind}`, {
|
||||
keyPrefix: 'mediaErrorDialog',
|
||||
})}
|
||||
label=""
|
||||
isDisabled={true}
|
||||
items={[]}
|
||||
placeholder={t(`NotFound.title.${kind}`, {
|
||||
keyPrefix: 'mediaErrorDialog',
|
||||
})}
|
||||
iconComponent={deviceIcons.select}
|
||||
{...contextProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (cannotUseDevice) {
|
||||
return (
|
||||
|
||||
+5
-45
@@ -12,15 +12,10 @@ import {
|
||||
useMaybeRoomContext,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { MediaDeviceFailure } from 'livekit-client'
|
||||
import { MediaDeviceErrorAlert } from '@/features/rooms/components/MediaDeviceErrorAlert'
|
||||
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'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
@@ -96,24 +91,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()
|
||||
|
||||
const isRequestingPermission = useRef(false)
|
||||
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
||||
|
||||
const onPress = async () => {
|
||||
if (!enabled && deviceMissing) {
|
||||
setShowDeviceNotFound(true)
|
||||
return
|
||||
}
|
||||
if (!cannotUseDevice) {
|
||||
toggle()
|
||||
return
|
||||
@@ -121,10 +104,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
if (isRequestingPermission.current) return
|
||||
isRequestingPermission.current = true
|
||||
try {
|
||||
const granted = await requestDevicePermission(
|
||||
kind,
|
||||
context === 'join' ? 'join_preview' : 'room'
|
||||
)
|
||||
const granted = await requestDevicePermission(kind)
|
||||
if (granted) {
|
||||
toggle()
|
||||
} else {
|
||||
@@ -181,20 +161,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{(cannotUseDevice || deviceMissing) && (
|
||||
<PermissionNeededButton
|
||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||
onPress={
|
||||
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{silentMicWarning && (
|
||||
<PermissionNeededButton
|
||||
tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
|
||||
onPress={openSilentMicDialog}
|
||||
/>
|
||||
)}
|
||||
{cannotUseDevice && <PermissionNeededButton />}
|
||||
<ToggleButton
|
||||
isSelected={!enabled}
|
||||
isDisabled={isDisabled}
|
||||
@@ -205,22 +172,15 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
onPress={onPress}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
deviceMissing
|
||||
? t(`deviceNotFound.${kind}`)
|
||||
: cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
<MediaDeviceErrorAlert
|
||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
||||
kind={kind}
|
||||
onClose={() => setShowDeviceNotFound(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
microphoneSystemDenied,
|
||||
cameraSystemDenied,
|
||||
} = useSnapshot(permissionsStore)
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -19,11 +17,9 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
switch (kind) {
|
||||
case 'audioinput':
|
||||
case 'audiooutput': // audiooutput uses microphone permissions
|
||||
return (
|
||||
isMicrophoneDenied || isMicrophonePrompted || microphoneSystemDenied
|
||||
)
|
||||
return isMicrophoneDenied || isMicrophonePrompted
|
||||
case 'videoinput':
|
||||
return isCameraDenied || isCameraPrompted || cameraSystemDenied
|
||||
return isCameraDenied || isCameraPrompted
|
||||
|
||||
default:
|
||||
return false
|
||||
@@ -35,7 +31,5 @@ export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||
isMicrophonePrompted,
|
||||
isCameraDenied,
|
||||
isCameraPrompted,
|
||||
microphoneSystemDenied,
|
||||
cameraSystemDenied,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -59,9 +59,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setIsCopied(true)
|
||||
} catch (error) {
|
||||
reportError('clipboard_failure', error, {
|
||||
context: 'copy_room_content',
|
||||
})
|
||||
reportError('clipboard_failure', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,9 +68,7 @@ export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||
await navigator.clipboard.writeText(roomUrl)
|
||||
setIsRoomUrlCopied(true)
|
||||
} catch (error) {
|
||||
reportError('clipboard_failure', error, {
|
||||
context: 'copy_room_url',
|
||||
})
|
||||
reportError('clipboard_failure', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
||||
import { permissionsStore } from '@/stores/permissions'
|
||||
|
||||
/**
|
||||
* enumerateDevices() may hide OS/app-blocked devices on Firefox Android.
|
||||
* Only report "missing" when no permission block explains the absence,
|
||||
* so the permission UI takes precedence.
|
||||
*/
|
||||
export const useDeviceMissing = (kind: MediaDeviceKind): boolean => {
|
||||
const { hasCamera, hasMicrophone } = useSnapshot(deviceAvailabilityStore)
|
||||
const {
|
||||
cameraSystemDenied,
|
||||
microphoneSystemDenied,
|
||||
isCameraDenied,
|
||||
isMicrophoneDenied,
|
||||
} = useSnapshot(permissionsStore)
|
||||
|
||||
switch (kind) {
|
||||
case 'videoinput':
|
||||
return !hasCamera && !cameraSystemDenied && !isCameraDenied
|
||||
case 'audioinput':
|
||||
return !hasMicrophone && !microphoneSystemDenied && !isMicrophoneDenied
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -10,16 +10,10 @@ import {
|
||||
} from 'livekit-client'
|
||||
import { BackgroundProcessorFactory } from '../components/blur'
|
||||
import {
|
||||
classifyPermissionError,
|
||||
isLikelySystemNotFound,
|
||||
isSystemPermissionError,
|
||||
noteGumSuccess,
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
type PermissionKind,
|
||||
} from '@/stores/permissions'
|
||||
import { getOS } from '@/utils/os'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import {
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
@@ -44,57 +38,13 @@ const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
|
||||
videoinput: 'camera',
|
||||
}
|
||||
|
||||
type MediaPath = 'join_preview' | 'room'
|
||||
|
||||
const onMediaPermissionError = (
|
||||
e: Error,
|
||||
kind?: PermissionKind,
|
||||
path: MediaPath = 'join_preview'
|
||||
) => {
|
||||
export const onJoinPreviewError = (e: Error, kind?: PermissionKind) => {
|
||||
reportError('join_preview_failure', e, { path: 'join_preview' })
|
||||
if (
|
||||
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
|
||||
) {
|
||||
void classifyPermissionError(e, kind).then((scope) => {
|
||||
if (scope === 'system') {
|
||||
noteSystemPermissionDenied(kind)
|
||||
} else {
|
||||
notePermissionDeniedFromGum(kind)
|
||||
}
|
||||
captureMediaEvent('permissions-denied', {
|
||||
path,
|
||||
kind,
|
||||
denied_scope: scope,
|
||||
os: getOS(),
|
||||
})
|
||||
})
|
||||
return
|
||||
notePermissionDeniedFromGum(kind)
|
||||
}
|
||||
|
||||
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
|
||||
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
||||
// settings, missing Android app permissions).
|
||||
void isLikelySystemNotFound(e, kind).then((system) => {
|
||||
if (system) {
|
||||
noteSystemPermissionDenied(kind)
|
||||
captureMediaEvent('permissions-denied', {
|
||||
path,
|
||||
kind,
|
||||
denied_scope: 'system',
|
||||
os: getOS(),
|
||||
})
|
||||
return
|
||||
}
|
||||
captureMediaEvent('device-not-found', { path, kind })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
|
||||
reportError(
|
||||
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||
e,
|
||||
{ path, kind }
|
||||
)
|
||||
}
|
||||
|
||||
// Module-level: effect dependencies, must be referentially stable.
|
||||
@@ -105,8 +55,7 @@ const stopAll = (stream: MediaStream) =>
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
|
||||
export const requestDevicePermission = async (
|
||||
kind: 'audioinput' | 'videoinput',
|
||||
path: MediaPath = 'join_preview'
|
||||
kind: 'audioinput' | 'videoinput'
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const track =
|
||||
@@ -114,32 +63,21 @@ export const requestDevicePermission = async (
|
||||
? await createLocalAudioTrack()
|
||||
: await createLocalVideoTrack()
|
||||
track.stop()
|
||||
noteGumSuccess(PERMISSION_KIND[kind])
|
||||
return true
|
||||
} catch (error) {
|
||||
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
||||
onJoinPreviewError(error as Error, PERMISSION_KIND[kind])
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type WarmupState = {
|
||||
audioReady: boolean
|
||||
videoReady: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests camera and microphone once on mount (one combined call → at
|
||||
* most one browser dialog) and releases them immediately. Readiness is
|
||||
* per kind: track acquisition must wait for its own kind to be settled
|
||||
* to avoid a second dialog, but a microphone that stalls or fails (e.g.
|
||||
* OS-level block on Firefox) must not hold the camera hostage — that is
|
||||
* how LiveKit behaves in-room (independent per-kind acquisitions).
|
||||
* most one browser dialog) and releases them immediately. Returns true
|
||||
* once settled; track acquisition must wait for it to avoid a second
|
||||
* dialog.
|
||||
*/
|
||||
function useWarmupPermissions(): WarmupState {
|
||||
const [state, setState] = useState<WarmupState>({
|
||||
audioReady: false,
|
||||
videoReady: false,
|
||||
})
|
||||
function useWarmupPermissions(): boolean {
|
||||
const [done, setDone] = useState(false)
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -148,8 +86,6 @@ function useWarmupPermissions(): WarmupState {
|
||||
}
|
||||
started.current = true
|
||||
|
||||
const bothReady = () => setState({ audioReady: true, videoReady: true })
|
||||
|
||||
const warmup = async () => {
|
||||
try {
|
||||
stopAll(
|
||||
@@ -158,50 +94,35 @@ function useWarmupPermissions(): WarmupState {
|
||||
video: true,
|
||||
})
|
||||
)
|
||||
noteGumSuccess()
|
||||
bothReady()
|
||||
} catch (error) {
|
||||
if (
|
||||
MediaDeviceFailure.getFailure(error as Error) ===
|
||||
MediaDeviceFailure.PermissionDenied &&
|
||||
!isSystemPermissionError(error)
|
||||
MediaDeviceFailure.PermissionDenied
|
||||
) {
|
||||
// Retrying after a dismissal would show a second dialog.
|
||||
onMediaPermissionError(error as Error)
|
||||
bothReady()
|
||||
onJoinPreviewError(error as Error)
|
||||
return
|
||||
}
|
||||
// Combined requests fail atomically (e.g. missing webcam fails the
|
||||
// mic too, and an OS-level block on one device fails both) — retry
|
||||
// per kind to know which device is affected; permission is settled
|
||||
// at the browser level, no dialog risk. The retries run in parallel
|
||||
// and settle readiness independently.
|
||||
void navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('microphone')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||
.finally(() =>
|
||||
setState((current) => ({ ...current, audioReady: true }))
|
||||
)
|
||||
void navigator.mediaDevices
|
||||
.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('camera')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||
.finally(() =>
|
||||
setState((current) => ({ ...current, videoReady: true }))
|
||||
)
|
||||
// mic too) — retry per kind; permission is settled, no dialog risk.
|
||||
try {
|
||||
stopAll(await navigator.mediaDevices.getUserMedia({ audio: true }))
|
||||
} catch (e) {
|
||||
onJoinPreviewError(e as Error, 'microphone')
|
||||
}
|
||||
try {
|
||||
stopAll(await navigator.mediaDevices.getUserMedia({ video: true }))
|
||||
} catch (e) {
|
||||
onJoinPreviewError(e as Error, 'camera')
|
||||
}
|
||||
} finally {
|
||||
setDone(true)
|
||||
}
|
||||
}
|
||||
warmup()
|
||||
}, [])
|
||||
|
||||
return state
|
||||
return done
|
||||
}
|
||||
|
||||
function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
||||
@@ -227,7 +148,6 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
||||
let cancelled = false
|
||||
create()
|
||||
.then((newTrack) => {
|
||||
noteGumSuccess(permissionKind)
|
||||
if (cancelled) {
|
||||
newTrack.stop()
|
||||
return
|
||||
@@ -235,7 +155,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
||||
setTrack(newTrack)
|
||||
})
|
||||
.catch((error) => {
|
||||
onMediaPermissionError(error as Error, permissionKind)
|
||||
onJoinPreviewError(error as Error, permissionKind)
|
||||
onFailure()
|
||||
})
|
||||
return () => {
|
||||
@@ -289,7 +209,7 @@ export function useJoinTracks(): {
|
||||
processorConfig,
|
||||
} = useSnapshot(userChoicesStore)
|
||||
|
||||
const { audioReady, videoReady } = useWarmupPermissions()
|
||||
const ready = useWarmupPermissions()
|
||||
|
||||
const createAudio = useCallback(
|
||||
() =>
|
||||
@@ -311,7 +231,7 @@ export function useJoinTracks(): {
|
||||
)
|
||||
|
||||
const audioTrack = useLocalTrack({
|
||||
ready: audioReady,
|
||||
ready,
|
||||
enabled: audioEnabled,
|
||||
create: createAudio,
|
||||
permissionKind: 'microphone',
|
||||
@@ -319,7 +239,7 @@ export function useJoinTracks(): {
|
||||
})
|
||||
|
||||
const videoTrack = useLocalTrack({
|
||||
ready: videoReady,
|
||||
ready,
|
||||
enabled: videoEnabled,
|
||||
create: createVideo,
|
||||
permissionKind: 'camera',
|
||||
|
||||
@@ -80,9 +80,12 @@ export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||
try {
|
||||
await raiseHand(!isHandRaised)
|
||||
} catch (e) {
|
||||
reportError('generic_failure', e, {
|
||||
context: 'toggle_raised_hand',
|
||||
})
|
||||
reportError(
|
||||
'generic_failure',
|
||||
new Error(
|
||||
`Failed to toggle hand: ${e instanceof Error ? e.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { useTitle } from 'hoofd'
|
||||
import { useRoomData } from './useRoomData'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
|
||||
|
||||
/**
|
||||
* Updates the browser tab title with the room id to help users easily find
|
||||
* Updates the browser tab title with the room name to help users easily find
|
||||
* the meeting tab among many open tabs. Works on both the join screen and
|
||||
* once connected.
|
||||
*/
|
||||
export const useRoomPageTitle = (roomId?: string) => {
|
||||
export const useRoomPageTitle = () => {
|
||||
const roomData = useRoomData()
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
if (!roomId) return APP_TITLE
|
||||
return `${APP_TITLE} - ${roomId}`
|
||||
}, [roomId])
|
||||
if (!roomData) {
|
||||
return APP_TITLE
|
||||
}
|
||||
|
||||
const roomLabel = roomData.name || roomData.slug || ''
|
||||
|
||||
if (!roomLabel) return APP_TITLE
|
||||
|
||||
return `${APP_TITLE} - ${roomLabel} `
|
||||
}, [roomData])
|
||||
|
||||
useTitle(pageTitle)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@ import { useEffect } from 'react'
|
||||
import { TrackEvent, type LocalTrack } from 'livekit-client'
|
||||
|
||||
/**
|
||||
* Keeps the persisted preference aligned with the track's actual device.
|
||||
* The track is the source of truth, not the store.
|
||||
* Keeps the persisted device preference aligned with the device the track
|
||||
* is ACTUALLY using — the track is the ground truth, not the store.
|
||||
*
|
||||
* Syncs on mount and TrackEvent.Restarted, covering acquisition/restart,
|
||||
* setDeviceId switches, unmute re-acquisition, and browser fallback.
|
||||
*
|
||||
* Skip persisting the raw "default" alias: it means "follow the OS default"
|
||||
* and resolving it would pin the preference to a concrete device.
|
||||
* Syncs on mount and on every TrackEvent.Restarted, which covers all the
|
||||
* moments the underlying device can change on the join screen:
|
||||
* - initial acquisition where LiveKit resolved the 'default' alias
|
||||
* (getDeviceId(normalize=true) resolves it to the concrete id via
|
||||
* livekit's DeviceManager — this replaces the former
|
||||
* useResolveInitiallyDefaultDeviceId, which never fired after init),
|
||||
* - a device switch via setDeviceId,
|
||||
* - an unmute re-acquisition,
|
||||
* - the browser falling back to another device.
|
||||
*/
|
||||
export const useSyncTrackDeviceId = (
|
||||
track: LocalTrack | undefined,
|
||||
@@ -20,9 +24,9 @@ export const useSyncTrackDeviceId = (
|
||||
let cancelled = false
|
||||
const sync = () => {
|
||||
track
|
||||
.getDeviceId(false)
|
||||
.getDeviceId()
|
||||
.then((deviceId) => {
|
||||
if (!cancelled && deviceId) {
|
||||
if (!cancelled && deviceId && deviceId !== 'default') {
|
||||
save(deviceId)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
|
||||
import {
|
||||
PERMISSION_BY_DEVICE_KIND,
|
||||
type PermissionDeniedScope,
|
||||
type PermissionKind,
|
||||
classifyPermissionError,
|
||||
isLikelySystemNotFound,
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
} from '@/stores/permissions'
|
||||
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
import { getOS } from '@/utils/os'
|
||||
|
||||
type MediaDeviceAlert = {
|
||||
error: MediaDeviceFailure | null
|
||||
kind: MediaDeviceKind | null
|
||||
}
|
||||
|
||||
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
||||
|
||||
const capturePermissionsDenied = (
|
||||
scope: PermissionDeniedScope,
|
||||
kind?: PermissionKind
|
||||
) =>
|
||||
captureMediaEvent('permissions-denied', {
|
||||
path: 'connect_publish',
|
||||
kind,
|
||||
denied_scope: scope,
|
||||
os: getOS(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Single owner of the room's media device errors
|
||||
* (RoomEvent.MediaDevicesError): telemetry, the user-facing alert, device
|
||||
* availability refresh, and browser- vs OS-level permission classification.
|
||||
* Listens to the raw room event because onMediaDeviceFailure only exposes
|
||||
* LiveKit's mapped enum, and the raw error is needed to tell a browser-level
|
||||
* denial from an OS-level one (Chromium's "Permission denied by system",
|
||||
* Firefox/macOS's NotFoundError).
|
||||
*/
|
||||
export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
||||
clear: () => void
|
||||
} => {
|
||||
const room = useRoomContext()
|
||||
const [alert, setAlert] = useState<MediaDeviceAlert>(NO_ALERT)
|
||||
|
||||
useEffect(() => {
|
||||
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
||||
const failure = MediaDeviceFailure.getFailure(error)
|
||||
if (!failure) return
|
||||
if (failure != MediaDeviceFailure.Other) {
|
||||
void captureMediaEvent('media-device-error', {
|
||||
log_code: 'media_devices_error_event',
|
||||
path: 'connect_publish',
|
||||
failure,
|
||||
kind: kind ?? 'unknown',
|
||||
})
|
||||
}
|
||||
if (!kind) return
|
||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||
switch (failure) {
|
||||
case MediaDeviceFailure.DeviceInUse:
|
||||
setAlert({ error: failure, kind })
|
||||
break
|
||||
case MediaDeviceFailure.NotFound:
|
||||
void syncDeviceAvailability()
|
||||
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
||||
// settings, missing Android app permissions).
|
||||
void isLikelySystemNotFound(error, permissionKind).then((system) => {
|
||||
if (system) {
|
||||
noteSystemPermissionDenied(permissionKind)
|
||||
void capturePermissionsDenied('system', permissionKind)
|
||||
} else {
|
||||
setAlert({ error: failure, kind })
|
||||
}
|
||||
})
|
||||
break
|
||||
case MediaDeviceFailure.PermissionDenied:
|
||||
void classifyPermissionError(error, permissionKind).then((scope) => {
|
||||
void capturePermissionsDenied(scope, permissionKind)
|
||||
if (scope === 'system') {
|
||||
noteSystemPermissionDenied(permissionKind)
|
||||
} else {
|
||||
notePermissionDeniedFromGum(permissionKind)
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
||||
return () => {
|
||||
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const clear = useCallback(() => setAlert(NO_ALERT), [])
|
||||
|
||||
return { ...alert, clear }
|
||||
}
|
||||
@@ -12,8 +12,7 @@ import type { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const CONTROL_BAR_BREAKPOINT_WIDE = 1100
|
||||
const CONTROL_BAR_BREAKPOINT_NARROW = 1050
|
||||
const CONTROL_BAR_BREAKPOINT = 1100
|
||||
|
||||
const NavigationControls = ({
|
||||
onPress,
|
||||
@@ -66,9 +65,10 @@ export const LateralMenu = () => {
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
interface BreakpointObserverProps {
|
||||
containerRef: RefObject<HTMLDivElement>
|
||||
onWideChange: (isWide: boolean | null) => void
|
||||
onWideChange: (isWide: boolean) => void
|
||||
}
|
||||
|
||||
const BreakpointObserver = ({
|
||||
@@ -76,20 +76,7 @@ const BreakpointObserver = ({
|
||||
onWideChange,
|
||||
}: BreakpointObserverProps) => {
|
||||
const { width } = useSize(containerRef)
|
||||
const [isWide, setIsWide] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!width) {
|
||||
return
|
||||
}
|
||||
if (width > CONTROL_BAR_BREAKPOINT_WIDE) {
|
||||
setIsWide(true)
|
||||
} else if (width <= CONTROL_BAR_BREAKPOINT_NARROW) {
|
||||
setIsWide(false)
|
||||
} else {
|
||||
setIsWide((prev) => (prev === null ? false : prev))
|
||||
}
|
||||
}, [width])
|
||||
const isWide = width > CONTROL_BAR_BREAKPOINT
|
||||
|
||||
useEffect(() => {
|
||||
onWideChange(isWide)
|
||||
@@ -103,7 +90,7 @@ export const MoreOptions = ({
|
||||
}: {
|
||||
parentElement: RefObject<HTMLDivElement>
|
||||
}) => {
|
||||
const [isWide, setIsWide] = useState<boolean | null>(null)
|
||||
const [isWide, setIsWide] = useState(false)
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -120,7 +107,7 @@ export const MoreOptions = ({
|
||||
containerRef={parentElement}
|
||||
onWideChange={setIsWide}
|
||||
/>
|
||||
{isWide !== null && (isWide ? <NavigationControls /> : <LateralMenu />)}
|
||||
{isWide ? <NavigationControls /> : <LateralMenu />}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import { SidePanel } from '../components/SidePanel'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import { getOS } from '@/utils/os'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
@@ -28,7 +27,6 @@ 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
|
||||
@@ -38,20 +36,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
||||
SettingsComponent?: React.ComponentType
|
||||
}
|
||||
|
||||
const getScreenSharePermissionDeniedScope = (
|
||||
error: Error
|
||||
): 'system' | 'user' | 'browser' | null => {
|
||||
if (error.name === 'NotAllowedError') {
|
||||
if (/by system/i.test(error.message)) return 'system'
|
||||
if (/by user/i.test(error.message)) return 'user'
|
||||
return 'browser'
|
||||
}
|
||||
if (error.name === 'NotFoundError' && isFireFox() && getOS() === 'macos') {
|
||||
return 'system'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
|
||||
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
|
||||
@@ -71,46 +55,18 @@ const getScreenSharePermissionDeniedScope = (
|
||||
* @public
|
||||
*/
|
||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
useRoomPageTitle()
|
||||
useNoiseReduction()
|
||||
|
||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
|
||||
const handleDeviceError = ({
|
||||
source,
|
||||
error,
|
||||
}: {
|
||||
source: Track.Source
|
||||
error: Error
|
||||
}) => {
|
||||
if (source === Track.Source.ScreenShare) {
|
||||
const scope = getScreenSharePermissionDeniedScope(error)
|
||||
if (scope) {
|
||||
if (scope === 'system') setIsShareErrorVisible(true)
|
||||
void captureMediaEvent('screen-share-permission-denied', {
|
||||
at: 'ControlBar.onDeviceError',
|
||||
source,
|
||||
error_name: error.name,
|
||||
error_message: error.message,
|
||||
denied_scope: scope,
|
||||
os: getOS(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
reportError('device_switch_failure', error, {
|
||||
at: 'ControlBar.onDeviceError',
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomMetadataSynchronizer />
|
||||
<ConnectionObserver />
|
||||
<SyncDevicePreferences />
|
||||
<RoomSilentMicDetector />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<VideoResolutionSubscription />
|
||||
@@ -136,7 +92,21 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<StageLayout />
|
||||
)}
|
||||
</RoomContentArea>
|
||||
<ControlBar onDeviceError={handleDeviceError} />
|
||||
<ControlBar
|
||||
onDeviceError={(e) => {
|
||||
reportError('device_switch_failure', e.error, {
|
||||
at: 'ControlBar.onDeviceError',
|
||||
source: e.source,
|
||||
})
|
||||
if (
|
||||
e.source == Track.Source.ScreenShare &&
|
||||
e.error.toString() ==
|
||||
'NotAllowedError: Permission denied by system'
|
||||
) {
|
||||
setIsShareErrorVisible(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Rating } from '@/features/rooms/components/Rating.tsx'
|
||||
import { useLocation } from 'wouter'
|
||||
import { useMemo } from 'react'
|
||||
import { DisconnectReason } from 'livekit-client'
|
||||
import type { CandidateInfo } from '@/stores/connectionObserver'
|
||||
|
||||
// fixme - duplicated with home, refactor in a proper style
|
||||
const Heading = styled('h1', {
|
||||
@@ -47,6 +48,10 @@ const FeedbackRoute = () => {
|
||||
const state = window.history.state
|
||||
return {
|
||||
room_id: state?.room_id as string,
|
||||
pc_publisher: state?.pc_publisher as CandidateInfo,
|
||||
pc_publisher_changes_count: state?.pc_publisher_changes_count as number,
|
||||
pc_subscriber: state?.pc_subscriber as CandidateInfo,
|
||||
pc_subscriber_changes_count: state?.pc_subscriber_changes_count as number,
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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,
|
||||
@@ -15,14 +14,11 @@ import {
|
||||
} from '@/features/rooms/utils/isRoomValid'
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
import { LogLevel, setLogLevel } from 'livekit-client'
|
||||
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
||||
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
||||
|
||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<UserAware>
|
||||
<Permissions />
|
||||
<SilentMicDialog />
|
||||
{children}
|
||||
</UserAware>
|
||||
)
|
||||
@@ -40,15 +36,12 @@ const Room = () => {
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
useRoomPageTitle(roomId)
|
||||
|
||||
useEffect(() => {
|
||||
const shouldSilenceLogs = data?.silence_livekit_debug_logs || false
|
||||
setLogLevel(shouldSilenceLogs ? LogLevel.silent : LogLevel.debug)
|
||||
}, [data?.silence_livekit_debug_logs])
|
||||
|
||||
useKeyboardShortcuts()
|
||||
useWatchDeviceAvailability()
|
||||
|
||||
const clearRouterState = () => {
|
||||
if (window?.history?.state) {
|
||||
|
||||
@@ -36,9 +36,12 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
saveUsername(name)
|
||||
onOpenChange?.(false) // only close on success
|
||||
} catch (error) {
|
||||
reportError('generic_failure', error, {
|
||||
context: 'rename_participant',
|
||||
})
|
||||
reportError(
|
||||
'generic_failure',
|
||||
new Error(
|
||||
`Failed to rename participant: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
const handleOnCancel = () => {
|
||||
|
||||
@@ -169,13 +169,7 @@ export const Header = () => {
|
||||
!isTermsOfService &&
|
||||
!loginButtonDisabledByUrl && (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
<LoginButton proConnectHint={false} />
|
||||
</div>
|
||||
<LoginButton proConnectHint={false} />
|
||||
<LoginHint />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"loading": "Laden…",
|
||||
"select": "Wähle einen Wert",
|
||||
"permissionsNeeded": "Berechtigung erforderlich",
|
||||
"deviceNotFound": {
|
||||
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Audioeinstellungen",
|
||||
"video": "Videoeinstellungen"
|
||||
@@ -66,7 +62,6 @@
|
||||
"usernameEmpty": "Dein Name darf nicht leer sein"
|
||||
},
|
||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||
"cameraStarting": "Kamera wird gestartet…",
|
||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||
@@ -122,16 +117,6 @@
|
||||
"videoinput": "Deine Kamera wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du deine Kamera hier verwenden möchtest, schließe den anderen Tab oder die App."
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"title": {
|
||||
"audioinput": "Kein Mikrofon erkannt",
|
||||
"videoinput": "Keine Kamera erkannt"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Auf deinem Gerät wurde kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist und nicht durch einen Schalter stummgeschaltet wird, und versuche es dann erneut.",
|
||||
"videoinput": "Auf deinem Gerät wurde keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist und nicht durch eine Abdeckung oder einen Schalter blockiert wird, und versuche es dann erneut."
|
||||
}
|
||||
},
|
||||
"close": "OK"
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
@@ -154,53 +139,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemPermissionDialog": {
|
||||
"heading": {
|
||||
"camera": "Erlauben Sie Ihrem Browser, Ihre Kamera zu verwenden",
|
||||
"microphone": "Erlauben Sie Ihrem Browser, Ihr Mikrofon zu verwenden",
|
||||
"cameraAndMicrophone": "Erlauben Sie Ihrem Browser, Ihre Kamera und Ihr Mikrofon zu verwenden"
|
||||
},
|
||||
"device": {
|
||||
"camera": "Kamera",
|
||||
"microphone": "Mikrofon",
|
||||
"cameraAndMicrophone": "Kamera und Ihr Mikrofon"
|
||||
},
|
||||
"intro": "Ihr Browser hat bereits die Berechtigung, aber die Systemeinstellungen Ihres Computers blockieren den Zugriff auf Ihr(e) {{device}}.",
|
||||
"steps": {
|
||||
"macos": {
|
||||
"1": "Öffnen Sie in den Systemeinstellungen „Datenschutz & Sicherheit“.",
|
||||
"2": "Erlauben Sie Ihrem Browser den Zugriff auf Ihr(e) {{device}}."
|
||||
},
|
||||
"windows": {
|
||||
"1": "Öffnen Sie in den Windows-Einstellungen „Datenschutz und Sicherheit“.",
|
||||
"2": "Aktivieren Sie den Zugriff auf {{device}} und erlauben Sie Desktop-Apps die Nutzung."
|
||||
},
|
||||
"android": {
|
||||
"1": "Öffnen Sie in den Android-Einstellungen „Apps“ und wählen Sie Ihren Browser aus.",
|
||||
"2": "Erlauben Sie unter „Berechtigungen“ den Zugriff auf Ihr(e) {{device}} und laden Sie die Seite anschließend neu."
|
||||
},
|
||||
"other": {
|
||||
"1": "Erlauben Sie Ihrem Browser in den Datenschutzeinstellungen Ihres Systems den Zugriff auf Ihr(e) {{device}}.",
|
||||
"2": "Versuchen Sie es anschließend erneut."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -214,11 +152,8 @@
|
||||
"title": "Bildschirmfreigabe nicht möglich",
|
||||
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
||||
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
|
||||
"settingsInstructions": "Gehe zu den",
|
||||
"settingsLabel": {
|
||||
"macos": "Systemeinstellungen",
|
||||
"windows": "Windows-Datenschutzeinstellungen"
|
||||
},
|
||||
"macInstructions": "Gehe zu den",
|
||||
"macSystemPreferences": "Systemeinstellungen",
|
||||
"helpLinkText": "Weitere Informationen findest du unter",
|
||||
"helpLinkLabel": "Bildschirmfreigabeproblem",
|
||||
"closeButton": "Schließen",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"loading": "Loading…",
|
||||
"select": "Select a value",
|
||||
"permissionsNeeded": "Permission needed",
|
||||
"deviceNotFound": {
|
||||
"videoinput": "No camera detected. Check that it is properly wired.",
|
||||
"audioinput": "No microphone detected. Check that it is properly wired."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Audio settings",
|
||||
"video": "Video settings"
|
||||
@@ -66,7 +62,6 @@
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
||||
"cameraStarting": "Camera is starting…",
|
||||
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
||||
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
||||
@@ -122,16 +117,6 @@
|
||||
"videoinput": "Your camera is already in use by another tab or application, which prevents it from being activated in this video call. If you wish, close the other tab or application to use it here."
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"title": {
|
||||
"audioinput": "No microphone detected",
|
||||
"videoinput": "No camera detected"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "No microphone was detected on your device. Check that it is properly plugged in and not disabled by a mute switch, then try again.",
|
||||
"videoinput": "No camera was detected on your device. Check that it is properly plugged in and not blocked by a cover or switch, then try again."
|
||||
}
|
||||
},
|
||||
"close": "OK"
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
@@ -154,53 +139,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemPermissionDialog": {
|
||||
"heading": {
|
||||
"camera": "Allow your browser to use your camera",
|
||||
"microphone": "Allow your browser to use your microphone",
|
||||
"cameraAndMicrophone": "Allow your browser to use your camera and microphone"
|
||||
},
|
||||
"device": {
|
||||
"camera": "camera",
|
||||
"microphone": "microphone",
|
||||
"cameraAndMicrophone": "camera and microphone"
|
||||
},
|
||||
"intro": "Your browser already has permission, but your computer's system settings are blocking access to your {{device}}.",
|
||||
"steps": {
|
||||
"macos": {
|
||||
"1": "In System Settings, open “Privacy & Security”.",
|
||||
"2": "Allow your browser to access your {{device}}."
|
||||
},
|
||||
"windows": {
|
||||
"1": "In Windows Settings, open “Privacy & security”.",
|
||||
"2": "Turn on {{device}} access and allow desktop apps to use it."
|
||||
},
|
||||
"android": {
|
||||
"1": "In Android Settings, open “Apps” and select your browser.",
|
||||
"2": "In “Permissions”, allow access to the {{device}}, then reload this page."
|
||||
},
|
||||
"other": {
|
||||
"1": "In your system's privacy settings, allow your browser to access your {{device}}.",
|
||||
"2": "Then try again."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -214,11 +152,8 @@
|
||||
"title": "Unable to share your screen",
|
||||
"ariaLabel": "Unable to share your screen",
|
||||
"message": "Your browser may not be allowed to record the screen on your computer.",
|
||||
"settingsInstructions": "Go to your",
|
||||
"settingsLabel": {
|
||||
"macos": "System Preferences",
|
||||
"windows": "Windows privacy settings"
|
||||
},
|
||||
"macInstructions": "Go to your",
|
||||
"macSystemPreferences": "System Preferences",
|
||||
"helpLinkText": "To learn more, see",
|
||||
"helpLinkLabel": "Presentation issue",
|
||||
"closeButton": "Dismiss",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"loading": "Chargement…",
|
||||
"select": "Sélectionnez une valeur",
|
||||
"permissionsNeeded": "Autorisations nécessaires",
|
||||
"deviceNotFound": {
|
||||
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Paramètres audio",
|
||||
"video": "Paramètres video"
|
||||
@@ -66,7 +62,6 @@
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivée.",
|
||||
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||
"cameraStarting": "La caméra va démarrer…",
|
||||
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
||||
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
||||
@@ -122,16 +117,6 @@
|
||||
"videoinput": "Votre caméra est déjà utilisée dans un autre onglet ou une autre application, ce qui empêche son activation dans cette visioconférence. Si vous le souhaitez, fermez l'autre onglet ou application pour l'utiliser ici."
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"title": {
|
||||
"audioinput": "Aucun microphone détecté",
|
||||
"videoinput": "Aucune caméra détectée"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Aucun microphone n'a été détecté sur votre appareil. Vérifiez qu'il est bien branché et qu'aucun bouton ne le coupe, puis réessayez.",
|
||||
"videoinput": "Aucune caméra n'a été détectée sur votre appareil. Vérifiez qu'elle est bien branchée et qu'aucun cache ou bouton ne la bloque, puis réessayez."
|
||||
}
|
||||
},
|
||||
"close": "OK"
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
@@ -154,53 +139,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemPermissionDialog": {
|
||||
"heading": {
|
||||
"camera": "Autorisez votre navigateur à utiliser votre caméra",
|
||||
"microphone": "Autorisez votre navigateur à utiliser votre micro",
|
||||
"cameraAndMicrophone": "Autorisez votre navigateur à utiliser votre caméra et votre micro"
|
||||
},
|
||||
"device": {
|
||||
"camera": "caméra",
|
||||
"microphone": "micro",
|
||||
"cameraAndMicrophone": "caméra et votre micro"
|
||||
},
|
||||
"intro": "Votre navigateur a déjà l'autorisation, mais les réglages de votre ordinateur bloquent l'accès à votre {{device}}.",
|
||||
"steps": {
|
||||
"macos": {
|
||||
"1": "Dans les réglages Système, ouvrez « Confidentialité et sécurité ».",
|
||||
"2": "Autorisez votre navigateur à accéder à votre {{device}}."
|
||||
},
|
||||
"windows": {
|
||||
"1": "Dans les paramètres Windows, ouvrez « Confidentialité et sécurité ».",
|
||||
"2": "Activez l'accès à votre {{device}} et autorisez les applications de bureau à l'utiliser."
|
||||
},
|
||||
"android": {
|
||||
"1": "Dans les paramètres Android, ouvrez « Applications » puis sélectionnez votre navigateur.",
|
||||
"2": "Dans « Autorisations », autorisez l'accès à votre {{device}}, puis rechargez la page."
|
||||
},
|
||||
"other": {
|
||||
"1": "Dans les paramètres de confidentialité de votre système, autorisez votre navigateur à accéder à votre {{device}}.",
|
||||
"2": "Puis réessayez."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -214,11 +152,8 @@
|
||||
"title": "Impossible de partager votre écran",
|
||||
"ariaLabel": "Impossible de partager votre écran",
|
||||
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
|
||||
"settingsInstructions": "Accédez à vos",
|
||||
"settingsLabel": {
|
||||
"macos": "Préférences système",
|
||||
"windows": "paramètres de confidentialité Windows"
|
||||
},
|
||||
"macInstructions": "Accèdez à vos",
|
||||
"macSystemPreferences": "Préférences système",
|
||||
"helpLinkText": "Pour en savoir plus, consulter",
|
||||
"helpLinkLabel": "Problème de présentation",
|
||||
"closeButton": "Ignorer",
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
"loading": "Bezig met laden…",
|
||||
"select": "Selecteer een waarde",
|
||||
"permissionsNeeded": "Toestemming vereist",
|
||||
"deviceNotFound": {
|
||||
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Audio-instellingen",
|
||||
"video": "Video-instellingen"
|
||||
@@ -66,7 +62,6 @@
|
||||
"usernameEmpty": "Uw naam kan niet leeg zijn"
|
||||
},
|
||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||
"cameraStarting": "Camera wordt ingeschakeld…",
|
||||
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
||||
@@ -122,16 +117,6 @@
|
||||
"videoinput": "Je camera wordt al gebruikt door een ander tabblad of een andere toepassing, waardoor deze niet geactiveerd kan worden in dit videogesprek. Sluit indien gewenst het andere tabblad of de toepassing om je camera hier te gebruiken."
|
||||
}
|
||||
},
|
||||
"NotFound": {
|
||||
"title": {
|
||||
"audioinput": "Geen microfoon gedetecteerd",
|
||||
"videoinput": "Geen camera gedetecteerd"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Er is geen microfoon gedetecteerd op je apparaat. Controleer of deze goed is aangesloten en niet door een schakelaar is gedempt, en probeer het opnieuw.",
|
||||
"videoinput": "Er is geen camera gedetecteerd op je apparaat. Controleer of deze goed is aangesloten en niet door een afdekking of schakelaar wordt geblokkeerd, en probeer het opnieuw."
|
||||
}
|
||||
},
|
||||
"close": "OK"
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
@@ -154,53 +139,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemPermissionDialog": {
|
||||
"heading": {
|
||||
"camera": "Geef je browser toestemming om je camera te gebruiken",
|
||||
"microphone": "Geef je browser toestemming om je microfoon te gebruiken",
|
||||
"cameraAndMicrophone": "Geef je browser toestemming om je camera en microfoon te gebruiken"
|
||||
},
|
||||
"device": {
|
||||
"camera": "camera",
|
||||
"microphone": "microfoon",
|
||||
"cameraAndMicrophone": "camera en microfoon"
|
||||
},
|
||||
"intro": "Je browser heeft al toestemming, maar de systeeminstellingen van je computer blokkeren de toegang tot je {{device}}.",
|
||||
"steps": {
|
||||
"macos": {
|
||||
"1": "Open in Systeeminstellingen “Privacy en beveiliging”.",
|
||||
"2": "Geef je browser toegang tot je {{device}}."
|
||||
},
|
||||
"windows": {
|
||||
"1": "Open in de Windows-instellingen “Privacy en beveiliging”.",
|
||||
"2": "Zet {{device}}-toegang aan en sta desktop-apps toe deze te gebruiken."
|
||||
},
|
||||
"android": {
|
||||
"1": "Open in de Android-instellingen “Apps” en kies je browser.",
|
||||
"2": "Sta bij “Machtigingen” toegang tot je {{device}} toe en herlaad daarna de pagina."
|
||||
},
|
||||
"other": {
|
||||
"1": "Geef je browser in de privacy-instellingen van je systeem toegang tot je {{device}}.",
|
||||
"2": "Probeer het daarna opnieuw."
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -214,11 +152,8 @@
|
||||
"title": "Kan uw scherm niet delen",
|
||||
"ariaLabel": "Kan uw scherm niet delen",
|
||||
"message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.",
|
||||
"settingsInstructions": "Ga naar uw",
|
||||
"settingsLabel": {
|
||||
"macos": "Systeemvoorkeuren",
|
||||
"windows": "Windows-privacyinstellingen"
|
||||
},
|
||||
"macInstructions": "Ga naar uw",
|
||||
"macSystemPreferences": "Systeemvoorkeuren",
|
||||
"helpLinkText": "Meer informatie, zie",
|
||||
"helpLinkLabel": "Presentatieprobleem",
|
||||
"closeButton": "Negeren",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { type StyledVariantProps } from '@/styled-system/types'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { FieldErrors } from './FieldErrors'
|
||||
import { FieldDescription } from './FieldDescription'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
// styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html
|
||||
const StyledCheckbox = styled(RACCheckbox, {
|
||||
@@ -112,6 +113,12 @@ export const Checkbox = ({
|
||||
const descriptionId = useId()
|
||||
|
||||
if (isInvalid !== undefined) {
|
||||
reportError(
|
||||
'generic_failure',
|
||||
new Error(
|
||||
'Checkbox: passing isInvalid is not supported, use the validate prop instead'
|
||||
)
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -116,55 +116,51 @@ export const Select = <T extends string | number>({
|
||||
const { t } = useTranslation('global')
|
||||
return (
|
||||
<RACSelect {...props}>
|
||||
{({ isOpen }) => (
|
||||
<>
|
||||
{label}
|
||||
<StyledButton variant={variant}>
|
||||
{!!IconComponent && (
|
||||
<StyledIcon>
|
||||
<IconComponent size={18} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<StyledSelectValue />
|
||||
<RiArrowDropDownLine
|
||||
aria-hidden="true"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
</StyledButton>
|
||||
<StyledPopover placement={placement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
<ListBox>
|
||||
{items.map((item) => (
|
||||
<ListBoxItem
|
||||
className={
|
||||
menuRecipe({
|
||||
extraPadding: true,
|
||||
variant: variant,
|
||||
}).item
|
||||
}
|
||||
id={item.value}
|
||||
key={item.value}
|
||||
textValue={
|
||||
typeof item.label === 'string' ? item.label : undefined
|
||||
}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
{item.label}
|
||||
{isSelected && (
|
||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||
)}
|
||||
</>
|
||||
{label}
|
||||
<StyledButton variant={variant}>
|
||||
{!!IconComponent && (
|
||||
<StyledIcon>
|
||||
<IconComponent size={18} />
|
||||
</StyledIcon>
|
||||
)}
|
||||
<StyledSelectValue />
|
||||
<RiArrowDropDownLine
|
||||
aria-hidden="true"
|
||||
className={css({ flexShrink: 0 })}
|
||||
/>
|
||||
</StyledButton>
|
||||
<StyledPopover placement={placement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
<ListBox>
|
||||
{items.map((item) => (
|
||||
<ListBoxItem
|
||||
className={
|
||||
menuRecipe({
|
||||
extraPadding: true,
|
||||
variant: variant,
|
||||
}).item
|
||||
}
|
||||
id={item.value}
|
||||
key={item.value}
|
||||
textValue={
|
||||
typeof item.label === 'string' ? item.label : undefined
|
||||
}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
{item.label}
|
||||
{isSelected && (
|
||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
{isOpen && menuFooter}
|
||||
</Box>
|
||||
</StyledPopover>
|
||||
{errors}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
{menuFooter}
|
||||
</Box>
|
||||
</StyledPopover>
|
||||
{errors}
|
||||
</RACSelect>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export type CandidateInfo = {
|
||||
type: string
|
||||
address: string
|
||||
protocol: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
isIdleDisconnectModalOpen: boolean
|
||||
publisher: CandidateInfo | null
|
||||
publisherChangesCount: number
|
||||
subscriber: CandidateInfo | null
|
||||
subscriberChangesCount: number
|
||||
}
|
||||
|
||||
export const connectionObserverStore = proxy<State>({
|
||||
isIdleDisconnectModalOpen: false,
|
||||
publisher: null,
|
||||
publisherChangesCount: 0,
|
||||
subscriber: null,
|
||||
subscriberChangesCount: 0,
|
||||
})
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
// Device presence (not permission): enumerateDevices() exposes kinds
|
||||
// before any grant. Optimistic defaults until the first sync.
|
||||
export const deviceAvailabilityStore = proxy({
|
||||
hasCamera: true,
|
||||
hasMicrophone: true,
|
||||
synced: false,
|
||||
})
|
||||
|
||||
export const syncDeviceAvailability = async (): Promise<void> => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
const hasCamera = devices.some((d) => d.kind === 'videoinput')
|
||||
const hasMicrophone = devices.some((d) => d.kind === 'audioinput')
|
||||
|
||||
// Report each absence once, not on every devicechange.
|
||||
const firstSync = !deviceAvailabilityStore.synced
|
||||
if (!hasCamera && (firstSync || deviceAvailabilityStore.hasCamera)) {
|
||||
void captureMediaEvent('device-not-found', { kind: 'videoinput' })
|
||||
}
|
||||
if (
|
||||
!hasMicrophone &&
|
||||
(firstSync || deviceAvailabilityStore.hasMicrophone)
|
||||
) {
|
||||
void captureMediaEvent('device-not-found', { kind: 'audioinput' })
|
||||
}
|
||||
|
||||
deviceAvailabilityStore.hasCamera = hasCamera
|
||||
deviceAvailabilityStore.hasMicrophone = hasMicrophone
|
||||
deviceAvailabilityStore.synced = true
|
||||
} catch (error) {
|
||||
reportError('permissions_api_failure', error as Error, {
|
||||
context: 'enumerateDevices for device availability',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { isFireFox, isMacintosh } from '@/utils/livekit'
|
||||
import { isAndroid } from '@/utils/os'
|
||||
|
||||
type PermissionState = undefined | 'granted' | 'prompt' | 'denied'
|
||||
|
||||
type State = {
|
||||
cameraPermission: PermissionState
|
||||
microphonePermission: PermissionState
|
||||
cameraSystemDenied: boolean
|
||||
microphoneSystemDenied: boolean
|
||||
isLoading: boolean
|
||||
isPermissionDialogOpen: boolean
|
||||
isSystemPermissionDialogOpen: boolean
|
||||
requestOrigin?: 'audioinput' | 'videoinput'
|
||||
isCameraGranted: boolean
|
||||
isMicrophoneGranted: boolean
|
||||
@@ -24,11 +19,8 @@ type State = {
|
||||
export const permissionsStore = proxy<State>({
|
||||
cameraPermission: undefined,
|
||||
microphonePermission: undefined,
|
||||
cameraSystemDenied: false,
|
||||
microphoneSystemDenied: false,
|
||||
isLoading: true,
|
||||
isPermissionDialogOpen: false,
|
||||
isSystemPermissionDialogOpen: false,
|
||||
requestOrigin: undefined,
|
||||
get isCameraGranted() {
|
||||
return this.cameraPermission === 'granted'
|
||||
@@ -53,33 +45,10 @@ export const permissionsStore = proxy<State>({
|
||||
export const openPermissionsDialog = (
|
||||
requestOrigin?: 'audioinput' | 'videoinput'
|
||||
) => {
|
||||
// When the block is at the OS level, browser-side instructions are
|
||||
// useless — route to the system permissions dialog instead.
|
||||
const systemBlocked =
|
||||
requestOrigin === 'audioinput'
|
||||
? permissionsStore.microphoneSystemDenied
|
||||
: requestOrigin === 'videoinput'
|
||||
? permissionsStore.cameraSystemDenied
|
||||
: permissionsStore.microphoneSystemDenied ||
|
||||
permissionsStore.cameraSystemDenied
|
||||
if (systemBlocked) {
|
||||
openSystemPermissionsDialog()
|
||||
return
|
||||
}
|
||||
permissionsStore.isPermissionDialogOpen = true
|
||||
permissionsStore.requestOrigin = requestOrigin
|
||||
}
|
||||
|
||||
export const openSystemPermissionsDialog = () => {
|
||||
// Never show both dialogs stacked: the system-level one wins.
|
||||
permissionsStore.isPermissionDialogOpen = false
|
||||
permissionsStore.isSystemPermissionDialogOpen = true
|
||||
}
|
||||
|
||||
export const closeSystemPermissionsDialog = () => {
|
||||
permissionsStore.isSystemPermissionDialogOpen = false
|
||||
}
|
||||
|
||||
export const closePermissionsDialog = () => {
|
||||
permissionsStore.isPermissionDialogOpen = false
|
||||
}
|
||||
@@ -131,166 +100,36 @@ const labelsVisible = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outcomes witnessed first-hand from getUserMedia this session. The
|
||||
* Permissions API is a weaker signal in both directions on Firefox:
|
||||
*
|
||||
* - denials: Firefox < 151 keeps temporary (non-"remembered") denials at
|
||||
* 'prompt' — the default mode on mobile — and some browsers cannot
|
||||
* query camera/microphone at all;
|
||||
* - grants: one-time grants are coupled to the active capture, so the
|
||||
* query drops back to 'prompt' the moment a track stops (e.g. the user
|
||||
* toggling a device off), and the PermissionStatus change event
|
||||
* re-syncs at exactly that moment.
|
||||
*
|
||||
* Without this memory, every syncPermissions (focus, devicechange,
|
||||
* status change) clobbers the state right back to 'prompt', which the UI
|
||||
* renders as "permission needed" — mid-session, on devices that just
|
||||
* worked. A queried 'prompt' therefore never downgrades a witnessed
|
||||
* outcome; only hard evidence (a queried 'granted'/'denied', or the next
|
||||
* gUM outcome) replaces it.
|
||||
*/
|
||||
const gumOutcome: Record<PermissionKind, 'granted' | 'denied' | undefined> = {
|
||||
camera: undefined,
|
||||
microphone: undefined,
|
||||
}
|
||||
|
||||
export const syncPermissions = async () => {
|
||||
const [camera, microphone] = await Promise.all([
|
||||
queryPermission('camera'),
|
||||
queryPermission('microphone'),
|
||||
])
|
||||
const granted = camera && microphone ? () => false : await labelsVisible()
|
||||
const resolve = (
|
||||
kind: PermissionKind,
|
||||
queried?: PermissionState
|
||||
): PermissionState => {
|
||||
// Hard evidence from the Permissions API wins in both directions.
|
||||
if (queried === 'granted' || (!queried && granted(kind))) {
|
||||
gumOutcome[kind] = 'granted'
|
||||
return 'granted'
|
||||
}
|
||||
if (queried === 'denied') {
|
||||
gumOutcome[kind] = 'denied'
|
||||
return 'denied'
|
||||
}
|
||||
// 'prompt' (or no query support): a witnessed gUM outcome is the
|
||||
// stronger signal — keep it.
|
||||
if (gumOutcome[kind]) return gumOutcome[kind]
|
||||
if (queried === 'prompt') return 'prompt'
|
||||
if (camera && microphone) {
|
||||
setPermissions({ camera, microphone })
|
||||
return
|
||||
}
|
||||
const granted = await labelsVisible()
|
||||
const resolve = (kind: PermissionKind, queried?: PermissionState) => {
|
||||
if (queried) return queried
|
||||
const current =
|
||||
kind === 'camera'
|
||||
? permissionsStore.cameraPermission
|
||||
: permissionsStore.microphonePermission
|
||||
if (current === 'denied') return 'denied'
|
||||
return 'prompt'
|
||||
return granted(kind) ? 'granted' : 'prompt'
|
||||
}
|
||||
const resolved = {
|
||||
setPermissions({
|
||||
camera: resolve('camera', camera),
|
||||
microphone: resolve('microphone', microphone),
|
||||
}
|
||||
setPermissions(resolved)
|
||||
})
|
||||
}
|
||||
|
||||
export const notePermissionDeniedFromGum = (kind?: PermissionKind) => {
|
||||
// A combined audio+video request fails atomically: no kind means the
|
||||
// denial covers both devices.
|
||||
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
|
||||
const denied: Partial<Record<PermissionKind, PermissionState>> = {}
|
||||
for (const k of kinds) {
|
||||
gumOutcome[k] = 'denied'
|
||||
denied[k] = 'denied'
|
||||
}
|
||||
setPermissions(denied)
|
||||
void syncPermissions()
|
||||
}
|
||||
|
||||
export type PermissionDeniedScope = 'browser' | 'system'
|
||||
|
||||
/**
|
||||
* Chromium reports OS-level blocks (macOS/Windows privacy settings) with an
|
||||
* explicit, non-localized message: "Permission denied by system", as opposed
|
||||
* to "Permission denied" for a browser-level block.
|
||||
*/
|
||||
export const isSystemPermissionError = (error: unknown): boolean =>
|
||||
(error as Error)?.name === 'NotAllowedError' &&
|
||||
/by system/i.test((error as Error)?.message ?? '')
|
||||
|
||||
/**
|
||||
* Decides whether a getUserMedia permission failure comes from the browser
|
||||
* or from the OS. Fallback for non-Chromium browsers: gUM rejected with
|
||||
* NotAllowedError although the browser-level permission is granted — the
|
||||
* block can only come from the OS.
|
||||
*/
|
||||
export const classifyPermissionError = async (
|
||||
error: unknown,
|
||||
kind?: PermissionKind
|
||||
): Promise<PermissionDeniedScope> => {
|
||||
if (isSystemPermissionError(error)) return 'system'
|
||||
if ((error as Error)?.name !== 'NotAllowedError') return 'browser'
|
||||
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
|
||||
const states = await Promise.all(kinds.map(queryPermission))
|
||||
return states.every((state) => state === 'granted') ? 'system' : 'browser'
|
||||
}
|
||||
|
||||
/**
|
||||
* Firefox surfaces OS-level blocks as NotFoundError ("The object can not
|
||||
* be found here.") instead of NotAllowedError.
|
||||
*
|
||||
* - macOS: tell it apart from a genuinely missing device — the device
|
||||
* still shows up in enumerateDevices.
|
||||
* - Android: Fenix rejects with NotFoundError when the app itself lacks
|
||||
* the Android camera/mic permission (mozilla-mobile/fenix#15023), often
|
||||
* before any site prompt, and may hide the blocked devices from
|
||||
* enumerateDevices entirely. Phones and tablets always have both
|
||||
* devices, so "not found" there means an OS-level block, not missing
|
||||
* hardware.
|
||||
*/
|
||||
export const isLikelySystemNotFound = async (
|
||||
error: unknown,
|
||||
kind?: PermissionKind
|
||||
): Promise<boolean> => {
|
||||
if ((error as Error)?.name !== 'NotFoundError') return false
|
||||
if (!isFireFox()) return false
|
||||
if (isAndroid()) return true
|
||||
if (!isMacintosh()) return false
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
const deviceKinds = kind
|
||||
? [KIND_MAP[kind]]
|
||||
: (['audioinput', 'videoinput'] as const)
|
||||
return deviceKinds.every((deviceKind) =>
|
||||
devices.some((device) => device.kind === deviceKind)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const noteSystemPermissionDenied = (kind?: PermissionKind) => {
|
||||
// Only flags the state (shown as the blocked-device hint on the toggle);
|
||||
// the dialog itself opens when the user clicks the blocked toggle.
|
||||
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
|
||||
for (const k of kinds) {
|
||||
const key = k === 'camera' ? 'cameraSystemDenied' : 'microphoneSystemDenied'
|
||||
permissionsStore[key] = true
|
||||
if (kind) {
|
||||
setPermissions({ [kind]: 'denied' })
|
||||
void syncPermissions()
|
||||
return
|
||||
}
|
||||
void syncPermissions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Successful acquisition proves the permission is granted and the OS no
|
||||
* longer blocks the device — record it directly, since the Permissions
|
||||
* API may still report 'prompt' (Firefox one-time grants).
|
||||
*/
|
||||
export const noteGumSuccess = (kind?: PermissionKind) => {
|
||||
const kinds: PermissionKind[] = kind ? [kind] : ['microphone', 'camera']
|
||||
const granted: Partial<Record<PermissionKind, PermissionState>> = {}
|
||||
for (const k of kinds) {
|
||||
gumOutcome[k] = 'granted'
|
||||
granted[k] = 'granted'
|
||||
if (k === 'camera') permissionsStore.cameraSystemDenied = false
|
||||
else permissionsStore.microphoneSystemDenied = false
|
||||
}
|
||||
setPermissions(granted)
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
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')
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function getUserState(): State {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
reportError('generic_failure', error, {
|
||||
context: '[UserStore] Failed to parse stored settings:',
|
||||
context: '[UserPreferencesStore] Failed to parse stored settings:',
|
||||
})
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
export type OS = 'macos' | 'windows' | 'linux' | 'android' | 'other'
|
||||
|
||||
export const isAndroid = (): boolean => /android/i.test(navigator.userAgent)
|
||||
|
||||
export const getOS = (): OS => {
|
||||
const source = `${
|
||||
(navigator as { userAgentData?: { platform?: string } }).userAgentData
|
||||
?.platform ?? navigator.platform
|
||||
} ${navigator.userAgent}`
|
||||
// Android UAs also contain "Linux" (and desktop-mode ones may not say
|
||||
// much else), so check it before the desktop platforms.
|
||||
if (isAndroid()) return 'android'
|
||||
if (/mac/i.test(source)) return 'macos'
|
||||
if (/win/i.test(source)) return 'windows'
|
||||
if (/linux|x11/i.test(source)) return 'linux'
|
||||
return 'other'
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.26.0",
|
||||
"version": "1.25.2",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.26.0"
|
||||
version = "1.25.2"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
Reference in New Issue
Block a user