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
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { type JoinResponse, type ParticipantUpdate } from '@livekit/protocol';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import type { Throws } from '@livekit/throws-transformer/throws';
|
|
4
|
+
import type TypedEmitter from 'typed-emitter';
|
|
5
|
+
import type { BaseE2EEManager } from '../../../e2ee/E2eeManager';
|
|
6
|
+
import { LoggerNames, getLogger } from '../../../logger';
|
|
7
|
+
import { abortSignalAny, abortSignalTimeout } from '../../../utils/abort-signal-polyfill';
|
|
8
|
+
import type Participant from '../../participant/Participant';
|
|
9
|
+
import type RemoteParticipant from '../../participant/RemoteParticipant';
|
|
10
|
+
import { Future } from '../../utils';
|
|
11
|
+
import RemoteDataTrack from '../RemoteDataTrack';
|
|
12
|
+
import { DataTrackDepacketizerDropError } from '../depacketizer';
|
|
13
|
+
import { type DataTrackFrame, DataTrackFrameInternal } from '../frame';
|
|
14
|
+
import { DataTrackHandle } from '../handle';
|
|
15
|
+
import { DataTrackPacket } from '../packet';
|
|
16
|
+
import { type DataTrackInfo, type DataTrackSid } from '../types';
|
|
17
|
+
import { DataTrackSubscribeError } from './errors';
|
|
18
|
+
import IncomingDataTrackPipeline from './pipeline';
|
|
19
|
+
import {
|
|
20
|
+
type EventSfuUpdateSubscription,
|
|
21
|
+
type EventTrackAvailable,
|
|
22
|
+
type EventTrackUnavailable,
|
|
23
|
+
} from './types';
|
|
24
|
+
|
|
25
|
+
const log = getLogger(LoggerNames.DataTracks);
|
|
26
|
+
|
|
27
|
+
export type DataTrackIncomingManagerCallbacks = {
|
|
28
|
+
/** Request sent to the SFU to update the subscription for a data track. */
|
|
29
|
+
sfuUpdateSubscription: (event: EventSfuUpdateSubscription) => void;
|
|
30
|
+
|
|
31
|
+
/** A track has been published by a remote participant and is available to be
|
|
32
|
+
* subscribed to. */
|
|
33
|
+
trackPublished: (event: EventTrackAvailable) => void;
|
|
34
|
+
|
|
35
|
+
/** A track has been unpublished by a remote participant and can no longer be subscribed to. */
|
|
36
|
+
trackUnpublished: (event: EventTrackUnavailable) => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Track is not subscribed to. */
|
|
40
|
+
type SubscriptionStateNone = { type: 'none' };
|
|
41
|
+
/** Track is being subscribed to, waiting for subscriber handle. */
|
|
42
|
+
type SubscriptionStatePending = {
|
|
43
|
+
type: 'pending';
|
|
44
|
+
completionFuture: Future<void, DataTrackSubscribeError>;
|
|
45
|
+
/** The number of in flight requests waiting for this subscription state to go to "active". */
|
|
46
|
+
pendingRequestCount: number;
|
|
47
|
+
/** A function that when called, cancels the pending subscription and moves back to "none". */
|
|
48
|
+
cancel: () => void;
|
|
49
|
+
};
|
|
50
|
+
/** Track has an active subscription. */
|
|
51
|
+
type SubscriptionStateActive = {
|
|
52
|
+
type: 'active';
|
|
53
|
+
subcriptionHandle: DataTrackHandle;
|
|
54
|
+
pipeline: IncomingDataTrackPipeline;
|
|
55
|
+
streamControllers: Set<ReadableStreamDefaultController<DataTrackFrame>>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type SubscriptionState = SubscriptionStateNone | SubscriptionStatePending | SubscriptionStateActive;
|
|
59
|
+
|
|
60
|
+
/** Information and state for a remote data track. */
|
|
61
|
+
type Descriptor<S extends SubscriptionState> = {
|
|
62
|
+
info: DataTrackInfo;
|
|
63
|
+
publisherIdentity: Participant['identity'];
|
|
64
|
+
subscription: S;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type IncomingDataTrackManagerOptions = {
|
|
68
|
+
/** Provider to use for decrypting incoming frame payloads.
|
|
69
|
+
* If none, remote tracks using end-to-end encryption will not be available
|
|
70
|
+
* for subscription.
|
|
71
|
+
*/
|
|
72
|
+
e2eeManager?: BaseE2EEManager;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** How long to wait when attempting to subscribe before timing out. */
|
|
76
|
+
const SUBSCRIBE_TIMEOUT_MILLISECONDS = 10_000;
|
|
77
|
+
|
|
78
|
+
/** Maximum number of {@link DataTrackFrame}s that are cached for each ReadableStream subscription.
|
|
79
|
+
* If data comes in too fast and saturates this threshold, backpressure will be applied. */
|
|
80
|
+
const READABLE_STREAM_DEFAULT_BUFFER_SIZE = 16;
|
|
81
|
+
|
|
82
|
+
export default class IncomingDataTrackManager extends (EventEmitter as new () => TypedEmitter<DataTrackIncomingManagerCallbacks>) {
|
|
83
|
+
private e2eeManager: BaseE2EEManager | null;
|
|
84
|
+
|
|
85
|
+
/** Mapping between track SID and descriptor. */
|
|
86
|
+
private descriptors = new Map<DataTrackSid, Descriptor<SubscriptionState>>();
|
|
87
|
+
|
|
88
|
+
/** Mapping between subscriber handle and track SID.
|
|
89
|
+
*
|
|
90
|
+
* This is an index that allows track descriptors to be looked up
|
|
91
|
+
* by subscriber handle in O(1) time, to make routing incoming packets
|
|
92
|
+
* a (hot code path) faster.
|
|
93
|
+
*/
|
|
94
|
+
private subscriptionHandles = new Map<DataTrackHandle, DataTrackSid>();
|
|
95
|
+
|
|
96
|
+
constructor(options?: IncomingDataTrackManagerOptions) {
|
|
97
|
+
super();
|
|
98
|
+
this.e2eeManager = options?.e2eeManager ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @internal */
|
|
102
|
+
updateE2eeManager(e2eeManager: BaseE2EEManager | null) {
|
|
103
|
+
this.e2eeManager = e2eeManager;
|
|
104
|
+
|
|
105
|
+
// Propegate downwards to all pre-existing pipelines
|
|
106
|
+
for (const descriptor of this.descriptors.values()) {
|
|
107
|
+
if (descriptor.subscription.type === 'active') {
|
|
108
|
+
descriptor.subscription.pipeline.updateE2eeManager(e2eeManager);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Allocates a ReadableStream which emits when a new {@link DataTrackFrame} is received from the
|
|
114
|
+
* SFU. The SFU subscription is initiated lazily when the stream is created.
|
|
115
|
+
*
|
|
116
|
+
* @returns A tuple of the ReadableStream and a Promise that resolves once the SFU subscription
|
|
117
|
+
* is fully established / the stream is ready to receive frames.
|
|
118
|
+
*
|
|
119
|
+
* @internal
|
|
120
|
+
**/
|
|
121
|
+
openSubscriptionStream(
|
|
122
|
+
sid: DataTrackSid,
|
|
123
|
+
signal?: AbortSignal,
|
|
124
|
+
bufferSize = READABLE_STREAM_DEFAULT_BUFFER_SIZE,
|
|
125
|
+
): [ReadableStream<DataTrackFrame>, Promise<Throws<void, DataTrackSubscribeError>>] {
|
|
126
|
+
let streamController: ReadableStreamDefaultController<DataTrackFrame> | null = null;
|
|
127
|
+
const sfuSubscriptionComplete = new Future<void, DataTrackSubscribeError>();
|
|
128
|
+
|
|
129
|
+
const stream = new ReadableStream<DataTrackFrame>(
|
|
130
|
+
{
|
|
131
|
+
start: (controller) => {
|
|
132
|
+
streamController = controller;
|
|
133
|
+
|
|
134
|
+
const onAbort = () => {
|
|
135
|
+
controller.error(DataTrackSubscribeError.cancelled());
|
|
136
|
+
sfuSubscriptionComplete.reject?.(DataTrackSubscribeError.cancelled());
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
this.subscribeRequest(sid, signal)
|
|
140
|
+
.then(async () => {
|
|
141
|
+
signal?.addEventListener('abort', onAbort);
|
|
142
|
+
|
|
143
|
+
const descriptor = this.descriptors.get(sid);
|
|
144
|
+
if (!descriptor) {
|
|
145
|
+
log.error(`Unknown track ${sid}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (descriptor.subscription.type !== 'active') {
|
|
149
|
+
log.error(`Subscription for track ${sid} is not active`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
descriptor.subscription.streamControllers.add(controller);
|
|
154
|
+
sfuSubscriptionComplete.resolve?.();
|
|
155
|
+
})
|
|
156
|
+
.catch((err) => {
|
|
157
|
+
controller.error(err);
|
|
158
|
+
sfuSubscriptionComplete.reject?.(err);
|
|
159
|
+
})
|
|
160
|
+
.finally(() => {
|
|
161
|
+
signal?.removeEventListener('abort', onAbort);
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
cancel: () => {
|
|
165
|
+
if (!streamController) {
|
|
166
|
+
log.warn(`ReadableStream subscribed to ${sid} was not started.`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const descriptor = this.descriptors.get(sid);
|
|
170
|
+
if (!descriptor) {
|
|
171
|
+
log.warn(`Unknown track ${sid}, skipping cancel...`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (descriptor.subscription.type !== 'active') {
|
|
175
|
+
log.warn(`Subscription for track ${sid} is not active, skipping cancel...`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
descriptor.subscription.streamControllers.delete(streamController);
|
|
180
|
+
|
|
181
|
+
// If no active stream controllers are left, also unsubscribe on the SFU end.
|
|
182
|
+
if (descriptor.subscription.streamControllers.size === 0) {
|
|
183
|
+
this.unSubscribeRequest(descriptor.info.sid);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
new CountQueuingStrategy({ highWaterMark: bufferSize }),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return [stream, sfuSubscriptionComplete.promise];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Client requested to subscribe to a data track.
|
|
194
|
+
*
|
|
195
|
+
* This is sent when the user calls {@link RemoteDataTrack.subscribe}.
|
|
196
|
+
*
|
|
197
|
+
* Only the first request to subscribe to a given track incurs meaningful overhead; subsequent
|
|
198
|
+
* requests simply attach an additional receiver to the broadcast channel, allowing them to consume
|
|
199
|
+
* frames from the existing subscription pipeline.
|
|
200
|
+
*/
|
|
201
|
+
async subscribeRequest(
|
|
202
|
+
sid: DataTrackSid,
|
|
203
|
+
signal?: AbortSignal,
|
|
204
|
+
): Promise<Throws<void, DataTrackSubscribeError>> {
|
|
205
|
+
const descriptor = this.descriptors.get(sid);
|
|
206
|
+
if (!descriptor) {
|
|
207
|
+
// @throws-transformer ignore - this should be treated as a "panic" and not be caught
|
|
208
|
+
throw new Error('Cannot subscribe to unknown track');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const waitForCompletionFuture = async (
|
|
212
|
+
currentDescriptor: Descriptor<SubscriptionState>,
|
|
213
|
+
userProvidedSignal?: AbortSignal,
|
|
214
|
+
timeoutSignal?: AbortSignal,
|
|
215
|
+
) => {
|
|
216
|
+
if (currentDescriptor.subscription.type === 'active') {
|
|
217
|
+
// Subscription has already become active! So bail out early, there is nothing to wait for.
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (currentDescriptor.subscription.type !== 'pending') {
|
|
221
|
+
// @throws-transformer ignore - this should be treated as a "panic" and not be caught
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Descriptor for track ${sid} is not pending, found ${currentDescriptor.subscription.type}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const combinedSignal = abortSignalAny(
|
|
228
|
+
[userProvidedSignal, timeoutSignal].filter(
|
|
229
|
+
(s): s is AbortSignal => typeof s !== 'undefined',
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const proxiedCompletionFuture = new Future<void, DataTrackSubscribeError>();
|
|
234
|
+
currentDescriptor.subscription.completionFuture.promise
|
|
235
|
+
.then(() => proxiedCompletionFuture.resolve?.())
|
|
236
|
+
.catch((err) => proxiedCompletionFuture.reject?.(err));
|
|
237
|
+
|
|
238
|
+
const onAbort = () => {
|
|
239
|
+
if (currentDescriptor.subscription.type !== 'pending') {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
currentDescriptor.subscription.pendingRequestCount -= 1;
|
|
243
|
+
|
|
244
|
+
if (timeoutSignal?.aborted) {
|
|
245
|
+
// A timeout should apply to the underlying SFU subscription and cancel all user
|
|
246
|
+
// subscriptions.
|
|
247
|
+
currentDescriptor.subscription.cancel();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (currentDescriptor.subscription.pendingRequestCount <= 0) {
|
|
252
|
+
// No user subscriptions are still pending, so cancel the underlying pending `sfuUpdateSubscription`
|
|
253
|
+
currentDescriptor.subscription.cancel();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Other subscriptions are still pending for this data track, so just cancel this one
|
|
258
|
+
// active user subscription, and leave the rest of the user subscriptions alone.
|
|
259
|
+
proxiedCompletionFuture.reject?.(DataTrackSubscribeError.cancelled());
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (combinedSignal.aborted) {
|
|
263
|
+
onAbort();
|
|
264
|
+
}
|
|
265
|
+
combinedSignal.addEventListener('abort', onAbort);
|
|
266
|
+
await proxiedCompletionFuture.promise;
|
|
267
|
+
combinedSignal.removeEventListener('abort', onAbort);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
switch (descriptor.subscription.type) {
|
|
271
|
+
case 'none': {
|
|
272
|
+
descriptor.subscription = {
|
|
273
|
+
type: 'pending',
|
|
274
|
+
completionFuture: new Future(),
|
|
275
|
+
pendingRequestCount: 1,
|
|
276
|
+
cancel: () => {
|
|
277
|
+
const previousDescriptorSubscription = descriptor.subscription;
|
|
278
|
+
descriptor.subscription = { type: 'none' };
|
|
279
|
+
|
|
280
|
+
// Let the SFU know that the subscribe has been cancelled
|
|
281
|
+
this.emit('sfuUpdateSubscription', { sid, subscribe: false });
|
|
282
|
+
|
|
283
|
+
if (previousDescriptorSubscription.type === 'pending') {
|
|
284
|
+
previousDescriptorSubscription.completionFuture.reject?.(
|
|
285
|
+
timeoutSignal.aborted
|
|
286
|
+
? DataTrackSubscribeError.timeout()
|
|
287
|
+
: // NOTE: the below cancelled case was introduced by web / there isn't a corresponding case in the rust version.
|
|
288
|
+
DataTrackSubscribeError.cancelled(),
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
this.emit('sfuUpdateSubscription', { sid, subscribe: true });
|
|
295
|
+
|
|
296
|
+
const timeoutSignal = abortSignalTimeout(SUBSCRIBE_TIMEOUT_MILLISECONDS);
|
|
297
|
+
|
|
298
|
+
// Wait for the subscription to complete, or time out if it takes too long
|
|
299
|
+
await waitForCompletionFuture(descriptor, signal, timeoutSignal);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
case 'pending': {
|
|
303
|
+
descriptor.subscription.pendingRequestCount += 1;
|
|
304
|
+
|
|
305
|
+
// Wait for the subscription to complete
|
|
306
|
+
await waitForCompletionFuture(descriptor, signal);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
case 'active': {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Get information about all currently subscribed tracks.
|
|
317
|
+
* @internal */
|
|
318
|
+
async querySubscribed() {
|
|
319
|
+
const descriptorInfos = Array.from(this.descriptors.values())
|
|
320
|
+
.filter(
|
|
321
|
+
(descriptor): descriptor is Descriptor<SubscriptionStateActive> =>
|
|
322
|
+
descriptor.subscription.type === 'active',
|
|
323
|
+
)
|
|
324
|
+
.map(
|
|
325
|
+
(descriptor) =>
|
|
326
|
+
[descriptor.info, descriptor.publisherIdentity] as [
|
|
327
|
+
info: DataTrackInfo,
|
|
328
|
+
identity: Participant['identity'],
|
|
329
|
+
],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
return descriptorInfos;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Client requested to unsubscribe from a data track. */
|
|
336
|
+
unSubscribeRequest(sid: DataTrackSid) {
|
|
337
|
+
const descriptor = this.descriptors.get(sid);
|
|
338
|
+
if (!descriptor) {
|
|
339
|
+
// FIXME: rust implementation returns here, not throws
|
|
340
|
+
// @throws-transformer ignore - this should be treated as a "panic" and not be caught
|
|
341
|
+
throw new Error('Cannot subscribe to unknown track');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (descriptor.subscription.type !== 'active') {
|
|
345
|
+
log.warn(
|
|
346
|
+
`Unexpected descriptor state in unSubscribeRequest, expected active, found ${descriptor.subscription?.type}`,
|
|
347
|
+
);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const controller of descriptor.subscription.streamControllers) {
|
|
352
|
+
controller.close();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// FIXME: this might be wrong? Shouldn't this only occur if it is the last subscription to
|
|
356
|
+
// terminate?
|
|
357
|
+
const previousDescriptorSubscription = descriptor.subscription;
|
|
358
|
+
descriptor.subscription = { type: 'none' };
|
|
359
|
+
this.subscriptionHandles.delete(previousDescriptorSubscription.subcriptionHandle);
|
|
360
|
+
|
|
361
|
+
this.emit('sfuUpdateSubscription', { sid, subscribe: false });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** SFU notification that track publications have changed.
|
|
365
|
+
*
|
|
366
|
+
* This event is produced from both {@link JoinResponse} and {@link ParticipantUpdate}
|
|
367
|
+
* to provide a complete view of remote participants' track publications:
|
|
368
|
+
*
|
|
369
|
+
* - From a `JoinResponse`, it captures the initial set of tracks published when a participant joins.
|
|
370
|
+
* - From a `ParticipantUpdate`, it captures subsequent changes (i.e., new tracks being
|
|
371
|
+
* published and existing tracks unpublished).
|
|
372
|
+
*/
|
|
373
|
+
async receiveSfuPublicationUpdates(updates: Map<Participant['identity'], Array<DataTrackInfo>>) {
|
|
374
|
+
if (updates.size === 0) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Detect published track
|
|
379
|
+
const publisherParticipantToSidsInUpdate = new Map<
|
|
380
|
+
Participant['identity'],
|
|
381
|
+
Set<DataTrackSid>
|
|
382
|
+
>();
|
|
383
|
+
for (const [publisherIdentity, infos] of updates.entries()) {
|
|
384
|
+
const sidsInUpdate = new Set<DataTrackSid>();
|
|
385
|
+
for (const info of infos) {
|
|
386
|
+
sidsInUpdate.add(info.sid);
|
|
387
|
+
if (this.descriptors.has(info.sid)) {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
await this.handleTrackPublished(publisherIdentity, info);
|
|
391
|
+
}
|
|
392
|
+
publisherParticipantToSidsInUpdate.set(publisherIdentity, sidsInUpdate);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Detect unpublished tracks
|
|
396
|
+
for (const [publisherIdentity, sidsInUpdate] of publisherParticipantToSidsInUpdate.entries()) {
|
|
397
|
+
const descriptorsForPublisher = Array.from(this.descriptors.entries())
|
|
398
|
+
.filter(([_sid, descriptor]) => descriptor.publisherIdentity === publisherIdentity)
|
|
399
|
+
.map(([sid]) => sid);
|
|
400
|
+
let unpublishedSids = descriptorsForPublisher.filter((sid) => !sidsInUpdate.has(sid));
|
|
401
|
+
for (const sid of unpublishedSids) {
|
|
402
|
+
this.handleTrackUnpublished(sid);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Get information about all currently remotely published tracks which could be subscribed to.
|
|
409
|
+
* @internal */
|
|
410
|
+
async queryPublications() {
|
|
411
|
+
return Array.from(this.descriptors.values()).map((descriptor) => descriptor.info);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async handleTrackPublished(publisherIdentity: Participant['identity'], info: DataTrackInfo) {
|
|
415
|
+
if (this.descriptors.has(info.sid)) {
|
|
416
|
+
log.error(`Existing descriptor for track ${info.sid}`);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
let descriptor: Descriptor<SubscriptionStateNone> = {
|
|
420
|
+
info,
|
|
421
|
+
publisherIdentity,
|
|
422
|
+
subscription: { type: 'none' },
|
|
423
|
+
};
|
|
424
|
+
this.descriptors.set(descriptor.info.sid, descriptor);
|
|
425
|
+
|
|
426
|
+
const track = new RemoteDataTrack(descriptor.info, this, { publisherIdentity });
|
|
427
|
+
this.emit('trackPublished', { track });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
handleTrackUnpublished(sid: DataTrackSid) {
|
|
431
|
+
const descriptor = this.descriptors.get(sid);
|
|
432
|
+
if (!descriptor) {
|
|
433
|
+
log.error(`Unknown track ${sid}`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.descriptors.delete(sid);
|
|
437
|
+
|
|
438
|
+
if (descriptor.subscription.type === 'active') {
|
|
439
|
+
this.subscriptionHandles.delete(descriptor.subscription.subcriptionHandle);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
this.emit('trackUnpublished', { sid, publisherIdentity: descriptor.publisherIdentity });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** SFU notification that handles have been assigned for requested subscriptions. */
|
|
446
|
+
receivedSfuSubscriberHandles(
|
|
447
|
+
/** Mapping between track handles attached to incoming packets to the
|
|
448
|
+
* track SIDs they belong to. */
|
|
449
|
+
mapping: Map<DataTrackHandle, DataTrackSid>,
|
|
450
|
+
) {
|
|
451
|
+
for (const [handle, sid] of mapping.entries()) {
|
|
452
|
+
this.registerSubscriberHandle(handle, sid);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private registerSubscriberHandle(assignedHandle: DataTrackHandle, sid: DataTrackSid) {
|
|
457
|
+
const descriptor = this.descriptors.get(sid);
|
|
458
|
+
if (!descriptor) {
|
|
459
|
+
log.error(`Unknown track ${sid}`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
switch (descriptor.subscription.type) {
|
|
463
|
+
case 'none': {
|
|
464
|
+
// Handle assigned when there is no pending or active subscription is unexpected.
|
|
465
|
+
log.warn(`No subscription for ${sid}`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
case 'active': {
|
|
469
|
+
// Update handle for an active subscription. This can occur following a full reconnect.
|
|
470
|
+
descriptor.subscription.subcriptionHandle = assignedHandle;
|
|
471
|
+
this.subscriptionHandles.set(assignedHandle, sid);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
case 'pending': {
|
|
475
|
+
const pipeline = new IncomingDataTrackPipeline({
|
|
476
|
+
info: descriptor.info,
|
|
477
|
+
publisherIdentity: descriptor.publisherIdentity,
|
|
478
|
+
e2eeManager: this.e2eeManager,
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const previousDescriptorSubscription = descriptor.subscription;
|
|
482
|
+
descriptor.subscription = {
|
|
483
|
+
type: 'active',
|
|
484
|
+
subcriptionHandle: assignedHandle,
|
|
485
|
+
pipeline,
|
|
486
|
+
streamControllers: new Set(),
|
|
487
|
+
};
|
|
488
|
+
this.subscriptionHandles.set(assignedHandle, sid);
|
|
489
|
+
|
|
490
|
+
previousDescriptorSubscription.completionFuture.resolve?.();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Packet has been received over the transport. */
|
|
496
|
+
async packetReceived(bytes: Uint8Array): Promise<Throws<void, DataTrackDepacketizerDropError>> {
|
|
497
|
+
let packet: DataTrackPacket;
|
|
498
|
+
try {
|
|
499
|
+
[packet] = DataTrackPacket.fromBinary(bytes);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
log.error(`Failed to deserialize packet: ${err}`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const sid = this.subscriptionHandles.get(packet.header.trackHandle);
|
|
506
|
+
if (!sid) {
|
|
507
|
+
log.warn(`Unknown subscriber handle ${packet.header.trackHandle}`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const descriptor = this.descriptors.get(sid);
|
|
512
|
+
if (!descriptor) {
|
|
513
|
+
log.error(`Missing descriptor for track ${sid}`);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (descriptor.subscription.type !== 'active') {
|
|
518
|
+
log.warn(`Received packet for track ${sid} without active subscription`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const internalFrame = await descriptor.subscription.pipeline.processPacket(packet);
|
|
523
|
+
if (!internalFrame) {
|
|
524
|
+
// Not all packets have been received yet to form a complete frame
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Broadcast to all downstream subscribers
|
|
529
|
+
for (const controller of descriptor.subscription.streamControllers) {
|
|
530
|
+
if (controller.desiredSize !== null && controller.desiredSize <= 0) {
|
|
531
|
+
log.warn(
|
|
532
|
+
`Cannot send frame to subscribers: readable stream is full (desiredSize is ${controller.desiredSize}). To increase this threshold, set a higher 'options.highWaterMark' when calling .subscribe().`,
|
|
533
|
+
);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const frame = DataTrackFrameInternal.lossyIntoFrame(internalFrame);
|
|
537
|
+
controller.enqueue(frame);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Resend all subscription updates.
|
|
542
|
+
*
|
|
543
|
+
* This must be sent after a full reconnect to ensure the SFU knows which
|
|
544
|
+
* tracks are subscribed to locally.
|
|
545
|
+
*/
|
|
546
|
+
resendSubscriptionUpdates() {
|
|
547
|
+
for (const [sid, descriptor] of this.descriptors) {
|
|
548
|
+
if (descriptor.subscription.type === 'none') {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
this.emit('sfuUpdateSubscription', { sid, subscribe: true });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Called when a remote participant is disconnected so that any pending data tracks can be
|
|
556
|
+
* cancelled. */
|
|
557
|
+
handleRemoteParticipantDisconnected(remoteParticipantIdentity: RemoteParticipant['identity']) {
|
|
558
|
+
for (const descriptor of this.descriptors.values()) {
|
|
559
|
+
if (descriptor.publisherIdentity !== remoteParticipantIdentity) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
switch (descriptor.subscription.type) {
|
|
563
|
+
case 'none':
|
|
564
|
+
break;
|
|
565
|
+
case 'pending':
|
|
566
|
+
descriptor.subscription.completionFuture.reject?.(DataTrackSubscribeError.disconnected());
|
|
567
|
+
break;
|
|
568
|
+
case 'active':
|
|
569
|
+
this.unSubscribeRequest(descriptor.info.sid);
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Shutdown the manager, ending any subscriptions. */
|
|
576
|
+
shutdown() {
|
|
577
|
+
for (const descriptor of this.descriptors.values()) {
|
|
578
|
+
this.emit('trackUnpublished', {
|
|
579
|
+
sid: descriptor.info.sid,
|
|
580
|
+
publisherIdentity: descriptor.publisherIdentity,
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (descriptor.subscription.type === 'pending') {
|
|
584
|
+
descriptor.subscription.completionFuture.reject?.(DataTrackSubscribeError.disconnected());
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
this.descriptors.clear();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { LivekitReasonedError } from '../../errors';
|
|
2
|
+
|
|
3
|
+
export enum DataTrackSubscribeErrorReason {
|
|
4
|
+
/** The track has been unpublished and is no longer available */
|
|
5
|
+
Unpublished = 0,
|
|
6
|
+
/** Request to subscribe to data track timed-out */
|
|
7
|
+
Timeout = 1,
|
|
8
|
+
/** Cannot subscribe to data track when disconnected */
|
|
9
|
+
Disconnected = 2,
|
|
10
|
+
/** Subscription to data track cancelled by caller */
|
|
11
|
+
Cancelled = 4,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class DataTrackSubscribeError<
|
|
15
|
+
Reason extends DataTrackSubscribeErrorReason = DataTrackSubscribeErrorReason,
|
|
16
|
+
> extends LivekitReasonedError<Reason> {
|
|
17
|
+
readonly name = 'DataTrackSubscribeError';
|
|
18
|
+
|
|
19
|
+
reason: Reason;
|
|
20
|
+
|
|
21
|
+
reasonName: string;
|
|
22
|
+
|
|
23
|
+
constructor(message: string, reason: Reason, options?: { cause?: unknown }) {
|
|
24
|
+
super(22, message, options);
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
this.reasonName = DataTrackSubscribeErrorReason[reason];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static unpublished() {
|
|
30
|
+
return new DataTrackSubscribeError(
|
|
31
|
+
'The track has been unpublished and is no longer available',
|
|
32
|
+
DataTrackSubscribeErrorReason.Unpublished,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static timeout() {
|
|
37
|
+
return new DataTrackSubscribeError(
|
|
38
|
+
'Request to subscribe to data track timed-out',
|
|
39
|
+
DataTrackSubscribeErrorReason.Timeout,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static disconnected() {
|
|
44
|
+
return new DataTrackSubscribeError(
|
|
45
|
+
'Cannot subscribe to data track when disconnected',
|
|
46
|
+
DataTrackSubscribeErrorReason.Disconnected,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// NOTE: this was introduced by web / there isn't a corresponding case in the rust version.
|
|
51
|
+
static cancelled() {
|
|
52
|
+
return new DataTrackSubscribeError(
|
|
53
|
+
'Subscription to data track cancelled by caller',
|
|
54
|
+
DataTrackSubscribeErrorReason.Cancelled,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|