livekit-client 2.17.3 → 2.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/livekit-client.e2ee.worker.js +1 -1
- package/dist/livekit-client.e2ee.worker.js.map +1 -1
- package/dist/livekit-client.e2ee.worker.mjs +8 -7
- package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
- package/dist/livekit-client.esm.mjs +7823 -5772
- package/dist/livekit-client.esm.mjs.map +1 -1
- package/dist/livekit-client.umd.js +1 -1
- package/dist/livekit-client.umd.js.map +1 -1
- package/dist/src/api/SignalClient.d.ts +12 -4
- package/dist/src/api/SignalClient.d.ts.map +1 -1
- package/dist/src/e2ee/constants.d.ts.map +1 -1
- package/dist/src/e2ee/types.d.ts +6 -0
- package/dist/src/e2ee/types.d.ts.map +1 -1
- package/dist/src/e2ee/utils.d.ts +2 -1
- package/dist/src/e2ee/utils.d.ts.map +1 -1
- package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
- package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
- package/dist/src/index.d.ts +5 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/room/PCTransport.d.ts +5 -0
- package/dist/src/room/PCTransport.d.ts.map +1 -1
- package/dist/src/room/PCTransportManager.d.ts +1 -1
- package/dist/src/room/PCTransportManager.d.ts.map +1 -1
- package/dist/src/room/RTCEngine.d.ts +27 -9
- package/dist/src/room/RTCEngine.d.ts.map +1 -1
- package/dist/src/room/Room.d.ts +13 -3
- package/dist/src/room/Room.d.ts.map +1 -1
- package/dist/src/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts.map +1 -1
- package/dist/src/room/data-track/LocalDataTrack.d.ts +48 -0
- package/dist/src/room/data-track/LocalDataTrack.d.ts.map +1 -0
- package/dist/src/room/data-track/RemoteDataTrack.d.ts +46 -0
- package/dist/src/room/data-track/RemoteDataTrack.d.ts.map +1 -0
- package/dist/src/room/data-track/depacketizer.d.ts +6 -6
- package/dist/src/room/data-track/depacketizer.d.ts.map +1 -1
- package/dist/src/room/data-track/frame.d.ts +14 -0
- package/dist/src/room/data-track/frame.d.ts.map +1 -1
- package/dist/src/room/data-track/handle.d.ts +2 -2
- package/dist/src/room/data-track/handle.d.ts.map +1 -1
- package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +104 -0
- package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -0
- package/dist/src/room/data-track/incoming/errors.d.ts +24 -0
- package/dist/src/room/data-track/incoming/errors.d.ts.map +1 -0
- package/dist/src/room/data-track/incoming/pipeline.d.ts +38 -0
- package/dist/src/room/data-track/incoming/pipeline.d.ts.map +1 -0
- package/dist/src/room/data-track/incoming/types.d.ts +20 -0
- package/dist/src/room/data-track/incoming/types.d.ts.map +1 -0
- package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +63 -28
- package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
- package/dist/src/room/data-track/outgoing/errors.d.ts +20 -10
- package/dist/src/room/data-track/outgoing/errors.d.ts.map +1 -1
- package/dist/src/room/data-track/outgoing/pipeline.d.ts +9 -8
- package/dist/src/room/data-track/outgoing/pipeline.d.ts.map +1 -1
- package/dist/src/room/data-track/outgoing/types.d.ts +16 -7
- package/dist/src/room/data-track/outgoing/types.d.ts.map +1 -1
- package/dist/src/room/data-track/packet/errors.d.ts +2 -4
- package/dist/src/room/data-track/packet/errors.d.ts.map +1 -1
- package/dist/src/room/data-track/packet/extensions.d.ts +4 -4
- package/dist/src/room/data-track/packet/extensions.d.ts.map +1 -1
- package/dist/src/room/data-track/packet/index.d.ts +5 -5
- package/dist/src/room/data-track/packet/index.d.ts.map +1 -1
- package/dist/src/room/data-track/packet/serializable.d.ts +4 -4
- package/dist/src/room/data-track/packet/serializable.d.ts.map +1 -1
- package/dist/src/room/data-track/packetizer.d.ts +6 -6
- package/dist/src/room/data-track/packetizer.d.ts.map +1 -1
- package/dist/src/room/data-track/track-interfaces.d.ts +23 -0
- package/dist/src/room/data-track/track-interfaces.d.ts.map +1 -0
- package/dist/src/room/data-track/types.d.ts +15 -0
- package/dist/src/room/data-track/types.d.ts.map +1 -0
- package/dist/src/room/events.d.ts +24 -3
- package/dist/src/room/events.d.ts.map +1 -1
- package/dist/src/room/participant/LocalParticipant.d.ts +11 -1
- package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
- package/dist/src/room/participant/RemoteParticipant.d.ts +14 -1
- package/dist/src/room/participant/RemoteParticipant.d.ts.map +1 -1
- package/dist/src/room/token-source/TokenSource.d.ts +1 -1
- package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
- package/dist/src/room/token-source/types.d.ts +1 -0
- package/dist/src/room/token-source/types.d.ts.map +1 -1
- package/dist/src/room/utils.d.ts +2 -1
- package/dist/src/room/utils.d.ts.map +1 -1
- package/dist/src/utils/abort-signal-polyfill.d.ts +13 -0
- package/dist/src/utils/abort-signal-polyfill.d.ts.map +1 -0
- package/dist/src/utils/deferrable-map.d.ts +32 -0
- package/dist/src/utils/deferrable-map.d.ts.map +1 -0
- package/dist/src/utils/subscribeToEvents.d.ts +3 -0
- package/dist/src/utils/subscribeToEvents.d.ts.map +1 -1
- package/dist/ts4.2/api/SignalClient.d.ts +12 -4
- package/dist/ts4.2/e2ee/types.d.ts +6 -0
- package/dist/ts4.2/e2ee/utils.d.ts +2 -1
- package/dist/ts4.2/index.d.ts +5 -4
- package/dist/ts4.2/room/PCTransport.d.ts +5 -0
- package/dist/ts4.2/room/PCTransportManager.d.ts +1 -1
- package/dist/ts4.2/room/RTCEngine.d.ts +27 -9
- package/dist/ts4.2/room/Room.d.ts +13 -3
- package/dist/ts4.2/room/data-track/LocalDataTrack.d.ts +48 -0
- package/dist/ts4.2/room/data-track/RemoteDataTrack.d.ts +46 -0
- package/dist/ts4.2/room/data-track/depacketizer.d.ts +6 -6
- package/dist/ts4.2/room/data-track/frame.d.ts +14 -0
- package/dist/ts4.2/room/data-track/handle.d.ts +2 -2
- package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +110 -0
- package/dist/ts4.2/room/data-track/incoming/errors.d.ts +24 -0
- package/dist/ts4.2/room/data-track/incoming/pipeline.d.ts +38 -0
- package/dist/ts4.2/room/data-track/incoming/types.d.ts +20 -0
- package/dist/ts4.2/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +63 -29
- package/dist/ts4.2/room/data-track/outgoing/errors.d.ts +20 -10
- package/dist/ts4.2/room/data-track/outgoing/pipeline.d.ts +9 -8
- package/dist/ts4.2/room/data-track/outgoing/types.d.ts +16 -7
- package/dist/ts4.2/room/data-track/packet/errors.d.ts +2 -4
- package/dist/ts4.2/room/data-track/packet/extensions.d.ts +4 -4
- package/dist/ts4.2/room/data-track/packet/index.d.ts +5 -6
- package/dist/ts4.2/room/data-track/packet/serializable.d.ts +4 -4
- package/dist/ts4.2/room/data-track/packetizer.d.ts +6 -6
- package/dist/ts4.2/room/data-track/track-interfaces.d.ts +23 -0
- package/dist/ts4.2/room/data-track/types.d.ts +15 -0
- package/dist/ts4.2/room/events.d.ts +24 -3
- package/dist/ts4.2/room/participant/LocalParticipant.d.ts +11 -1
- package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +14 -1
- package/dist/ts4.2/room/token-source/TokenSource.d.ts +1 -1
- package/dist/ts4.2/room/token-source/types.d.ts +1 -0
- package/dist/ts4.2/room/utils.d.ts +2 -1
- package/dist/ts4.2/utils/abort-signal-polyfill.d.ts +13 -0
- package/dist/ts4.2/utils/deferrable-map.d.ts +32 -0
- package/dist/ts4.2/utils/subscribeToEvents.d.ts +3 -0
- package/package.json +4 -2
- package/src/api/SignalClient.test.ts +9 -4
- package/src/api/SignalClient.ts +116 -9
- package/src/e2ee/constants.ts +1 -0
- package/src/e2ee/types.ts +6 -0
- package/src/e2ee/utils.ts +4 -3
- package/src/e2ee/worker/DataCryptor.ts +1 -4
- package/src/e2ee/worker/FrameCryptor.ts +1 -4
- package/src/e2ee/worker/ParticipantKeyHandler.ts +1 -1
- package/src/index.ts +6 -4
- package/src/room/PCTransport.ts +41 -1
- package/src/room/PCTransportManager.ts +1 -1
- package/src/room/RTCEngine.ts +274 -112
- package/src/room/Room.ts +152 -15
- package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +9 -9
- package/src/room/data-track/LocalDataTrack.ts +126 -0
- package/src/room/data-track/RemoteDataTrack.ts +80 -0
- package/src/room/data-track/depacketizer.ts +23 -26
- package/src/room/data-track/frame.ts +28 -2
- package/src/room/data-track/handle.ts +2 -8
- package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +555 -0
- package/src/room/data-track/incoming/IncomingDataTrackManager.ts +589 -0
- package/src/room/data-track/incoming/errors.ts +57 -0
- package/src/room/data-track/incoming/pipeline.ts +121 -0
- package/src/room/data-track/incoming/types.ts +22 -0
- package/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +240 -27
- package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +165 -84
- package/src/room/data-track/outgoing/errors.ts +40 -11
- package/src/room/data-track/outgoing/pipeline.ts +25 -23
- package/src/room/data-track/outgoing/types.ts +14 -6
- package/src/room/data-track/packet/errors.ts +2 -14
- package/src/room/data-track/packet/extensions.ts +21 -26
- package/src/room/data-track/packet/index.test.ts +22 -33
- package/src/room/data-track/packet/index.ts +4 -6
- package/src/room/data-track/packet/serializable.ts +4 -4
- package/src/room/data-track/packetizer.test.ts +2 -2
- package/src/room/data-track/packetizer.ts +7 -10
- package/src/room/data-track/track-interfaces.ts +53 -0
- package/src/room/data-track/types.ts +31 -0
- package/src/room/events.ts +26 -1
- package/src/room/participant/LocalParticipant.ts +57 -6
- package/src/room/participant/RemoteParticipant.ts +26 -1
- package/src/room/token-source/TokenSource.ts +8 -2
- package/src/room/token-source/types.ts +4 -0
- package/src/room/utils.ts +5 -1
- package/src/utils/abort-signal-polyfill.ts +63 -0
- package/src/utils/deferrable-map.ts +109 -0
- package/src/utils/subscribeToEvents.ts +18 -1
- package/dist/src/room/data-track/e2ee.d.ts +0 -12
- package/dist/src/room/data-track/e2ee.d.ts.map +0 -1
- package/dist/src/room/data-track/track.d.ts +0 -30
- package/dist/src/room/data-track/track.d.ts.map +0 -1
- package/dist/src/utils/throws.d.ts +0 -36
- package/dist/src/utils/throws.d.ts.map +0 -1
- package/dist/ts4.2/room/data-track/e2ee.d.ts +0 -12
- package/dist/ts4.2/room/data-track/track.d.ts +0 -30
- package/dist/ts4.2/utils/throws.d.ts +0 -39
- package/src/room/data-track/e2ee.ts +0 -14
- package/src/room/data-track/track.ts +0 -50
- package/src/utils/throws.ts +0 -42
package/src/room/Room.ts
CHANGED
|
@@ -47,7 +47,7 @@ import TypedPromise from '../utils/TypedPromise';
|
|
|
47
47
|
import { getBrowser } from '../utils/browserParser';
|
|
48
48
|
import { BackOffStrategy } from './BackOffStrategy';
|
|
49
49
|
import DeviceManager from './DeviceManager';
|
|
50
|
-
import RTCEngine from './RTCEngine';
|
|
50
|
+
import RTCEngine, { DataChannelKind } from './RTCEngine';
|
|
51
51
|
import { RegionUrlProvider } from './RegionUrlProvider';
|
|
52
52
|
import IncomingDataStreamManager from './data-stream/incoming/IncomingDataStreamManager';
|
|
53
53
|
import {
|
|
@@ -55,6 +55,11 @@ import {
|
|
|
55
55
|
type TextStreamHandler,
|
|
56
56
|
} from './data-stream/incoming/StreamReader';
|
|
57
57
|
import OutgoingDataStreamManager from './data-stream/outgoing/OutgoingDataStreamManager';
|
|
58
|
+
import type LocalDataTrack from './data-track/LocalDataTrack';
|
|
59
|
+
import type RemoteDataTrack from './data-track/RemoteDataTrack';
|
|
60
|
+
import IncomingDataTrackManager from './data-track/incoming/IncomingDataTrackManager';
|
|
61
|
+
import OutgoingDataTrackManager from './data-track/outgoing/OutgoingDataTrackManager';
|
|
62
|
+
import { DataTrackInfo, type DataTrackSid } from './data-track/types';
|
|
58
63
|
import {
|
|
59
64
|
audioDefaults,
|
|
60
65
|
publishDefaults,
|
|
@@ -70,7 +75,7 @@ import {
|
|
|
70
75
|
} from './errors';
|
|
71
76
|
import { EngineEvent, ParticipantEvent, RoomEvent, TrackEvent } from './events';
|
|
72
77
|
import LocalParticipant from './participant/LocalParticipant';
|
|
73
|
-
import
|
|
78
|
+
import Participant from './participant/Participant';
|
|
74
79
|
import { type ConnectionQuality, ParticipantKind } from './participant/Participant';
|
|
75
80
|
import RemoteParticipant from './participant/RemoteParticipant';
|
|
76
81
|
import { MAX_PAYLOAD_BYTES, RpcError, type RpcInvocationData, byteLength } from './rpc';
|
|
@@ -205,6 +210,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
205
210
|
|
|
206
211
|
private outgoingDataStreamManager: OutgoingDataStreamManager;
|
|
207
212
|
|
|
213
|
+
private incomingDataTrackManager: IncomingDataTrackManager;
|
|
214
|
+
|
|
215
|
+
private outgoingDataTrackManager: OutgoingDataTrackManager;
|
|
216
|
+
|
|
208
217
|
private rpcHandlers: Map<string, (data: RpcInvocationData) => Promise<string>> = new Map();
|
|
209
218
|
|
|
210
219
|
get hasE2EESetup(): boolean {
|
|
@@ -243,6 +252,46 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
243
252
|
this.incomingDataStreamManager = new IncomingDataStreamManager();
|
|
244
253
|
this.outgoingDataStreamManager = new OutgoingDataStreamManager(this.engine, this.log);
|
|
245
254
|
|
|
255
|
+
this.incomingDataTrackManager = new IncomingDataTrackManager({ e2eeManager: this.e2eeManager });
|
|
256
|
+
this.incomingDataTrackManager
|
|
257
|
+
.on('sfuUpdateSubscription', (event) => {
|
|
258
|
+
this.engine.client.sendUpdateDataSubscription(event.sid, event.subscribe);
|
|
259
|
+
})
|
|
260
|
+
.on('trackPublished', (event) => {
|
|
261
|
+
if (event.track.publisherIdentity === this.localParticipant.identity) {
|
|
262
|
+
// Only advertize tracks from other participants
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
this.emit(RoomEvent.DataTrackPublished, event.track);
|
|
266
|
+
this.remoteParticipants.get(event.track.publisherIdentity)?.addRemoteDataTrack(event.track);
|
|
267
|
+
})
|
|
268
|
+
.on('trackUnpublished', (event) => {
|
|
269
|
+
if (event.publisherIdentity === this.localParticipant.identity) {
|
|
270
|
+
// Only advertize tracks from other participants
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
this.emit(RoomEvent.DataTrackUnpublished, event.sid);
|
|
274
|
+
this.remoteParticipants.get(event.publisherIdentity)?.removeRemoteDataTrack(event.sid);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
this.outgoingDataTrackManager = new OutgoingDataTrackManager({ e2eeManager: this.e2eeManager });
|
|
278
|
+
this.outgoingDataTrackManager
|
|
279
|
+
.on('sfuPublishRequest', (event) => {
|
|
280
|
+
this.engine.client.sendPublishDataTrackRequest(event.handle, event.name, event.usesE2ee);
|
|
281
|
+
})
|
|
282
|
+
.on('sfuUnpublishRequest', (event) => {
|
|
283
|
+
this.engine.client.sendUnPublishDataTrackRequest(event.handle);
|
|
284
|
+
})
|
|
285
|
+
.on('trackPublished', (event) => {
|
|
286
|
+
this.emit(RoomEvent.LocalDataTrackPublished, event.track);
|
|
287
|
+
})
|
|
288
|
+
.on('trackUnpublished', (event) => {
|
|
289
|
+
this.emit(RoomEvent.LocalDataTrackUnpublished, event.sid);
|
|
290
|
+
})
|
|
291
|
+
.on('packetAvailable', ({ bytes }) => {
|
|
292
|
+
this.engine.sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY, 'wait');
|
|
293
|
+
});
|
|
294
|
+
|
|
246
295
|
this.disconnectLock = new Mutex();
|
|
247
296
|
|
|
248
297
|
this.localParticipant = new LocalParticipant(
|
|
@@ -252,6 +301,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
252
301
|
this.options,
|
|
253
302
|
this.rpcHandlers,
|
|
254
303
|
this.outgoingDataStreamManager,
|
|
304
|
+
this.outgoingDataTrackManager,
|
|
255
305
|
);
|
|
256
306
|
|
|
257
307
|
if (this.options.e2ee || this.options.encryption) {
|
|
@@ -259,6 +309,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
259
309
|
}
|
|
260
310
|
|
|
261
311
|
this.engine.e2eeManager = this.e2eeManager;
|
|
312
|
+
this.incomingDataTrackManager.updateE2eeManager(this.e2eeManager ?? null);
|
|
313
|
+
this.outgoingDataTrackManager.updateE2eeManager(this.e2eeManager ?? null);
|
|
262
314
|
|
|
263
315
|
if (this.options.videoCaptureDefaults.deviceId) {
|
|
264
316
|
this.localParticipant.activeDeviceMap.set(
|
|
@@ -406,7 +458,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
406
458
|
room: this.name,
|
|
407
459
|
roomID: this.roomInfo?.sid,
|
|
408
460
|
participant: this.localParticipant.identity,
|
|
409
|
-
|
|
461
|
+
participantID: this.localParticipant.sid,
|
|
410
462
|
};
|
|
411
463
|
}
|
|
412
464
|
|
|
@@ -464,7 +516,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
464
516
|
}
|
|
465
517
|
|
|
466
518
|
private maybeCreateEngine() {
|
|
467
|
-
if (this.engine && !this.engine.isClosed) {
|
|
519
|
+
if (this.engine && (this.engine.isNewlyCreated || !this.engine.isClosed)) {
|
|
468
520
|
return;
|
|
469
521
|
}
|
|
470
522
|
|
|
@@ -515,6 +567,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
515
567
|
}
|
|
516
568
|
})
|
|
517
569
|
.on(EngineEvent.Restarting, this.handleRestarting)
|
|
570
|
+
.on(EngineEvent.Restarted, this.handleRestarted)
|
|
518
571
|
.on(EngineEvent.SignalRestarted, this.handleSignalRestarted)
|
|
519
572
|
.on(EngineEvent.Offline, () => {
|
|
520
573
|
if (this.setAndEmitConnectionState(ConnectionState.Reconnecting)) {
|
|
@@ -560,6 +613,66 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
560
613
|
} else {
|
|
561
614
|
this.handleParticipantUpdates(roomMoved.otherParticipants);
|
|
562
615
|
}
|
|
616
|
+
})
|
|
617
|
+
.on(EngineEvent.PublishDataTrackResponse, (event) => {
|
|
618
|
+
if (!event.info) {
|
|
619
|
+
this.log.warn(
|
|
620
|
+
`received PublishDataTrackResponse, but event.info was ${event.info}, so skipping.`,
|
|
621
|
+
this.logContext,
|
|
622
|
+
);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
this.outgoingDataTrackManager.receivedSfuPublishResponse(event.info.pubHandle, {
|
|
627
|
+
type: 'ok',
|
|
628
|
+
data: {
|
|
629
|
+
sid: event.info.sid,
|
|
630
|
+
pubHandle: event.info.pubHandle,
|
|
631
|
+
name: event.info.name,
|
|
632
|
+
usesE2ee: event.info.encryption !== Encryption_Type.NONE,
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
})
|
|
636
|
+
.on(EngineEvent.UnPublishDataTrackResponse, (event) => {
|
|
637
|
+
if (!event.info) {
|
|
638
|
+
this.log.warn(
|
|
639
|
+
`received UnPublishDataTrackResponse, but event.info was ${event.info}, so skipping.`,
|
|
640
|
+
this.logContext,
|
|
641
|
+
);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
this.outgoingDataTrackManager.receivedSfuUnpublishResponse(event.info.pubHandle);
|
|
646
|
+
})
|
|
647
|
+
.on(EngineEvent.DataTrackSubscriberHandles, (event) => {
|
|
648
|
+
const handleToSidMapping = new Map(
|
|
649
|
+
Object.entries(event.subHandles).map(([key, value]) => {
|
|
650
|
+
return [parseInt(key, 10), value.trackSid];
|
|
651
|
+
}),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
this.incomingDataTrackManager.receivedSfuSubscriberHandles(handleToSidMapping);
|
|
655
|
+
})
|
|
656
|
+
.on(EngineEvent.DataTrackPacketReceived, (packetBytes) => {
|
|
657
|
+
try {
|
|
658
|
+
this.incomingDataTrackManager.packetReceived(packetBytes);
|
|
659
|
+
} catch (err) {
|
|
660
|
+
// NOTE: wrapping in the bare try/catch like this means that the Throws<...> type doesn't
|
|
661
|
+
// propagate upwards into the public interface.
|
|
662
|
+
throw err;
|
|
663
|
+
}
|
|
664
|
+
})
|
|
665
|
+
.on(EngineEvent.Joined, (joinResponse) => {
|
|
666
|
+
// Ingest data track publication updates into data tracks infrastructure
|
|
667
|
+
const mapped = new Map(
|
|
668
|
+
joinResponse.otherParticipants.map((participant) => {
|
|
669
|
+
return [
|
|
670
|
+
participant.identity,
|
|
671
|
+
participant.dataTracks.map((info) => DataTrackInfo.from(info)),
|
|
672
|
+
];
|
|
673
|
+
}),
|
|
674
|
+
);
|
|
675
|
+
this.incomingDataTrackManager.receiveSfuPublicationUpdates(mapped);
|
|
563
676
|
});
|
|
564
677
|
|
|
565
678
|
if (this.localParticipant) {
|
|
@@ -1456,10 +1569,13 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1456
1569
|
) as RemoteParticipant | undefined;
|
|
1457
1570
|
|
|
1458
1571
|
if (!participant) {
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
this.
|
|
1462
|
-
|
|
1572
|
+
// server could require extra media sections to accelerate subscription.
|
|
1573
|
+
if (participantSid.startsWith('PA')) {
|
|
1574
|
+
this.log.error(
|
|
1575
|
+
`Tried to add a track for a participant, that's not present. Sid: ${participantSid}`,
|
|
1576
|
+
this.logContext,
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1463
1579
|
return;
|
|
1464
1580
|
}
|
|
1465
1581
|
|
|
@@ -1481,7 +1597,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1481
1597
|
if (!trackId.startsWith('TR')) {
|
|
1482
1598
|
this.log.warn(
|
|
1483
1599
|
`Tried to add a track whose 'sid' could not be determined for a participant, that's not present. Sid: ${participantSid}, streamId: ${streamId}, trackId: ${trackId}`,
|
|
1484
|
-
{ ...this.logContext,
|
|
1600
|
+
{ ...this.logContext, remoteParticipantID: participantSid, streamId, trackId },
|
|
1485
1601
|
);
|
|
1486
1602
|
}
|
|
1487
1603
|
|
|
@@ -1527,6 +1643,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1527
1643
|
}
|
|
1528
1644
|
};
|
|
1529
1645
|
|
|
1646
|
+
private handleRestarted = () => {
|
|
1647
|
+
this.outgoingDataTrackManager.sfuWillRepublishTracks();
|
|
1648
|
+
this.incomingDataTrackManager.resendSubscriptionUpdates();
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1530
1651
|
private handleSignalRestarted = async (joinResponse: JoinResponse) => {
|
|
1531
1652
|
this.log.debug(`signal reconnected to server, region ${joinResponse.serverRegion}`, {
|
|
1532
1653
|
...this.logContext,
|
|
@@ -1640,10 +1761,10 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1640
1761
|
|
|
1641
1762
|
private handleParticipantUpdates = (participantInfos: ParticipantInfo[]) => {
|
|
1642
1763
|
// handle changes to participant state, and send events
|
|
1643
|
-
|
|
1764
|
+
for (const info of participantInfos) {
|
|
1644
1765
|
if (info.identity === this.localParticipant.identity) {
|
|
1645
1766
|
this.localParticipant.updateInfo(info);
|
|
1646
|
-
|
|
1767
|
+
continue;
|
|
1647
1768
|
}
|
|
1648
1769
|
|
|
1649
1770
|
// LiveKit server doesn't send identity info prior to version 1.5.2 in disconnect updates
|
|
@@ -1661,7 +1782,17 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1661
1782
|
// create participant if doesn't exist
|
|
1662
1783
|
remoteParticipant = this.getOrCreateParticipant(info.identity, info);
|
|
1663
1784
|
}
|
|
1664
|
-
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
// Ingest data track publication updates into data tracks infrastructure
|
|
1788
|
+
const mapped = new Map(
|
|
1789
|
+
participantInfos
|
|
1790
|
+
.filter((p) => p.identity !== this.localParticipant.identity)
|
|
1791
|
+
.map((info) => {
|
|
1792
|
+
return [info.identity, info.dataTracks.map((dataTrack) => DataTrackInfo.from(dataTrack))];
|
|
1793
|
+
}),
|
|
1794
|
+
);
|
|
1795
|
+
this.incomingDataTrackManager.receiveSfuPublicationUpdates(mapped);
|
|
1665
1796
|
};
|
|
1666
1797
|
|
|
1667
1798
|
private handleParticipantDisconnected(identity: string, participant?: RemoteParticipant) {
|
|
@@ -1672,6 +1803,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
1672
1803
|
}
|
|
1673
1804
|
|
|
1674
1805
|
this.incomingDataStreamManager.validateParticipantHasNoActiveDataStreams(identity);
|
|
1806
|
+
this.incomingDataTrackManager.handleRemoteParticipantDisconnected(identity);
|
|
1675
1807
|
|
|
1676
1808
|
participant.trackPublications.forEach((publication) => {
|
|
1677
1809
|
participant.unpublishTrack(publication.trackSid, true);
|
|
@@ -2192,7 +2324,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
2192
2324
|
track.on(TrackEvent.VideoPlaybackFailed, this.handleVideoPlaybackFailed);
|
|
2193
2325
|
track.on(TrackEvent.VideoPlaybackStarted, this.handleVideoPlaybackStarted);
|
|
2194
2326
|
}
|
|
2195
|
-
this.
|
|
2327
|
+
this.emitWhenConnected(RoomEvent.TrackSubscribed, track, publication, participant);
|
|
2196
2328
|
},
|
|
2197
2329
|
)
|
|
2198
2330
|
.on(ParticipantEvent.TrackUnpublished, (publication: RemoteTrackPublication) => {
|
|
@@ -2270,7 +2402,8 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
|
|
|
2270
2402
|
return acc;
|
|
2271
2403
|
}, [] as RemoteTrackPublication[]);
|
|
2272
2404
|
const localTracks = this.localParticipant.getTrackPublications() as LocalTrackPublication[]; // FIXME would be nice to have this return LocalTrackPublications directly instead of the type cast
|
|
2273
|
-
this.
|
|
2405
|
+
const localDataTrackInfos = this.outgoingDataTrackManager.queryPublished();
|
|
2406
|
+
this.engine.sendSyncState(remoteTracks, localTracks, localDataTrackInfos);
|
|
2274
2407
|
}
|
|
2275
2408
|
|
|
2276
2409
|
/**
|
|
@@ -2732,10 +2865,14 @@ export type RoomEventCallbacks = {
|
|
|
2732
2865
|
recordingStatusChanged: (recording: boolean) => void;
|
|
2733
2866
|
participantEncryptionStatusChanged: (encrypted: boolean, participant?: Participant) => void;
|
|
2734
2867
|
encryptionError: (error: Error, participant?: Participant) => void;
|
|
2735
|
-
dcBufferStatusChanged: (isLow: boolean, kind:
|
|
2868
|
+
dcBufferStatusChanged: (isLow: boolean, kind: DataChannelKind) => void;
|
|
2736
2869
|
activeDeviceChanged: (kind: MediaDeviceKind, deviceId: string) => void;
|
|
2737
2870
|
chatMessage: (message: ChatMessage, participant?: RemoteParticipant | LocalParticipant) => void;
|
|
2738
2871
|
localTrackSubscribed: (publication: LocalTrackPublication, participant: LocalParticipant) => void;
|
|
2739
2872
|
metricsReceived: (metrics: MetricsBatch, participant?: Participant) => void;
|
|
2740
2873
|
participantActive: (participant: Participant) => void;
|
|
2874
|
+
dataTrackPublished: (track: RemoteDataTrack) => void;
|
|
2875
|
+
dataTrackUnpublished: (sid: DataTrackSid) => void;
|
|
2876
|
+
localDataTrackPublished: (track: LocalDataTrack) => void;
|
|
2877
|
+
localDataTrackUnpublished: (sid: DataTrackSid) => void;
|
|
2741
2878
|
};
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { Mutex } from '@livekit/mutex';
|
|
2
2
|
import {
|
|
3
3
|
DataPacket,
|
|
4
|
-
DataPacket_Kind,
|
|
5
4
|
DataStream_ByteHeader,
|
|
6
5
|
DataStream_Chunk,
|
|
7
6
|
DataStream_Header,
|
|
@@ -12,6 +11,7 @@ import {
|
|
|
12
11
|
} from '@livekit/protocol';
|
|
13
12
|
import { type StructuredLogger } from '../../../logger';
|
|
14
13
|
import type RTCEngine from '../../RTCEngine';
|
|
14
|
+
import { DataChannelKind } from '../../RTCEngine';
|
|
15
15
|
import { EngineEvent } from '../../events';
|
|
16
16
|
import type {
|
|
17
17
|
ByteStreamInfo,
|
|
@@ -114,7 +114,7 @@ export default class OutgoingDataStreamManager {
|
|
|
114
114
|
mimeType: info.mimeType,
|
|
115
115
|
topic: info.topic,
|
|
116
116
|
timestamp: numberToBigInt(info.timestamp),
|
|
117
|
-
totalLength: numberToBigInt(
|
|
117
|
+
totalLength: numberToBigInt(info.size),
|
|
118
118
|
attributes: info.attributes,
|
|
119
119
|
contentHeader: {
|
|
120
120
|
case: 'textHeader',
|
|
@@ -137,7 +137,7 @@ export default class OutgoingDataStreamManager {
|
|
|
137
137
|
value: header,
|
|
138
138
|
},
|
|
139
139
|
});
|
|
140
|
-
await this.engine.sendDataPacket(packet,
|
|
140
|
+
await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE);
|
|
141
141
|
|
|
142
142
|
let chunkId = 0;
|
|
143
143
|
const engine = this.engine;
|
|
@@ -158,7 +158,7 @@ export default class OutgoingDataStreamManager {
|
|
|
158
158
|
value: chunk,
|
|
159
159
|
},
|
|
160
160
|
});
|
|
161
|
-
await engine.sendDataPacket(chunkPacket,
|
|
161
|
+
await engine.sendDataPacket(chunkPacket, DataChannelKind.RELIABLE);
|
|
162
162
|
|
|
163
163
|
chunkId += 1;
|
|
164
164
|
}
|
|
@@ -174,7 +174,7 @@ export default class OutgoingDataStreamManager {
|
|
|
174
174
|
value: trailer,
|
|
175
175
|
},
|
|
176
176
|
});
|
|
177
|
-
await engine.sendDataPacket(trailerPacket,
|
|
177
|
+
await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE);
|
|
178
178
|
},
|
|
179
179
|
abort(err) {
|
|
180
180
|
console.log('Sink error:', err);
|
|
@@ -240,7 +240,7 @@ export default class OutgoingDataStreamManager {
|
|
|
240
240
|
};
|
|
241
241
|
|
|
242
242
|
const header = new DataStream_Header({
|
|
243
|
-
totalLength: numberToBigInt(info.size
|
|
243
|
+
totalLength: numberToBigInt(info.size),
|
|
244
244
|
mimeType: info.mimeType,
|
|
245
245
|
streamId,
|
|
246
246
|
topic: info.topic,
|
|
@@ -262,7 +262,7 @@ export default class OutgoingDataStreamManager {
|
|
|
262
262
|
},
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
await this.engine.sendDataPacket(packet,
|
|
265
|
+
await this.engine.sendDataPacket(packet, DataChannelKind.RELIABLE);
|
|
266
266
|
|
|
267
267
|
let chunkId = 0;
|
|
268
268
|
const writeMutex = new Mutex();
|
|
@@ -288,7 +288,7 @@ export default class OutgoingDataStreamManager {
|
|
|
288
288
|
}),
|
|
289
289
|
},
|
|
290
290
|
});
|
|
291
|
-
await engine.sendDataPacket(chunkPacket,
|
|
291
|
+
await engine.sendDataPacket(chunkPacket, DataChannelKind.RELIABLE);
|
|
292
292
|
chunkId += 1;
|
|
293
293
|
byteOffset += subChunk.byteLength;
|
|
294
294
|
}
|
|
@@ -307,7 +307,7 @@ export default class OutgoingDataStreamManager {
|
|
|
307
307
|
value: trailer,
|
|
308
308
|
},
|
|
309
309
|
});
|
|
310
|
-
await engine.sendDataPacket(trailerPacket,
|
|
310
|
+
await engine.sendDataPacket(trailerPacket, DataChannelKind.RELIABLE);
|
|
311
311
|
},
|
|
312
312
|
abort(err) {
|
|
313
313
|
logLocal.error('Sink error:', err);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import log, { LoggerNames, type StructuredLogger, getLogger } from '../../logger';
|
|
2
|
+
import { type DataTrackFrame, DataTrackFrameInternal } from './frame';
|
|
3
|
+
import type { DataTrackHandle } from './handle';
|
|
4
|
+
import type OutgoingDataTrackManager from './outgoing/OutgoingDataTrackManager';
|
|
5
|
+
import { DataTrackPushFrameError } from './outgoing/errors';
|
|
6
|
+
import type { DataTrackOptions } from './outgoing/types';
|
|
7
|
+
import {
|
|
8
|
+
DataTrackSymbol,
|
|
9
|
+
type IDataTrack,
|
|
10
|
+
type ILocalTrack,
|
|
11
|
+
TrackSymbol,
|
|
12
|
+
} from './track-interfaces';
|
|
13
|
+
import type { DataTrackInfo } from './types';
|
|
14
|
+
|
|
15
|
+
export default class LocalDataTrack implements ILocalTrack, IDataTrack {
|
|
16
|
+
readonly trackSymbol = TrackSymbol;
|
|
17
|
+
|
|
18
|
+
readonly isLocal = true;
|
|
19
|
+
|
|
20
|
+
readonly typeSymbol = DataTrackSymbol;
|
|
21
|
+
|
|
22
|
+
protected options: DataTrackOptions;
|
|
23
|
+
|
|
24
|
+
/** Represents the currently active {@link DataTrackHandle} for the publication. */
|
|
25
|
+
protected handle: DataTrackHandle | null = null;
|
|
26
|
+
|
|
27
|
+
protected manager: OutgoingDataTrackManager;
|
|
28
|
+
|
|
29
|
+
protected log: StructuredLogger = log;
|
|
30
|
+
|
|
31
|
+
/** @internal */
|
|
32
|
+
constructor(options: DataTrackOptions, manager: OutgoingDataTrackManager) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.manager = manager;
|
|
35
|
+
|
|
36
|
+
this.log = getLogger(LoggerNames.DataTracks);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @internal */
|
|
40
|
+
static withExplicitHandle(
|
|
41
|
+
options: DataTrackOptions,
|
|
42
|
+
manager: OutgoingDataTrackManager,
|
|
43
|
+
handle: DataTrackHandle,
|
|
44
|
+
) {
|
|
45
|
+
const track = new LocalDataTrack(options, manager);
|
|
46
|
+
track.handle = handle;
|
|
47
|
+
return track;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Metrics about the data track publication. */
|
|
51
|
+
get info() {
|
|
52
|
+
const descriptor = this.descriptor;
|
|
53
|
+
if (descriptor?.type === 'active') {
|
|
54
|
+
return descriptor.info;
|
|
55
|
+
} else {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The raw descriptor from the manager containing the internal state for this local track. */
|
|
61
|
+
protected get descriptor() {
|
|
62
|
+
return this.handle ? this.manager.getDescriptor(this.handle) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Publish the track to the SFU. This must be done before calling {@link tryPush} for the first time.
|
|
67
|
+
* @internal
|
|
68
|
+
* */
|
|
69
|
+
async publish(signal?: AbortSignal) {
|
|
70
|
+
try {
|
|
71
|
+
this.handle = await this.manager.publishRequest(this.options, signal);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
// NOTE: Rethrow errors to break Throws<...> type boundary
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isPublished(): this is { info: DataTrackInfo } {
|
|
79
|
+
// NOTE: a track which is internally in the "resubscribing" state is still considered
|
|
80
|
+
// published from the public API perspective.
|
|
81
|
+
return this.descriptor?.type === 'active' && this.descriptor.publishState !== 'unpublished';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Try pushing a frame to subscribers of the track.
|
|
85
|
+
*
|
|
86
|
+
* Pushing a frame can fail for several reasons:
|
|
87
|
+
*
|
|
88
|
+
* - The track has been unpublished by the local participant or SFU
|
|
89
|
+
* - The room is no longer connected
|
|
90
|
+
*/
|
|
91
|
+
tryPush(frame: DataTrackFrame) {
|
|
92
|
+
if (!this.handle) {
|
|
93
|
+
throw DataTrackPushFrameError.trackUnpublished();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const internalFrame = DataTrackFrameInternal.from(frame);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
return this.manager.tryProcessAndSend(this.handle, internalFrame);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// NOTE: wrapping in the bare try/catch like this means that the Throws<...> type doesn't
|
|
102
|
+
// propagate upwards into the public interface.
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Unpublish the track from the SFU. Once this is called, any further calls to {@link tryPush}
|
|
109
|
+
* will fail.
|
|
110
|
+
* */
|
|
111
|
+
async unpublish() {
|
|
112
|
+
if (!this.handle) {
|
|
113
|
+
log.warn(
|
|
114
|
+
`Data track "${this.options.name}" is not published, so unpublishing has no effect.`,
|
|
115
|
+
);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await this.manager.unpublishRequest(this.handle);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// NOTE: Rethrow errors to break Throws<...> type boundary
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type Participant from '../participant/Participant';
|
|
2
|
+
import { type DataTrackFrame } from './frame';
|
|
3
|
+
import type IncomingDataTrackManager from './incoming/IncomingDataTrackManager';
|
|
4
|
+
import {
|
|
5
|
+
DataTrackSymbol,
|
|
6
|
+
type IDataTrack,
|
|
7
|
+
type IRemoteTrack,
|
|
8
|
+
TrackSymbol,
|
|
9
|
+
} from './track-interfaces';
|
|
10
|
+
import { type DataTrackInfo } from './types';
|
|
11
|
+
|
|
12
|
+
type RemoteDataTrackOptions = {
|
|
13
|
+
publisherIdentity: Participant['identity'];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DataTrackSubscribeOptions = {
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
|
|
19
|
+
/** The number of {@link DataTrackFrame}s to hold in the ReadableStream before disgarding extra
|
|
20
|
+
* frames. Defaults to 16, but this may not be good enough for especially high frequency data. */
|
|
21
|
+
bufferSize?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default class RemoteDataTrack implements IRemoteTrack, IDataTrack {
|
|
25
|
+
readonly trackSymbol = TrackSymbol;
|
|
26
|
+
|
|
27
|
+
readonly isLocal = false;
|
|
28
|
+
|
|
29
|
+
readonly typeSymbol = DataTrackSymbol;
|
|
30
|
+
|
|
31
|
+
info: DataTrackInfo;
|
|
32
|
+
|
|
33
|
+
publisherIdentity: Participant['identity'];
|
|
34
|
+
|
|
35
|
+
protected manager: IncomingDataTrackManager;
|
|
36
|
+
|
|
37
|
+
/** @internal */
|
|
38
|
+
constructor(
|
|
39
|
+
info: DataTrackInfo,
|
|
40
|
+
manager: IncomingDataTrackManager,
|
|
41
|
+
options: RemoteDataTrackOptions,
|
|
42
|
+
) {
|
|
43
|
+
this.info = info;
|
|
44
|
+
this.manager = manager;
|
|
45
|
+
this.publisherIdentity = options.publisherIdentity;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Subscribes to the data track to receive frames.
|
|
49
|
+
*
|
|
50
|
+
* # Returns
|
|
51
|
+
*
|
|
52
|
+
* A stream that yields {@link DataTrackFrame}s as they arrive.
|
|
53
|
+
*
|
|
54
|
+
* # Multiple Subscriptions
|
|
55
|
+
*
|
|
56
|
+
* An application may call `subscribe` more than once to process frames in
|
|
57
|
+
* multiple places. For example, one async task might plot values on a graph
|
|
58
|
+
* while another writes them to a file.
|
|
59
|
+
*
|
|
60
|
+
* Internally, only the first call to `subscribe` communicates with the SFU and
|
|
61
|
+
* allocates the resources required to receive frames. Additional subscriptions
|
|
62
|
+
* reuse the same underlying pipeline and do not trigger additional signaling.
|
|
63
|
+
*
|
|
64
|
+
* Note that newly created subscriptions only receive frames published after
|
|
65
|
+
* the initial subscription is established.
|
|
66
|
+
*/
|
|
67
|
+
subscribe(options?: DataTrackSubscribeOptions): ReadableStream<DataTrackFrame> {
|
|
68
|
+
try {
|
|
69
|
+
const [stream] = this.manager.openSubscriptionStream(
|
|
70
|
+
this.info.sid,
|
|
71
|
+
options?.signal,
|
|
72
|
+
options?.bufferSize,
|
|
73
|
+
);
|
|
74
|
+
return stream;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// NOTE: Rethrow errors to break Throws<...> type boundary
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|