2023-08-29 12:44:30 +01:00
/ *
2024-09-06 10:22:13 +02:00
Copyright 2023 , 2024 New Vector Ltd .
2023-08-29 12:44:30 +01:00
2025-02-18 17:59:58 +00:00
SPDX - License - Identifier : AGPL - 3.0 - only OR LicenseRef - Element - Commercial
2024-09-06 10:22:13 +02:00
Please see LICENSE in the repository root for full details .
2023-08-29 12:44:30 +01:00
* /
2023-09-08 17:22:02 +01:00
import {
2024-12-11 09:27:55 +00:00
type AudioCaptureOptions ,
2025-02-26 19:00:56 +07:00
ConnectionError ,
2023-09-08 17:22:02 +01:00
ConnectionState ,
2024-12-11 09:27:55 +00:00
type LocalTrack ,
type Room ,
2023-09-08 17:22:02 +01:00
RoomEvent ,
2023-10-11 16:07:46 +01:00
Track ,
2023-09-08 17:22:02 +01:00
} from "livekit-client" ;
2023-08-29 12:44:30 +01:00
import { useCallback , useEffect , useRef , useState } from "react" ;
import { logger } from "matrix-js-sdk/src/logger" ;
2023-10-27 16:18:00 +01:00
import * as Sentry from "@sentry/react" ;
2023-08-29 12:44:30 +01:00
2024-12-11 09:27:55 +00:00
import { type SFUConfig , sfuConfigEquals } from "./openIDSFU" ;
2024-04-16 17:21:37 +02:00
import { PosthogAnalytics } from "../analytics/PosthogAnalytics" ;
2025-02-26 19:00:56 +07:00
import { InsufficientCapacityError , RichError } from "../RichError" ;
2023-08-29 12:44:30 +01:00
2024-04-09 10:06:34 -04:00
declare global {
interface Window {
peerConnectionTimeout? : number ;
websocketTimeout? : number ;
}
}
2023-08-29 12:44:30 +01:00
/ *
* Additional values for states that a call can be in , beyond what livekit
* provides in ConnectionState . Also reconnects the call if the SFU Config
* changes .
* /
export enum ECAddonConnectionState {
// We are switching from one focus to another (or between livekit room aliases on the same focus)
ECSwitchingFocus = "ec_switching_focus" ,
// The call has just been initialised and is waiting for credentials to arrive before attempting
2024-09-10 09:49:35 +02:00
// to connect. This distinguishes from the 'Disconnected' state which is now just for when livekit
2023-08-29 12:44:30 +01:00
// gives up on connectivity and we consider the call to have failed.
ECWaiting = "ec_waiting" ,
}
export type ECConnectionState = ConnectionState | ECAddonConnectionState ;
2023-08-31 09:44:23 +01:00
// This is mostly necessary because an empty useRef is an empty object
// which is truthy, so we can't just use Boolean(currentSFUConfig.current)
function sfuConfigValid ( sfuConfig? : SFUConfig ) : boolean {
return Boolean ( sfuConfig ? . url ) && Boolean ( sfuConfig ? . jwt ) ;
}
2023-09-08 17:22:02 +01:00
async function doConnect (
livekitRoom : Room ,
sfuConfig : SFUConfig ,
audioEnabled : boolean ,
2023-10-11 10:42:04 -04:00
audioOptions : AudioCaptureOptions ,
2023-09-08 17:22:02 +01:00
) : Promise < void > {
2023-09-20 13:21:45 -04:00
// Always create an audio track manually.
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
// doesn't publish it until you unmute. We want to publish it from the start so we're
// always capturing audio: it helps keep bluetooth headsets in the right mode and
// mobile browsers to know we're doing a call.
2024-02-09 11:22:36 -05:00
if (
livekitRoom ! . localParticipant . getTrackPublication ( Track . Source . Microphone )
) {
2023-10-11 16:07:46 +01:00
logger . warn (
2023-10-11 16:27:17 +01:00
"Pre-creating audio track but participant already appears to have an microphone track: this shouldn't happen!" ,
2023-10-11 16:07:46 +01:00
) ;
2023-10-27 16:18:00 +01:00
Sentry . captureMessage (
"Pre-creating audio track but participant already appears to have an microphone track!" ,
) ;
2023-10-11 16:07:46 +01:00
return ;
}
logger . info ( "Pre-creating microphone track" ) ;
2023-11-20 18:49:08 +00:00
let preCreatedAudioTrack : LocalTrack | undefined ;
try {
const audioTracks = await livekitRoom ! . localParticipant . createTracks ( {
audio : audioOptions ,
} ) ;
if ( audioTracks . length < 1 ) {
logger . info ( "Tried to pre-create local audio track but got no tracks" ) ;
} else {
preCreatedAudioTrack = audioTracks [ 0 ] ;
}
logger . info ( "Pre-created microphone track" ) ;
} catch ( e ) {
logger . error ( "Failed to pre-create microphone track" , e ) ;
2023-09-18 12:43:03 +02:00
}
2023-09-20 13:21:45 -04:00
2023-11-20 18:49:08 +00:00
if ( ! audioEnabled ) await preCreatedAudioTrack ? . mute ( ) ;
2023-11-10 15:24:43 +00:00
2023-10-11 16:07:46 +01:00
// check again having awaited for the track to create
2024-02-09 11:22:36 -05:00
if (
livekitRoom ! . localParticipant . getTrackPublication ( Track . Source . Microphone )
) {
2023-10-11 16:07:46 +01:00
logger . warn (
2023-11-20 18:49:08 +00:00
"Pre-created audio track but participant already appears to have an microphone track: this shouldn't happen!" ,
2023-10-11 16:07:46 +01:00
) ;
2023-11-20 18:49:08 +00:00
preCreatedAudioTrack ? . stop ( ) ;
2023-10-11 16:07:46 +01:00
return ;
}
2023-11-10 15:24:43 +00:00
logger . info ( "Connecting & publishing" ) ;
try {
2023-11-20 18:49:08 +00:00
await connectAndPublish ( livekitRoom , sfuConfig , preCreatedAudioTrack , [ ] ) ;
2023-11-10 15:24:43 +00:00
} catch ( e ) {
2023-11-20 18:49:08 +00:00
preCreatedAudioTrack ? . stop ( ) ;
2025-02-26 19:00:56 +07:00
logger . debug ( "Stopped precreated audio tracks." ) ;
throw e ;
2023-11-10 15:24:43 +00:00
}
}
/ * *
* Connect to the SFU and publish specific tracks , if provided .
* This is very specific to what we need to do : for instance , we don ' t
* currently have a need to prepublish video tracks . We just prepublish
* a mic track at the start of a call and copy any srceenshare tracks over
* when switching focus ( because we can ' t re - acquire them without the user
* going through the dialog to choose them again ) .
* /
async function connectAndPublish (
livekitRoom : Room ,
sfuConfig : SFUConfig ,
micTrack : LocalTrack | undefined ,
screenshareTracks : MediaStreamTrack [ ] ,
) : Promise < void > {
2024-04-16 17:21:37 +02:00
const tracker = PosthogAnalytics . instance . eventCallConnectDuration ;
// Track call connect duration
tracker . cacheConnectStart ( ) ;
livekitRoom . once ( RoomEvent . SignalConnected , tracker . cacheWsConnect ) ;
2025-02-26 19:00:56 +07:00
try {
await livekitRoom ! . connect ( sfuConfig ! . url , sfuConfig ! . jwt , {
// Due to stability issues on Firefox we are testing the effect of different
// timeouts, and allow these values to be set through the console
peerConnectionTimeout : window.peerConnectionTimeout ? ? 45000 ,
websocketTimeout : window.websocketTimeout ? ? 45000 ,
} ) ;
} catch ( e ) {
// LiveKit uses 503 to indicate that the server has hit its track limits
// or equivalently, 429 in LiveKit Cloud
// For reference, the 503 response is generated at: https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
if ( e instanceof ConnectionError && ( e . status === 503 || e . status === 429 ) )
throw new InsufficientCapacityError ( ) ;
throw e ;
}
2023-11-10 15:24:43 +00:00
2024-04-16 17:21:37 +02:00
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
livekitRoom . off ( RoomEvent . SignalConnected , tracker . cacheWsConnect ) ;
tracker . track ( { log : true } ) ;
2023-11-10 15:24:43 +00:00
if ( micTrack ) {
logger . info ( ` Publishing precreated mic track ` ) ;
await livekitRoom . localParticipant . publishTrack ( micTrack , {
source : Track.Source.Microphone ,
} ) ;
}
logger . info (
` Publishing ${ screenshareTracks . length } precreated screenshare tracks ` ,
) ;
for ( const st of screenshareTracks ) {
2024-09-10 09:49:35 +02:00
livekitRoom . localParticipant
. publishTrack ( st , {
source : Track.Source.ScreenShare ,
} )
. catch ( ( e ) = > {
logger . error ( "Failed to publish screenshare track" , e ) ;
} ) ;
2023-11-10 15:24:43 +00:00
}
2023-09-08 17:22:02 +01:00
}
2023-08-29 12:44:30 +01:00
export function useECConnectionState (
2023-09-08 17:22:02 +01:00
initialAudioOptions : AudioCaptureOptions ,
initialAudioEnabled : boolean ,
2023-08-29 12:44:30 +01:00
livekitRoom? : Room ,
2023-10-11 10:42:04 -04:00
sfuConfig? : SFUConfig ,
2023-08-29 12:44:30 +01:00
) : ECConnectionState {
const [ connState , setConnState ] = useState (
sfuConfig && livekitRoom
? livekitRoom . state
2023-10-11 10:42:04 -04:00
: ECAddonConnectionState . ECWaiting ,
2023-08-29 12:44:30 +01:00
) ;
const [ isSwitchingFocus , setSwitchingFocus ] = useState ( false ) ;
2023-09-20 13:21:45 -04:00
const [ isInDoConnect , setIsInDoConnect ] = useState ( false ) ;
2025-02-26 19:00:56 +07:00
const [ error , setError ] = useState < RichError | null > ( null ) ;
if ( error !== null ) throw error ;
2023-08-29 12:44:30 +01:00
const onConnStateChanged = useCallback ( ( state : ConnectionState ) = > {
if ( state == ConnectionState . Connected ) setSwitchingFocus ( false ) ;
setConnState ( state ) ;
} , [ ] ) ;
useEffect ( ( ) = > {
const oldRoom = livekitRoom ;
if ( livekitRoom ) {
livekitRoom . on ( RoomEvent . ConnectionStateChanged , onConnStateChanged ) ;
}
2024-06-04 11:20:25 -04:00
return ( ) : void = > {
2023-08-29 12:44:30 +01:00
if ( oldRoom )
oldRoom . off ( RoomEvent . ConnectionStateChanged , onConnStateChanged ) ;
} ;
} , [ livekitRoom , onConnStateChanged ] ) ;
2023-11-10 15:24:43 +00:00
const doFocusSwitch = useCallback ( async ( ) : Promise < void > = > {
const screenshareTracks : MediaStreamTrack [ ] = [ ] ;
2024-02-09 11:22:36 -05:00
for ( const t of livekitRoom ! . localParticipant . videoTrackPublications . values ( ) ) {
2023-11-10 15:24:43 +00:00
if ( t . track && t . source == Track . Source . ScreenShare ) {
const newTrack = t . track . mediaStreamTrack . clone ( ) ;
newTrack . enabled = true ;
screenshareTracks . push ( newTrack ) ;
}
}
2023-11-15 16:23:06 +00:00
// Flag that we're currently switching focus. This will get reset when the
// connection state changes back to connected in onConnStateChanged above.
2023-11-10 15:24:43 +00:00
setSwitchingFocus ( true ) ;
await livekitRoom ? . disconnect ( ) ;
setIsInDoConnect ( true ) ;
try {
await connectAndPublish (
livekitRoom ! ,
sfuConfig ! ,
undefined ,
screenshareTracks ,
) ;
} finally {
setIsInDoConnect ( false ) ;
}
} , [ livekitRoom , sfuConfig ] ) ;
2023-08-29 12:44:30 +01:00
const currentSFUConfig = useRef ( Object . assign ( { } , sfuConfig ) ) ;
2023-08-31 09:44:23 +01:00
// Id we are transitioning from a valid config to another valid one, we need
// to explicitly switch focus
2023-08-29 12:44:30 +01:00
useEffect ( ( ) = > {
if (
2023-08-31 09:44:23 +01:00
sfuConfigValid ( sfuConfig ) &&
sfuConfigValid ( currentSFUConfig . current ) &&
2023-08-29 12:44:30 +01:00
! sfuConfigEquals ( currentSFUConfig . current , sfuConfig )
) {
2023-08-31 09:44:23 +01:00
logger . info (
2023-10-11 10:42:04 -04:00
` SFU config changed! URL was ${ currentSFUConfig . current ? . url } now ${ sfuConfig ? . url } ` ,
2023-08-31 09:44:23 +01:00
) ;
2023-08-29 12:44:30 +01:00
2024-09-10 09:49:35 +02:00
doFocusSwitch ( ) . catch ( ( e ) = > {
logger . error ( "Failed to switch focus" , e ) ;
} ) ;
2023-09-08 17:22:02 +01:00
} else if (
! sfuConfigValid ( currentSFUConfig . current ) &&
sfuConfigValid ( sfuConfig )
) {
// if we're transitioning from an invalid config to a valid one (ie. connecting)
// then do an initial connection, including publishing the microphone track:
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
// doesn't publish it until you unmute. We want to publish it from the start so we're
// always capturing audio: it helps keep bluetooth headsets in the right mode and
// mobile browsers to know we're doing a call.
2023-09-20 13:21:45 -04:00
setIsInDoConnect ( true ) ;
2023-09-08 17:22:02 +01:00
doConnect (
livekitRoom ! ,
sfuConfig ! ,
initialAudioEnabled ,
2023-10-11 10:42:04 -04:00
initialAudioOptions ,
2024-09-10 09:49:35 +02:00
)
. catch ( ( e ) = > {
2025-02-26 19:00:56 +07:00
if ( e instanceof RichError )
setError ( e ) ; // Bubble up any error screens to React
else logger . error ( "Failed to connect to SFU" , e ) ;
2024-09-10 09:49:35 +02:00
} )
. finally ( ( ) = > setIsInDoConnect ( false ) ) ;
2023-08-29 12:44:30 +01:00
}
currentSFUConfig . current = Object . assign ( { } , sfuConfig ) ;
2023-11-10 15:24:43 +00:00
} , [
sfuConfig ,
livekitRoom ,
initialAudioOptions ,
initialAudioEnabled ,
doFocusSwitch ,
] ) ;
2023-08-29 12:44:30 +01:00
2023-09-20 13:21:45 -04:00
// Because we create audio tracks by hand, there's more to connecting than
// just what LiveKit does in room.connect, and we should continue to return
// ConnectionState.Connecting for the entire duration of the doConnect promise
return isSwitchingFocus
? ECAddonConnectionState . ECSwitchingFocus
: isInDoConnect
2023-11-30 23:40:33 -05:00
? ConnectionState . Connecting
: connState ;
2023-08-29 12:44:30 +01:00
}