@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.
@@ -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?: { enableDataChannel?: boolean };
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
- mediaError.value = null;
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
- roster.value = payload.peers;
155
- payload.peers.filter((id) => id !== peerId).forEach((id) => ensurePeer(id));
156
- participants.value = participants.value.filter((p) =>
157
- payload.peers.includes(p.id)
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 (!payload.peers.includes(id)) destroyPeer(id);
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,