@conference-kit/vue 0.0.5 → 0.0.6
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/features.d.ts +9 -0
- package/dist/features.d.ts.map +1 -0
- package/dist/features.js +7 -0
- package/dist/features.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/signaling.d.ts +16 -1
- package/dist/signaling.d.ts.map +1 -1
- package/dist/signaling.js +66 -5
- package/dist/signaling.js.map +1 -1
- package/dist/useMeshRoom.d.ts +16 -4
- package/dist/useMeshRoom.d.ts.map +1 -1
- package/dist/useMeshRoom.js +273 -8
- package/dist/useMeshRoom.js.map +1 -1
- package/package.json +1 -1
- package/src/features.ts +17 -0
- package/src/index.ts +1 -0
- package/src/signaling.ts +108 -9
- package/src/useMeshRoom.ts +332 -12
package/src/useMeshRoom.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ref, onMounted, onUnmounted, computed, watch } from "vue";
|
|
2
2
|
import { Peer, type PeerSide, type SignalData } from "@conference-kit/core";
|
|
3
3
|
import { SignalingClient } from "./signaling";
|
|
4
|
+
import { mergeFeatures, type FeatureConfig } from "./features";
|
|
4
5
|
|
|
5
6
|
export type MeshParticipant = {
|
|
6
7
|
id: string;
|
|
@@ -12,26 +13,32 @@ export type MeshParticipant = {
|
|
|
12
13
|
|
|
13
14
|
export type UseMeshRoomOptions = {
|
|
14
15
|
peerId: string;
|
|
16
|
+
displayName?: string;
|
|
15
17
|
room: string;
|
|
16
18
|
signalingUrl: string;
|
|
19
|
+
isHost?: boolean;
|
|
17
20
|
mediaConstraints?: MediaStreamConstraints;
|
|
18
21
|
rtcConfig?: RTCConfiguration;
|
|
19
22
|
trickle?: boolean;
|
|
20
23
|
autoReconnect?: boolean;
|
|
21
|
-
features?:
|
|
24
|
+
features?: FeatureConfig;
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
25
28
|
const {
|
|
26
29
|
peerId,
|
|
30
|
+
displayName: initialDisplayName,
|
|
27
31
|
room,
|
|
28
32
|
signalingUrl,
|
|
33
|
+
isHost,
|
|
29
34
|
mediaConstraints,
|
|
30
35
|
rtcConfig,
|
|
31
36
|
trickle,
|
|
32
37
|
autoReconnect,
|
|
33
38
|
} = options;
|
|
34
39
|
|
|
40
|
+
const features = mergeFeatures(options.features ?? {});
|
|
41
|
+
|
|
35
42
|
const localStream = ref<MediaStream | null>(null);
|
|
36
43
|
const requesting = ref(false);
|
|
37
44
|
const mediaError = ref<Error | null>(null);
|
|
@@ -40,31 +47,89 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
40
47
|
const roster = ref<string[]>([]);
|
|
41
48
|
const participants = ref<MeshParticipant[]>([]);
|
|
42
49
|
const error = ref<Error | null>(null);
|
|
50
|
+
const waitingList = ref<string[]>([]);
|
|
51
|
+
const peerDisplayNames = ref<Record<string, string>>({});
|
|
52
|
+
const inWaitingRoom = ref(features.enableWaitingRoom && !isHost);
|
|
53
|
+
const activeSpeakerId = ref<string | null>(null);
|
|
54
|
+
const raisedHands = ref<Set<string>>(new Set());
|
|
55
|
+
const signalingStatus = ref<"idle" | "connecting" | "open" | "closed">(
|
|
56
|
+
"idle"
|
|
57
|
+
);
|
|
43
58
|
|
|
44
59
|
const signaling = new SignalingClient({
|
|
45
60
|
url: signalingUrl,
|
|
46
61
|
peerId,
|
|
62
|
+
displayName: initialDisplayName,
|
|
47
63
|
room,
|
|
64
|
+
isHost,
|
|
65
|
+
enableWaitingRoom: features.enableWaitingRoom,
|
|
48
66
|
autoReconnect,
|
|
49
67
|
});
|
|
50
68
|
|
|
51
|
-
const enableDataChannel = options.features?.enableDataChannel ?? true;
|
|
52
|
-
|
|
53
69
|
const peers = new Map<string, Peer>();
|
|
54
70
|
|
|
71
|
+
// Active speaker detection state
|
|
72
|
+
const analyzers = new Map<
|
|
73
|
+
string,
|
|
74
|
+
{
|
|
75
|
+
ctx: AudioContext;
|
|
76
|
+
analyser: AnalyserNode;
|
|
77
|
+
source: MediaStreamAudioSourceNode;
|
|
78
|
+
}
|
|
79
|
+
>();
|
|
80
|
+
let activeCandidate: string | null = null;
|
|
81
|
+
let activeSince = 0;
|
|
82
|
+
let silenceSince = 0;
|
|
83
|
+
let speakerInterval: ReturnType<typeof setInterval> | null = null;
|
|
84
|
+
|
|
55
85
|
const sideForPeer = (otherId: string): PeerSide =>
|
|
56
86
|
peerId > otherId ? "initiator" : "responder";
|
|
57
87
|
|
|
58
88
|
const requestStream = async () => {
|
|
89
|
+
const wantsAudio = Boolean(mediaConstraints?.audio);
|
|
90
|
+
const wantsVideo = Boolean(mediaConstraints?.video);
|
|
91
|
+
|
|
92
|
+
if (!wantsAudio && !wantsVideo) {
|
|
93
|
+
localStream.value = null;
|
|
94
|
+
requesting.value = false;
|
|
95
|
+
mediaError.value = null;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof window !== "undefined") {
|
|
100
|
+
const host = window.location.hostname;
|
|
101
|
+
const isLocal =
|
|
102
|
+
host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
103
|
+
const isSecure = window.isSecureContext;
|
|
104
|
+
if (!isSecure && !isLocal) {
|
|
105
|
+
mediaError.value = new Error(
|
|
106
|
+
"Media devices require a secure origin (https) or localhost."
|
|
107
|
+
);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
typeof navigator === "undefined" ||
|
|
114
|
+
!navigator.mediaDevices?.getUserMedia
|
|
115
|
+
) {
|
|
116
|
+
mediaError.value = new Error(
|
|
117
|
+
"Media devices are not available in this environment"
|
|
118
|
+
);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
59
122
|
try {
|
|
60
123
|
requesting.value = true;
|
|
124
|
+
mediaError.value = null;
|
|
61
125
|
const constraints = mediaConstraints ?? { audio: true, video: true };
|
|
62
126
|
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
63
127
|
localStream.value = stream;
|
|
64
|
-
|
|
128
|
+
return stream;
|
|
65
129
|
} catch (err) {
|
|
66
130
|
mediaError.value = err as Error;
|
|
67
131
|
localStream.value = null;
|
|
132
|
+
return null;
|
|
68
133
|
} finally {
|
|
69
134
|
requesting.value = false;
|
|
70
135
|
}
|
|
@@ -99,6 +164,12 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
99
164
|
];
|
|
100
165
|
return;
|
|
101
166
|
}
|
|
167
|
+
const changed = Object.keys(patch).some(
|
|
168
|
+
(key) =>
|
|
169
|
+
patch[key as keyof MeshParticipant] !==
|
|
170
|
+
existing[key as keyof MeshParticipant]
|
|
171
|
+
);
|
|
172
|
+
if (!changed) return;
|
|
102
173
|
participants.value = participants.value.map((p) =>
|
|
103
174
|
p.id === id ? { ...p, ...patch } : p
|
|
104
175
|
);
|
|
@@ -106,6 +177,7 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
106
177
|
|
|
107
178
|
const ensurePeer = (id: string, side?: PeerSide) => {
|
|
108
179
|
if (id === peerId) return null;
|
|
180
|
+
if (features.enableWaitingRoom && inWaitingRoom.value) return null;
|
|
109
181
|
const existing = peers.get(id);
|
|
110
182
|
if (existing) return existing;
|
|
111
183
|
const peer = new Peer({
|
|
@@ -113,8 +185,8 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
113
185
|
stream: localStream.value ?? undefined,
|
|
114
186
|
config: rtcConfig,
|
|
115
187
|
trickle,
|
|
116
|
-
enableDataChannel,
|
|
117
|
-
});
|
|
188
|
+
enableDataChannel: features.enableDataChannel,
|
|
189
|
+
} as any);
|
|
118
190
|
peers.set(id, peer);
|
|
119
191
|
upsertParticipant(id, {
|
|
120
192
|
peer,
|
|
@@ -148,35 +220,244 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
148
220
|
const handlePresence = (payload: {
|
|
149
221
|
peers: string[];
|
|
150
222
|
peerId: string;
|
|
223
|
+
displayName?: string;
|
|
224
|
+
peerDisplayNames?: Record<string, string>;
|
|
151
225
|
room?: string | null;
|
|
152
226
|
action: "join" | "leave";
|
|
153
227
|
}) => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
228
|
+
if (payload.peerDisplayNames) {
|
|
229
|
+
peerDisplayNames.value = payload.peerDisplayNames;
|
|
230
|
+
}
|
|
231
|
+
const ids = payload.peers;
|
|
232
|
+
if (
|
|
233
|
+
roster.value.length !== ids.length ||
|
|
234
|
+
!roster.value.every((id, i) => id === ids[i])
|
|
235
|
+
) {
|
|
236
|
+
roster.value = ids;
|
|
237
|
+
}
|
|
238
|
+
ids.filter((id) => id !== peerId).forEach((id) => ensurePeer(id));
|
|
239
|
+
const filtered = participants.value.filter((p) => ids.includes(p.id));
|
|
240
|
+
if (filtered.length !== participants.value.length) {
|
|
241
|
+
participants.value = filtered;
|
|
242
|
+
}
|
|
159
243
|
Array.from(peers.keys()).forEach((id) => {
|
|
160
|
-
if (!
|
|
244
|
+
if (!ids.includes(id)) destroyPeer(id);
|
|
161
245
|
});
|
|
162
246
|
};
|
|
163
247
|
|
|
248
|
+
const handleControl = ({
|
|
249
|
+
action,
|
|
250
|
+
data,
|
|
251
|
+
}: {
|
|
252
|
+
action: string;
|
|
253
|
+
data?: unknown;
|
|
254
|
+
}) => {
|
|
255
|
+
if (action === "waiting-list") {
|
|
256
|
+
const waiting = ((data as any)?.waiting as string[]) ?? [];
|
|
257
|
+
if (
|
|
258
|
+
waitingList.value.length !== waiting.length ||
|
|
259
|
+
!waitingList.value.every((id, i) => id === waiting[i])
|
|
260
|
+
) {
|
|
261
|
+
waitingList.value = waiting;
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (action === "waiting") {
|
|
266
|
+
inWaitingRoom.value = true;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (action === "admitted") {
|
|
270
|
+
inWaitingRoom.value = false;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (action === "rejected") {
|
|
274
|
+
inWaitingRoom.value = false;
|
|
275
|
+
error.value = new Error("Rejected by host");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (action === "raise-hand") {
|
|
279
|
+
const peer = ((data as any)?.peerId as string) ?? null;
|
|
280
|
+
if (!peer) return;
|
|
281
|
+
if (!raisedHands.value.has(peer)) {
|
|
282
|
+
const next = new Set(raisedHands.value);
|
|
283
|
+
next.add(peer);
|
|
284
|
+
raisedHands.value = next;
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (action === "hand-lowered") {
|
|
289
|
+
const peer = ((data as any)?.peerId as string) ?? null;
|
|
290
|
+
if (!peer) return;
|
|
291
|
+
if (raisedHands.value.has(peer)) {
|
|
292
|
+
const next = new Set(raisedHands.value);
|
|
293
|
+
next.delete(peer);
|
|
294
|
+
raisedHands.value = next;
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (action === "display-name-changed") {
|
|
299
|
+
const names =
|
|
300
|
+
((data as any)?.peerDisplayNames as Record<string, string>) ?? null;
|
|
301
|
+
if (names) {
|
|
302
|
+
peerDisplayNames.value = names;
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
164
308
|
const handleSignal = ({ from, data }: { from: string; data: unknown }) => {
|
|
165
309
|
const peer = ensurePeer(from, sideForPeer(from));
|
|
166
310
|
void peer?.signal(data as SignalData);
|
|
167
311
|
};
|
|
168
312
|
|
|
313
|
+
const handleOpen = () => {
|
|
314
|
+
signalingStatus.value = "open";
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const handleClose = () => {
|
|
318
|
+
signalingStatus.value = "closed";
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const handleError = (err: Error) => {
|
|
322
|
+
if (error.value?.message !== err.message) {
|
|
323
|
+
error.value = err;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// Active speaker detection
|
|
328
|
+
const ensureAnalyzer = (id: string, stream: MediaStream) => {
|
|
329
|
+
if (analyzers.has(id)) return;
|
|
330
|
+
const ctx = new AudioContext();
|
|
331
|
+
const source = ctx.createMediaStreamSource(stream);
|
|
332
|
+
const analyser = ctx.createAnalyser();
|
|
333
|
+
analyser.fftSize = 256;
|
|
334
|
+
source.connect(analyser);
|
|
335
|
+
analyzers.set(id, { ctx, analyser, source });
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const cleanupAnalyzers = () => {
|
|
339
|
+
analyzers.forEach((entry) => {
|
|
340
|
+
entry.source.disconnect();
|
|
341
|
+
entry.analyser.disconnect();
|
|
342
|
+
entry.ctx.close();
|
|
343
|
+
});
|
|
344
|
+
analyzers.clear();
|
|
345
|
+
activeCandidate = null;
|
|
346
|
+
activeSince = 0;
|
|
347
|
+
silenceSince = 0;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const startActiveSpeakerDetection = () => {
|
|
351
|
+
if (!features.enableActiveSpeaker || speakerInterval) return;
|
|
352
|
+
|
|
353
|
+
const minHoldMs = 700;
|
|
354
|
+
const minLevel = 18;
|
|
355
|
+
const silenceHoldMs = 1200;
|
|
356
|
+
|
|
357
|
+
speakerInterval = setInterval(() => {
|
|
358
|
+
let loudestId: string | null = null;
|
|
359
|
+
let loudest = 0;
|
|
360
|
+
analyzers.forEach((entry, id) => {
|
|
361
|
+
const data = new Uint8Array(entry.analyser.frequencyBinCount);
|
|
362
|
+
entry.analyser.getByteFrequencyData(data);
|
|
363
|
+
const avg = data.reduce((acc, v) => acc + v, 0) / data.length;
|
|
364
|
+
if (avg > loudest) {
|
|
365
|
+
loudest = avg;
|
|
366
|
+
loudestId = avg > minLevel ? id : null;
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const now = performance.now();
|
|
371
|
+
if (loudestId !== activeCandidate) {
|
|
372
|
+
activeCandidate = loudestId;
|
|
373
|
+
activeSince = now;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const heldLongEnough = now - activeSince >= minHoldMs;
|
|
377
|
+
if (heldLongEnough && activeSpeakerId.value !== activeCandidate) {
|
|
378
|
+
activeSpeakerId.value = activeCandidate;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!loudestId) {
|
|
382
|
+
if (!silenceSince) silenceSince = now;
|
|
383
|
+
const silentLongEnough = now - silenceSince >= silenceHoldMs;
|
|
384
|
+
if (silentLongEnough && activeSpeakerId.value !== null) {
|
|
385
|
+
activeSpeakerId.value = null;
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
silenceSince = 0;
|
|
389
|
+
}
|
|
390
|
+
}, 400);
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const stopActiveSpeakerDetection = () => {
|
|
394
|
+
if (speakerInterval) {
|
|
395
|
+
clearInterval(speakerInterval);
|
|
396
|
+
speakerInterval = null;
|
|
397
|
+
}
|
|
398
|
+
cleanupAnalyzers();
|
|
399
|
+
activeSpeakerId.value = null;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Watch participants for active speaker detection
|
|
403
|
+
watch(
|
|
404
|
+
() => participants.value,
|
|
405
|
+
(currentParticipants) => {
|
|
406
|
+
if (!features.enableActiveSpeaker || signalingStatus.value !== "open")
|
|
407
|
+
return;
|
|
408
|
+
|
|
409
|
+
currentParticipants.forEach((p) => {
|
|
410
|
+
if (p.remoteStream) ensureAnalyzer(p.id, p.remoteStream);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
analyzers.forEach((_value, id) => {
|
|
414
|
+
const stillPresent = currentParticipants.find(
|
|
415
|
+
(p) => p.id === id && p.remoteStream
|
|
416
|
+
);
|
|
417
|
+
if (!stillPresent) {
|
|
418
|
+
const entry = analyzers.get(id);
|
|
419
|
+
entry?.source.disconnect();
|
|
420
|
+
entry?.analyser.disconnect();
|
|
421
|
+
entry?.ctx.close();
|
|
422
|
+
analyzers.delete(id);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
// Watch signaling status for active speaker detection
|
|
429
|
+
watch(
|
|
430
|
+
() => signalingStatus.value,
|
|
431
|
+
(status) => {
|
|
432
|
+
if (features.enableActiveSpeaker && status === "open") {
|
|
433
|
+
startActiveSpeakerDetection();
|
|
434
|
+
} else {
|
|
435
|
+
stopActiveSpeakerDetection();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
|
|
169
440
|
onMounted(() => {
|
|
441
|
+
signalingStatus.value = "connecting";
|
|
170
442
|
signaling.on("presence", handlePresence as any);
|
|
171
443
|
signaling.on("signal", handleSignal as any);
|
|
444
|
+
signaling.on("control", handleControl as any);
|
|
445
|
+
signaling.on("open", handleOpen as any);
|
|
446
|
+
signaling.on("close", handleClose as any);
|
|
447
|
+
signaling.on("error", handleError as any);
|
|
172
448
|
signaling.connect();
|
|
173
449
|
});
|
|
174
450
|
|
|
175
451
|
onUnmounted(() => {
|
|
176
452
|
signaling.off("presence", handlePresence as any);
|
|
177
453
|
signaling.off("signal", handleSignal as any);
|
|
454
|
+
signaling.off("control", handleControl as any);
|
|
455
|
+
signaling.off("open", handleOpen as any);
|
|
456
|
+
signaling.off("close", handleClose as any);
|
|
457
|
+
signaling.off("error", handleError as any);
|
|
178
458
|
Array.from(peers.values()).forEach((p) => p.destroy());
|
|
179
459
|
peers.clear();
|
|
460
|
+
stopActiveSpeakerDetection();
|
|
180
461
|
stopStream();
|
|
181
462
|
signaling.close();
|
|
182
463
|
});
|
|
@@ -186,10 +467,38 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
186
467
|
peers.clear();
|
|
187
468
|
participants.value = [];
|
|
188
469
|
roster.value = [];
|
|
470
|
+
waitingList.value = [];
|
|
471
|
+
inWaitingRoom.value = false;
|
|
472
|
+
raisedHands.value = new Set();
|
|
473
|
+
stopActiveSpeakerDetection();
|
|
189
474
|
stopStream();
|
|
190
475
|
signaling.close();
|
|
191
476
|
};
|
|
192
477
|
|
|
478
|
+
const admitPeer = (id: string) => {
|
|
479
|
+
if (!features.enableWaitingRoom || !isHost) return;
|
|
480
|
+
signaling.sendControl("admit", { peerId: id });
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const rejectPeer = (id: string) => {
|
|
484
|
+
if (!features.enableWaitingRoom || !isHost) return;
|
|
485
|
+
signaling.sendControl("reject", { peerId: id });
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const raiseHand = () => {
|
|
489
|
+
if (!features.enableHostControls) return;
|
|
490
|
+
signaling.sendControl("raise-hand", { peerId });
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const lowerHand = () => {
|
|
494
|
+
if (!features.enableHostControls) return;
|
|
495
|
+
signaling.sendControl("hand-lowered", { peerId });
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const setDisplayName = (displayName: string) => {
|
|
499
|
+
signaling.setDisplayName(displayName);
|
|
500
|
+
};
|
|
501
|
+
|
|
193
502
|
const ready = computed(() => Boolean(localStream.value));
|
|
194
503
|
|
|
195
504
|
watch(
|
|
@@ -211,6 +520,17 @@ export function useMeshRoom(options: UseMeshRoomOptions) {
|
|
|
211
520
|
mediaError,
|
|
212
521
|
participants,
|
|
213
522
|
roster,
|
|
523
|
+
waitingList,
|
|
524
|
+
inWaitingRoom,
|
|
525
|
+
activeSpeakerId,
|
|
526
|
+
raisedHands,
|
|
527
|
+
peerDisplayNames,
|
|
528
|
+
signalingStatus,
|
|
529
|
+
admitPeer,
|
|
530
|
+
rejectPeer,
|
|
531
|
+
raiseHand,
|
|
532
|
+
lowerHand,
|
|
533
|
+
setDisplayName,
|
|
214
534
|
requestStream,
|
|
215
535
|
stopStream,
|
|
216
536
|
leave,
|