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.
- package/README.md +18 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +511 -101
- package/cli/scaffold/webrtc/global.css +229 -32
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +3 -3
- package/dist/zquery.min.js +2 -2
- package/package.json +1 -1
|
@@ -1,47 +1,72 @@
|
|
|
1
|
-
// video-room.js -
|
|
1
|
+
// video-room.js - Polished WebRTC room over zero-server signaling.
|
|
2
2
|
//
|
|
3
|
-
// Joins a real
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
16
|
+
|
|
17
|
+
// Lifecycle
|
|
16
18
|
joined: false,
|
|
17
19
|
connecting: false,
|
|
18
|
-
status: 'Pick a room name and join.
|
|
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
|
-
//
|
|
27
|
-
|
|
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
|
-
|
|
35
|
+
|
|
36
|
+
// Peer roster - each entry exposes derived `camStream` / `screenStream`
|
|
37
|
+
// MediaStream objects that the UI binds with z-stream.
|
|
32
38
|
peers: [],
|
|
33
|
-
|
|
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.
|
|
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)
|
|
54
|
-
setName(e)
|
|
55
|
-
setDraft(e) { this.setState({ draft:
|
|
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.
|
|
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.
|
|
129
|
-
this.
|
|
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
|
-
//
|
|
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({
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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({
|
|
212
|
-
|
|
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.
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 + '
|
|
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
|
-
|
|
317
|
-
|
|
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>
|
|
321
|
-
Join
|
|
322
|
-
|
|
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,
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
<
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
${
|
|
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 ||
|
|
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
|
},
|