@devchitchat/chat 4.5.0 → 5.0.1

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