zero-query 1.2.5 → 1.2.7

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,47 +1,72 @@
1
- // video-room.js - Mini-Discord style room over zero-server signaling.
1
+ // video-room.js - Polished WebRTC room over zero-server signaling.
2
2
  //
3
- // Joins a real WebRTC mesh room via `$.webrtc.join()` against the
4
- // SignalingHub running in server/index.js. The user picks a room name and
5
- // joins as a viewer first - no camera, no microphone, no screen capture
6
- // runs until they explicitly click a Start button. Each control acquires
7
- // (or releases) exactly the media it owns, so granting "Start mic" never
8
- // turns on the camera and vice-versa.
3
+ // Joins a real mesh room via `$.webrtc.join()` against the SignalingHub in
4
+ // server/index.js. Camera, microphone, and screen capture each stay off
5
+ // until the user explicitly turns them on. Presence metadata is broadcast
6
+ // over a dedicated data channel so the UI can split each peer's combined
7
+ // MediaStream into separate camera and screen streams - that's what lets
8
+ // us promote a screen share to the full stage while keeping cameras as
9
+ // thumbnails alongside it.
9
10
 
10
11
  $.component('video-room', {
11
12
  state: () => ({
12
13
  // Pre-join form
13
14
  roomName: 'lobby',
14
15
  displayName: 'User-' + Math.random().toString(36).slice(2, 6),
15
- // Live state
16
+
17
+ // Lifecycle
16
18
  joined: false,
17
19
  connecting: false,
18
- status: 'Pick a room name and join. The camera and mic stay off until you turn them on.',
20
+ status: 'Pick a room name and join. Your camera and mic stay off until you turn them on.',
19
21
  error: '',
22
+
20
23
  // Local publishing flags
21
24
  micOn: false,
22
25
  camOn: false,
23
26
  micMuted: false,
24
27
  camMuted: false,
25
28
  sharing: false,
26
- // Live MediaStream refs (passed through reactive() unchanged because
27
- // the proxy only wraps plain objects / arrays).
29
+ shareAudio: false, // true when getDisplayMedia delivered an audio track
30
+
31
+ // Live MediaStream refs (kept on state so z-stream can bind them).
28
32
  micStream: null,
29
33
  camStream: null,
30
34
  screenStream: null,
31
- // Roster (each entry also holds the remote MediaStream for z-stream)
35
+
36
+ // Peer roster - each entry exposes derived `camStream` / `screenStream`
37
+ // MediaStream objects that the UI binds with z-stream.
32
38
  peers: [],
33
- // Chat history
39
+
40
+ // Pinning: when set, that tile takes the full stage regardless of
41
+ // how many screens / cams are active. id is 'me' or a peerId, kind
42
+ // is 'screen' or 'cam'.
43
+ pinned: null, // { id, kind } | null
44
+
45
+ // Chat
34
46
  messages: [],
35
47
  draft: '',
48
+
49
+ // Pre-join device availability hints
50
+ hasMic: true,
51
+ hasCam: true,
52
+ hasShare: true,
36
53
  }),
37
54
 
38
55
  mounted() {
39
- // Room/data-channel handles live on the instance; MediaStreams live
40
- // in state so z-stream bindings can resolve them by name.
41
56
  this._room = null;
42
57
  this._chat = null;
43
- this._cameraTrack = null;
58
+ this._presence = null;
44
59
  this._unsubs = [];
60
+
61
+ // peerId -> { name, micOn, micMuted, camOn, camMuted, sharing,
62
+ // camTrackId, screenTrackIds:[], screenAudioTrackIds:[] }
63
+ this._presenceMap = new Map();
64
+
65
+ // peerId -> { camStream, screenStream } (cached so MediaStream
66
+ // identity is stable across re-renders).
67
+ this._peerStreams = new Map();
68
+
69
+ this._probeDevices();
45
70
  },
46
71
 
47
72
  async destroyed() {
@@ -50,9 +75,22 @@ $.component('video-room', {
50
75
 
51
76
  // ---- Form bindings ---------------------------------------------------
52
77
 
53
- setRoom(e) { this.setState({ roomName: e.target.value }); },
54
- setName(e) { this.setState({ displayName: e.target.value }); },
55
- setDraft(e) { this.setState({ draft: e.target.value }); },
78
+ setRoom(e) { this.setState({ roomName: e.target.value }); },
79
+ setName(e) { this.setState({ displayName: e.target.value }); },
80
+ setDraft(e) { this.setState({ draft: e.target.value }); },
81
+
82
+ // ---- Pre-join device probe ------------------------------------------
83
+
84
+ async _probeDevices() {
85
+ try {
86
+ if (!navigator.mediaDevices || typeof navigator.mediaDevices.enumerateDevices !== 'function') return;
87
+ const devs = await navigator.mediaDevices.enumerateDevices();
88
+ const hasMic = devs.some((d) => d.kind === 'audioinput');
89
+ const hasCam = devs.some((d) => d.kind === 'videoinput');
90
+ const hasShare = typeof navigator.mediaDevices.getDisplayMedia === 'function';
91
+ this.setState({ hasMic, hasCam, hasShare });
92
+ } catch (_) { /* non-fatal */ }
93
+ },
56
94
 
57
95
  // ---- Join / leave ----------------------------------------------------
58
96
 
@@ -67,9 +105,8 @@ $.component('video-room', {
67
105
  this.setState({ connecting: true, error: '', status: 'Connecting to signaling server...' });
68
106
 
69
107
  try {
70
- // Pull a fresh join token + the server's preferred ws url.
71
108
  const meta = await this._fetchJSON('/rtc/token/' + encodeURIComponent(this.state.roomName));
72
- const ice = await this._fetchJSON('/rtc/turn');
109
+ const ice = await this._fetchJSON('/rtc/turn').catch(() => null);
73
110
 
74
111
  this._room = await $.webrtc.join(meta.wsUrl || this._defaultWsUrl(), {
75
112
  room: this.state.roomName,
@@ -82,7 +119,7 @@ $.component('video-room', {
82
119
  this.setState({
83
120
  joined: true,
84
121
  connecting: false,
85
- status: 'Joined "' + this.state.roomName + '" as a viewer. Start your mic or camera when ready.',
122
+ status: 'Joined "' + this.state.roomName + '" as a viewer. Turn on the devices you actually want to share.',
86
123
  });
87
124
  } catch (err) {
88
125
  this.setState({
@@ -102,11 +139,13 @@ $.component('video-room', {
102
139
  micMuted: false,
103
140
  camMuted: false,
104
141
  sharing: false,
142
+ shareAudio: false,
105
143
  micStream: null,
106
144
  camStream: null,
107
145
  screenStream: null,
108
146
  peers: [],
109
147
  messages: [],
148
+ pinned: null,
110
149
  status: 'Left the room. Click Join to reconnect.',
111
150
  });
112
151
  },
@@ -119,14 +158,14 @@ $.component('video-room', {
119
158
  try { await this._room.leave(); } catch (_) {}
120
159
  this._room = null;
121
160
  }
122
- // Read raw values to avoid touching the reactive proxy while we
123
- // shut things down.
124
161
  const raw = this.state.__raw || this.state;
125
162
  this._stopStream(raw.screenStream);
126
163
  this._stopStream(raw.camStream);
127
164
  this._stopStream(raw.micStream);
128
- this._cameraTrack = null;
129
- this._chat = null;
165
+ this._presenceMap.clear();
166
+ this._peerStreams.clear();
167
+ this._chat = null;
168
+ this._presence = null;
130
169
  },
131
170
 
132
171
  _stopStream(stream) {
@@ -137,17 +176,20 @@ $.component('video-room', {
137
176
  // ---- Room wiring ----------------------------------------------------
138
177
 
139
178
  _wireRoom(room) {
140
- // Initial roster snapshot.
141
- this._refreshPeers(room);
142
-
143
- // Re-render whenever the room's peer map changes.
144
179
  this._unsubs.push(room.peers.subscribe(() => this._refreshPeers(room)));
145
180
 
146
181
  this._unsubs.push(room.on('peer-joined', ({ peerId }) => {
147
182
  this.setState({ status: 'Peer joined: ' + peerId });
148
183
  this._refreshPeers(room);
184
+ // Catch the new peer up on our current state.
185
+ this._publishPresence();
149
186
  }));
150
187
  this._unsubs.push(room.on('peer-left', ({ peerId }) => {
188
+ this._presenceMap.delete(peerId);
189
+ this._peerStreams.delete(peerId);
190
+ if (this.state.pinned && this.state.pinned.id === peerId) {
191
+ this.setState({ pinned: null });
192
+ }
151
193
  this.setState({ status: 'Peer left: ' + peerId });
152
194
  this._refreshPeers(room);
153
195
  }));
@@ -155,25 +197,152 @@ $.component('video-room', {
155
197
  this.setState({ error: String(err && err.message || err) });
156
198
  }));
157
199
 
158
- // Multiplexed text-chat data channel - opens on every peer.
200
+ // Text chat channel.
159
201
  this._chat = room.dataChannel('chat');
160
202
  this._unsubs.push(this._chat.on('message', (raw, peerId) => {
161
203
  try {
162
204
  const msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
163
- this._appendChat({ from: peerId, name: msg.name || peerId, text: String(msg.text || ''), mine: false });
205
+ this._appendChat({
206
+ from: peerId,
207
+ name: msg.name || this._displayNameOf(peerId),
208
+ text: String(msg.text || ''),
209
+ mine: false,
210
+ });
164
211
  } catch (_) { /* ignore malformed */ }
165
212
  }));
213
+
214
+ // Presence channel - lets every peer announce which tracks belong
215
+ // to its camera vs. its screen share, plus mic/cam mute state.
216
+ this._presence = room.dataChannel('presence');
217
+ this._unsubs.push(this._presence.on('message', (raw, peerId) => {
218
+ try {
219
+ const msg = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
220
+ if (!msg || msg.type !== 'presence') return;
221
+ this._presenceMap.set(peerId, {
222
+ name: String(msg.name || peerId),
223
+ micOn: !!msg.micOn,
224
+ micMuted: !!msg.micMuted,
225
+ camOn: !!msg.camOn,
226
+ camMuted: !!msg.camMuted,
227
+ sharing: !!msg.sharing,
228
+ shareAudio: !!msg.shareAudio,
229
+ camTrackId: msg.camTrackId || null,
230
+ screenTrackIds: Array.isArray(msg.screenTrackIds) ? msg.screenTrackIds.slice() : [],
231
+ screenAudioTrackIds: Array.isArray(msg.screenAudioTrackIds) ? msg.screenAudioTrackIds.slice() : [],
232
+ });
233
+ this._refreshPeers(this._room);
234
+ } catch (_) { /* ignore malformed */ }
235
+ }));
236
+
237
+ this._refreshPeers(room);
238
+ },
239
+
240
+ _displayNameOf(peerId) {
241
+ const p = this._presenceMap.get(peerId);
242
+ return (p && p.name) || peerId;
166
243
  },
167
244
 
245
+ _publishPresence() {
246
+ if (!this._presence) return;
247
+ const raw = this.state.__raw || this.state;
248
+ const camTrack = raw.camStream ? (raw.camStream.getVideoTracks()[0] || null) : null;
249
+ const screenTracks = raw.screenStream ? raw.screenStream.getVideoTracks() : [];
250
+ const screenAudio = raw.screenStream ? raw.screenStream.getAudioTracks() : [];
251
+ const payload = {
252
+ type: 'presence',
253
+ name: raw.displayName,
254
+ micOn: raw.micOn,
255
+ micMuted: raw.micMuted,
256
+ camOn: raw.camOn,
257
+ camMuted: raw.camMuted,
258
+ sharing: raw.sharing,
259
+ shareAudio: raw.shareAudio,
260
+ camTrackId: camTrack ? camTrack.id : null,
261
+ screenTrackIds: screenTracks.map((t) => t.id),
262
+ screenAudioTrackIds: screenAudio.map((t) => t.id),
263
+ };
264
+ try { this._presence.send(JSON.stringify(payload)); } catch (_) {}
265
+ },
266
+
267
+ // ---- Peer reconciliation --------------------------------------------
268
+
168
269
  _refreshPeers(room) {
270
+ if (!room) { this.setState({ peers: [] }); return; }
169
271
  const list = [];
170
- const map = room.peers.peek();
171
- for (const info of map.values()) {
172
- list.push({ id: info.id, name: info.id, stream: info.stream });
272
+ for (const info of room.peers.peek().values()) {
273
+ list.push(this._derivePeerView(info));
274
+ }
275
+ // Drop cached entries for peers that vanished.
276
+ for (const id of Array.from(this._peerStreams.keys())) {
277
+ if (!list.some((p) => p.id === id)) this._peerStreams.delete(id);
173
278
  }
174
279
  this.setState({ peers: list });
175
280
  },
176
281
 
282
+ _derivePeerView(info) {
283
+ const presence = this._presenceMap.get(info.id) || null;
284
+ const cache = this._peerStreams.get(info.id) || { camStream: this._newStream(), screenStream: this._newStream() };
285
+ this._peerStreams.set(info.id, cache);
286
+
287
+ const tracks = (info.stream && typeof info.stream.getTracks === 'function')
288
+ ? info.stream.getTracks()
289
+ : [];
290
+
291
+ // Classify each remote track using the latest presence hints. When
292
+ // presence hasn't arrived yet we fall back to "first video = cam".
293
+ const want = { cam: new Set(), screen: new Set() };
294
+ let fallbackCamSeen = false;
295
+ for (const t of tracks) {
296
+ if (presence) {
297
+ if (presence.screenTrackIds.indexOf(t.id) !== -1) want.screen.add(t);
298
+ else if (presence.screenAudioTrackIds.indexOf(t.id) !== -1) want.screen.add(t);
299
+ else if (t.id === presence.camTrackId) want.cam.add(t);
300
+ else if (t.kind === 'audio') want.cam.add(t); // mic
301
+ else want.cam.add(t);
302
+ } else {
303
+ if (t.kind === 'video' && !fallbackCamSeen) { want.cam.add(t); fallbackCamSeen = true; }
304
+ else if (t.kind === 'video') { want.screen.add(t); }
305
+ else { want.cam.add(t); }
306
+ }
307
+ }
308
+
309
+ this._syncStreamTracks(cache.camStream, want.cam);
310
+ this._syncStreamTracks(cache.screenStream, want.screen);
311
+
312
+ const hasCamVideo = cache.camStream.getVideoTracks().length > 0;
313
+ const hasScreenVideo = cache.screenStream.getVideoTracks().length > 0;
314
+
315
+ return {
316
+ id: info.id,
317
+ name: presence ? presence.name : info.id,
318
+ micOn: presence ? presence.micOn : cache.camStream.getAudioTracks().length > 0,
319
+ micMuted: presence ? presence.micMuted : false,
320
+ camOn: presence ? presence.camOn : hasCamVideo,
321
+ camMuted: presence ? presence.camMuted : false,
322
+ sharing: presence ? presence.sharing : hasScreenVideo,
323
+ shareAudio: presence ? presence.shareAudio : false,
324
+ connection: info.connection || 'connected',
325
+ camStream: hasCamVideo ? cache.camStream : null,
326
+ screenStream: hasScreenVideo ? cache.screenStream : null,
327
+ hasAudio: cache.camStream.getAudioTracks().length > 0,
328
+ };
329
+ },
330
+
331
+ _syncStreamTracks(stream, wanted) {
332
+ const current = new Set(stream.getTracks());
333
+ for (const t of current) {
334
+ if (!wanted.has(t)) { try { stream.removeTrack(t); } catch (_) {} }
335
+ }
336
+ for (const t of wanted) {
337
+ if (!current.has(t)) { try { stream.addTrack(t); } catch (_) {} }
338
+ }
339
+ },
340
+
341
+ _newStream() {
342
+ try { return new MediaStream(); }
343
+ catch (_) { return { _tracks: [], getTracks() { return this._tracks.slice(); }, getVideoTracks() { return this._tracks.filter(t => t.kind==='video'); }, getAudioTracks() { return this._tracks.filter(t => t.kind==='audio'); }, addTrack(t){ this._tracks.push(t); }, removeTrack(t){ const i=this._tracks.indexOf(t); if(i>=0) this._tracks.splice(i,1); } }; }
344
+ },
345
+
177
346
  // ---- Mic ------------------------------------------------------------
178
347
 
179
348
  async startMic() {
@@ -182,6 +351,7 @@ $.component('video-room', {
182
351
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
183
352
  await this._room.publish(stream);
184
353
  this.setState({ micStream: stream, micOn: true, micMuted: false, error: '', status: 'Microphone is live.' });
354
+ this._publishPresence();
185
355
  } catch (err) {
186
356
  this.setState({ error: 'Microphone denied or unavailable.' });
187
357
  }
@@ -193,6 +363,7 @@ $.component('video-room', {
193
363
  try { await this._room.unpublish(stream); } catch (_) {}
194
364
  this._stopStream(stream);
195
365
  this.setState({ micStream: null, micOn: false, micMuted: false, status: 'Microphone stopped.' });
366
+ this._publishPresence();
196
367
  },
197
368
 
198
369
  toggleMute() {
@@ -201,6 +372,7 @@ $.component('video-room', {
201
372
  const next = !this.state.micMuted;
202
373
  for (const t of stream.getAudioTracks()) t.enabled = !next;
203
374
  this.setState({ micMuted: next });
375
+ this._publishPresence();
204
376
  },
205
377
 
206
378
  // ---- Camera ---------------------------------------------------------
@@ -208,10 +380,12 @@ $.component('video-room', {
208
380
  async startCam() {
209
381
  if (this.state.camStream || !this._room) return;
210
382
  try {
211
- const stream = await navigator.mediaDevices.getUserMedia({ video: true });
212
- this._cameraTrack = stream.getVideoTracks()[0] || null;
383
+ const stream = await navigator.mediaDevices.getUserMedia({
384
+ video: { width: { ideal: 1280 }, height: { ideal: 720 } },
385
+ });
213
386
  await this._room.publish(stream);
214
387
  this.setState({ camStream: stream, camOn: true, camMuted: false, error: '', status: 'Camera is live.' });
388
+ this._publishPresence();
215
389
  } catch (err) {
216
390
  this.setState({ error: 'Camera denied or unavailable.' });
217
391
  }
@@ -220,11 +394,13 @@ $.component('video-room', {
220
394
  async stopCam() {
221
395
  const stream = this.state.camStream;
222
396
  if (!stream || !this._room) return;
223
- if (this.state.sharing) await this.stopShare();
224
397
  try { await this._room.unpublish(stream); } catch (_) {}
225
398
  this._stopStream(stream);
226
- this._cameraTrack = null;
399
+ if (this.state.pinned && this.state.pinned.id === 'me' && this.state.pinned.kind === 'cam') {
400
+ this.setState({ pinned: null });
401
+ }
227
402
  this.setState({ camStream: null, camOn: false, camMuted: false, status: 'Camera stopped.' });
403
+ this._publishPresence();
228
404
  },
229
405
 
230
406
  toggleCamMute() {
@@ -233,6 +409,7 @@ $.component('video-room', {
233
409
  const next = !this.state.camMuted;
234
410
  for (const t of stream.getVideoTracks()) t.enabled = !next;
235
411
  this.setState({ camMuted: next });
412
+ this._publishPresence();
236
413
  },
237
414
 
238
415
  // ---- Screen share ---------------------------------------------------
@@ -244,15 +421,40 @@ $.component('video-room', {
244
421
  return;
245
422
  }
246
423
  try {
247
- const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
248
- const track = stream.getVideoTracks()[0];
249
- if (!track) throw new Error('No video track from getDisplayMedia');
250
- // Native browser "Stop sharing" button.
251
- track.onended = () => { if (this.state.sharing) this.stopShare(); };
424
+ // Browsers that honour these hints will present an audio-capture
425
+ // checkbox in the picker (Chromium asks "Share tab audio" / "Share
426
+ // system audio"). Firefox + Safari silently ignore the audio flag
427
+ // for full-screen captures and return video only - that's fine.
428
+ const stream = await navigator.mediaDevices.getDisplayMedia({
429
+ video: { frameRate: { ideal: 30 }, displaySurface: 'monitor' },
430
+ audio: true,
431
+ systemAudio: 'include',
432
+ selfBrowserSurface: 'exclude',
433
+ surfaceSwitching: 'include',
434
+ });
435
+ const videoTrack = stream.getVideoTracks()[0];
436
+ if (!videoTrack) throw new Error('No video track from getDisplayMedia');
437
+
438
+ // Native browser "Stop sharing" hand-off.
439
+ videoTrack.onended = () => { if (this.state.sharing) this.stopShare(); };
440
+
252
441
  await this._room.publish(stream);
253
- this.setState({ screenStream: stream, sharing: true, error: '', status: 'Sharing your screen.' });
442
+ const shareAudio = stream.getAudioTracks().length > 0;
443
+ this.setState({
444
+ screenStream: stream,
445
+ sharing: true,
446
+ shareAudio,
447
+ error: '',
448
+ status: shareAudio ? 'Sharing screen with audio.' : 'Sharing screen (video only).',
449
+ // Auto-pin our own share to the main stage for the host;
450
+ // viewers will still see whatever sharer is pinned for them.
451
+ pinned: { id: 'me', kind: 'screen' },
452
+ });
453
+ this._publishPresence();
254
454
  } catch (err) {
255
- this.setState({ error: 'Screen share denied or unavailable.' });
455
+ // User cancelling the picker throws NotAllowedError - keep quiet.
456
+ const benign = err && (err.name === 'NotAllowedError' || err.name === 'AbortError');
457
+ if (!benign) this.setState({ error: 'Screen share denied or unavailable.' });
256
458
  }
257
459
  },
258
460
 
@@ -261,9 +463,31 @@ $.component('video-room', {
261
463
  if (!stream) return;
262
464
  try { await this._room.unpublish(stream); } catch (_) {}
263
465
  this._stopStream(stream);
264
- this.setState({ screenStream: null, sharing: false, status: 'Stopped sharing screen.' });
466
+ if (this.state.pinned && this.state.pinned.id === 'me' && this.state.pinned.kind === 'screen') {
467
+ this.setState({ pinned: null });
468
+ }
469
+ this.setState({
470
+ screenStream: null,
471
+ sharing: false,
472
+ shareAudio: false,
473
+ status: 'Stopped sharing screen.',
474
+ });
475
+ this._publishPresence();
265
476
  },
266
477
 
478
+ // ---- Pinning --------------------------------------------------------
479
+
480
+ pinTile(id, kind) {
481
+ const cur = this.state.pinned;
482
+ if (cur && cur.id === id && cur.kind === kind) {
483
+ this.setState({ pinned: null });
484
+ } else {
485
+ this.setState({ pinned: { id, kind } });
486
+ }
487
+ },
488
+
489
+ unpin() { this.setState({ pinned: null }); },
490
+
267
491
  // ---- Chat -----------------------------------------------------------
268
492
 
269
493
  sendChat(e) {
@@ -277,7 +501,7 @@ $.component('video-room', {
277
501
  },
278
502
 
279
503
  _appendChat(msg) {
280
- const next = this.state.messages.concat([msg]);
504
+ const next = this.state.messages.concat([Object.assign({ at: Date.now() }, msg)]);
281
505
  if (next.length > 200) next.splice(0, next.length - 200);
282
506
  this.setState({ messages: next });
283
507
  },
@@ -286,7 +510,7 @@ $.component('video-room', {
286
510
 
287
511
  async _fetchJSON(url) {
288
512
  const res = await fetch(url, { headers: { Accept: 'application/json' } });
289
- if (!res.ok) throw new Error(url + ' HTTP ' + res.status);
513
+ if (!res.ok) throw new Error(url + ' HTTP ' + res.status);
290
514
  return res.json();
291
515
  },
292
516
 
@@ -296,6 +520,84 @@ $.component('video-room', {
296
520
  return proto + '//' + location.host + '/rtc';
297
521
  },
298
522
 
523
+ _formatTime(ts) {
524
+ try {
525
+ const d = new Date(ts);
526
+ const hh = String(d.getHours()).padStart(2, '0');
527
+ const mm = String(d.getMinutes()).padStart(2, '0');
528
+ return hh + ':' + mm;
529
+ } catch (_) { return ''; }
530
+ },
531
+
532
+ // Lucide-style line icons, 24x24, strokes follow `currentColor` so they
533
+ // pick up button text colour automatically. Used everywhere instead of
534
+ // emoji so glyphs look consistent across OSes and inside dark UI chrome.
535
+ _icon(name, size) {
536
+ const s = size || 18;
537
+ const head = '<svg xmlns="http://www.w3.org/2000/svg" width="' + s + '" height="' + s +
538
+ '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"' +
539
+ ' stroke-linecap="round" stroke-linejoin="round" class="icon icon-' + name + '" aria-hidden="true">';
540
+ const paths = {
541
+ 'mic': '<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>',
542
+ 'mic-off': '<line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>',
543
+ 'video': '<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>',
544
+ 'video-off': '<path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/>',
545
+ 'monitor': '<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
546
+ 'volume': '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>',
547
+ 'pin': '<path d="M12 17v5"/><path d="M9 10.76V6h-.5a1.5 1.5 0 0 1 0-3h7a1.5 1.5 0 0 1 0 3H15v4.76a2 2 0 0 0 .55 1.38l2.45 2.6V17H6v-2.26l2.45-2.6A2 2 0 0 0 9 10.76z"/>',
548
+ 'phone-off': '<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="23" y1="1" x2="1" y2="23"/>',
549
+ 'stop': '<rect x="6" y="6" width="12" height="12" rx="1"/>',
550
+ 'send': '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
551
+ 'message': '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>',
552
+ 'wave': '<path d="M18 11V6a2 2 0 0 0-4 0v6"/><path d="M14 10V4a2 2 0 0 0-4 0v8"/><path d="M10 10.5V6a2 2 0 0 0-4 0v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>',
553
+ 'alert': '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
554
+ };
555
+ return head + (paths[name] || '') + '</svg>';
556
+ },
557
+
558
+ // ---- Tile assembly --------------------------------------------------
559
+
560
+ _collectTiles() {
561
+ const { peers, sharing, camOn, camMuted, micOn, micMuted, shareAudio } = this.state;
562
+ const screens = [];
563
+ const cams = [];
564
+
565
+ if (sharing) {
566
+ screens.push({
567
+ id: 'me', kind: 'screen', name: 'You', isSelf: true,
568
+ streamBinding: 'screenStream',
569
+ badges: shareAudio ? ['with audio'] : [],
570
+ });
571
+ }
572
+ if (camOn) {
573
+ cams.push({
574
+ id: 'me', kind: 'cam', name: 'You', isSelf: true,
575
+ streamBinding: 'camStream',
576
+ muted: camMuted, micOn, micMuted,
577
+ });
578
+ }
579
+
580
+ peers.forEach((p, i) => {
581
+ if (p.screenStream) {
582
+ screens.push({
583
+ id: p.id, kind: 'screen', name: p.name, isSelf: false,
584
+ streamBinding: 'peers[' + i + '].screenStream',
585
+ badges: p.shareAudio ? ['with audio'] : [],
586
+ });
587
+ }
588
+ if (p.camStream) {
589
+ cams.push({
590
+ id: p.id, kind: 'cam', name: p.name, isSelf: false,
591
+ streamBinding: 'peers[' + i + '].camStream',
592
+ muted: p.camMuted, micOn: p.micOn, micMuted: p.micMuted,
593
+ connection: p.connection,
594
+ });
595
+ }
596
+ });
597
+
598
+ return { screens, cams };
599
+ },
600
+
299
601
  // ---- Render ---------------------------------------------------------
300
602
 
301
603
  render() {
@@ -304,22 +606,29 @@ $.component('video-room', {
304
606
  },
305
607
 
306
608
  _renderLobby() {
307
- const { roomName, displayName, status, error, connecting } = this.state;
609
+ const { roomName, displayName, status, error, connecting, hasMic, hasCam, hasShare } = this.state;
610
+ const hint = [];
611
+ if (!hasMic) hint.push('no microphone detected');
612
+ if (!hasCam) hint.push('no camera detected');
613
+ if (!hasShare) hint.push('screen sharing unavailable in this browser');
614
+ const hintLine = hint.length ? `<div class="device-hint">${this._icon('alert', 14)}<span>${$.escapeHtml(hint.join(' · '))}</span></div>` : '';
615
+
308
616
  return `
309
617
  <div class="lobby">
310
618
  <h1>zQuery WebRTC Demo</h1>
311
619
  <p class="lead">
312
- Mesh video call powered by
313
- <code>$.webrtc.join()</code> against a
620
+ Mesh video call powered by <code>$.webrtc.join()</code> against a
314
621
  <a href="https://github.com/tonywied17/zero-server" target="_blank" rel="noopener">zero-server</a>
315
- <code>SignalingHub</code>. Open this page on a second device
316
- (or in another browser) and join the same room to see a peer
317
- appear.
622
+ <code>SignalingHub</code>. Open this page on a second device (or in another
623
+ browser window) and join the same room to see a peer appear. Each call
624
+ supports up to a handful of peers in a full mesh; for larger meetings
625
+ swap in an SFU.
318
626
  </p>
319
627
  <p class="lead">
320
- <strong>Camera and microphone are off by default.</strong>
321
- Join the room first; then turn on the devices you actually
322
- want to share.
628
+ <strong>Mic, camera, and screen share are off by default.</strong>
629
+ Join first - then enable the devices you actually want to share.
630
+ Screen share will prompt you to choose a screen / window / tab and,
631
+ on Chromium-based browsers, also lets you include tab or system audio.
323
632
  </p>
324
633
  <form class="join-form" @submit="join">
325
634
  <label>
@@ -334,6 +643,7 @@ $.component('video-room', {
334
643
  ${connecting ? 'Joining...' : 'Join room'}
335
644
  </button>
336
645
  </form>
646
+ ${hintLine}
337
647
  <p class="status ${error ? 'error' : ''}">
338
648
  ${error ? $.escapeHtml(error) : $.escapeHtml(status)}
339
649
  </p>
@@ -344,74 +654,125 @@ $.component('video-room', {
344
654
  _renderRoom() {
345
655
  const {
346
656
  peers, status, error, displayName, roomName,
347
- micOn, camOn, micMuted, camMuted, sharing, messages, draft,
657
+ micOn, camOn, micMuted, camMuted, sharing, shareAudio,
658
+ messages, draft, pinned, hasShare,
348
659
  } = this.state;
349
660
 
350
661
  const peerCount = peers.length + 1;
351
- const peerTiles = peers.map((p, i) => `
352
- <div class="tile">
353
- <video z-stream="peers[${i}].stream" autoplay playsinline></video>
354
- <div class="label">${$.escapeHtml(p.name || p.id)}</div>
355
- </div>
356
- `).join('');
662
+ const { screens, cams } = this._collectTiles();
663
+
664
+ // Decide the layout:
665
+ // - pinned tile (if any) wins and occupies the main stage solo.
666
+ // - else if there are screen shares, screens fill the main stage
667
+ // and cameras drop to a thumbnail strip alongside.
668
+ // - else cameras tile across the stage.
669
+ let stageMode = 'cams'; // 'cams' | 'screens' | 'pinned'
670
+ let mainTiles = [];
671
+ let stripTiles = [];
672
+
673
+ const pinTile = pinned
674
+ ? [].concat(screens, cams).find((t) => t.id === pinned.id && t.kind === pinned.kind)
675
+ : null;
676
+
677
+ if (pinTile) {
678
+ stageMode = 'pinned';
679
+ mainTiles = [pinTile];
680
+ stripTiles = [].concat(screens, cams).filter((t) => t !== pinTile);
681
+ } else if (screens.length > 0) {
682
+ stageMode = 'screens';
683
+ mainTiles = screens;
684
+ stripTiles = cams;
685
+ } else {
686
+ stageMode = 'cams';
687
+ mainTiles = cams;
688
+ stripTiles = [];
689
+ }
690
+
691
+ const mainGrid = mainTiles.length > 0
692
+ ? `<div class="stage-main stage-main-${this._gridClass(mainTiles.length)}">
693
+ ${mainTiles.map((t) => this._tileHtml(t, 'main')).join('')}
694
+ </div>`
695
+ : `<div class="stage-empty">
696
+ <div class="empty-title">Nobody is sharing yet</div>
697
+ <div class="empty-sub">Start your camera or screen share from the controls below.</div>
698
+ </div>`;
699
+
700
+ const strip = stripTiles.length > 0
701
+ ? `<div class="stage-strip">
702
+ ${stripTiles.map((t) => this._tileHtml(t, 'strip')).join('')}
703
+ </div>`
704
+ : '';
357
705
 
358
706
  const chatLines = messages.map((m) => `
359
707
  <div class="msg ${m.mine ? 'mine' : ''}">
360
- <span class="who">${$.escapeHtml(m.name)}</span>
361
- <span class="text">${$.escapeHtml(m.text)}</span>
708
+ <div class="msg-head">
709
+ <span class="who">${$.escapeHtml(m.name)}</span>
710
+ <span class="when">${$.escapeHtml(this._formatTime(m.at))}</span>
711
+ </div>
712
+ <div class="text">${$.escapeHtml(m.text)}</div>
362
713
  </div>
363
714
  `).join('');
364
715
 
365
- // Self-tile prefers the screen stream when sharing, else the camera.
366
- const selfBinding = sharing ? 'screenStream' : 'camStream';
367
-
368
716
  return `
369
- <div class="room">
717
+ <div class="room ${stageMode === 'screens' || stageMode === 'pinned' ? 'has-focus' : ''}">
370
718
  <aside class="sidebar">
371
719
  <div class="room-meta">
372
720
  <div class="room-name">#${$.escapeHtml(roomName)}</div>
373
- <div class="room-sub">${peerCount} ${peerCount === 1 ? 'person' : 'people'}</div>
721
+ <div class="room-sub">${peerCount} ${peerCount === 1 ? 'person' : 'people'} · ${screens.length} sharing</div>
374
722
  </div>
375
723
  <div class="roster">
376
724
  <div class="roster-row me">
377
725
  <span class="dot ${micOn && !micMuted ? 'on' : 'off'}"></span>
378
- ${$.escapeHtml(displayName)} <small>(you)</small>
726
+ <span class="who">${$.escapeHtml(displayName)} <small>(you)</small></span>
727
+ <span class="roster-icons">
728
+ ${micOn ? this._icon(micMuted ? 'mic-off' : 'mic', 14) : ''}
729
+ ${camOn ? this._icon(camMuted ? 'video-off' : 'video', 14) : ''}
730
+ ${sharing ? this._icon('monitor', 14) : ''}
731
+ </span>
379
732
  </div>
380
733
  ${peers.map((p) => `
381
- <div class="roster-row">
382
- <span class="dot on"></span>
383
- ${$.escapeHtml(p.name || p.id)}
734
+ <div class="roster-row ${p.connection === 'failed' ? 'broken' : ''}">
735
+ <span class="dot ${p.micOn && !p.micMuted ? 'on' : 'off'}"></span>
736
+ <span class="who">${$.escapeHtml(p.name || p.id)}</span>
737
+ <span class="roster-icons">
738
+ ${p.micOn ? this._icon(p.micMuted ? 'mic-off' : 'mic', 14) : ''}
739
+ ${p.camOn ? this._icon(p.camMuted ? 'video-off' : 'video', 14) : ''}
740
+ ${p.sharing ? this._icon('monitor', 14) : ''}
741
+ </span>
384
742
  </div>
385
743
  `).join('')}
386
744
  </div>
387
- <button class="leave" @click="leave">Leave room</button>
745
+ <button class="leave" @click="leave">${this._icon('phone-off', 16)}<span>Leave room</span></button>
388
746
  </aside>
389
747
 
390
748
  <section class="stage">
391
- <div class="tiles">
392
- <div class="tile self">
393
- ${camOn || sharing
394
- ? `<video z-stream="${selfBinding}" autoplay playsinline muted></video>`
395
- : '<div class="camoff">Camera off</div>'}
396
- <div class="label">You${sharing ? ' · sharing' : ''}${camMuted ? ' · paused' : ''}</div>
397
- </div>
398
- ${peerTiles}
749
+ <div class="stage-area">
750
+ ${mainGrid}
751
+ ${strip}
399
752
  </div>
400
753
 
401
754
  <div class="controls">
402
- ${micOn
403
- ? `<button class="${micMuted ? 'off' : ''}" @click="toggleMute">${micMuted ? '🔇 Unmute' : '🎤 Mute'}</button>
404
- <button class="off" @click="stopMic">⏹ Stop mic</button>`
405
- : `<button class="primary" @click="startMic">🎤 Start mic</button>`}
755
+ <div class="ctl-group">
756
+ ${micOn
757
+ ? `<button class="${micMuted ? 'off' : ''}" @click="toggleMute" title="${micMuted ? 'Unmute mic' : 'Mute mic'}">${this._icon(micMuted ? 'mic-off' : 'mic')}<span>${micMuted ? 'Unmute' : 'Mute'}</span></button>
758
+ <button class="off ghost" @click="stopMic" title="Stop microphone">${this._icon('stop')}<span>Mic</span></button>`
759
+ : `<button class="primary" @click="startMic">${this._icon('mic')}<span>Start mic</span></button>`}
760
+ </div>
406
761
 
407
- ${camOn
408
- ? `<button class="${camMuted ? 'off' : ''}" @click="toggleCamMute">${camMuted ? '🚫 Resume' : '📷 Pause'}</button>
409
- <button class="off" @click="stopCam">⏹ Stop camera</button>`
410
- : `<button class="primary" @click="startCam">📷 Start camera</button>`}
762
+ <div class="ctl-group">
763
+ ${camOn
764
+ ? `<button class="${camMuted ? 'off' : ''}" @click="toggleCamMute" title="${camMuted ? 'Resume camera' : 'Pause camera'}">${this._icon(camMuted ? 'video-off' : 'video')}<span>${camMuted ? 'Resume' : 'Pause'}</span></button>
765
+ <button class="off ghost" @click="stopCam" title="Stop camera">${this._icon('stop')}<span>Cam</span></button>`
766
+ : `<button class="primary" @click="startCam">${this._icon('video')}<span>Start camera</span></button>`}
767
+ </div>
768
+
769
+ <div class="ctl-group">
770
+ ${sharing
771
+ ? `<button class="active" @click="stopShare" title="Stop screen share">${this._icon('stop')}<span>Stop sharing${shareAudio ? ' (with audio)' : ''}</span></button>`
772
+ : `<button @click="startShare" ${hasShare ? '' : 'disabled'} title="${hasShare ? 'Share a screen, window or tab (audio capture optional)' : 'Screen share unsupported here'}">${this._icon('monitor')}<span>Share screen</span></button>`}
773
+ </div>
411
774
 
412
- ${sharing
413
- ? `<button class="active" @click="stopShare">🛑 Stop sharing</button>`
414
- : `<button @click="startShare">🖥️ Share screen</button>`}
775
+ ${pinned ? `<button class="ghost" @click="unpin" title="Unpin focused tile">${this._icon('pin')}<span>Unpin</span></button>` : ''}
415
776
 
416
777
  <div class="status-inline ${error ? 'error' : ''}">
417
778
  ${error ? $.escapeHtml(error) : $.escapeHtml(status)}
@@ -420,21 +781,70 @@ $.component('video-room', {
420
781
  </section>
421
782
 
422
783
  <aside class="chat">
423
- <div class="chat-header">Chat</div>
784
+ <div class="chat-header">${this._icon('message', 14)}<span>Chat · ${messages.length}</span></div>
424
785
  <div class="chat-log" id="chat-log">
425
- ${chatLines || '<div class="empty">No messages yet. Say hi 👋</div>'}
786
+ ${chatLines || `<div class="empty">${this._icon('wave', 22)}<span>No messages yet. Say hi.</span></div>`}
426
787
  </div>
427
788
  <form class="chat-form" @submit="sendChat">
428
- <input type="text" value="${$.escapeHtml(draft)}" @input="setDraft" placeholder="Message #${$.escapeHtml(roomName)}" />
429
- <button type="submit" class="primary">Send</button>
789
+ <input type="text" value="${$.escapeHtml(draft)}" @input="setDraft" placeholder="Message #${$.escapeHtml(roomName)}" maxlength="500" />
790
+ <button type="submit" class="primary">${this._icon('send')}<span>Send</span></button>
430
791
  </form>
431
792
  </aside>
432
793
  </div>
433
794
  `;
434
795
  },
435
796
 
797
+ _gridClass(n) {
798
+ if (n <= 1) return '1';
799
+ if (n === 2) return '2';
800
+ if (n <= 4) return '4';
801
+ if (n <= 6) return '6';
802
+ return '9';
803
+ },
804
+
805
+ _tileHtml(t, slot) {
806
+ const isScreen = t.kind === 'screen';
807
+ const audioAttr = t.isSelf ? 'muted' : '';
808
+ const cls = [
809
+ 'tile',
810
+ 'tile-' + slot,
811
+ 'tile-' + t.kind,
812
+ t.isSelf ? 'self' : '',
813
+ t.muted ? 'muted-video' : '',
814
+ slot === 'strip' ? 'thumb' : '',
815
+ ].filter(Boolean).join(' ');
816
+
817
+ const overlay = t.muted && !isScreen
818
+ ? `<div class="camoff">Camera paused</div>`
819
+ : '';
820
+
821
+ const micChip = (!isScreen && t.micOn !== undefined)
822
+ ? `<span class="chip ${t.micMuted ? 'chip-off' : 'chip-on'}">${this._icon(t.micMuted ? 'mic-off' : 'mic', 12)}</span>`
823
+ : '';
824
+
825
+ const audioChip = (isScreen && t.badges && t.badges.length)
826
+ ? `<span class="chip chip-on">${this._icon('volume', 12)}</span>`
827
+ : '';
828
+
829
+ const label = `<div class="label">
830
+ <span class="label-name">${$.escapeHtml(t.name)}</span>
831
+ ${micChip}${audioChip}
832
+ ${isScreen ? `<span class="chip chip-screen">${this._icon('monitor', 12)}</span>` : ''}
833
+ </div>`;
834
+
835
+ const pinBtn = `<button class="pin-btn" @click="pinTile('${t.id}', '${t.kind}')" title="Pin to focus">${this._icon('pin', 14)}</button>`;
836
+
837
+ return `
838
+ <div class="${cls}">
839
+ <video z-stream="${t.streamBinding}" autoplay playsinline ${audioAttr}></video>
840
+ ${overlay}
841
+ ${label}
842
+ ${pinBtn}
843
+ </div>
844
+ `;
845
+ },
846
+
436
847
  updated() {
437
- // Auto-scroll chat to the latest message after each render.
438
848
  const log = this._el && this._el.querySelector ? this._el.querySelector('#chat-log') : null;
439
849
  if (log) log.scrollTop = log.scrollHeight;
440
850
  },