zero-query 1.2.4 โ 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/cli/commands/build-api.js +21 -8
- package/cli/scaffold/webrtc/app/components/video-room.js +482 -98
- package/cli/scaffold/webrtc/global.css +190 -31
- package/dist/API.md +246 -69
- 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();
|
|
476
|
+
},
|
|
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
|
+
}
|
|
265
487
|
},
|
|
266
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,58 @@ $.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
|
+
// ---- Tile assembly --------------------------------------------------
|
|
533
|
+
|
|
534
|
+
_collectTiles() {
|
|
535
|
+
const { peers, sharing, camOn, camMuted, micOn, micMuted, shareAudio } = this.state;
|
|
536
|
+
const screens = [];
|
|
537
|
+
const cams = [];
|
|
538
|
+
|
|
539
|
+
if (sharing) {
|
|
540
|
+
screens.push({
|
|
541
|
+
id: 'me', kind: 'screen', name: 'You', isSelf: true,
|
|
542
|
+
streamBinding: 'screenStream',
|
|
543
|
+
badges: shareAudio ? ['with audio'] : [],
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (camOn) {
|
|
547
|
+
cams.push({
|
|
548
|
+
id: 'me', kind: 'cam', name: 'You', isSelf: true,
|
|
549
|
+
streamBinding: 'camStream',
|
|
550
|
+
muted: camMuted, micOn, micMuted,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
peers.forEach((p, i) => {
|
|
555
|
+
if (p.screenStream) {
|
|
556
|
+
screens.push({
|
|
557
|
+
id: p.id, kind: 'screen', name: p.name, isSelf: false,
|
|
558
|
+
streamBinding: 'peers[' + i + '].screenStream',
|
|
559
|
+
badges: p.shareAudio ? ['with audio'] : [],
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
if (p.camStream) {
|
|
563
|
+
cams.push({
|
|
564
|
+
id: p.id, kind: 'cam', name: p.name, isSelf: false,
|
|
565
|
+
streamBinding: 'peers[' + i + '].camStream',
|
|
566
|
+
muted: p.camMuted, micOn: p.micOn, micMuted: p.micMuted,
|
|
567
|
+
connection: p.connection,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
return { screens, cams };
|
|
573
|
+
},
|
|
574
|
+
|
|
299
575
|
// ---- Render ---------------------------------------------------------
|
|
300
576
|
|
|
301
577
|
render() {
|
|
@@ -304,22 +580,29 @@ $.component('video-room', {
|
|
|
304
580
|
},
|
|
305
581
|
|
|
306
582
|
_renderLobby() {
|
|
307
|
-
const { roomName, displayName, status, error, connecting } = this.state;
|
|
583
|
+
const { roomName, displayName, status, error, connecting, hasMic, hasCam, hasShare } = this.state;
|
|
584
|
+
const hint = [];
|
|
585
|
+
if (!hasMic) hint.push('no microphone detected');
|
|
586
|
+
if (!hasCam) hint.push('no camera detected');
|
|
587
|
+
if (!hasShare) hint.push('screen sharing unavailable in this browser');
|
|
588
|
+
const hintLine = hint.length ? `<div class="device-hint">โ ${$.escapeHtml(hint.join(' ยท '))}</div>` : '';
|
|
589
|
+
|
|
308
590
|
return `
|
|
309
591
|
<div class="lobby">
|
|
310
592
|
<h1>zQuery WebRTC Demo</h1>
|
|
311
593
|
<p class="lead">
|
|
312
|
-
Mesh video call powered by
|
|
313
|
-
<code>$.webrtc.join()</code> against a
|
|
594
|
+
Mesh video call powered by <code>$.webrtc.join()</code> against a
|
|
314
595
|
<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
|
-
|
|
596
|
+
<code>SignalingHub</code>. Open this page on a second device (or in another
|
|
597
|
+
browser window) and join the same room to see a peer appear. Each call
|
|
598
|
+
supports up to a handful of peers in a full mesh; for larger meetings
|
|
599
|
+
swap in an SFU.
|
|
318
600
|
</p>
|
|
319
601
|
<p class="lead">
|
|
320
|
-
<strong>
|
|
321
|
-
Join
|
|
322
|
-
|
|
602
|
+
<strong>Mic, camera, and screen share are off by default.</strong>
|
|
603
|
+
Join first - then enable the devices you actually want to share.
|
|
604
|
+
Screen share will prompt you to choose a screen / window / tab and,
|
|
605
|
+
on Chromium-based browsers, also lets you include tab or system audio.
|
|
323
606
|
</p>
|
|
324
607
|
<form class="join-form" @submit="join">
|
|
325
608
|
<label>
|
|
@@ -334,6 +617,7 @@ $.component('video-room', {
|
|
|
334
617
|
${connecting ? 'Joining...' : 'Join room'}
|
|
335
618
|
</button>
|
|
336
619
|
</form>
|
|
620
|
+
${hintLine}
|
|
337
621
|
<p class="status ${error ? 'error' : ''}">
|
|
338
622
|
${error ? $.escapeHtml(error) : $.escapeHtml(status)}
|
|
339
623
|
</p>
|
|
@@ -344,43 +628,91 @@ $.component('video-room', {
|
|
|
344
628
|
_renderRoom() {
|
|
345
629
|
const {
|
|
346
630
|
peers, status, error, displayName, roomName,
|
|
347
|
-
micOn, camOn, micMuted, camMuted, sharing,
|
|
631
|
+
micOn, camOn, micMuted, camMuted, sharing, shareAudio,
|
|
632
|
+
messages, draft, pinned, hasShare,
|
|
348
633
|
} = this.state;
|
|
349
634
|
|
|
350
635
|
const peerCount = peers.length + 1;
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
636
|
+
const { screens, cams } = this._collectTiles();
|
|
637
|
+
|
|
638
|
+
// Decide the layout:
|
|
639
|
+
// - pinned tile (if any) wins and occupies the main stage solo.
|
|
640
|
+
// - else if there are screen shares, screens fill the main stage
|
|
641
|
+
// and cameras drop to a thumbnail strip alongside.
|
|
642
|
+
// - else cameras tile across the stage.
|
|
643
|
+
let stageMode = 'cams'; // 'cams' | 'screens' | 'pinned'
|
|
644
|
+
let mainTiles = [];
|
|
645
|
+
let stripTiles = [];
|
|
646
|
+
|
|
647
|
+
const pinTile = pinned
|
|
648
|
+
? [].concat(screens, cams).find((t) => t.id === pinned.id && t.kind === pinned.kind)
|
|
649
|
+
: null;
|
|
650
|
+
|
|
651
|
+
if (pinTile) {
|
|
652
|
+
stageMode = 'pinned';
|
|
653
|
+
mainTiles = [pinTile];
|
|
654
|
+
stripTiles = [].concat(screens, cams).filter((t) => t !== pinTile);
|
|
655
|
+
} else if (screens.length > 0) {
|
|
656
|
+
stageMode = 'screens';
|
|
657
|
+
mainTiles = screens;
|
|
658
|
+
stripTiles = cams;
|
|
659
|
+
} else {
|
|
660
|
+
stageMode = 'cams';
|
|
661
|
+
mainTiles = cams;
|
|
662
|
+
stripTiles = [];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const mainGrid = mainTiles.length > 0
|
|
666
|
+
? `<div class="stage-main stage-main-${this._gridClass(mainTiles.length)}">
|
|
667
|
+
${mainTiles.map((t) => this._tileHtml(t, 'main')).join('')}
|
|
668
|
+
</div>`
|
|
669
|
+
: `<div class="stage-empty">
|
|
670
|
+
<div class="empty-title">Nobody is sharing yet</div>
|
|
671
|
+
<div class="empty-sub">Start your camera or screen share from the controls below.</div>
|
|
672
|
+
</div>`;
|
|
673
|
+
|
|
674
|
+
const strip = stripTiles.length > 0
|
|
675
|
+
? `<div class="stage-strip">
|
|
676
|
+
${stripTiles.map((t) => this._tileHtml(t, 'strip')).join('')}
|
|
677
|
+
</div>`
|
|
678
|
+
: '';
|
|
357
679
|
|
|
358
680
|
const chatLines = messages.map((m) => `
|
|
359
681
|
<div class="msg ${m.mine ? 'mine' : ''}">
|
|
360
|
-
<
|
|
361
|
-
|
|
682
|
+
<div class="msg-head">
|
|
683
|
+
<span class="who">${$.escapeHtml(m.name)}</span>
|
|
684
|
+
<span class="when">${$.escapeHtml(this._formatTime(m.at))}</span>
|
|
685
|
+
</div>
|
|
686
|
+
<div class="text">${$.escapeHtml(m.text)}</div>
|
|
362
687
|
</div>
|
|
363
688
|
`).join('');
|
|
364
689
|
|
|
365
|
-
// Self-tile prefers the screen stream when sharing, else the camera.
|
|
366
|
-
const selfBinding = sharing ? 'screenStream' : 'camStream';
|
|
367
|
-
|
|
368
690
|
return `
|
|
369
|
-
<div class="room">
|
|
691
|
+
<div class="room ${stageMode === 'screens' || stageMode === 'pinned' ? 'has-focus' : ''}">
|
|
370
692
|
<aside class="sidebar">
|
|
371
693
|
<div class="room-meta">
|
|
372
694
|
<div class="room-name">#${$.escapeHtml(roomName)}</div>
|
|
373
|
-
<div class="room-sub">${peerCount} ${peerCount === 1 ? 'person' : 'people'}</div>
|
|
695
|
+
<div class="room-sub">${peerCount} ${peerCount === 1 ? 'person' : 'people'} ยท ${screens.length} sharing</div>
|
|
374
696
|
</div>
|
|
375
697
|
<div class="roster">
|
|
376
698
|
<div class="roster-row me">
|
|
377
699
|
<span class="dot ${micOn && !micMuted ? 'on' : 'off'}"></span>
|
|
378
|
-
|
|
700
|
+
<span class="who">${$.escapeHtml(displayName)} <small>(you)</small></span>
|
|
701
|
+
<span class="roster-icons">
|
|
702
|
+
${micOn ? (micMuted ? '๐' : '๐ค') : ''}
|
|
703
|
+
${camOn ? (camMuted ? '๐ซ' : '๐ท') : ''}
|
|
704
|
+
${sharing ? '๐ฅ๏ธ' : ''}
|
|
705
|
+
</span>
|
|
379
706
|
</div>
|
|
380
707
|
${peers.map((p) => `
|
|
381
|
-
<div class="roster-row">
|
|
382
|
-
<span class="dot on"></span>
|
|
383
|
-
|
|
708
|
+
<div class="roster-row ${p.connection === 'failed' ? 'broken' : ''}">
|
|
709
|
+
<span class="dot ${p.micOn && !p.micMuted ? 'on' : 'off'}"></span>
|
|
710
|
+
<span class="who">${$.escapeHtml(p.name || p.id)}</span>
|
|
711
|
+
<span class="roster-icons">
|
|
712
|
+
${p.micOn ? (p.micMuted ? '๐' : '๐ค') : ''}
|
|
713
|
+
${p.camOn ? (p.camMuted ? '๐ซ' : '๐ท') : ''}
|
|
714
|
+
${p.sharing ? '๐ฅ๏ธ' : ''}
|
|
715
|
+
</span>
|
|
384
716
|
</div>
|
|
385
717
|
`).join('')}
|
|
386
718
|
</div>
|
|
@@ -388,30 +720,33 @@ $.component('video-room', {
|
|
|
388
720
|
</aside>
|
|
389
721
|
|
|
390
722
|
<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}
|
|
723
|
+
<div class="stage-area">
|
|
724
|
+
${mainGrid}
|
|
725
|
+
${strip}
|
|
399
726
|
</div>
|
|
400
727
|
|
|
401
728
|
<div class="controls">
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
729
|
+
<div class="ctl-group">
|
|
730
|
+
${micOn
|
|
731
|
+
? `<button class="${micMuted ? 'off' : ''}" @click="toggleMute" title="${micMuted ? 'Unmute mic' : 'Mute mic'}">${micMuted ? '๐ Unmute' : '๐ค Mute'}</button>
|
|
732
|
+
<button class="off ghost" @click="stopMic" title="Stop microphone">โน Mic</button>`
|
|
733
|
+
: `<button class="primary" @click="startMic">๐ค Start mic</button>`}
|
|
734
|
+
</div>
|
|
406
735
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
736
|
+
<div class="ctl-group">
|
|
737
|
+
${camOn
|
|
738
|
+
? `<button class="${camMuted ? 'off' : ''}" @click="toggleCamMute" title="${camMuted ? 'Resume camera' : 'Pause camera'}">${camMuted ? '๐ซ Resume' : '๐ท Pause'}</button>
|
|
739
|
+
<button class="off ghost" @click="stopCam" title="Stop camera">โน Cam</button>`
|
|
740
|
+
: `<button class="primary" @click="startCam">๐ท Start camera</button>`}
|
|
741
|
+
</div>
|
|
742
|
+
|
|
743
|
+
<div class="ctl-group">
|
|
744
|
+
${sharing
|
|
745
|
+
? `<button class="active" @click="stopShare" title="Stop screen share">๐ Stop sharing${shareAudio ? ' (with audio)' : ''}</button>`
|
|
746
|
+
: `<button @click="startShare" ${hasShare ? '' : 'disabled'} title="${hasShare ? 'Share a screen, window or tab (audio capture optional)' : 'Screen share unsupported here'}">๐ฅ๏ธ Share screen</button>`}
|
|
747
|
+
</div>
|
|
411
748
|
|
|
412
|
-
${
|
|
413
|
-
? `<button class="active" @click="stopShare">๐ Stop sharing</button>`
|
|
414
|
-
: `<button @click="startShare">๐ฅ๏ธ Share screen</button>`}
|
|
749
|
+
${pinned ? `<button class="ghost" @click="unpin" title="Unpin focused tile">๐ Unpin</button>` : ''}
|
|
415
750
|
|
|
416
751
|
<div class="status-inline ${error ? 'error' : ''}">
|
|
417
752
|
${error ? $.escapeHtml(error) : $.escapeHtml(status)}
|
|
@@ -420,12 +755,12 @@ $.component('video-room', {
|
|
|
420
755
|
</section>
|
|
421
756
|
|
|
422
757
|
<aside class="chat">
|
|
423
|
-
<div class="chat-header">Chat</div>
|
|
758
|
+
<div class="chat-header">Chat ยท ${messages.length}</div>
|
|
424
759
|
<div class="chat-log" id="chat-log">
|
|
425
760
|
${chatLines || '<div class="empty">No messages yet. Say hi ๐</div>'}
|
|
426
761
|
</div>
|
|
427
762
|
<form class="chat-form" @submit="sendChat">
|
|
428
|
-
<input type="text" value="${$.escapeHtml(draft)}" @input="setDraft" placeholder="Message #${$.escapeHtml(roomName)}" />
|
|
763
|
+
<input type="text" value="${$.escapeHtml(draft)}" @input="setDraft" placeholder="Message #${$.escapeHtml(roomName)}" maxlength="500" />
|
|
429
764
|
<button type="submit" class="primary">Send</button>
|
|
430
765
|
</form>
|
|
431
766
|
</aside>
|
|
@@ -433,8 +768,57 @@ $.component('video-room', {
|
|
|
433
768
|
`;
|
|
434
769
|
},
|
|
435
770
|
|
|
771
|
+
_gridClass(n) {
|
|
772
|
+
if (n <= 1) return '1';
|
|
773
|
+
if (n === 2) return '2';
|
|
774
|
+
if (n <= 4) return '4';
|
|
775
|
+
if (n <= 6) return '6';
|
|
776
|
+
return '9';
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
_tileHtml(t, slot) {
|
|
780
|
+
const isScreen = t.kind === 'screen';
|
|
781
|
+
const audioAttr = t.isSelf ? 'muted' : '';
|
|
782
|
+
const cls = [
|
|
783
|
+
'tile',
|
|
784
|
+
'tile-' + slot,
|
|
785
|
+
'tile-' + t.kind,
|
|
786
|
+
t.isSelf ? 'self' : '',
|
|
787
|
+
t.muted ? 'muted-video' : '',
|
|
788
|
+
slot === 'strip' ? 'thumb' : '',
|
|
789
|
+
].filter(Boolean).join(' ');
|
|
790
|
+
|
|
791
|
+
const overlay = t.muted && !isScreen
|
|
792
|
+
? `<div class="camoff">Camera paused</div>`
|
|
793
|
+
: '';
|
|
794
|
+
|
|
795
|
+
const micChip = (!isScreen && t.micOn !== undefined)
|
|
796
|
+
? `<span class="chip ${t.micMuted ? 'chip-off' : 'chip-on'}">${t.micMuted ? '๐' : '๐ค'}</span>`
|
|
797
|
+
: '';
|
|
798
|
+
|
|
799
|
+
const audioChip = (isScreen && t.badges && t.badges.length)
|
|
800
|
+
? `<span class="chip chip-on">๐</span>`
|
|
801
|
+
: '';
|
|
802
|
+
|
|
803
|
+
const label = `<div class="label">
|
|
804
|
+
<span class="label-name">${$.escapeHtml(t.name)}${t.isSelf ? '' : ''}</span>
|
|
805
|
+
${micChip}${audioChip}
|
|
806
|
+
${isScreen ? '<span class="chip chip-screen">๐ฅ๏ธ</span>' : ''}
|
|
807
|
+
</div>`;
|
|
808
|
+
|
|
809
|
+
const pinBtn = `<button class="pin-btn" @click="pinTile('${t.id}', '${t.kind}')" title="Pin to focus">๐</button>`;
|
|
810
|
+
|
|
811
|
+
return `
|
|
812
|
+
<div class="${cls}">
|
|
813
|
+
<video z-stream="${t.streamBinding}" autoplay playsinline ${audioAttr}></video>
|
|
814
|
+
${overlay}
|
|
815
|
+
${label}
|
|
816
|
+
${pinBtn}
|
|
817
|
+
</div>
|
|
818
|
+
`;
|
|
819
|
+
},
|
|
820
|
+
|
|
436
821
|
updated() {
|
|
437
|
-
// Auto-scroll chat to the latest message after each render.
|
|
438
822
|
const log = this._el && this._el.querySelector ? this._el.querySelector('#chat-log') : null;
|
|
439
823
|
if (log) log.scrollTop = log.scrollHeight;
|
|
440
824
|
},
|