@@ -11,6 +11,7 @@ import {
11
11
VideoSource ,
12
12
observeParticipantEvents ,
13
13
observeParticipantMedia ,
14
+ roomEventSelector ,
14
15
} from "@livekit/components-core" ;
15
16
import {
16
17
LocalParticipant ,
@@ -21,20 +22,28 @@ import {
21
22
Track ,
22
23
TrackEvent ,
23
24
facingModeFromLocalTrack ,
25
+ Room as LivekitRoom ,
26
+ RoomEvent as LivekitRoomEvent ,
27
+ RemoteTrack ,
24
28
} from "livekit-client" ;
25
29
import { RoomMember , RoomMemberEvent } from "matrix-js-sdk/src/matrix" ;
26
30
import {
27
31
BehaviorSubject ,
28
32
Observable ,
29
33
Subject ,
30
34
combineLatest ,
35
+ distinctUntilChanged ,
31
36
distinctUntilKeyChanged ,
37
+ filter ,
32
38
fromEvent ,
39
+ interval ,
33
40
map ,
34
41
merge ,
35
42
of ,
43
+ shareReplay ,
36
44
startWith ,
37
45
switchMap ,
46
+ throttleTime ,
38
47
} from "rxjs" ;
39
48
import { useEffect } from "react" ;
40
49
@@ -81,6 +90,115 @@ export function observeTrackReference(
81
90
) ;
82
91
}
83
92
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
+
84
202
abstract class BaseMediaViewModel extends ViewModel {
85
203
/**
86
204
* Whether the media belongs to the local user.
@@ -95,6 +213,8 @@ abstract class BaseMediaViewModel extends ViewModel {
95
213
*/
96
214
public readonly unencryptedWarning : Observable < boolean > ;
97
215
216
+ public readonly encryptionStatus : Observable < EncryptionStatus > ;
217
+
98
218
public constructor (
99
219
/**
100
220
* An opaque identifier for this media.
@@ -110,6 +230,7 @@ abstract class BaseMediaViewModel extends ViewModel {
110
230
encryptionSystem : EncryptionSystem ,
111
231
audioSource : AudioSource ,
112
232
videoSource : VideoSource ,
233
+ livekitRoom : LivekitRoom ,
113
234
) {
114
235
super ( ) ;
115
236
const audio = observeTrackReference ( participant , audioSource ) . pipe (
@@ -124,7 +245,64 @@ abstract class BaseMediaViewModel extends ViewModel {
124
245
encryptionSystem . kind !== E2eeType . NONE &&
125
246
( a . publication ?. isEncrypted === false ||
126
247
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
+ }
128
306
}
129
307
}
130
308
@@ -171,6 +349,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
171
349
member : RoomMember | undefined ,
172
350
participant : LocalParticipant | RemoteParticipant ,
173
351
encryptionSystem : EncryptionSystem ,
352
+ livekitRoom : LivekitRoom ,
174
353
) {
175
354
super (
176
355
id ,
@@ -179,6 +358,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
179
358
encryptionSystem ,
180
359
Track . Source . Microphone ,
181
360
Track . Source . Camera ,
361
+ livekitRoom ,
182
362
) ;
183
363
184
364
const media = observeParticipantMedia ( participant ) . pipe ( this . scope . state ( ) ) ;
@@ -228,8 +408,9 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
228
408
member : RoomMember | undefined ,
229
409
participant : LocalParticipant ,
230
410
encryptionSystem : EncryptionSystem ,
411
+ livekitRoom : LivekitRoom ,
231
412
) {
232
- super ( id , member , participant , encryptionSystem ) ;
413
+ super ( id , member , participant , encryptionSystem , livekitRoom ) ;
233
414
}
234
415
}
235
416
@@ -288,8 +469,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
288
469
member : RoomMember | undefined ,
289
470
participant : RemoteParticipant ,
290
471
encryptionSystem : EncryptionSystem ,
472
+ livekitRoom : LivekitRoom ,
291
473
) {
292
- super ( id , member , participant , encryptionSystem ) ;
474
+ super ( id , member , participant , encryptionSystem , livekitRoom ) ;
293
475
294
476
// Sync the local volume with LiveKit
295
477
this . localVolume
@@ -321,6 +503,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
321
503
member : RoomMember | undefined ,
322
504
participant : LocalParticipant | RemoteParticipant ,
323
505
encryptionSystem : EncryptionSystem ,
506
+ livekitRoom : LivekitRoom ,
324
507
) {
325
508
super (
326
509
id ,
@@ -329,6 +512,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
329
512
encryptionSystem ,
330
513
Track . Source . ScreenShareAudio ,
331
514
Track . Source . ScreenShare ,
515
+ livekitRoom ,
332
516
) ;
333
517
}
334
518
}
0 commit comments