index.html
Raw
1<!doctype html>
2<html lang="en" data-theme="dark">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>WebRTC Tool</title>
7<script>
8 /* Responsive viewport with a soft minimum: render at the real device width
9 when there's enough room (≥ MIN_WIDTH CSS px), otherwise lock the layout
10 viewport to MIN_WIDTH so a narrow device scales the page down to fit
11 instead of breaking layout. Re-runs on rotation / window resize so a
12 phone that lands above the threshold in landscape gets the responsive
13 layout there. */
14 (function () {
15 var MIN_WIDTH = 500;
16 var meta = document.querySelector('meta[name="viewport"]');
17 var responsive = 'width=device-width, initial-scale=1';
18 function update() {
19 /* `screen.width` reflects the physical device width in CSS pixels and
20 tracks orientation on every modern browser. It's independent of the
21 current viewport meta, so we can safely use it to *decide* whether
22 to switch viewports without feedback loops. */
23 var w = (window.screen && window.screen.width) || window.innerWidth;
24 var want = w < MIN_WIDTH ? 'width=' + MIN_WIDTH : responsive;
25 if (meta.getAttribute('content') !== want) meta.setAttribute('content', want);
26 }
27 update();
28 window.addEventListener('resize', update);
29 window.addEventListener('orientationchange', update);
30 })();
31</script>
32<style>
33 :root {
34 --bg: #0d1117;
35 --bg-elev: #161b22;
36 --bg-elev-2: #1f2630;
37 --border: #30363d;
38 --border-strong: #484f58;
39 --text: #e6edf3;
40 --text-dim: #8b949e;
41 --text-faint: #6e7681;
42 --accent: #2f81f7;
43 --accent-fg: #ffffff;
44 --ok: #3fb950;
45 --warn: #d29922;
46 --err: #f85149;
47 --shadow: 0 8px 24px rgba(0,0,0,0.5);
48 --radius: 10px;
49 --radius-sm: 6px;
50 --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
51 --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
52 }
53 html[data-theme="light"] {
54 --bg: #ffffff;
55 --bg-elev: #f6f8fa;
56 --bg-elev-2: #eaeef2;
57 --border: #d0d7de;
58 --border-strong: #afb8c1;
59 --text: #1f2328;
60 --text-dim: #59636e;
61 --text-faint: #818b98;
62 --accent: #0969da;
63 --accent-fg: #ffffff;
64 --ok: #1a7f37;
65 --warn: #9a6700;
66 --err: #cf222e;
67 --shadow: 0 8px 24px rgba(140,149,159,0.2);
68 }
69 * { box-sizing: border-box; }
70 html, body { height: 100%; margin: 0; overflow: hidden; }
71 body {
72 background: var(--bg);
73 color: var(--text);
74 font-family: var(--sans);
75 font-size: 14px;
76 line-height: 1.5;
77 -webkit-font-smoothing: antialiased;
78 }
79 a { color: var(--accent); }
80 code, kbd, pre { font-family: var(--mono); font-size: 12.5px; }
81 button {
82 font-family: inherit;
83 font-size: inherit;
84 background: var(--bg-elev-2);
85 color: var(--text);
86 border: 1px solid var(--border);
87 border-radius: var(--radius-sm);
88 padding: 7px 14px;
89 cursor: pointer;
90 transition: background 120ms, border-color 120ms, transform 80ms;
91 }
92 button:hover { background: var(--bg-elev); border-color: var(--border-strong); }
93 button:active { transform: translateY(1px); }
94 button:disabled { opacity: 0.5; cursor: not-allowed; }
95 /* Firefox adds an invisible inner border/padding to buttons via the
96 ::-moz-focus-inner pseudo-element that shrinks the usable content box
97 and makes our buttons render visibly smaller than in Chromium. */
98 button::-moz-focus-inner { border: 0; padding: 0; }
99 button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
100 button.primary:hover { filter: brightness(1.1); }
101 button.danger { background: var(--err); color: white; border-color: transparent; }
102 button.danger:hover { filter: brightness(1.1); }
103 button.ghost { background: transparent; }
104 button.icon { padding: 7px 10px; }
105 input[type="text"], input[type="number"], input[type="url"], select, textarea {
106 font-family: inherit;
107 font-size: inherit;
108 background: var(--bg);
109 color: var(--text);
110 border: 1px solid var(--border);
111 border-radius: var(--radius-sm);
112 padding: 7px 10px;
113 outline: none;
114 transition: border-color 120ms;
115 }
116 input:focus, select:focus, textarea:focus { border-color: var(--accent); }
117 textarea { display: block; width: 100%; font-family: var(--mono); font-size: 12.5px; resize: vertical; min-height: 110px; }
118 label.row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
119 label.row input[type="checkbox"] { accent-color: var(--accent); }
120 label.field { display: block; margin-bottom: 12px; }
121 label.field > span { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
122 .hidden { display: none !important; }
123
124 /* Inline icon: inherits the surrounding text color, doesn't shrink in flex
125 rows, never hijacks pointer events from its parent button. */
126 .ic { flex: none; display: inline-block; vertical-align: middle; pointer-events: none; }
127
128 /* Layout — body never scrolls; each view either scrolls internally
129 (welcome/configure/exchange) or is overflow:hidden (call). */
130 #app { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
131 .view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
132 .view.hidden { display: none; }
133 #view-welcome, #view-sdp-inspect { overflow-y: auto; }
134
135 /* SDP inspector */
136 .sdp-section { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; margin-bottom: 14px; }
137 .sdp-section > .sdp-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 8px; }
138 .sdp-section > .sdp-head h3 { margin: 0; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--text); }
139 .sdp-section > .sdp-head .sdp-sub { color: var(--text-dim); font-size: 12px; font-family: var(--mono); }
140 .sdp-kv { display: grid; grid-template-columns: 170px 1fr; gap: 4px 12px; font-size: 13px; }
141 .sdp-kv .k { color: var(--text-dim); }
142 .sdp-kv .v { font-family: var(--mono); word-break: break-all; }
143 .sdp-kv .v.code { font-family: var(--mono); }
144 .sdp-help { color: var(--text-dim); font-size: 12px; margin: 6px 0 0; font-style: italic; }
145 .sdp-list { list-style: none; padding: 0; margin: 6px 0 0; display: grid; gap: 4px; font-size: 12.5px; font-family: var(--mono); }
146 .sdp-list li { padding: 4px 8px; border-radius: var(--radius-sm); background: var(--bg-elev-2); }
147 .sdp-list li .tag { display: inline-block; min-width: 36px; padding: 0 6px; border-radius: 8px; background: var(--bg-elev); color: var(--text-dim); margin-right: 8px; text-align: center; }
148 .sdp-list li .badge { display: inline-block; padding: 0 6px; border-radius: 8px; font-size: 11px; margin-left: 6px; background: rgba(99,158,255,0.12); color: var(--accent); }
149 .sdp-list li .dim { color: var(--text-dim); }
150 details.sdp-rawblock { margin-top: 12px; }
151 details.sdp-rawblock > summary { font-size: 12px; color: var(--text-dim); cursor: pointer; user-select: none; }
152 details.sdp-rawblock pre { margin: 8px 0 0; padding: 10px; background: var(--bg-elev-2); border-radius: var(--radius-sm); font-size: 12px; white-space: pre-wrap; word-break: break-word; }
153 .topbar {
154 min-height: 48px; flex: none;
155 display: flex; align-items: center; justify-content: space-between;
156 gap: 8px;
157 padding: 6px 16px;
158 border-bottom: 1px solid var(--border);
159 background: var(--bg-elev);
160 }
161 .topbar .brand { font-weight: 600; letter-spacing: 0.2px; min-width: 0; }
162 .topbar .brand small { color: var(--text-faint); font-weight: 400; margin-left: 8px; }
163 .topbar .right { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
164 /* Theme toggle shows the current theme: moon in dark mode, sun in light. */
165 html[data-theme="light"] #theme-toggle .theme-icon-moon { display: none; }
166 html:not([data-theme="light"]) #theme-toggle .theme-icon-sun { display: none; }
167 @media (max-width: 720px) {
168 .topbar { padding: 6px 10px; }
169 }
170 .role-badge {
171 display: inline-block; padding: 2px 8px; border-radius: 12px;
172 background: var(--bg-elev-2); color: var(--text-dim);
173 font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
174 }
175 .role-badge.initiator { background: rgba(47,129,247,0.15); color: var(--accent); }
176 .role-badge.joiner { background: rgba(63,185,80,0.15); color: var(--ok); }
177 .role-badge.loopback { background: rgba(210,153,34,0.15); color: var(--warn); }
178
179 .container { max-width: 880px; width: 100%; margin: 0 auto; padding: 32px 24px; }
180 .container.wide { max-width: 1280px; }
181 h1 { font-size: 24px; margin: 0 0 8px; }
182 h2 { font-size: 16px; margin: 24px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
183 h3 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin: 16px 0 8px; }
184 .lede { color: var(--text-dim); margin-bottom: 24px; }
185 .card {
186 background: var(--bg-elev);
187 border: 1px solid var(--border);
188 border-radius: var(--radius);
189 padding: 18px;
190 margin-bottom: 16px;
191 }
192 .card h2:first-child { margin-top: 0; }
193 details { margin-bottom: 16px; }
194 details > summary { cursor: pointer; padding: 10px 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); user-select: none; }
195 details[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
196 details > .details-body { border: 1px solid var(--border); border-top: none; border-radius: 0 0 var(--radius-sm) var(--radius-sm); padding: 14px; background: var(--bg-elev); }
197
198 /* Collapsible card: <details class="card card-collapsible"> */
199 details.card-collapsible { padding: 0; margin-bottom: 16px; background: var(--bg-elev); }
200 details.card-collapsible > summary {
201 list-style: none;
202 cursor: pointer;
203 padding: 14px 18px;
204 user-select: none;
205 display: flex;
206 align-items: center;
207 gap: 10px;
208 background: transparent;
209 border: none;
210 border-radius: var(--radius);
211 }
212 details.card-collapsible > summary::-webkit-details-marker { display: none; }
213 details.card-collapsible > summary::before {
214 content: '';
215 width: 12px; height: 12px;
216 background: var(--text-dim);
217 -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='9 6 15 12 9 18'/></svg>") center/contain no-repeat;
218 mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='9 6 15 12 9 18'/></svg>") center/contain no-repeat;
219 flex: none;
220 transition: transform 120ms ease;
221 }
222 details.card-collapsible[open] > summary::before { transform: rotate(90deg); }
223 details.card-collapsible > summary h2 {
224 margin: 0;
225 padding: 0;
226 border-bottom: 0;
227 display: inline-block;
228 }
229 details.card-collapsible[open] > summary {
230 border-bottom: 1px solid var(--border);
231 border-radius: var(--radius) var(--radius) 0 0;
232 }
233 details.card-collapsible > .card-body { padding: 14px 18px 18px; }
234
235 /* Prominent room code "hero" callout. */
236 .room-code-hero {
237 background: rgba(47,129,247,0.08);
238 border: 1px solid rgba(47,129,247,0.35);
239 border-radius: var(--radius);
240 padding: 18px 20px;
241 margin: 8px 0 4px;
242 }
243 .room-code-hero .hero-label {
244 display: block;
245 font-size: 12px;
246 font-weight: 600;
247 color: var(--accent);
248 text-transform: uppercase;
249 letter-spacing: 0.6px;
250 margin-bottom: 10px;
251 }
252 .room-code-input-row { display: flex; gap: 8px; align-items: stretch; }
253 .room-code-input-row input {
254 flex: 1;
255 font-family: var(--mono);
256 font-size: 15px;
257 font-weight: 600;
258 padding: 9px 12px;
259 letter-spacing: 0.5px;
260 text-transform: lowercase;
261 background: var(--bg);
262 }
263 .room-code-input-row button {
264 padding: 0 14px;
265 font-size: 16px;
266 background: var(--bg);
267 }
268 .room-code-hero .hero-help { margin: 10px 0 0; font-size: 12px; color: var(--text-dim); }
269 .room-code-hero .hero-help code { background: rgba(47,129,247,0.12); padding: 1px 4px; border-radius: 3px; }
270
271 .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
272 .opus-list { display: flex; flex-direction: column; gap: 14px; }
273 .opus-item { display: flex; flex-direction: column; gap: 2px; }
274 .opus-item > .small { margin: 0 0 0 22px; color: var(--text-dim); }
275 .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
276 @media (max-width: 720px) {
277 .grid-2, .grid-3 { grid-template-columns: 1fr; }
278 }
279 .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
280 .role-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px; }
281 .role-picker button {
282 padding: 22px;
283 text-align: left;
284 border-radius: var(--radius);
285 border: 1px solid var(--border);
286 background: var(--bg-elev);
287 display: flex; flex-direction: column; gap: 6px;
288 transition: border-color 120ms, transform 120ms;
289 }
290 .role-picker button:hover { border-color: var(--accent); transform: translateY(-1px); }
291 .role-picker .role-title { font-size: 16px; font-weight: 600; }
292 .role-picker .role-desc { color: var(--text-dim); font-size: 13px; }
293 .role-picker .role-loopback { grid-column: 1 / -1; }
294
295 /* Two-option segmented toggle (signaling mode). */
296 .seg-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; margin: 4px 0 10px; }
297 .seg-toggle button {
298 border: none; border-radius: 0; background: transparent; color: var(--text-dim);
299 padding: 8px 14px; font-weight: 500;
300 }
301 .seg-toggle button + button { border-left: 1px solid var(--border); }
302 .seg-toggle button.active { background: rgba(47,129,247,0.15); color: var(--accent); }
303 .seg-toggle button:hover:not(.active) { background: var(--bg-elev-2); }
304
305 .ice-row { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 8px; margin-bottom: 6px; }
306 .ice-row input { width: 100%; }
307 @media (max-width: 720px) { .ice-row { grid-template-columns: 1fr; } }
308
309 .blob-area { display: flex; flex-direction: column; gap: 8px; }
310 .blob-area textarea { min-height: 180px; }
311 .blob-controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
312 .pill { padding: 2px 8px; border-radius: 12px; font-size: 11px; background: var(--bg-elev-2); color: var(--text-dim); }
313 .pill:empty { display: none; }
314 .pill.ok { background: rgba(63,185,80,0.15); color: var(--ok); }
315 .pill.warn { background: rgba(210,153,34,0.15); color: var(--warn); }
316 .pill.err { background: rgba(248,81,73,0.15); color: var(--err); }
317
318 .progress { height: 6px; background: var(--bg-elev-2); border-radius: 3px; overflow: hidden; margin: 8px 0; }
319 .progress > div { height: 100%; background: var(--accent); width: 0%; transition: width 200ms ease; }
320
321 /* Call view — flex:1 inside #app fills viewport minus the topbar; nothing
322 scrolls except internal panes (chat log, files list, settings, stats). */
323 #view-call { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
324 .call-body {
325 flex: 1; min-height: 0; min-width: 0;
326 display: grid;
327 grid-template-columns: 1fr 360px;
328 grid-template-rows: minmax(0, 1fr);
329 overflow: hidden;
330 }
331 @media (max-width: 900px) {
332 .call-body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
333 }
334 /* Conference grid: a vertical stack of flex rows. App.tiles.relayout picks
335 the rows × cols that maximises tile area for the current container and
336 tile count, then partitions tiles into row containers and sets row
337 height + tile width inline. Each row centres its tiles, so an incomplete
338 last row (e.g. 3 tiles in a 2-col layout) is centred instead of
339 left-aligned. All tiles always visible; no scroll. */
340 .video-area {
341 background: #000;
342 position: relative;
343 display: flex;
344 flex-direction: column;
345 justify-content: center;
346 align-items: center;
347 gap: 8px;
348 padding: 12px;
349 min-height: 0;
350 min-width: 0;
351 overflow: hidden;
352 }
353 .video-row {
354 display: flex;
355 flex-direction: row;
356 justify-content: center;
357 gap: 8px;
358 /* height set inline by relayout */
359 }
360 .video-tile {
361 position: relative;
362 background: #050608;
363 border-radius: var(--radius);
364 overflow: hidden;
365 display: flex; align-items: center; justify-content: center;
366 flex: none;
367 min-width: 0; min-height: 0;
368 height: 100%;
369 /* width set inline by relayout */
370 }
371 .video-tile > video { width: 100%; height: 100%; object-fit: contain; background: #050608; display: block; }
372 .video-tile > .vid-pip { display: none; } /* PIP video is inside .pip wrapper, not direct child */
373 /* Per-peer audio element sits invisibly inside the tile so its lifetime
374 matches the peer's. Hidden but autoplay still works. */
375 .video-tile > audio.audio-remote { display: none; }
376 .video-tile .leave-peer {
377 position: absolute; top: 6px; right: 6px;
378 background: rgba(0,0,0,0.55); color: white;
379 border: none; border-radius: 4px;
380 width: 22px; height: 22px; padding: 0;
381 cursor: pointer; opacity: 0; transition: opacity 120ms;
382 display: flex; align-items: center; justify-content: center;
383 z-index: 3;
384 }
385 .video-tile:hover .leave-peer { opacity: 1; }
386 .video-tile .leave-peer:hover { background: var(--err); }
387 .video-tile .conn-pill {
388 position: absolute; top: 6px; left: 6px;
389 background: rgba(0,0,0,0.55); color: white;
390 padding: 2px 8px; border-radius: 10px;
391 font-size: 10px; letter-spacing: 0.3px;
392 pointer-events: none;
393 }
394 .video-tile .conn-pill.connected { background: rgba(63,185,80,0.55); }
395 .video-tile .conn-pill.connecting,
396 .video-tile .conn-pill.new { background: rgba(210,153,34,0.55); }
397 .video-tile .conn-pill.failed,
398 .video-tile .conn-pill.disconnected,
399 .video-tile .conn-pill.closed { background: rgba(248,81,73,0.55); }
400 .video-tile .conn-pill.connected { display: none; } /* hide once steady */
401
402 /* In-call banner shown when there are no peers yet. Sits between the
403 topbar and call-body, full-width, dismisses permanently on first join. */
404 .call-banner {
405 flex: none;
406 display: flex; align-items: center; justify-content: center; gap: 12px;
407 padding: 10px 16px;
408 background: rgba(47,129,247,0.10);
409 border-bottom: 1px solid rgba(47,129,247,0.30);
410 color: var(--text);
411 font-size: 13px;
412 }
413 .call-banner.hidden { display: none; }
414 .call-banner button { padding: 6px 12px; }
415
416 /* Add Participant dialog reuses the role-picker styles but in compact
417 form (smaller padding, single column). */
418 .role-picker.compact { grid-template-columns: 1fr; gap: 8px; margin-top: 8px; }
419 .role-picker.compact button { padding: 12px 14px; }
420 .role-picker.compact .role-title { font-size: 14px; }
421 .role-picker.compact .role-desc { font-size: 12px; }
422 .video-tile.screen > video { cursor: zoom-in; }
423 .video-tile.screen > video:fullscreen { cursor: zoom-out; }
424 .video-tile .tile-label {
425 position: absolute; left: 8px; bottom: 8px;
426 background: rgba(0,0,0,0.55); color: white;
427 padding: 3px 8px; border-radius: 12px; font-size: 11px;
428 letter-spacing: 0.4px;
429 pointer-events: none;
430 }
431 .video-tile .empty-state {
432 position: absolute; inset: 0;
433 display: none; align-items: center; justify-content: center;
434 color: var(--text-faint);
435 pointer-events: none;
436 }
437 .video-tile.empty .empty-state { display: flex; }
438 .video-tile.empty > video { display: none; }
439 .video-tile .mic-muted {
440 position: absolute;
441 bottom: 8px; right: 8px;
442 background: rgba(0,0,0,0.55);
443 color: white;
444 border-radius: 50%;
445 width: 28px; height: 28px;
446 display: flex; align-items: center; justify-content: center;
447 pointer-events: none;
448 z-index: 2;
449 }
450 /* When a PIP is visible in the bottom-right corner, move the mic-muted
451 badge to the top-right so they don't overlap. */
452 .video-tile:has(.pip:not(.hidden)) .mic-muted { bottom: auto; top: 8px; }
453 .video-tile .mic-muted.hidden { display: none; }
454 .video-tile .pip {
455 position: absolute;
456 right: 10px; bottom: 10px;
457 width: 22%; max-width: 200px; min-width: 120px;
458 aspect-ratio: 16 / 9;
459 border-radius: 6px;
460 overflow: hidden;
461 background: #050608;
462 border: 1px solid rgba(255,255,255,0.15);
463 box-shadow: 0 4px 12px rgba(0,0,0,0.5);
464 z-index: 1;
465 }
466 .video-tile .pip.hidden { display: none; }
467 .video-tile .pip video { width: 100%; height: 100%; object-fit: cover; background: #050608; display: block; }
468 @media (max-width: 900px) {
469 .video-area { /* layout still computed in JS */ }
470 }
471
472 .sidebar { background: var(--bg-elev); border-left: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
473 .tabs { display: flex; border-bottom: 1px solid var(--border); }
474 .tabs button {
475 flex: 1;
476 border: none;
477 background: transparent;
478 border-radius: 0;
479 border-bottom: 2px solid transparent;
480 padding: 12px 10px;
481 color: var(--text-dim);
482 }
483 .tabs button.active { color: var(--text); border-bottom-color: var(--accent); }
484 .tab-pane { flex: 1; overflow-y: auto; padding: 14px; min-height: 0; display: none; }
485 .tab-pane.active { display: flex; flex-direction: column; }
486
487 .toolbar {
488 flex: none;
489 display: flex; gap: 8px; justify-content: center; align-items: center;
490 flex-wrap: wrap;
491 padding: 10px 16px;
492 background: var(--bg-elev);
493 border-top: 1px solid var(--border);
494 }
495 .toolbar button { padding: 0 16px; height: 40px; min-width: 44px; display: flex; align-items: center; justify-content: center; gap: 6px; line-height: 1; }
496 .toolbar button > * { line-height: 1; }
497 .toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 140px; }
498 .toolbar .spacer { flex: 1; }
499 .toolbar button.on { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
500 .toolbar button.off { background: rgba(248,81,73,0.12); border-color: var(--err); color: var(--err); }
501
502 /* Device picker: chevron sits flush next to its primary toolbar button. */
503 .toolbar .device-picker { position: relative; display: flex; gap: 2px; }
504 .toolbar .device-chevron { min-width: 28px; padding: 0 6px; }
505 .toolbar .device-chevron::before {
506 content: '';
507 width: 12px; height: 12px;
508 background: currentColor;
509 -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='18 15 12 9 6 15'/></svg>") center/contain no-repeat;
510 mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='18 15 12 9 6 15'/></svg>") center/contain no-repeat;
511 transition: transform 120ms ease;
512 }
513 .toolbar .device-chevron[aria-expanded="true"]::before { transform: rotate(180deg); }
514 .toolbar .device-chevron[aria-expanded="true"] { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
515 .device-menu {
516 position: absolute; bottom: calc(100% + 6px); left: 0;
517 min-width: 240px; max-width: min(360px, calc(100vw - 24px));
518 max-height: 50vh; overflow: auto;
519 background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
520 padding: 6px; z-index: 50;
521 box-shadow: 0 8px 24px rgba(0,0,0,0.4);
522 display: flex; flex-direction: column; gap: 2px;
523 }
524 .device-menu.hidden { display: none; }
525 .device-menu .device-row {
526 display: flex; align-items: center; gap: 8px;
527 padding: 6px 8px; border-radius: 4px; cursor: pointer;
528 font-size: 13px; line-height: 1.3;
529 }
530 .device-menu .device-row:hover { background: rgba(255,255,255,0.05); }
531 .device-menu .device-row input { margin: 0; flex: none; }
532 .device-menu .device-row .device-label { flex: 1; word-break: break-word; }
533 .device-menu .device-row .device-active {
534 color: var(--accent); font-size: 11px; flex: none;
535 display: inline-flex; align-items: center; gap: 4px;
536 }
537 .device-menu .device-empty { padding: 6px 8px; font-size: 12px; font-style: italic; color: var(--muted, #888); }
538 @media (max-width: 720px) {
539 /* Drop the spacer so the row collapses to its content, shrink button
540 padding/widths so all five buttons fit on one line at 500 px wide. */
541 .toolbar { padding: 8px 10px; gap: 6px; }
542 .toolbar .spacer { display: none; }
543 .toolbar button { padding: 0 10px; }
544 .toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 0; }
545 .toolbar .device-chevron { min-width: 24px; padding: 0 4px; }
546 }
547
548 /* Chat */
549 .chat-log { flex: 1; overflow: auto; padding-right: 4px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
550 .chat-msg { padding: 8px 10px; border-radius: var(--radius-sm); background: var(--bg-elev-2); max-width: 80%; word-wrap: break-word; }
551 .chat-msg.me { align-self: flex-end; background: rgba(47,129,247,0.15); }
552 .chat-msg .meta { font-size: 10px; color: var(--text-faint); margin-top: 3px; }
553 .chat-input { display: flex; gap: 6px; margin-top: 8px; }
554 .chat-input input { flex: 1; }
555 .chat-counter { color: var(--text-faint); margin-top: 4px; text-align: right; font-variant-numeric: tabular-nums; }
556 .chat-counter.over { color: var(--err); }
557
558 /* Files */
559 .files-drop {
560 border: 2px dashed var(--border-strong);
561 border-radius: var(--radius);
562 padding: 18px;
563 text-align: center;
564 color: var(--text-dim);
565 margin-bottom: 12px;
566 transition: border-color 120ms, background 120ms;
567 }
568 .files-drop.over { border-color: var(--accent); background: rgba(47,129,247,0.06); }
569 .files-drop.disabled { opacity: 0.5; pointer-events: none; }
570 .files-section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; }
571 .files-section-head h3 { margin: 0; }
572 .file-item { position: relative; padding: 8px 28px 8px 10px; background: var(--bg-elev-2); border-radius: var(--radius-sm); margin-bottom: 6px; font-size: 12px; }
573 .file-item .name { font-weight: 500; }
574 .file-item .meta { color: var(--text-dim); font-size: 11px; }
575 .file-item .row-close {
576 position: absolute; top: 4px; right: 4px;
577 background: transparent; border: none; color: var(--text-faint);
578 width: 22px; height: 22px; padding: 0; border-radius: 4px;
579 cursor: pointer; font-size: 14px; line-height: 1;
580 }
581 .file-item .row-close:hover { background: var(--bg-elev); color: var(--text); }
582 .file-item .req-actions { display: flex; gap: 6px; margin-top: 6px; }
583 .file-item .req-actions button {
584 border: 1px solid var(--border); border-radius: 4px; padding: 3px 12px;
585 font-size: 12px; cursor: pointer;
586 }
587 .file-item .req-actions .btn-accept { background: var(--accent); color: #fff; border-color: var(--accent); }
588 .file-item .req-actions .btn-deny { background: transparent; color: var(--text-dim); }
589 .file-item .req-actions button:hover { filter: brightness(1.1); }
590
591 /* Stats */
592 .stats-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
593 .stats-table td { padding: 4px 6px; border-bottom: 1px solid var(--border); }
594 .stats-table td:first-child { color: var(--text-dim); width: 45%; }
595 .stats-head { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; }
596 .stats-head h3 { margin: 0; flex: none; font-size: 14px; }
597 .stats-head select { flex: 1; min-width: 0; }
598
599 /* Recipient chip bar (chat + files) */
600 .recipients {
601 display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
602 margin: 6px 0 4px; padding: 4px 0;
603 font-size: 11.5px; color: var(--text-dim);
604 }
605 .recipients .label { color: var(--text-faint); margin-right: 4px; }
606 .recipients .chip {
607 display: inline-flex; align-items: center;
608 padding: 2px 8px; border-radius: 999px;
609 background: var(--bg-elev-2); border: 1px solid var(--border);
610 color: var(--text-dim);
611 cursor: pointer; user-select: none;
612 transition: background 80ms, border-color 80ms, color 80ms;
613 font-size: 11px;
614 }
615 .recipients .chip.on { background: rgba(47,129,247,0.18); border-color: var(--accent); color: var(--text); }
616 .recipients .chip:hover { background: var(--bg-elev); }
617 .recipients .chip.on:hover { filter: brightness(1.15); }
618 .recipients .empty { color: var(--text-faint); font-style: italic; }
619
620 /* Delivery popup (chat message click) */
621 .chat-msg.has-detail { cursor: pointer; }
622 .chat-msg .detail {
623 display: none;
624 margin-top: 6px; padding-top: 6px;
625 border-top: 1px solid var(--border);
626 font-size: 10.5px; color: var(--text-dim);
627 }
628 .chat-msg .detail .row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
629 .chat-msg .detail .state { font-variant-numeric: tabular-nums; }
630 .chat-msg .detail .state.acked { color: var(--ok); }
631 .chat-msg .detail .state.sent { color: var(--warn); }
632 .chat-msg .detail .state.failed,
633 .chat-msg .detail .state.closed { color: var(--err); }
634 .chat-msg.open .detail { display: block; }
635
636 /* Files row click-detail (per-recipient progress) */
637 .file-item.has-detail { cursor: pointer; }
638 .file-item .detail {
639 display: none;
640 margin-top: 6px; padding-top: 6px;
641 border-top: 1px solid var(--border);
642 font-size: 10.5px;
643 }
644 .file-item .detail .row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
645 .file-item .detail .state { font-variant-numeric: tabular-nums; }
646 .file-item .detail .state.delivered { color: var(--ok); }
647 .file-item .detail .state.sent { color: var(--ok); }
648 .file-item .detail .state.sending { color: var(--warn); }
649 .file-item .detail .state.queued { color: var(--text-dim); }
650 .file-item .detail .state.failed,
651 .file-item .detail .state.closed,
652 .file-item .detail .state.cancelled { color: var(--err); }
653 .file-item.open .detail { display: block; }
654
655 /* Welcome: freestanding Start block (no card chrome). The input and the
656 Start button sit side-by-side and stretch to a matching height. */
657 .welcome-start { margin: 24px 0 32px; max-width: 520px; }
658 .welcome-start label.field { margin-bottom: 8px; }
659 .welcome-start-row {
660 display: flex;
661 gap: 8px;
662 align-items: stretch;
663 }
664 .welcome-start-row > input {
665 flex: 1;
666 min-width: 0;
667 font-size: 16px;
668 padding: 10px 14px;
669 }
670 .welcome-start-btn {
671 flex: none;
672 font-size: 16px;
673 padding: 10px 22px;
674 line-height: 1.4;
675 }
676
677 /* Settings: per-peer target picker */
678 .settings-target {
679 background: var(--bg-elev-2);
680 border: 1px solid var(--border);
681 border-radius: var(--radius-sm);
682 padding: 8px 10px;
683 margin: 24px 0 14px;
684 }
685 .settings-target label.field { margin-bottom: 0; }
686 .settings-target .small { margin-top: 4px; }
687
688 /* Modal dialog (native <dialog>) — used for peer-left notification. */
689 dialog {
690 background: var(--bg-elev);
691 color: var(--text);
692 border: 1px solid var(--border-strong);
693 border-radius: var(--radius);
694 padding: 22px 24px;
695 max-width: 420px;
696 box-shadow: 0 12px 40px rgba(0,0,0,0.5);
697 }
698 dialog::backdrop { background: rgba(0,0,0,0.55); }
699 dialog h2 { margin: 0 0 8px; padding: 0; border: none; font-size: 17px; }
700 dialog p { margin: 0 0 16px; color: var(--text-dim); }
701 .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
702 /* Wide, scrollable variant of <dialog> for the configure step. */
703 dialog.view-modal {
704 width: min(720px, calc(100vw - 32px));
705 max-width: none;
706 max-height: calc(100vh - 32px);
707 overflow-y: auto;
708 padding: 24px 26px;
709 }
710 dialog.view-modal h1 { margin: 0 0 8px; padding: 0; border: none; font-size: 22px; }
711 dialog.view-modal .container { padding: 0; max-width: none; }
712 .step-dialog-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
713 .step-dialog-head h2 { margin: 0; }
714 .step-dialog-room {
715 display: flex; align-items: center; gap: 10px;
716 margin: 0 0 16px;
717 padding: 10px 12px;
718 background: var(--bg-elev-2);
719 border: 1px solid var(--border);
720 border-radius: var(--radius-sm);
721 }
722 .step-dialog-room.hidden { display: none; }
723 .step-dialog-room-label { color: var(--text-dim); font-size: 12px; }
724 .step-dialog-room code {
725 font-family: var(--mono); font-size: 14px;
726 color: var(--text); user-select: all;
727 }
728
729 /* Console drawer — sits below the toolbar inside #view-call so opening it
730 shrinks the call area instead of overlapping it. */
731 #console-drawer {
732 flex: none;
733 height: 35vh; min-height: 200px; max-height: 50vh;
734 background: var(--bg-elev); border-top: 1px solid var(--border-strong);
735 display: flex; flex-direction: column;
736 min-width: 0;
737 }
738 #console-drawer.hidden { display: none; }
739 .console-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
740 .console-head .spacer { flex: 1; }
741 .console-head select { padding: 4px 8px; font-size: 12px; }
742 .console-head label.small { display: inline-flex; align-items: center; gap: 4px; }
743 .console-head #console-filter { width: 120px; min-width: 80px; }
744 @media (max-width: 720px) {
745 .console-head { padding: 6px 10px; gap: 6px; }
746 /* Hide the "Level" / "Filter" inline label text — the dropdown and the
747 placeholder make the controls self-evident. */
748 .console-head label.small { font-size: 0; }
749 .console-head label.small > * { font-size: 12px; }
750 .console-head #console-filter { width: 100px; }
751 }
752 .console-body { flex: 1; overflow: auto; padding: 6px 12px; font-family: var(--mono); font-size: 11px; }
753 .log-line { padding: 2px 0; white-space: pre-wrap; word-break: break-word; }
754 .log-line .ts { color: var(--text-faint); margin-right: 6px; }
755 .log-line .lvl { display: inline-block; min-width: 34px; padding: 0 5px; border-radius: 3px; margin-right: 6px; font-size: 9px; text-align: center; }
756 .log-line.debug .lvl { background: var(--bg-elev-2); color: var(--text-dim); }
757 .log-line.info .lvl { background: rgba(47,129,247,0.15); color: var(--accent); }
758 .log-line.warn .lvl { background: rgba(210,153,34,0.15); color: var(--warn); }
759 .log-line.error .lvl { background: rgba(248,81,73,0.15); color: var(--err); }
760 .log-line .label { color: var(--text-dim); margin-right: 6px; }
761
762 .kbd { font-family: var(--mono); padding: 1px 5px; border-radius: 4px; background: var(--bg-elev-2); border: 1px solid var(--border); font-size: 11px; }
763 .small { font-size: 12px; color: var(--text-dim); }
764 .nowrap { white-space: nowrap; }
765 .mono { font-family: var(--mono); }
766
767 /* Inline progress (Continue to signaling, Apply offer) */
768 .progress-row {
769 display: flex; align-items: center; gap: 10px;
770 margin-top: 12px;
771 padding: 10px 12px;
772 background: var(--bg-elev);
773 border: 1px solid var(--border);
774 border-radius: var(--radius-sm);
775 color: var(--text);
776 font-size: 13px;
777 }
778 .progress-row.hidden { display: none; }
779 .spinner {
780 width: 14px; height: 14px; flex: none;
781 border: 2px solid var(--border-strong);
782 border-top-color: var(--accent);
783 border-radius: 50%;
784 animation: spin 0.8s linear infinite;
785 }
786 @keyframes spin { to { transform: rotate(360deg); } }
787 .progress-row .sub { color: var(--text-dim); font-size: 12px; }
788
789 /* Insecure-context warning */
790 .warn-banner {
791 margin-bottom: 16px;
792 padding: 12px 14px;
793 background: rgba(210,153,34,0.1);
794 border: 1px solid rgba(210,153,34,0.4);
795 border-radius: var(--radius-sm);
796 color: var(--warn);
797 font-size: 13px;
798 }
799 .warn-banner.hidden { display: none; }
800 .warn-banner strong { color: var(--warn); }
801
802 ::-webkit-scrollbar { width: 10px; height: 10px; }
803 ::-webkit-scrollbar-track { background: transparent; }
804 ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
805 ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
806</style>
807</head>
808<body>
809
810<main id="app">
811 <header class="topbar">
812 <div class="brand">WebRTC Tool <small>direct peer-to-peer audio, video, chat, and files</small></div>
813 <div class="right">
814 <span id="role-badge" class="role-badge hidden"></span>
815 <span id="conn-pill" class="pill">disconnected</span>
816 <button class="icon" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
817 <svg class="theme-icon-moon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
818 <svg class="theme-icon-sun" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
819 </button>
820 </div>
821 </header>
822
823 <!-- ============== WELCOME ============== -->
824 <section id="view-welcome" class="view">
825 <div class="container">
826 <h1>WebRTC Tool</h1>
827 <p class="lede">
828 A direct peer-to-peer call tool — a fallback for video, screen share, chat, and file transfer
829 when your usual conferencing software isn't cooperating. Connect to one or more participants
830 either via a shared room code (using a small relay server that only shuttles the offer/answer)
831 or by pasting two short JSON blobs. Browser-only on each end: no install, no account.
832 </p>
833
834 <div class="welcome-start">
835 <label class="field welcome-start-field">
836 <span>Your display name</span>
837 <div class="welcome-start-row">
838 <input type="text" id="welcome-username" placeholder="Anonymous" maxlength="64" autocomplete="off" spellcheck="false">
839 <button id="welcome-start" class="primary welcome-start-btn">Start call →</button>
840 </div>
841 </label>
842 <p class="small">Shown to other participants in chat and on their video tile. Saved locally; can be changed later from the call's Settings tab.</p>
843 <p class="small" style="margin-top:6px">Opens an empty call. Invite participants from inside the call (room code or SDP paste).</p>
844 </div>
845
846 <div class="card">
847 <h2>Tools</h2>
848 <div class="actions">
849 <button id="welcome-sdp-inspect" class="ghost">SDP inspector →</button>
850 </div>
851 <p class="small">Paste an offer/answer (raw SDP, JSON, or <code>b64:</code>-wrapped) and see it broken down into sections, codecs, ICE candidates, and DTLS info — with short explanations.</p>
852 </div>
853 </div>
854 </section>
855
856 <!-- ============== CONFIGURE ============== -->
857 <dialog id="view-configure" class="view-modal">
858 <div class="container">
859 <h1>Configure <span id="role-title-cfg" class="role-badge"></span></h1>
860 <p class="lede" id="cfg-lede">Set up media and ICE servers, then continue to the signaling step.</p>
861
862 <div id="insecure-warn" class="warn-banner hidden">
863 <strong>Insecure context:</strong>
864 <span id="insecure-warn-text">this page isn't served over HTTPS (or localhost), so the browser will not expose microphone, camera, or screen-share APIs. You can still join the call in listen-only mode and receive the other peer's audio/video, chat, and files.</span>
865 </div>
866
867 <div class="card" id="signaling-card">
868 <h2>Signaling</h2>
869 <div class="seg-toggle" role="tablist" aria-label="Signaling mode">
870 <button type="button" id="sig-mode-auto" role="tab" class="active">Auto via server (room code)</button>
871 <button type="button" id="sig-mode-manual" role="tab">Manual SDP exchange</button>
872 </div>
873 <p class="small hidden" id="sig-help-manual">
874 You and your peer copy two JSON blobs (offer and answer) between yourselves through any
875 channel — email, chat, paper. Nothing leaves the browser except the call itself. Use this when
876 you don't want to (or can't) run a server, or when you want to inspect the SDP.
877 </p>
878 <p class="small" id="sig-help-auto">
879 Both peers enter the same short <em>room code</em> on a small relay server that just shuttles
880 the offer/answer pair. The server never sees media or chat — those still flow peer-to-peer.
881 Whoever opens the room first becomes the initiator; the second peer joins. The relay holds
882 each blob for 10 minutes and forgets it after.
883 </p>
884
885 <div id="sig-auto-fields" class="hidden">
886 <div class="room-code-hero">
887 <label class="hero-label" for="sig-room-code">Enter a room code to connect</label>
888 <div class="room-code-input-row">
889 <input type="text" id="sig-room-code" placeholder="e.g. blue-fish-42" spellcheck="false" autocomplete="off" maxlength="15">
890 <button type="button" id="sig-room-gen" class="ghost" title="Generate a random code"><svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg></button>
891 </div>
892 <p class="hero-help">Any short alphanumeric string (max 15 chars, letters/digits/<code>-_</code>). Share it with your peer through any channel.</p>
893 </div>
894
895 <label class="field" style="max-width:420px; margin-top:14px">
896 <span>Server URL</span>
897 <div class="row" style="gap:6px; align-items:stretch">
898 <input type="url" id="sig-server-url" placeholder="https://example.com:8080" spellcheck="false" autocomplete="off" style="flex:1">
899 <button type="button" id="sig-check" class="ghost" title="Probe the server's /health endpoint">Check</button>
900 </div>
901 <span id="sig-check-status" class="pill hidden" style="margin-top:6px; align-self:flex-start"></span>
902 </label>
903 <p class="small">Base URL of the signaling server. The page will POST/GET to <code>&lt;url&gt;/room/&lt;code&gt;/offer</code> and <code>/answer</code>.</p>
904 </div>
905
906 <details style="margin-top:14px">
907 <summary>Advanced</summary>
908 <div class="details-body">
909 <label class="field" style="max-width:260px">
910 <span>ICE gathering timeout (seconds)</span>
911 <input type="number" id="sig-ice-timeout" min="0" step="1" placeholder="8">
912 </label>
913 <p class="small">
914 Hard cap on how long the page waits for ICE candidate gathering before exporting the SDP.
915 Use <code>0</code> to wait indefinitely — useful when you want every candidate (e.g. slow TURN
916 relays) but expect to abort manually if gathering stalls. Default is 8 seconds.
917 </p>
918 </div>
919 </details>
920 </div>
921
922 <details class="card card-collapsible">
923 <summary><h2>ICE / TURN servers</h2></summary>
924 <div class="card-body">
925 <p class="small">
926 Leave the STUN default for typical LAN/internet use. Add TURN servers for cross-NAT peers.
927 Empty list means fully-local (host candidates only) — works on the same LAN or same machine.
928 </p>
929 <div id="ice-rows"></div>
930 <div class="actions">
931 <button id="ice-add" class="ghost">+ Add server</button>
932 <button id="ice-clear" class="ghost">Clear all</button>
933 <button id="ice-reset" class="ghost">Reset to default</button>
934 <button id="ice-toggle-json" class="ghost">Edit as JSON…</button>
935 </div>
936 <div id="ice-json-wrap" class="hidden" style="margin-top:10px">
937 <label class="field">
938 <span>RTCIceServer[] JSON</span>
939 <textarea id="ice-json" spellcheck="false"></textarea>
940 </label>
941 <div class="actions">
942 <button id="ice-json-apply" class="primary">Apply JSON</button>
943 </div>
944 </div>
945
946 <h3 style="margin-top:18px">LAN connectivity</h3>
947 <p class="small">
948 For privacy, browsers (especially Firefox) restrict WebRTC ICE candidates to the default network
949 interface while no microphone or camera stream is active on the page. If both peers are on the
950 same network and you don't have a TURN server, enabling this opens a muted microphone stream
951 (no audio is captured or sent) so the ICE agent can see all your local interfaces and direct
952 LAN candidates can be exchanged. The OS will indicate the microphone is in use; stop it from
953 here, the toolbar, or by hanging up.
954 </p>
955 <div class="actions">
956 <button id="ice-warmup" class="ghost">Enable LAN connectivity</button>
957 <span id="ice-warmup-status" class="pill">off</span>
958 </div>
959 </div>
960 </details>
961
962 <details class="card card-collapsible">
963 <summary><h2>Media</h2></summary>
964 <div class="card-body">
965 <p class="small">Munged into the offer/answer SDP at signaling time. These can <strong>only</strong> be set here — they are locked once the call is up and cannot be changed mid-call.</p>
966
967 <label class="field" style="max-width:260px">
968 <span>Receive codec preference</span>
969 <select id="preferred-codec">
970 <option value="auto">auto (browser default)</option>
971 </select>
972 </label>
973 <p class="small">Reorders this side's SDP to ask the peer to encode with this codec when sending to you. Influences what the peer sends, not what you send.</p>
974
975 <label class="field" style="max-width:260px">
976 <span>Send codec</span>
977 <select id="send-codec">
978 <option value="auto">auto (browser default)</option>
979 </select>
980 </label>
981 <p class="small">Pins the encoder used when sending video (both camera and screen share). Picks from the codecs negotiated with the peer; falls back to the browser default if the codec isn't available. Can also be changed mid-call from the runtime panel.</p>
982 <p class="small">If video freezes after a few seconds, try a different send codec — some browser/device combinations have buggy encoders (notably VP8 on Firefox Android).</p>
983
984 <details>
985 <summary>Advanced Opus settings (SDP)</summary>
986 <div class="details-body">
987 <div class="opus-list">
988 <div class="opus-item">
989 <label class="row"><input type="checkbox" id="o-stereo"> stereo / sprop-stereo</label>
990 <p class="small">Negotiate a 2-channel Opus stream. Only useful if the capture device is actually stereo — otherwise the second channel just duplicates the first and wastes bitrate.</p>
991 </div>
992 <div class="opus-item">
993 <label class="row"><input type="checkbox" id="o-fec" checked> useinbandfec</label>
994 <p class="small">Forward error correction. The encoder embeds a low-bitrate copy of each frame inside the next one so the decoder can reconstruct single-packet losses without a retransmit. Costs a few percent bitrate.</p>
995 </div>
996 <div class="opus-item">
997 <label class="row"><input type="checkbox" id="o-dtx" checked> usedtx</label>
998 <p class="small">Discontinuous transmission: the encoder stops sending packets during silence and the decoder fills in comfort noise. Saves bandwidth on quiet channels; can cut off very soft speech.</p>
999 </div>
1000 <div class="opus-item">
1001 <label class="row"><input type="checkbox" id="o-cbr"> cbr (constant bitrate)</label>
1002 <p class="small">Force a constant bitrate instead of letting Opus vary it with content complexity. Mostly useful when something downstream expects a steady rate; usually leave off.</p>
1003 </div>
1004 <div class="opus-item">
1005 <label class="field">
1006 <span>maxaveragebitrate (bits/s; 0 = unset)</span>
1007 <input type="number" id="o-maxbr" placeholder="0" min="0" max="510000" step="1000">
1008 </label>
1009 <p class="small">Upper bound the encoder targets on average. Set lower to save bandwidth on weak links; leave at 0 to let Opus pick (typically 32–64 kbps for speech, higher for music).</p>
1010 </div>
1011 </div>
1012 </div>
1013 </details>
1014
1015 </div>
1016 </details>
1017
1018 <div class="actions">
1019 <button id="cfg-back" class="ghost">← Back</button>
1020 <button id="cfg-continue" class="primary">Continue to signaling →</button>
1021 </div>
1022
1023 <div id="cfg-progress" class="progress-row hidden" role="status" aria-live="polite">
1024 <span class="spinner"></span>
1025 <span>
1026 <span id="cfg-progress-label">Working…</span>
1027 <span id="cfg-progress-sub" class="sub"></span>
1028 </span>
1029 </div>
1030 </div>
1031 </dialog>
1032
1033 <!-- ============== EXCHANGE ============== -->
1034 <dialog id="view-exchange" class="view-modal">
1035 <div class="container">
1036 <h1>Signaling exchange <span id="role-title-exch" class="role-badge"></span></h1>
1037 <p class="lede" id="exch-lede"></p>
1038
1039 <div class="card">
1040 <h2 id="step-1-h">Step 1</h2>
1041 <div id="step-1-body" class="blob-area">
1042 <!-- filled by JS -->
1043 </div>
1044 </div>
1045
1046 <div class="card" id="step-2-card">
1047 <h2 id="step-2-h">Step 2</h2>
1048 <div id="step-2-body" class="blob-area">
1049 <!-- filled by JS -->
1050 </div>
1051 </div>
1052
1053 <div class="actions">
1054 <button id="exch-cancel" class="ghost">Cancel</button>
1055 </div>
1056
1057 <div id="exch-progress" class="progress-row hidden" role="status" aria-live="polite">
1058 <span class="spinner"></span>
1059 <span>
1060 <span id="exch-progress-label">Working…</span>
1061 <span id="exch-progress-sub" class="sub"></span>
1062 </span>
1063 </div>
1064 </div>
1065 </dialog>
1066
1067 <!-- ============== SDP INSPECTOR ============== -->
1068 <section id="view-sdp-inspect" class="view hidden">
1069 <div class="container">
1070 <h1>SDP inspector</h1>
1071 <p class="lede">Paste an offer or answer below — raw SDP, the JSON blob this tool emits, or its base64-wrapped form. Nothing leaves the page; parsing happens locally.</p>
1072
1073 <div class="card">
1074 <h2>Input</h2>
1075 <textarea id="sdp-in" spellcheck="false" placeholder="Paste SDP / JSON / b64:… here"></textarea>
1076 <div class="blob-controls">
1077 <button id="sdp-inspect-go" class="primary">Inspect</button>
1078 <button id="sdp-inspect-clear" class="ghost">Clear</button>
1079 <button id="sdp-inspect-upload" class="ghost">Upload…</button>
1080 <input id="sdp-inspect-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
1081 <span class="pill" id="sdp-inspect-status"></span>
1082 </div>
1083 </div>
1084
1085 <div id="sdp-inspect-out"></div>
1086
1087 <div class="actions">
1088 <button id="sdp-inspect-back" class="ghost">← Back</button>
1089 </div>
1090 </div>
1091 </section>
1092
1093 <!-- ============== CALL ============== -->
1094 <section id="view-call" class="view hidden">
1095 <div id="call-banner" class="call-banner hidden">
1096 <span>You're in an empty call. Add a participant to get started.</span>
1097 <button id="call-banner-add" class="primary">+ Add participant</button>
1098 </div>
1099 <div class="call-body">
1100 <div class="video-area" id="video-area">
1101 <div class="video-tile empty" id="tile-local">
1102 <video class="vid-main" id="vid-local-main" autoplay muted playsinline></video>
1103 <div class="pip hidden" id="pip-local">
1104 <video class="vid-pip" id="vid-local-pip" autoplay muted playsinline></video>
1105 </div>
1106 <div class="empty-state"><svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-label="no video"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/></svg></div>
1107 <div class="mic-muted hidden" id="mic-muted-local" title="microphone muted"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="muted"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg></div>
1108 <span class="tile-label" id="tile-local-label">you</span>
1109 </div>
1110 <!-- Per-peer tiles inserted here by App.tiles. -->
1111 </div>
1112
1113 <aside class="sidebar">
1114 <div class="tabs">
1115 <button data-tab="chat" class="active">Chat</button>
1116 <button data-tab="files">Files</button>
1117 <button data-tab="settings">Settings</button>
1118 <button data-tab="stats">Stats</button>
1119 </div>
1120
1121 <div class="tab-pane active" data-pane="chat">
1122 <div id="chat-log" class="chat-log"></div>
1123 <div id="chat-recipients" class="recipients"></div>
1124 <div class="chat-input">
1125 <input type="text" id="chat-text" placeholder="Type a message and press Enter" autocomplete="off">
1126 <button id="chat-send" class="primary">Send</button>
1127 </div>
1128 <div id="chat-counter" class="chat-counter small">0 / 8192 B</div>
1129 </div>
1130
1131 <div class="tab-pane" data-pane="files">
1132 <div id="files-recipients" class="recipients"></div>
1133 <div id="files-drop" class="files-drop">
1134 <p><strong>Drop a file here</strong> or <a href="#" id="files-pick">pick one</a>.</p>
1135 <p class="small">Single file at a time. Chunked over a dedicated data channel. Max <span id="files-max">5 GB</span> per file.</p>
1136 <input type="file" id="files-input" class="hidden">
1137 </div>
1138 <div class="files-section-head"><h3>Outgoing</h3><button class="ghost small" id="files-out-clear">Clear all</button></div>
1139 <div id="files-out"></div>
1140 <div class="files-section-head"><h3>Incoming</h3><button class="ghost small" id="files-in-clear">Clear all</button></div>
1141 <div id="files-in"></div>
1142 </div>
1143
1144 <div class="tab-pane" data-pane="settings">
1145 <h3>Profile</h3>
1146 <label class="field">
1147 <span>Username</span>
1148 <div class="row" style="gap:6px; align-items:stretch">
1149 <input type="text" id="rt-username" maxlength="64" autocomplete="off" style="flex:1">
1150 <button id="rt-username-apply" class="ghost">Save</button>
1151 </div>
1152 </label>
1153 <p class="small">Shown to other participants in chat and on their video tile. Saved locally; broadcast over each open chat channel.</p>
1154
1155 <h3>Camera capture</h3>
1156 <p class="small">Resolution and framerate restart the camera when applied. New peers inherit these as their initial values.</p>
1157 <div class="grid-2">
1158 <label class="field">
1159 <span>Width (px, 0 = auto)</span>
1160 <input type="number" id="rt-v-w" min="0" max="3840">
1161 </label>
1162 <label class="field">
1163 <span>Height (px, 0 = auto)</span>
1164 <input type="number" id="rt-v-h" min="0" max="2160">
1165 </label>
1166 <label class="field">
1167 <span>Frame rate (fps, 0 = auto)</span>
1168 <input type="number" id="rt-v-fps" min="0" max="120">
1169 </label>
1170 </div>
1171 <button id="rt-v-cap-apply" class="ghost">Apply camera</button>
1172
1173 <h3>Screen capture</h3>
1174 <p class="small">Resolution and framerate restart the share — you'll be re-prompted for the source.</p>
1175 <div class="grid-2">
1176 <label class="field">
1177 <span>Width (px, 0 = auto)</span>
1178 <input type="number" id="rt-s-w" min="0" max="7680">
1179 </label>
1180 <label class="field">
1181 <span>Height (px, 0 = auto)</span>
1182 <input type="number" id="rt-s-h" min="0" max="4320">
1183 </label>
1184 <label class="field">
1185 <span>Frame rate (fps, 0 = auto)</span>
1186 <input type="number" id="rt-s-fps" min="0" max="120">
1187 </label>
1188 </div>
1189 <button id="rt-s-cap-apply" class="ghost">Apply screen</button>
1190
1191 <h3>Audio</h3>
1192 <p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
1193 <label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
1194 <label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
1195 <label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
1196 <div class="grid-2">
1197 <label class="field">
1198 <span>Channels</span>
1199 <select id="rt-a-channels">
1200 <option value="1">1 (mono)</option>
1201 <option value="2">2 (stereo)</option>
1202 </select>
1203 </label>
1204 <label class="field">
1205 <span>Sample rate (Hz, 0 = auto)</span>
1206 <input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
1207 </label>
1208 </div>
1209 <button id="rt-a-apply" class="ghost">Apply audio</button>
1210
1211 <div class="settings-target">
1212 <label class="field">
1213 <span>Per-peer encoder target</span>
1214 <select id="settings-target">
1215 <option value="all">All connected peers</option>
1216 </select>
1217 </label>
1218 <p class="small">Encoder settings below apply to the selected target. Choosing "All connected peers" also updates the values new peers inherit.</p>
1219 </div>
1220
1221 <h3>Camera encoder</h3>
1222 <p class="small">Max bitrate and degradation preference are applied instantly without touching the camera.</p>
1223 <div class="grid-2">
1224 <label class="field">
1225 <span>Max send bitrate (kbps, 0 = unset)</span>
1226 <input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
1227 </label>
1228 <label class="field">
1229 <span>Degradation preference</span>
1230 <select id="rt-v-degrade">
1231 <option value="balanced">balanced</option>
1232 <option value="maintain-framerate">maintain-framerate</option>
1233 <option value="maintain-resolution">maintain-resolution</option>
1234 </select>
1235 </label>
1236 </div>
1237 <button id="rt-v-enc-apply" class="ghost">Apply camera encoder</button>
1238
1239 <h3>Screen encoder</h3>
1240 <div class="grid-2">
1241 <label class="field">
1242 <span>Max send bitrate (kbps, 0 = unset)</span>
1243 <input type="number" id="rt-s-maxbr" min="0" max="50000" step="100">
1244 </label>
1245 <label class="field">
1246 <span>Degradation preference</span>
1247 <select id="rt-s-degrade">
1248 <option value="balanced">balanced</option>
1249 <option value="maintain-framerate">maintain-framerate</option>
1250 <option value="maintain-resolution">maintain-resolution</option>
1251 </select>
1252 </label>
1253 </div>
1254 <button id="rt-s-enc-apply" class="ghost">Apply screen encoder</button>
1255
1256 <h3>Send codec</h3>
1257 <p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
1258 <div class="grid-2">
1259 <label class="field">
1260 <span>Send codec</span>
1261 <select id="rt-codec">
1262 <option value="auto">auto</option>
1263 </select>
1264 </label>
1265 </div>
1266 <button id="rt-codec-apply" class="ghost">Apply codec</button>
1267 </div>
1268
1269 <div class="tab-pane" data-pane="stats">
1270 <div class="stats-head">
1271 <h3>Peer</h3>
1272 <select id="stats-peer"><option value="">(no peers)</option></select>
1273 <button id="stats-export" class="ghost small">Export</button>
1274 </div>
1275 <table class="stats-table" id="stats-table"><tbody></tbody></table>
1276 </div>
1277 </aside>
1278 </div>
1279
1280 <div class="toolbar">
1281 <div class="device-picker">
1282 <button id="tb-mic" class="off" title="Enable microphone"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11v1a7 7 0 0 0 14 0v-1"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="8" y1="22" x2="16" y2="22"/></svg><span class="nowrap">Mic off</span></button>
1283 <button id="tb-mic-pick" class="device-chevron" title="Choose microphone" aria-haspopup="true" aria-expanded="false"></button>
1284 <div id="tb-mic-menu" class="device-menu hidden" role="menu" aria-label="Microphone"></div>
1285 </div>
1286 <div class="device-picker">
1287 <button id="tb-cam" class="off" title="Enable camera"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 7l-7 5 7 5z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg><span class="nowrap">Cam off</span></button>
1288 <button id="tb-cam-pick" class="device-chevron" title="Choose camera" aria-haspopup="true" aria-expanded="false"></button>
1289 <div id="tb-cam-menu" class="device-menu hidden" role="menu" aria-label="Camera"></div>
1290 </div>
1291 <button id="tb-screen" class="off" title="Share screen"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="13" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="16" x2="12" y2="21"/><polyline points="9 10 12 7 15 10"/><line x1="12" y1="7" x2="12" y2="13"/></svg><span class="nowrap">Screen off</span></button>
1292 <span class="spacer"></span>
1293 <button id="tb-add-peer" class="primary" title="Add participant"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg><span class="nowrap">Add participant</span></button>
1294 <button id="console-toggle" title="Debug console"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg><span class="nowrap">Console</span></button>
1295 <button id="tb-hangup" class="danger" title="Hang up"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg><span class="nowrap">Hang up</span></button>
1296 </div>
1297 <aside id="console-drawer" class="hidden">
1298 <div class="console-head">
1299 <strong>Debug console</strong>
1300 <span class="small" id="console-count">0 entries</span>
1301 <span class="spacer"></span>
1302 <label class="small">Level
1303 <select id="console-level">
1304 <option value="debug">debug</option>
1305 <option value="info" selected>info</option>
1306 <option value="warn">warn</option>
1307 <option value="error">error</option>
1308 </select>
1309 </label>
1310 <label class="small">Filter <input type="text" id="console-filter" placeholder="text"></label>
1311 <button class="ghost small" id="console-clear">Clear</button>
1312 <button class="ghost small" id="console-export">Export</button>
1313 <button class="ghost small" id="console-stats-toggle">Stats poll</button>
1314 <button class="ghost small" id="console-close">×</button>
1315 </div>
1316 <div class="console-body" id="console-body"></div>
1317 </aside>
1318 </section>
1319
1320 <dialog id="add-peer-dialog">
1321 <h2>Add participant</h2>
1322 <p class="small">Set up one new pairwise connection. Each participant you add is independent — chat and files fan out to everyone connected to you.</p>
1323
1324 <h3 style="margin-top:14px">Your role for this connection</h3>
1325 <div class="role-picker compact">
1326 <button type="button" data-role="initiator">
1327 <span class="role-title">Start (create offer)</span>
1328 <span class="role-desc">You'll generate the offer; the other side joins.</span>
1329 </button>
1330 <button type="button" data-role="joiner">
1331 <span class="role-title">Join (apply offer)</span>
1332 <span class="role-desc">The other side started; you apply their offer and send back the answer.</span>
1333 </button>
1334 <button type="button" data-role="loopback" class="role-loopback">
1335 <span class="role-title">Loopback (same tab)</span>
1336 <span class="role-desc">Add a synthetic peer in this tab. Useful for testing media and the conference layout locally. Can be added multiple times.</span>
1337 </button>
1338 </div>
1339
1340 <div class="dialog-actions">
1341 <button id="add-peer-cancel" class="ghost">Cancel</button>
1342 </div>
1343 </dialog>
1344
1345 <dialog id="step-dialog">
1346 <div class="step-dialog-head">
1347 <span class="spinner" aria-hidden="true"></span>
1348 <h2 id="step-dialog-label">Working…</h2>
1349 </div>
1350 <p id="step-dialog-sub"></p>
1351 <div id="step-dialog-room" class="step-dialog-room hidden">
1352 <span class="step-dialog-room-label">Room code</span>
1353 <code id="step-dialog-room-code"></code>
1354 </div>
1355 <div class="dialog-actions">
1356 <button id="step-dialog-cancel" class="ghost">Cancel</button>
1357 </div>
1358 </dialog>
1359
1360</main>
1361
1362<script>
1363'use strict';
1364
1365/* =========================================================================
1366 WebRTC Playground — single-file, backend-less.
1367
1368 Module layout (all under one `App` namespace):
1369 App.log — ring-buffer log + console drawer rendering
1370 App.theme — light/dark
1371 App.state — current view / role / pc / dc / settings
1372 App.signal — blob format + wait for ICE complete
1373 App.codec — Opus SDP munging
1374 App.media — gUM/gDM, pre-allocated transceivers, replaceTrack
1375 App.chat — text over the chat data channel
1376 App.files — chunked file transfer over the files data channel
1377 App.stats — getStats() polling
1378 App.ui — view rendering, event wiring
1379========================================================================= */
1380
1381const App = {};
1382window.App = App; /* useful for poking from devtools */
1383
1384/* -------------------------------------------------------------------------
1385 Log
1386------------------------------------------------------------------------- */
1387App.log = (() => {
1388 const MAX = 1000;
1389 const buf = [];
1390 const subs = new Set();
1391 function push(level, label, args) {
1392 const entry = { level, label, ts: Date.now(), args };
1393 buf.push(entry);
1394 if (buf.length > MAX) buf.shift();
1395 subs.forEach(fn => { try { fn(entry); } catch (e) { /* swallow */ } });
1396 const m = level === 'debug' ? 'log' : level;
1397 try { console[m](`[${label}]`, ...args); } catch (_) {}
1398 }
1399 return {
1400 debug: (l, ...a) => push('debug', l, a),
1401 info: (l, ...a) => push('info', l, a),
1402 warn: (l, ...a) => push('warn', l, a),
1403 error: (l, ...a) => push('error', l, a),
1404 subscribe: fn => { subs.add(fn); return () => subs.delete(fn); },
1405 snapshot: () => buf.slice(),
1406 clear: () => { buf.length = 0; subs.forEach(fn => fn(null)); },
1407 };
1408})();
1409
1410window.addEventListener('error', e => App.log.error('window', e.message, e.filename + ':' + e.lineno));
1411window.addEventListener('unhandledrejection', e => App.log.error('promise', String(e.reason)));
1412
1413/* -------------------------------------------------------------------------
1414 Theme
1415------------------------------------------------------------------------- */
1416App.theme = (() => {
1417 const stored = localStorage.getItem('webrtc-tool.theme');
1418 if (stored) document.documentElement.setAttribute('data-theme', stored);
1419 return {
1420 toggle() {
1421 const cur = document.documentElement.getAttribute('data-theme') || 'dark';
1422 const next = cur === 'dark' ? 'light' : 'dark';
1423 document.documentElement.setAttribute('data-theme', next);
1424 localStorage.setItem('webrtc-tool.theme', next);
1425 }
1426 };
1427})();
1428
1429/* -------------------------------------------------------------------------
1430 Progress: inline labeled spinner shown during slow setup steps (gUM
1431 prompt, ICE gathering, etc.). Two hosts: configure view + exchange view.
1432------------------------------------------------------------------------- */
1433App.progress = (() => {
1434 let modalCancelHandler = null;
1435 function active() {
1436 /* Pick the progress widget inside the currently visible view OR open
1437 dialog (configure / exchange are <dialog>s now, not .view sections). */
1438 const candidates = ['cfg-progress', 'exch-progress'];
1439 for (const id of candidates) {
1440 const el = document.getElementById(id);
1441 if (!el) continue;
1442 const dlg = el.closest('dialog');
1443 if (dlg && dlg.open) return el;
1444 const view = el.closest('.view');
1445 if (view && !view.classList.contains('hidden')) return el;
1446 }
1447 return null;
1448 }
1449 function setBusy(busy) {
1450 /* Disable the primary action button(s) while a step is running so the
1451 user can't double-fire ICE gathering. */
1452 const ids = ['cfg-continue', 'cfg-back', 'blob-apply'];
1453 ids.forEach(id => {
1454 const el = document.getElementById(id);
1455 if (el) el.disabled = busy;
1456 });
1457 }
1458 function show(label, sub) {
1459 /* If the modal is up from a previous step, close it before falling back
1460 to the inline widget. */
1461 hideModal();
1462 const host = active();
1463 if (host) {
1464 host.classList.remove('hidden');
1465 host.querySelector('#' + host.id + '-label').textContent = label || 'Working…';
1466 host.querySelector('#' + host.id + '-sub').textContent = sub || '';
1467 }
1468 setBusy(true);
1469 }
1470 /* Modal version: used for long blocking steps (ICE gathering, waiting for
1471 peer) where the user needs an explicit Cancel affordance and where the
1472 inline widget would otherwise sit under a Continue button they can't
1473 reach. opts.roomCode, when set, is rendered in a highlighted block. */
1474 function showModal(label, sub, opts) {
1475 opts = opts || {};
1476 const dlg = document.getElementById('step-dialog');
1477 if (!dlg) return;
1478 /* Hide the inline widget if it happened to be up. */
1479 document.getElementById('cfg-progress')?.classList.add('hidden');
1480 document.getElementById('exch-progress')?.classList.add('hidden');
1481 document.getElementById('step-dialog-label').textContent = label || 'Working…';
1482 document.getElementById('step-dialog-sub').textContent = sub || '';
1483 const roomWrap = document.getElementById('step-dialog-room');
1484 if (opts.roomCode) {
1485 document.getElementById('step-dialog-room-code').textContent = opts.roomCode;
1486 roomWrap.classList.remove('hidden');
1487 } else {
1488 roomWrap.classList.add('hidden');
1489 }
1490 modalCancelHandler = opts.onCancel || null;
1491 setBusy(true);
1492 if (!dlg.open && typeof dlg.showModal === 'function') {
1493 try { dlg.showModal(); } catch (_) { /* already open */ }
1494 }
1495 }
1496 function hideModal() {
1497 const dlg = document.getElementById('step-dialog');
1498 if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
1499 modalCancelHandler = null;
1500 }
1501 function hide() {
1502 document.getElementById('cfg-progress')?.classList.add('hidden');
1503 document.getElementById('exch-progress')?.classList.add('hidden');
1504 hideModal();
1505 setBusy(false);
1506 }
1507 function triggerCancel() {
1508 const fn = modalCancelHandler;
1509 modalCancelHandler = null;
1510 if (fn) fn();
1511 }
1512 return { show, showModal, hide, hideModal, triggerCancel };
1513})();
1514
1515/* -------------------------------------------------------------------------
1516 State
1517
1518 Multi-peer model: each remote participant has its own PeerCtx (its own
1519 RTCPeerConnection, data channels, transceivers, remote streams, and
1520 peer-published media-state). Local media (mic/cam/screen capture) is
1521 captured once and fanned out to every peer's senders via replaceTrack —
1522 so capture state stays on App.state, not on a peer.
1523------------------------------------------------------------------------- */
1524App.state = {
1525 peers: new Map(), /* peerId (string) -> PeerCtx */
1526 pendingPeer: null, /* PeerCtx being set up via the Add Participant dialog */
1527 localStream: null, /* gUM result (mic + cam, synthetic) */
1528 screenStream: null, /* gDM result */
1529 micTrack: null, /* live local mic MediaStreamTrack, or null */
1530 camTrack: null, /* live local cam MediaStreamTrack, or null */
1531 iceWarmupStream: null, /* kept alive (muted) to unlock LAN ICE candidates in Firefox */
1532 username: 'Anonymous',
1533 settings: defaultSettings(),
1534 /* Banner shown when peers is empty. Once dismissed (auto on first join)
1535 it stays dismissed for the rest of the session, even if all peers leave. */
1536 bannerDismissed: false,
1537};
1538
1539/* PeerCtx — everything specific to one remote participant. */
1540function newPeerCtx(opts) {
1541 return {
1542 id: opts.id,
1543 role: opts.role, /* 'initiator' | 'joiner' | 'loopback' */
1544 label: opts.label || opts.id, /* logger tag */
1545 username: opts.username || 'Anonymous',
1546 pc: null,
1547 dcChat: null,
1548 dcFiles: null,
1549 micTransceiver: null,
1550 camTransceiver: null,
1551 screenTransceiver: null,
1552 remoteStream: null, /* mic + cam from this peer */
1553 remoteScreenStream: null, /* screen from this peer */
1554 peerMediaState: { mic: false, cam: false, screen: false },
1555 /* Per-peer media settings (setParameters-tunable, no renegotiation). */
1556 videoMaxBitrateKbps: 0,
1557 videoDegradationPreference: 'balanced',
1558 screenMaxBitrateKbps: 0,
1559 screenDegradationPreference: 'maintain-resolution',
1560 sendVideoCodec: 'auto',
1561 /* Loopback only: this peer's synthetic pcB. Each loopback peer has its
1562 own pcB so multiple loopbacks can coexist. */
1563 loopbackB: null,
1564 /* Signaling lifecycle: an in-flight AbortController for auto-mode fetches
1565 so cancelSetup / hangup can abort the long-poll cleanly. */
1566 signalAbort: null,
1567 /* True if the chat dc has already received a 'bye' from this peer, so we
1568 don't run the leave-handler twice. */
1569 leaveReceived: false,
1570 };
1571}
1572
1573function generatePeerId() {
1574 /* 8 lowercase hex chars — enough to disambiguate "Anonymous" peers. */
1575 const a = new Uint8Array(4);
1576 crypto.getRandomValues(a);
1577 return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
1578}
1579
1580/* Convenience: iterate live peers (any state); callers usually want only
1581 those with an open chat channel and a connected pc. */
1582function peerList() { return Array.from(App.state.peers.values()); }
1583function peersWithOpenChat() {
1584 return peerList().filter(p => p.dcChat && p.dcChat.readyState === 'open');
1585}
1586function peersWithOpenFiles() {
1587 return peerList().filter(p => p.dcFiles && p.dcFiles.readyState === 'open');
1588}
1589
1590function defaultSettings() {
1591 const stored = localStorage.getItem('webrtc-tool.iceServers');
1592 let ice;
1593 try { ice = stored ? JSON.parse(stored) : [{ urls: 'stun:stun.l.google.com:19302' }]; }
1594 catch (_) { ice = [{ urls: 'stun:stun.l.google.com:19302' }]; }
1595 /* Audio + video gUM constraints are global (one local capture, fanned out
1596 to all peers). bitrate/codec/degradation are stored here as defaults for
1597 newly-added peers, and copied into each PeerCtx on creation. */
1598 return {
1599 iceServers: ice,
1600 audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
1601 opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
1602 video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
1603 screen: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'maintain-resolution' },
1604 preferredVideoCodec: 'auto',
1605 sendVideoCodec: 'auto',
1606 base64: false,
1607 signaling: loadSignaling(),
1608 };
1609}
1610
1611function loadUsername() {
1612 try {
1613 const raw = localStorage.getItem('webrtc-tool.username');
1614 if (typeof raw === 'string' && raw.trim()) return raw.trim().slice(0, 64);
1615 } catch (_) {}
1616 return 'Anonymous';
1617}
1618function saveUsername(name) {
1619 try { localStorage.setItem('webrtc-tool.username', name); } catch (_) {}
1620}
1621
1622function loadSignaling() {
1623 /* Default server URL is the page's own origin — the natural assumption is
1624 that the relay is colocated with the static page. For file:// loads
1625 location.origin is "null"; fall back to blank so the user fills it in. */
1626 const defaultUrl = (location.protocol === 'http:' || location.protocol === 'https:')
1627 ? location.origin : '';
1628 const defaults = { mode: 'auto', serverUrl: defaultUrl, iceGatherTimeoutMs: 8000 };
1629 try {
1630 const stored = localStorage.getItem('webrtc-tool.signaling');
1631 if (stored) {
1632 const s = JSON.parse(stored);
1633 const t = Number(s.iceGatherTimeoutMs);
1634 return {
1635 mode: s.mode === 'manual' ? 'manual' : 'auto',
1636 serverUrl: typeof s.serverUrl === 'string' ? s.serverUrl : defaultUrl,
1637 /* 0 is a valid value (= no timeout); only fall back when missing/NaN/negative. */
1638 iceGatherTimeoutMs: Number.isFinite(t) && t >= 0 ? t : defaults.iceGatherTimeoutMs,
1639 };
1640 }
1641 } catch (_) {}
1642 return defaults;
1643}
1644
1645function saveSignaling() {
1646 try {
1647 localStorage.setItem('webrtc-tool.signaling', JSON.stringify({
1648 mode: App.state.settings.signaling.mode,
1649 serverUrl: App.state.settings.signaling.serverUrl,
1650 iceGatherTimeoutMs: App.state.settings.signaling.iceGatherTimeoutMs,
1651 }));
1652 } catch (_) {}
1653}
1654
1655function saveIce() {
1656 try { localStorage.setItem('webrtc-tool.iceServers', JSON.stringify(App.state.settings.iceServers)); } catch (_) {}
1657}
1658
1659/* -------------------------------------------------------------------------
1660 Signal: blob format + ICE-complete wait
1661------------------------------------------------------------------------- */
1662App.signal = (() => {
1663 function waitForIceComplete(pc, signal) {
1664 return new Promise(resolve => {
1665 if (pc.iceGatheringState === 'complete') return resolve();
1666 if (signal && signal.aborted) return resolve();
1667 let timeoutId = null;
1668 function done() {
1669 if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; }
1670 pc.removeEventListener('icegatheringstatechange', check);
1671 if (signal) signal.removeEventListener('abort', done);
1672 resolve();
1673 }
1674 function check() { if (pc.iceGatheringState === 'complete') done(); }
1675 pc.addEventListener('icegatheringstatechange', check);
1676 if (signal) signal.addEventListener('abort', done, { once: true });
1677 /* Hard cap so a never-completing gathering doesn't stall the UI forever.
1678 User-configurable in the Signaling → Advanced panel; 0 disables the
1679 cap entirely (the abort signal / setRemoteDescription is then the
1680 only way out). */
1681 const cap = App.state.settings.signaling.iceGatherTimeoutMs;
1682 if (cap > 0) {
1683 timeoutId = setTimeout(() => {
1684 if (pc.iceGatheringState !== 'complete')
1685 App.log.warn('signal', 'ICE gathering timed out at ' + (cap / 1000) + 's; exporting partial SDP');
1686 timeoutId = null;
1687 done();
1688 }, cap);
1689 }
1690 });
1691 }
1692 function utf8ToBase64(s) {
1693 const bytes = new TextEncoder().encode(s);
1694 /* btoa works on binary strings (one char = one byte). Convert through
1695 String.fromCharCode in 8 KB chunks to avoid blowing the argument limit. */
1696 let bin = '';
1697 for (let i = 0; i < bytes.length; i += 0x2000)
1698 bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x2000));
1699 return btoa(bin);
1700 }
1701 function base64ToUtf8(b64) {
1702 const bin = atob(b64);
1703 const bytes = new Uint8Array(bin.length);
1704 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1705 return new TextDecoder().decode(bytes);
1706 }
1707 function encode(desc) {
1708 const obj = { v: 1, type: desc.type, sdp: desc.sdp, ts: Date.now() };
1709 let text = JSON.stringify(obj);
1710 if (App.state.settings.base64) text = 'b64:' + utf8ToBase64(text);
1711 return text;
1712 }
1713 function decode(text) {
1714 text = (text || '').trim();
1715 if (!text) throw new Error('empty');
1716 if (text.startsWith('b64:')) {
1717 try { text = base64ToUtf8(text.slice(4)); }
1718 catch (e) { throw new Error('bad base64'); }
1719 }
1720 let obj;
1721 try { obj = JSON.parse(text); } catch (e) { throw new Error('not JSON: ' + e.message); }
1722 if (!obj || (obj.type !== 'offer' && obj.type !== 'answer'))
1723 throw new Error('expected {type:"offer"|"answer", sdp:...}');
1724 if (typeof obj.sdp !== 'string' || !obj.sdp.includes('v=')) throw new Error('missing SDP');
1725 return obj;
1726 }
1727 return { waitForIceComplete, encode, decode };
1728})();
1729
1730function iceGatheringSubtitle() {
1731 const cap = App.state.settings.signaling.iceGatherTimeoutMs;
1732 if (cap > 0) return 'Probing the network for routable addresses. Up to ~' + Math.round(cap / 1000) + ' seconds.';
1733 return 'Probing the network for routable addresses. No timeout — cancel manually if it stalls.';
1734}
1735
1736/* -------------------------------------------------------------------------
1737 Codec: Opus SDP munging
1738------------------------------------------------------------------------- */
1739App.codec = (() => {
1740 /* Find Opus payload type in the m=audio section, then ensure an fmtp line
1741 exists for it with the params we want. */
1742 function mungeOpus(sdp, opus) {
1743 if (!opus) return sdp;
1744 const lines = sdp.split(/\r?\n/);
1745 /* Locate audio m-section bounds */
1746 let audioStart = -1, audioEnd = lines.length;
1747 for (let i = 0; i < lines.length; i++) {
1748 if (lines[i].startsWith('m=audio')) { audioStart = i; }
1749 else if (audioStart >= 0 && lines[i].startsWith('m=') && i > audioStart) { audioEnd = i; break; }
1750 }
1751 if (audioStart < 0) return sdp;
1752
1753 /* Find Opus payload types */
1754 const opusPts = [];
1755 for (let i = audioStart; i < audioEnd; i++) {
1756 const m = lines[i].match(/^a=rtpmap:(\d+)\s+opus\/(\d+)(?:\/(\d+))?/i);
1757 if (m) opusPts.push(m[1]);
1758 }
1759 if (!opusPts.length) return sdp;
1760
1761 const params = [];
1762 if (opus.stereo) { params.push('stereo=1'); params.push('sprop-stereo=1'); }
1763 params.push('useinbandfec=' + (opus.fec ? 1 : 0));
1764 if (opus.dtx) params.push('usedtx=1');
1765 if (opus.cbr) params.push('cbr=1');
1766 if (opus.maxAverageBitrate && opus.maxAverageBitrate > 0)
1767 params.push('maxaveragebitrate=' + opus.maxAverageBitrate);
1768 const want = params.join(';');
1769
1770 for (const pt of opusPts) {
1771 let found = false;
1772 for (let i = audioStart; i < audioEnd; i++) {
1773 if (lines[i].startsWith('a=fmtp:' + pt + ' ')) {
1774 /* Merge: drop any of the keys we're setting, keep the rest, append ours */
1775 const existing = lines[i].slice(('a=fmtp:' + pt + ' ').length);
1776 const kept = existing.split(';')
1777 .map(s => s.trim()).filter(Boolean)
1778 .filter(kv => {
1779 const k = kv.split('=')[0].toLowerCase();
1780 return !['stereo','sprop-stereo','useinbandfec','usedtx','cbr','maxaveragebitrate'].includes(k);
1781 });
1782 const merged = [...kept, ...params.filter(Boolean)].join(';');
1783 lines[i] = 'a=fmtp:' + pt + ' ' + merged;
1784 found = true; break;
1785 }
1786 }
1787 if (!found) {
1788 /* Insert after the matching rtpmap */
1789 for (let i = audioStart; i < audioEnd; i++) {
1790 if (lines[i].match(new RegExp('^a=rtpmap:' + pt + '\\b'))) {
1791 lines.splice(i + 1, 0, 'a=fmtp:' + pt + ' ' + want);
1792 audioEnd++; break;
1793 }
1794 }
1795 }
1796 }
1797 return lines.join('\r\n');
1798 }
1799 return { mungeOpus };
1800})();
1801
1802/* -------------------------------------------------------------------------
1803 Media: gUM/gDM, pre-allocated transceivers, replaceTrack
1804
1805 Local capture (mic, cam, screen) happens once on App.state and is fanned
1806 out to every PeerCtx's sender via replaceTrack — so per-peer encoders
1807 share a single source track. Per-peer encoder parameters (bitrate,
1808 degradation, codec) live on the PeerCtx and are pushed via setParameters
1809 without renegotiation.
1810------------------------------------------------------------------------- */
1811App.media = (() => {
1812 /* Reorder a peer's codec list so the user's preferred RECEIVE codec is first.
1813 Must be called before createOffer or createAnswer for that peer. With
1814 'auto' we don't touch the list, letting the browser's default order win. */
1815 function applyVideoCodecPreference(peer) {
1816 const pref = App.state.settings.preferredVideoCodec;
1817 if (!pref || pref === 'auto') return;
1818 if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
1819 const caps = RTCRtpSender.getCapabilities('video');
1820 if (!caps || !caps.codecs) return;
1821 const wanted = pref.toLowerCase();
1822 const head = [], tail = [];
1823 for (const c of caps.codecs) {
1824 const sub = (c.mimeType || '').toLowerCase().split('/')[1] || '';
1825 (sub === wanted ? head : tail).push(c);
1826 }
1827 if (!head.length) { App.log.warn('media', peer.label, 'preferred codec not available', pref); return; }
1828 const ordered = [...head, ...tail];
1829 for (const t of peer.pc.getTransceivers()) {
1830 const kind = (t.receiver && t.receiver.track && t.receiver.track.kind)
1831 || (t.sender && t.sender.track && t.sender.track.kind);
1832 const isVideo = kind === 'video'
1833 || t === peer.camTransceiver
1834 || t === peer.screenTransceiver;
1835 if (!isVideo || !t.setCodecPreferences) continue;
1836 try { t.setCodecPreferences(ordered); }
1837 catch (e) { App.log.warn('media', peer.label, 'setCodecPreferences failed', e.message); }
1838 }
1839 App.log.info('media', peer.label, 'preferred video codec', pref);
1840 }
1841
1842 /* Pre-allocate three transceivers on the initiator's pc so all toolbar
1843 actions are renegotiation-free (see plan). The joiner gets matching
1844 m-sections from setRemoteDescription and we index transceivers by
1845 position. */
1846 function preallocate(peer) {
1847 peer.micTransceiver = peer.pc.addTransceiver('audio', { direction: 'sendrecv' });
1848 peer.camTransceiver = peer.pc.addTransceiver('video', { direction: 'sendrecv' });
1849 peer.screenTransceiver = peer.pc.addTransceiver('video', { direction: 'sendrecv' });
1850 App.log.debug('media', peer.label, 'pre-allocated 1 audio + 2 video transceivers');
1851 }
1852 function adoptTransceiversFromRemote(peer) {
1853 /* For the joiner: after setRemoteDescription, transceivers exist in
1854 the same order the initiator added them (mid 0, 1, 2). They were
1855 auto-created by SRD and default to recvonly because the joiner has
1856 no local tracks yet — but later enabling mic/cam on a recvonly
1857 transceiver would never send. Force sendrecv so the answer SDP
1858 advertises bidirectional intent. */
1859 const ts = peer.pc.getTransceivers();
1860 for (const t of ts) {
1861 try { t.direction = 'sendrecv'; }
1862 catch (e) { App.log.warn('media', peer.label, 'could not upgrade transceiver to sendrecv', e.message); }
1863 }
1864 peer.micTransceiver = ts[0] || null;
1865 peer.camTransceiver = ts[1] || null;
1866 peer.screenTransceiver = ts[2] || null;
1867 App.log.debug('media', peer.label, 'adopted', ts.length, 'transceivers from remote SDP');
1868 }
1869
1870 /* After a new peer's transceivers are set up, push the currently-live
1871 local tracks into them so the peer immediately receives whatever the
1872 user already enabled before they joined. */
1873 async function publishLocalTracksTo(peer) {
1874 if (peer.micTransceiver && App.state.micTrack) {
1875 try { await peer.micTransceiver.sender.replaceTrack(App.state.micTrack); }
1876 catch (e) { App.log.warn('media', peer.label, 'publish mic failed', e.message); }
1877 }
1878 if (peer.camTransceiver && App.state.camTrack) {
1879 try { await peer.camTransceiver.sender.replaceTrack(App.state.camTrack); }
1880 catch (e) { App.log.warn('media', peer.label, 'publish cam failed', e.message); }
1881 }
1882 if (peer.screenTransceiver && App.state.screenStream) {
1883 const track = App.state.screenStream.getVideoTracks()[0];
1884 if (track) {
1885 try { await peer.screenTransceiver.sender.replaceTrack(track); }
1886 catch (e) { App.log.warn('media', peer.label, 'publish screen failed', e.message); }
1887 }
1888 }
1889 applyCamSendParamsFor(peer);
1890 applyScreenSendParamsFor(peer);
1891 }
1892
1893 /* The local preview MediaStream is a synthetic view: we add/remove tracks
1894 to it as the user enables/disables mic and camera from the toolbar. */
1895 function localStream() {
1896 if (!App.state.localStream) App.state.localStream = new MediaStream();
1897 return App.state.localStream;
1898 }
1899 function setLocalTrack(kind, track) {
1900 const s = localStream();
1901 s.getTracks().filter(t => t.kind === kind).forEach(t => s.removeTrack(t));
1902 if (track) s.addTrack(track);
1903 if (kind === 'audio') App.state.micTrack = track || null;
1904 if (kind === 'video') App.state.camTrack = track || null;
1905 refreshLocalDisplay();
1906 }
1907 /* Decide what plays in the main tile vs the corner PIP for the local side.
1908 Screen share takes the main tile; the cam moves to the PIP. */
1909 function refreshLocalDisplay() {
1910 const main = document.getElementById('vid-local-main');
1911 const pip = document.getElementById('vid-local-pip');
1912 const pipWrap = document.getElementById('pip-local');
1913 const tile = document.getElementById('tile-local');
1914 if (!main || !tile) return;
1915 const camStream = App.state.localStream;
1916 const hasCam = camStream && camStream.getVideoTracks().length > 0;
1917 const screenStream = App.state.screenStream;
1918 const hasScreen = !!screenStream;
1919 if (hasScreen) {
1920 main.srcObject = screenStream;
1921 tile.classList.add('screen');
1922 if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
1923 else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
1924 } else if (hasCam) {
1925 main.srcObject = camStream;
1926 tile.classList.remove('screen');
1927 pip.srcObject = null; pipWrap.classList.add('hidden');
1928 } else {
1929 main.srcObject = null;
1930 tile.classList.remove('screen');
1931 pip.srcObject = null; pipWrap.classList.add('hidden');
1932 }
1933 tile.classList.toggle('empty', !hasCam && !hasScreen);
1934 const micOn = !!App.state.micTrack;
1935 const m = document.getElementById('mic-muted-local');
1936 if (m) m.classList.toggle('hidden', micOn);
1937 }
1938 /* Refresh the per-peer remote tile (video / pip / mic-muted badge). The
1939 tile DOM is created and managed by tilesUI; we just push current
1940 srcObjects + visibility into it. */
1941 function refreshRemoteDisplayFor(peer) {
1942 const tile = document.getElementById('tile-' + peer.id);
1943 if (!tile) return;
1944 const main = tile.querySelector('.vid-main');
1945 const pip = tile.querySelector('.vid-pip');
1946 const pipWrap = tile.querySelector('.pip');
1947 const audioEl = tile.querySelector('audio.audio-remote');
1948 const camStream = peer.remoteStream;
1949 const screenStream = peer.remoteScreenStream;
1950 /* Always route the peer's audio through a dedicated audio element so it
1951 plays regardless of whether any video is currently visible. */
1952 if (audioEl && audioEl.srcObject !== camStream) audioEl.srcObject = camStream || null;
1953 /* Track presence isn't enough — replaceTrack(null) on the sender leaves
1954 the receiver's track in place (frozen on last frame). Trust the peer's
1955 broadcast media state when deciding whether to show video. */
1956 const ms = peer.peerMediaState || { mic: false, cam: false, screen: false };
1957 const hasCam = ms.cam && camStream && camStream.getVideoTracks().length > 0;
1958 const hasScreen = ms.screen && screenStream && screenStream.getVideoTracks().length > 0;
1959 if (hasScreen) {
1960 main.srcObject = screenStream;
1961 tile.classList.add('screen');
1962 if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
1963 else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
1964 } else if (hasCam) {
1965 main.srcObject = camStream;
1966 tile.classList.remove('screen');
1967 pip.srcObject = null; pipWrap.classList.add('hidden');
1968 } else {
1969 /* Keep audio attached even when not showing video, so the peer's mic
1970 still plays through. */
1971 main.srcObject = camStream || null;
1972 tile.classList.remove('screen');
1973 pip.srcObject = null; pipWrap.classList.add('hidden');
1974 }
1975 tile.classList.toggle('empty', !hasCam && !hasScreen);
1976 const m = tile.querySelector('.mic-muted');
1977 if (m) m.classList.toggle('hidden', ms.mic);
1978 }
1979 function refreshRemoteDisplay() {
1980 for (const peer of App.state.peers.values()) refreshRemoteDisplayFor(peer);
1981 }
1982 /* In loopback mode, each loopback peer's pcB needs to "see" the same tracks
1983 pcA is sending so the remote tile gets video back. Called whenever a
1984 sender track changes; iterates every loopback peer. */
1985 function mirrorToLoopback() {
1986 const a = App.state.micTrack;
1987 const v = App.state.camTrack;
1988 const s = App.state.screenStream && App.state.screenStream.getVideoTracks()[0] || null;
1989 const fail = (label, peerLabel) => err =>
1990 App.log.warn('loopback', peerLabel + ' mirror ' + label + ' failed', err.message);
1991 for (const peer of App.state.peers.values()) {
1992 const pcB = peer.loopbackB;
1993 if (!pcB) continue;
1994 const ts = pcB.getTransceivers();
1995 if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic', peer.label));
1996 if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam', peer.label));
1997 if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen', peer.label));
1998 }
1999 }
2000 /* Fan-out the local track-state to every peer's sender. Failures on one
2001 peer don't block the others. */
2002 async function fanOutTrack(getTransceiver, track, label) {
2003 const tasks = [];
2004 for (const peer of App.state.peers.values()) {
2005 const tr = getTransceiver(peer);
2006 if (!tr) continue;
2007 tasks.push(
2008 tr.sender.replaceTrack(track).catch(e =>
2009 App.log.warn('media', peer.label, 'replaceTrack ' + label + ' failed', e.message))
2010 );
2011 }
2012 if (tasks.length) await Promise.allSettled(tasks);
2013 }
2014 function gumAvailable() {
2015 return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
2016 }
2017
2018 /* Detect "device unplugged while active": the track fires 'ended'. Flip the
2019 toolbar button off so the UI reflects reality and the user can pick a
2020 different device. We guard with App.state.micTrack/camTrack === track to
2021 ignore the ended event that fires when *we* swap the track via
2022 replaceTrack(). */
2023 function attachTrackEndedHandler(track, kind) {
2024 track.addEventListener('ended', () => {
2025 const live = kind === 'mic' ? App.state.micTrack : App.state.camTrack;
2026 if (live !== track) return;
2027 const label = kind === 'mic' ? 'Microphone' : 'Camera';
2028 App.log.warn('media', kind + ' track ended unexpectedly');
2029 App.chat.appendSystem?.(label + ' disconnected.');
2030 if (kind === 'mic') setMic(false); else setCam(false);
2031 });
2032 }
2033
2034 async function setMic(on) {
2035 const btn = document.getElementById('tb-mic');
2036 if (on) {
2037 if (!gumAvailable()) {
2038 App.log.warn('media', 'mic unavailable (insecure context?)');
2039 App.chat.appendSystem?.('Microphone unavailable — this page must be served over HTTPS or localhost.');
2040 return;
2041 }
2042 const a = App.state.settings.audio;
2043 let stream = null;
2044 try {
2045 stream = await navigator.mediaDevices.getUserMedia({
2046 audio: {
2047 echoCancellation: a.echoCancellation,
2048 noiseSuppression: a.noiseSuppression,
2049 autoGainControl: a.autoGainControl,
2050 channelCount: a.channelCount || undefined,
2051 sampleRate: a.sampleRate || undefined,
2052 deviceId: a.deviceId ? { exact: a.deviceId } : undefined,
2053 },
2054 });
2055 const track = stream.getAudioTracks()[0];
2056 setLocalTrack('audio', track);
2057 attachTrackEndedHandler(track, 'mic');
2058 await fanOutTrack(p => p.micTransceiver, track, 'mic');
2059 mirrorToLoopback();
2060 if (btn) {
2061 btn.classList.add('on'); btn.classList.remove('off');
2062 btn.querySelector('.nowrap').textContent = 'Mic on';
2063 }
2064 App.log.info('media', 'mic on');
2065 applyMediaButtonAvailability?.();
2066 } catch (e) {
2067 /* If the saved device disappeared between sessions, fall back to the
2068 OS default rather than trapping the user with a broken preference. */
2069 if (e && e.name === 'OverconstrainedError' && a.deviceId) {
2070 App.log.warn('media', 'saved mic deviceId no longer available, clearing');
2071 a.deviceId = '';
2072 }
2073 App.log.error('media', 'mic getUserMedia failed', e.message);
2074 App.chat.appendSystem?.('Microphone failed: ' + e.message);
2075 if (stream) stream.getTracks().forEach(t => t.stop());
2076 }
2077 } else {
2078 const t = App.state.micTrack;
2079 if (t) t.stop();
2080 setLocalTrack('audio', null);
2081 await fanOutTrack(p => p.micTransceiver, null, 'mic');
2082 mirrorToLoopback();
2083 if (btn) {
2084 btn.classList.remove('on'); btn.classList.add('off');
2085 btn.querySelector('.nowrap').textContent = 'Mic off';
2086 }
2087 App.log.info('media', 'mic off');
2088 }
2089 broadcastMediaState();
2090 }
2091
2092 async function setCam(on) {
2093 const btn = document.getElementById('tb-cam');
2094 if (on) {
2095 if (!gumAvailable()) {
2096 App.log.warn('media', 'camera unavailable (insecure context?)');
2097 App.chat.appendSystem?.('Camera unavailable — this page must be served over HTTPS or localhost.');
2098 return;
2099 }
2100 const vs = App.state.settings.video;
2101 let stream = null;
2102 try {
2103 stream = await navigator.mediaDevices.getUserMedia({
2104 video: {
2105 width: vs.width || undefined,
2106 height: vs.height || undefined,
2107 frameRate: vs.frameRate || undefined,
2108 deviceId: vs.deviceId ? { exact: vs.deviceId } : undefined,
2109 },
2110 });
2111 const track = stream.getVideoTracks()[0];
2112 setLocalTrack('video', track);
2113 attachTrackEndedHandler(track, 'cam');
2114 await fanOutTrack(p => p.camTransceiver, track, 'cam');
2115 applyCamSendParamsAll();
2116 mirrorToLoopback();
2117 if (btn) {
2118 btn.classList.add('on'); btn.classList.remove('off');
2119 btn.querySelector('.nowrap').textContent = 'Cam on';
2120 }
2121 App.log.info('media', 'cam on');
2122 applyMediaButtonAvailability?.();
2123 } catch (e) {
2124 if (e && e.name === 'OverconstrainedError' && vs.deviceId) {
2125 App.log.warn('media', 'saved cam deviceId no longer available, clearing');
2126 vs.deviceId = '';
2127 }
2128 App.log.error('media', 'camera getUserMedia failed', e.message);
2129 App.chat.appendSystem?.('Camera failed: ' + e.message);
2130 if (stream) stream.getTracks().forEach(t => t.stop());
2131 }
2132 } else {
2133 const t = App.state.camTrack;
2134 if (t) t.stop();
2135 setLocalTrack('video', null);
2136 await fanOutTrack(p => p.camTransceiver, null, 'cam');
2137 mirrorToLoopback();
2138 if (btn) {
2139 btn.classList.remove('on'); btn.classList.add('off');
2140 btn.querySelector('.nowrap').textContent = 'Cam off';
2141 }
2142 App.log.info('media', 'cam off');
2143 }
2144 broadcastMediaState();
2145 }
2146
2147 async function startScreenshare() {
2148 if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
2149 throw new Error('Screen share requires HTTPS (or localhost). Open this page over a secure context.');
2150 }
2151 const ss = App.state.settings.screen;
2152 const ms = await navigator.mediaDevices.getDisplayMedia({
2153 video: {
2154 width: ss.width || undefined,
2155 height: ss.height || undefined,
2156 frameRate: ss.frameRate || undefined,
2157 },
2158 audio: false,
2159 });
2160 App.state.screenStream = ms;
2161 const track = ms.getVideoTracks()[0];
2162 track.addEventListener('ended', () => stopScreenshare());
2163 await fanOutTrack(p => p.screenTransceiver, track, 'screen');
2164 applyScreenSendParamsAll();
2165 mirrorToLoopback();
2166 refreshLocalDisplay();
2167 const sBtn = document.getElementById('tb-screen');
2168 if (sBtn) {
2169 sBtn.classList.add('on'); sBtn.classList.remove('off');
2170 sBtn.querySelector('.nowrap').textContent = 'Sharing';
2171 }
2172 App.log.info('media', 'screenshare started');
2173 broadcastMediaState();
2174 }
2175 async function stopScreenshare() {
2176 const ms = App.state.screenStream;
2177 if (ms) ms.getTracks().forEach(t => t.stop());
2178 App.state.screenStream = null;
2179 await fanOutTrack(p => p.screenTransceiver, null, 'screen');
2180 mirrorToLoopback();
2181 refreshLocalDisplay();
2182 const sBtn = document.getElementById('tb-screen');
2183 if (sBtn) {
2184 sBtn.classList.remove('on'); sBtn.classList.add('off');
2185 sBtn.querySelector('.nowrap').textContent = 'Screen off';
2186 }
2187 App.log.info('media', 'screenshare stopped');
2188 broadcastMediaState();
2189 }
2190
2191 /* Collect the relevant sender for one peer (and its loopback mirror, if
2192 any) for a given track kind ('cam' | 'screen'). */
2193 function senderFor(peer, kind) {
2194 const tr = kind === 'screen' ? peer.screenTransceiver : peer.camTransceiver;
2195 return tr ? tr.sender : null;
2196 }
2197 /* For a loopback peer, returns pcB's reverse sender for this track kind
2198 (the one that mirrors A's track back so peer.remoteStream picks it up).
2199 Transceivers on pcB are in mic/cam/screen order via preallocate's m-section
2200 ordering. */
2201 function loopbackMirrorSender(peer, kind) {
2202 if (!peer.loopbackB) return null;
2203 const tIdx = kind === 'screen' ? 2 : 1;
2204 const t = peer.loopbackB.getTransceivers()[tIdx];
2205 return t ? t.sender : null;
2206 }
2207 /* setParameters is transactional via an internal transactionId attached to
2208 the params object returned by getParameters. Two interleaved
2209 get/mutate/set cycles on the same sender race and the second can fail
2210 with InvalidModificationError. Coalesce bitrate + degradation into one
2211 round-trip per sender, and serialize calls via a per-sender chain. */
2212 const pendingByS = new WeakMap(); /* sender -> Promise (most recent set) */
2213 function applyVideoSendParamsToSender(sender, kbps, pref, wantCodec, logLabel) {
2214 if (!sender) return;
2215 const prev = pendingByS.get(sender) || Promise.resolve();
2216 const next = prev.then(() => {
2217 const params = sender.getParameters();
2218 if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
2219 if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
2220 else delete params.encodings[0].maxBitrate;
2221 if (pref) params.degradationPreference = pref;
2222 /* encodings[0].codec is the "set sending codec" API. Pick from the
2223 negotiated codec list on the sender — if the codec we want isn't
2224 there (e.g. peer didn't offer it, or browser doesn't expose
2225 params.codecs yet), leave the field unset so the browser keeps
2226 picking automatically. */
2227 if (wantCodec === 'auto') {
2228 delete params.encodings[0].codec;
2229 } else if (params.codecs && params.codecs.length) {
2230 const pick = params.codecs.find(c => {
2231 const sub = (c.mimeType || '').split('/')[1] || '';
2232 return sub.toLowerCase() === wantCodec;
2233 });
2234 if (pick) params.encodings[0].codec = pick;
2235 else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
2236 }
2237 return sender.setParameters(params).then(
2238 () => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
2239 e => App.log.error('media', logLabel, 'setParameters failed', e.message)
2240 );
2241 });
2242 pendingByS.set(sender, next);
2243 }
2244 /* Apply this peer's per-peer cam encoding params. For loopback peers we
2245 also push the same params onto pcB's mirror sender so the cap is visible
2246 on the received-side stream too. */
2247 function applyCamSendParamsFor(peer) {
2248 const codec = (peer.sendVideoCodec || 'auto').toLowerCase();
2249 applyVideoSendParamsToSender(
2250 senderFor(peer, 'cam'),
2251 peer.videoMaxBitrateKbps,
2252 peer.videoDegradationPreference,
2253 codec,
2254 'cam[' + peer.label + ']');
2255 const lb = loopbackMirrorSender(peer, 'cam');
2256 if (lb) applyVideoSendParamsToSender(lb, peer.videoMaxBitrateKbps, peer.videoDegradationPreference, codec, 'cam[' + peer.label + '/B]');
2257 }
2258 function applyScreenSendParamsFor(peer) {
2259 const codec = (peer.sendVideoCodec || 'auto').toLowerCase();
2260 applyVideoSendParamsToSender(
2261 senderFor(peer, 'screen'),
2262 peer.screenMaxBitrateKbps,
2263 peer.screenDegradationPreference,
2264 codec,
2265 'screen[' + peer.label + ']');
2266 const lb = loopbackMirrorSender(peer, 'screen');
2267 if (lb) applyVideoSendParamsToSender(lb, peer.screenMaxBitrateKbps, peer.screenDegradationPreference, codec, 'screen[' + peer.label + '/B]');
2268 }
2269 /* Fan out the current per-peer params to every live peer. Each peer's
2270 loopback mirror (if any) is updated inside its applyCam/Screen call. */
2271 function applyCamSendParamsAll() {
2272 for (const peer of App.state.peers.values()) applyCamSendParamsFor(peer);
2273 }
2274 function applyScreenSendParamsAll() {
2275 for (const peer of App.state.peers.values()) applyScreenSendParamsFor(peer);
2276 }
2277 async function applyAudioConstraints() {
2278 const t = App.state.micTrack;
2279 if (!t) return;
2280 const a = App.state.settings.audio;
2281 try {
2282 await t.applyConstraints({
2283 echoCancellation: a.echoCancellation,
2284 noiseSuppression: a.noiseSuppression,
2285 autoGainControl: a.autoGainControl,
2286 });
2287 App.log.info('media', 'audio constraints applied', t.getSettings());
2288 } catch (e) {
2289 App.log.warn('media', 'applyConstraints failed', e.message);
2290 }
2291 }
2292
2293 /* Live device switch: grab the new track, fan-out replaceTrack across all
2294 peers, then stop the old one. No off→on cycle, so no black-frame flicker
2295 for any peer and no period where the OS mic indicator drops. Returns the
2296 new deviceId on success, or null on failure (caller keeps prior selection). */
2297 async function switchInputDevice(kind, deviceId) {
2298 const oldTrack = kind === 'mic' ? App.state.micTrack : App.state.camTrack;
2299 if (!oldTrack) return null;
2300 if (!gumAvailable()) return null;
2301 const a = App.state.settings.audio;
2302 const vs = App.state.settings.video;
2303 let stream = null;
2304 try {
2305 const constraints = kind === 'mic'
2306 ? { audio: {
2307 echoCancellation: a.echoCancellation,
2308 noiseSuppression: a.noiseSuppression,
2309 autoGainControl: a.autoGainControl,
2310 channelCount: a.channelCount || undefined,
2311 sampleRate: a.sampleRate || undefined,
2312 deviceId: deviceId ? { exact: deviceId } : undefined,
2313 } }
2314 : { video: {
2315 width: vs.width || undefined,
2316 height: vs.height || undefined,
2317 frameRate: vs.frameRate || undefined,
2318 deviceId: deviceId ? { exact: deviceId } : undefined,
2319 } };
2320 stream = await navigator.mediaDevices.getUserMedia(constraints);
2321 const newTrack = kind === 'mic' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0];
2322 setLocalTrack(kind === 'mic' ? 'audio' : 'video', newTrack);
2323 await fanOutTrack(
2324 p => kind === 'mic' ? p.micTransceiver : p.camTransceiver,
2325 newTrack, kind);
2326 if (oldTrack !== newTrack) oldTrack.stop();
2327 attachTrackEndedHandler(newTrack, kind);
2328 if (kind === 'cam') applyCamSendParamsAll();
2329 mirrorToLoopback();
2330 App.log.info('media', kind + ' switched', { requested: deviceId || '(default)', got: newTrack.getSettings ? newTrack.getSettings().deviceId : '?' });
2331 return deviceId || '';
2332 } catch (e) {
2333 App.log.error('media', kind + ' switch failed', e.message);
2334 App.chat.appendSystem?.('Failed to switch ' + (kind === 'mic' ? 'microphone' : 'camera') + ': ' + e.message);
2335 if (stream) stream.getTracks().forEach(t => t.stop());
2336 return null;
2337 }
2338 }
2339
2340 return {
2341 preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference, publishLocalTracksTo,
2342 refreshRemoteDisplayFor, applyCamSendParamsFor, applyScreenSendParamsFor,
2343 applyCamSendParamsAll, applyScreenSendParamsAll,
2344 startScreenshare, stopScreenshare,
2345 setMic, setCam,
2346 switchInputDevice,
2347 applyAudioConstraints,
2348 refreshLocalDisplay, refreshRemoteDisplay,
2349 mirrorToLoopback,
2350 };
2351})();
2352
2353/* -------------------------------------------------------------------------
2354 Chat
2355------------------------------------------------------------------------- */
2356App.chat = (() => {
2357 /* Limits are measured in UTF-8 bytes (the wire size), not UTF-16 code
2358 units. A 64 KB code-unit cap could let a non-ASCII payload through at
2359 up to ~256 KB on the wire; checking bytes prevents that. */
2360 const MAX_CHAT_MSG = 64 * 1024;
2361 const MAX_TEXT = 8 * 1024;
2362 const MAX_NAME = 64;
2363 const ID_RE = /^[A-Za-z0-9_\-]{1,24}$/;
2364 const utf8Length = s => new TextEncoder().encode(s).length;
2365 function newMsgId() {
2366 return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
2367 }
2368 /* Outgoing-message delivery tracking. msgId -> { recipients: Map<peerId,
2369 'sent'|'acked'|'failed'|'closed'>, text, ts, el }. Used to render the
2370 per-recipient delivery popup when the user clicks one of their own
2371 messages. */
2372 const outMsgs = new Map();
2373
2374 /* Each peer's chat data channel is attached here. The peer arg is the
2375 PeerCtx so we can tag incoming messages with the sender and update
2376 per-peer media-state. */
2377 function attach(peer, dc) {
2378 dc.binaryType = 'arraybuffer';
2379 dc.onopen = () => {
2380 App.log.info('chat', peer.label, 'data channel open');
2381 appendSystem(displayNameOf(peer) + ' connected');
2382 /* Push our current media state + username so the peer's UI matches
2383 reality from the moment the channel opens. */
2384 sendMediaStateTo(peer);
2385 sendUsernameTo(peer);
2386 setInCallControlsEnabled();
2387 };
2388 dc.onclose = () => { App.log.info('chat', peer.label, 'data channel closed'); setInCallControlsEnabled(); };
2389 dc.onerror = e => App.log.error('chat', peer.label, 'error', e.error ? e.error.message : e);
2390 dc.onmessage = e => {
2391 if (typeof e.data !== 'string') { App.log.warn('chat', peer.label, 'binary payload rejected'); return; }
2392 if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', peer.label, 'oversized message rejected', e.data.length); return; }
2393 if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', peer.label, 'oversized message rejected (bytes)'); return; }
2394 let m;
2395 try { m = JSON.parse(e.data); }
2396 catch (err) { App.log.warn('chat', peer.label, 'bad json', err.message); return; }
2397 if (!m || typeof m !== 'object' || typeof m.kind !== 'string') return;
2398 if (m.kind === 'msg') {
2399 if (typeof m.text !== 'string') return;
2400 if (m.text.length > MAX_TEXT || utf8Length(m.text) > MAX_TEXT) return;
2401 const ts = Number.isFinite(m.ts) ? m.ts : Date.now();
2402 append(false, m.text, ts, displayNameOf(peer));
2403 /* Ack the message so the sender can mark this peer 'delivered'. */
2404 if (typeof m.id === 'string' && ID_RE.test(m.id)) {
2405 try { dc.send(JSON.stringify({ kind: 'msg-ack', id: m.id })); }
2406 catch (_) {}
2407 }
2408 } else if (m.kind === 'msg-ack') {
2409 if (typeof m.id !== 'string' || !ID_RE.test(m.id)) return;
2410 const rec = outMsgs.get(m.id);
2411 if (!rec) return;
2412 const r = rec.recipients.get(peer.id);
2413 if (r && r !== 'failed' && r !== 'closed') {
2414 rec.recipients.set(peer.id, 'acked');
2415 refreshMsgDetail(m.id);
2416 }
2417 } else if (m.kind === 'media-state') {
2418 peer.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
2419 App.media.refreshRemoteDisplayFor(peer);
2420 } else if (m.kind === 'username') {
2421 if (typeof m.name !== 'string') return;
2422 const cleaned = m.name.trim().slice(0, MAX_NAME) || 'Anonymous';
2423 const old = displayNameOf(peer);
2424 peer.username = cleaned;
2425 if (old !== displayNameOf(peer)) {
2426 App.tiles?.refreshLabel(peer);
2427 App.ui.refreshPeerWidgets?.();
2428 appendSystem(old + ' is now ' + displayNameOf(peer));
2429 }
2430 } else if (m.kind === 'bye') {
2431 onPeerLeft(peer, 'remote');
2432 }
2433 /* unknown kinds: silently ignore */
2434 };
2435 }
2436 /* Fan out a chat message to every peer with an open chat channel (or the
2437 subset given via opts.peers). The returned message id is also embedded
2438 in the wire payload so receivers can ack it back. */
2439 function send(text, opts) {
2440 const peers = (opts && opts.peers) || (App.ui.selectedChatPeers ? App.ui.selectedChatPeers() : peersWithOpenChat());
2441 if (!peers.length) { App.log.warn('chat', 'no recipients selected'); return; }
2442 const id = newMsgId();
2443 const msg = { kind: 'msg', id, text, ts: Date.now() };
2444 const payload = JSON.stringify(msg);
2445 const recipients = new Map();
2446 for (const peer of peers) {
2447 try {
2448 peer.dcChat.send(payload);
2449 recipients.set(peer.id, 'sent');
2450 } catch (e) {
2451 App.log.warn('chat', peer.label, 'send failed', e.message);
2452 recipients.set(peer.id, 'failed');
2453 }
2454 }
2455 const el = append(true, text, msg.ts, 'you', id);
2456 outMsgs.set(id, { recipients, text, ts: msg.ts, el });
2457 refreshMsgDetail(id);
2458 }
2459 /* Application-level hangup signal. Sent best-effort right before we tear
2460 a connection down so the peer can react instantly instead of waiting
2461 for ICE consent freshness to time out (~10–30 s). Skipped in loopback
2462 because pcB echoes chat messages back to pcA — a bye would round-trip
2463 and spuriously trigger the peer-left handler on the local user. */
2464 function sendByeTo(peer) {
2465 if (peer.role === 'loopback') return;
2466 const dc = peer.dcChat;
2467 if (!dc || dc.readyState !== 'open') return;
2468 try { dc.send(JSON.stringify({ kind: 'bye' })); } catch (_) {}
2469 }
2470 function sendByeAll() {
2471 for (const peer of App.state.peers.values()) sendByeTo(peer);
2472 }
2473 function sendUsernameTo(peer) {
2474 const dc = peer.dcChat;
2475 if (!dc || dc.readyState !== 'open') return;
2476 try { dc.send(JSON.stringify({ kind: 'username', name: App.state.username })); }
2477 catch (e) { App.log.warn('chat', peer.label, 'username send failed', e.message); }
2478 }
2479 function broadcastUsername() {
2480 for (const peer of peersWithOpenChat()) sendUsernameTo(peer);
2481 }
2482 function append(mine, text, ts, who, msgId) {
2483 const log = document.getElementById('chat-log');
2484 if (!log) return null;
2485 const el = document.createElement('div');
2486 el.className = 'chat-msg' + (mine ? ' me' : '') + (mine && msgId ? ' has-detail' : '');
2487 const meta = mine ? new Date(ts).toLocaleTimeString()
2488 : (who || 'peer') + ' · ' + new Date(ts).toLocaleTimeString();
2489 el.innerHTML = '<div class="text"></div><div class="meta"></div><div class="detail"></div>';
2490 el.querySelector('.text').textContent = text;
2491 el.querySelector('.meta').textContent = meta;
2492 if (mine && msgId) {
2493 el.dataset.msgId = msgId;
2494 el.addEventListener('click', () => el.classList.toggle('open'));
2495 }
2496 log.appendChild(el);
2497 log.scrollTop = log.scrollHeight;
2498 return el;
2499 }
2500 /* Re-render the per-recipient delivery detail block for one outgoing msg. */
2501 function refreshMsgDetail(id) {
2502 const rec = outMsgs.get(id);
2503 if (!rec || !rec.el) return;
2504 const detail = rec.el.querySelector('.detail');
2505 if (!detail) return;
2506 const totals = { acked: 0, sent: 0, failed: 0, closed: 0 };
2507 const rows = [];
2508 for (const [peerId, state] of rec.recipients) {
2509 totals[state] = (totals[state] || 0) + 1;
2510 const peer = App.state.peers.get(peerId);
2511 const name = peer ? displayNameOf(peer) : ('peer ' + peerId.slice(0, 4));
2512 rows.push(`<div class="row"><span>${escapeHtml(name)}</span><span class="state ${state}">${state}</span></div>`);
2513 }
2514 /* One-line summary appears in .meta after the timestamp. */
2515 const meta = rec.el.querySelector('.meta');
2516 if (meta) {
2517 const total = rec.recipients.size;
2518 const acked = totals.acked || 0;
2519 const failed = (totals.failed || 0) + (totals.closed || 0);
2520 let summary = ' · ' + acked + '/' + total + ' delivered';
2521 if (failed) summary += ', ' + failed + ' failed';
2522 meta.textContent = new Date(rec.ts).toLocaleTimeString() + summary;
2523 }
2524 detail.innerHTML = rows.join('');
2525 }
2526 /* Called from removePeer / cleanupForPeer to mark outstanding messages as
2527 closed for that recipient. */
2528 function notePeerClosed(peer) {
2529 for (const [id, rec] of outMsgs) {
2530 const r = rec.recipients.get(peer.id);
2531 if (r === 'sent') {
2532 rec.recipients.set(peer.id, 'closed');
2533 refreshMsgDetail(id);
2534 }
2535 }
2536 }
2537 function appendSystem(text) {
2538 const log = document.getElementById('chat-log');
2539 if (!log) return;
2540 const el = document.createElement('div');
2541 el.className = 'small';
2542 el.style.textAlign = 'center';
2543 el.style.color = 'var(--text-faint)';
2544 el.textContent = '— ' + text + ' —';
2545 log.appendChild(el);
2546 log.scrollTop = log.scrollHeight;
2547 }
2548 /* Wipe the chat log AND the delivery-tracking map. Each outMsg entry
2549 pins its `.chat-msg` DOM node, so clearing only the log would still
2550 leak the detached nodes through outMsgs. */
2551 function clearAll() {
2552 outMsgs.clear();
2553 document.getElementById('chat-log')?.replaceChildren();
2554 }
2555 return {
2556 attach, send, sendByeTo, sendByeAll, broadcastUsername, sendUsernameTo,
2557 append, appendSystem, notePeerClosed, clearAll, MAX_TEXT, utf8Length,
2558 };
2559})();
2560
2561/* Display name for a peer, with a short id suffix when colliding with
2562 ours or with another peer (e.g. multiple Anonymous users). */
2563function displayNameOf(peer) {
2564 const base = (peer.username || 'Anonymous').trim() || 'Anonymous';
2565 /* Disambiguate when this name is shared with anyone else (other peers OR
2566 ourselves). Suffix is taken from the peer's id. */
2567 const collides = (App.state.username || '').trim() === base
2568 || peerList().some(p => p !== peer && (p.username || '').trim() === base);
2569 return collides ? base + ' #' + peer.id.slice(0, 4) : base;
2570}
2571
2572/* Push the local media-state to one peer's chat dc. */
2573function sendMediaStateTo(peer) {
2574 const dc = peer.dcChat;
2575 if (!dc || dc.readyState !== 'open') return;
2576 const state = { kind: 'media-state', ...localMediaState() };
2577 try { dc.send(JSON.stringify(state)); }
2578 catch (e) { App.log.warn('media', peer.label, 'state send failed', e.message); }
2579}
2580
2581/* -------------------------------------------------------------------------
2582 Files: chunked transfer with backpressure
2583------------------------------------------------------------------------- */
2584App.files = (() => {
2585 const CHUNK = 16 * 1024;
2586 const HIGH_WATER = 1024 * 1024; /* 1 MB */
2587 const LOW_WATER = 256 * 1024;
2588 /* Per-peer receive state. Key = peer.id + '/' + fileId so two peers can
2589 simultaneously send files with the same id without colliding. */
2590 const incoming = new Map(); /* "<peerId>/<id>" -> { peer, id, name, size, mime, received, chunks } */
2591 /* Per-peer send state. Each peer has its own queue, current send, abort
2592 set, since each peer's dcFiles has its own backpressure and lifecycle. */
2593 /* peer -> { queue: [{ id, file }], busy: bool, currentId: string|null,
2594 aborted: Set<id>, pending: Map<id, resolveFn> } */
2595 const sendState = new WeakMap();
2596
2597 function stateFor(peer) {
2598 let s = sendState.get(peer);
2599 if (!s) {
2600 s = { queue: [], busy: false, currentId: null, aborted: new Set(), pending: new Map() };
2601 sendState.set(peer, s);
2602 }
2603 return s;
2604 }
2605 function incKey(peer, id) { return peer.id + '/' + id; }
2606
2607 function attach(peer, dc) {
2608 dc.binaryType = 'arraybuffer';
2609 dc.bufferedAmountLowThreshold = LOW_WATER;
2610 dc.onopen = () => { App.log.info('files', peer.label, 'data channel open'); setInCallControlsEnabled(); };
2611 dc.onclose = () => { App.log.info('files', peer.label, 'data channel closed'); setInCallControlsEnabled(); };
2612 dc.onerror = e => App.log.error('files', peer.label, 'error', e.error ? e.error.message : e);
2613 dc.onmessage = e => onMessage(peer, e.data);
2614 }
2615
2616 const MAX_CTRL = 8 * 1024;
2617 const MAX_NAME = 1024;
2618 const MAX_MIME = 256;
2619 const MAX_FILE = 5 * 1024 * 1024 * 1024; /* 5 GB */
2620 const ID_RE = /^[A-Za-z0-9_\-]{1,16}$/;
2621 function validId(id) { return typeof id === 'string' && ID_RE.test(id); }
2622
2623 async function onMessage(peer, data) {
2624 if (typeof data === 'string') {
2625 if (data.length > MAX_CTRL) { App.log.warn('files', peer.label, 'ctrl too large'); return; }
2626 /* Tighten to byte-size so a non-ASCII file name can't bypass the cap. */
2627 if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', peer.label, 'ctrl too large (bytes)'); return; }
2628 let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', peer.label, 'bad ctl', e.message); return; }
2629 if (!m || typeof m !== 'object' || typeof m.kind !== 'string' || !validId(m.id)) return;
2630 const key = incKey(peer, m.id);
2631 if (m.kind === 'file-start') {
2632 if (incoming.has(key)) { App.log.warn('files', peer.label, 'duplicate file-start ignored', m.id); return; }
2633 const size = Number(m.size);
2634 if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', peer.label, 'invalid size', m.size); return; }
2635 const name = typeof m.name === 'string' ? m.name.slice(0, MAX_NAME) : 'file';
2636 const mime = typeof m.mime === 'string' ? m.mime.slice(0, MAX_MIME) : '';
2637 /* Park the transfer as a pending request — the sender holds its chunks
2638 until we reply file-accept/file-deny. mode/writer are filled in on
2639 accept; chunks is only used by the in-memory fallback. */
2640 incoming.set(key, { peer, id: m.id, name, size, mime, received: 0,
2641 accepted: false, mode: null, writer: null,
2642 writeChain: null, writeFailed: false, chunks: null });
2643 addIncomingRequestRow(key, name, size, peer);
2644 App.log.info('files', peer.label, 'incoming request', name, size);
2645 } else if (m.kind === 'file-accept' || m.kind === 'file-deny') {
2646 /* Sender side: the recipient answered our offer; release doSend's wait. */
2647 const p = stateFor(peer).pending.get(m.id);
2648 if (p) p(m.kind === 'file-accept' ? 'accept' : 'deny');
2649 } else if (m.kind === 'file-end') {
2650 const f = incoming.get(key);
2651 if (!f || !f.accepted) return;
2652 if (f.mode === 'disk') {
2653 try { await f.writeChain; await f.writer.close(); markIncomingSaved(key); }
2654 catch (e) { App.log.error('files', peer.label, 'finalizing failed', e.message); abortIncomingRow(key); }
2655 } else {
2656 finishIncoming(key, new Blob(f.chunks, { type: f.mime || 'application/octet-stream' }), f.name);
2657 }
2658 incoming.delete(key);
2659 App.log.info('files', peer.label, 'incoming done', f.name);
2660 /* Confirm receipt so the sender can mark this recipient delivered. */
2661 try { peer.dcFiles.send(JSON.stringify({ kind: 'file-received', id: m.id })); }
2662 catch (_) {}
2663 } else if (m.kind === 'file-received') {
2664 markOutgoingPerRecipient(m.id, peer, 'delivered');
2665 } else if (m.kind === 'file-abort') {
2666 const f = incoming.get(key);
2667 if (f) {
2668 App.log.warn('files', peer.label, 'incoming aborted', f.name);
2669 abortWriter(f);
2670 incoming.delete(key);
2671 abortIncomingRow(key);
2672 }
2673 } else if (m.kind === 'file-cancel') {
2674 /* Receiver-initiated cancel: stop sending if this id is queued, awaiting
2675 acceptance, or in-flight for THIS peer. Other peers' sends of the same
2676 id (a fan-out to multiple recipients) are unaffected. */
2677 const s = stateFor(peer);
2678 const qi = s.queue.findIndex(q => q.id === m.id);
2679 if (qi >= 0) {
2680 s.queue.splice(qi, 1);
2681 markOutgoingPerRecipient(m.id, peer, 'cancelled by peer');
2682 } else if (s.currentId === m.id) {
2683 s.aborted.add(m.id);
2684 const p = s.pending.get(m.id);
2685 if (p) p('cancelled');
2686 App.log.info('files', peer.label, 'send cancelled by peer', m.id);
2687 }
2688 }
2689 /* unknown kinds: silently ignore */
2690 } else {
2691 /* Binary: first 16 bytes ASCII id (right-padded), then payload. */
2692 const view = new Uint8Array(data);
2693 if (view.byteLength <= 16) return;
2694 const idStr = new TextDecoder().decode(view.slice(0, 16)).replace(/\0+$/, '').trim();
2695 if (!validId(idStr)) return;
2696 const payload = view.slice(16);
2697 const key = incKey(peer, idStr);
2698 const f = incoming.get(key);
2699 /* Drop chunks for unknown / not-yet-accepted transfers — a well-behaved
2700 sender never sends before file-accept. */
2701 if (!f || !f.accepted) return;
2702 /* A peer claiming size N must not be able to push more than N bytes —
2703 otherwise it can OOM the tab (memory mode) or fill the disk. Tell the
2704 sender to stop and discard whatever we've written. */
2705 if (f.received + payload.byteLength > f.size) {
2706 App.log.warn('files', peer.label, 'chunk overflows declared size, aborting', idStr);
2707 try { peer.dcFiles.send(JSON.stringify({ kind: 'file-cancel', id: f.id })); } catch (_) {}
2708 abortWriter(f);
2709 incoming.delete(key);
2710 abortIncomingRow(key);
2711 return;
2712 }
2713 f.received += payload.byteLength;
2714 if (f.mode === 'disk') {
2715 /* Stream straight to disk. Serialize writes through a promise chain so
2716 they land in order (the channel delivers in order); a single write
2717 failure cancels the whole transfer. */
2718 const writer = f.writer;
2719 f.writeChain = f.writeChain.then(() => f.writeFailed ? null : writer.write(payload)).catch(err => {
2720 if (f.writeFailed) return;
2721 f.writeFailed = true;
2722 App.log.error('files', peer.label, 'disk write failed', err.message);
2723 try { peer.dcFiles.send(JSON.stringify({ kind: 'file-cancel', id: f.id })); } catch (_) {}
2724 abortWriter(f);
2725 incoming.delete(key);
2726 abortIncomingRow(key);
2727 });
2728 } else {
2729 f.chunks.push(payload);
2730 }
2731 updateIncomingRow(key, f.received, f.size);
2732 }
2733 }
2734
2735 /* Receiver accepts an offered file: pick a destination and stream to it.
2736 Falls back to in-memory buffering + a download link where the File System
2737 Access API is unavailable (e.g. Firefox). */
2738 async function acceptIncoming(key) {
2739 const f = incoming.get(key);
2740 if (!f || f.accepted) return;
2741 if (window.showSaveFilePicker) {
2742 let handle;
2743 try {
2744 handle = await window.showSaveFilePicker({ suggestedName: f.name });
2745 } catch (e) {
2746 /* AbortError = user dismissed the picker; treat as a deny. */
2747 if (e && e.name !== 'AbortError') App.log.warn('files', f.peer.label, 'save picker failed', e.message);
2748 denyIncoming(key);
2749 return;
2750 }
2751 try {
2752 f.writer = await handle.createWritable();
2753 } catch (e) {
2754 App.log.error('files', f.peer.label, 'could not open file for writing', e.message);
2755 denyIncoming(key);
2756 return;
2757 }
2758 f.mode = 'disk';
2759 f.writeChain = Promise.resolve();
2760 } else {
2761 f.mode = 'memory';
2762 f.chunks = [];
2763 }
2764 f.accepted = true;
2765 /* Swap the request row for the normal progress row, then tell the sender. */
2766 removeIncomingRowEl(key);
2767 addIncomingRow(key, f.name, f.size, f.peer);
2768 try { f.peer.dcFiles.send(JSON.stringify({ kind: 'file-accept', id: f.id })); }
2769 catch (e) { App.log.warn('files', f.peer.label, 'file-accept send failed', e.message); }
2770 App.log.info('files', f.peer.label, 'accepted', f.name, '(' + f.mode + ')');
2771 }
2772
2773 function denyIncoming(key) {
2774 const f = incoming.get(key);
2775 if (!f) return;
2776 try { f.peer.dcFiles.send(JSON.stringify({ kind: 'file-deny', id: f.id })); }
2777 catch (_) {}
2778 abortWriter(f);
2779 incoming.delete(key);
2780 removeIncomingRowEl(key);
2781 App.log.info('files', f.peer.label, 'declined', f.name);
2782 }
2783
2784 /* Abort and discard a half-written destination file. Idempotent. */
2785 function abortWriter(rec) {
2786 if (!rec || !rec.writer || rec.aborting) return;
2787 rec.aborting = true;
2788 const w = rec.writer;
2789 Promise.resolve(rec.writeChain).catch(() => {}).then(() => w.abort().catch(() => {}));
2790 }
2791
2792 /* Fan a file out to every selected peer (or to `opts.peers` if given).
2793 One Blob is shared across the per-peer queues. */
2794 function sendFile(file, opts) {
2795 const recipients = (opts && opts.peers)
2796 || (App.ui.selectedFilesPeers ? App.ui.selectedFilesPeers() : peersWithOpenFiles());
2797 if (!recipients.length) { App.log.warn('files', 'no recipients selected'); return; }
2798 if (file.size > MAX_FILE) {
2799 App.log.warn('files', 'file too large', file.size);
2800 App.chat.appendSystem?.(`File "${file.name}" exceeds the 5 GB limit.`);
2801 return;
2802 }
2803 const id = (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)).padEnd(16, '_').slice(0, 16);
2804 addOutgoingRow(id, file.name, file.size, recipients);
2805 for (const peer of recipients) {
2806 const s = stateFor(peer);
2807 s.queue.push({ id, file });
2808 if (s.queue.length > 1 || s.busy) markRecipientQueued(id, peer);
2809 pumpQueue(peer);
2810 }
2811 }
2812
2813 async function pumpQueue(peer) {
2814 const s = stateFor(peer);
2815 if (s.busy) return;
2816 const next = s.queue.shift();
2817 if (!next) return;
2818 s.busy = true;
2819 try {
2820 await doSend(peer, next.id, next.file);
2821 } finally {
2822 s.busy = false;
2823 pumpQueue(peer);
2824 }
2825 }
2826
2827 /* Wait for the recipient's file-accept/file-deny. Resolves 'accept', 'deny',
2828 'cancelled' (we aborted / peer cancelled), or 'closed' (channel went away),
2829 so the send loop never hangs waiting on a peer who never answers. */
2830 function waitForDecision(s, dc, id) {
2831 return new Promise(resolve => {
2832 let settled = false;
2833 const finish = v => {
2834 if (settled) return;
2835 settled = true;
2836 s.pending.delete(id);
2837 dc.removeEventListener('close', onGone);
2838 dc.removeEventListener('error', onGone);
2839 resolve(v);
2840 };
2841 const onGone = () => finish('closed');
2842 if (s.aborted.has(id)) return finish('cancelled');
2843 if (dc.readyState !== 'open') return finish('closed');
2844 s.pending.set(id, finish);
2845 dc.addEventListener('close', onGone);
2846 dc.addEventListener('error', onGone);
2847 });
2848 }
2849
2850 async function doSend(peer, id, file) {
2851 const s = stateFor(peer);
2852 const dc = peer.dcFiles;
2853 if (!dc || dc.readyState !== 'open') {
2854 App.log.warn('files', peer.label, 'channel closed before send', file.name);
2855 markOutgoingPerRecipient(id, peer, 'channel closed');
2856 s.aborted.delete(id);
2857 return;
2858 }
2859 if (s.aborted.has(id)) {
2860 markOutgoingPerRecipient(id, peer, 'cancelled');
2861 s.aborted.delete(id);
2862 return;
2863 }
2864 s.currentId = id;
2865 const start = { kind: 'file-start', id, name: file.name, size: file.size, mime: file.type };
2866 dc.send(JSON.stringify(start));
2867 markOutgoingPerRecipient(id, peer, 'awaiting acceptance');
2868 App.log.info('files', peer.label, 'offered', file.name, file.size);
2869
2870 /* Hold the chunks until the recipient accepts (or declines / disconnects /
2871 we abort). The receiver opens its save target before answering. */
2872 const decision = await waitForDecision(s, dc, id);
2873 if (decision !== 'accept') {
2874 const label = decision === 'deny' ? 'declined'
2875 : decision === 'closed' ? 'channel closed' : 'cancelled';
2876 markOutgoingPerRecipient(id, peer, label);
2877 App.log.info('files', peer.label, decision === 'deny' ? 'recipient declined' : 'send ended before accept', file.name);
2878 s.aborted.delete(id);
2879 s.currentId = null;
2880 return;
2881 }
2882 markOutgoingPerRecipient(id, peer, 'sending');
2883 App.log.info('files', peer.label, 'sending', file.name, file.size);
2884
2885 const idBytes = new TextEncoder().encode(id);
2886 let offset = 0;
2887 let cancelled = false;
2888 try {
2889 while (offset < file.size) {
2890 if (s.aborted.has(id)) { cancelled = true; break; }
2891 if (dc.bufferedAmount > HIGH_WATER) {
2892 /* Wait for backpressure to clear — but also wake up if the channel
2893 closes (peer disconnected, removePeer closed the dc, browser
2894 tore it down). A closed RTCDataChannel never fires
2895 bufferedamountlow, so without the close/error listeners this
2896 promise could hang forever and freeze the per-peer queue. */
2897 await new Promise(res => {
2898 const done = () => {
2899 dc.removeEventListener('bufferedamountlow', done);
2900 dc.removeEventListener('close', done);
2901 dc.removeEventListener('error', done);
2902 res();
2903 };
2904 dc.addEventListener('bufferedamountlow', done);
2905 dc.addEventListener('close', done);
2906 dc.addEventListener('error', done);
2907 });
2908 if (dc.readyState !== 'open') {
2909 App.log.warn('files', peer.label, 'channel closed during backpressure wait');
2910 cancelled = true; break;
2911 }
2912 if (s.aborted.has(id)) { cancelled = true; break; }
2913 }
2914 const slice = await file.slice(offset, offset + CHUNK).arrayBuffer();
2915 if (s.aborted.has(id)) { cancelled = true; break; }
2916 const buf = new Uint8Array(16 + slice.byteLength);
2917 buf.set(idBytes, 0);
2918 buf.set(new Uint8Array(slice), 16);
2919 dc.send(buf.buffer);
2920 offset += slice.byteLength;
2921 updateOutgoingProgress(id, peer, offset, file.size);
2922 }
2923 if (cancelled) {
2924 try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2925 markOutgoingPerRecipient(id, peer, 'cancelled');
2926 App.log.info('files', peer.label, 'send cancelled', file.name);
2927 } else {
2928 dc.send(JSON.stringify({ kind: 'file-end', id }));
2929 markOutgoingPerRecipient(id, peer, 'sent');
2930 App.log.info('files', peer.label, 'sent', file.name);
2931 }
2932 } catch (e) {
2933 App.log.error('files', peer.label, 'send failed', e.message);
2934 try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2935 markOutgoingPerRecipient(id, peer, 'send failed');
2936 } finally {
2937 s.currentId = null;
2938 s.aborted.delete(id);
2939 }
2940 }
2941
2942 /* UI row helpers */
2943 /* Track blob URLs for incoming finished downloads so Clear can revoke them. */
2944 const incomingUrls = new Map(); /* key (peerId/id) -> objectURL string */
2945
2946 /* Outgoing row tracks per-recipient state in dataset JSON. The summary
2947 line shows aggregated progress (min %) + a count of {recipients done}. */
2948 /* perRecipient[peerId] = { state: 'queued'|'sending'|'sent'|'cancelled'|'channel closed'|'send failed', sent: number } */
2949 const outgoingMeta = new Map(); /* outId -> { name, size, perRecipient: Map<peerId, ...> } */
2950
2951 function rowEl(side, key, name, size) {
2952 const el = document.createElement('div');
2953 el.className = 'file-item' + (side === 'out' ? ' has-detail' : '');
2954 el.dataset.id = key;
2955 el.innerHTML = `<button class="row-close" type="button" title="Cancel / remove" aria-label="Cancel or remove">×</button>
2956 <div class="name"></div>
2957 <div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%) <span class="recip"></span></div>
2958 <div class="progress"><div></div></div>
2959 <div class="dl"></div>
2960 <div class="detail"></div>`;
2961 el.querySelector('.name').textContent = name;
2962 el.querySelector('.total').textContent = size.toLocaleString();
2963 el.querySelector('.row-close').addEventListener('click', e => {
2964 e.stopPropagation();
2965 removeRow(side, key);
2966 });
2967 if (side === 'out') {
2968 el.addEventListener('click', e => {
2969 if (e.target.closest('.row-close')) return;
2970 el.classList.toggle('open');
2971 });
2972 }
2973 return el;
2974 }
2975 function removeRow(side, key) {
2976 const sel = side === 'in' ? '#files-in' : '#files-out';
2977 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
2978 if (row) row.remove();
2979 if (side === 'in') {
2980 const rec = incoming.get(key);
2981 if (rec) {
2982 /* Tell the sender to stop: file-deny while still a pending request,
2983 file-cancel once we've accepted and chunks may be arriving. */
2984 const dc = rec.peer.dcFiles;
2985 if (dc && dc.readyState === 'open') {
2986 const kind = rec.accepted ? 'file-cancel' : 'file-deny';
2987 try { dc.send(JSON.stringify({ kind, id: rec.id })); } catch (_) {}
2988 }
2989 abortWriter(rec);
2990 }
2991 const url = incomingUrls.get(key);
2992 if (url) { URL.revokeObjectURL(url); incomingUrls.delete(key); }
2993 incoming.delete(key);
2994 } else {
2995 /* outgoing: cancel across every recipient that still has this id. */
2996 outgoingMeta.delete(key);
2997 for (const peer of App.state.peers.values()) {
2998 const s = stateFor(peer);
2999 const i = s.queue.findIndex(q => q.id === key);
3000 if (i >= 0) s.queue.splice(i, 1);
3001 if (s.currentId === key) {
3002 s.aborted.add(key);
3003 /* If we're parked awaiting acceptance, release that wait too. */
3004 const p = s.pending.get(key);
3005 if (p) p('cancelled');
3006 }
3007 }
3008 }
3009 }
3010 function clearAll(side) {
3011 const sel = side === 'in' ? '#files-in' : '#files-out';
3012 document.querySelectorAll(sel + ' .file-item').forEach(el => el.remove());
3013 if (side === 'in') {
3014 /* Tell each peer to stop for any pending requests / in-progress receives. */
3015 incoming.forEach((rec, _key) => {
3016 const dc = rec.peer.dcFiles;
3017 if (dc && dc.readyState === 'open') {
3018 const kind = rec.accepted ? 'file-cancel' : 'file-deny';
3019 try { dc.send(JSON.stringify({ kind, id: rec.id })); } catch (_) {}
3020 }
3021 abortWriter(rec);
3022 });
3023 incomingUrls.forEach(url => URL.revokeObjectURL(url));
3024 incomingUrls.clear();
3025 incoming.clear();
3026 } else {
3027 outgoingMeta.clear();
3028 for (const peer of App.state.peers.values()) {
3029 const s = stateFor(peer);
3030 s.queue.length = 0;
3031 if (s.currentId) s.aborted.add(s.currentId);
3032 }
3033 }
3034 }
3035 function addOutgoingRow(id, name, size, recipients) {
3036 const row = rowEl('out', id, name, size);
3037 document.getElementById('files-out').appendChild(row);
3038 const perRecipient = new Map();
3039 for (const peer of recipients) perRecipient.set(peer.id, { state: 'queued', sent: 0 });
3040 outgoingMeta.set(id, { name, size, perRecipient });
3041 refreshOutgoingRow(id);
3042 }
3043 function refreshOutgoingRow(id) {
3044 const meta = outgoingMeta.get(id);
3045 if (!meta) return;
3046 const row = document.querySelector('#files-out [data-id="' + CSS.escape(id) + '"]');
3047 if (!row) return;
3048 const recips = Array.from(meta.perRecipient.entries());
3049 const minSent = recips.reduce((m, [, r]) => Math.min(m, r.sent), Infinity);
3050 const pct = meta.size ? Math.floor((minSent / meta.size) * 100) : 0;
3051 row.querySelector('.bytes').textContent = (Number.isFinite(minSent) ? minSent : 0).toLocaleString();
3052 row.querySelector('.pct').textContent = pct;
3053 row.querySelector('.progress > div').style.width = pct + '%';
3054 const doneStates = new Set(['sent', 'delivered']);
3055 const failStates = new Set(['channel closed', 'send failed', 'cancelled', 'cancelled by peer', 'declined']);
3056 const delivered = recips.filter(([, r]) => r.state === 'delivered').length;
3057 const total = recips.length;
3058 const failed = recips.filter(([, r]) => failStates.has(r.state)).length;
3059 let recipText = ' · ' + delivered + '/' + total + ' delivered';
3060 if (failed) recipText += ', ' + failed + ' failed';
3061 row.querySelector('.recip').textContent = recipText;
3062 const allDone = recips.every(([, r]) => doneStates.has(r.state) || failStates.has(r.state));
3063 if (allDone && failed === 0) row.querySelector('.progress > div').style.background = 'var(--ok)';
3064 /* Per-recipient detail block — only visible when the user clicks the row. */
3065 const detail = row.querySelector('.detail');
3066 if (detail) {
3067 const STATE_CLASS = {
3068 'queued': 'queued', 'awaiting acceptance': 'queued', 'sending': 'sending',
3069 'sent': 'sent', 'delivered': 'delivered',
3070 'channel closed': 'closed', 'send failed': 'failed',
3071 'cancelled': 'cancelled', 'cancelled by peer': 'cancelled', 'declined': 'cancelled',
3072 };
3073 detail.innerHTML = recips.map(([peerId, r]) => {
3074 const peer = App.state.peers.get(peerId);
3075 const name = peer ? displayNameOf(peer) : ('peer ' + peerId.slice(0, 4));
3076 const sizePct = meta.size ? Math.floor((r.sent / meta.size) * 100) : 0;
3077 const stateClass = STATE_CLASS[r.state] || '';
3078 const stateText = r.state === 'sending' ? r.state + ' ' + sizePct + '%' : r.state;
3079 return `<div class="row"><span>${escapeHtml(name)}</span><span class="state ${stateClass}">${stateText}</span></div>`;
3080 }).join('');
3081 }
3082 }
3083 function updateOutgoingProgress(id, peer, sent, _size) {
3084 const meta = outgoingMeta.get(id);
3085 if (!meta) return;
3086 const r = meta.perRecipient.get(peer.id);
3087 if (!r) return;
3088 r.state = 'sending';
3089 r.sent = sent;
3090 refreshOutgoingRow(id);
3091 }
3092 function markOutgoingPerRecipient(id, peer, state) {
3093 const meta = outgoingMeta.get(id);
3094 if (!meta) return;
3095 const r = meta.perRecipient.get(peer.id);
3096 if (!r) return;
3097 r.state = state;
3098 refreshOutgoingRow(id);
3099 }
3100 function markRecipientQueued(id, peer) {
3101 const meta = outgoingMeta.get(id);
3102 if (!meta) return;
3103 const r = meta.perRecipient.get(peer.id);
3104 if (r) { r.state = 'queued'; refreshOutgoingRow(id); }
3105 }
3106 function addIncomingRow(key, name, size, peer) {
3107 const row = rowEl('in', key, name, size);
3108 row.querySelector('.recip').textContent = ' · from ' + displayNameOf(peer);
3109 document.getElementById('files-in').appendChild(row);
3110 }
3111 /* The pending-request row: name + size + Accept/Deny, shown until the user
3112 decides. Accept opens a save target and streams to it; Deny tells the
3113 sender to drop the transfer. */
3114 function addIncomingRequestRow(key, name, size, peer) {
3115 const el = document.createElement('div');
3116 el.className = 'file-item file-request';
3117 el.dataset.id = key;
3118 el.innerHTML = `<button class="row-close" type="button" title="Decline" aria-label="Decline">×</button>
3119 <div class="name"></div>
3120 <div class="meta"><span class="total"></span> B · from <span class="from"></span></div>
3121 <div class="req-actions">
3122 <button class="btn-accept" type="button">Accept</button>
3123 <button class="btn-deny" type="button">Deny</button>
3124 </div>`;
3125 el.querySelector('.name').textContent = name;
3126 el.querySelector('.total').textContent = size.toLocaleString();
3127 el.querySelector('.from').textContent = displayNameOf(peer);
3128 el.querySelector('.row-close').addEventListener('click', e => { e.stopPropagation(); denyIncoming(key); });
3129 el.querySelector('.btn-deny').addEventListener('click', () => denyIncoming(key));
3130 el.querySelector('.btn-accept').addEventListener('click', () => acceptIncoming(key));
3131 document.getElementById('files-in').appendChild(el);
3132 }
3133 function removeIncomingRowEl(key) {
3134 const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
3135 if (row) row.remove();
3136 }
3137 function markIncomingSaved(key) {
3138 const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
3139 if (!row) return;
3140 const dl = row.querySelector('.dl');
3141 if (dl) { dl.textContent = 'Saved to disk'; dl.style.color = 'var(--ok)'; }
3142 markDone('#files-in', key);
3143 }
3144 function updateIncomingRow(key, recv, size) { updateRow('#files-in', key, recv, size); }
3145 function abortIncomingRow(key) {
3146 const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
3147 if (row) row.querySelector('.dl').textContent = '(aborted)';
3148 }
3149 function finishIncoming(key, blob, name) {
3150 const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
3151 if (!row) return;
3152 const url = URL.createObjectURL(blob);
3153 incomingUrls.set(key, url);
3154 const a = document.createElement('a');
3155 a.href = url; a.download = name; a.textContent = 'Download';
3156 a.style.color = 'var(--accent)';
3157 row.querySelector('.dl').innerHTML = '';
3158 row.querySelector('.dl').appendChild(a);
3159 markDone('#files-in', key);
3160 }
3161 function updateRow(sel, key, cur, size) {
3162 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
3163 if (!row) return;
3164 const pct = size ? Math.floor((cur / size) * 100) : 0;
3165 row.querySelector('.bytes').textContent = cur.toLocaleString();
3166 row.querySelector('.pct').textContent = pct;
3167 row.querySelector('.progress > div').style.width = pct + '%';
3168 }
3169 function markDone(sel, key) {
3170 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
3171 if (!row) return;
3172 row.querySelector('.progress > div').style.background = 'var(--ok)';
3173 }
3174
3175 /* Called when a peer disconnects — mark any in-flight incoming receives
3176 from that peer as aborted, and any outgoing sends to that peer as
3177 channel-closed. The per-peer queues are abandoned along with the peer. */
3178 function cleanupForPeer(peer) {
3179 for (const [key, rec] of incoming) {
3180 if (rec.peer === peer) {
3181 abortWriter(rec);
3182 abortIncomingRow(key);
3183 incoming.delete(key);
3184 }
3185 }
3186 for (const meta of outgoingMeta.values()) {
3187 const r = meta.perRecipient.get(peer.id);
3188 if (r && r.state !== 'sent') {
3189 r.state = 'channel closed';
3190 }
3191 }
3192 /* Refresh every outgoing row's recipient summary. */
3193 for (const id of outgoingMeta.keys()) refreshOutgoingRow(id);
3194 sendState.delete(peer);
3195 }
3196
3197 return { attach, sendFile, clearAll, cleanupForPeer, MAX_FILE };
3198})();
3199
3200/* -------------------------------------------------------------------------
3201 Stats
3202------------------------------------------------------------------------- */
3203App.stats = (() => {
3204 let timer = null;
3205 let consoleTimer = null;
3206 /* id -> { bytes, ts } from the previous tick. Used to compute live bitrate
3207 as a delta — the WebRTC stats objects don't expose a current bitrate for
3208 inbound, only cumulative bytes. */
3209 const prev = new Map();
3210
3211 function fmtBytes(n) {
3212 if (n == null || isNaN(n)) return '—';
3213 if (n < 1024) return n + ' B';
3214 if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
3215 if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(2) + ' MB';
3216 return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
3217 }
3218 function fmtBps(bps) {
3219 if (bps == null || isNaN(bps)) return '—';
3220 if (bps < 1000) return Math.round(bps) + ' bps';
3221 if (bps < 1_000_000) return (bps / 1000).toFixed(1) + ' kbps';
3222 return (bps / 1_000_000).toFixed(2) + ' Mbps';
3223 }
3224 function bitrateFor(id, bytes, ts) {
3225 const p = prev.get(id);
3226 prev.set(id, { bytes, ts });
3227 if (!p || ts <= p.ts) return null;
3228 return ((bytes - p.bytes) * 8 * 1000) / (ts - p.ts);
3229 }
3230
3231 /* Peer dropdown drives which connection we poll. Falls back to the first
3232 live peer if the dropdown is empty / out of sync. */
3233 function selectedPeer() {
3234 const sel = document.getElementById('stats-peer');
3235 const id = sel && sel.value;
3236 if (id) {
3237 const peer = App.state.peers.get(id);
3238 if (peer) return peer;
3239 }
3240 return App.state.peers.values().next().value || null;
3241 }
3242 async function tick() {
3243 const peer = selectedPeer();
3244 const tbody = document.querySelector('#stats-table tbody');
3245 if (!peer || !peer.pc) {
3246 /* No peer selected: empty the table so stale values from a peer we
3247 just disconnected from don't linger. */
3248 if (tbody) tbody.innerHTML = '';
3249 prev.clear();
3250 return;
3251 }
3252 const stats = await peer.pc.getStats();
3253 if (!tbody) return;
3254 const rows = collect(peer, stats);
3255 /* Drop prev entries for stat ids that no longer appear in this report,
3256 so the cache doesn't grow unboundedly across long calls where the
3257 browser internally rotates RTP stream ids. */
3258 const liveIds = new Set();
3259 stats.forEach(r => { if (r.type === 'outbound-rtp' || r.type === 'inbound-rtp') liveIds.add(r.id); });
3260 for (const k of prev.keys()) if (!liveIds.has(k)) prev.delete(k);
3261 tbody.innerHTML = rows.map(r =>
3262 `<tr><td>${escapeHtml(r.k)}</td><td>${escapeHtml(String(r.v))}</td></tr>`).join('');
3263 }
3264
3265 function collect(peer, stats) {
3266 const out = [];
3267 /* Single pass over the report: index every record by id (for candidate /
3268 codec lookups), capture the transport's selected pair id, and collect the
3269 candidate-pair and RTP records we'll emit. Avoids re-scanning the report
3270 several times per tick. */
3271 const byId = new Map();
3272 const candidatePairs = [];
3273 const rtpRecords = [];
3274 let selected = null;
3275 stats.forEach(r => {
3276 byId.set(r.id, r);
3277 if (r.type === 'transport' && r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
3278 else if (r.type === 'candidate-pair') candidatePairs.push(r);
3279 else if ((r.type === 'outbound-rtp' || r.type === 'inbound-rtp') && !r.isRemote) rtpRecords.push(r);
3280 });
3281 let localCand = null, remoteCand = null;
3282 const pair = candidatePairs.find(r =>
3283 (r.nominated || r.selected || r.id === selected) && r.state === 'succeeded') || null;
3284 if (pair) {
3285 localCand = byId.get(pair.localCandidateId) || null;
3286 remoteCand = byId.get(pair.remoteCandidateId) || null;
3287 out.push({ k: 'rtt (ms)', v: pair.currentRoundTripTime ? Math.round(pair.currentRoundTripTime * 1000) : '—' });
3288 out.push({ k: 'bytes sent', v: fmtBytes(pair.bytesSent) });
3289 out.push({ k: 'bytes received', v: fmtBytes(pair.bytesReceived) });
3290 out.push({ k: 'available outgoing bw', v: pair.availableOutgoingBitrate ? fmtBps(pair.availableOutgoingBitrate) : '—' });
3291 if (localCand) out.push({ k: 'local candidate', v: `${localCand.candidateType} ${localCand.address || localCand.ip || ''}:${localCand.port || ''} ${localCand.protocol || ''}` });
3292 if (remoteCand) out.push({ k: 'remote candidate', v: `${remoteCand.candidateType} ${remoteCand.address || remoteCand.ip || ''}:${remoteCand.port || ''} ${remoteCand.protocol || ''}` });
3293 }
3294 /* outbound-rtp / inbound-rtp report kind='video' for both the cam and the
3295 screen-share transceivers, so we need to disambiguate by mid. */
3296 const midRole = new Map();
3297 if (peer.micTransceiver && peer.micTransceiver.mid != null) midRole.set(String(peer.micTransceiver.mid), 'audio');
3298 if (peer.camTransceiver && peer.camTransceiver.mid != null) midRole.set(String(peer.camTransceiver.mid), 'cam');
3299 if (peer.screenTransceiver && peer.screenTransceiver.mid != null) midRole.set(String(peer.screenTransceiver.mid), 'screen');
3300 const roleOf = r => midRole.get(String(r.mid)) || r.kind;
3301 /* codecId → short codec label, resolved from the byId index built above
3302 (the 'codec' records appear in arbitrary order relative to rtp records). */
3303 const codecLabel = id => {
3304 const c = byId.get(id);
3305 if (!c || !c.mimeType) return '';
3306 return ' [' + c.mimeType.split('/')[1] + ']';
3307 };
3308 rtpRecords.forEach(r => {
3309 if (r.type === 'outbound-rtp') {
3310 const role = roleOf(r);
3311 const br = bitrateFor(r.id, r.bytesSent || 0, r.timestamp);
3312 const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
3313 out.push({ k: `↑ ${role} sent${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesSent)} / ${r.packetsSent} pkts @ ${fmtBps(br)}${fps}` });
3314 if (r.targetBitrate) out.push({ k: `↑ ${role} target br`, v: fmtBps(r.targetBitrate) });
3315 } else {
3316 const role = roleOf(r);
3317 const br = bitrateFor(r.id, r.bytesReceived || 0, r.timestamp);
3318 const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
3319 const lost = r.packetsLost ?? 0;
3320 const jit = r.jitter ? r.jitter.toFixed(3) : 0;
3321 out.push({ k: `↓ ${role} recv${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesReceived)} / ${r.packetsReceived} pkts @ ${fmtBps(br)}${fps} (lost ${lost}, jitter ${jit})` });
3322 }
3323 });
3324 return out;
3325 }
3326
3327 function start() {
3328 if (timer) return;
3329 timer = setInterval(tick, 1000);
3330 tick();
3331 }
3332 function stop() { if (timer) { clearInterval(timer); timer = null; } prev.clear(); }
3333 /* Called from the peer-select onchange so the next tick treats the new
3334 peer as a fresh poll instead of inheriting stale bitrate deltas. */
3335 function refreshNow() { prev.clear(); tick(); }
3336
3337 async function exportAll() {
3338 const peer = selectedPeer();
3339 if (!peer || !peer.pc) return;
3340 const stats = await peer.pc.getStats();
3341 const arr = [];
3342 stats.forEach(r => arr.push(r));
3343 const blob = new Blob([JSON.stringify(arr, null, 2)], { type: 'application/json' });
3344 const url = URL.createObjectURL(blob);
3345 const a = document.createElement('a');
3346 a.href = url;
3347 a.download = 'webrtc-stats-' + Date.now() + '.json';
3348 document.body.appendChild(a); a.click(); a.remove();
3349 URL.revokeObjectURL(url);
3350 }
3351
3352 function toggleConsoleStats() {
3353 if (consoleTimer) {
3354 clearInterval(consoleTimer); consoleTimer = null;
3355 App.log.info('stats', 'console poll stopped');
3356 } else {
3357 consoleTimer = setInterval(async () => {
3358 const peer = selectedPeer();
3359 if (!peer || !peer.pc) return;
3360 const stats = await peer.pc.getStats();
3361 const rows = collect(peer, stats);
3362 App.log.debug('stats', peer.label, rows.map(r => r.k + '=' + r.v).join(' | '));
3363 }, 2000);
3364 App.log.info('stats', 'console poll started (2s)');
3365 }
3366 }
3367
3368 return { start, stop, exportAll, toggleConsoleStats, refreshNow };
3369})();
3370
3371function escapeHtml(s) {
3372 return s.replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]);
3373}
3374
3375/* -------------------------------------------------------------------------
3376 Per-peer tile factory. Each tile owns: a main video, a PIP video (for cam
3377 when a screen share is in the main slot), a dedicated audio element so
3378 audio plays even when the tile is in its empty state, a mic-muted badge,
3379 a leave button, a label, and a connection-state pill.
3380------------------------------------------------------------------------- */
3381App.tiles = (() => {
3382 const EMPTY_SVG = '<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-label="no video"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
3383 const MIC_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="muted"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg>';
3384
3385 /* --- Conference layout ---
3386 Pick the rows × cols decomposition that maximises tile area for the
3387 current container, with each tile constrained to a 16:9 box. Then size
3388 the grid template + each tile explicitly so the browser doesn't second-
3389 guess us. Re-runs on ResizeObserver and on every tile add/remove. */
3390 const TILE_RATIO = 16 / 9;
3391 const GAP = 8;
3392 const PAD = 12;
3393 function bestGrid(n, W, H) {
3394 let best = { rows: 1, cols: n, tileW: 0, tileH: 0, area: 0 };
3395 for (let rows = 1; rows <= n; rows++) {
3396 const cols = Math.ceil(n / rows);
3397 const cellW = (W - (cols - 1) * GAP) / cols;
3398 const cellH = (H - (rows - 1) * GAP) / rows;
3399 if (cellW <= 0 || cellH <= 0) continue;
3400 let tileW = cellW, tileH = cellW / TILE_RATIO;
3401 if (tileH > cellH) { tileH = cellH; tileW = cellH * TILE_RATIO; }
3402 const area = tileW * tileH;
3403 if (area > best.area) best = { rows, cols, tileW, tileH, area };
3404 }
3405 return best;
3406 }
3407 function relayout() {
3408 const area = document.getElementById('video-area');
3409 if (!area) return;
3410 /* Skip while something is fullscreen — re-parenting the row containers
3411 (which we do below) detaches the fullscreened <video> from the
3412 document, and browsers immediately exit fullscreen when their target
3413 element leaves the DOM. A fullscreenchange listener re-runs us when
3414 the user exits. */
3415 if (document.fullscreenElement) return;
3416 /* Gather every tile, whether it's currently a direct child of the area
3417 (newly added) or already inside a .video-row from a previous relayout. */
3418 const tiles = Array.from(area.querySelectorAll('.video-tile'));
3419 const n = tiles.length;
3420 if (!n) return;
3421 const innerW = Math.max(0, area.clientWidth - PAD * 2);
3422 const innerH = Math.max(0, area.clientHeight - PAD * 2);
3423 if (innerW === 0 || innerH === 0) return;
3424 /* The flex column adds (rows-1) gaps vertically; the rowEl adds (perRow-1)
3425 gaps horizontally. bestGrid already accounts for that. */
3426 const { rows, cols, tileW, tileH } = bestGrid(n, innerW, innerH);
3427 /* Remove any old row wrappers (but keep tile DOM identity so videos
3428 and event listeners aren't recreated). */
3429 area.querySelectorAll(':scope > .video-row').forEach(r => r.remove());
3430 for (const t of tiles) {
3431 t.style.width = tileW + 'px';
3432 t.style.height = '100%';
3433 }
3434 for (let r = 0; r < rows; r++) {
3435 const rowEl = document.createElement('div');
3436 rowEl.className = 'video-row';
3437 rowEl.style.height = tileH + 'px';
3438 const start = r * cols;
3439 const end = Math.min(n, start + cols);
3440 for (let i = start; i < end; i++) rowEl.appendChild(tiles[i]);
3441 area.appendChild(rowEl);
3442 }
3443 }
3444 let _ro = null;
3445 let _fsBound = false;
3446 function ensureObserver() {
3447 if (_ro) return;
3448 const area = document.getElementById('video-area');
3449 if (!area || typeof ResizeObserver !== 'function') return;
3450 _ro = new ResizeObserver(() => relayout());
3451 _ro.observe(area);
3452 /* Re-run layout after the user exits fullscreen — the observer fired
3453 during entry and we skipped it, so the post-exit shape needs catching
3454 up. Bound once for the page lifetime. */
3455 if (!_fsBound) {
3456 document.addEventListener('fullscreenchange', () => {
3457 if (!document.fullscreenElement) relayout();
3458 });
3459 _fsBound = true;
3460 }
3461 }
3462 function teardownObserver() {
3463 if (!_ro) return;
3464 try { _ro.disconnect(); } catch (_) {}
3465 _ro = null;
3466 }
3467
3468 function add(peer) {
3469 if (document.getElementById('tile-' + peer.id)) return;
3470 const area = document.getElementById('video-area');
3471 if (!area) return;
3472 const tile = document.createElement('div');
3473 tile.className = 'video-tile empty';
3474 tile.id = 'tile-' + peer.id;
3475 tile.innerHTML = `
3476 <video class="vid-main" autoplay playsinline></video>
3477 <audio class="audio-remote" autoplay></audio>
3478 <div class="pip hidden">
3479 <video class="vid-pip" autoplay playsinline></video>
3480 </div>
3481 <div class="empty-state">${EMPTY_SVG}</div>
3482 <div class="mic-muted hidden" title="microphone muted">${MIC_SVG}</div>
3483 <button class="leave-peer" title="Disconnect from this peer" aria-label="Disconnect from this peer">×</button>
3484 <span class="conn-pill new">connecting</span>
3485 <span class="tile-label"></span>`;
3486 area.appendChild(tile);
3487 refreshLabel(peer);
3488 refreshConnState(peer);
3489 tile.querySelector('.leave-peer').addEventListener('click', e => {
3490 e.stopPropagation();
3491 removePeer(peer, { sendBye: true });
3492 });
3493 /* Click anywhere on a tile that's showing a screen share → fullscreen the
3494 main video, matching the previous remote-tile fullscreen behaviour. */
3495 tile.addEventListener('click', e => {
3496 if (!tile.classList.contains('screen')) return;
3497 if (e.target.closest('.pip')) return;
3498 if (e.target.closest('.leave-peer')) return;
3499 const video = tile.querySelector(':scope > .vid-main');
3500 if (!video) return;
3501 if (document.fullscreenElement) {
3502 (document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
3503 .catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
3504 } else {
3505 (video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
3506 .catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
3507 }
3508 });
3509 App.media.refreshRemoteDisplayFor(peer);
3510 ensureObserver();
3511 relayout();
3512 }
3513 function remove(peer) {
3514 const tile = document.getElementById('tile-' + peer.id);
3515 if (tile) tile.remove();
3516 relayout();
3517 }
3518 function refreshLabel(peer) {
3519 const tile = document.getElementById('tile-' + peer.id);
3520 if (!tile) return;
3521 const label = tile.querySelector('.tile-label');
3522 if (label) label.textContent = displayNameOf(peer);
3523 /* Re-run displayNameOf for every peer in case the disambiguation suffix
3524 changed because of this peer's username update. */
3525 for (const other of App.state.peers.values()) {
3526 if (other === peer) continue;
3527 const t = document.getElementById('tile-' + other.id);
3528 const l = t && t.querySelector('.tile-label');
3529 if (l) l.textContent = displayNameOf(other);
3530 }
3531 }
3532 function refreshConnState(peer) {
3533 const tile = document.getElementById('tile-' + peer.id);
3534 if (!tile) return;
3535 const pill = tile.querySelector('.conn-pill');
3536 if (!pill) return;
3537 const st = (peer.pc && peer.pc.connectionState) || 'closed';
3538 pill.textContent = st;
3539 pill.className = 'conn-pill ' + st;
3540 }
3541 function refreshAll() {
3542 for (const peer of App.state.peers.values()) {
3543 if (!document.getElementById('tile-' + peer.id)) add(peer);
3544 refreshLabel(peer);
3545 refreshConnState(peer);
3546 App.media.refreshRemoteDisplayFor(peer);
3547 }
3548 ensureObserver();
3549 relayout();
3550 }
3551 return { add, remove, refreshLabel, refreshConnState, refreshAll, relayout, teardownObserver };
3552})();
3553
3554/* -------------------------------------------------------------------------
3555 Empty-call banner — shown when no peers are connected, dismissed forever
3556 (per session) on first successful join.
3557------------------------------------------------------------------------- */
3558App.banner = (() => {
3559 function refresh() {
3560 const el = document.getElementById('call-banner');
3561 if (!el) return;
3562 const noPeers = App.state.peers.size === 0;
3563 const show = noPeers && !App.state.bannerDismissed;
3564 el.classList.toggle('hidden', !show);
3565 }
3566 return { refresh };
3567})();
3568
3569/* Tear down ONE peer's connection without leaving the call view. */
3570function removePeer(peer, opts) {
3571 if (!peer) return;
3572 const sendBye = !opts || opts.sendBye !== false;
3573 if (sendBye) App.chat.sendByeTo(peer);
3574 if (peer.signalAbort) { try { peer.signalAbort.abort(); } catch (_) {} peer.signalAbort = null; }
3575 try { if (peer.dcChat) peer.dcChat.close(); } catch (_) {}
3576 try { if (peer.dcFiles) peer.dcFiles.close(); } catch (_) {}
3577 try { if (peer.pc) peer.pc.close(); } catch (_) {}
3578 /* Tear down this peer's loopback pcB, if any. */
3579 if (peer.loopbackB) {
3580 try { peer.loopbackB.close(); } catch (_) {}
3581 peer.loopbackB = null;
3582 }
3583 App.files.cleanupForPeer(peer);
3584 App.chat.notePeerClosed?.(peer);
3585 App.tiles.remove(peer);
3586 App.state.peers.delete(peer.id);
3587 if (App.state.pendingPeer === peer) App.state.pendingPeer = null;
3588 App.chat.appendSystem?.(displayNameOf(peer) + ' left');
3589 updateConnPill();
3590 App.banner.refresh();
3591 setInCallControlsEnabled();
3592 App.ui.refreshPeerWidgets?.();
3593}
3594
3595/* The remote side sent a 'bye', or its pc transitioned to a terminal state.
3596 Same path as user-initiated removePeer, but suppresses bye fan-back to
3597 avoid a round-trip on already-dead channels. */
3598function onPeerLeft(peer, reason) {
3599 if (!App.state.peers.has(peer.id)) return; /* already gone */
3600 if (peer.leaveReceived) return;
3601 peer.leaveReceived = true;
3602 App.log.info('app', peer.label, 'peer left', reason || '');
3603 removePeer(peer, { sendBye: false });
3604}
3605
3606/* -------------------------------------------------------------------------
3607 PeerConnection creation + event wiring
3608------------------------------------------------------------------------- */
3609/* Both Chromium and Firefox obfuscate the local IP of host ICE candidates by
3610 default (mDNS host-name obfuscation), which makes per-interface diagnosis
3611 of icecandidateerror impossible — the address either ends in ".local" or
3612 is empty/0.0.0.0. Detect this on the first host candidate we see and log a
3613 one-shot hint pointing at the relevant browser preference. */
3614let _obfuscationHintLogged = false;
3615function maybeWarnObfuscation(c) {
3616 if (_obfuscationHintLogged) return;
3617 if (c.type !== 'host') return;
3618 const addr = c.address || '';
3619 const obfuscated = !addr || addr.endsWith('.local') || addr === '0.0.0.0' || addr === '::';
3620 if (!obfuscated) return;
3621 _obfuscationHintLogged = true;
3622 App.log.info('pc',
3623 'host-candidate addresses are obfuscated (got "' + (addr || 'empty') + '"); ' +
3624 'icecandidateerror "from=" will not identify a real interface. To see real local IPs:\n' +
3625 ' • Firefox: about:config → media.peerconnection.ice.obfuscate_host_addresses = false\n' +
3626 ' • Chromium: chrome://flags/#enable-webrtc-hide-local-ips-with-mdns → Disabled');
3627}
3628
3629/* Create the RTCPeerConnection for one PeerCtx, wiring all the lifecycle
3630 handlers with that peer's identity baked in. */
3631function newPeerPc(peer) {
3632 const cfg = { iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 };
3633 const pc = new RTCPeerConnection(cfg);
3634 peer.pc = pc;
3635 pc.addEventListener('icegatheringstatechange', () =>
3636 App.log.debug('pc', peer.label, 'iceGatheringState', pc.iceGatheringState));
3637 pc.addEventListener('iceconnectionstatechange', () =>
3638 App.log.info('pc', peer.label, 'iceConnectionState', pc.iceConnectionState));
3639 pc.addEventListener('connectionstatechange', () => {
3640 App.log.info('pc', peer.label, 'connectionState', pc.connectionState);
3641 updateConnPill();
3642 /* Tile state may need to swap an empty placeholder for a stalled badge. */
3643 App.tiles?.refreshConnState(peer);
3644 if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
3645 /* Surface as a leave so the tile is cleaned up. The peer-initiated
3646 'bye' message gets there faster, but this is the backstop. */
3647 onPeerLeft(peer, pc.connectionState);
3648 }
3649 });
3650 pc.addEventListener('signalingstatechange', () =>
3651 App.log.debug('pc', peer.label, 'signalingState', pc.signalingState));
3652 pc.addEventListener('icecandidate', e => {
3653 if (e.candidate) maybeWarnObfuscation(e.candidate);
3654 });
3655 pc.addEventListener('icecandidateerror', e => {
3656 const local = e.address ? (e.address + ':' + (e.port || '?')) : (e.hostCandidate || '?');
3657 App.log.warn('pc', peer.label, 'iceCandidateError',
3658 e.errorCode, e.errorText || '(no text)',
3659 'server=' + (e.url || '(none)'),
3660 'from=' + local);
3661 });
3662 pc.addEventListener('negotiationneeded', () => {
3663 /* All three m-sections (mic/cam/screen) are pre-allocated in preallocate()
3664 with direction=sendrecv before the very first createOffer, so toggling
3665 a track via replaceTrack() never changes the SDP shape. Any
3666 negotiationneeded the browser fires is spurious for this app and
3667 safely ignored. */
3668 App.log.debug('pc', peer.label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
3669 });
3670 pc.addEventListener('track', e => {
3671 App.log.info('pc', peer.label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
3672 handleRemoteTrack(peer, e);
3673 });
3674 return pc;
3675}
3676
3677/* Local media state derived from the live local tracks / screen stream. */
3678function localMediaState() {
3679 return {
3680 mic: !!App.state.micTrack,
3681 cam: !!App.state.camTrack,
3682 screen: !!App.state.screenStream,
3683 };
3684}
3685/* Tell every connected peer about our current mic/cam/screen state. Sent
3686 over each chat data channel as a typed JSON message — replaceTrack(null)
3687 doesn't itself propagate any signal across the wire, so the receiver
3688 would otherwise just see frozen video on the last frame. */
3689function broadcastMediaState() {
3690 for (const peer of App.state.peers.values()) sendMediaStateTo(peer);
3691}
3692
3693/* Map a receiver-side transceiver to its role using the stored references
3694 on the PeerCtx from preallocate() / adoptTransceiversFromRemote(). Identity
3695 comparison is the only reliable cue — relying on indexOf-by-kind misroutes
3696 cam and screen when only one of them has live frames (track events fire
3697 out of order across browsers / offerer-vs-joiner roles). */
3698function roleForTransceiver(peer, t) {
3699 if (!t) return null;
3700 if (t === peer.micTransceiver) return 'mic';
3701 if (t === peer.camTransceiver) return 'cam';
3702 if (t === peer.screenTransceiver) return 'screen';
3703 return null;
3704}
3705
3706/* Track listeners we attached, so we can remove the exact references later
3707 instead of leaking anonymous arrow closures over the lifetime of the page. */
3708const remoteTrackListeners = new WeakMap(); /* track -> { unmute, ended } */
3709
3710function attachRemoteTrackListeners(peer, track, onEnded) {
3711 detachRemoteTrackListeners(track);
3712 const refresh = () => App.media.refreshRemoteDisplayFor(peer);
3713 const handlers = { unmute: refresh, ended: onEnded };
3714 track.addEventListener('unmute', refresh);
3715 track.addEventListener('ended', onEnded);
3716 remoteTrackListeners.set(track, handlers);
3717}
3718function detachRemoteTrackListeners(track) {
3719 const h = remoteTrackListeners.get(track);
3720 if (!h) return;
3721 track.removeEventListener('unmute', h.unmute);
3722 track.removeEventListener('ended', h.ended);
3723 remoteTrackListeners.delete(track);
3724}
3725
3726function rebuildRemoteStreamsFor(peer) {
3727 if (!peer || !peer.pc) return;
3728 let audio = null, cam = null, screen = null;
3729 for (const t of peer.pc.getTransceivers()) {
3730 const tr = t.receiver && t.receiver.track;
3731 if (!tr) continue;
3732 const role = roleForTransceiver(peer, t);
3733 if (role === 'mic' && !audio) audio = tr;
3734 if (role === 'cam' && !cam) cam = tr;
3735 if (role === 'screen' && !screen) screen = tr;
3736 }
3737 const remote = new MediaStream();
3738 if (audio) remote.addTrack(audio);
3739 if (cam) remote.addTrack(cam);
3740 peer.remoteStream = remote;
3741 const remoteScreen = new MediaStream();
3742 if (screen) remoteScreen.addTrack(screen);
3743 peer.remoteScreenStream = remoteScreen;
3744 if (audio) attachRemoteTrackListeners(peer, audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplayFor(peer); });
3745 if (cam) attachRemoteTrackListeners(peer, cam, () => { remote.removeTrack(cam); App.media.refreshRemoteDisplayFor(peer); });
3746 if (screen) attachRemoteTrackListeners(peer, screen, () => { remoteScreen.removeTrack(screen); App.media.refreshRemoteDisplayFor(peer); });
3747 App.log.info('pc', peer.label, 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
3748 App.media.refreshRemoteDisplayFor(peer);
3749}
3750
3751function handleRemoteTrack(peer, e) {
3752 const role = roleForTransceiver(peer, e.transceiver);
3753 App.log.info('pc', peer.label, 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
3754 if (role === 'mic' || role === 'cam') {
3755 if (!peer.remoteStream) peer.remoteStream = new MediaStream();
3756 peer.remoteStream.addTrack(e.track);
3757 attachRemoteTrackListeners(peer, e.track, () => {
3758 if (peer.remoteStream) peer.remoteStream.removeTrack(e.track);
3759 App.media.refreshRemoteDisplayFor(peer);
3760 });
3761 App.media.refreshRemoteDisplayFor(peer);
3762 } else if (role === 'screen') {
3763 if (!peer.remoteScreenStream) peer.remoteScreenStream = new MediaStream();
3764 peer.remoteScreenStream.addTrack(e.track);
3765 attachRemoteTrackListeners(peer, e.track, () => {
3766 if (peer.remoteScreenStream) peer.remoteScreenStream.removeTrack(e.track);
3767 App.media.refreshRemoteDisplayFor(peer);
3768 });
3769 App.media.refreshRemoteDisplayFor(peer);
3770 }
3771}
3772
3773/* Connection pill summarizes the worst-of state across all peers. */
3774function updateConnPill() {
3775 const pill = document.getElementById('conn-pill');
3776 if (!pill) return;
3777 const peers = peerList();
3778 if (!peers.length) { pill.textContent = 'no peers'; pill.className = 'pill'; return; }
3779 const states = peers.map(p => p.pc ? p.pc.connectionState : 'closed');
3780 const worst = states.includes('failed') ? 'failed'
3781 : states.includes('disconnected') ? 'disconnected'
3782 : states.includes('closed') ? 'closed'
3783 : states.includes('connecting') || states.includes('new') ? 'connecting'
3784 : 'connected';
3785 const label = peers.length === 1 ? worst : worst + ' (' + peers.length + ')';
3786 pill.textContent = label;
3787 pill.className = 'pill ' + (
3788 worst === 'connected' ? 'ok' :
3789 worst === 'connecting' || worst === 'new' ? 'warn' :
3790 'err'
3791 );
3792}
3793
3794/* -------------------------------------------------------------------------
3795 Signaling flows: initiator, joiner, loopback
3796
3797 Each invocation builds a fresh PeerCtx, registers it in App.state.peers,
3798 and runs the chosen handshake against that peer alone. Local media,
3799 chat/files UI, etc. are shared across all peers — only the per-peer
3800 pc/dc/transceivers/streams live on the ctx.
3801------------------------------------------------------------------------- */
3802/* Invoked when the user clicks Cancel in the step modal. Tears down the
3803 in-flight setup (closes pc, aborts any long-poll fetch). Leaves any
3804 already-connected peers alone — only the pending peer is dropped. */
3805function cancelSetup() {
3806 App.log.info('app', 'setup cancelled by user');
3807 App.state.userCancelled = true;
3808 App.progress.hide();
3809 const pending = App.state.pendingPeer;
3810 App.state.pendingPeer = null;
3811 if (pending) removePeer(pending, { sendBye: false });
3812}
3813
3814/* Create + register a PeerCtx, wire its pc, return it. */
3815function beginPeer(opts) {
3816 const peer = newPeerCtx({
3817 id: opts.id || generatePeerId(),
3818 role: opts.role,
3819 label: opts.label,
3820 username: opts.username || 'unknown',
3821 });
3822 /* Seed per-peer defaults from current settings — these are setParameters-
3823 tunable later via the per-peer settings panel. */
3824 const s = App.state.settings;
3825 peer.videoMaxBitrateKbps = s.video.maxBitrateKbps;
3826 peer.videoDegradationPreference = s.video.degradationPreference;
3827 peer.screenMaxBitrateKbps = s.screen.maxBitrateKbps;
3828 peer.screenDegradationPreference = s.screen.degradationPreference;
3829 peer.sendVideoCodec = s.sendVideoCodec || 'auto';
3830 newPeerPc(peer);
3831 App.state.peers.set(peer.id, peer);
3832 App.state.pendingPeer = peer;
3833 return peer;
3834}
3835
3836/* Promote pending → committed: invoked once the handshake reaches a point
3837 where giving up would require cooperating with the remote side. */
3838function commitPeer(peer) {
3839 if (App.state.pendingPeer === peer) App.state.pendingPeer = null;
3840 App.tiles?.add(peer);
3841 App.state.bannerDismissed = true;
3842 App.banner?.refresh();
3843 updateConnPill();
3844 App.ui.refreshPeerWidgets?.();
3845}
3846
3847async function startInitiator() {
3848 const peer = beginPeer({ role: 'initiator', label: 'init' });
3849 App.media.preallocate(peer);
3850 App.media.applyVideoCodecPreference(peer);
3851 const ac = new AbortController();
3852 peer.signalAbort = ac;
3853
3854 /* Data channels MUST be created on the initiator before createOffer
3855 so they're included in the SDP m-section list. */
3856 peer.dcChat = peer.pc.createDataChannel('chat', { ordered: true });
3857 peer.dcFiles = peer.pc.createDataChannel('files', { ordered: true });
3858 App.chat.attach(peer, peer.dcChat);
3859 App.files.attach(peer, peer.dcFiles);
3860
3861 App.progress.show('Creating offer…', 'Negotiating local SDP.');
3862 let offer = await peer.pc.createOffer();
3863 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
3864 await peer.pc.setLocalDescription(offer);
3865 App.log.info('signal', peer.label, 'offer created, waiting for ICE gathering…');
3866 App.progress.showModal('Gathering ICE candidates…',
3867 iceGatheringSubtitle(),
3868 { onCancel: cancelSetup });
3869 await App.signal.waitForIceComplete(peer.pc, ac.signal);
3870 if (ac.signal.aborted) throw new Error('cancelled');
3871 App.log.info('signal', peer.label, 'ICE gathering complete; offer ready to export');
3872
3873 App.progress.hide();
3874 renderInitiatorExchange(peer);
3875}
3876
3877async function startJoiner() {
3878 const peer = beginPeer({ role: 'joiner', label: 'join' });
3879
3880 peer.pc.ondatachannel = e => {
3881 if (!App.state.peers.has(peer.id)) { App.log.warn('signal', peer.label, 'datachannel from stale pc, ignoring'); return; }
3882 App.log.info('signal', peer.label, 'incoming data channel', e.channel.label);
3883 if (e.channel.label === 'chat') { peer.dcChat = e.channel; App.chat.attach(peer, e.channel); }
3884 if (e.channel.label === 'files') { peer.dcFiles = e.channel; App.files.attach(peer, e.channel); }
3885 };
3886
3887 renderJoinerExchange(peer);
3888}
3889
3890async function finishJoiner(peer, offerObj) {
3891 const ac = peer.signalAbort || new AbortController();
3892 peer.signalAbort = ac;
3893 App.progress.show('Applying remote offer…', 'Parsing your peer\'s SDP.');
3894 await peer.pc.setRemoteDescription(offerObj);
3895 App.media.adoptTransceiversFromRemote(peer);
3896 App.media.applyVideoCodecPreference(peer);
3897 rebuildRemoteStreamsFor(peer);
3898 await App.media.publishLocalTracksTo(peer);
3899 App.progress.show('Creating answer…', 'Negotiating local SDP.');
3900 let answer = await peer.pc.createAnswer();
3901 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
3902 await peer.pc.setLocalDescription(answer);
3903 App.progress.showModal('Gathering ICE candidates…',
3904 iceGatheringSubtitle(),
3905 { onCancel: cancelSetup });
3906 await App.signal.waitForIceComplete(peer.pc, ac.signal);
3907 if (ac.signal.aborted) throw new Error('cancelled');
3908 App.progress.hide();
3909 showAnswerForJoiner(peer);
3910}
3911
3912async function applyAnswerOnInitiator(peer, answerObj) {
3913 await peer.pc.setRemoteDescription(answerObj);
3914 rebuildRemoteStreamsFor(peer);
3915 await App.media.publishLocalTracksTo(peer);
3916 App.log.info('signal', peer.label, 'remote answer applied; waiting to connect…');
3917}
3918
3919/* -------------------------------------------------------------------------
3920 Auto signaling: HTTP relay (POST/long-poll GET) against a tiny server.
3921 See server/signal.c for the protocol. The relay never touches media or
3922 the data channels — those still flow peer-to-peer.
3923------------------------------------------------------------------------- */
3924async function signalPost(base, code, slot, body, signal) {
3925 const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
3926 const r = await fetch(url, {
3927 method: 'POST',
3928 headers: { 'Content-Type': 'application/sdp' },
3929 body,
3930 signal,
3931 });
3932 if (!r.ok) throw new Error('POST ' + slot + ' failed: ' + r.status);
3933}
3934
3935/* Long-poll a slot. The server holds the request open for ~10 s; on a 204
3936 No Content we retry. The total deadline is generous so a peer can take
3937 their time sharing the code. Aborts cleanly when hangup() is called by
3938 tracking a shared AbortController. (Status used to be 408, but Firefox
3939 silently auto-retries 408 internally per RFC 7231 §6.5.7 — JS only sees
3940 one fetch and CORS-fails after ~10 retries.) */
3941async function signalPoll(base, code, slot, totalDeadlineMs, signal) {
3942 const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
3943 const start = Date.now();
3944 while (!signal.aborted) {
3945 let r;
3946 try { r = await fetch(url, { signal }); }
3947 catch (e) {
3948 if (signal.aborted) throw e;
3949 throw new Error('GET ' + slot + ' failed: ' + e.message);
3950 }
3951 if (r.status === 200) return await r.text();
3952 if (r.status !== 204) throw new Error('GET ' + slot + ' status ' + r.status);
3953 if (Date.now() - start > totalDeadlineMs) throw new Error('peer did not respond within ' + Math.round(totalDeadlineMs/1000) + 's');
3954 /* 204 → server-side long-poll timed out; loop and reconnect. */
3955 }
3956 throw new Error('aborted');
3957}
3958
3959/* Initiator side: produce the offer the normal way, push it to the relay,
3960 then long-poll the answer slot. Skips the blob copy/paste exchange view. */
3961async function startInitiatorAuto(code) {
3962 const base = App.state.settings.signaling.serverUrl;
3963 const peer = beginPeer({ role: 'initiator', label: 'init/' + code });
3964 const ac = new AbortController();
3965 peer.signalAbort = ac;
3966
3967 App.media.preallocate(peer);
3968 App.media.applyVideoCodecPreference(peer);
3969 peer.dcChat = peer.pc.createDataChannel('chat', { ordered: true });
3970 peer.dcFiles = peer.pc.createDataChannel('files', { ordered: true });
3971 App.chat.attach(peer, peer.dcChat);
3972 App.files.attach(peer, peer.dcFiles);
3973
3974 App.progress.show('Creating offer…', 'Negotiating local SDP.');
3975 let offer = await peer.pc.createOffer();
3976 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
3977 await peer.pc.setLocalDescription(offer);
3978 App.progress.showModal('Gathering ICE candidates…',
3979 iceGatheringSubtitle(),
3980 { onCancel: cancelSetup });
3981 await App.signal.waitForIceComplete(peer.pc, ac.signal);
3982 if (ac.signal.aborted) throw new Error('cancelled');
3983
3984 const offerBlob = App.signal.encode(peer.pc.localDescription);
3985 App.progress.show('Publishing offer…', 'Room ' + code + ' on ' + base);
3986 await signalPost(base, code, 'offer', offerBlob, ac.signal);
3987
3988 App.progress.showModal('Waiting for peer…',
3989 'Share the room code with them. They have 5 minutes to join.',
3990 { roomCode: code, onCancel: cancelSetup });
3991 const answerText = await signalPoll(base, code, 'answer', 5 * 60 * 1000, ac.signal);
3992 if (ac.signal.aborted) throw new Error('cancelled');
3993 App.progress.show('Applying answer…', 'Finalizing the handshake.');
3994 const obj = App.signal.decode(answerText);
3995 if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
3996 await applyAnswerOnInitiator(peer, obj);
3997 App.progress.hide();
3998 commitPeer(peer);
3999 goToCall();
4000}
4001
4002/* Joiner side: long-poll the offer slot, apply it, push the answer back. */
4003async function startJoinerAuto(code) {
4004 const base = App.state.settings.signaling.serverUrl;
4005 const peer = beginPeer({ role: 'joiner', label: 'join/' + code });
4006 const ac = new AbortController();
4007 peer.signalAbort = ac;
4008
4009 peer.pc.ondatachannel = e => {
4010 if (!App.state.peers.has(peer.id)) { App.log.warn('signal', peer.label, 'datachannel from stale pc, ignoring'); return; }
4011 App.log.info('signal', peer.label, 'incoming data channel', e.channel.label);
4012 if (e.channel.label === 'chat') { peer.dcChat = e.channel; App.chat.attach(peer, e.channel); }
4013 if (e.channel.label === 'files') { peer.dcFiles = e.channel; App.files.attach(peer, e.channel); }
4014 };
4015
4016 App.progress.showModal('Waiting for offer…',
4017 'Polling the signaling server until your peer publishes their offer.',
4018 { roomCode: code, onCancel: cancelSetup });
4019 const offerText = await signalPoll(base, code, 'offer', 5 * 60 * 1000, ac.signal);
4020 if (ac.signal.aborted) throw new Error('cancelled');
4021 const obj = App.signal.decode(offerText);
4022 if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
4023
4024 App.progress.show('Applying offer…', 'Building answer.');
4025 await peer.pc.setRemoteDescription(obj);
4026 App.media.adoptTransceiversFromRemote(peer);
4027 App.media.applyVideoCodecPreference(peer);
4028 rebuildRemoteStreamsFor(peer);
4029 await App.media.publishLocalTracksTo(peer);
4030
4031 let answer = await peer.pc.createAnswer();
4032 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
4033 await peer.pc.setLocalDescription(answer);
4034 App.progress.showModal('Gathering ICE candidates…',
4035 iceGatheringSubtitle(),
4036 { onCancel: cancelSetup });
4037 await App.signal.waitForIceComplete(peer.pc, ac.signal);
4038 if (ac.signal.aborted) throw new Error('cancelled');
4039
4040 App.progress.show('Publishing answer…', 'Room ' + code + ' on ' + base);
4041 await signalPost(base, code, 'answer', App.signal.encode(peer.pc.localDescription), ac.signal);
4042 App.progress.hide();
4043 commitPeer(peer);
4044 goToCall();
4045}
4046
4047async function startLoopback() {
4048 /* Each loopback adds one PeerCtx (the visible side, pcA) plus its own
4049 synthetic pcB, stored on the peer. Multiple loopbacks can coexist —
4050 useful for stress-testing the conference grid. */
4051 const idx = peerList().filter(p => p.role === 'loopback').length + 1;
4052 const username = idx === 1 ? 'Loopback' : 'Loopback ' + idx;
4053 const peer = beginPeer({ role: 'loopback', label: 'loop-A/' + idx, username });
4054 const pcA = peer.pc;
4055 const pcB = new RTCPeerConnection({ iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 });
4056 peer.loopbackB = pcB;
4057
4058 /* Trickle candidates between the two local PCs. Buffer candidates that
4059 arrive before the target has its remote description set — otherwise
4060 addIceCandidate rejects with InvalidStateError. */
4061 const pendingForA = [];
4062 const pendingForB = [];
4063 let remoteSetA = false, remoteSetB = false;
4064 const flush = (pc, queue) => { while (queue.length) pc.addIceCandidate(queue.shift()).catch(err => App.log.warn('loopback', 'flush', err.message)); };
4065 pcA.onicecandidate = e => {
4066 if (!e.candidate) return;
4067 if (remoteSetB) pcB.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'B add', err.message));
4068 else pendingForB.push(e.candidate);
4069 };
4070 pcB.onicecandidate = e => {
4071 if (!e.candidate) return;
4072 if (remoteSetA) pcA.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'A add', err.message));
4073 else pendingForA.push(e.candidate);
4074 };
4075
4076 pcB.ondatachannel = e => {
4077 /* In loopback the *visible* call uses pcA's POV. pcB is the synthetic
4078 peer; echoing both data channels back to A is how A learns about
4079 its "peer's" media state (which in loopback is itself), how chat
4080 messages round-trip, and how files appear as incoming rows for
4081 verification. The file echo doubles memory while a transfer is
4082 in flight, but loopback exists for testing so that trade-off is
4083 acceptable. */
4084 e.channel.onmessage = ev => {
4085 App.log.debug('loopback', 'B got', e.channel.label, typeof ev.data === 'string' ? ev.data.slice(0, 80) : '(binary)');
4086 try { e.channel.send(ev.data); } catch (_) {}
4087 };
4088 };
4089 pcB.ontrack = e => App.log.debug('loopback', 'B got track', e.track.kind);
4090
4091 App.media.preallocate(peer);
4092 App.media.applyVideoCodecPreference(peer);
4093 peer.dcChat = pcA.createDataChannel('chat', { ordered: true });
4094 peer.dcFiles = pcA.createDataChannel('files', { ordered: true });
4095 App.chat.attach(peer, peer.dcChat);
4096 App.files.attach(peer, peer.dcFiles);
4097
4098 App.progress.show('Negotiating local loopback…', 'Exchanging SDP between the two in-tab peers.');
4099 const offer = await pcA.createOffer();
4100 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
4101 await pcA.setLocalDescription(offer);
4102 await pcB.setRemoteDescription(offer);
4103 remoteSetB = true; flush(pcB, pendingForB);
4104 /* Transceivers auto-created by setRemoteDescription default to recvonly
4105 because pcB has no local tracks yet. Force them to sendrecv so pcB can
4106 mirror tracks back to pcA when the user later toggles mic/cam/screen. */
4107 for (const t of pcB.getTransceivers()) {
4108 try { t.direction = 'sendrecv'; }
4109 catch (e) { App.log.warn('loopback', 'could not upgrade transceiver to sendrecv', e.message); }
4110 }
4111
4112 const answer = await pcB.createAnswer();
4113 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
4114 await pcB.setLocalDescription(answer);
4115 await pcA.setRemoteDescription(answer);
4116 remoteSetA = true; flush(pcA, pendingForA);
4117 rebuildRemoteStreamsFor(peer);
4118 await App.media.publishLocalTracksTo(peer);
4119 /* If mic/cam/screen were already on before the user started loopback, the
4120 mirror is empty — pcB has nothing to send back. Push the current tracks
4121 onto pcB's senders now so the synthetic peer immediately echoes them. */
4122 App.media.mirrorToLoopback();
4123 App.log.info('loopback', 'offer/answer exchanged locally');
4124 App.progress.hide();
4125 commitPeer(peer);
4126 goToCall();
4127}
4128
4129/* -------------------------------------------------------------------------
4130 UI: views + event wiring
4131------------------------------------------------------------------------- */
4132const viewToHash = {
4133 'view-welcome': 'welcome',
4134 'view-sdp-inspect': 'sdp-inspect',
4135 'view-call': 'call',
4136};
4137
4138/* The configure and exchange steps are modal <dialog>s, not routed views.
4139 These helpers keep each dialog plus its progress row state in sync with
4140 the caller. */
4141function openConfigureDialog() {
4142 const dlg = document.getElementById('view-configure');
4143 if (!dlg || typeof dlg.showModal !== 'function') return;
4144 if (dlg.open) return;
4145 document.getElementById('cfg-progress')?.classList.add('hidden');
4146 try { dlg.showModal(); } catch (_) {}
4147}
4148function closeConfigureDialog() {
4149 const dlg = document.getElementById('view-configure');
4150 if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
4151 document.getElementById('cfg-progress')?.classList.add('hidden');
4152}
4153function openExchangeDialog() {
4154 const dlg = document.getElementById('view-exchange');
4155 if (!dlg || typeof dlg.showModal !== 'function') return;
4156 if (dlg.open) return;
4157 document.getElementById('exch-progress')?.classList.add('hidden');
4158 try { dlg.showModal(); } catch (_) {}
4159}
4160function closeExchangeDialog() {
4161 const dlg = document.getElementById('view-exchange');
4162 if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
4163 document.getElementById('exch-progress')?.classList.add('hidden');
4164 /* Reset both step bodies so a future open starts clean. */
4165 document.getElementById('step-1-body')?.replaceChildren();
4166 document.getElementById('step-2-body')?.replaceChildren();
4167}
4168const hashToView = Object.fromEntries(
4169 Object.entries(viewToHash).map(([v, h]) => [h, v])
4170);
4171
4172function clearViewInputs(viewEl) {
4173 viewEl.querySelectorAll('input, textarea').forEach(el => {
4174 const t = (el.type || '').toLowerCase();
4175 if (t === 'button' || t === 'submit' || t === 'reset' || t === 'file' ||
4176 t === 'checkbox' || t === 'radio') return;
4177 el.value = '';
4178 });
4179}
4180
4181/* Reset transient UI state held outside <input>/<textarea> elements. Called
4182 only for views the user is actually leaving — not on initial page load
4183 for views that were never visible — so App-global state like the log
4184 buffer isn't wiped on every fresh start. */
4185function resetViewState(id) {
4186 if (id === 'view-sdp-inspect') {
4187 document.getElementById('sdp-inspect-out')?.replaceChildren();
4188 const status = document.getElementById('sdp-inspect-status');
4189 if (status) { status.textContent = ''; status.className = 'pill'; }
4190 } else if (id === 'view-call') {
4191 /* App.chat.clearAll empties both the chat-log DOM and the outMsgs map
4192 so detached message nodes can be GC'd. */
4193 App.chat?.clearAll?.();
4194 if (App.files && App.files.clearAll) {
4195 App.files.clearAll('out');
4196 App.files.clearAll('in');
4197 }
4198 if (App.log && App.log.clear) App.log.clear();
4199 document.getElementById('console-drawer')?.classList.add('hidden');
4200 }
4201}
4202
4203function showView(id, opts) {
4204 const wasVisible = new Set();
4205 document.querySelectorAll('.view').forEach(v => {
4206 if (!v.classList.contains('hidden')) wasVisible.add(v.id);
4207 v.classList.add('hidden');
4208 if (v.id !== id) clearViewInputs(v);
4209 });
4210 document.getElementById(id).classList.remove('hidden');
4211 wasVisible.forEach(vid => { if (vid !== id) resetViewState(vid); });
4212 if (opts && opts.push === false) return;
4213 const url = '#' + viewToHash[id];
4214 const state = { view: id };
4215 /* The call view holds a live RTCPeerConnection — we don't want a normal
4216 back/forward stop on it. Replace, so back lands where we came from. */
4217 if (id === 'view-call' || (history.state && history.state.view === id)) {
4218 history.replaceState(state, '', url);
4219 } else {
4220 history.pushState(state, '', url);
4221 }
4222}
4223
4224function currentView() {
4225 for (const id of Object.keys(viewToHash)) {
4226 const el = document.getElementById(id);
4227 if (el && !el.classList.contains('hidden')) return id;
4228 }
4229 return null;
4230}
4231
4232function onPopstate(event) {
4233 const wasOnCall = currentView() === 'view-call';
4234 const hashView = hashToView[(location.hash || '').replace(/^#/, '')];
4235 const target = (event.state && event.state.view) || hashView || 'view-welcome';
4236
4237 if (wasOnCall) {
4238 if (!confirm('Hang up and leave the call?')) {
4239 history.pushState({ view: 'view-call' }, '', '#call');
4240 return;
4241 }
4242 teardownAll();
4243 history.replaceState({ view: 'view-welcome' }, '', '#welcome');
4244 showView('view-welcome', { push: false });
4245 return;
4246 }
4247
4248 /* Call needs in-flight state (live pc) that doesn't exist when reached
4249 via back/forward — redirect to welcome. */
4250 if (target === 'view-call') {
4251 if (App.state.peers.size || App.state.pendingPeer) teardownAll();
4252 history.replaceState({ view: 'view-welcome' }, '', '#welcome');
4253 showView('view-welcome', { push: false });
4254 return;
4255 }
4256
4257 if (App.state.peers.size || App.state.pendingPeer) teardownAll();
4258 showView(target, { push: false });
4259}
4260
4261function resolveInitialView() {
4262 const hash = (location.hash || '').replace(/^#/, '');
4263 const requested = hashToView[hash];
4264 const reloadSafe = requested === 'view-welcome' || requested === 'view-sdp-inspect';
4265 const target = reloadSafe ? requested : 'view-welcome';
4266 history.replaceState({ view: target }, '', '#' + viewToHash[target]);
4267 showView(target, { push: false });
4268}
4269
4270/* Role badge in the topbar reflects the role of the in-flight (pending)
4271 peer setup, if any. Otherwise hidden. */
4272function updateRoleBadge() {
4273 const badge = document.getElementById('role-badge');
4274 const cfg = document.getElementById('role-title-cfg');
4275 const exch = document.getElementById('role-title-exch');
4276 const r = App.state.pendingPeer ? App.state.pendingPeer.role : null;
4277 if (!r) {
4278 if (badge) badge.classList.add('hidden');
4279 if (cfg) cfg.textContent = '';
4280 if (exch) exch.textContent = '';
4281 return;
4282 }
4283 if (badge) { badge.classList.remove('hidden'); badge.textContent = r; badge.className = 'role-badge ' + r; }
4284 if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
4285 if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
4286}
4287
4288/* Open the Add Participant flow for the given role. The configure/exchange
4289 views are reused (the existing layout), so the user can edit the per-add
4290 signaling mode without leaving the call. On Cancel or after the new peer
4291 joins, we return to the call view. */
4292function pickRoleForAdd(role) {
4293 /* No global pc to tear down — adding a participant just spins up a new
4294 PeerCtx. If a previous Add flow was abandoned, cancelSetup clears it. */
4295 if (App.state.pendingPeer) cancelSetup();
4296 /* Store the chosen role on a transient slot so cfg-continue knows what to
4297 do. The actual PeerCtx is created in start*() once the user clicks
4298 Continue. */
4299 App.state.nextAddRole = role;
4300 updateRoleBadge();
4301 populateConfigInputs();
4302 document.getElementById('cfg-lede').textContent =
4303 role === 'loopback' ? 'Loopback mode: both peers run in this tab and skip the paste step.'
4304 : role === 'initiator' ? 'You will generate an offer; your peer pastes it and sends back an answer.'
4305 : 'Your peer sends you an offer; you paste it and send back the generated answer.';
4306 openConfigureDialog();
4307 refreshInsecureWarning();
4308}
4309
4310function refreshInsecureWarning() {
4311 /* Browsers gate gUM/gDM behind a secure context. Surface this up-front so
4312 the user knows microphone/camera/screen-share won't be available — but
4313 the call itself still works as receive-only. */
4314 const banner = document.getElementById('insecure-warn');
4315 if (!banner) return;
4316 const noMedia = !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia;
4317 if (noMedia) banner.classList.remove('hidden');
4318 else banner.classList.add('hidden');
4319}
4320
4321/* ICE rows */
4322function renderIceRows() {
4323 const wrap = document.getElementById('ice-rows');
4324 wrap.innerHTML = '';
4325 App.state.settings.iceServers.forEach((s, i) => {
4326 const urls = Array.isArray(s.urls) ? s.urls.join(',') : (s.urls || '');
4327 const row = document.createElement('div');
4328 row.className = 'ice-row';
4329 row.innerHTML = `
4330 <input type="text" placeholder="stun:host:port or turn:host:port" value="${escapeAttr(urls)}">
4331 <input type="text" placeholder="username (optional)" value="${escapeAttr(s.username || '')}">
4332 <input type="text" placeholder="credential (optional)" value="${escapeAttr(s.credential || '')}">
4333 <button class="ghost" title="Remove"><svg class="ic" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="6" y1="18" x2="18" y2="6"/></svg></button>`;
4334 const [u, un, cr, rm] = row.children;
4335 u.addEventListener('input', () => { s.urls = u.value.includes(',') ? u.value.split(',').map(x=>x.trim()) : u.value; });
4336 un.addEventListener('input', () => { s.username = un.value || undefined; });
4337 cr.addEventListener('input', () => { s.credential = cr.value || undefined; });
4338 rm.addEventListener('click', () => { App.state.settings.iceServers.splice(i, 1); renderIceRows(); });
4339 wrap.appendChild(row);
4340 });
4341}
4342function escapeAttr(s) { return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
4343
4344function populateCodecDropdown(selId) {
4345 const sel = document.getElementById(selId);
4346 if (!sel) return;
4347 /* Remove previously added options in reverse so each removal doesn't shift
4348 the indices of options we still need to inspect. */
4349 for (let i = sel.options.length - 1; i >= 0; i--) {
4350 if (sel.options[i].value !== 'auto') sel.remove(i);
4351 }
4352 if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
4353 const caps = RTCRtpSender.getCapabilities('video');
4354 if (!caps || !caps.codecs) return;
4355 /* Some codecs (H264, AV1) appear multiple times with different profile
4356 params — dedupe by subtype. RED/ULPFEC/rtx aren't real media codecs to
4357 pick, so filter them out. */
4358 const seen = new Set();
4359 const skip = new Set(['rtx', 'red', 'ulpfec', 'flexfec-03']);
4360 for (const c of caps.codecs) {
4361 const sub = (c.mimeType || '').split('/')[1] || '';
4362 const key = sub.toLowerCase();
4363 if (!sub || skip.has(key) || seen.has(key)) continue;
4364 seen.add(key);
4365 const opt = document.createElement('option');
4366 opt.value = sub;
4367 opt.textContent = sub;
4368 sel.appendChild(opt);
4369 }
4370}
4371
4372function populateConfigInputs() {
4373 renderIceRows();
4374 populateCodecDropdown('preferred-codec');
4375 populateCodecDropdown('send-codec');
4376 const s = App.state.settings;
4377 const $ = id => document.getElementById(id);
4378 $('o-stereo').checked = s.opus.stereo;
4379 $('o-fec').checked = s.opus.fec;
4380 $('o-dtx').checked = s.opus.dtx;
4381 $('o-cbr').checked = s.opus.cbr;
4382 $('o-maxbr').value = s.opus.maxAverageBitrate || '';
4383 /* Reflect the saved preference if it still exists in the populated list,
4384 otherwise fall back to auto. Compare case-insensitively so a saved
4385 "VP8" still matches a hypothetical browser that returns "vp8" — and
4386 preserve whatever casing the current browser actually gave us. */
4387 const setSel = (id, val) => {
4388 const el = $(id);
4389 if (!el) return;
4390 const want = (val || 'auto').toLowerCase();
4391 const match = Array.from(el.options).find(o => o.value.toLowerCase() === want);
4392 el.value = match ? match.value : 'auto';
4393 };
4394 setSel('preferred-codec', s.preferredVideoCodec);
4395 setSel('send-codec', s.sendVideoCodec);
4396
4397 /* Signaling mode UI */
4398 $('sig-server-url').value = s.signaling.serverUrl || '';
4399 $('sig-ice-timeout').value = Math.round((s.signaling.iceGatherTimeoutMs || 0) / 1000);
4400 applySignalingMode(s.signaling.mode);
4401 /* Loopback doesn't use signaling at all — hide the card so the user isn't
4402 given irrelevant choices. */
4403 $('signaling-card').classList.toggle('hidden', App.state.nextAddRole === 'loopback');
4404}
4405
4406function applySignalingMode(mode) {
4407 const isAuto = mode === 'auto';
4408 App.state.settings.signaling.mode = isAuto ? 'auto' : 'manual';
4409 document.getElementById('sig-mode-manual').classList.toggle('active', !isAuto);
4410 document.getElementById('sig-mode-auto').classList.toggle('active', isAuto);
4411 document.getElementById('sig-help-manual').classList.toggle('hidden', isAuto);
4412 document.getElementById('sig-help-auto').classList.toggle('hidden', !isAuto);
4413 document.getElementById('sig-auto-fields').classList.toggle('hidden', !isAuto);
4414 /* The Continue button's wording reflects what happens next. */
4415 const cont = document.getElementById('cfg-continue');
4416 if (cont) cont.textContent = isAuto ? 'Connect →' : 'Continue to signaling →';
4417}
4418
4419function randomRoomCode() {
4420 /* Short and pronounceable-ish: 8 lowercase alphanumeric chars from a
4421 reduced alphabet that drops easily-confused glyphs. */
4422 const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
4423 const a = new Uint8Array(8);
4424 crypto.getRandomValues(a);
4425 let out = '';
4426 for (const b of a) out += alphabet[b % alphabet.length];
4427 return out;
4428}
4429
4430function readConfigInputs() {
4431 const s = App.state.settings;
4432 const $ = id => document.getElementById(id);
4433 s.opus.stereo = $('o-stereo').checked;
4434 s.opus.fec = $('o-fec').checked;
4435 s.opus.dtx = $('o-dtx').checked;
4436 s.opus.cbr = $('o-cbr').checked;
4437 s.opus.maxAverageBitrate = parseInt($('o-maxbr').value, 10) || 0;
4438 s.preferredVideoCodec = $('preferred-codec').value || 'auto';
4439 s.sendVideoCodec = $('send-codec').value || 'auto';
4440 s.signaling.serverUrl = ($('sig-server-url').value || '').trim().replace(/\/+$/, '');
4441 /* Seconds in the UI, milliseconds in state. 0 = no timeout; clamp negatives to 0. */
4442 const tSec = parseInt($('sig-ice-timeout').value, 10);
4443 s.signaling.iceGatherTimeoutMs = Number.isFinite(tSec) && tSec > 0 ? tSec * 1000 : 0;
4444 saveIce();
4445 saveSignaling();
4446}
4447
4448/* Wire the Upload button on a paste box: reads the chosen file as text into
4449 the `blob-in` textarea so the user can then click Apply. Does not auto-apply
4450 — the user still reviews and submits manually. */
4451function wireUploadButton(name) {
4452 const btn = document.getElementById('blob-upload');
4453 const file = document.getElementById('blob-upload-file');
4454 const ta = document.getElementById('blob-in');
4455 const statusEl = document.getElementById('blob-in-status');
4456 btn.addEventListener('click', () => file.click());
4457 file.addEventListener('change', async () => {
4458 const f = file.files && file.files[0];
4459 if (!f) return;
4460 try {
4461 ta.value = await f.text();
4462 statusEl.textContent = 'loaded ' + f.name; statusEl.className = 'pill ok';
4463 App.log.info('signal', name + ' loaded from ' + f.name);
4464 } catch (e) {
4465 statusEl.textContent = 'read failed: ' + e.message; statusEl.className = 'pill err';
4466 App.log.error('signal', 'file read failed', e.message);
4467 } finally {
4468 /* Reset so re-selecting the same file fires `change` again. */
4469 file.value = '';
4470 }
4471 });
4472}
4473
4474/* Render the outgoing-blob block (textarea + controls) into `body`. Owns its
4475 own copy/download/base64-toggle wiring; re-encodes from peer.pc.localDescription
4476 when the toggle flips so the visible blob always matches the setting.
4477 `extraButtons` is an array of {id,label,cls,onClick} appended after Download. */
4478function mountOutgoingBlob(body, name, peer, extraButtons) {
4479 const extras = (extraButtons || []).map(b =>
4480 `<button id="${b.id}" class="${b.cls || 'ghost'}">${b.label}</button>`).join('');
4481 body.innerHTML = `
4482 <textarea id="blob-out" readonly spellcheck="false"></textarea>
4483 <div class="blob-controls">
4484 <button id="blob-copy" class="primary">Copy</button>
4485 <button id="blob-download" class="ghost">Download</button>
4486 ${extras}
4487 <label class="row"><input type="checkbox" id="b64-toggle"> Base64-wrap <span class="small">(safer for paste channels that mangle whitespace)</span></label>
4488 <span class="pill" id="blob-out-stats"></span>
4489 </div>`;
4490
4491 const ta = body.querySelector('#blob-out');
4492 const stats = body.querySelector('#blob-out-stats');
4493 const b64 = body.querySelector('#b64-toggle');
4494 b64.checked = !!App.state.settings.base64;
4495
4496 let current = '';
4497 function refresh() {
4498 const desc = peer && peer.pc && peer.pc.localDescription;
4499 if (!desc) return;
4500 current = App.signal.encode(desc);
4501 ta.value = current;
4502 stats.textContent = current.length + ' bytes';
4503 }
4504 refresh();
4505
4506 const copyBtn = body.querySelector('#blob-copy');
4507 if (!navigator.clipboard || !navigator.clipboard.writeText) {
4508 copyBtn.disabled = true;
4509 copyBtn.title = 'Clipboard API not available in this context (requires HTTPS or localhost). Select the text above and copy manually.';
4510 } else {
4511 copyBtn.addEventListener('click', async () => {
4512 try { await navigator.clipboard.writeText(current); App.log.info('signal', name + ' copied'); }
4513 catch (e) { App.log.warn('signal', 'clipboard write failed', e.message); }
4514 });
4515 }
4516
4517 body.querySelector('#blob-download').addEventListener('click', () => {
4518 const wrapped = App.state.settings.base64;
4519 const ext = wrapped ? 'txt' : 'json';
4520 const mime = wrapped ? 'text/plain' : 'application/json';
4521 const blob = new Blob([current], { type: mime });
4522 const url = URL.createObjectURL(blob);
4523 const a = document.createElement('a');
4524 a.href = url; a.download = `webrtc-${name}.${ext}`;
4525 document.body.appendChild(a); a.click(); a.remove();
4526 URL.revokeObjectURL(url);
4527 App.log.info('signal', name + ' downloaded as ' + a.download);
4528 });
4529
4530 (extraButtons || []).forEach(b => {
4531 body.querySelector('#' + b.id).addEventListener('click', b.onClick);
4532 });
4533
4534 b64.addEventListener('change', e => {
4535 App.state.settings.base64 = e.target.checked;
4536 refresh();
4537 });
4538}
4539
4540/* Exchange views */
4541function renderInitiatorExchange(peer) {
4542 document.getElementById('step-1-h').textContent = 'Step 1: send this offer to your peer';
4543 mountOutgoingBlob(document.getElementById('step-1-body'), 'offer', peer);
4544
4545 /* The initiator needs BOTH steps visible: send the offer, then paste the
4546 answer. Make sure step-2 is shown — a prior joiner flow may have hidden
4547 it. */
4548 document.getElementById('step-2-card').classList.remove('hidden');
4549 document.getElementById('step-2-h').textContent = 'Step 2: paste your peer\'s answer';
4550 const s2 = document.getElementById('step-2-body');
4551 s2.innerHTML = `
4552 <textarea id="blob-in" spellcheck="false" placeholder="Paste answer JSON here"></textarea>
4553 <div class="blob-controls">
4554 <button id="blob-apply" class="primary">Apply answer</button>
4555 <button id="blob-upload" class="ghost">Upload…</button>
4556 <input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
4557 <span class="pill" id="blob-in-status"></span>
4558 </div>`;
4559 wireUploadButton('answer');
4560 document.getElementById('blob-apply').addEventListener('click', async () => {
4561 const text = document.getElementById('blob-in').value;
4562 const statusEl = document.getElementById('blob-in-status');
4563 try {
4564 const obj = App.signal.decode(text);
4565 if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
4566 statusEl.textContent = 'applying…'; statusEl.className = 'pill warn';
4567 await applyAnswerOnInitiator(peer, obj);
4568 statusEl.textContent = 'applied'; statusEl.className = 'pill ok';
4569 commitPeer(peer);
4570 closeExchangeDialog();
4571 goToCall();
4572 } catch (e) {
4573 statusEl.textContent = e.message; statusEl.className = 'pill err';
4574 App.log.error('signal', 'apply answer failed', e.message);
4575 }
4576 });
4577
4578 openExchangeDialog();
4579}
4580
4581function renderJoinerExchange(peer) {
4582 document.getElementById('step-1-h').textContent = 'Step 1: paste the offer from your peer';
4583 const s1 = document.getElementById('step-1-body');
4584 s1.innerHTML = `
4585 <textarea id="blob-in" spellcheck="false" placeholder="Paste offer JSON here"></textarea>
4586 <div class="blob-controls">
4587 <button id="blob-apply" class="primary">Apply offer & generate answer</button>
4588 <button id="blob-upload" class="ghost">Upload…</button>
4589 <input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
4590 <span class="pill" id="blob-in-status"></span>
4591 </div>`;
4592 document.getElementById('step-2-card').classList.add('hidden');
4593 wireUploadButton('offer');
4594
4595 document.getElementById('blob-apply').addEventListener('click', async () => {
4596 const text = document.getElementById('blob-in').value;
4597 const statusEl = document.getElementById('blob-in-status');
4598 try {
4599 const obj = App.signal.decode(text);
4600 if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
4601 statusEl.textContent = 'working…'; statusEl.className = 'pill warn';
4602 await finishJoiner(peer, obj);
4603 statusEl.textContent = 'ready'; statusEl.className = 'pill ok';
4604 } catch (e) {
4605 statusEl.textContent = e.message; statusEl.className = 'pill err';
4606 App.log.error('signal', 'apply offer failed', e.message);
4607 App.progress.hide();
4608 }
4609 });
4610
4611 openExchangeDialog();
4612}
4613
4614function showAnswerForJoiner(peer) {
4615 document.getElementById('step-2-card').classList.remove('hidden');
4616 document.getElementById('step-2-h').textContent = 'Step 2: send this answer back to your peer';
4617 mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', peer, [
4618 { id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => { commitPeer(peer); closeExchangeDialog(); goToCall(); } },
4619 ]);
4620}
4621
4622function goToCall() {
4623 showView('view-call');
4624 setInCallControlsEnabled(true);
4625 /* Pre-fill runtime settings panel from current global defaults — these
4626 apply to NEW peers; per-peer overrides will be added in a later task. */
4627 const s = App.state.settings;
4628 const $ = id => document.getElementById(id);
4629 $('rt-v-w').value = s.video.width || 0;
4630 $('rt-v-h').value = s.video.height || 0;
4631 $('rt-v-fps').value = s.video.frameRate || 0;
4632 $('rt-v-maxbr').value = s.video.maxBitrateKbps || 0;
4633 $('rt-v-degrade').value = s.video.degradationPreference || 'balanced';
4634 populateCodecDropdown('rt-codec');
4635 const wantSend = (s.sendVideoCodec || 'auto').toLowerCase();
4636 const rtCodec = $('rt-codec');
4637 const rtMatch = Array.from(rtCodec.options).find(o => o.value.toLowerCase() === wantSend);
4638 rtCodec.value = rtMatch ? rtMatch.value : 'auto';
4639 $('rt-s-w').value = s.screen.width || 0;
4640 $('rt-s-h').value = s.screen.height || 0;
4641 $('rt-s-fps').value = s.screen.frameRate || 0;
4642 $('rt-s-maxbr').value = s.screen.maxBitrateKbps || 0;
4643 $('rt-s-degrade').value = s.screen.degradationPreference || 'maintain-resolution';
4644 $('rt-a-aec').checked = s.audio.echoCancellation;
4645 $('rt-a-ns').checked = s.audio.noiseSuppression;
4646 $('rt-a-agc').checked = s.audio.autoGainControl;
4647 $('rt-a-channels').value = String(s.audio.channelCount || 1);
4648 $('rt-a-rate').value = s.audio.sampleRate || 0;
4649 $('rt-username').value = App.state.username || 'Anonymous';
4650 App.stats.start();
4651 App.tiles.refreshAll();
4652 App.banner.refresh();
4653 App.ui.refreshPeerWidgets?.();
4654 /* Refresh the displays after the view is actually visible — some browsers
4655 don't render hidden video elements properly, so re-bind srcObject. */
4656 App.media.refreshLocalDisplay();
4657 App.media.refreshRemoteDisplay();
4658 applyMediaButtonAvailability();
4659 /* Set local tile label from current username. */
4660 const localLabel = document.getElementById('tile-local-label');
4661 if (localLabel) localLabel.textContent = (App.state.username || 'Anonymous') + ' (you)';
4662}
4663
4664/* Reflect whether at least one peer with an open data channel exists.
4665 Toolbar media buttons stay enabled even with no peers (the user might
4666 enable mic/cam before adding anyone — fan-out is a no-op). Chat + files
4667 inputs are gated by their own update*() helpers, which are wired in
4668 wire() and registered on App.ui so this function can call them. */
4669function setInCallControlsEnabled() {
4670 App.ui.updateChatGate?.();
4671 App.ui.updateFilesGate?.();
4672 /* The set of "peers with open data channels" just changed, so the recipient
4673 bars need to add/remove their chips. */
4674 if (typeof renderRecipientBar === 'function') {
4675 renderRecipientBar('chat');
4676 renderRecipientBar('files');
4677 }
4678}
4679App.ui = App.ui || {};
4680
4681/* Shared recipient-selection state for chat + files. Each set holds the
4682 peerIds the user has EXCLUDED from the current selection — empty set ==
4683 "all peers". This way, peers added later are included by default. */
4684App.ui.chatExcluded = new Set();
4685App.ui.filesExcluded = new Set();
4686
4687/* Compute the active recipient list for a given channel based on the
4688 excluded set and the currently-open data channels. */
4689App.ui.selectedChatPeers = function () {
4690 return peersWithOpenChat().filter(p => !App.ui.chatExcluded.has(p.id));
4691};
4692App.ui.selectedFilesPeers = function () {
4693 return peersWithOpenFiles().filter(p => !App.ui.filesExcluded.has(p.id));
4694};
4695
4696/* Render one chip bar. `kind` is 'chat' or 'files'. */
4697function renderRecipientBar(kind) {
4698 const el = document.getElementById(kind === 'chat' ? 'chat-recipients' : 'files-recipients');
4699 if (!el) return;
4700 const excluded = kind === 'chat' ? App.ui.chatExcluded : App.ui.filesExcluded;
4701 const available = (kind === 'chat' ? peersWithOpenChat() : peersWithOpenFiles());
4702 /* Drop excluded ids that no longer correspond to a connected peer. */
4703 for (const id of Array.from(excluded)) {
4704 if (!available.find(p => p.id === id)) excluded.delete(id);
4705 }
4706 el.innerHTML = '';
4707 if (!available.length) {
4708 const span = document.createElement('span');
4709 span.className = 'empty';
4710 span.textContent = 'No connected peers yet — add one from the toolbar.';
4711 el.appendChild(span);
4712 return;
4713 }
4714 const label = document.createElement('span');
4715 label.className = 'label';
4716 label.textContent = 'To:';
4717 el.appendChild(label);
4718 for (const peer of available) {
4719 const chip = document.createElement('span');
4720 const on = !excluded.has(peer.id);
4721 chip.className = 'chip' + (on ? ' on' : '');
4722 chip.textContent = displayNameOf(peer);
4723 chip.title = on ? 'Click to exclude' : 'Click to include';
4724 chip.addEventListener('click', () => {
4725 if (excluded.has(peer.id)) excluded.delete(peer.id);
4726 else excluded.add(peer.id);
4727 renderRecipientBar(kind);
4728 if (kind === 'chat') App.ui.updateChatGate?.();
4729 if (kind === 'files') App.ui.updateFilesGate?.();
4730 });
4731 el.appendChild(chip);
4732 }
4733}
4734
4735/* Refresh every peer-aware widget. Called whenever peers join/leave or
4736 their display names change. */
4737App.ui.refreshPeerWidgets = function () {
4738 renderRecipientBar('chat');
4739 renderRecipientBar('files');
4740 refreshSettingsTargetDropdown();
4741 refreshStatsPeerDropdown();
4742};
4743
4744/* Settings target dropdown — keeps options for {defaults, all, ...peers}. */
4745function refreshSettingsTargetDropdown() {
4746 const sel = document.getElementById('settings-target');
4747 if (!sel) return;
4748 const cur = sel.value || 'all';
4749 const peers = peerList();
4750 const ids = new Set(['all', ...peers.map(p => p.id)]);
4751 /* Remove peer options that no longer exist. */
4752 Array.from(sel.options).forEach(o => {
4753 if (!ids.has(o.value)) sel.removeChild(o);
4754 });
4755 /* Ensure each peer has an option (preserve existing order). */
4756 for (const peer of peers) {
4757 if (!sel.querySelector('option[value="' + CSS.escape(peer.id) + '"]')) {
4758 const o = document.createElement('option');
4759 o.value = peer.id;
4760 o.textContent = displayNameOf(peer);
4761 sel.appendChild(o);
4762 } else {
4763 const o = sel.querySelector('option[value="' + CSS.escape(peer.id) + '"]');
4764 o.textContent = displayNameOf(peer);
4765 }
4766 }
4767 const fellBack = !(peers.find(p => p.id === cur) || cur === 'all');
4768 sel.value = fellBack ? 'all' : cur;
4769 /* Only reload the form if the effective target changed — otherwise we'd
4770 overwrite values the user is in the middle of editing. */
4771 if (fellBack && typeof App.ui.loadSettingsForTarget === 'function') {
4772 App.ui.loadSettingsForTarget();
4773 }
4774}
4775
4776function refreshStatsPeerDropdown() {
4777 const sel = document.getElementById('stats-peer');
4778 if (!sel) return;
4779 const cur = sel.value;
4780 const peers = peerList();
4781 sel.innerHTML = '';
4782 if (!peers.length) {
4783 const o = document.createElement('option');
4784 o.value = ''; o.textContent = '(no peers)';
4785 sel.appendChild(o);
4786 App.stats?.refreshNow?.();
4787 return;
4788 }
4789 for (const peer of peers) {
4790 const o = document.createElement('option');
4791 o.value = peer.id;
4792 o.textContent = displayNameOf(peer);
4793 sel.appendChild(o);
4794 }
4795 if (peers.find(p => p.id === cur)) sel.value = cur;
4796 else sel.value = peers[0].id;
4797 App.stats?.refreshNow?.();
4798}
4799
4800/* Disable mic/cam/screen toolbar buttons when they can't possibly succeed —
4801 either because the page isn't a secure context (gUM/gDM unavailable) or
4802 because no matching hardware is connected. Re-runs on devicechange so
4803 plugging in a webcam mid-call re-enables the button. */
4804let _deviceChangeBound = false;
4805async function applyMediaButtonAvailability() {
4806 const hasGum = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
4807 const hasGdm = !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
4808 const insecureTip = 'Unavailable in this context — requires HTTPS or localhost.';
4809 const micBtn = document.getElementById('tb-mic');
4810 const camBtn = document.getElementById('tb-cam');
4811 const screenBtn = document.getElementById('tb-screen');
4812 const micPick = document.getElementById('tb-mic-pick');
4813 const camPick = document.getElementById('tb-cam-pick');
4814
4815 if (!hasGdm) { screenBtn.disabled = true; screenBtn.title = insecureTip; }
4816 if (!hasGum) {
4817 micBtn.disabled = true; micBtn.title = insecureTip;
4818 camBtn.disabled = true; camBtn.title = insecureTip;
4819 if (micPick) micPick.disabled = true;
4820 if (camPick) camPick.disabled = true;
4821 return;
4822 }
4823
4824 /* Bind the devicechange listener once. Counts of audioinput/videoinput
4825 entries reflect presence even before permission is granted (the entries
4826 have empty labels but still exist), so this works on first call too. */
4827 if (!_deviceChangeBound && navigator.mediaDevices.addEventListener) {
4828 navigator.mediaDevices.addEventListener('devicechange', applyMediaButtonAvailability);
4829 _deviceChangeBound = true;
4830 }
4831
4832 let devs = [];
4833 try {
4834 devs = await navigator.mediaDevices.enumerateDevices();
4835 } catch (e) {
4836 App.log.warn('media', 'enumerateDevices failed', e.message);
4837 /* Fall through with permissive defaults — gUM may still work. */
4838 }
4839 const audioInputs = devs.filter(d => d.kind === 'audioinput');
4840 const videoInputs = devs.filter(d => d.kind === 'videoinput');
4841 const hasMic = devs.length === 0 || audioInputs.length > 0;
4842 const hasCam = devs.length === 0 || videoInputs.length > 0;
4843
4844 /* If the device disappears while in use, don't yank the button out from
4845 under the user — leave it clickable so they can turn the active track
4846 off. The track-end handler will reset the button when the OS releases
4847 the device. */
4848 const micOn = micBtn.classList.contains('on');
4849 const camOn = camBtn.classList.contains('on');
4850 micBtn.disabled = !hasMic && !micOn;
4851 micBtn.title = hasMic ? 'Enable microphone' : 'No microphone detected';
4852 camBtn.disabled = !hasCam && !camOn;
4853 camBtn.title = hasCam ? 'Enable camera' : 'No camera detected';
4854
4855 /* Device labels are only populated after the user has granted permission for
4856 that media kind. With blank labels the picker would just list anonymous
4857 "Microphone 1 / 2", which the user can't meaningfully choose between —
4858 gate the chevron until the input has been enabled at least once. */
4859 const micLabelled = audioInputs.some(d => d.label);
4860 const camLabelled = videoInputs.some(d => d.label);
4861 if (micPick) {
4862 micPick.disabled = !hasMic || !micLabelled;
4863 micPick.title = micPick.disabled
4864 ? 'Enable microphone first to choose a device'
4865 : 'Choose microphone';
4866 }
4867 if (camPick) {
4868 camPick.disabled = !hasCam || !camLabelled;
4869 camPick.title = camPick.disabled
4870 ? 'Enable camera first to choose a device'
4871 : 'Choose camera';
4872 }
4873
4874 renderDeviceMenus(devs);
4875}
4876
4877/* Rebuild the mic and camera popover lists from an enumerateDevices() snapshot.
4878 Devices without labels (no permission yet) show as "Microphone 1", etc., so
4879 the user can still see *how many* devices exist before granting permission.
4880 The "currently live" indicator is derived from App.state.micTrack/camTrack
4881 (the local capture) rather than any per-peer sender. */
4882function renderDeviceMenus(devs) {
4883 renderOneDeviceMenu('tb-mic-menu', 'mic', devs.filter(d => d.kind === 'audioinput'),
4884 App.state.settings.audio.deviceId,
4885 App.state.micTrack);
4886 renderOneDeviceMenu('tb-cam-menu', 'cam', devs.filter(d => d.kind === 'videoinput'),
4887 App.state.settings.video.deviceId,
4888 App.state.camTrack);
4889}
4890
4891function renderOneDeviceMenu(menuId, kind, devs, savedId, liveTrack) {
4892 const menu = document.getElementById(menuId);
4893 if (!menu) return;
4894 /* Some webcams (notably HP combo cameras) expose the RGB and IR sensors as
4895 two enumerateDevices entries with the *same* deviceId. gUM can't tell
4896 them apart with {exact: deviceId}, so listing both rows would let the
4897 user click a "different" device that's actually the same one. Dedup by
4898 deviceId, keeping the first label we saw. */
4899 const seenIds = new Set();
4900 devs = devs.filter(d => {
4901 if (!d.deviceId) return true; /* pre-permission entries — keep them */
4902 if (seenIds.has(d.deviceId)) return false;
4903 seenIds.add(d.deviceId);
4904 return true;
4905 });
4906 /* "active" = the device currently producing the live stream. If we passed
4907 {exact: savedId} to gUM and it succeeded, savedId IS what's live — trust
4908 that over getSettings().deviceId, which some webcams misreport. Only
4909 fall back to getSettings() when no preference is saved. */
4910 let activeId = '';
4911 if (liveTrack) {
4912 activeId = savedId
4913 || (liveTrack.getSettings ? liveTrack.getSettings().deviceId : '')
4914 || '';
4915 }
4916 const kindLabel = kind === 'mic' ? 'Microphone' : 'Camera';
4917 const rows = [];
4918
4919 const activeDot = '<svg class="ic" width="8" height="8" viewBox="0 0 8 8" aria-hidden="true"><circle cx="4" cy="4" r="3" fill="currentColor"/></svg>';
4920 const sysActive = !savedId && activeId ? ` <span class="device-active">${activeDot} active</span>` : '';
4921 rows.push(`<label class="device-row" role="menuitemradio">
4922 <input type="radio" name="dev-${kind}" value="" ${savedId ? '' : 'checked'}>
4923 <span class="device-label">System default</span>${sysActive}
4924 </label>`);
4925
4926 if (devs.length === 0) {
4927 rows.push(`<div class="device-empty">No ${kindLabel.toLowerCase()}s detected.</div>`);
4928 } else {
4929 devs.forEach((d, i) => {
4930 const label = d.label || `${kindLabel} ${i + 1}`;
4931 const checked = d.deviceId === savedId ? 'checked' : '';
4932 const isActive = d.deviceId === activeId
4933 ? ` <span class="device-active">${activeDot} active</span>`
4934 : '';
4935 rows.push(`<label class="device-row" role="menuitemradio">
4936 <input type="radio" name="dev-${kind}" value="${escapeAttr(d.deviceId)}" ${checked}>
4937 <span class="device-label">${escapeHtml(label)}</span>${isActive}
4938 </label>`);
4939 });
4940 if (!devs[0].label) {
4941 rows.push(`<div class="device-empty">Enable ${kindLabel.toLowerCase()} to see device names.</div>`);
4942 }
4943 }
4944
4945 menu.innerHTML = rows.join('');
4946}
4947
4948/* Re-renders the picker lists with the current saved/active state. Used by
4949 the picker change handler so the radio + "active" marker reflect the new
4950 selection immediately, without waiting for the next devicechange event. */
4951function refreshDeviceMenus() {
4952 navigator.mediaDevices.enumerateDevices().then(renderDeviceMenus).catch(() => {});
4953}
4954
4955function setupDevicePickers() {
4956 setupOnePicker('mic');
4957 setupOnePicker('cam');
4958
4959 /* Close any open menu on outside click or Escape. */
4960 document.addEventListener('click', (e) => {
4961 document.querySelectorAll('.device-picker').forEach(group => {
4962 if (!group.contains(e.target)) closeMenuInGroup(group);
4963 });
4964 });
4965 document.addEventListener('keydown', (e) => {
4966 if (e.key === 'Escape') {
4967 document.querySelectorAll('.device-menu:not(.hidden)').forEach(m => {
4968 const chev = m.parentElement.querySelector('.device-chevron');
4969 m.classList.add('hidden');
4970 if (chev) chev.setAttribute('aria-expanded', 'false');
4971 });
4972 }
4973 });
4974}
4975
4976function closeMenuInGroup(group) {
4977 const menu = group.querySelector('.device-menu');
4978 const chev = group.querySelector('.device-chevron');
4979 if (menu) menu.classList.add('hidden');
4980 if (chev) chev.setAttribute('aria-expanded', 'false');
4981}
4982
4983function setupOnePicker(kind) {
4984 const chev = document.getElementById(`tb-${kind}-pick`);
4985 const menu = document.getElementById(`tb-${kind}-menu`);
4986 if (!chev || !menu) return;
4987
4988 chev.addEventListener('click', (e) => {
4989 e.stopPropagation();
4990 /* Close any other open menus first. */
4991 document.querySelectorAll('.device-picker').forEach(g => {
4992 if (!g.contains(chev)) closeMenuInGroup(g);
4993 });
4994 const opening = menu.classList.contains('hidden');
4995 menu.classList.toggle('hidden');
4996 chev.setAttribute('aria-expanded', opening ? 'true' : 'false');
4997 if (opening) {
4998 /* Re-enumerate on open so labels are fresh after a permission grant. */
4999 refreshDeviceMenus();
5000 }
5001 });
5002
5003 menu.addEventListener('change', async (e) => {
5004 const input = e.target.closest('input[type="radio"]');
5005 if (!input) return;
5006 const settings = kind === 'mic' ? App.state.settings.audio : App.state.settings.video;
5007 const newId = input.value || '';
5008 menu.classList.add('hidden');
5009 chev.setAttribute('aria-expanded', 'false');
5010
5011 const btn = document.getElementById(`tb-${kind}`);
5012 if (!btn.classList.contains('on')) {
5013 /* Input is off — just persist the choice for the next time the user
5014 turns it on. No gUM call. */
5015 settings.deviceId = newId;
5016 refreshDeviceMenus();
5017 return;
5018 }
5019 /* Input is live — swap the track in place. On failure, leave the saved
5020 preference unchanged so the menu reverts to the previously-working
5021 selection on next render. */
5022 const got = await App.media.switchInputDevice(kind, newId);
5023 if (got !== null) settings.deviceId = newId;
5024 refreshDeviceMenus();
5025 });
5026}
5027
5028
5029/* -------------------------------------------------------------------------
5030 Console drawer rendering
5031------------------------------------------------------------------------- */
5032function setupConsole() {
5033 const body = document.getElementById('console-body');
5034 const countEl = document.getElementById('console-count');
5035 const levelSel = document.getElementById('console-level');
5036 const filterEl = document.getElementById('console-filter');
5037 const drawer = document.getElementById('console-drawer');
5038 const toggle = document.getElementById('console-toggle');
5039
5040 const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
5041 function shouldShow(e) {
5042 if (!e) return false;
5043 if (ORDER[e.level] < ORDER[levelSel.value]) return false;
5044 const q = filterEl.value.toLowerCase();
5045 if (q && !(e.label.toLowerCase().includes(q) || e.args.some(a => String(a).toLowerCase().includes(q)))) return false;
5046 return true;
5047 }
5048 function fmt(args) {
5049 return args.map(a => {
5050 if (a == null) return String(a);
5051 if (typeof a === 'string') return a;
5052 try { return JSON.stringify(a); } catch (_) { return String(a); }
5053 }).join(' ');
5054 }
5055 function append(entry) {
5056 if (!entry) { body.innerHTML = ''; countEl.textContent = '0 entries'; return; }
5057 if (!shouldShow(entry)) { countEl.textContent = App.log.snapshot().length + ' entries'; return; }
5058 const div = document.createElement('div');
5059 div.className = 'log-line ' + entry.level;
5060 const t = new Date(entry.ts);
5061 const ts = t.toTimeString().slice(0, 8) + '.' + String(t.getMilliseconds()).padStart(3, '0');
5062 div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level}</span><span class="label">${escapeHtml(entry.label)}</span><span class="text"></span>`;
5063 div.querySelector('.text').textContent = fmt(entry.args);
5064 const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 20;
5065 body.appendChild(div);
5066 if (wasAtBottom) body.scrollTop = body.scrollHeight;
5067 countEl.textContent = App.log.snapshot().length + ' entries';
5068 }
5069 function rerender() {
5070 body.innerHTML = '';
5071 App.log.snapshot().forEach(append);
5072 }
5073
5074 App.log.subscribe(append);
5075 levelSel.addEventListener('change', rerender);
5076 filterEl.addEventListener('input', rerender);
5077 toggle.addEventListener('click', () => drawer.classList.toggle('hidden'));
5078 document.getElementById('console-close').addEventListener('click', () => drawer.classList.add('hidden'));
5079 document.getElementById('console-clear').addEventListener('click', () => App.log.clear());
5080 document.getElementById('console-export').addEventListener('click', () => {
5081 const blob = new Blob([JSON.stringify(App.log.snapshot(), null, 2)], { type: 'application/json' });
5082 const url = URL.createObjectURL(blob);
5083 const a = document.createElement('a');
5084 a.href = url;
5085 a.download = 'webrtc-log-' + Date.now() + '.json';
5086 document.body.appendChild(a); a.click(); a.remove();
5087 URL.revokeObjectURL(url);
5088 });
5089 document.getElementById('console-stats-toggle').addEventListener('click', () => App.stats.toggleConsoleStats());
5090
5091 /* Keyboard shortcut */
5092 document.addEventListener('keydown', e => {
5093 if ((e.ctrlKey || e.metaKey) && e.key === '`') {
5094 e.preventDefault(); drawer.classList.toggle('hidden');
5095 }
5096 });
5097}
5098
5099/* -------------------------------------------------------------------------
5100 SDP inspector: parse an offer/answer and render it as labelled sections
5101 with one-line explanations for the common attributes.
5102
5103 Input accepted: raw SDP (starts with "v="), the {type,sdp} JSON this tool
5104 emits, or its "b64:"-prefixed wrapped form. The renderer never uses
5105 innerHTML with user content — everything goes through textContent — so
5106 pasted SDP can't smuggle markup into the page.
5107------------------------------------------------------------------------- */
5108App.sdpInspect = (() => {
5109 /* Short explanations for the SDP attributes we render. Missing entries
5110 just render without help text. */
5111 const ATTR_HELP = {
5112 'group': 'Groups m= sections into one transport. "BUNDLE 0 1 2" multiplexes those mids onto a single ICE/DTLS connection.',
5113 'msid-semantic': 'Declares the meaning of msid values (WMS = WebRTC Media Stream).',
5114 'ice-ufrag': 'ICE username fragment — half of the STUN binding-request credentials.',
5115 'ice-pwd': 'ICE password — the other half of the ICE credentials. Treat as a short-lived secret.',
5116 'ice-options': 'ICE feature flags (e.g. "trickle" = candidates may arrive after the SDP).',
5117 'fingerprint': 'DTLS certificate fingerprint. The peer authenticates the cert against this value.',
5118 'setup': 'DTLS role: active (client), passive (server), or actpass (will negotiate during the handshake).',
5119 'mid': 'Media identifier for this m= section; referenced by BUNDLE and by the "mid" RTP header extension.',
5120 'extmap': 'RTP header extension: numeric id → URI describing what the extension carries.',
5121 'rtcp-mux': 'RTP and RTCP share one UDP port. Always present in WebRTC.',
5122 'rtcp-rsize': 'Allows reduced-size RTCP packets.',
5123 'rtcp': 'Explicit RTCP port (legacy; ignored when rtcp-mux is set).',
5124 'sendrecv': 'This side will both send and receive media on this m=.',
5125 'sendonly': 'This side will only send media on this m=.',
5126 'recvonly': 'This side will only receive media on this m=.',
5127 'inactive': 'Negotiated but neither side will send/receive media on this m=.',
5128 'rtpmap': 'Maps an RTP payload type to a codec/clock-rate/channels triple.',
5129 'fmtp': 'Per-payload-type format parameters — encoder hints (e.g. opus useinbandfec=1).',
5130 'rtcp-fb': 'RTCP feedback messages this payload type supports (nack, pli, transport-cc, …).',
5131 'candidate': 'An ICE candidate — one possible source/destination address pair for media.',
5132 'end-of-candidates':'No more candidates will be trickled.',
5133 'msid': 'Binds this m= to a MediaStream id and Track id used by the JS API.',
5134 'ssrc': 'Synchronization source ID for an RTP stream, plus metadata (cname, msid, …).',
5135 'ssrc-group': 'Groups SSRCs (FID = RTX retransmission pair; SIM = simulcast layers).',
5136 'rid': 'Restriction identifier for one simulcast layer.',
5137 'simulcast': 'Declares simulcast layer ids and directions.',
5138 'maxptime': 'Maximum packetization time (ms) the receiver will accept.',
5139 'ptime': 'Preferred packetization time (ms).',
5140 'extmap-allow-mixed':'Receiver accepts RTP packets that mix one-byte and two-byte header extensions in the same packet (RFC 8285).',
5141 'rtcp': 'Explicit RTCP address/port. Legacy — ignored when rtcp-mux is in effect (RFC 3605).',
5142 'bundle-only': 'This m= section is only usable when bundled via the BUNDLE group; port is 0 if not selected (RFC 8843).',
5143 'sctp-port': 'SCTP port for the data channel association. WebRTC always uses 5000 (RFC 8841).',
5144 'max-message-size': 'Maximum SCTP user message size (bytes) the receiver will accept (RFC 8841).',
5145 };
5146
5147 const CANDIDATE_TYPE_HELP = {
5148 host: 'Local interface address on this machine (LAN or loopback).',
5149 srflx: 'Server-reflexive: public address as seen by a STUN server (post-NAT).',
5150 prflx: 'Peer-reflexive: address discovered during connectivity checks.',
5151 relay: 'TURN relay; media flows through the TURN server.',
5152 };
5153
5154 /* Accept raw SDP, JSON wrappers, or b64-prefixed JSON. Throws on garbage. */
5155 function extractSdp(text) {
5156 text = (text || '').trim();
5157 if (!text) throw new Error('empty input');
5158 if (text.startsWith('b64:')) {
5159 try {
5160 const bin = atob(text.slice(4));
5161 const bytes = new Uint8Array(bin.length);
5162 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
5163 text = new TextDecoder().decode(bytes);
5164 } catch (e) { throw new Error('bad base64: ' + e.message); }
5165 }
5166 if (text.startsWith('{')) {
5167 let obj;
5168 try { obj = JSON.parse(text); } catch (e) { throw new Error('not valid JSON: ' + e.message); }
5169 if (!obj || typeof obj.sdp !== 'string') throw new Error('JSON has no "sdp" string field');
5170 if (!obj.sdp.includes('v=')) throw new Error('"sdp" field is not SDP (missing v=)');
5171 return { type: obj.type || '(unknown)', sdp: obj.sdp };
5172 }
5173 if (/^v=/m.test(text)) return { type: '(raw)', sdp: text };
5174 throw new Error('unrecognized input — expected SDP, JSON, or b64:…');
5175 }
5176
5177 /* Tokenize SDP into a session + one block per m= section. */
5178 function parseSdp(sdp) {
5179 const session = { kind: 'session', lines: [], attrs: [], media: [] };
5180 let cur = session;
5181 for (const raw of sdp.split(/\r?\n/)) {
5182 const line = raw.replace(/\r$/, '');
5183 if (!line) continue;
5184 const m = line.match(/^([a-z])=(.*)$/);
5185 if (!m) continue;
5186 const key = m[1], val = m[2];
5187 if (key === 'm') {
5188 const parts = val.split(/\s+/);
5189 const media = {
5190 kind: 'media', type: parts[0] || '?', port: parts[1] || '?', proto: parts[2] || '?',
5191 payloadTypes: parts.slice(3), lines: [], attrs: [],
5192 };
5193 session.media.push(media);
5194 cur = media;
5195 cur.lines.push({ key, val });
5196 continue;
5197 }
5198 cur.lines.push({ key, val });
5199 if (key === 'a') {
5200 const colon = val.indexOf(':');
5201 cur.attrs.push({
5202 name: colon === -1 ? val : val.slice(0, colon),
5203 value: colon === -1 ? '' : val.slice(colon + 1),
5204 });
5205 }
5206 }
5207 return session;
5208 }
5209
5210 /* DOM helpers — everything textContent, no innerHTML on user input. */
5211 function el(tag, cls, text) {
5212 const e = document.createElement(tag);
5213 if (cls) e.className = cls;
5214 if (text != null) e.textContent = text;
5215 return e;
5216 }
5217 function kv(grid, k, v, help) {
5218 grid.appendChild(el('span', 'k', k));
5219 const vEl = el('span', 'v', v == null ? '' : String(v));
5220 if (help) vEl.title = help;
5221 grid.appendChild(vEl);
5222 }
5223 function section(title, sub) {
5224 const wrap = el('div', 'sdp-section');
5225 const head = el('div', 'sdp-head');
5226 head.appendChild(el('h3', null, title));
5227 if (sub) head.appendChild(el('span', 'sdp-sub', sub));
5228 wrap.appendChild(head);
5229 return wrap;
5230 }
5231 function helpLine(text) { return el('p', 'sdp-help', text); }
5232 function rawBlock(label, lines) {
5233 const d = el('details', 'sdp-rawblock');
5234 d.appendChild(el('summary', null, label));
5235 d.appendChild(el('pre', null, lines.map(l => l.key + '=' + l.val).join('\n')));
5236 return d;
5237 }
5238
5239 /* Render an a=candidate:... value into a structured single-line list item.
5240 RFC 5245 layout: <foundation> <component> <transport> <priority>
5241 <addr> <port> typ <type> [raddr <addr> rport <port>]
5242 [generation N] [tcptype …] [ufrag …] */
5243 function renderCandidate(value) {
5244 const t = value.split(/\s+/);
5245 const li = el('li');
5246 const out = [];
5247 out.push({ text: t[2] || '?', cls: 'tag', help: 'transport (udp/tcp)' });
5248 out.push({ text: (t[4] || '?') + ':' + (t[5] || '?'), help: 'local address & port (often obfuscated by the browser)' });
5249 const typeIdx = t.indexOf('typ');
5250 const type = typeIdx >= 0 ? t[typeIdx + 1] : '?';
5251 out.push({ text: type, cls: 'badge', help: CANDIDATE_TYPE_HELP[type] || 'ICE candidate type' });
5252 const raddrIdx = t.indexOf('raddr');
5253 if (raddrIdx >= 0) {
5254 const rportIdx = t.indexOf('rport');
5255 out.push({ text: 'related ' + t[raddrIdx + 1] + ':' + (rportIdx >= 0 ? t[rportIdx + 1] : '?'),
5256 cls: 'dim', help: 'related (base) address — for srflx/relay, the host address behind it' });
5257 }
5258 out.push({ text: 'prio ' + (t[3] || '?'), cls: 'dim', help: 'priority — higher wins during pair selection' });
5259 out.push({ text: 'foundation ' + (t[0] || '?'), cls: 'dim',
5260 help: 'foundation — candidates with the same foundation share a local interface/server pair' });
5261
5262 out.forEach((p, i) => {
5263 const span = el('span', p.cls || null, p.text);
5264 if (p.help) span.title = p.help;
5265 li.appendChild(span);
5266 if (i < out.length - 1) li.appendChild(document.createTextNode(' '));
5267 });
5268 return li;
5269 }
5270
5271 function attrsBy(attrs, name) { return attrs.filter(a => a.name === name); }
5272 function attrFirst(attrs, name) { const a = attrs.find(x => x.name === name); return a ? a.value : null; }
5273 function directionOf(attrs) {
5274 for (const d of ['sendrecv','sendonly','recvonly','inactive'])
5275 if (attrs.some(a => a.name === d)) return d;
5276 return null;
5277 }
5278
5279 function renderSession(parsed) {
5280 const out = document.createDocumentFragment();
5281
5282 /* Session-level summary */
5283 const sec = section('Session');
5284 const grid = el('div', 'sdp-kv');
5285 const o = parsed.lines.find(l => l.key === 'o');
5286 if (o) {
5287 const op = o.val.split(/\s+/);
5288 kv(grid, 'origin', op.join(' '),
5289 'o=<username> <sess-id> <sess-version> <nettype> <addrtype> <unicast-addr>');
5290 }
5291 const sName = parsed.lines.find(l => l.key === 's');
5292 if (sName) kv(grid, 'name (s=)', sName.val, 'Session name. WebRTC uses "-".');
5293 const t = parsed.lines.find(l => l.key === 't');
5294 if (t) kv(grid, 'time (t=)', t.val, 't=<start> <stop>; "0 0" means unbounded — usual for real-time sessions.');
5295 const c = parsed.lines.find(l => l.key === 'c');
5296 if (c) kv(grid, 'connection (c=)', c.val, 'c=<nettype> <addrtype> <connection-address>.');
5297
5298 const sessionAttrs = parsed.attrs;
5299 const group = attrFirst(sessionAttrs, 'group');
5300 if (group) kv(grid, 'group', group, ATTR_HELP['group']);
5301 const msidSem = attrFirst(sessionAttrs, 'msid-semantic');
5302 if (msidSem) kv(grid, 'msid-semantic', msidSem, ATTR_HELP['msid-semantic']);
5303 const fp = attrFirst(sessionAttrs, 'fingerprint');
5304 if (fp) kv(grid, 'fingerprint (session)', fp, ATTR_HELP['fingerprint']);
5305 const setup = attrFirst(sessionAttrs, 'setup');
5306 if (setup) kv(grid, 'setup (session)', setup, ATTR_HELP['setup']);
5307 const ufrag = attrFirst(sessionAttrs, 'ice-ufrag');
5308 if (ufrag) kv(grid, 'ice-ufrag (session)', ufrag, ATTR_HELP['ice-ufrag']);
5309 const pwd = attrFirst(sessionAttrs, 'ice-pwd');
5310 if (pwd) kv(grid, 'ice-pwd (session)', pwd, ATTR_HELP['ice-pwd']);
5311 const iceOpts = attrFirst(sessionAttrs, 'ice-options');
5312 if (iceOpts) kv(grid, 'ice-options', iceOpts, ATTR_HELP['ice-options']);
5313 if (sessionAttrs.some(a => a.name === 'extmap-allow-mixed'))
5314 kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
5315
5316 sec.appendChild(grid);
5317 out.appendChild(sec);
5318
5319 /* One section per m= */
5320 parsed.media.forEach((media, idx) => renderMedia(media, idx, sessionAttrs, out));
5321 return out;
5322 }
5323
5324 function renderMedia(media, idx, sessionAttrs, out) {
5325 const sub = media.proto + ' port ' + media.port + ' PTs: ' + media.payloadTypes.join(' ');
5326 const sec = section('m=' + media.type + ' [' + idx + ']', sub);
5327
5328 const grid = el('div', 'sdp-kv');
5329 const mid = attrFirst(media.attrs, 'mid');
5330 if (mid) kv(grid, 'mid', mid, ATTR_HELP['mid']);
5331 const dir = directionOf(media.attrs);
5332 if (dir) kv(grid, 'direction', dir, ATTR_HELP[dir]);
5333 const msid = attrFirst(media.attrs, 'msid');
5334 if (msid) kv(grid, 'msid', msid, ATTR_HELP['msid']);
5335 if (media.attrs.some(a => a.name === 'rtcp-mux')) kv(grid, 'rtcp-mux', 'yes', ATTR_HELP['rtcp-mux']);
5336 if (media.attrs.some(a => a.name === 'rtcp-rsize')) kv(grid, 'rtcp-rsize', 'yes', ATTR_HELP['rtcp-rsize']);
5337 if (media.attrs.some(a => a.name === 'extmap-allow-mixed'))
5338 kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
5339 if (media.attrs.some(a => a.name === 'bundle-only'))
5340 kv(grid, 'bundle-only', 'yes', ATTR_HELP['bundle-only']);
5341 const rtcpAddr = attrFirst(media.attrs, 'rtcp');
5342 if (rtcpAddr) kv(grid, 'rtcp (legacy)', rtcpAddr, ATTR_HELP['rtcp']);
5343 const sctpPort = attrFirst(media.attrs, 'sctp-port');
5344 if (sctpPort) kv(grid, 'sctp-port', sctpPort, ATTR_HELP['sctp-port']);
5345 const maxMsg = attrFirst(media.attrs, 'max-message-size');
5346 if (maxMsg) kv(grid, 'max-message-size', maxMsg + ' bytes', ATTR_HELP['max-message-size']);
5347 const mFp = attrFirst(media.attrs, 'fingerprint');
5348 if (mFp) kv(grid, 'fingerprint', mFp, ATTR_HELP['fingerprint']);
5349 const mSetup = attrFirst(media.attrs, 'setup');
5350 if (mSetup) kv(grid, 'setup', mSetup, ATTR_HELP['setup']);
5351 const mUfrag = attrFirst(media.attrs, 'ice-ufrag');
5352 if (mUfrag) kv(grid, 'ice-ufrag', mUfrag, ATTR_HELP['ice-ufrag']);
5353 const mPwd = attrFirst(media.attrs, 'ice-pwd');
5354 if (mPwd) kv(grid, 'ice-pwd', mPwd, ATTR_HELP['ice-pwd']);
5355 sec.appendChild(grid);
5356
5357 /* Codecs */
5358 const rtpmaps = attrsBy(media.attrs, 'rtpmap');
5359 const fmtps = attrsBy(media.attrs, 'fmtp');
5360 const fbs = attrsBy(media.attrs, 'rtcp-fb');
5361 if (rtpmaps.length) {
5362 sec.appendChild(el('h3', null, 'Codecs'));
5363 sec.appendChild(helpLine('Each payload type (PT) maps to a codec definition. fmtp/rtcp-fb lines attach to a PT by id.'));
5364 const list = el('ul', 'sdp-list');
5365 rtpmaps.forEach(r => {
5366 const m = r.value.match(/^(\d+)\s+(.+)$/);
5367 if (!m) return;
5368 const pt = m[1], spec = m[2];
5369 const li = el('li');
5370 const tag = el('span', 'tag', pt);
5371 tag.title = 'RTP payload type number';
5372 li.appendChild(tag);
5373 li.appendChild(document.createTextNode(spec));
5374 const fmtp = fmtps.find(f => f.value.startsWith(pt + ' '));
5375 if (fmtp) {
5376 const b = el('span', 'badge', 'fmtp: ' + fmtp.value.slice(pt.length + 1));
5377 b.title = ATTR_HELP['fmtp'];
5378 li.appendChild(document.createTextNode(' '));
5379 li.appendChild(b);
5380 }
5381 const ptFbs = fbs.filter(f => f.value.startsWith(pt + ' ') || f.value.startsWith('* '));
5382 if (ptFbs.length) {
5383 const fbText = ptFbs.map(f => f.value.split(/\s+/).slice(1).join(' ')).join(' / ');
5384 const b = el('span', 'dim', 'fb: ' + fbText);
5385 b.title = ATTR_HELP['rtcp-fb'];
5386 li.appendChild(document.createTextNode(' '));
5387 li.appendChild(b);
5388 }
5389 list.appendChild(li);
5390 });
5391 sec.appendChild(list);
5392 }
5393
5394 /* RTP header extensions */
5395 const extmaps = attrsBy(media.attrs, 'extmap');
5396 if (extmaps.length) {
5397 sec.appendChild(el('h3', null, 'RTP header extensions'));
5398 sec.appendChild(helpLine(ATTR_HELP['extmap']));
5399 const list = el('ul', 'sdp-list');
5400 extmaps.forEach(e => {
5401 const m = e.value.match(/^(\d+)(?:\/(\S+))?\s+(.+)$/);
5402 const li = el('li');
5403 if (m) {
5404 li.appendChild(el('span', 'tag', m[1]));
5405 if (m[2]) { const dir = el('span', 'badge', m[2]); dir.title = 'extension direction'; li.appendChild(dir); }
5406 li.appendChild(document.createTextNode(' ' + m[3]));
5407 } else li.textContent = e.value;
5408 list.appendChild(li);
5409 });
5410 sec.appendChild(list);
5411 }
5412
5413 /* ICE candidates */
5414 const cands = attrsBy(media.attrs, 'candidate');
5415 if (cands.length) {
5416 sec.appendChild(el('h3', null, 'ICE candidates (' + cands.length + ')'));
5417 sec.appendChild(helpLine(ATTR_HELP['candidate']));
5418 const list = el('ul', 'sdp-list');
5419 cands.forEach(c => list.appendChild(renderCandidate(c.value)));
5420 sec.appendChild(list);
5421 }
5422 if (media.attrs.some(a => a.name === 'end-of-candidates'))
5423 sec.appendChild(helpLine('end-of-candidates present: ' + ATTR_HELP['end-of-candidates']));
5424
5425 /* SSRCs */
5426 const ssrcs = attrsBy(media.attrs, 'ssrc');
5427 const ssrcGroups = attrsBy(media.attrs, 'ssrc-group');
5428 if (ssrcs.length || ssrcGroups.length) {
5429 sec.appendChild(el('h3', null, 'SSRCs'));
5430 if (ssrcGroups.length) {
5431 const gl = el('ul', 'sdp-list');
5432 ssrcGroups.forEach(g => {
5433 const li = el('li');
5434 li.appendChild(el('span', 'badge', 'group'));
5435 li.appendChild(document.createTextNode(' ' + g.value));
5436 li.title = ATTR_HELP['ssrc-group'];
5437 gl.appendChild(li);
5438 });
5439 sec.appendChild(gl);
5440 }
5441 const byId = new Map();
5442 ssrcs.forEach(s => {
5443 const m = s.value.match(/^(\d+)\s+(\S+?)(?::(.*))?$/);
5444 if (!m) return;
5445 const id = m[1], attr = m[2], val = m[3] || '';
5446 if (!byId.has(id)) byId.set(id, []);
5447 byId.get(id).push(attr + (val ? '=' + val : ''));
5448 });
5449 const list = el('ul', 'sdp-list');
5450 byId.forEach((props, id) => {
5451 const li = el('li');
5452 li.appendChild(el('span', 'tag', id));
5453 li.appendChild(document.createTextNode(props.join(' ')));
5454 li.title = ATTR_HELP['ssrc'];
5455 list.appendChild(li);
5456 });
5457 sec.appendChild(list);
5458 }
5459
5460 /* Simulcast / rid */
5461 const rids = attrsBy(media.attrs, 'rid');
5462 const sim = attrFirst(media.attrs, 'simulcast');
5463 if (rids.length || sim) {
5464 sec.appendChild(el('h3', null, 'Simulcast'));
5465 sec.appendChild(helpLine(ATTR_HELP['simulcast']));
5466 if (sim) sec.appendChild(el('p', 'sdp-help', 'simulcast: ' + sim));
5467 if (rids.length) {
5468 const list = el('ul', 'sdp-list');
5469 rids.forEach(r => { const li = el('li'); li.textContent = r.value; li.title = ATTR_HELP['rid']; list.appendChild(li); });
5470 sec.appendChild(list);
5471 }
5472 }
5473
5474 /* Anything we didn't classify — show raw for completeness. */
5475 const handled = new Set([
5476 'mid','msid','rtcp-mux','rtcp-rsize','fingerprint','setup','ice-ufrag','ice-pwd',
5477 'sendrecv','sendonly','recvonly','inactive','rtpmap','fmtp','rtcp-fb','candidate',
5478 'end-of-candidates','ssrc','ssrc-group','rid','simulcast','extmap',
5479 'extmap-allow-mixed','bundle-only','rtcp','sctp-port','max-message-size',
5480 ]);
5481 const other = media.attrs.filter(a => !handled.has(a.name));
5482 if (other.length) {
5483 sec.appendChild(rawBlock('Other attributes (' + other.length + ')',
5484 other.map(a => ({ key: 'a', val: a.name + (a.value ? ':' + a.value : '') }))));
5485 }
5486 sec.appendChild(rawBlock('Raw lines for this m= (' + media.lines.length + ')', media.lines));
5487 out.appendChild(sec);
5488 }
5489
5490 function run(input, container, statusEl) {
5491 container.replaceChildren();
5492 try {
5493 const { type, sdp } = extractSdp(input);
5494 const parsed = parseSdp(sdp);
5495 const header = el('div', 'sdp-section');
5496 const head = el('div', 'sdp-head');
5497 head.appendChild(el('h3', null, 'Detected: ' + type));
5498 head.appendChild(el('span', 'sdp-sub',
5499 parsed.media.length + ' m= section(s) · ' + sdp.split(/\r?\n/).length + ' lines'));
5500 header.appendChild(head);
5501 container.appendChild(header);
5502 container.appendChild(renderSession(parsed));
5503 statusEl.textContent = 'parsed ' + parsed.media.length + ' section(s)';
5504 statusEl.className = 'pill ok';
5505 } catch (e) {
5506 statusEl.textContent = e.message;
5507 statusEl.className = 'pill err';
5508 }
5509 }
5510
5511 return { run };
5512})();
5513
5514/* -------------------------------------------------------------------------
5515 Wire up everything on DOMContentLoaded
5516------------------------------------------------------------------------- */
5517function wire() {
5518 window.addEventListener('popstate', onPopstate);
5519 resolveInitialView();
5520
5521 /* Welcome */
5522 const usernameInput = document.getElementById('welcome-username');
5523 if (usernameInput) {
5524 usernameInput.value = App.state.username === 'Anonymous' ? '' : App.state.username;
5525 usernameInput.placeholder = 'Anonymous';
5526 }
5527 document.getElementById('welcome-start').addEventListener('click', () => {
5528 const raw = (usernameInput?.value || '').trim().slice(0, 64);
5529 App.state.username = raw || 'Anonymous';
5530 if (raw) saveUsername(raw);
5531 /* No peers yet — straight to the call view with the empty-state banner. */
5532 App.state.bannerDismissed = false;
5533 goToCall();
5534 App.banner.refresh();
5535 });
5536 document.getElementById('welcome-sdp-inspect').addEventListener('click', () => {
5537 showView('view-sdp-inspect');
5538 });
5539
5540 /* Add Participant dialog: each button picks a role and routes through the
5541 existing configure → exchange flow, which knows how to attach the result
5542 to a fresh PeerCtx. */
5543 const addDlg = document.getElementById('add-peer-dialog');
5544 function openAddDialog() {
5545 if (!addDlg || typeof addDlg.showModal !== 'function' || addDlg.open) return;
5546 try { addDlg.showModal(); } catch (_) {}
5547 }
5548 document.getElementById('tb-add-peer').addEventListener('click', openAddDialog);
5549 document.getElementById('call-banner-add').addEventListener('click', openAddDialog);
5550 document.getElementById('add-peer-cancel').addEventListener('click', () => {
5551 if (addDlg && addDlg.open) addDlg.close();
5552 });
5553 addDlg.querySelectorAll('.role-picker button[data-role]').forEach(b => {
5554 b.addEventListener('click', () => {
5555 addDlg.close();
5556 pickRoleForAdd(b.dataset.role);
5557 });
5558 });
5559
5560 /* SDP inspector */
5561 const sdpIn = document.getElementById('sdp-in');
5562 const sdpOut = document.getElementById('sdp-inspect-out');
5563 const sdpStatus = document.getElementById('sdp-inspect-status');
5564 document.getElementById('sdp-inspect-go').addEventListener('click',
5565 () => App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus));
5566 document.getElementById('sdp-inspect-clear').addEventListener('click', () => {
5567 sdpIn.value = ''; sdpOut.replaceChildren(); sdpStatus.textContent = ''; sdpStatus.className = 'pill';
5568 });
5569 document.getElementById('sdp-inspect-back').addEventListener('click', () => showView('view-welcome'));
5570 const sdpFile = document.getElementById('sdp-inspect-file');
5571 document.getElementById('sdp-inspect-upload').addEventListener('click', () => sdpFile.click());
5572 sdpFile.addEventListener('change', async () => {
5573 const f = sdpFile.files && sdpFile.files[0];
5574 if (!f) return;
5575 try { sdpIn.value = await f.text(); App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus); }
5576 catch (e) { sdpStatus.textContent = 'read failed: ' + e.message; sdpStatus.className = 'pill err'; }
5577 finally { sdpFile.value = ''; }
5578 });
5579
5580 /* Theme */
5581 document.getElementById('theme-toggle').addEventListener('click', () => App.theme.toggle());
5582
5583 /* Step dialog: Cancel and Esc both invoke the registered cancel handler.
5584 Listen on 'cancel' (fired by Esc) and 'close' as a belt-and-suspenders. */
5585 document.getElementById('step-dialog-cancel').addEventListener('click', () => {
5586 App.progress.triggerCancel();
5587 });
5588 document.getElementById('step-dialog').addEventListener('cancel', e => {
5589 /* Don't let the dialog close before we run the cancel handler — the
5590 handler itself calls dlg.close() through App.progress.hideModal(). */
5591 e.preventDefault();
5592 App.progress.triggerCancel();
5593 });
5594
5595 /* Best-effort hangup notification when the tab is closing or backgrounded
5596 to bfcache. Use pagehide (more reliable than beforeunload, especially
5597 on mobile) and broadcast to every connected peer. */
5598 window.addEventListener('pagehide', () => {
5599 if (App.chat && App.chat.sendByeAll) App.chat.sendByeAll();
5600 });
5601
5602 /* Configure */
5603 document.getElementById('ice-add').addEventListener('click', () => {
5604 App.state.settings.iceServers.push({ urls: '' });
5605 renderIceRows();
5606 });
5607 document.getElementById('ice-clear').addEventListener('click', () => {
5608 App.state.settings.iceServers = [];
5609 renderIceRows();
5610 });
5611 document.getElementById('ice-reset').addEventListener('click', () => {
5612 App.state.settings.iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
5613 renderIceRows();
5614 });
5615 document.getElementById('ice-toggle-json').addEventListener('click', () => {
5616 const w = document.getElementById('ice-json-wrap');
5617 document.getElementById('ice-json').value = JSON.stringify(App.state.settings.iceServers, null, 2);
5618 w.classList.toggle('hidden');
5619 });
5620 document.getElementById('ice-warmup').addEventListener('click', async () => {
5621 const status = document.getElementById('ice-warmup-status');
5622 const btn = document.getElementById('ice-warmup');
5623 if (App.state.iceWarmupStream) {
5624 App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
5625 App.state.iceWarmupStream = null;
5626 status.textContent = 'off'; status.className = 'pill';
5627 btn.textContent = 'Enable LAN connectivity';
5628 App.log.info('ice', 'LAN warmup stream stopped');
5629 return;
5630 }
5631 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
5632 status.textContent = 'unavailable (needs HTTPS)'; status.className = 'pill err';
5633 return;
5634 }
5635 btn.disabled = true;
5636 status.textContent = 'requesting…'; status.className = 'pill warn';
5637 try {
5638 const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
5639 /* Mute the track but keep the stream alive — Firefox exposes LAN ICE
5640 candidates only while a gUM stream is in use. Stopping the track would
5641 revert to restricted candidates. */
5642 stream.getAudioTracks().forEach(t => t.enabled = false);
5643 App.state.iceWarmupStream = stream;
5644 status.textContent = 'on (mic in use, muted)'; status.className = 'pill ok';
5645 btn.textContent = 'Disable LAN connectivity';
5646 App.log.info('ice', 'LAN warmup stream active; LAN candidates unlocked');
5647 } catch (e) {
5648 status.textContent = 'denied: ' + e.message; status.className = 'pill err';
5649 App.log.warn('ice', 'LAN warmup denied', e.message);
5650 } finally {
5651 btn.disabled = false;
5652 }
5653 });
5654 document.getElementById('ice-json-apply').addEventListener('click', () => {
5655 try {
5656 const v = JSON.parse(document.getElementById('ice-json').value);
5657 if (!Array.isArray(v)) throw new Error('expected an array');
5658 App.state.settings.iceServers = v;
5659 renderIceRows();
5660 App.log.info('ice', 'applied JSON config', v.length, 'servers');
5661 } catch (e) { App.log.error('ice', 'bad JSON', e.message); alert('Bad JSON: ' + e.message); }
5662 });
5663 /* Signaling mode toggle */
5664 document.getElementById('sig-mode-manual').addEventListener('click', () => {
5665 applySignalingMode('manual'); saveSignaling();
5666 });
5667 document.getElementById('sig-mode-auto').addEventListener('click', () => {
5668 applySignalingMode('auto'); saveSignaling();
5669 });
5670 document.getElementById('sig-room-gen').addEventListener('click', () => {
5671 document.getElementById('sig-room-code').value = randomRoomCode();
5672 });
5673 document.getElementById('sig-check').addEventListener('click', async () => {
5674 const btn = document.getElementById('sig-check');
5675 const status = document.getElementById('sig-check-status');
5676 const url = (document.getElementById('sig-server-url').value || '').trim().replace(/\/+$/, '');
5677 status.classList.remove('hidden');
5678 if (!url) { status.textContent = 'enter a URL first'; status.className = 'pill err'; return; }
5679 btn.disabled = true;
5680 status.textContent = 'checking…'; status.className = 'pill warn';
5681 /* Independent abort from the room-handshake one so cancelling Check doesn't
5682 affect anything else. 5 s is plenty for a healthy server. */
5683 const ac = new AbortController();
5684 const timer = setTimeout(() => ac.abort(), 5000);
5685 const t0 = performance.now();
5686 try {
5687 const r = await fetch(url + '/health', { signal: ac.signal, cache: 'no-store' });
5688 const ms = Math.round(performance.now() - t0);
5689 if (r.ok) { status.textContent = 'reachable (' + r.status + ', ' + ms + ' ms)'; status.className = 'pill ok'; }
5690 else { status.textContent = 'HTTP ' + r.status; status.className = 'pill err'; }
5691 } catch (e) {
5692 status.textContent = ac.signal.aborted ? 'timed out (5 s)' : 'unreachable: ' + e.message;
5693 status.className = 'pill err';
5694 } finally {
5695 clearTimeout(timer);
5696 btn.disabled = false;
5697 }
5698 });
5699 document.getElementById('sig-room-code').addEventListener('input', e => {
5700 /* Server validates the same character class — keep the input clean so the
5701 user notices invalid keystrokes immediately rather than at request time. */
5702 e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 15);
5703 });
5704
5705 document.getElementById('cfg-back').addEventListener('click', () => {
5706 /* Cfg-back during Add: drop the pending peer (no live commit yet) and
5707 return to the call view (or welcome, if no call started yet). */
5708 cancelSetup();
5709 App.state.nextAddRole = null;
5710 updateRoleBadge();
5711 closeConfigureDialog();
5712 if (App.state.peers.size || currentView() === 'view-call') goToCall();
5713 else showView('view-welcome');
5714 });
5715 /* Also handle native dialog close (Esc / backdrop). */
5716 document.getElementById('view-configure').addEventListener('close', () => {
5717 /* Only treat this as a cancellation when nothing is mid-handshake. */
5718 if (!App.state.pendingPeer) {
5719 App.state.nextAddRole = null;
5720 updateRoleBadge();
5721 }
5722 });
5723 /* Esc on the exchange dialog cancels the pending peer (a half-finished
5724 handshake is unusable). */
5725 document.getElementById('view-exchange').addEventListener('close', () => {
5726 if (App.state.pendingPeer) {
5727 cancelSetup();
5728 App.state.nextAddRole = null;
5729 updateRoleBadge();
5730 }
5731 });
5732 document.getElementById('cfg-continue').addEventListener('click', async () => {
5733 readConfigInputs();
5734 saveIce();
5735 const role = App.state.nextAddRole;
5736 if (!role) { alert('No role chosen — open Add participant.'); return; }
5737 const auto = App.state.settings.signaling.mode === 'auto' && role !== 'loopback';
5738 try {
5739 /* Close the dialog before the start* calls so the exchange view (or
5740 progress modal) isn't rendered underneath the configure backdrop.
5741 The dialog's `close` handler clears nextAddRole when no pendingPeer
5742 exists, so when we re-open after a validation error we must restore
5743 the role before showing the dialog again. */
5744 closeConfigureDialog();
5745 function reopenWithRole() {
5746 App.state.nextAddRole = role;
5747 updateRoleBadge();
5748 openConfigureDialog();
5749 }
5750 if (auto) {
5751 const code = (document.getElementById('sig-room-code').value || '').trim();
5752 if (!code) { alert('Room code is required for auto mode.'); reopenWithRole(); return; }
5753 if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); reopenWithRole(); return; }
5754 if (role === 'initiator') await startInitiatorAuto(code);
5755 else if (role === 'joiner') await startJoinerAuto(code);
5756 } else {
5757 if (role === 'initiator') await startInitiator();
5758 else if (role === 'joiner') await startJoiner();
5759 else if (role === 'loopback') await startLoopback();
5760 }
5761 App.state.nextAddRole = null;
5762 updateRoleBadge();
5763 } catch (e) {
5764 App.progress.hide();
5765 if (App.state.userCancelled) { App.state.userCancelled = false; return; }
5766 App.log.error('setup', 'failed', e.message);
5767 alert('Setup failed: ' + e.message);
5768 }
5769 });
5770
5771 document.getElementById('exch-cancel').addEventListener('click', () => {
5772 cancelSetup();
5773 App.state.nextAddRole = null;
5774 updateRoleBadge();
5775 closeExchangeDialog();
5776 if (App.state.peers.size || currentView() === 'view-call') goToCall();
5777 else showView('view-welcome');
5778 });
5779
5780 /* Disable a button until its async handler resolves, so double-clicks
5781 during gUM/gDM don't interleave. Used for both the Apply buttons in the
5782 settings panel and the main toolbar Mic/Cam/Screen toggles. */
5783 function withReentryGuard(btnId, fn) {
5784 const btn = document.getElementById(btnId);
5785 btn.addEventListener('click', async () => {
5786 if (btn.disabled) return;
5787 btn.disabled = true;
5788 try { await fn(); }
5789 catch (e) { App.log.error('media', 'action failed', e.message); }
5790 finally { btn.disabled = false; }
5791 });
5792 }
5793
5794 /* Call: toolbar — mic/cam lazily call getUserMedia on first enable. */
5795 withReentryGuard('tb-mic', async () => {
5796 const btn = document.getElementById('tb-mic');
5797 await App.media.setMic(!btn.classList.contains('on'));
5798 });
5799 withReentryGuard('tb-cam', async () => {
5800 const btn = document.getElementById('tb-cam');
5801 await App.media.setCam(!btn.classList.contains('on'));
5802 });
5803 withReentryGuard('tb-screen', async () => {
5804 if (App.state.screenStream) await App.media.stopScreenshare();
5805 else {
5806 try { await App.media.startScreenshare(); }
5807 catch (e) { App.log.error('media', 'screenshare', e.message); App.chat.appendSystem?.('Screen share failed: ' + e.message); }
5808 }
5809 });
5810 document.getElementById('tb-hangup').addEventListener('click', hangup);
5811
5812 /* Local tile screen-share fullscreen toggle. Remote tiles wire their own
5813 click handler inside App.tiles.add(). */
5814 document.getElementById('tile-local').addEventListener('click', e => {
5815 const tile = e.currentTarget;
5816 if (!tile.classList.contains('screen')) return;
5817 if (e.target.closest('.pip')) return;
5818 const video = tile.querySelector(':scope > .vid-main');
5819 if (!video) return;
5820 if (document.fullscreenElement) {
5821 (document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
5822 .catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
5823 } else {
5824 (video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
5825 .catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
5826 }
5827 });
5828
5829 /* Files: clear-all */
5830 document.getElementById('files-out-clear').addEventListener('click', () => App.files.clearAll('out'));
5831 document.getElementById('files-in-clear').addEventListener('click', () => App.files.clearAll('in'));
5832
5833 /* Call: sidebar tabs */
5834 document.querySelectorAll('.tabs button').forEach(b => {
5835 b.addEventListener('click', () => {
5836 document.querySelectorAll('.tabs button').forEach(x => x.classList.remove('active'));
5837 document.querySelectorAll('.tab-pane').forEach(x => x.classList.remove('active'));
5838 b.classList.add('active');
5839 document.querySelector(`.tab-pane[data-pane="${b.dataset.tab}"]`).classList.add('active');
5840 });
5841 });
5842
5843 /* Chat */
5844 const chatInput = document.getElementById('chat-text');
5845 const chatSend = document.getElementById('chat-send');
5846 const chatCounter = document.getElementById('chat-counter');
5847 const CHAT_MAX = App.chat.MAX_TEXT;
5848 function updateChatCounter() {
5849 const bytes = App.chat.utf8Length(chatInput.value);
5850 const totalOpen = peersWithOpenChat().length;
5851 const selected = App.ui.selectedChatPeers ? App.ui.selectedChatPeers().length : totalOpen;
5852 let suffix = '';
5853 if (!totalOpen) suffix = ' · no connected peers';
5854 else if (!selected) suffix = ' · no recipients selected';
5855 else if (selected < totalOpen) suffix = ' · → ' + selected + '/' + totalOpen + ' peers';
5856 chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B' + suffix;
5857 const over = bytes > CHAT_MAX;
5858 chatCounter.classList.toggle('over', over);
5859 chatInput.disabled = !totalOpen;
5860 chatSend.disabled = over || bytes === 0 || !selected;
5861 }
5862 App.ui.updateChatGate = updateChatCounter;
5863 chatSend.addEventListener('click', sendChat);
5864 chatInput.addEventListener('input', updateChatCounter);
5865 chatInput.addEventListener('keydown', e => {
5866 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
5867 });
5868 updateChatCounter();
5869 function sendChat() {
5870 const t = chatInput.value.trim();
5871 if (!t) return;
5872 if (App.chat.utf8Length(t) > CHAT_MAX) return;
5873 const peers = App.ui.selectedChatPeers();
5874 if (!peers.length) return;
5875 App.chat.send(t, { peers });
5876 chatInput.value = '';
5877 updateChatCounter();
5878 }
5879
5880 /* Files */
5881 (() => {
5882 const n = App.files.MAX_FILE;
5883 let txt;
5884 if (n >= 1024 ** 3) txt = (n / 1024 ** 3) + ' GB';
5885 else if (n >= 1024 ** 2) txt = (n / 1024 ** 2) + ' MB';
5886 else txt = (n / 1024) + ' KB';
5887 document.getElementById('files-max').textContent = txt;
5888 })();
5889 const drop = document.getElementById('files-drop');
5890 drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('over'); });
5891 drop.addEventListener('dragleave', () => drop.classList.remove('over'));
5892 drop.addEventListener('drop', e => {
5893 e.preventDefault(); drop.classList.remove('over');
5894 const f = e.dataTransfer.files[0];
5895 if (!f) return;
5896 const peers = App.ui.selectedFilesPeers();
5897 if (!peers.length) return;
5898 App.files.sendFile(f, { peers });
5899 });
5900 document.getElementById('files-pick').addEventListener('click', e => {
5901 e.preventDefault();
5902 document.getElementById('files-input').click();
5903 });
5904 document.getElementById('files-input').addEventListener('change', e => {
5905 const f = e.target.files[0];
5906 if (f) {
5907 const peers = App.ui.selectedFilesPeers();
5908 if (peers.length) App.files.sendFile(f, { peers });
5909 }
5910 e.target.value = '';
5911 });
5912 function updateFilesGate() {
5913 const totalOpen = peersWithOpenFiles().length;
5914 const selected = App.ui.selectedFilesPeers ? App.ui.selectedFilesPeers().length : totalOpen;
5915 drop.classList.toggle('disabled', !selected);
5916 }
5917 App.ui.updateFilesGate = updateFilesGate;
5918 updateFilesGate();
5919
5920 /* Target-aware writes for the per-peer encoder fields. `all` updates the
5921 global settings and every connected peer (so new peers inherit too).
5922 A specific peer id updates only that peer; the global "defaults" stay
5923 unchanged in that case. */
5924 function currentTarget() {
5925 return document.getElementById('settings-target').value || 'all';
5926 }
5927 function targetPeers() {
5928 const t = currentTarget();
5929 if (t === 'all') return Array.from(App.state.peers.values());
5930 const peer = App.state.peers.get(t);
5931 return peer ? [peer] : [];
5932 }
5933 function writeVideoEncoder(maxKbps, degrade) {
5934 const t = currentTarget();
5935 if (t === 'all') {
5936 App.state.settings.video.maxBitrateKbps = maxKbps;
5937 App.state.settings.video.degradationPreference = degrade;
5938 }
5939 for (const p of targetPeers()) {
5940 p.videoMaxBitrateKbps = maxKbps;
5941 p.videoDegradationPreference = degrade;
5942 }
5943 if (t === 'all') App.media.applyCamSendParamsAll();
5944 else {
5945 const peer = App.state.peers.get(t);
5946 if (peer) App.media.applyCamSendParamsFor(peer);
5947 }
5948 }
5949 function writeScreenEncoder(maxKbps, degrade) {
5950 const t = currentTarget();
5951 if (t === 'all') {
5952 App.state.settings.screen.maxBitrateKbps = maxKbps;
5953 App.state.settings.screen.degradationPreference = degrade;
5954 }
5955 for (const p of targetPeers()) {
5956 p.screenMaxBitrateKbps = maxKbps;
5957 p.screenDegradationPreference = degrade;
5958 }
5959 if (t === 'all') App.media.applyScreenSendParamsAll();
5960 else {
5961 const peer = App.state.peers.get(t);
5962 if (peer) App.media.applyScreenSendParamsFor(peer);
5963 }
5964 }
5965 function writeSendCodec(codec) {
5966 const t = currentTarget();
5967 if (t === 'all') App.state.settings.sendVideoCodec = codec;
5968 for (const p of targetPeers()) p.sendVideoCodec = codec;
5969 if (t === 'all') {
5970 App.media.applyCamSendParamsAll();
5971 App.media.applyScreenSendParamsAll();
5972 } else {
5973 const peer = App.state.peers.get(t);
5974 if (peer) {
5975 App.media.applyCamSendParamsFor(peer);
5976 App.media.applyScreenSendParamsFor(peer);
5977 }
5978 }
5979 }
5980
5981 /* Populate the per-peer encoder inputs from the current target. The
5982 resolution/fps and audio inputs are unaffected — they're always global. */
5983 App.ui.loadSettingsForTarget = function () {
5984 const t = currentTarget();
5985 const $ = id => document.getElementById(id);
5986 let v, s, codec;
5987 if (t === 'all') {
5988 v = { maxBitrateKbps: App.state.settings.video.maxBitrateKbps,
5989 degradationPreference: App.state.settings.video.degradationPreference };
5990 s = { maxBitrateKbps: App.state.settings.screen.maxBitrateKbps,
5991 degradationPreference: App.state.settings.screen.degradationPreference };
5992 codec = App.state.settings.sendVideoCodec || 'auto';
5993 } else {
5994 const peer = App.state.peers.get(t);
5995 if (!peer) return;
5996 v = { maxBitrateKbps: peer.videoMaxBitrateKbps,
5997 degradationPreference: peer.videoDegradationPreference };
5998 s = { maxBitrateKbps: peer.screenMaxBitrateKbps,
5999 degradationPreference: peer.screenDegradationPreference };
6000 codec = peer.sendVideoCodec || 'auto';
6001 }
6002 $('rt-v-maxbr').value = v.maxBitrateKbps || 0;
6003 $('rt-v-degrade').value = v.degradationPreference || 'balanced';
6004 $('rt-s-maxbr').value = s.maxBitrateKbps || 0;
6005 $('rt-s-degrade').value = s.degradationPreference || 'maintain-resolution';
6006 const rtCodec = $('rt-codec');
6007 if (rtCodec) {
6008 const want = codec.toLowerCase();
6009 const match = Array.from(rtCodec.options).find(o => o.value.toLowerCase() === want);
6010 rtCodec.value = match ? match.value : 'auto';
6011 }
6012 };
6013 document.getElementById('settings-target').addEventListener('change', () => {
6014 App.ui.loadSettingsForTarget();
6015 });
6016
6017 /* Username (always global). */
6018 document.getElementById('rt-username-apply').addEventListener('click', () => {
6019 const inp = document.getElementById('rt-username');
6020 const name = (inp.value || '').trim().slice(0, 64) || 'Anonymous';
6021 inp.value = name;
6022 if (name === App.state.username) return;
6023 App.state.username = name;
6024 saveUsername(name);
6025 /* Update local tile label + broadcast to all peers. */
6026 const localLabel = document.getElementById('tile-local-label');
6027 if (localLabel) localLabel.textContent = name + ' (you)';
6028 App.chat.broadcastUsername?.();
6029 });
6030
6031 /* Capture-side applies (always global). */
6032 withReentryGuard('rt-v-cap-apply', async () => {
6033 const v = App.state.settings.video;
6034 const prevW = v.width, prevH = v.height, prevFps = v.frameRate;
6035 v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
6036 v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
6037 v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
6038 const camChanged = v.width !== prevW || v.height !== prevH || v.frameRate !== prevFps;
6039 const camOn = document.getElementById('tb-cam').classList.contains('on');
6040 if (camChanged && camOn) {
6041 App.log.info('media', 'restarting camera to apply new resolution/framerate');
6042 await App.media.setCam(false);
6043 await App.media.setCam(true);
6044 }
6045 });
6046 withReentryGuard('rt-s-cap-apply', async () => {
6047 const s = App.state.settings.screen;
6048 const prevW = s.width, prevH = s.height, prevFps = s.frameRate;
6049 s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
6050 s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
6051 s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
6052 const dimsChanged = s.width !== prevW || s.height !== prevH || s.frameRate !== prevFps;
6053 const screenOn = document.getElementById('tb-screen').classList.contains('on');
6054 if (dimsChanged && screenOn) {
6055 App.log.info('media', 'restarting screen share to apply new resolution/framerate');
6056 await App.media.stopScreenshare();
6057 try { await App.media.startScreenshare(); }
6058 catch (e) { App.log.warn('media', 'restart screen failed', e.message); }
6059 }
6060 });
6061 /* Encoder-side applies (per target). */
6062 withReentryGuard('rt-v-enc-apply', async () => {
6063 const maxBr = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
6064 const degrade = document.getElementById('rt-v-degrade').value;
6065 writeVideoEncoder(maxBr, degrade);
6066 });
6067 withReentryGuard('rt-s-enc-apply', async () => {
6068 const maxBr = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
6069 const degrade = document.getElementById('rt-s-degrade').value;
6070 writeScreenEncoder(maxBr, degrade);
6071 });
6072 withReentryGuard('rt-codec-apply', async () => {
6073 const codec = document.getElementById('rt-codec').value || 'auto';
6074 writeSendCodec(codec);
6075 });
6076 withReentryGuard('rt-a-apply', async () => {
6077 const a = App.state.settings.audio;
6078 const prevCh = a.channelCount, prevRate = a.sampleRate;
6079 a.echoCancellation = document.getElementById('rt-a-aec').checked;
6080 a.noiseSuppression = document.getElementById('rt-a-ns').checked;
6081 a.autoGainControl = document.getElementById('rt-a-agc').checked;
6082 a.channelCount = parseInt(document.getElementById('rt-a-channels').value, 10) || 1;
6083 a.sampleRate = parseInt(document.getElementById('rt-a-rate').value, 10) || 0;
6084 App.media.applyAudioConstraints();
6085 const capChanged = a.channelCount !== prevCh || a.sampleRate !== prevRate;
6086 const micOn = document.getElementById('tb-mic').classList.contains('on');
6087 if (capChanged && micOn) {
6088 App.log.info('media', 'restarting microphone to apply new channels/sample rate');
6089 await App.media.setMic(false);
6090 await App.media.setMic(true);
6091 }
6092 });
6093
6094 /* Stats: peer dropdown + export */
6095 document.getElementById('stats-peer').addEventListener('change', () => App.stats.refreshNow());
6096 document.getElementById('stats-export').addEventListener('click', () => App.stats.exportAll());
6097
6098 setupConsole();
6099 setupDevicePickers();
6100 App.log.info('app', 'ready');
6101}
6102
6103/* Tear down ALL peers, stop local media, and reset call-tied UI state.
6104 Called when the user leaves the call entirely. */
6105function teardownAll(opts) {
6106 const sendBye = !opts || opts.sendBye !== false;
6107 if (sendBye) App.chat.sendByeAll();
6108 App.stats.stop();
6109 App.tiles.teardownObserver?.();
6110 /* Snapshot peers first — removePeer mutates App.state.peers. removePeer
6111 also closes each peer's loopback pcB if present. */
6112 for (const peer of Array.from(App.state.peers.values())) {
6113 removePeer(peer, { sendBye: false });
6114 }
6115 if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
6116 if (App.state.screenStream) App.state.screenStream.getTracks().forEach(t => t.stop());
6117 if (App.state.iceWarmupStream) {
6118 App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
6119 App.state.iceWarmupStream = null;
6120 const wb = document.getElementById('ice-warmup');
6121 const ws = document.getElementById('ice-warmup-status');
6122 if (wb) wb.textContent = 'Enable LAN connectivity';
6123 if (ws) { ws.textContent = 'off'; ws.className = 'pill'; }
6124 }
6125 App.state.localStream = null;
6126 App.state.screenStream = null;
6127 App.state.micTrack = null;
6128 App.state.camTrack = null;
6129 App.state.bannerDismissed = false;
6130 const main = document.getElementById('vid-local-main');
6131 const pip = document.getElementById('vid-local-pip');
6132 if (main) main.srcObject = null;
6133 if (pip) pip.srcObject = null;
6134 const ltile = document.getElementById('tile-local');
6135 if (ltile) { ltile.classList.add('empty'); ltile.classList.remove('screen'); }
6136 document.querySelectorAll('#video-area .video-tile .pip').forEach(el => el.classList.add('hidden'));
6137 /* Reset toolbar buttons back to the initial off state. */
6138 for (const [id, label] of [['tb-mic', 'Mic off'], ['tb-cam', 'Cam off'], ['tb-screen', 'Screen off']]) {
6139 const btn = document.getElementById(id);
6140 if (!btn) continue;
6141 btn.classList.remove('on');
6142 btn.classList.add('off');
6143 btn.querySelector('.nowrap').textContent = label;
6144 }
6145 updateConnPill();
6146 App.banner.refresh();
6147}
6148
6149function hangup(opts) {
6150 App.log.info('app', 'hangup (leave call)');
6151 teardownAll(opts);
6152 showView('view-welcome');
6153}
6154
6155if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
6156else wire();
6157</script>
6158</body>
6159</html>
6160