@@ -11,6 +11,7 @@ import {
1111 VideoSource ,
1212 observeParticipantEvents ,
1313 observeParticipantMedia ,
14+ roomEventSelector ,
1415} from "@livekit/components-core" ;
1516import {
1617 LocalParticipant ,
@@ -21,20 +22,28 @@ import {
2122 Track ,
2223 TrackEvent ,
2324 facingModeFromLocalTrack ,
25+ Room as LivekitRoom ,
26+ RoomEvent as LivekitRoomEvent ,
27+ RemoteTrack ,
2428} from "livekit-client" ;
2529import { RoomMember , RoomMemberEvent } from "matrix-js-sdk/src/matrix" ;
2630import {
2731 BehaviorSubject ,
2832 Observable ,
2933 Subject ,
3034 combineLatest ,
35+ distinctUntilChanged ,
3136 distinctUntilKeyChanged ,
37+ filter ,
3238 fromEvent ,
39+ interval ,
3340 map ,
3441 merge ,
3542 of ,
43+ shareReplay ,
3644 startWith ,
3745 switchMap ,
46+ throttleTime ,
3847} from "rxjs" ;
3948import { useEffect } from "react" ;
4049
@@ -81,6 +90,115 @@ export function observeTrackReference(
8190 ) ;
8291}
8392
93+ function observeRemoteTrackReceivingOkay (
94+ participant : Participant ,
95+ source : Track . Source ,
96+ ) : Observable < boolean | undefined > {
97+ let lastStats : {
98+ framesDecoded : number | undefined ;
99+ framesDropped : number | undefined ;
100+ framesReceived : number | undefined ;
101+ } = {
102+ framesDecoded : undefined ,
103+ framesDropped : undefined ,
104+ framesReceived : undefined ,
105+ } ;
106+
107+ return combineLatest ( [
108+ observeTrackReference ( participant , source ) ,
109+ interval ( 1000 ) . pipe ( startWith ( 0 ) ) ,
110+ ] ) . pipe (
111+ switchMap ( async ( [ trackReference ] ) => {
112+ const track = trackReference . publication ?. track ;
113+ if ( ! track || ! ( track instanceof RemoteTrack ) ) {
114+ return undefined ;
115+ }
116+ const report = await track . getRTCStatsReport ( ) ;
117+ if ( ! report ) {
118+ return undefined ;
119+ }
120+
121+ for ( const v of report . values ( ) ) {
122+ if ( v . type === "inbound-rtp" ) {
123+ const { framesDecoded, framesDropped, framesReceived } =
124+ v as RTCInboundRtpStreamStats ;
125+ return {
126+ framesDecoded,
127+ framesDropped,
128+ framesReceived,
129+ } ;
130+ }
131+ }
132+
133+ return undefined ;
134+ } ) ,
135+ filter ( ( newStats ) => ! ! newStats ) ,
136+ map ( ( newStats ) : boolean | undefined => {
137+ const oldStats = lastStats ;
138+ lastStats = newStats ;
139+ if (
140+ typeof newStats . framesReceived === "number" &&
141+ typeof oldStats . framesReceived === "number" &&
142+ typeof newStats . framesDecoded === "number" &&
143+ typeof oldStats . framesDecoded === "number"
144+ ) {
145+ const framesReceivedDelta =
146+ newStats . framesReceived - oldStats . framesReceived ;
147+ const framesDecodedDelta =
148+ newStats . framesDecoded - oldStats . framesDecoded ;
149+
150+ // if we received >0 frames and managed to decode >0 frames then we treat that as success
151+
152+ if ( framesReceivedDelta > 0 ) {
153+ return framesDecodedDelta > 0 ;
154+ }
155+ }
156+
157+ // no change
158+ return undefined ;
159+ } ) ,
160+ filter ( ( x ) => typeof x === "boolean" ) ,
161+ startWith ( undefined ) ,
162+ ) ;
163+ }
164+
165+ function encryptionErrorObservable (
166+ room : LivekitRoom ,
167+ participant : Participant ,
168+ encryptionSystem : EncryptionSystem ,
169+ criteria : string ,
170+ ) : Observable < boolean > {
171+ return roomEventSelector ( room , LivekitRoomEvent . EncryptionError ) . pipe (
172+ map ( ( e ) => {
173+ const [ err ] = e ;
174+ if ( encryptionSystem . kind === E2eeType . PER_PARTICIPANT ) {
175+ return (
176+ // Ideally we would pull the participant identity from the field on the error.
177+ // However, it gets lost in the serialization process between workers.
178+ // So, instead we do a string match
179+ ( err ?. message . includes ( participant . identity ) &&
180+ err ?. message . includes ( criteria ) ) ??
181+ false
182+ ) ;
183+ } else if ( encryptionSystem . kind === E2eeType . SHARED_KEY ) {
184+ return ! ! err ?. message . includes ( criteria ) ;
185+ }
186+
187+ return false ;
188+ } ) ,
189+ throttleTime ( 1000 ) , // Throttle to avoid spamming the UI
190+ startWith ( false ) ,
191+ ) ;
192+ }
193+
194+ export enum EncryptionStatus {
195+ Connecting ,
196+ Okay ,
197+ KeyMissing ,
198+ KeyInvalid ,
199+ PasswordInvalid ,
200+ }
201+
84202abstract class BaseMediaViewModel extends ViewModel {
85203 /**
86204 * Whether the media belongs to the local user.
@@ -95,6 +213,8 @@ abstract class BaseMediaViewModel extends ViewModel {
95213 */
96214 public readonly unencryptedWarning : Observable < boolean > ;
97215
216+ public readonly encryptionStatus : Observable < EncryptionStatus > ;
217+
98218 public constructor (
99219 /**
100220 * An opaque identifier for this media.
@@ -110,6 +230,7 @@ abstract class BaseMediaViewModel extends ViewModel {
110230 encryptionSystem : EncryptionSystem ,
111231 audioSource : AudioSource ,
112232 videoSource : VideoSource ,
233+ livekitRoom : LivekitRoom ,
113234 ) {
114235 super ( ) ;
115236 const audio = observeTrackReference ( participant , audioSource ) . pipe (
@@ -124,7 +245,64 @@ abstract class BaseMediaViewModel extends ViewModel {
124245 encryptionSystem . kind !== E2eeType . NONE &&
125246 ( a . publication ?. isEncrypted === false ||
126247 v . publication ?. isEncrypted === false ) ,
127- ) . pipe ( this . scope . state ( ) ) ;
248+ ) . pipe ( distinctUntilChanged ( ) , shareReplay ( 1 ) ) ;
249+
250+ if ( participant . isLocal || encryptionSystem . kind === E2eeType . NONE ) {
251+ this . encryptionStatus = of ( EncryptionStatus . Okay ) . pipe (
252+ this . scope . state ( ) ,
253+ ) ;
254+ } else if ( encryptionSystem . kind === E2eeType . PER_PARTICIPANT ) {
255+ this . encryptionStatus = combineLatest ( [
256+ encryptionErrorObservable (
257+ livekitRoom ,
258+ participant ,
259+ encryptionSystem ,
260+ "MissingKey" ,
261+ ) ,
262+ encryptionErrorObservable (
263+ livekitRoom ,
264+ participant ,
265+ encryptionSystem ,
266+ "InvalidKey" ,
267+ ) ,
268+ observeRemoteTrackReceivingOkay ( participant , audioSource ) ,
269+ observeRemoteTrackReceivingOkay ( participant , videoSource ) ,
270+ ] ) . pipe (
271+ map ( ( [ keyMissing , keyInvalid , audioOkay , videoOkay ] ) => {
272+ if ( keyMissing ) return EncryptionStatus . KeyMissing ;
273+ if ( keyInvalid ) return EncryptionStatus . KeyInvalid ;
274+ if ( audioOkay || videoOkay ) return EncryptionStatus . Okay ;
275+ return undefined ; // no change
276+ } ) ,
277+ filter ( ( x ) => ! ! x ) ,
278+ startWith ( EncryptionStatus . Connecting ) ,
279+ this . scope . state ( ) ,
280+ ) ;
281+ } else {
282+ this . encryptionStatus = combineLatest ( [
283+ encryptionErrorObservable (
284+ livekitRoom ,
285+ participant ,
286+ encryptionSystem ,
287+ "InvalidKey" ,
288+ ) ,
289+ observeRemoteTrackReceivingOkay ( participant , audioSource ) ,
290+ observeRemoteTrackReceivingOkay ( participant , videoSource ) ,
291+ ] ) . pipe (
292+ map (
293+ ( [ keyInvalid , audioOkay , videoOkay ] ) :
294+ | EncryptionStatus
295+ | undefined => {
296+ if ( keyInvalid ) return EncryptionStatus . PasswordInvalid ;
297+ if ( audioOkay || videoOkay ) return EncryptionStatus . Okay ;
298+ return undefined ; // no change
299+ } ,
300+ ) ,
301+ filter ( ( x ) => ! ! x ) ,
302+ startWith ( EncryptionStatus . Connecting ) ,
303+ this . scope . state ( ) ,
304+ ) ;
305+ }
128306 }
129307}
130308
@@ -171,6 +349,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
171349 member : RoomMember | undefined ,
172350 participant : LocalParticipant | RemoteParticipant ,
173351 encryptionSystem : EncryptionSystem ,
352+ livekitRoom : LivekitRoom ,
174353 ) {
175354 super (
176355 id ,
@@ -179,6 +358,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
179358 encryptionSystem ,
180359 Track . Source . Microphone ,
181360 Track . Source . Camera ,
361+ livekitRoom ,
182362 ) ;
183363
184364 const media = observeParticipantMedia ( participant ) . pipe ( this . scope . state ( ) ) ;
@@ -228,8 +408,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
228408 member : RoomMember | undefined ,
229409 participant : LocalParticipant ,
230410 encryptionSystem : EncryptionSystem ,
411+ livekitRoom : LivekitRoom ,
231412 ) {
232- super ( id , member , participant , encryptionSystem ) ;
413+ super ( id , member , participant , encryptionSystem , livekitRoom ) ;
233414 }
234415}
235416
@@ -288,8 +469,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
288469 member : RoomMember | undefined ,
289470 participant : RemoteParticipant ,
290471 encryptionSystem : EncryptionSystem ,
472+ livekitRoom : LivekitRoom ,
291473 ) {
292- super ( id , member , participant , encryptionSystem ) ;
474+ super ( id , member , participant , encryptionSystem , livekitRoom ) ;
293475
294476 // Sync the local volume with LiveKit
295477 this . localVolume
@@ -321,6 +503,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
321503 member : RoomMember | undefined ,
322504 participant : LocalParticipant | RemoteParticipant ,
323505 encryptionSystem : EncryptionSystem ,
506+ livekitRoom : LivekitRoom ,
324507 ) {
325508 super (
326509 id ,
@@ -329,6 +512,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
329512 encryptionSystem ,
330513 Track . Source . ScreenShareAudio ,
331514 Track . Source . ScreenShare ,
515+ livekitRoom ,
332516 ) ;
333517 }
334518}
0 commit comments