mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-05 23:26:02 +00:00
21c57bffb4
Introduce an in-app devtool that monitors WebRTC statistics in real time and lets developers simulate various network scenarios, including constraining the uplink and downlink bandwidth. Makes it much easier to reproduce and investigate connectivity or quality issues locally without depending on external tools. The code was AI generated, and might contain some smell. It's only enabled in dev, and not included in the production build. Feel free to enhance it as needed.
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
import {
|
|
LiveKitRoom,
|
|
usePersistentUserChoices,
|
|
} from '@livekit/components-react'
|
|
import {
|
|
ConnectionError,
|
|
ConnectionErrorReason,
|
|
DisconnectReason,
|
|
MediaDeviceFailure,
|
|
Room,
|
|
type RoomOptions,
|
|
VideoPresets,
|
|
} from 'livekit-client'
|
|
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
|
|
import { keys } from '@/api/queryKeys'
|
|
import { queryClient } from '@/api/queryClient'
|
|
import { Screen } from '@/layout/Screen'
|
|
import { QueryAware } from '@/components/QueryAware'
|
|
import { ErrorScreen } from '@/components/ErrorScreen'
|
|
import { fetchRoom } from '../api/fetchRoom'
|
|
import type { ApiRoom } from '../api/ApiRoom'
|
|
import { useCreateRoom } from '../api/createRoom'
|
|
import { InviteDialog } from './InviteDialog'
|
|
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|
import { css } from '@/styled-system/css'
|
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
|
import { LocalUserChoices } from '@/stores/userChoices'
|
|
import {
|
|
captureEvent,
|
|
captureMediaEvent,
|
|
reportError,
|
|
} 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 { 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 { MeetDevtools } from '@/features/devtools'
|
|
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
|
|
|
export const Conference = ({
|
|
roomId,
|
|
initialRoomData,
|
|
mode = 'join',
|
|
}: {
|
|
roomId: string
|
|
mode?: 'join' | 'create'
|
|
initialRoomData?: ApiRoom
|
|
}) => {
|
|
const { data: apiConfig } = useConfig()
|
|
|
|
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
|
userChoices: LocalUserChoices
|
|
}
|
|
|
|
const { username } = useSnapshot(userStore)
|
|
|
|
useEffect(() => {
|
|
void captureMediaEvent('visit-room', { slug: roomId })
|
|
}, [roomId])
|
|
const fetchKey = [keys.room, roomId]
|
|
|
|
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
|
|
|
const userPreferencesSnap = useSnapshot(userPreferencesStore)
|
|
|
|
const {
|
|
mutateAsync: createRoom,
|
|
status: createStatus,
|
|
isError: isCreateError,
|
|
} = useCreateRoom({
|
|
onSuccess: (data) => {
|
|
queryClient.setQueryData(fetchKey, data)
|
|
},
|
|
})
|
|
|
|
const {
|
|
status: fetchStatus,
|
|
isError: isFetchError,
|
|
data,
|
|
} = useQuery({
|
|
queryKey: fetchKey,
|
|
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
|
initialData: initialRoomData,
|
|
queryFn: () =>
|
|
fetchRoom({
|
|
roomId: roomId as string,
|
|
username: username,
|
|
}).catch((error) => {
|
|
if (error.statusCode == '404') {
|
|
createRoom({ slug: roomId, username })
|
|
}
|
|
}),
|
|
retry: false,
|
|
})
|
|
|
|
const roomOptions = useMemo((): RoomOptions => {
|
|
return {
|
|
adaptiveStream: true,
|
|
dynacast: true,
|
|
publishDefaults: {
|
|
videoCodec: 'vp9',
|
|
},
|
|
videoCaptureDefaults: {
|
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
|
resolution: userConfig.videoPublishResolution
|
|
? VideoPresets[userConfig.videoPublishResolution].resolution
|
|
: undefined,
|
|
},
|
|
audioCaptureDefaults: {
|
|
deviceId: userConfig.audioDeviceId ?? undefined,
|
|
...VOICE_AUDIO_CONSTRAINTS,
|
|
},
|
|
audioOutput: {
|
|
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
|
},
|
|
}
|
|
// do not rely on the userConfig object directly as its reference may change on every render
|
|
}, [
|
|
userConfig.videoDeviceId,
|
|
userConfig.videoPublishResolution,
|
|
userConfig.audioDeviceId,
|
|
userConfig.audioOutputDeviceId,
|
|
])
|
|
|
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
|
|
|
useEffect(() => {
|
|
/**
|
|
* Warm up connection to LiveKit server before joining room
|
|
* This prefetch helps reduce initial connection latency by establishing
|
|
* an early HTTP connection to the WebRTC signaling server
|
|
*
|
|
* It should cache DNS and TLS keys.
|
|
*/
|
|
const prepareConnection = async () => {
|
|
if (!apiConfig || isConnectionWarmedUp) return
|
|
await room.prepareConnection(apiConfig.livekit.url)
|
|
|
|
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
|
try {
|
|
const wssUrl =
|
|
apiConfig.livekit.url
|
|
.replace('https://', 'wss://')
|
|
.replace(/\/$/, '') + '/rtc'
|
|
|
|
/**
|
|
* FIREFOX + PROXY WORKAROUND:
|
|
*
|
|
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
|
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
|
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
|
*
|
|
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
|
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
|
*
|
|
* Note: This issue is reproducible on LiveKit's demo app.
|
|
* Reference: livekit-examples/meet/issues/466
|
|
*/
|
|
const ws = new WebSocket(wssUrl)
|
|
// 401 unauthorized response is expected
|
|
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
|
} catch (e) {
|
|
console.debug('Firefox WebSocket workaround failed.', e)
|
|
}
|
|
}
|
|
|
|
setIsConnectionWarmedUp(true)
|
|
}
|
|
prepareConnection()
|
|
}, [room, apiConfig, isConnectionWarmedUp])
|
|
|
|
const isMobile = useIsMobile()
|
|
|
|
const hasAutoMutedRef = useRef(false)
|
|
|
|
/*
|
|
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
|
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
|
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
|
|
*/
|
|
const serverUrl = useMemo(() => {
|
|
const livekit_url = apiConfig?.livekit.url
|
|
if (!livekit_url) return
|
|
if (apiConfig?.livekit.force_wss_protocol) {
|
|
return livekit_url.replace('https://', 'wss://')
|
|
}
|
|
return livekit_url
|
|
}, [apiConfig?.livekit])
|
|
|
|
const { t } = useTranslation('rooms')
|
|
if (isCreateError) {
|
|
// this error screen should be replaced by a proper waiting room for anonymous user.
|
|
return (
|
|
<ErrorScreen
|
|
title={t('error.createRoom.heading')}
|
|
body={t('error.createRoom.body')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Some clients (like DINUM) operate in bandwidth-constrained environments
|
|
// These settings help ensure successful connections in poor network conditions
|
|
const connectOptions = {
|
|
maxRetries: 5, // Default: 1. Only for unreachable server scenarios
|
|
peerConnectionTimeout: 60000, // Default: 15s. Extended for slow TURN/TLS negotiation
|
|
}
|
|
|
|
return (
|
|
<QueryAware status={isFetchError ? createStatus : fetchStatus}>
|
|
<Screen header={false} footer={false}>
|
|
<LiveKitRoom
|
|
room={room}
|
|
serverUrl={serverUrl}
|
|
token={data?.livekit?.token}
|
|
connect={isConnectionWarmedUp}
|
|
audio={userConfig.audioEnabled}
|
|
video={
|
|
userConfig.videoEnabled && {
|
|
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
|
userConfig.processorConfig
|
|
),
|
|
}
|
|
}
|
|
connectOptions={connectOptions}
|
|
className={css({
|
|
backgroundColor: 'primaryDark.50 !important',
|
|
})}
|
|
onError={(e) => {
|
|
const failure = getMediaDeviceFailure(e)
|
|
if (failure && failure !== MediaDeviceFailure.Other) return
|
|
|
|
// connect() was aborted by a disconnect() before the join completed
|
|
if (
|
|
e instanceof ConnectionError &&
|
|
e.reason === ConnectionErrorReason.Cancelled
|
|
) {
|
|
void captureEvent('connection-cancelled')
|
|
return
|
|
}
|
|
|
|
reportError('livekit_room_error', e, {
|
|
path: 'connect_publish',
|
|
})
|
|
}}
|
|
onConnected={async () => {
|
|
if (!apiConfig) return
|
|
if (
|
|
userPreferencesSnap.is_auto_mute_large_room_enabled &&
|
|
!hasAutoMutedRef.current &&
|
|
userConfig.audioEnabled &&
|
|
room.numParticipants > apiConfig.auto_mute_on_join_threshold
|
|
) {
|
|
hasAutoMutedRef.current = true
|
|
await room.localParticipant.setMicrophoneEnabled(false)
|
|
notifyAutoMutedOnJoin()
|
|
}
|
|
}}
|
|
onDisconnected={(e) => {
|
|
const metadata = {
|
|
room_id: roomId,
|
|
}
|
|
|
|
switch (e) {
|
|
case DisconnectReason.CLIENT_INITIATED:
|
|
navigateTo(
|
|
'feedback',
|
|
{},
|
|
{
|
|
state: { ...metadata },
|
|
}
|
|
)
|
|
return
|
|
case DisconnectReason.DUPLICATE_IDENTITY:
|
|
case DisconnectReason.PARTICIPANT_REMOVED:
|
|
navigateTo(
|
|
'feedback',
|
|
{},
|
|
{
|
|
state: {
|
|
reason: e,
|
|
...metadata,
|
|
},
|
|
}
|
|
)
|
|
return
|
|
}
|
|
}}
|
|
>
|
|
<WatchMediaDeviceErrors />
|
|
<VideoConference />
|
|
{!isMobile && <InviteDialog mode={mode} />}
|
|
<PictureInPictureConference />
|
|
<MeetDevtools />
|
|
</LiveKitRoom>
|
|
</Screen>
|
|
</QueryAware>
|
|
)
|
|
}
|