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-03-05 20:50:19 +01:00
import {
ElementCallError ,
InsufficientCapacityError ,
UnknownCallError ,
} from "../utils/errors.ts" ;
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-03-05 20:50:19 +01:00
const [ error , setError ] = useState < ElementCallError | null >( null );
2025-02-26 19:00:56 +07:00
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-03-05 20:50:19 +01:00
if ( e instanceof ElementCallError ) {
2025-02-26 19:00:56 +07:00
setError ( e ); // Bubble up any error screens to React
2025-03-05 20:50:19 +01:00
} else if ( e instanceof Error ) {
setError ( new UnknownCallError ( e ));
} 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
}