@devchitchat/chat 4.5.0 → 5.0.0

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.
@@ -0,0 +1,761 @@
1
+ /**
2
+ * CallView.js — WebRTC call UI.
3
+ *
4
+ * Ported from islands/call.js with minimal changes. Manages local media,
5
+ * tile grid, device picker, mini-bar, and all RTC signaling WS messages.
6
+ *
7
+ * Model events handled:
8
+ * channel-selected → update callChannelId context; show mini-bar if mid-call
9
+ *
10
+ * WS events handled directly (RTC is real-time critical — no model indirection):
11
+ * rtc.call_state, rtc.call, rtc.joined, rtc.peer_event, rtc.offer_event,
12
+ * rtc.answer_event, rtc.ice_event, rtc.call_end, rtc.left
13
+ */
14
+
15
+ import { escHtml } from '../shared/messages.js'
16
+ import { RtcPeerManager } from '../rtc-peer-manager.js'
17
+ import { patchSettings } from '../settings-sync.js'
18
+ import { navigateTo } from '../router.js'
19
+ import * as Ev from '../model/events.js'
20
+
21
+ const DEVICES_KEY = 'devchitchat_devices'
22
+ const LAYOUT_KEY = 'devchitchat_tile_layout'
23
+
24
+ export class CallView {
25
+ #model
26
+ #ws
27
+ #root // .chat-panel
28
+
29
+ // DOM refs
30
+ #tilePanelEl
31
+ #tileGridEl
32
+ #callStatusEl
33
+ #callStatusInfo
34
+ #callStatusAvatars
35
+ #callControlsEl
36
+ #peerCountEl
37
+ #btnStartCall
38
+ #btnJoinCall
39
+ #btnLeaveCall
40
+ #ctrlMic
41
+ #ctrlCam
42
+ #ctrlScreen
43
+ #ctrlDevices
44
+ #miniBarEl
45
+ #miniBarName
46
+ #miniBarMic
47
+ #miniBarReturn
48
+ #miniBarLeave
49
+
50
+ // Call state
51
+ #inCall = false
52
+ #callId = null
53
+ #selfPeerId = null
54
+ #micMuted = false
55
+ #camOff = true
56
+ #screenSharing = false
57
+ #pinnedPeerId = null
58
+ #callChannelId = null // channel where the active call lives
59
+
60
+ // Media streams
61
+ #audioStream = null
62
+ #videoStream = null
63
+ #screenStream = null
64
+ #iceServers = [{ urls: 'stun:stun.l.google.com:19302' }]
65
+
66
+ // Devices
67
+ #availableDevices = { cameras: [], mics: [] }
68
+ #activeCameraId = null
69
+ #activeMicId = null
70
+ #devicePickerEl = null
71
+
72
+ // RTC
73
+ #rtcManager
74
+
75
+ /**
76
+ * @param {AppModel} model
77
+ * @param {WsClient} ws
78
+ * @param {HTMLElement} rootEl — .chat-panel
79
+ */
80
+ constructor(model, ws, rootEl) {
81
+ this.#model = model
82
+ this.#ws = ws
83
+ this.#root = rootEl
84
+
85
+ this.#grabDomRefs()
86
+ this.#buildRtcManager()
87
+ this.#restoreLayoutState()
88
+ this.#bindControls()
89
+ this.#bindWsEvents()
90
+ this.#bindModelEvents()
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────
94
+ // Initialization
95
+ // ─────────────────────────────────────────────────────────────────────────
96
+
97
+ #grabDomRefs() {
98
+ const q = id => document.getElementById(id)
99
+ this.#tilePanelEl = q('tile-panel')
100
+ this.#tileGridEl = q('tile-grid')
101
+ this.#callStatusEl = q('call-status')
102
+ this.#callStatusInfo = q('call-status-info')
103
+ this.#callStatusAvatars = q('call-status-avatars')
104
+ this.#callControlsEl = q('call-controls-bar')
105
+ this.#peerCountEl = q('call-peer-count')
106
+ this.#btnStartCall = q('btn-start-call')
107
+ this.#btnJoinCall = q('btn-join-call')
108
+ this.#btnLeaveCall = q('btn-leave-call')
109
+ this.#ctrlMic = q('ctrl-mic')
110
+ this.#ctrlCam = q('ctrl-cam')
111
+ this.#ctrlScreen = q('ctrl-screen')
112
+ this.#ctrlDevices = q('ctrl-devices')
113
+ this.#miniBarEl = q('call-mini-bar')
114
+ this.#miniBarName = q('mini-bar-channel-name')
115
+ this.#miniBarMic = q('mini-bar-mic')
116
+ this.#miniBarReturn = q('mini-bar-return')
117
+ this.#miniBarLeave = q('mini-bar-leave')
118
+ }
119
+
120
+ #buildRtcManager() {
121
+ const ws = this.#ws
122
+ this.#rtcManager = new RtcPeerManager({
123
+ iceServers: this.#iceServers,
124
+ getLocalStreams: () => ({
125
+ audio: this.#audioStream,
126
+ video: this.#videoStream,
127
+ screen: this.#screenStream,
128
+ }),
129
+ handlers: {
130
+ onOffer: (peerId, sdp) => ws.send({ t: 'rtc.offer', body: { call_id: this.#callId, to_peer_id: peerId, sdp } }),
131
+ onAnswer: (peerId, sdp) => ws.send({ t: 'rtc.answer', body: { call_id: this.#callId, to_peer_id: peerId, sdp } }),
132
+ onIceCandidate: (peerId, candidate) => ws.send({ t: 'rtc.ice', body: { call_id: this.#callId, to_peer_id: peerId, candidate } }),
133
+ onTrack: (peerId, tileId, stream, label) => { this.#renderTile(tileId, stream, false, label); this.#ensureRemoteAudio(stream, peerId) },
134
+ onAudio: (peerId, stream) => this.#ensureRemoteAudio(stream, peerId),
135
+ onPeerClosed: (peerId) => {
136
+ this.#tileGridEl?.querySelectorAll(`[data-peer^="${peerId}"]`).forEach(t => t.remove())
137
+ document.querySelectorAll(`audio[data-peer-id="${peerId}"]`).forEach(a => { a.srcObject = null; a.remove() })
138
+ this.#updateTileLayout()
139
+ },
140
+ },
141
+ })
142
+ }
143
+
144
+ #restoreLayoutState() {
145
+ try {
146
+ const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
147
+ if (saved.collapsed) this.#tilePanelEl?.classList.add('collapsed')
148
+ if (saved.overlayRight && saved.overlayTop && this.#tilePanelEl) {
149
+ this.#tilePanelEl.style.right = saved.overlayRight
150
+ this.#tilePanelEl.style.top = saved.overlayTop
151
+ }
152
+ } catch { /* ignore */ }
153
+ this.#attachOverlayDrag(this.#tilePanelEl)
154
+ }
155
+
156
+ // ─────────────────────────────────────────────────────────────────────────
157
+ // Button bindings
158
+ // ─────────────────────────────────────────────────────────────────────────
159
+
160
+ #bindControls() {
161
+ // Mobile "back to sidebar" button
162
+ this.#root.querySelector('.btn-back-mobile')?.addEventListener('click', () => {
163
+ document.body.classList.add('sidebar-open')
164
+ })
165
+
166
+ this.#btnStartCall?.addEventListener('click', () => {
167
+ const channelId = this.#model.currentChannelId
168
+ this.#ws.send({ t: 'rtc.call_create', body: { channel_id: channelId, kind: 'mesh' } })
169
+ })
170
+ this.#btnJoinCall?.addEventListener('click', () => {
171
+ if (this.#callId) this.#ws.send({ t: 'rtc.join', body: { call_id: this.#callId } })
172
+ })
173
+ this.#btnLeaveCall?.addEventListener('click', () => this.#leaveCall())
174
+ this.#ctrlMic?.addEventListener('click', () => this.#toggleMic())
175
+ this.#ctrlCam?.addEventListener('click', () => this.#toggleCamera())
176
+ this.#ctrlScreen?.addEventListener('click', () => this.#toggleScreen())
177
+ this.#ctrlDevices?.addEventListener('click', () => {
178
+ this.#devicePickerEl?.classList.contains('open') ? this.#closePicker() : this.#openPicker()
179
+ })
180
+ this.#miniBarMic?.addEventListener('click', () => this.#toggleMic())
181
+ this.#miniBarReturn?.addEventListener('click', () => {
182
+ if (this.#callChannelId) {
183
+ navigateTo(`${window.__BASE_PATH__ ?? ''}/channels/${this.#callChannelId}`, false)
184
+ }
185
+ })
186
+ this.#miniBarLeave?.addEventListener('click', () => this.#leaveCall())
187
+
188
+ document.getElementById('tile-panel-collapse')?.addEventListener('click', () => {
189
+ const collapsed = this.#tilePanelEl?.classList.toggle('collapsed')
190
+ try {
191
+ const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
192
+ localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, collapsed: !!collapsed }))
193
+ } catch { /* ignore */ }
194
+ })
195
+ }
196
+
197
+ // ─────────────────────────────────────────────────────────────────────────
198
+ // WebSocket event bindings
199
+ // ─────────────────────────────────────────────────────────────────────────
200
+
201
+ #bindWsEvents() {
202
+ const ws = this.#ws
203
+
204
+ ws.on('rtc.call_state', body => {
205
+ const channelId = this.#model.currentChannelId
206
+ if (body.channel_id !== channelId) return
207
+ if (!this.#inCall || body.channel_id === this.#callChannelId) {
208
+ this.#callId = body.call_id
209
+ }
210
+ this.#updateCallStatusRow(body.call_id, body.count, body.users ?? [])
211
+ this.#updateChannelBadge(body.count)
212
+ })
213
+
214
+ ws.on('rtc.call', body => {
215
+ if (body.ice_servers?.length) {
216
+ this.#iceServers = body.ice_servers
217
+ this.#rtcManager.setIceServers(this.#iceServers)
218
+ }
219
+ this.#callId = body.call_id
220
+ ws.send({ t: 'rtc.join', body: { call_id: body.call_id } })
221
+ })
222
+
223
+ ws.on('rtc.joined', async body => {
224
+ const { call_id, peer_id, peers } = body
225
+ if (body.ice_servers?.length) {
226
+ this.#iceServers = body.ice_servers
227
+ this.#rtcManager.setIceServers(this.#iceServers)
228
+ }
229
+ this.#selfPeerId = peer_id
230
+ this.#callId = call_id
231
+ this.#callChannelId = this.#model.currentChannelId
232
+ this.#inCall = true
233
+ this.#showCallControls()
234
+ this.#showTilePanel()
235
+ this.#attachDeviceChangeListener()
236
+ patchSettings({ last_channel_id: this.#callChannelId })
237
+
238
+ await this.#startAudio()
239
+
240
+ for (const peer of peers) {
241
+ if (peer.peer_id !== peer_id) {
242
+ this.#rtcManager.setDisplayName(peer.peer_id, peer.display_name)
243
+ this.#rtcManager.ensurePeer(peer.peer_id)
244
+ this.#rtcManager.negotiate(peer.peer_id)
245
+ }
246
+ }
247
+ })
248
+
249
+ ws.on('rtc.peer_event', ({ kind, peer }) => {
250
+ if (kind === 'join' && peer.peer_id !== this.#selfPeerId) {
251
+ this.#rtcManager.setDisplayName(peer.peer_id, peer.display_name)
252
+ this.#rtcManager.ensurePeer(peer.peer_id)
253
+ }
254
+ if (kind === 'leave') {
255
+ this.#rtcManager.closePeer(peer.peer_id)
256
+ }
257
+ })
258
+
259
+ ws.on('rtc.offer_event', async ({ from_peer_id, sdp }) => {
260
+ await this.#rtcManager.handleRemoteOffer(from_peer_id, this.#callId, sdp)
261
+ })
262
+
263
+ ws.on('rtc.answer_event', async ({ from_peer_id, sdp }) => {
264
+ await this.#rtcManager.handleRemoteAnswer(from_peer_id, sdp)
265
+ })
266
+
267
+ ws.on('rtc.ice_event', async ({ from_peer_id, candidate }) => {
268
+ await this.#rtcManager.handleIceCandidate(from_peer_id, candidate)
269
+ })
270
+
271
+ ws.on('rtc.call_end', ({ call_id }) => {
272
+ if (call_id === this.#callId) this.#teardownCall()
273
+ })
274
+
275
+ ws.on('rtc.left', () => { /* server confirmed our leave */ })
276
+ }
277
+
278
+ // ─────────────────────────────────────────────────────────────────────────
279
+ // Model event bindings
280
+ // ─────────────────────────────────────────────────────────────────────────
281
+
282
+ #bindModelEvents() {
283
+ this.#model.addEventListener(Ev.CHANNEL_SELECTED, e => {
284
+ const { channelId } = e.detail
285
+ if (this.#inCall && channelId !== this.#callChannelId) {
286
+ this.#showMiniBar()
287
+ } else if (channelId === this.#callChannelId) {
288
+ this.#hideMiniBar()
289
+ }
290
+ })
291
+ }
292
+
293
+ // ─────────────────────────────────────────────────────────────────────────
294
+ // Call state UI
295
+ // ─────────────────────────────────────────────────────────────────────────
296
+
297
+ #updateCallStatusRow(activeCallId, count, users) {
298
+ if (!this.#callStatusEl) return
299
+ if (this.#inCall) {
300
+ if (this.#peerCountEl) this.#peerCountEl.textContent = count > 1 ? `${count} in call` : ''
301
+ this.#callStatusEl.hidden = true
302
+ return
303
+ }
304
+ if (!activeCallId || count === 0) { this.#callStatusEl.hidden = true; return }
305
+ this.#callStatusEl.hidden = false
306
+ if (this.#callStatusInfo) this.#callStatusInfo.textContent = `${count} in call`
307
+ if (this.#callStatusAvatars) {
308
+ this.#callStatusAvatars.innerHTML = users.slice(0, 5).map(u =>
309
+ `<span class="call-status-avatar" title="${escHtml(u.user_id)}">${escHtml(u.user_id.slice(0, 2).toUpperCase())}</span>`
310
+ ).join('')
311
+ }
312
+ }
313
+
314
+ #updateChannelBadge(count) {
315
+ const channelId = this.#model.currentChannelId
316
+ const li = document.querySelector(`.channel-link[data-channel-id="${channelId}"]`)?.closest('li')
317
+ if (!li) return
318
+ li.classList.toggle('call-active', count > 0)
319
+ const badge = li.querySelector('.call-badge')
320
+ if (badge) badge.textContent = count > 0 ? String(count) : ''
321
+ }
322
+
323
+ #showCallControls() {
324
+ if (this.#callStatusEl) this.#callStatusEl.hidden = true
325
+ this.#callControlsEl?.classList.add('active')
326
+ if (this.#btnStartCall) this.#btnStartCall.hidden = true
327
+ }
328
+
329
+ #hideCallControls() {
330
+ this.#callControlsEl?.classList.remove('active')
331
+ if (this.#btnStartCall) this.#btnStartCall.hidden = false
332
+ }
333
+
334
+ #showTilePanel() {
335
+ document.querySelector('.main-content')?.classList.add('has-call')
336
+ this.#tilePanelEl?.classList.add('active')
337
+ }
338
+
339
+ #hideTilePanel() {
340
+ document.querySelector('.main-content')?.classList.remove('has-call')
341
+ this.#tilePanelEl?.classList.remove('active', 'collapsed')
342
+ }
343
+
344
+ #showMiniBar() {
345
+ if (!this.#miniBarEl) return
346
+ const meta = this.#model.currentChannelMeta
347
+ if (this.#miniBarName) this.#miniBarName.textContent = meta.name ?? ''
348
+ this.#miniBarEl.classList.add('active')
349
+ }
350
+
351
+ #hideMiniBar() { this.#miniBarEl?.classList.remove('active') }
352
+
353
+ // ─────────────────────────────────────────────────────────────────────────
354
+ // Local media
355
+ // ─────────────────────────────────────────────────────────────────────────
356
+
357
+ async #startAudio() {
358
+ if (this.#audioStream) return
359
+ try {
360
+ const saved = this.#loadSavedDevices()
361
+ this.#audioStream = await navigator.mediaDevices.getUserMedia({
362
+ audio: saved.micId ? { deviceId: { ideal: saved.micId } } : true,
363
+ video: false,
364
+ })
365
+ this.#activeMicId = this.#audioStream.getAudioTracks()[0]?.getSettings().deviceId ?? null
366
+ this.#audioStream.getAudioTracks().forEach(t => { t.enabled = !this.#micMuted })
367
+ await this.#refreshDevices()
368
+ for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
369
+ } catch {
370
+ this.#micMuted = true
371
+ }
372
+ }
373
+
374
+ async #toggleMic() {
375
+ this.#micMuted = !this.#micMuted
376
+ this.#audioStream?.getAudioTracks().forEach(t => { t.enabled = !this.#micMuted })
377
+ if (this.#ctrlMic) this.#ctrlMic.textContent = this.#micMuted ? '🔇' : '🎙'
378
+ if (this.#miniBarMic) this.#miniBarMic.textContent = this.#micMuted ? '🔇' : '🎙'
379
+ }
380
+
381
+ async #toggleCamera() {
382
+ if (this.#videoStream) {
383
+ this.#videoStream.getTracks().forEach(t => t.stop())
384
+ this.#removeTile('local-cam')
385
+ this.#videoStream = null
386
+ this.#camOff = true
387
+ for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
388
+ if (this.#ctrlCam) this.#ctrlCam.textContent = '📷'
389
+ return
390
+ }
391
+ try {
392
+ const saved = this.#loadSavedDevices()
393
+ const videoConstraint = saved.cameraId
394
+ ? { deviceId: { ideal: saved.cameraId }, width: 640, height: 360 }
395
+ : { width: 640, height: 360 }
396
+ this.#videoStream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: false })
397
+ this.#activeCameraId = this.#videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null
398
+ this.#camOff = false
399
+ const userHandle = this.#model.userHandle
400
+ this.#renderTile('local-cam', this.#videoStream, true, `${userHandle ?? 'You'} (cam)`)
401
+ this.#ws.send({ t: 'rtc.stream_publish', body: { call_id: this.#callId, stream: { kind: 'camera' } } })
402
+ for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
403
+ if (this.#ctrlCam) this.#ctrlCam.textContent = '📷✓'
404
+ } catch { /* camera denied */ }
405
+ }
406
+
407
+ async #toggleScreen() {
408
+ if (this.#screenStream) {
409
+ this.#screenStream.getTracks().forEach(t => t.stop())
410
+ this.#removeTile('local-screen')
411
+ this.#screenStream = null
412
+ this.#screenSharing = false
413
+ for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
414
+ if (this.#ctrlScreen) this.#ctrlScreen.textContent = '🖥'
415
+ return
416
+ }
417
+ if (!navigator.mediaDevices?.getDisplayMedia) return
418
+ try {
419
+ this.#screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false })
420
+ this.#screenSharing = true
421
+ const userHandle = this.#model.userHandle
422
+ this.#renderTile('local-screen', this.#screenStream, true, `${userHandle ?? 'You'} (screen)`)
423
+ this.#ws.send({ t: 'rtc.stream_publish', body: { call_id: this.#callId, stream: { kind: 'screen' } } })
424
+ this.#screenStream.getVideoTracks()[0].addEventListener('ended', () => this.#toggleScreen())
425
+ for (const peerId of this.#rtcManager.peerIds()) this.#rtcManager.negotiate(peerId)
426
+ if (this.#ctrlScreen) this.#ctrlScreen.textContent = '🖥✓'
427
+ } catch { /* user cancelled */ }
428
+ }
429
+
430
+ // ─────────────────────────────────────────────────────────────────────────
431
+ // Remote audio
432
+ // ─────────────────────────────────────────────────────────────────────────
433
+
434
+ #ensureRemoteAudio(stream, peerId) {
435
+ if (document.querySelector(`audio[data-peer-id="${peerId}"]`)) return
436
+ const audio = document.createElement('audio')
437
+ audio.autoplay = true
438
+ audio.dataset.peerId = peerId
439
+ audio.srcObject = stream
440
+ document.body.appendChild(audio)
441
+ }
442
+
443
+ // ─────────────────────────────────────────────────────────────────────────
444
+ // Tile grid
445
+ // ─────────────────────────────────────────────────────────────────────────
446
+
447
+ #renderTile(tileId, stream, muted, label) {
448
+ if (!this.#tileGridEl) return
449
+ let tile = this.#tileGridEl.querySelector(`[data-peer="${tileId}"]`)
450
+ if (!tile) {
451
+ tile = document.createElement('div')
452
+ tile.className = 'stream-tile'
453
+ tile.dataset.peer = tileId
454
+ tile.innerHTML = `
455
+ <video autoplay playsinline controls ${muted ? 'muted' : ''}></video>
456
+ <span class="tile-label">${escHtml(label)}</span>
457
+ <div class="tile-capture-wrap">
458
+ <button class="tile-pin" title="Move to top">⬆</button>
459
+ <button class="tile-capture" title="Capture photo">📸</button>
460
+ <div class="tile-capture-menu" hidden>
461
+ <button class="tile-capture-opt" data-delay="0">0s</button>
462
+ <button class="tile-capture-opt" data-delay="1">1s</button>
463
+ <button class="tile-capture-opt" data-delay="3">3s</button>
464
+ <button class="tile-capture-opt" data-delay="5">5s</button>
465
+ </div>
466
+ </div>
467
+ <div class="tile-countdown" hidden></div>`
468
+ tile.querySelector('video').addEventListener('click', e => e.stopPropagation())
469
+ tile.querySelector('.tile-pin').addEventListener('click', e => { e.stopPropagation(); this.#pinTile(tileId) })
470
+ const menu = tile.querySelector('.tile-capture-menu')
471
+ tile.querySelector('.tile-capture').addEventListener('click', e => {
472
+ e.stopPropagation()
473
+ if (tile._captureTimer) { this.#startCapture(tile, label, 0); return }
474
+ menu.hidden = !menu.hidden
475
+ })
476
+ menu.querySelectorAll('.tile-capture-opt').forEach(btn => {
477
+ btn.addEventListener('click', e => {
478
+ e.stopPropagation()
479
+ menu.hidden = true
480
+ this.#startCapture(tile, label, parseInt(btn.dataset.delay))
481
+ })
482
+ })
483
+ this.#tileGridEl.appendChild(tile)
484
+ this.#updateTileLayout()
485
+ }
486
+ if (stream) tile.querySelector('video').srcObject = stream
487
+ return tile
488
+ }
489
+
490
+ #removeTile(tileId) {
491
+ this.#tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.remove()
492
+ this.#updateTileLayout()
493
+ }
494
+
495
+ #updateTileLayout() {
496
+ if (!this.#tileGridEl) return
497
+ const count = this.#tileGridEl.querySelectorAll('.stream-tile').length
498
+ this.#tileGridEl.classList.toggle('avatars-only', count >= 5)
499
+ }
500
+
501
+ #pinTile(tileId) {
502
+ if (this.#pinnedPeerId === tileId) {
503
+ this.#tileGridEl?.classList.remove('pinned')
504
+ this.#tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
505
+ this.#pinnedPeerId = null
506
+ } else {
507
+ this.#tileGridEl?.classList.add('pinned')
508
+ this.#tileGridEl?.querySelectorAll('.stream-tile').forEach(t => t.classList.remove('pinned-tile'))
509
+ this.#tileGridEl?.querySelector(`[data-peer="${tileId}"]`)?.classList.add('pinned-tile')
510
+ this.#pinnedPeerId = tileId
511
+ }
512
+ }
513
+
514
+ #captureFrame(tile, label) {
515
+ const video = tile.querySelector('video')
516
+ if (!video?.videoWidth) return
517
+ const canvas = document.createElement('canvas')
518
+ canvas.width = video.videoWidth; canvas.height = video.videoHeight
519
+ canvas.getContext('2d').drawImage(video, 0, 0)
520
+ const a = document.createElement('a')
521
+ a.href = canvas.toDataURL('image/png')
522
+ a.download = `capture-${label.replace(/[^a-z0-9]/gi, '-')}-${Date.now()}.png`
523
+ a.click()
524
+ }
525
+
526
+ #startCapture(tile, label, delay) {
527
+ const countdown = tile.querySelector('.tile-countdown')
528
+ const captureBtn = tile.querySelector('.tile-capture')
529
+ if (tile._captureTimer) {
530
+ clearInterval(tile._captureTimer); tile._captureTimer = null
531
+ countdown.hidden = true; captureBtn.textContent = '📸'; return
532
+ }
533
+ if (delay === 0) { this.#captureFrame(tile, label); return }
534
+ let remaining = delay
535
+ countdown.textContent = remaining; countdown.hidden = false; captureBtn.textContent = '✕'
536
+ tile._captureTimer = setInterval(() => {
537
+ remaining--
538
+ if (remaining <= 0) {
539
+ clearInterval(tile._captureTimer); tile._captureTimer = null
540
+ countdown.hidden = true; captureBtn.textContent = '📸'; this.#captureFrame(tile, label)
541
+ } else { countdown.textContent = remaining }
542
+ }, 1000)
543
+ }
544
+
545
+ // ─────────────────────────────────────────────────────────────────────────
546
+ // Device management
547
+ // ─────────────────────────────────────────────────────────────────────────
548
+
549
+ #loadSavedDevices() {
550
+ try { return JSON.parse(localStorage.getItem(DEVICES_KEY) ?? '{}') } catch { return {} }
551
+ }
552
+
553
+ #saveDevices(patch) {
554
+ localStorage.setItem(DEVICES_KEY, JSON.stringify({ ...this.#loadSavedDevices(), ...patch }))
555
+ }
556
+
557
+ async #refreshDevices() {
558
+ const devices = await navigator.mediaDevices.enumerateDevices()
559
+ this.#availableDevices = {
560
+ cameras: devices.filter(d => d.kind === 'videoinput'),
561
+ mics: devices.filter(d => d.kind === 'audioinput'),
562
+ }
563
+ return this.#availableDevices
564
+ }
565
+
566
+ #onDeviceChange() {
567
+ this.#refreshDevices().then(({ cameras, mics }) => {
568
+ const cameraGone = this.#activeCameraId && !cameras.find(d => d.deviceId === this.#activeCameraId)
569
+ const micGone = this.#activeMicId && !mics.find(d => d.deviceId === this.#activeMicId)
570
+ if (cameraGone || micGone) this.#showDeviceWarning(cameraGone ? 'camera' : 'mic')
571
+ if (this.#devicePickerEl?.classList.contains('open')) this.#populatePicker()
572
+ })
573
+ }
574
+
575
+ #attachDeviceChangeListener() {
576
+ navigator.mediaDevices.addEventListener('devicechange', this.#onDeviceChange.bind(this))
577
+ }
578
+
579
+ #detachDeviceChangeListener() {
580
+ navigator.mediaDevices.removeEventListener('devicechange', this.#onDeviceChange.bind(this))
581
+ }
582
+
583
+ async #openPicker() {
584
+ if (!this.#devicePickerEl) this.#buildPicker()
585
+ await this.#refreshDevices()
586
+ this.#populatePicker()
587
+ this.#devicePickerEl.classList.add('open')
588
+ }
589
+
590
+ #closePicker() {
591
+ this.#devicePickerEl?._previewStream?.getTracks().forEach(t => t.stop())
592
+ if (this.#devicePickerEl) this.#devicePickerEl._previewStream = null
593
+ this.#devicePickerEl?.classList.remove('open')
594
+ }
595
+
596
+ #buildPicker() {
597
+ const el = document.createElement('div')
598
+ el.className = 'device-picker'
599
+ el.innerHTML = `
600
+ <div class="device-picker-row">
601
+ <label>Camera</label><select id="dp-camera"></select>
602
+ <video id="dp-preview" autoplay playsinline muted></video>
603
+ </div>
604
+ <div class="device-picker-row">
605
+ <label>Microphone</label><select id="dp-mic"></select>
606
+ <canvas id="dp-level" width="80" height="12"></canvas>
607
+ </div>
608
+ <div class="device-picker-footer">
609
+ <button id="dp-cancel" class="btn-ghost" type="button">Cancel</button>
610
+ <button id="dp-apply" class="btn-primary" type="button">Switch</button>
611
+ </div>`
612
+ this.#callControlsEl?.after(el)
613
+ this.#devicePickerEl = el
614
+
615
+ el.querySelector('#dp-cancel').addEventListener('click', () => this.#closePicker())
616
+ el.querySelector('#dp-apply').addEventListener('click', () => this.#applyPicker())
617
+
618
+ el.querySelector('#dp-camera').addEventListener('change', async () => {
619
+ el._previewStream?.getTracks().forEach(t => t.stop())
620
+ el._previewStream = null
621
+ const val = el.querySelector('#dp-camera').value
622
+ if (!val) return
623
+ try {
624
+ const stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: val } } })
625
+ el.querySelector('#dp-preview').srcObject = stream
626
+ el._previewStream = stream
627
+ } catch { /* unavailable */ }
628
+ })
629
+ }
630
+
631
+ #populatePicker() {
632
+ const { cameras, mics } = this.#availableDevices
633
+ const cameraSelect = this.#devicePickerEl?.querySelector('#dp-camera')
634
+ const micSelect = this.#devicePickerEl?.querySelector('#dp-mic')
635
+ if (cameraSelect) cameraSelect.innerHTML = cameras
636
+ .map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === this.#activeCameraId ? ' selected' : ''}>${escHtml(d.label || 'Camera')}</option>`)
637
+ .join('')
638
+ if (micSelect) micSelect.innerHTML = mics
639
+ .map(d => `<option value="${escHtml(d.deviceId)}"${d.deviceId === this.#activeMicId ? ' selected' : ''}>${escHtml(d.label || 'Microphone')}</option>`)
640
+ .join('')
641
+ }
642
+
643
+ async #applyPicker() {
644
+ const cameraId = this.#devicePickerEl?.querySelector('#dp-camera')?.value
645
+ const micId = this.#devicePickerEl?.querySelector('#dp-mic')?.value
646
+ try {
647
+ if (cameraId && cameraId !== this.#activeCameraId && this.#videoStream) await this.#switchCamera(cameraId)
648
+ if (micId && micId !== this.#activeMicId) await this.#switchMic(micId)
649
+ this.#ctrlDevices?.classList.remove('device-warning')
650
+ } catch { /* leave current stream in place */ }
651
+ this.#closePicker()
652
+ }
653
+
654
+ async #switchCamera(deviceId) {
655
+ const newStream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } } })
656
+ await this.#rtcManager.replaceTrack('camera', newStream.getVideoTracks()[0])
657
+ this.#videoStream?.getTracks().forEach(t => t.stop())
658
+ this.#videoStream = newStream
659
+ this.#activeCameraId = deviceId
660
+ this.#saveDevices({ cameraId: deviceId })
661
+ const tile = this.#tileGridEl?.querySelector('[data-peer="local-cam"]')
662
+ if (tile) tile.querySelector('video').srcObject = newStream
663
+ }
664
+
665
+ async #switchMic(deviceId) {
666
+ const newStream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: deviceId } } })
667
+ const newTrack = newStream.getAudioTracks()[0]
668
+ newTrack.enabled = !this.#micMuted
669
+ await this.#rtcManager.replaceTrack('audio', newTrack)
670
+ this.#audioStream?.getTracks().forEach(t => t.stop())
671
+ this.#audioStream = newStream
672
+ this.#activeMicId = deviceId
673
+ this.#saveDevices({ micId: deviceId })
674
+ }
675
+
676
+ #showDeviceWarning(kind) {
677
+ const label = kind === 'camera' ? 'Camera' : 'Microphone'
678
+ const toast = document.createElement('div')
679
+ toast.className = 'device-warning-toast'
680
+ toast.textContent = `${label} disconnected — click ⚙ to switch`
681
+ document.body.appendChild(toast)
682
+ setTimeout(() => toast.remove(), 6000)
683
+ this.#ctrlDevices?.classList.add('device-warning')
684
+ }
685
+
686
+ // ─────────────────────────────────────────────────────────────────────────
687
+ // Leave / teardown
688
+ // ─────────────────────────────────────────────────────────────────────────
689
+
690
+ #leaveCall() {
691
+ if (!this.#inCall || !this.#callId) return
692
+ this.#ws.send({ t: 'rtc.leave', body: { call_id: this.#callId } })
693
+ this.#teardownCall()
694
+ }
695
+
696
+ #teardownCall() {
697
+ this.#rtcManager.teardown()
698
+ this.#audioStream?.getTracks().forEach(t => t.stop()); this.#audioStream = null
699
+ this.#videoStream?.getTracks().forEach(t => t.stop()); this.#videoStream = null
700
+ this.#screenStream?.getTracks().forEach(t => t.stop()); this.#screenStream = null
701
+ document.querySelectorAll('audio[data-peer-id]').forEach(a => { a.srcObject = null; a.remove() })
702
+ if (this.#tileGridEl) this.#tileGridEl.innerHTML = ''
703
+ this.#updateTileLayout()
704
+ this.#hideCallControls()
705
+ this.#hideTilePanel()
706
+ this.#hideMiniBar()
707
+ this.#closePicker()
708
+ this.#detachDeviceChangeListener()
709
+ this.#ctrlDevices?.classList.remove('device-warning')
710
+ this.#micMuted = false
711
+ this.#camOff = true
712
+ this.#screenSharing = false
713
+ this.#inCall = false
714
+ this.#selfPeerId = null
715
+ this.#callChannelId = null
716
+ }
717
+
718
+ // ─────────────────────────────────────────────────────────────────────────
719
+ // Overlay drag (mobile)
720
+ // ─────────────────────────────────────────────────────────────────────────
721
+
722
+ #attachOverlayDrag(panel) {
723
+ if (!panel) return
724
+ if (window.matchMedia('(min-width: 1025px)').matches) return
725
+ const header = panel.querySelector('.tile-panel-header')
726
+ if (!header) return
727
+
728
+ let startX, startY, startRight, startTop
729
+
730
+ const onMove = e => {
731
+ e.preventDefault()
732
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX
733
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY
734
+ const dx = startX - clientX; const dy = clientY - startY
735
+ panel.style.right = `${Math.max(0, Math.min(startRight + dx, window.innerWidth - 60))}px`
736
+ panel.style.top = `${Math.max(0, Math.min(startTop + dy, window.innerHeight - 60))}px`
737
+ }
738
+
739
+ const onEnd = () => {
740
+ document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd)
741
+ document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd)
742
+ try {
743
+ const saved = JSON.parse(localStorage.getItem(LAYOUT_KEY) ?? '{}')
744
+ localStorage.setItem(LAYOUT_KEY, JSON.stringify({ ...saved, overlayRight: panel.style.right, overlayTop: panel.style.top }))
745
+ } catch { /* ignore */ }
746
+ }
747
+
748
+ header.addEventListener('mousedown', e => {
749
+ startX = e.clientX; startY = e.clientY
750
+ startRight = parseInt(panel.style.right) || 0; startTop = parseInt(panel.style.top) || 0
751
+ document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd)
752
+ })
753
+
754
+ header.addEventListener('touchstart', e => {
755
+ e.preventDefault()
756
+ startX = e.touches[0].clientX; startY = e.touches[0].clientY
757
+ startRight = parseInt(panel.style.right) || 0; startTop = parseInt(panel.style.top) || 0
758
+ document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onEnd)
759
+ }, { passive: false })
760
+ }
761
+ }