mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 12:17:24 +00:00
d4df0df027
/** * Keeps permissionsStore aligned with the browser. The store is a cache, * never a second source of truth: every signal re-reads the browser via * syncPermissions() (single writer). * * Re-sync triggers, all event-driven — no polling: * - devicechange: granting permission reveals device labels/ids, so this * fires on grant in every browser INCLUDING Safari — replacing the * previous 500ms Safari polling. (A denial is caught by the concurrent * getUserMedia rejection → notePermissionDeniedFromGum.) * - window focus: returning from the browser/system permission UI. * - Permissions API 'change' events, where the query is supported. */
340 lines
11 KiB
TypeScript
340 lines
11 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 {
|
|
DisconnectReason,
|
|
MediaDeviceFailure,
|
|
Room,
|
|
type RoomOptions,
|
|
VideoPresets,
|
|
} from 'livekit-client'
|
|
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 { 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 {
|
|
PERMISSION_BY_DEVICE_KIND,
|
|
notePermissionDeniedFromGum,
|
|
} from '@/stores/permissions'
|
|
|
|
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(() => {
|
|
captureEvent('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,
|
|
},
|
|
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 [mediaDeviceError, setMediaDeviceError] = useState<{
|
|
error: MediaDeviceFailure | null
|
|
kind: MediaDeviceKind | null
|
|
}>({
|
|
error: null,
|
|
kind: null,
|
|
})
|
|
|
|
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) => {
|
|
reportError('livekit_room_error', e, {
|
|
path: 'connect_publish',
|
|
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
|
|
})
|
|
}}
|
|
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,
|
|
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(
|
|
'feedback',
|
|
{},
|
|
{
|
|
state: { ...metadata },
|
|
}
|
|
)
|
|
return
|
|
case DisconnectReason.DUPLICATE_IDENTITY:
|
|
case DisconnectReason.PARTICIPANT_REMOVED:
|
|
navigateTo(
|
|
'feedback',
|
|
{},
|
|
{
|
|
state: {
|
|
reason: e,
|
|
...metadata,
|
|
},
|
|
}
|
|
)
|
|
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
|
|
}
|
|
}}
|
|
>
|
|
<VideoConference />
|
|
{!isMobile && <InviteDialog mode={mode} />}
|
|
<MediaDeviceErrorAlert
|
|
{...mediaDeviceError}
|
|
onClose={() => setMediaDeviceError({ error: null, kind: null })}
|
|
/>
|
|
<PictureInPictureConference />
|
|
</LiveKitRoom>
|
|
</Screen>
|
|
</QueryAware>
|
|
)
|
|
}
|