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=500">
6<title>WebRTC Tool</title>
7<style>
8 :root {
9 --bg: #0d1117;
10 --bg-elev: #161b22;
11 --bg-elev-2: #1f2630;
12 --border: #30363d;
13 --border-strong: #484f58;
14 --text: #e6edf3;
15 --text-dim: #8b949e;
16 --text-faint: #6e7681;
17 --accent: #2f81f7;
18 --accent-fg: #ffffff;
19 --ok: #3fb950;
20 --warn: #d29922;
21 --err: #f85149;
22 --shadow: 0 8px 24px rgba(0,0,0,0.5);
23 --radius: 10px;
24 --radius-sm: 6px;
25 --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
26 --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
27 }
28 html[data-theme="light"] {
29 --bg: #ffffff;
30 --bg-elev: #f6f8fa;
31 --bg-elev-2: #eaeef2;
32 --border: #d0d7de;
33 --border-strong: #afb8c1;
34 --text: #1f2328;
35 --text-dim: #59636e;
36 --text-faint: #818b98;
37 --accent: #0969da;
38 --accent-fg: #ffffff;
39 --ok: #1a7f37;
40 --warn: #9a6700;
41 --err: #cf222e;
42 --shadow: 0 8px 24px rgba(140,149,159,0.2);
43 }
44 * { box-sizing: border-box; }
45 html, body { height: 100%; margin: 0; overflow: hidden; }
46 body {
47 background: var(--bg);
48 color: var(--text);
49 font-family: var(--sans);
50 font-size: 14px;
51 line-height: 1.5;
52 -webkit-font-smoothing: antialiased;
53 }
54 a { color: var(--accent); }
55 code, kbd, pre { font-family: var(--mono); font-size: 12.5px; }
56 button {
57 font-family: inherit;
58 font-size: inherit;
59 background: var(--bg-elev-2);
60 color: var(--text);
61 border: 1px solid var(--border);
62 border-radius: var(--radius-sm);
63 padding: 7px 14px;
64 cursor: pointer;
65 transition: background 120ms, border-color 120ms, transform 80ms;
66 }
67 button:hover { background: var(--bg-elev); border-color: var(--border-strong); }
68 button:active { transform: translateY(1px); }
69 button:disabled { opacity: 0.5; cursor: not-allowed; }
70 /* Firefox adds an invisible inner border/padding to buttons via the
71 ::-moz-focus-inner pseudo-element that shrinks the usable content box
72 and makes our buttons render visibly smaller than in Chromium. */
73 button::-moz-focus-inner { border: 0; padding: 0; }
74 button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
75 button.primary:hover { filter: brightness(1.1); }
76 button.danger { background: var(--err); color: white; border-color: transparent; }
77 button.danger:hover { filter: brightness(1.1); }
78 button.ghost { background: transparent; }
79 button.icon { padding: 7px 10px; }
80 input[type="text"], input[type="number"], input[type="url"], select, textarea {
81 font-family: inherit;
82 font-size: inherit;
83 background: var(--bg);
84 color: var(--text);
85 border: 1px solid var(--border);
86 border-radius: var(--radius-sm);
87 padding: 7px 10px;
88 outline: none;
89 transition: border-color 120ms;
90 }
91 input:focus, select:focus, textarea:focus { border-color: var(--accent); }
92 textarea { display: block; width: 100%; font-family: var(--mono); font-size: 12.5px; resize: vertical; min-height: 110px; }
93 label.row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
94 label.row input[type="checkbox"] { accent-color: var(--accent); }
95 label.field { display: block; margin-bottom: 12px; }
96 label.field > span { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
97 .hidden { display: none !important; }
98
99 /* Inline icon: inherits the surrounding text color, doesn't shrink in flex
100 rows, never hijacks pointer events from its parent button. */
101 .ic { flex: none; display: inline-block; vertical-align: middle; pointer-events: none; }
102
103 /* Layout — body never scrolls; each view either scrolls internally
104 (welcome/configure/exchange) or is overflow:hidden (call). */
105 #app { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
106 .view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
107 .view.hidden { display: none; }
108 #view-welcome, #view-configure, #view-exchange, #view-sdp-inspect { overflow-y: auto; }
109
110 /* SDP inspector */
111 .sdp-section { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; margin-bottom: 14px; }
112 .sdp-section > .sdp-head { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; margin-bottom: 8px; }
113 .sdp-section > .sdp-head h3 { margin: 0; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--text); }
114 .sdp-section > .sdp-head .sdp-sub { color: var(--text-dim); font-size: 12px; font-family: var(--mono); }
115 .sdp-kv { display: grid; grid-template-columns: 170px 1fr; gap: 4px 12px; font-size: 13px; }
116 .sdp-kv .k { color: var(--text-dim); }
117 .sdp-kv .v { font-family: var(--mono); word-break: break-all; }
118 .sdp-kv .v.code { font-family: var(--mono); }
119 .sdp-help { color: var(--text-dim); font-size: 12px; margin: 6px 0 0; font-style: italic; }
120 .sdp-list { list-style: none; padding: 0; margin: 6px 0 0; display: grid; gap: 4px; font-size: 12.5px; font-family: var(--mono); }
121 .sdp-list li { padding: 4px 8px; border-radius: var(--radius-sm); background: var(--bg-elev-2); }
122 .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; }
123 .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); }
124 .sdp-list li .dim { color: var(--text-dim); }
125 details.sdp-rawblock { margin-top: 12px; }
126 details.sdp-rawblock > summary { font-size: 12px; color: var(--text-dim); cursor: pointer; user-select: none; }
127 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; }
128 .topbar {
129 min-height: 48px; flex: none;
130 display: flex; align-items: center; justify-content: space-between;
131 gap: 8px;
132 padding: 6px 16px;
133 border-bottom: 1px solid var(--border);
134 background: var(--bg-elev);
135 }
136 .topbar .brand { font-weight: 600; letter-spacing: 0.2px; min-width: 0; }
137 .topbar .brand small { color: var(--text-faint); font-weight: 400; margin-left: 8px; }
138 .topbar .right { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
139 /* Theme toggle shows the current theme: moon in dark mode, sun in light. */
140 html[data-theme="light"] #theme-toggle .theme-icon-moon { display: none; }
141 html:not([data-theme="light"]) #theme-toggle .theme-icon-sun { display: none; }
142 @media (max-width: 720px) {
143 .topbar { padding: 6px 10px; }
144 }
145 .role-badge {
146 display: inline-block; padding: 2px 8px; border-radius: 12px;
147 background: var(--bg-elev-2); color: var(--text-dim);
148 font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
149 }
150 .role-badge.initiator { background: rgba(47,129,247,0.15); color: var(--accent); }
151 .role-badge.joiner { background: rgba(63,185,80,0.15); color: var(--ok); }
152 .role-badge.loopback { background: rgba(210,153,34,0.15); color: var(--warn); }
153
154 .container { max-width: 880px; width: 100%; margin: 0 auto; padding: 32px 24px; }
155 .container.wide { max-width: 1280px; }
156 h1 { font-size: 24px; margin: 0 0 8px; }
157 h2 { font-size: 16px; margin: 24px 0 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
158 h3 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); margin: 16px 0 8px; }
159 .lede { color: var(--text-dim); margin-bottom: 24px; }
160 .card {
161 background: var(--bg-elev);
162 border: 1px solid var(--border);
163 border-radius: var(--radius);
164 padding: 18px;
165 margin-bottom: 16px;
166 }
167 .card h2:first-child { margin-top: 0; }
168 details { margin-bottom: 16px; }
169 details > summary { cursor: pointer; padding: 10px 14px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius-sm); user-select: none; }
170 details[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
171 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); }
172
173 /* Collapsible card: <details class="card card-collapsible"> */
174 details.card-collapsible { padding: 0; margin-bottom: 16px; background: var(--bg-elev); }
175 details.card-collapsible > summary {
176 list-style: none;
177 cursor: pointer;
178 padding: 14px 18px;
179 user-select: none;
180 display: flex;
181 align-items: center;
182 gap: 10px;
183 background: transparent;
184 border: none;
185 border-radius: var(--radius);
186 }
187 details.card-collapsible > summary::-webkit-details-marker { display: none; }
188 details.card-collapsible > summary::before {
189 content: '';
190 width: 12px; height: 12px;
191 background: var(--text-dim);
192 -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;
193 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;
194 flex: none;
195 transition: transform 120ms ease;
196 }
197 details.card-collapsible[open] > summary::before { transform: rotate(90deg); }
198 details.card-collapsible > summary h2 {
199 margin: 0;
200 padding: 0;
201 border-bottom: 0;
202 display: inline-block;
203 }
204 details.card-collapsible[open] > summary {
205 border-bottom: 1px solid var(--border);
206 border-radius: var(--radius) var(--radius) 0 0;
207 }
208 details.card-collapsible > .card-body { padding: 14px 18px 18px; }
209
210 /* Prominent room code "hero" callout. */
211 .room-code-hero {
212 background: rgba(47,129,247,0.08);
213 border: 1px solid rgba(47,129,247,0.35);
214 border-radius: var(--radius);
215 padding: 18px 20px;
216 margin: 8px 0 4px;
217 }
218 .room-code-hero .hero-label {
219 display: block;
220 font-size: 12px;
221 font-weight: 600;
222 color: var(--accent);
223 text-transform: uppercase;
224 letter-spacing: 0.6px;
225 margin-bottom: 10px;
226 }
227 .room-code-input-row { display: flex; gap: 8px; align-items: stretch; }
228 .room-code-input-row input {
229 flex: 1;
230 font-family: var(--mono);
231 font-size: 15px;
232 font-weight: 600;
233 padding: 9px 12px;
234 letter-spacing: 0.5px;
235 text-transform: lowercase;
236 background: var(--bg);
237 }
238 .room-code-input-row button {
239 padding: 0 14px;
240 font-size: 16px;
241 background: var(--bg);
242 }
243 .room-code-hero .hero-help { margin: 10px 0 0; font-size: 12px; color: var(--text-dim); }
244 .room-code-hero .hero-help code { background: rgba(47,129,247,0.12); padding: 1px 4px; border-radius: 3px; }
245
246 .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
247 .opus-list { display: flex; flex-direction: column; gap: 14px; }
248 .opus-item { display: flex; flex-direction: column; gap: 2px; }
249 .opus-item > .small { margin: 0 0 0 22px; color: var(--text-dim); }
250 .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
251 @media (max-width: 720px) {
252 .grid-2, .grid-3 { grid-template-columns: 1fr; }
253 }
254 .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
255 .role-picker { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px; }
256 .role-picker button {
257 padding: 22px;
258 text-align: left;
259 border-radius: var(--radius);
260 border: 1px solid var(--border);
261 background: var(--bg-elev);
262 display: flex; flex-direction: column; gap: 6px;
263 transition: border-color 120ms, transform 120ms;
264 }
265 .role-picker button:hover { border-color: var(--accent); transform: translateY(-1px); }
266 .role-picker .role-title { font-size: 16px; font-weight: 600; }
267 .role-picker .role-desc { color: var(--text-dim); font-size: 13px; }
268 .role-picker .role-loopback { grid-column: 1 / -1; }
269
270 /* Two-option segmented toggle (signaling mode). */
271 .seg-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; margin: 4px 0 10px; }
272 .seg-toggle button {
273 border: none; border-radius: 0; background: transparent; color: var(--text-dim);
274 padding: 8px 14px; font-weight: 500;
275 }
276 .seg-toggle button + button { border-left: 1px solid var(--border); }
277 .seg-toggle button.active { background: rgba(47,129,247,0.15); color: var(--accent); }
278 .seg-toggle button:hover:not(.active) { background: var(--bg-elev-2); }
279
280 .ice-row { display: grid; grid-template-columns: 2fr 1fr 1fr auto; gap: 8px; margin-bottom: 6px; }
281 .ice-row input { width: 100%; }
282 @media (max-width: 720px) { .ice-row { grid-template-columns: 1fr; } }
283
284 .blob-area { display: flex; flex-direction: column; gap: 8px; }
285 .blob-area textarea { min-height: 180px; }
286 .blob-controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
287 .pill { padding: 2px 8px; border-radius: 12px; font-size: 11px; background: var(--bg-elev-2); color: var(--text-dim); }
288 .pill:empty { display: none; }
289 .pill.ok { background: rgba(63,185,80,0.15); color: var(--ok); }
290 .pill.warn { background: rgba(210,153,34,0.15); color: var(--warn); }
291 .pill.err { background: rgba(248,81,73,0.15); color: var(--err); }
292
293 .progress { height: 6px; background: var(--bg-elev-2); border-radius: 3px; overflow: hidden; margin: 8px 0; }
294 .progress > div { height: 100%; background: var(--accent); width: 0%; transition: width 200ms ease; }
295
296 /* Call view — flex:1 inside #app fills viewport minus the topbar; nothing
297 scrolls except internal panes (chat log, files list, settings, stats). */
298 #view-call { display: flex; flex-direction: column; overflow: hidden; min-height: 0; }
299 .call-body {
300 flex: 1; min-height: 0; min-width: 0;
301 display: grid;
302 grid-template-columns: 1fr 360px;
303 grid-template-rows: minmax(0, 1fr);
304 overflow: hidden;
305 }
306 @media (max-width: 900px) {
307 .call-body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
308 }
309 .video-area {
310 background: #000;
311 position: relative;
312 display: grid;
313 grid-template-columns: 1fr 1fr;
314 grid-template-rows: minmax(0, 1fr);
315 gap: 8px;
316 padding: 12px;
317 min-height: 0;
318 min-width: 0;
319 overflow: hidden;
320 }
321 .video-tile { position: relative; background: #050608; border-radius: var(--radius); overflow: hidden; min-height: 0; display: flex; align-items: center; justify-content: center; }
322 .video-tile > video { width: 100%; height: 100%; object-fit: contain; background: #050608; display: block; }
323 .video-tile.screen > video { cursor: zoom-in; }
324 .video-tile.screen > video:fullscreen { cursor: zoom-out; }
325 .video-tile .tile-label {
326 position: absolute; left: 8px; bottom: 8px;
327 background: rgba(0,0,0,0.55); color: white;
328 padding: 3px 8px; border-radius: 12px; font-size: 11px;
329 letter-spacing: 0.4px;
330 pointer-events: none;
331 }
332 .video-tile .empty-state {
333 position: absolute; inset: 0;
334 display: none; align-items: center; justify-content: center;
335 color: var(--text-faint);
336 pointer-events: none;
337 }
338 .video-tile.empty .empty-state { display: flex; }
339 .video-tile.empty > video { display: none; }
340 .video-tile .mic-muted {
341 position: absolute;
342 bottom: 8px; right: 8px;
343 background: rgba(0,0,0,0.55);
344 color: white;
345 border-radius: 50%;
346 width: 28px; height: 28px;
347 display: flex; align-items: center; justify-content: center;
348 pointer-events: none;
349 z-index: 2;
350 }
351 /* When a PIP is visible in the bottom-right corner, move the mic-muted
352 badge to the top-right so they don't overlap. */
353 .video-tile:has(.pip:not(.hidden)) .mic-muted { bottom: auto; top: 8px; }
354 .video-tile .mic-muted.hidden { display: none; }
355 .video-tile .pip {
356 position: absolute;
357 right: 10px; bottom: 10px;
358 width: 22%; max-width: 200px; min-width: 120px;
359 aspect-ratio: 16 / 9;
360 border-radius: 6px;
361 overflow: hidden;
362 background: #050608;
363 border: 1px solid rgba(255,255,255,0.15);
364 box-shadow: 0 4px 12px rgba(0,0,0,0.5);
365 z-index: 1;
366 }
367 .video-tile .pip.hidden { display: none; }
368 .video-tile .pip video { width: 100%; height: 100%; object-fit: cover; background: #050608; display: block; }
369 @media (max-width: 900px) {
370 .video-area { grid-template-columns: 1fr; grid-template-rows: repeat(2, minmax(0, 1fr)); }
371 }
372
373 .sidebar { background: var(--bg-elev); border-left: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
374 .tabs { display: flex; border-bottom: 1px solid var(--border); }
375 .tabs button {
376 flex: 1;
377 border: none;
378 background: transparent;
379 border-radius: 0;
380 border-bottom: 2px solid transparent;
381 padding: 12px 10px;
382 color: var(--text-dim);
383 }
384 .tabs button.active { color: var(--text); border-bottom-color: var(--accent); }
385 .tab-pane { flex: 1; overflow-y: auto; padding: 14px; min-height: 0; display: none; }
386 .tab-pane.active { display: flex; flex-direction: column; }
387
388 .toolbar {
389 flex: none;
390 display: flex; gap: 8px; justify-content: center; align-items: center;
391 flex-wrap: wrap;
392 padding: 10px 16px;
393 background: var(--bg-elev);
394 border-top: 1px solid var(--border);
395 }
396 .toolbar button { padding: 0 16px; height: 40px; min-width: 44px; display: flex; align-items: center; justify-content: center; gap: 6px; line-height: 1; }
397 .toolbar button > * { line-height: 1; }
398 .toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 140px; }
399 .toolbar .spacer { flex: 1; }
400 .toolbar button.on { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
401 .toolbar button.off { background: rgba(248,81,73,0.12); border-color: var(--err); color: var(--err); }
402
403 /* Device picker: chevron sits flush next to its primary toolbar button. */
404 .toolbar .device-picker { position: relative; display: flex; gap: 2px; }
405 .toolbar .device-chevron { min-width: 28px; padding: 0 6px; }
406 .toolbar .device-chevron::before {
407 content: '';
408 width: 12px; height: 12px;
409 background: currentColor;
410 -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;
411 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;
412 transition: transform 120ms ease;
413 }
414 .toolbar .device-chevron[aria-expanded="true"]::before { transform: rotate(180deg); }
415 .toolbar .device-chevron[aria-expanded="true"] { background: rgba(47,129,247,0.15); border-color: var(--accent); color: var(--accent); }
416 .device-menu {
417 position: absolute; bottom: calc(100% + 6px); left: 0;
418 min-width: 240px; max-width: min(360px, calc(100vw - 24px));
419 max-height: 50vh; overflow: auto;
420 background: var(--bg-elev); border: 1px solid var(--border); border-radius: 6px;
421 padding: 6px; z-index: 50;
422 box-shadow: 0 8px 24px rgba(0,0,0,0.4);
423 display: flex; flex-direction: column; gap: 2px;
424 }
425 .device-menu.hidden { display: none; }
426 .device-menu .device-row {
427 display: flex; align-items: center; gap: 8px;
428 padding: 6px 8px; border-radius: 4px; cursor: pointer;
429 font-size: 13px; line-height: 1.3;
430 }
431 .device-menu .device-row:hover { background: rgba(255,255,255,0.05); }
432 .device-menu .device-row input { margin: 0; flex: none; }
433 .device-menu .device-row .device-label { flex: 1; word-break: break-word; }
434 .device-menu .device-row .device-active {
435 color: var(--accent); font-size: 11px; flex: none;
436 display: inline-flex; align-items: center; gap: 4px;
437 }
438 .device-menu .device-empty { padding: 6px 8px; font-size: 12px; font-style: italic; color: var(--muted, #888); }
439 @media (max-width: 720px) {
440 /* Drop the spacer so the row collapses to its content, shrink button
441 padding/widths so all five buttons fit on one line at 500 px wide. */
442 .toolbar { padding: 8px 10px; gap: 6px; }
443 .toolbar .spacer { display: none; }
444 .toolbar button { padding: 0 10px; }
445 .toolbar #tb-mic, .toolbar #tb-cam, .toolbar #tb-screen { min-width: 0; }
446 .toolbar .device-chevron { min-width: 24px; padding: 0 4px; }
447 }
448
449 /* Chat */
450 .chat-log { flex: 1; overflow: auto; padding-right: 4px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
451 .chat-msg { padding: 8px 10px; border-radius: var(--radius-sm); background: var(--bg-elev-2); max-width: 80%; word-wrap: break-word; }
452 .chat-msg.me { align-self: flex-end; background: rgba(47,129,247,0.15); }
453 .chat-msg .meta { font-size: 10px; color: var(--text-faint); margin-top: 3px; }
454 .chat-input { display: flex; gap: 6px; margin-top: 8px; }
455 .chat-input input { flex: 1; }
456 .chat-counter { color: var(--text-faint); margin-top: 4px; text-align: right; font-variant-numeric: tabular-nums; }
457 .chat-counter.over { color: var(--err); }
458
459 /* Files */
460 .files-drop {
461 border: 2px dashed var(--border-strong);
462 border-radius: var(--radius);
463 padding: 18px;
464 text-align: center;
465 color: var(--text-dim);
466 margin-bottom: 12px;
467 transition: border-color 120ms, background 120ms;
468 }
469 .files-drop.over { border-color: var(--accent); background: rgba(47,129,247,0.06); }
470 .files-drop.disabled { opacity: 0.5; pointer-events: none; }
471 .files-section-head { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; }
472 .files-section-head h3 { margin: 0; }
473 .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; }
474 .file-item .name { font-weight: 500; }
475 .file-item .meta { color: var(--text-dim); font-size: 11px; }
476 .file-item .row-close {
477 position: absolute; top: 4px; right: 4px;
478 background: transparent; border: none; color: var(--text-faint);
479 width: 22px; height: 22px; padding: 0; border-radius: 4px;
480 cursor: pointer; font-size: 14px; line-height: 1;
481 }
482 .file-item .row-close:hover { background: var(--bg-elev); color: var(--text); }
483
484 /* Stats */
485 .stats-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
486 .stats-table td { padding: 4px 6px; border-bottom: 1px solid var(--border); }
487 .stats-table td:first-child { color: var(--text-dim); width: 45%; }
488
489 /* Modal dialog (native <dialog>) — used for peer-left notification. */
490 dialog {
491 background: var(--bg-elev);
492 color: var(--text);
493 border: 1px solid var(--border-strong);
494 border-radius: var(--radius);
495 padding: 22px 24px;
496 max-width: 420px;
497 box-shadow: 0 12px 40px rgba(0,0,0,0.5);
498 }
499 dialog::backdrop { background: rgba(0,0,0,0.55); }
500 dialog h2 { margin: 0 0 8px; padding: 0; border: none; font-size: 17px; }
501 dialog p { margin: 0 0 16px; color: var(--text-dim); }
502 .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
503 .step-dialog-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
504 .step-dialog-head h2 { margin: 0; }
505 .step-dialog-room {
506 display: flex; align-items: center; gap: 10px;
507 margin: 0 0 16px;
508 padding: 10px 12px;
509 background: var(--bg-elev-2);
510 border: 1px solid var(--border);
511 border-radius: var(--radius-sm);
512 }
513 .step-dialog-room.hidden { display: none; }
514 .step-dialog-room-label { color: var(--text-dim); font-size: 12px; }
515 .step-dialog-room code {
516 font-family: var(--mono); font-size: 14px;
517 color: var(--text); user-select: all;
518 }
519
520 /* Console drawer — sits below the toolbar inside #view-call so opening it
521 shrinks the call area instead of overlapping it. */
522 #console-drawer {
523 flex: none;
524 height: 35vh; min-height: 200px; max-height: 50vh;
525 background: var(--bg-elev); border-top: 1px solid var(--border-strong);
526 display: flex; flex-direction: column;
527 min-width: 0;
528 }
529 #console-drawer.hidden { display: none; }
530 .console-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
531 .console-head .spacer { flex: 1; }
532 .console-head select { padding: 4px 8px; font-size: 12px; }
533 .console-head label.small { display: inline-flex; align-items: center; gap: 4px; }
534 .console-head #console-filter { width: 120px; min-width: 80px; }
535 @media (max-width: 720px) {
536 .console-head { padding: 6px 10px; gap: 6px; }
537 /* Hide the "Level" / "Filter" inline label text — the dropdown and the
538 placeholder make the controls self-evident. */
539 .console-head label.small { font-size: 0; }
540 .console-head label.small > * { font-size: 12px; }
541 .console-head #console-filter { width: 100px; }
542 }
543 .console-body { flex: 1; overflow: auto; padding: 6px 12px; font-family: var(--mono); font-size: 11px; }
544 .log-line { padding: 2px 0; white-space: pre-wrap; word-break: break-word; }
545 .log-line .ts { color: var(--text-faint); margin-right: 6px; }
546 .log-line .lvl { display: inline-block; min-width: 34px; padding: 0 5px; border-radius: 3px; margin-right: 6px; font-size: 9px; text-align: center; }
547 .log-line.debug .lvl { background: var(--bg-elev-2); color: var(--text-dim); }
548 .log-line.info .lvl { background: rgba(47,129,247,0.15); color: var(--accent); }
549 .log-line.warn .lvl { background: rgba(210,153,34,0.15); color: var(--warn); }
550 .log-line.error .lvl { background: rgba(248,81,73,0.15); color: var(--err); }
551 .log-line .label { color: var(--text-dim); margin-right: 6px; }
552
553 .kbd { font-family: var(--mono); padding: 1px 5px; border-radius: 4px; background: var(--bg-elev-2); border: 1px solid var(--border); font-size: 11px; }
554 .small { font-size: 12px; color: var(--text-dim); }
555 .nowrap { white-space: nowrap; }
556 .mono { font-family: var(--mono); }
557
558 /* Inline progress (Continue to signaling, Apply offer) */
559 .progress-row {
560 display: flex; align-items: center; gap: 10px;
561 margin-top: 12px;
562 padding: 10px 12px;
563 background: var(--bg-elev);
564 border: 1px solid var(--border);
565 border-radius: var(--radius-sm);
566 color: var(--text);
567 font-size: 13px;
568 }
569 .progress-row.hidden { display: none; }
570 .spinner {
571 width: 14px; height: 14px; flex: none;
572 border: 2px solid var(--border-strong);
573 border-top-color: var(--accent);
574 border-radius: 50%;
575 animation: spin 0.8s linear infinite;
576 }
577 @keyframes spin { to { transform: rotate(360deg); } }
578 .progress-row .sub { color: var(--text-dim); font-size: 12px; }
579
580 /* Insecure-context warning */
581 .warn-banner {
582 margin-bottom: 16px;
583 padding: 12px 14px;
584 background: rgba(210,153,34,0.1);
585 border: 1px solid rgba(210,153,34,0.4);
586 border-radius: var(--radius-sm);
587 color: var(--warn);
588 font-size: 13px;
589 }
590 .warn-banner.hidden { display: none; }
591 .warn-banner strong { color: var(--warn); }
592
593 ::-webkit-scrollbar { width: 10px; height: 10px; }
594 ::-webkit-scrollbar-track { background: transparent; }
595 ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
596 ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
597</style>
598</head>
599<body>
600
601<main id="app">
602 <header class="topbar">
603 <div class="brand">WebRTC Tool <small>direct peer-to-peer audio, video, chat, and files</small></div>
604 <div class="right">
605 <span id="role-badge" class="role-badge hidden"></span>
606 <span id="conn-pill" class="pill">disconnected</span>
607 <button class="icon" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
608 <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>
609 <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>
610 </button>
611 </div>
612 </header>
613
614 <!-- ============== WELCOME ============== -->
615 <section id="view-welcome" class="view">
616 <div class="container">
617 <h1>WebRTC Tool</h1>
618 <p class="lede">
619 A direct peer-to-peer call tool — a fallback for video, screen share, chat, and file transfer
620 when your usual conferencing software isn't cooperating. Two peers can connect either via a
621 shared room code (using a small relay server that only shuttles the offer/answer) or by
622 pasting two short JSON blobs to each other. Browser-only on each end: no install, no account.
623 </p>
624
625 <div class="card">
626 <h2>Pick your role</h2>
627 <p class="small">One side starts the call (creates the offer); the other side joins it.</p>
628 <div class="role-picker">
629 <button data-role="initiator">
630 <span class="role-title">Start a call</span>
631 <span class="role-desc">You'll generate the offer. Connect via a shared room code, or send your peer the JSON blob and apply the answer they send back.</span>
632 </button>
633 <button data-role="joiner">
634 <span class="role-title">Join a call</span>
635 <span class="role-desc">You'll apply your peer's offer. Connect via the room code they share, or paste the JSON blob they send and reply with the generated answer.</span>
636 </button>
637 <button data-role="loopback" class="role-loopback">
638 <span class="role-title">Loopback test (same tab)</span>
639 <span class="role-desc">Run both peers in this tab. Useful for verifying that media and the call UI work locally.</span>
640 </button>
641 </div>
642 </div>
643
644 <div class="card">
645 <h2>Tools</h2>
646 <div class="actions">
647 <button id="welcome-sdp-inspect" class="ghost">SDP inspector →</button>
648 </div>
649 <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>
650 </div>
651 </div>
652 </section>
653
654 <!-- ============== CONFIGURE ============== -->
655 <section id="view-configure" class="view hidden">
656 <div class="container">
657 <h1>Configure <span id="role-title-cfg" class="role-badge"></span></h1>
658 <p class="lede" id="cfg-lede">Set up media and ICE servers, then continue to the signaling step.</p>
659
660 <div id="insecure-warn" class="warn-banner hidden">
661 <strong>Insecure context:</strong>
662 <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>
663 </div>
664
665 <div class="card" id="signaling-card">
666 <h2>Signaling</h2>
667 <div class="seg-toggle" role="tablist" aria-label="Signaling mode">
668 <button type="button" id="sig-mode-auto" role="tab" class="active">Auto via server (room code)</button>
669 <button type="button" id="sig-mode-manual" role="tab">Manual SDP exchange</button>
670 </div>
671 <p class="small hidden" id="sig-help-manual">
672 You and your peer copy two JSON blobs (offer and answer) between yourselves through any
673 channel — email, chat, paper. Nothing leaves the browser except the call itself. Use this when
674 you don't want to (or can't) run a server, or when you want to inspect the SDP.
675 </p>
676 <p class="small" id="sig-help-auto">
677 Both peers enter the same short <em>room code</em> on a small relay server that just shuttles
678 the offer/answer pair. The server never sees media or chat — those still flow peer-to-peer.
679 Whoever opens the room first becomes the initiator; the second peer joins. The relay holds
680 each blob for 10 minutes and forgets it after.
681 </p>
682
683 <div id="sig-auto-fields" class="hidden">
684 <div class="room-code-hero">
685 <label class="hero-label" for="sig-room-code">Enter a room code to connect</label>
686 <div class="room-code-input-row">
687 <input type="text" id="sig-room-code" placeholder="e.g. blue-fish-42" spellcheck="false" autocomplete="off" maxlength="15">
688 <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>
689 </div>
690 <p class="hero-help">Any short alphanumeric string (max 15 chars, letters/digits/<code>-_</code>). Share it with your peer through any channel.</p>
691 </div>
692
693 <label class="field" style="max-width:420px; margin-top:14px">
694 <span>Server URL</span>
695 <div class="row" style="gap:6px; align-items:stretch">
696 <input type="url" id="sig-server-url" placeholder="https://example.com:8080" spellcheck="false" autocomplete="off" style="flex:1">
697 <button type="button" id="sig-check" class="ghost" title="Probe the server's /health endpoint">Check</button>
698 </div>
699 <span id="sig-check-status" class="pill hidden" style="margin-top:6px; align-self:flex-start"></span>
700 </label>
701 <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>
702 </div>
703
704 <details style="margin-top:14px">
705 <summary>Advanced</summary>
706 <div class="details-body">
707 <label class="field" style="max-width:260px">
708 <span>ICE gathering timeout (seconds)</span>
709 <input type="number" id="sig-ice-timeout" min="0" step="1" placeholder="8">
710 </label>
711 <p class="small">
712 Hard cap on how long the page waits for ICE candidate gathering before exporting the SDP.
713 Use <code>0</code> to wait indefinitely — useful when you want every candidate (e.g. slow TURN
714 relays) but expect to abort manually if gathering stalls. Default is 8 seconds.
715 </p>
716 </div>
717 </details>
718 </div>
719
720 <details class="card card-collapsible">
721 <summary><h2>ICE / TURN servers</h2></summary>
722 <div class="card-body">
723 <p class="small">
724 Leave the STUN default for typical LAN/internet use. Add TURN servers for cross-NAT peers.
725 Empty list means fully-local (host candidates only) — works on the same LAN or same machine.
726 </p>
727 <div id="ice-rows"></div>
728 <div class="actions">
729 <button id="ice-add" class="ghost">+ Add server</button>
730 <button id="ice-clear" class="ghost">Clear all</button>
731 <button id="ice-reset" class="ghost">Reset to default</button>
732 <button id="ice-toggle-json" class="ghost">Edit as JSON…</button>
733 </div>
734 <div id="ice-json-wrap" class="hidden" style="margin-top:10px">
735 <label class="field">
736 <span>RTCIceServer[] JSON</span>
737 <textarea id="ice-json" spellcheck="false"></textarea>
738 </label>
739 <div class="actions">
740 <button id="ice-json-apply" class="primary">Apply JSON</button>
741 </div>
742 </div>
743
744 <h3 style="margin-top:18px">LAN connectivity</h3>
745 <p class="small">
746 For privacy, browsers (especially Firefox) restrict WebRTC ICE candidates to the default network
747 interface while no microphone or camera stream is active on the page. If both peers are on the
748 same network and you don't have a TURN server, enabling this opens a muted microphone stream
749 (no audio is captured or sent) so the ICE agent can see all your local interfaces and direct
750 LAN candidates can be exchanged. The OS will indicate the microphone is in use; stop it from
751 here, the toolbar, or by hanging up.
752 </p>
753 <div class="actions">
754 <button id="ice-warmup" class="ghost">Enable LAN connectivity</button>
755 <span id="ice-warmup-status" class="pill">off</span>
756 </div>
757 </div>
758 </details>
759
760 <details class="card card-collapsible">
761 <summary><h2>Media</h2></summary>
762 <div class="card-body">
763 <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>
764
765 <label class="field" style="max-width:260px">
766 <span>Receive codec preference</span>
767 <select id="preferred-codec">
768 <option value="auto">auto (browser default)</option>
769 </select>
770 </label>
771 <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>
772
773 <label class="field" style="max-width:260px">
774 <span>Send codec</span>
775 <select id="send-codec">
776 <option value="auto">auto (browser default)</option>
777 </select>
778 </label>
779 <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>
780 <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>
781
782 <details>
783 <summary>Advanced Opus settings (SDP)</summary>
784 <div class="details-body">
785 <div class="opus-list">
786 <div class="opus-item">
787 <label class="row"><input type="checkbox" id="o-stereo"> stereo / sprop-stereo</label>
788 <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>
789 </div>
790 <div class="opus-item">
791 <label class="row"><input type="checkbox" id="o-fec" checked> useinbandfec</label>
792 <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>
793 </div>
794 <div class="opus-item">
795 <label class="row"><input type="checkbox" id="o-dtx" checked> usedtx</label>
796 <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>
797 </div>
798 <div class="opus-item">
799 <label class="row"><input type="checkbox" id="o-cbr"> cbr (constant bitrate)</label>
800 <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>
801 </div>
802 <div class="opus-item">
803 <label class="field">
804 <span>maxaveragebitrate (bits/s; 0 = unset)</span>
805 <input type="number" id="o-maxbr" placeholder="0" min="0" max="510000" step="1000">
806 </label>
807 <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>
808 </div>
809 </div>
810 </div>
811 </details>
812
813 </div>
814 </details>
815
816 <div class="actions">
817 <button id="cfg-back" class="ghost">← Back</button>
818 <button id="cfg-continue" class="primary">Continue to signaling →</button>
819 </div>
820
821 <div id="cfg-progress" class="progress-row hidden" role="status" aria-live="polite">
822 <span class="spinner"></span>
823 <span>
824 <span id="cfg-progress-label">Working…</span>
825 <span id="cfg-progress-sub" class="sub"></span>
826 </span>
827 </div>
828 </div>
829 </section>
830
831 <!-- ============== EXCHANGE ============== -->
832 <section id="view-exchange" class="view hidden">
833 <div class="container">
834 <h1>Signaling exchange <span id="role-title-exch" class="role-badge"></span></h1>
835 <p class="lede" id="exch-lede"></p>
836
837 <div class="card">
838 <h2 id="step-1-h">Step 1</h2>
839 <div id="step-1-body" class="blob-area">
840 <!-- filled by JS -->
841 </div>
842 </div>
843
844 <div class="card" id="step-2-card">
845 <h2 id="step-2-h">Step 2</h2>
846 <div id="step-2-body" class="blob-area">
847 <!-- filled by JS -->
848 </div>
849 </div>
850
851 <div class="actions">
852 <button id="exch-cancel" class="ghost">Cancel</button>
853 </div>
854
855 <div id="exch-progress" class="progress-row hidden" role="status" aria-live="polite">
856 <span class="spinner"></span>
857 <span>
858 <span id="exch-progress-label">Working…</span>
859 <span id="exch-progress-sub" class="sub"></span>
860 </span>
861 </div>
862 </div>
863 </section>
864
865 <!-- ============== SDP INSPECTOR ============== -->
866 <section id="view-sdp-inspect" class="view hidden">
867 <div class="container">
868 <h1>SDP inspector</h1>
869 <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>
870
871 <div class="card">
872 <h2>Input</h2>
873 <textarea id="sdp-in" spellcheck="false" placeholder="Paste SDP / JSON / b64:… here"></textarea>
874 <div class="blob-controls">
875 <button id="sdp-inspect-go" class="primary">Inspect</button>
876 <button id="sdp-inspect-clear" class="ghost">Clear</button>
877 <button id="sdp-inspect-upload" class="ghost">Upload…</button>
878 <input id="sdp-inspect-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
879 <span class="pill" id="sdp-inspect-status"></span>
880 </div>
881 </div>
882
883 <div id="sdp-inspect-out"></div>
884
885 <div class="actions">
886 <button id="sdp-inspect-back" class="ghost">← Back</button>
887 </div>
888 </div>
889 </section>
890
891 <!-- ============== CALL ============== -->
892 <section id="view-call" class="view hidden">
893 <div class="call-body">
894 <div class="video-area">
895 <div class="video-tile empty" id="tile-local">
896 <video id="vid-local-main" autoplay muted playsinline></video>
897 <div class="pip hidden" id="pip-local">
898 <video id="vid-local-pip" autoplay muted playsinline></video>
899 </div>
900 <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>
901 <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>
902 <span class="tile-label">you</span>
903 </div>
904 <div class="video-tile empty" id="tile-remote">
905 <video id="vid-remote-main" autoplay muted playsinline></video>
906 <div class="pip hidden" id="pip-remote">
907 <video id="vid-remote-pip" autoplay muted playsinline></video>
908 </div>
909 <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>
910 <div class="mic-muted hidden" id="mic-muted-remote" 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>
911 <span class="tile-label">peer</span>
912 </div>
913 </div>
914 <!-- Plays the peer's audio independently of any video element so it
915 keeps working even when the remote tile is showing the empty state
916 (i.e. mic-only call) — display:none on a <video> suppresses audio
917 output in some browsers (notably Firefox). -->
918 <audio id="audio-remote" autoplay></audio>
919
920 <aside class="sidebar">
921 <div class="tabs">
922 <button data-tab="chat" class="active">Chat</button>
923 <button data-tab="files">Files</button>
924 <button data-tab="settings">Settings</button>
925 <button data-tab="stats">Stats</button>
926 </div>
927
928 <div class="tab-pane active" data-pane="chat">
929 <div id="chat-log" class="chat-log"></div>
930 <div class="chat-input">
931 <input type="text" id="chat-text" placeholder="Type a message and press Enter" autocomplete="off">
932 <button id="chat-send" class="primary">Send</button>
933 </div>
934 <div id="chat-counter" class="chat-counter small">0 / 8192 B</div>
935 </div>
936
937 <div class="tab-pane" data-pane="files">
938 <div id="files-drop" class="files-drop">
939 <p><strong>Drop a file here</strong> or <a href="#" id="files-pick">pick one</a>.</p>
940 <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>
941 <input type="file" id="files-input" class="hidden">
942 </div>
943 <div class="files-section-head"><h3>Outgoing</h3><button class="ghost small" id="files-out-clear">Clear all</button></div>
944 <div id="files-out"></div>
945 <div class="files-section-head"><h3>Incoming</h3><button class="ghost small" id="files-in-clear">Clear all</button></div>
946 <div id="files-in"></div>
947 </div>
948
949 <div class="tab-pane" data-pane="settings">
950 <h3>Video</h3>
951 <p class="small">Resolution and framerate are applied by restarting the camera. Bitrate and degradation preference apply instantly without touching the camera.</p>
952 <div class="grid-2">
953 <label class="field">
954 <span>Width (px, 0 = auto)</span>
955 <input type="number" id="rt-v-w" min="0" max="3840">
956 </label>
957 <label class="field">
958 <span>Height (px, 0 = auto)</span>
959 <input type="number" id="rt-v-h" min="0" max="2160">
960 </label>
961 <label class="field">
962 <span>Frame rate (fps, 0 = auto)</span>
963 <input type="number" id="rt-v-fps" min="0" max="120">
964 </label>
965 <label class="field">
966 <span>Max send bitrate (kbps, 0 = unset)</span>
967 <input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
968 </label>
969 <label class="field">
970 <span>Degradation preference</span>
971 <select id="rt-v-degrade">
972 <option value="balanced">balanced</option>
973 <option value="maintain-framerate">maintain-framerate</option>
974 <option value="maintain-resolution">maintain-resolution</option>
975 </select>
976 </label>
977 </div>
978 <button id="rt-v-apply" class="ghost">Apply</button>
979
980 <h3>Send codec</h3>
981 <p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
982 <div class="grid-2">
983 <label class="field">
984 <span>Send codec</span>
985 <select id="rt-codec">
986 <option value="auto">auto</option>
987 </select>
988 </label>
989 </div>
990 <button id="rt-codec-apply" class="ghost">Apply</button>
991
992 <h3>Screen share</h3>
993 <p class="small">Resolution and framerate are applied by restarting the share — you'll be re-prompted for the source. Bitrate and degradation preference apply instantly.</p>
994 <div class="grid-2">
995 <label class="field">
996 <span>Width (px, 0 = auto)</span>
997 <input type="number" id="rt-s-w" min="0" max="7680">
998 </label>
999 <label class="field">
1000 <span>Height (px, 0 = auto)</span>
1001 <input type="number" id="rt-s-h" min="0" max="4320">
1002 </label>
1003 <label class="field">
1004 <span>Frame rate (fps, 0 = auto)</span>
1005 <input type="number" id="rt-s-fps" min="0" max="120">
1006 </label>
1007 <label class="field">
1008 <span>Max send bitrate (kbps, 0 = unset)</span>
1009 <input type="number" id="rt-s-maxbr" min="0" max="50000" step="100">
1010 </label>
1011 <label class="field">
1012 <span>Degradation preference</span>
1013 <select id="rt-s-degrade">
1014 <option value="balanced">balanced</option>
1015 <option value="maintain-framerate">maintain-framerate</option>
1016 <option value="maintain-resolution">maintain-resolution</option>
1017 </select>
1018 </label>
1019 </div>
1020 <button id="rt-s-apply" class="ghost">Apply</button>
1021
1022 <h3>Audio</h3>
1023 <p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
1024 <label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
1025 <label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
1026 <label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
1027 <div class="grid-2">
1028 <label class="field">
1029 <span>Channels</span>
1030 <select id="rt-a-channels">
1031 <option value="1">1 (mono)</option>
1032 <option value="2">2 (stereo)</option>
1033 </select>
1034 </label>
1035 <label class="field">
1036 <span>Sample rate (Hz, 0 = auto)</span>
1037 <input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
1038 </label>
1039 </div>
1040 <button id="rt-a-apply" class="ghost">Apply</button>
1041 </div>
1042
1043 <div class="tab-pane" data-pane="stats">
1044 <h3>Peer connection <button id="stats-export" class="ghost small" style="float:right">Export</button></h3>
1045 <table class="stats-table" id="stats-table"><tbody></tbody></table>
1046 </div>
1047 </aside>
1048 </div>
1049
1050 <div class="toolbar">
1051 <div class="device-picker">
1052 <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>
1053 <button id="tb-mic-pick" class="device-chevron" title="Choose microphone" aria-haspopup="true" aria-expanded="false"></button>
1054 <div id="tb-mic-menu" class="device-menu hidden" role="menu" aria-label="Microphone"></div>
1055 </div>
1056 <div class="device-picker">
1057 <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>
1058 <button id="tb-cam-pick" class="device-chevron" title="Choose camera" aria-haspopup="true" aria-expanded="false"></button>
1059 <div id="tb-cam-menu" class="device-menu hidden" role="menu" aria-label="Camera"></div>
1060 </div>
1061 <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>
1062 <span class="spacer"></span>
1063 <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>
1064 <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>
1065 </div>
1066 <aside id="console-drawer" class="hidden">
1067 <div class="console-head">
1068 <strong>Debug console</strong>
1069 <span class="small" id="console-count">0 entries</span>
1070 <span class="spacer"></span>
1071 <label class="small">Level
1072 <select id="console-level">
1073 <option value="debug">debug</option>
1074 <option value="info" selected>info</option>
1075 <option value="warn">warn</option>
1076 <option value="error">error</option>
1077 </select>
1078 </label>
1079 <label class="small">Filter <input type="text" id="console-filter" placeholder="text"></label>
1080 <button class="ghost small" id="console-clear">Clear</button>
1081 <button class="ghost small" id="console-export">Export</button>
1082 <button class="ghost small" id="console-stats-toggle">Stats poll</button>
1083 <button class="ghost small" id="console-close">×</button>
1084 </div>
1085 <div class="console-body" id="console-body"></div>
1086 </aside>
1087 </section>
1088
1089 <dialog id="peer-left-dialog">
1090 <h2>Peer left the call</h2>
1091 <p>The other person hung up. The call has ended.</p>
1092 <div class="dialog-actions">
1093 <button id="peer-left-ok" class="primary">OK</button>
1094 </div>
1095 </dialog>
1096
1097 <dialog id="step-dialog">
1098 <div class="step-dialog-head">
1099 <span class="spinner" aria-hidden="true"></span>
1100 <h2 id="step-dialog-label">Working…</h2>
1101 </div>
1102 <p id="step-dialog-sub"></p>
1103 <div id="step-dialog-room" class="step-dialog-room hidden">
1104 <span class="step-dialog-room-label">Room code</span>
1105 <code id="step-dialog-room-code"></code>
1106 </div>
1107 <div class="dialog-actions">
1108 <button id="step-dialog-cancel" class="ghost">Cancel</button>
1109 </div>
1110 </dialog>
1111
1112</main>
1113
1114<script>
1115'use strict';
1116
1117/* =========================================================================
1118 WebRTC Playground — single-file, backend-less.
1119
1120 Module layout (all under one `App` namespace):
1121 App.log — ring-buffer log + console drawer rendering
1122 App.theme — light/dark
1123 App.state — current view / role / pc / dc / settings
1124 App.signal — blob format + wait for ICE complete
1125 App.codec — Opus SDP munging
1126 App.media — gUM/gDM, pre-allocated transceivers, replaceTrack
1127 App.chat — text over the chat data channel
1128 App.files — chunked file transfer over the files data channel
1129 App.stats — getStats() polling
1130 App.ui — view rendering, event wiring
1131========================================================================= */
1132
1133const App = {};
1134window.App = App; /* useful for poking from devtools */
1135
1136/* -------------------------------------------------------------------------
1137 Log
1138------------------------------------------------------------------------- */
1139App.log = (() => {
1140 const MAX = 1000;
1141 const buf = [];
1142 const subs = new Set();
1143 function push(level, label, args) {
1144 const entry = { level, label, ts: Date.now(), args };
1145 buf.push(entry);
1146 if (buf.length > MAX) buf.shift();
1147 subs.forEach(fn => { try { fn(entry); } catch (e) { /* swallow */ } });
1148 const m = level === 'debug' ? 'log' : level;
1149 try { console[m](`[${label}]`, ...args); } catch (_) {}
1150 }
1151 return {
1152 debug: (l, ...a) => push('debug', l, a),
1153 info: (l, ...a) => push('info', l, a),
1154 warn: (l, ...a) => push('warn', l, a),
1155 error: (l, ...a) => push('error', l, a),
1156 subscribe: fn => { subs.add(fn); return () => subs.delete(fn); },
1157 snapshot: () => buf.slice(),
1158 clear: () => { buf.length = 0; subs.forEach(fn => fn(null)); },
1159 };
1160})();
1161
1162window.addEventListener('error', e => App.log.error('window', e.message, e.filename + ':' + e.lineno));
1163window.addEventListener('unhandledrejection', e => App.log.error('promise', String(e.reason)));
1164
1165/* -------------------------------------------------------------------------
1166 Theme
1167------------------------------------------------------------------------- */
1168App.theme = (() => {
1169 const stored = localStorage.getItem('webrtc-tool.theme');
1170 if (stored) document.documentElement.setAttribute('data-theme', stored);
1171 return {
1172 toggle() {
1173 const cur = document.documentElement.getAttribute('data-theme') || 'dark';
1174 const next = cur === 'dark' ? 'light' : 'dark';
1175 document.documentElement.setAttribute('data-theme', next);
1176 localStorage.setItem('webrtc-tool.theme', next);
1177 }
1178 };
1179})();
1180
1181/* -------------------------------------------------------------------------
1182 Progress: inline labeled spinner shown during slow setup steps (gUM
1183 prompt, ICE gathering, etc.). Two hosts: configure view + exchange view.
1184------------------------------------------------------------------------- */
1185App.progress = (() => {
1186 let modalCancelHandler = null;
1187 function active() {
1188 /* Pick the progress widget inside the currently visible view. */
1189 const candidates = ['cfg-progress', 'exch-progress'];
1190 for (const id of candidates) {
1191 const el = document.getElementById(id);
1192 if (!el) continue;
1193 const view = el.closest('.view');
1194 if (view && !view.classList.contains('hidden')) return el;
1195 }
1196 return null;
1197 }
1198 function setBusy(busy) {
1199 /* Disable the primary action button(s) while a step is running so the
1200 user can't double-fire ICE gathering. */
1201 const ids = ['cfg-continue', 'cfg-back', 'blob-apply'];
1202 ids.forEach(id => {
1203 const el = document.getElementById(id);
1204 if (el) el.disabled = busy;
1205 });
1206 }
1207 function show(label, sub) {
1208 /* If the modal is up from a previous step, close it before falling back
1209 to the inline widget. */
1210 hideModal();
1211 const host = active();
1212 if (host) {
1213 host.classList.remove('hidden');
1214 host.querySelector('#' + host.id + '-label').textContent = label || 'Working…';
1215 host.querySelector('#' + host.id + '-sub').textContent = sub || '';
1216 }
1217 setBusy(true);
1218 }
1219 /* Modal version: used for long blocking steps (ICE gathering, waiting for
1220 peer) where the user needs an explicit Cancel affordance and where the
1221 inline widget would otherwise sit under a Continue button they can't
1222 reach. opts.roomCode, when set, is rendered in a highlighted block. */
1223 function showModal(label, sub, opts) {
1224 opts = opts || {};
1225 const dlg = document.getElementById('step-dialog');
1226 if (!dlg) return;
1227 /* Hide the inline widget if it happened to be up. */
1228 document.getElementById('cfg-progress')?.classList.add('hidden');
1229 document.getElementById('exch-progress')?.classList.add('hidden');
1230 document.getElementById('step-dialog-label').textContent = label || 'Working…';
1231 document.getElementById('step-dialog-sub').textContent = sub || '';
1232 const roomWrap = document.getElementById('step-dialog-room');
1233 if (opts.roomCode) {
1234 document.getElementById('step-dialog-room-code').textContent = opts.roomCode;
1235 roomWrap.classList.remove('hidden');
1236 } else {
1237 roomWrap.classList.add('hidden');
1238 }
1239 modalCancelHandler = opts.onCancel || null;
1240 setBusy(true);
1241 if (!dlg.open && typeof dlg.showModal === 'function') {
1242 try { dlg.showModal(); } catch (_) { /* already open */ }
1243 }
1244 }
1245 function hideModal() {
1246 const dlg = document.getElementById('step-dialog');
1247 if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
1248 modalCancelHandler = null;
1249 }
1250 function hide() {
1251 document.getElementById('cfg-progress')?.classList.add('hidden');
1252 document.getElementById('exch-progress')?.classList.add('hidden');
1253 hideModal();
1254 setBusy(false);
1255 }
1256 function triggerCancel() {
1257 const fn = modalCancelHandler;
1258 modalCancelHandler = null;
1259 if (fn) fn();
1260 }
1261 return { show, showModal, hide, hideModal, triggerCancel };
1262})();
1263
1264/* -------------------------------------------------------------------------
1265 State
1266------------------------------------------------------------------------- */
1267App.state = {
1268 role: null, /* 'initiator' | 'joiner' | 'loopback' */
1269 pc: null, /* primary RTCPeerConnection */
1270 pcB: null, /* loopback only: secondary RTCPeerConnection */
1271 dcChat: null,
1272 dcFiles: null,
1273 micTransceiver: null,
1274 camTransceiver: null,
1275 screenTransceiver: null,
1276 localStream: null, /* gUM result */
1277 screenStream: null,
1278 iceWarmupStream: null, /* kept alive (muted) to unlock LAN ICE candidates in Firefox */
1279 remoteStream: null, /* assembled from incoming audio + cam tracks */
1280 remoteScreenStream: null,/* assembled from incoming screen track */
1281 peerMediaState: { mic: false, cam: false, screen: false }, /* sent by peer over the chat dc */
1282 settings: defaultSettings(),
1283};
1284
1285function defaultSettings() {
1286 const stored = localStorage.getItem('webrtc-tool.iceServers');
1287 let ice;
1288 try { ice = stored ? JSON.parse(stored) : [{ urls: 'stun:stun.l.google.com:19302' }]; }
1289 catch (_) { ice = [{ urls: 'stun:stun.l.google.com:19302' }]; }
1290 return {
1291 iceServers: ice,
1292 audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
1293 opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
1294 video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
1295 screen: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'maintain-resolution' },
1296 preferredVideoCodec: 'auto',
1297 sendVideoCodec: 'auto',
1298 base64: false,
1299 signaling: loadSignaling(),
1300 };
1301}
1302
1303function loadSignaling() {
1304 /* Default server URL is the page's own origin — the natural assumption is
1305 that the relay is colocated with the static page. For file:// loads
1306 location.origin is "null"; fall back to blank so the user fills it in. */
1307 const defaultUrl = (location.protocol === 'http:' || location.protocol === 'https:')
1308 ? location.origin : '';
1309 const defaults = { mode: 'auto', serverUrl: defaultUrl, iceGatherTimeoutMs: 8000 };
1310 try {
1311 const stored = localStorage.getItem('webrtc-tool.signaling');
1312 if (stored) {
1313 const s = JSON.parse(stored);
1314 const t = Number(s.iceGatherTimeoutMs);
1315 return {
1316 mode: s.mode === 'manual' ? 'manual' : 'auto',
1317 serverUrl: typeof s.serverUrl === 'string' ? s.serverUrl : defaultUrl,
1318 /* 0 is a valid value (= no timeout); only fall back when missing/NaN/negative. */
1319 iceGatherTimeoutMs: Number.isFinite(t) && t >= 0 ? t : defaults.iceGatherTimeoutMs,
1320 };
1321 }
1322 } catch (_) {}
1323 return defaults;
1324}
1325
1326function saveSignaling() {
1327 try {
1328 localStorage.setItem('webrtc-tool.signaling', JSON.stringify({
1329 mode: App.state.settings.signaling.mode,
1330 serverUrl: App.state.settings.signaling.serverUrl,
1331 iceGatherTimeoutMs: App.state.settings.signaling.iceGatherTimeoutMs,
1332 }));
1333 } catch (_) {}
1334}
1335
1336function saveIce() {
1337 try { localStorage.setItem('webrtc-tool.iceServers', JSON.stringify(App.state.settings.iceServers)); } catch (_) {}
1338}
1339
1340/* -------------------------------------------------------------------------
1341 Signal: blob format + ICE-complete wait
1342------------------------------------------------------------------------- */
1343App.signal = (() => {
1344 function waitForIceComplete(pc, signal) {
1345 return new Promise(resolve => {
1346 if (pc.iceGatheringState === 'complete') return resolve();
1347 if (signal && signal.aborted) return resolve();
1348 let timeoutId = null;
1349 function done() {
1350 if (timeoutId !== null) { clearTimeout(timeoutId); timeoutId = null; }
1351 pc.removeEventListener('icegatheringstatechange', check);
1352 if (signal) signal.removeEventListener('abort', done);
1353 resolve();
1354 }
1355 function check() { if (pc.iceGatheringState === 'complete') done(); }
1356 pc.addEventListener('icegatheringstatechange', check);
1357 if (signal) signal.addEventListener('abort', done, { once: true });
1358 /* Hard cap so a never-completing gathering doesn't stall the UI forever.
1359 User-configurable in the Signaling → Advanced panel; 0 disables the
1360 cap entirely (the abort signal / setRemoteDescription is then the
1361 only way out). */
1362 const cap = App.state.settings.signaling.iceGatherTimeoutMs;
1363 if (cap > 0) {
1364 timeoutId = setTimeout(() => {
1365 if (pc.iceGatheringState !== 'complete')
1366 App.log.warn('signal', 'ICE gathering timed out at ' + (cap / 1000) + 's; exporting partial SDP');
1367 timeoutId = null;
1368 done();
1369 }, cap);
1370 }
1371 });
1372 }
1373 function utf8ToBase64(s) {
1374 const bytes = new TextEncoder().encode(s);
1375 /* btoa works on binary strings (one char = one byte). Convert through
1376 String.fromCharCode in 8 KB chunks to avoid blowing the argument limit. */
1377 let bin = '';
1378 for (let i = 0; i < bytes.length; i += 0x2000)
1379 bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x2000));
1380 return btoa(bin);
1381 }
1382 function base64ToUtf8(b64) {
1383 const bin = atob(b64);
1384 const bytes = new Uint8Array(bin.length);
1385 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1386 return new TextDecoder().decode(bytes);
1387 }
1388 function encode(desc) {
1389 const obj = { v: 1, type: desc.type, sdp: desc.sdp, ts: Date.now() };
1390 let text = JSON.stringify(obj);
1391 if (App.state.settings.base64) text = 'b64:' + utf8ToBase64(text);
1392 return text;
1393 }
1394 function decode(text) {
1395 text = (text || '').trim();
1396 if (!text) throw new Error('empty');
1397 if (text.startsWith('b64:')) {
1398 try { text = base64ToUtf8(text.slice(4)); }
1399 catch (e) { throw new Error('bad base64'); }
1400 }
1401 let obj;
1402 try { obj = JSON.parse(text); } catch (e) { throw new Error('not JSON: ' + e.message); }
1403 if (!obj || (obj.type !== 'offer' && obj.type !== 'answer'))
1404 throw new Error('expected {type:"offer"|"answer", sdp:...}');
1405 if (typeof obj.sdp !== 'string' || !obj.sdp.includes('v=')) throw new Error('missing SDP');
1406 return obj;
1407 }
1408 return { waitForIceComplete, encode, decode };
1409})();
1410
1411function iceGatheringSubtitle() {
1412 const cap = App.state.settings.signaling.iceGatherTimeoutMs;
1413 if (cap > 0) return 'Probing the network for routable addresses. Up to ~' + Math.round(cap / 1000) + ' seconds.';
1414 return 'Probing the network for routable addresses. No timeout — cancel manually if it stalls.';
1415}
1416
1417/* -------------------------------------------------------------------------
1418 Codec: Opus SDP munging
1419------------------------------------------------------------------------- */
1420App.codec = (() => {
1421 /* Find Opus payload type in the m=audio section, then ensure an fmtp line
1422 exists for it with the params we want. */
1423 function mungeOpus(sdp, opus) {
1424 if (!opus) return sdp;
1425 const lines = sdp.split(/\r?\n/);
1426 /* Locate audio m-section bounds */
1427 let audioStart = -1, audioEnd = lines.length;
1428 for (let i = 0; i < lines.length; i++) {
1429 if (lines[i].startsWith('m=audio')) { audioStart = i; }
1430 else if (audioStart >= 0 && lines[i].startsWith('m=') && i > audioStart) { audioEnd = i; break; }
1431 }
1432 if (audioStart < 0) return sdp;
1433
1434 /* Find Opus payload types */
1435 const opusPts = [];
1436 for (let i = audioStart; i < audioEnd; i++) {
1437 const m = lines[i].match(/^a=rtpmap:(\d+)\s+opus\/(\d+)(?:\/(\d+))?/i);
1438 if (m) opusPts.push(m[1]);
1439 }
1440 if (!opusPts.length) return sdp;
1441
1442 const params = [];
1443 if (opus.stereo) { params.push('stereo=1'); params.push('sprop-stereo=1'); }
1444 params.push('useinbandfec=' + (opus.fec ? 1 : 0));
1445 if (opus.dtx) params.push('usedtx=1');
1446 if (opus.cbr) params.push('cbr=1');
1447 if (opus.maxAverageBitrate && opus.maxAverageBitrate > 0)
1448 params.push('maxaveragebitrate=' + opus.maxAverageBitrate);
1449 const want = params.join(';');
1450
1451 for (const pt of opusPts) {
1452 let found = false;
1453 for (let i = audioStart; i < audioEnd; i++) {
1454 if (lines[i].startsWith('a=fmtp:' + pt + ' ')) {
1455 /* Merge: drop any of the keys we're setting, keep the rest, append ours */
1456 const existing = lines[i].slice(('a=fmtp:' + pt + ' ').length);
1457 const kept = existing.split(';')
1458 .map(s => s.trim()).filter(Boolean)
1459 .filter(kv => {
1460 const k = kv.split('=')[0].toLowerCase();
1461 return !['stereo','sprop-stereo','useinbandfec','usedtx','cbr','maxaveragebitrate'].includes(k);
1462 });
1463 const merged = [...kept, ...params.filter(Boolean)].join(';');
1464 lines[i] = 'a=fmtp:' + pt + ' ' + merged;
1465 found = true; break;
1466 }
1467 }
1468 if (!found) {
1469 /* Insert after the matching rtpmap */
1470 for (let i = audioStart; i < audioEnd; i++) {
1471 if (lines[i].match(new RegExp('^a=rtpmap:' + pt + '\\b'))) {
1472 lines.splice(i + 1, 0, 'a=fmtp:' + pt + ' ' + want);
1473 audioEnd++; break;
1474 }
1475 }
1476 }
1477 }
1478 return lines.join('\r\n');
1479 }
1480 return { mungeOpus };
1481})();
1482
1483/* -------------------------------------------------------------------------
1484 Media: gUM/gDM, pre-allocated transceivers, replaceTrack
1485------------------------------------------------------------------------- */
1486App.media = (() => {
1487 /* Reorder the codec list so the user's preferred video codec is first.
1488 Must be called before createOffer (initiator/loopback A) or createAnswer
1489 (joiner / loopback B). With 'auto' we don't touch the list, letting the
1490 browser's default order win. */
1491 function applyVideoCodecPreference(pc) {
1492 const pref = App.state.settings.preferredVideoCodec;
1493 if (!pref || pref === 'auto') return;
1494 if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
1495 const caps = RTCRtpSender.getCapabilities('video');
1496 if (!caps || !caps.codecs) return;
1497 const wanted = pref.toLowerCase();
1498 const head = [], tail = [];
1499 for (const c of caps.codecs) {
1500 const sub = (c.mimeType || '').toLowerCase().split('/')[1] || '';
1501 (sub === wanted ? head : tail).push(c);
1502 }
1503 if (!head.length) { App.log.warn('media', 'preferred codec not available', pref); return; }
1504 const ordered = [...head, ...tail];
1505 for (const t of pc.getTransceivers()) {
1506 const kind = (t.receiver && t.receiver.track && t.receiver.track.kind)
1507 || (t.sender && t.sender.track && t.sender.track.kind);
1508 const isVideo = kind === 'video'
1509 || t === App.state.camTransceiver
1510 || t === App.state.screenTransceiver;
1511 if (!isVideo || !t.setCodecPreferences) continue;
1512 try { t.setCodecPreferences(ordered); }
1513 catch (e) { App.log.warn('media', 'setCodecPreferences failed', e.message); }
1514 }
1515 App.log.info('media', 'preferred video codec', pref);
1516 }
1517
1518 /* Pre-allocate three transceivers on the initiator so all toolbar actions
1519 are renegotiation-free (see plan). The joiner gets matching m-sections
1520 from setRemoteDescription and we index transceivers by position. */
1521 function preallocate(pc) {
1522 App.state.micTransceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
1523 App.state.camTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
1524 App.state.screenTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
1525 App.log.debug('media', 'pre-allocated 1 audio + 2 video transceivers');
1526 }
1527 function adoptTransceiversFromRemote(pc) {
1528 /* For the joiner: after setRemoteDescription, transceivers exist in
1529 the same order the initiator added them (mid 0, 1, 2). They were
1530 auto-created by SRD and default to recvonly because the joiner has
1531 no local tracks yet — but later enabling mic/cam on a recvonly
1532 transceiver would never send. Force sendrecv so the answer SDP
1533 advertises bidirectional intent. */
1534 const ts = pc.getTransceivers();
1535 for (const t of ts) {
1536 try { t.direction = 'sendrecv'; }
1537 catch (e) { App.log.warn('media', 'could not upgrade transceiver to sendrecv', e.message); }
1538 }
1539 App.state.micTransceiver = ts[0] || null;
1540 App.state.camTransceiver = ts[1] || null;
1541 App.state.screenTransceiver = ts[2] || null;
1542 App.log.debug('media', 'adopted', ts.length, 'transceivers from remote SDP');
1543 }
1544
1545 /* The local preview MediaStream is a synthetic view: we add/remove tracks
1546 to it as the user enables/disables mic and camera from the toolbar. */
1547 function localStream() {
1548 if (!App.state.localStream) App.state.localStream = new MediaStream();
1549 return App.state.localStream;
1550 }
1551 function setLocalTrack(kind, track) {
1552 const s = localStream();
1553 s.getTracks().filter(t => t.kind === kind).forEach(t => s.removeTrack(t));
1554 if (track) s.addTrack(track);
1555 refreshLocalDisplay();
1556 }
1557 /* Decide what plays in the main tile vs the corner PIP for the local side.
1558 Screen share takes the main tile; the cam moves to the PIP. */
1559 function refreshLocalDisplay() {
1560 const main = document.getElementById('vid-local-main');
1561 const pip = document.getElementById('vid-local-pip');
1562 const pipWrap = document.getElementById('pip-local');
1563 const tile = document.getElementById('tile-local');
1564 const camStream = App.state.localStream;
1565 const hasCam = camStream && camStream.getVideoTracks().length > 0;
1566 const screenStream = App.state.screenStream;
1567 const hasScreen = !!screenStream;
1568 if (hasScreen) {
1569 main.srcObject = screenStream;
1570 tile.classList.add('screen');
1571 if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
1572 else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
1573 } else if (hasCam) {
1574 main.srcObject = camStream;
1575 tile.classList.remove('screen');
1576 pip.srcObject = null; pipWrap.classList.add('hidden');
1577 } else {
1578 main.srcObject = null;
1579 tile.classList.remove('screen');
1580 pip.srcObject = null; pipWrap.classList.add('hidden');
1581 }
1582 tile.classList.toggle('empty', !hasCam && !hasScreen);
1583 const micOn = !!(App.state.micTransceiver && App.state.micTransceiver.sender.track);
1584 document.getElementById('mic-muted-local').classList.toggle('hidden', micOn);
1585 }
1586 function refreshRemoteDisplay() {
1587 const main = document.getElementById('vid-remote-main');
1588 const pip = document.getElementById('vid-remote-pip');
1589 const pipWrap = document.getElementById('pip-remote');
1590 const tile = document.getElementById('tile-remote');
1591 const audioEl = document.getElementById('audio-remote');
1592 const camStream = App.state.remoteStream;
1593 const screenStream = App.state.remoteScreenStream;
1594 /* Always route the peer's audio through the dedicated audio element so it
1595 plays regardless of whether any video is currently visible. */
1596 if (audioEl && audioEl.srcObject !== camStream) audioEl.srcObject = camStream || null;
1597 /* Track presence isn't enough — replaceTrack(null) on the sender leaves
1598 the receiver's track in place (frozen on last frame). Trust the peer's
1599 broadcast media state when deciding whether to show video. */
1600 const peer = App.state.peerMediaState || { mic: false, cam: false, screen: false };
1601 const hasCam = peer.cam && camStream && camStream.getVideoTracks().length > 0;
1602 const hasScreen = peer.screen && screenStream && screenStream.getVideoTracks().length > 0;
1603 if (hasScreen) {
1604 main.srcObject = screenStream;
1605 tile.classList.add('screen');
1606 if (hasCam) { pip.srcObject = camStream; pipWrap.classList.remove('hidden'); }
1607 else { pip.srcObject = null; pipWrap.classList.add('hidden'); }
1608 } else if (hasCam) {
1609 main.srcObject = camStream;
1610 tile.classList.remove('screen');
1611 pip.srcObject = null; pipWrap.classList.add('hidden');
1612 } else {
1613 /* Keep audio attached even when not showing video, so the peer's mic
1614 still plays through. */
1615 main.srcObject = camStream || null;
1616 tile.classList.remove('screen');
1617 pip.srcObject = null; pipWrap.classList.add('hidden');
1618 }
1619 tile.classList.toggle('empty', !hasCam && !hasScreen);
1620 document.getElementById('mic-muted-remote').classList.toggle('hidden', peer.mic);
1621 }
1622 function mirrorToLoopback() {
1623 /* In loopback mode, pcB needs to "see" the same tracks pcA is sending so
1624 the remote tile gets video back. Called whenever a sender track changes. */
1625 if (!App.state.pcB) return;
1626 const ts = App.state.pcB.getTransceivers();
1627 const a = App.state.micTransceiver && App.state.micTransceiver.sender.track;
1628 const v = App.state.camTransceiver && App.state.camTransceiver.sender.track;
1629 const s = App.state.screenTransceiver && App.state.screenTransceiver.sender.track;
1630 const fail = label => err => App.log.warn('loopback', 'mirror ' + label + ' failed', err.message);
1631 if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic'));
1632 if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam'));
1633 if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen'));
1634 }
1635 function gumAvailable() {
1636 return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
1637 }
1638
1639 /* Detect "device unplugged while active": the track fires 'ended'. Flip the
1640 toolbar button off so the UI reflects reality and the user can pick a
1641 different device. We guard with sender.track === track to ignore the
1642 ended event that fires when *we* swap the track via replaceTrack(). */
1643 function attachTrackEndedHandler(track, kind) {
1644 const sender = kind === 'mic'
1645 ? App.state.micTransceiver && App.state.micTransceiver.sender
1646 : App.state.camTransceiver && App.state.camTransceiver.sender;
1647 track.addEventListener('ended', () => {
1648 if (!sender || sender.track !== track) return;
1649 const label = kind === 'mic' ? 'Microphone' : 'Camera';
1650 App.log.warn('media', kind + ' track ended unexpectedly');
1651 App.chat.appendSystem?.(label + ' disconnected.');
1652 if (kind === 'mic') setMic(false); else setCam(false);
1653 });
1654 }
1655
1656 async function setMic(on) {
1657 const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
1658 if (!sender) return;
1659 const btn = document.getElementById('tb-mic');
1660 if (on) {
1661 if (!gumAvailable()) {
1662 App.log.warn('media', 'mic unavailable (insecure context?)');
1663 App.chat.appendSystem?.('Microphone unavailable — this page must be served over HTTPS or localhost.');
1664 return;
1665 }
1666 const a = App.state.settings.audio;
1667 let stream = null;
1668 try {
1669 stream = await navigator.mediaDevices.getUserMedia({
1670 audio: {
1671 echoCancellation: a.echoCancellation,
1672 noiseSuppression: a.noiseSuppression,
1673 autoGainControl: a.autoGainControl,
1674 channelCount: a.channelCount || undefined,
1675 sampleRate: a.sampleRate || undefined,
1676 deviceId: a.deviceId ? { exact: a.deviceId } : undefined,
1677 },
1678 });
1679 const track = stream.getAudioTracks()[0];
1680 /* If the call was torn down while gUM was waiting on the user's
1681 permission prompt, the sender's pc is now closed and replaceTrack
1682 would reject — stop the track instead so the OS-level mic
1683 indicator turns off. */
1684 if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1685 throw new Error('call ended before mic prompt resolved');
1686 }
1687 await sender.replaceTrack(track);
1688 setLocalTrack('audio', track);
1689 attachTrackEndedHandler(track, 'mic');
1690 mirrorToLoopback();
1691 btn.classList.add('on'); btn.classList.remove('off');
1692 btn.querySelector('.nowrap').textContent = 'Mic on';
1693 App.log.info('media', 'mic on');
1694 applyMediaButtonAvailability?.();
1695 } catch (e) {
1696 /* If the saved device disappeared between sessions, fall back to the
1697 OS default rather than trapping the user with a broken preference. */
1698 if (e && e.name === 'OverconstrainedError' && a.deviceId) {
1699 App.log.warn('media', 'saved mic deviceId no longer available, clearing');
1700 a.deviceId = '';
1701 }
1702 App.log.error('media', 'mic getUserMedia failed', e.message);
1703 App.chat.appendSystem?.('Microphone failed: ' + e.message);
1704 if (stream) stream.getTracks().forEach(t => t.stop());
1705 }
1706 } else {
1707 if (sender.track) sender.track.stop();
1708 await sender.replaceTrack(null);
1709 setLocalTrack('audio', null);
1710 mirrorToLoopback();
1711 btn.classList.remove('on'); btn.classList.add('off');
1712 btn.querySelector('.nowrap').textContent = 'Mic off';
1713 App.log.info('media', 'mic off');
1714 }
1715 broadcastMediaState();
1716 }
1717
1718 async function setCam(on) {
1719 const sender = App.state.camTransceiver && App.state.camTransceiver.sender;
1720 if (!sender) return;
1721 const btn = document.getElementById('tb-cam');
1722 if (on) {
1723 if (!gumAvailable()) {
1724 App.log.warn('media', 'camera unavailable (insecure context?)');
1725 App.chat.appendSystem?.('Camera unavailable — this page must be served over HTTPS or localhost.');
1726 return;
1727 }
1728 const vs = App.state.settings.video;
1729 let stream = null;
1730 try {
1731 stream = await navigator.mediaDevices.getUserMedia({
1732 video: {
1733 width: vs.width || undefined,
1734 height: vs.height || undefined,
1735 frameRate: vs.frameRate || undefined,
1736 deviceId: vs.deviceId ? { exact: vs.deviceId } : undefined,
1737 },
1738 });
1739 const track = stream.getVideoTracks()[0];
1740 if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1741 throw new Error('call ended before camera prompt resolved');
1742 }
1743 await sender.replaceTrack(track);
1744 setLocalTrack('video', track);
1745 attachTrackEndedHandler(track, 'cam');
1746 applyCamSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
1747 mirrorToLoopback();
1748 btn.classList.add('on'); btn.classList.remove('off');
1749 btn.querySelector('.nowrap').textContent = 'Cam on';
1750 App.log.info('media', 'cam on');
1751 applyMediaButtonAvailability?.();
1752 } catch (e) {
1753 if (e && e.name === 'OverconstrainedError' && vs.deviceId) {
1754 App.log.warn('media', 'saved cam deviceId no longer available, clearing');
1755 vs.deviceId = '';
1756 }
1757 App.log.error('media', 'camera getUserMedia failed', e.message);
1758 App.chat.appendSystem?.('Camera failed: ' + e.message);
1759 if (stream) stream.getTracks().forEach(t => t.stop());
1760 }
1761 } else {
1762 if (sender.track) sender.track.stop();
1763 await sender.replaceTrack(null);
1764 setLocalTrack('video', null);
1765 mirrorToLoopback();
1766 btn.classList.remove('on'); btn.classList.add('off');
1767 btn.querySelector('.nowrap').textContent = 'Cam off';
1768 App.log.info('media', 'cam off');
1769 }
1770 broadcastMediaState();
1771 }
1772
1773 async function startScreenshare() {
1774 if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) {
1775 throw new Error('Screen share requires HTTPS (or localhost). Open this page over a secure context.');
1776 }
1777 const ss = App.state.settings.screen;
1778 const ms = await navigator.mediaDevices.getDisplayMedia({
1779 video: {
1780 width: ss.width || undefined,
1781 height: ss.height || undefined,
1782 frameRate: ss.frameRate || undefined,
1783 },
1784 audio: false,
1785 });
1786 /* If the call ended while the source picker was open, the user has
1787 already selected a source — release it instead of leaving it active. */
1788 if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1789 ms.getTracks().forEach(t => t.stop());
1790 throw new Error('call ended before screen share started');
1791 }
1792 App.state.screenStream = ms;
1793 const track = ms.getVideoTracks()[0];
1794 track.addEventListener('ended', () => stopScreenshare());
1795 if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(track);
1796 applyScreenSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
1797 mirrorToLoopback();
1798 refreshLocalDisplay();
1799 const sBtn = document.getElementById('tb-screen');
1800 sBtn.classList.add('on'); sBtn.classList.remove('off');
1801 sBtn.querySelector('.nowrap').textContent = 'Sharing';
1802 App.log.info('media', 'screenshare started');
1803 broadcastMediaState();
1804 }
1805 async function stopScreenshare() {
1806 const ms = App.state.screenStream;
1807 if (ms) ms.getTracks().forEach(t => t.stop());
1808 App.state.screenStream = null;
1809 if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(null);
1810 mirrorToLoopback();
1811 refreshLocalDisplay();
1812 const sBtn = document.getElementById('tb-screen');
1813 sBtn.classList.remove('on'); sBtn.classList.add('off');
1814 sBtn.querySelector('.nowrap').textContent = 'Screen off';
1815 App.log.info('media', 'screenshare stopped');
1816 broadcastMediaState();
1817 }
1818
1819 /* Collect the relevant senders for a given track kind ('cam' | 'screen'),
1820 including pcB's mirrored sender in loopback so the cap is visible there. */
1821 function sendersFor(kind) {
1822 const tIdx = kind === 'screen' ? 2 : 1;
1823 const local = kind === 'screen' ? App.state.screenTransceiver : App.state.camTransceiver;
1824 const out = [];
1825 if (local) out.push(local.sender);
1826 if (App.state.pcB) {
1827 const t = App.state.pcB.getTransceivers()[tIdx];
1828 if (t) out.push(t.sender);
1829 }
1830 return out;
1831 }
1832 /* setParameters is transactional via an internal transactionId attached to
1833 the params object returned by getParameters. Two interleaved
1834 get/mutate/set cycles on the same sender race and the second can fail
1835 with InvalidModificationError. Coalesce bitrate + degradation into one
1836 round-trip per sender, and serialize calls via a per-sender chain. */
1837 const pendingByS = new WeakMap(); /* sender -> Promise (most recent set) */
1838 function applyVideoSendParams(kind, logLabel) {
1839 const cfg = kind === 'screen' ? App.state.settings.screen : App.state.settings.video;
1840 const kbps = cfg.maxBitrateKbps;
1841 const pref = cfg.degradationPreference;
1842 const wantCodec = (App.state.settings.sendVideoCodec || 'auto').toLowerCase();
1843 const senders = sendersFor(kind);
1844 for (const sender of senders) {
1845 const prev = pendingByS.get(sender) || Promise.resolve();
1846 const next = prev.then(() => {
1847 const params = sender.getParameters();
1848 if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
1849 if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
1850 else delete params.encodings[0].maxBitrate;
1851 if (pref) params.degradationPreference = pref;
1852 /* encodings[0].codec is the "set sending codec" API. Pick from the
1853 negotiated codec list on the sender — if the codec we want isn't
1854 there (e.g. peer didn't offer it, or browser doesn't expose
1855 params.codecs yet), leave the field unset so the browser keeps
1856 picking automatically. */
1857 if (wantCodec === 'auto') {
1858 delete params.encodings[0].codec;
1859 } else if (params.codecs && params.codecs.length) {
1860 const pick = params.codecs.find(c => {
1861 const sub = (c.mimeType || '').split('/')[1] || '';
1862 return sub.toLowerCase() === wantCodec;
1863 });
1864 if (pick) params.encodings[0].codec = pick;
1865 else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
1866 }
1867 return sender.setParameters(params).then(
1868 () => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
1869 e => App.log.error('media', logLabel, 'setParameters failed', e.message)
1870 );
1871 });
1872 pendingByS.set(sender, next);
1873 }
1874 }
1875 /* Cam and screen each have a single "push current bitrate + degradation
1876 preference" entry point. We don't split bitrate-only / degradation-only
1877 because every call site already wants both, and a single setParameters
1878 round-trip per sender is more efficient than two. */
1879 function applyCamSendParams() { applyVideoSendParams('cam', 'cam'); }
1880 function applyScreenSendParams() { applyVideoSendParams('screen', 'screen'); }
1881 async function applyAudioConstraints() {
1882 const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
1883 const t = sender && sender.track;
1884 if (!t) return;
1885 const a = App.state.settings.audio;
1886 try {
1887 await t.applyConstraints({
1888 echoCancellation: a.echoCancellation,
1889 noiseSuppression: a.noiseSuppression,
1890 autoGainControl: a.autoGainControl,
1891 });
1892 App.log.info('media', 'audio constraints applied', t.getSettings());
1893 } catch (e) {
1894 App.log.warn('media', 'applyConstraints failed', e.message);
1895 }
1896 }
1897
1898 /* Live device switch: grab the new track, replaceTrack to it, then stop the
1899 old one. No off→on cycle, so no black-frame flicker for the remote peer
1900 and no period where the OS mic indicator drops. Returns the new deviceId
1901 on success, or null on failure (caller keeps prior selection). */
1902 async function switchInputDevice(kind, deviceId) {
1903 const tr = kind === 'mic' ? App.state.micTransceiver : App.state.camTransceiver;
1904 const sender = tr && tr.sender;
1905 if (!sender || !sender.track) return null;
1906 if (!gumAvailable()) return null;
1907 const a = App.state.settings.audio;
1908 const vs = App.state.settings.video;
1909 let stream = null;
1910 try {
1911 const constraints = kind === 'mic'
1912 ? { audio: {
1913 echoCancellation: a.echoCancellation,
1914 noiseSuppression: a.noiseSuppression,
1915 autoGainControl: a.autoGainControl,
1916 channelCount: a.channelCount || undefined,
1917 sampleRate: a.sampleRate || undefined,
1918 deviceId: deviceId ? { exact: deviceId } : undefined,
1919 } }
1920 : { video: {
1921 width: vs.width || undefined,
1922 height: vs.height || undefined,
1923 frameRate: vs.frameRate || undefined,
1924 deviceId: deviceId ? { exact: deviceId } : undefined,
1925 } };
1926 stream = await navigator.mediaDevices.getUserMedia(constraints);
1927 const newTrack = kind === 'mic' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0];
1928 if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1929 newTrack.stop();
1930 throw new Error('call ended before device prompt resolved');
1931 }
1932 const oldTrack = sender.track;
1933 await sender.replaceTrack(newTrack);
1934 if (oldTrack && oldTrack !== newTrack) oldTrack.stop();
1935 setLocalTrack(kind === 'mic' ? 'audio' : 'video', newTrack);
1936 attachTrackEndedHandler(newTrack, kind);
1937 if (kind === 'cam') applyCamSendParams();
1938 mirrorToLoopback();
1939 App.log.info('media', kind + ' switched', { requested: deviceId || '(default)', got: newTrack.getSettings ? newTrack.getSettings().deviceId : '?' });
1940 return deviceId || '';
1941 } catch (e) {
1942 App.log.error('media', kind + ' switch failed', e.message);
1943 App.chat.appendSystem?.('Failed to switch ' + (kind === 'mic' ? 'microphone' : 'camera') + ': ' + e.message);
1944 if (stream) stream.getTracks().forEach(t => t.stop());
1945 return null;
1946 }
1947 }
1948
1949 return {
1950 preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference,
1951 startScreenshare, stopScreenshare,
1952 setMic, setCam,
1953 switchInputDevice,
1954 applyCamSendParams, applyScreenSendParams, applyAudioConstraints,
1955 refreshLocalDisplay, refreshRemoteDisplay,
1956 };
1957})();
1958
1959/* -------------------------------------------------------------------------
1960 Chat
1961------------------------------------------------------------------------- */
1962App.chat = (() => {
1963 /* Limits are measured in UTF-8 bytes (the wire size), not UTF-16 code
1964 units. A 64 KB code-unit cap could let a non-ASCII payload through at
1965 up to ~256 KB on the wire; checking bytes prevents that. */
1966 const MAX_CHAT_MSG = 64 * 1024;
1967 const MAX_TEXT = 8 * 1024;
1968 const utf8Length = s => new TextEncoder().encode(s).length;
1969 function attach(dc) {
1970 dc.binaryType = 'arraybuffer';
1971 dc.onopen = () => {
1972 App.log.info('chat', 'data channel open');
1973 appendSystem('connected');
1974 /* Push our current media state so the peer's UI matches reality from
1975 the moment the channel opens. */
1976 broadcastMediaState();
1977 };
1978 dc.onclose = () => App.log.info('chat', 'data channel closed');
1979 dc.onerror = e => App.log.error('chat', 'error', e.error ? e.error.message : e);
1980 dc.onmessage = e => {
1981 if (typeof e.data !== 'string') { App.log.warn('chat', 'binary payload rejected'); return; }
1982 /* Cheap UTF-16 prefilter — if the code-unit count already exceeds the
1983 byte cap, the byte count cannot be smaller, so skip the encode. */
1984 if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected', e.data.length); return; }
1985 if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected (bytes)'); return; }
1986 let m;
1987 try { m = JSON.parse(e.data); }
1988 catch (err) { App.log.warn('chat', 'bad json', err.message); return; }
1989 if (!m || typeof m !== 'object' || typeof m.kind !== 'string') return;
1990 if (m.kind === 'msg') {
1991 if (typeof m.text !== 'string') return;
1992 if (m.text.length > MAX_TEXT || utf8Length(m.text) > MAX_TEXT) return;
1993 const ts = Number.isFinite(m.ts) ? m.ts : Date.now();
1994 append(false, m.text, ts);
1995 } else if (m.kind === 'media-state') {
1996 App.state.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
1997 App.media.refreshRemoteDisplay();
1998 } else if (m.kind === 'bye') {
1999 onPeerHangup();
2000 }
2001 /* unknown kinds: silently ignore */
2002 };
2003 }
2004 function send(text) {
2005 const dc = App.state.dcChat;
2006 if (!dc || dc.readyState !== 'open') { App.log.warn('chat', 'not open'); return; }
2007 const msg = { kind: 'msg', text, ts: Date.now() };
2008 dc.send(JSON.stringify(msg));
2009 append(true, text, msg.ts);
2010 }
2011 /* Application-level hangup signal. Sent best-effort right before we tear
2012 the connection down so the peer can react instantly instead of waiting
2013 for ICE consent freshness to time out (~10–30 s). Skipped in loopback
2014 because pcB echoes chat messages back to pcA — a bye would round-trip
2015 and spuriously trigger the peer-left dialog on the local user. */
2016 function sendBye() {
2017 if (App.state.role === 'loopback') return;
2018 const dc = App.state.dcChat;
2019 if (!dc || dc.readyState !== 'open') return;
2020 try { dc.send(JSON.stringify({ kind: 'bye' })); } catch (_) {}
2021 }
2022 function append(mine, text, ts) {
2023 const log = document.getElementById('chat-log');
2024 const el = document.createElement('div');
2025 el.className = 'chat-msg' + (mine ? ' me' : '');
2026 el.innerHTML = '<div class="text"></div><div class="meta"></div>';
2027 el.querySelector('.text').textContent = text;
2028 el.querySelector('.meta').textContent = new Date(ts).toLocaleTimeString();
2029 log.appendChild(el);
2030 log.scrollTop = log.scrollHeight;
2031 }
2032 function appendSystem(text) {
2033 const log = document.getElementById('chat-log');
2034 const el = document.createElement('div');
2035 el.className = 'small';
2036 el.style.textAlign = 'center';
2037 el.style.color = 'var(--text-faint)';
2038 el.textContent = '— ' + text + ' —';
2039 log.appendChild(el);
2040 log.scrollTop = log.scrollHeight;
2041 }
2042 return { attach, send, sendBye, append, appendSystem, MAX_TEXT, utf8Length };
2043})();
2044
2045/* -------------------------------------------------------------------------
2046 Files: chunked transfer with backpressure
2047------------------------------------------------------------------------- */
2048App.files = (() => {
2049 const CHUNK = 16 * 1024;
2050 const HIGH_WATER = 1024 * 1024; /* 1 MB */
2051 const LOW_WATER = 256 * 1024;
2052 const incoming = new Map(); /* id -> { name, size, mime, received, chunks: [] } */
2053 const outQueue = []; /* [{ id, file }] — pending sends */
2054 let outBusy = false;
2055 /* Outgoing cancellation: ids in this set cause doSend's loop to bail
2056 between chunks and emit a file-abort to the peer. Receiver-initiated
2057 cancels arrive as a file-cancel ctl and get folded into this set. */
2058 const abortedOut = new Set();
2059 let currentSendId = null;
2060
2061 function attach(dc) {
2062 dc.binaryType = 'arraybuffer';
2063 dc.bufferedAmountLowThreshold = LOW_WATER;
2064 dc.onopen = () => App.log.info('files', 'data channel open');
2065 dc.onclose = () => App.log.info('files', 'data channel closed');
2066 dc.onerror = e => App.log.error('files', 'error', e.error ? e.error.message : e);
2067 dc.onmessage = e => onMessage(e.data);
2068 }
2069
2070 const MAX_CTRL = 8 * 1024;
2071 const MAX_NAME = 1024;
2072 const MAX_MIME = 256;
2073 const MAX_FILE = 5 * 1024 * 1024 * 1024; /* 5 GB */
2074 const ID_RE = /^[A-Za-z0-9_\-]{1,16}$/;
2075 function validId(id) { return typeof id === 'string' && ID_RE.test(id); }
2076
2077 function onMessage(data) {
2078 if (typeof data === 'string') {
2079 if (data.length > MAX_CTRL) { App.log.warn('files', 'ctrl too large'); return; }
2080 /* Tighten to byte-size so a non-ASCII file name can't bypass the cap. */
2081 if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', 'ctrl too large (bytes)'); return; }
2082 let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', 'bad ctl', e.message); return; }
2083 if (!m || typeof m !== 'object' || typeof m.kind !== 'string' || !validId(m.id)) return;
2084 if (m.kind === 'file-start') {
2085 if (incoming.has(m.id)) { App.log.warn('files', 'duplicate file-start ignored', m.id); return; }
2086 const size = Number(m.size);
2087 if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', 'invalid size', m.size); return; }
2088 const name = typeof m.name === 'string' ? m.name.slice(0, MAX_NAME) : 'file';
2089 const mime = typeof m.mime === 'string' ? m.mime.slice(0, MAX_MIME) : '';
2090 incoming.set(m.id, { name, size, mime, received: 0, chunks: [] });
2091 addIncomingRow(m.id, name, size);
2092 App.log.info('files', 'incoming start', name, size);
2093 } else if (m.kind === 'file-end') {
2094 const f = incoming.get(m.id);
2095 if (!f) return;
2096 const blob = new Blob(f.chunks, { type: f.mime || 'application/octet-stream' });
2097 finishIncoming(m.id, blob, f.name);
2098 incoming.delete(m.id);
2099 App.log.info('files', 'incoming done', f.name);
2100 } else if (m.kind === 'file-abort') {
2101 const f = incoming.get(m.id);
2102 if (f) {
2103 App.log.warn('files', 'incoming aborted', f.name);
2104 incoming.delete(m.id);
2105 abortIncomingRow(m.id);
2106 }
2107 } else if (m.kind === 'file-cancel') {
2108 /* Receiver-initiated cancel: stop sending if this id is in-flight
2109 or queued. The doSend loop will see abortedOut and bail. */
2110 const qi = outQueue.findIndex(q => q.id === m.id);
2111 if (qi >= 0) {
2112 outQueue.splice(qi, 1);
2113 abortOutgoingRow(m.id, 'cancelled by peer');
2114 } else if (currentSendId === m.id) {
2115 abortedOut.add(m.id);
2116 App.log.info('files', 'send cancelled by peer', m.id);
2117 }
2118 /* If id is unknown (already finished or never started), ignore. */
2119 }
2120 /* unknown kinds: silently ignore */
2121 } else {
2122 /* Binary: first 16 bytes ASCII id (right-padded), then payload. */
2123 const view = new Uint8Array(data);
2124 if (view.byteLength <= 16) return;
2125 /* The sender pads ids to 16 ASCII bytes with '_', and the *padded* form
2126 is what's used as the Map key. So leave underscores in place — only
2127 strip NULs and whitespace (defensive). */
2128 const idStr = new TextDecoder().decode(view.slice(0, 16)).replace(/\0+$/, '').trim();
2129 if (!validId(idStr)) return;
2130 const payload = view.slice(16);
2131 const f = incoming.get(idStr);
2132 if (!f) return;
2133 /* A peer claiming size N must not be able to push more than N bytes —
2134 otherwise it can OOM the tab by chunking unbounded data under one id. */
2135 if (f.received + payload.byteLength > f.size) {
2136 App.log.warn('files', 'chunk overflows declared size, aborting', idStr);
2137 incoming.delete(idStr);
2138 abortIncomingRow(idStr);
2139 return;
2140 }
2141 f.chunks.push(payload);
2142 f.received += payload.byteLength;
2143 updateIncomingRow(idStr, f.received, f.size);
2144 }
2145 }
2146
2147 function sendFile(file) {
2148 const dc = App.state.dcFiles;
2149 if (!dc || dc.readyState !== 'open') { App.log.warn('files', 'channel not open'); return; }
2150 if (file.size > MAX_FILE) {
2151 App.log.warn('files', 'file too large', file.size);
2152 App.chat.appendSystem?.(`File "${file.name}" exceeds the 5 GB limit.`);
2153 return;
2154 }
2155 const id = (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)).padEnd(16, '_').slice(0, 16);
2156 addOutgoingRow(id, file.name, file.size);
2157 outQueue.push({ id, file });
2158 if (outQueue.length > 1 || outBusy) markQueued('#files-out', id);
2159 pumpQueue();
2160 }
2161
2162 async function pumpQueue() {
2163 if (outBusy) return;
2164 const next = outQueue.shift();
2165 if (!next) return;
2166 outBusy = true;
2167 try {
2168 await doSend(next.id, next.file);
2169 } finally {
2170 outBusy = false;
2171 pumpQueue();
2172 }
2173 }
2174
2175 async function doSend(id, file) {
2176 const dc = App.state.dcFiles;
2177 if (!dc || dc.readyState !== 'open') {
2178 App.log.warn('files', 'channel closed before send', file.name);
2179 abortOutgoingRow(id, 'channel closed');
2180 abortedOut.delete(id);
2181 return;
2182 }
2183 if (abortedOut.has(id)) {
2184 /* Cancelled while still queued — never went on the wire. */
2185 abortOutgoingRow(id, 'cancelled');
2186 abortedOut.delete(id);
2187 return;
2188 }
2189 clearQueuedMark('#files-out', id);
2190 currentSendId = id;
2191 const start = { kind: 'file-start', id, name: file.name, size: file.size, mime: file.type };
2192 dc.send(JSON.stringify(start));
2193 App.log.info('files', 'sending', file.name, file.size);
2194
2195 const idBytes = new TextEncoder().encode(id);
2196 let offset = 0;
2197 let cancelled = false;
2198 try {
2199 while (offset < file.size) {
2200 if (abortedOut.has(id)) { cancelled = true; break; }
2201 if (dc.bufferedAmount > HIGH_WATER) {
2202 await new Promise(res => {
2203 const h = () => { dc.removeEventListener('bufferedamountlow', h); res(); };
2204 dc.addEventListener('bufferedamountlow', h);
2205 });
2206 if (abortedOut.has(id)) { cancelled = true; break; }
2207 }
2208 const slice = await file.slice(offset, offset + CHUNK).arrayBuffer();
2209 if (abortedOut.has(id)) { cancelled = true; break; }
2210 const buf = new Uint8Array(16 + slice.byteLength);
2211 buf.set(idBytes, 0);
2212 buf.set(new Uint8Array(slice), 16);
2213 dc.send(buf.buffer);
2214 offset += slice.byteLength;
2215 updateOutgoingRow(id, offset, file.size);
2216 }
2217 if (cancelled) {
2218 try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2219 abortOutgoingRow(id, 'cancelled');
2220 App.log.info('files', 'send cancelled', file.name);
2221 } else {
2222 dc.send(JSON.stringify({ kind: 'file-end', id }));
2223 finishOutgoingRow(id);
2224 App.log.info('files', 'sent', file.name);
2225 }
2226 } catch (e) {
2227 App.log.error('files', 'send failed', e.message);
2228 try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2229 abortOutgoingRow(id, 'send failed');
2230 } finally {
2231 currentSendId = null;
2232 abortedOut.delete(id);
2233 }
2234 }
2235
2236 /* UI row helpers */
2237 /* Track blob URLs for incoming finished downloads so Clear can revoke them. */
2238 const incomingUrls = new Map(); /* id -> objectURL string */
2239
2240 function rowEl(side, id, name, size) {
2241 const el = document.createElement('div');
2242 el.className = 'file-item';
2243 el.dataset.id = id;
2244 el.innerHTML = `<button class="row-close" type="button" title="Cancel / remove" aria-label="Cancel or remove">×</button>
2245 <div class="name"></div>
2246 <div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%)</div>
2247 <div class="progress"><div></div></div>
2248 <div class="dl"></div>`;
2249 el.querySelector('.name').textContent = name;
2250 el.querySelector('.total').textContent = size.toLocaleString();
2251 el.querySelector('.row-close').addEventListener('click', () => removeRow(side, id));
2252 return el;
2253 }
2254 function removeRow(side, id) {
2255 const sel = side === 'in' ? '#files-in' : '#files-out';
2256 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2257 if (row) row.remove();
2258 if (side === 'in') {
2259 /* If the transfer is still arriving, ask the sender to stop. */
2260 if (incoming.has(id)) {
2261 const dc = App.state.dcFiles;
2262 if (dc && dc.readyState === 'open') {
2263 try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
2264 }
2265 }
2266 const url = incomingUrls.get(id);
2267 if (url) { URL.revokeObjectURL(url); incomingUrls.delete(id); }
2268 incoming.delete(id);
2269 } else {
2270 const i = outQueue.findIndex(q => q.id === id);
2271 if (i >= 0) outQueue.splice(i, 1);
2272 /* If it's the in-flight send, flag it for cancellation. The doSend
2273 loop will pick up the flag, emit file-abort, and clear it. */
2274 if (currentSendId === id) abortedOut.add(id);
2275 }
2276 }
2277 function clearAll(side) {
2278 const sel = side === 'in' ? '#files-in' : '#files-out';
2279 document.querySelectorAll(sel + ' .file-item').forEach(el => el.remove());
2280 if (side === 'in') {
2281 /* Tell the peer to stop for any in-progress receives. */
2282 const dc = App.state.dcFiles;
2283 if (dc && dc.readyState === 'open') {
2284 incoming.forEach((_f, id) => {
2285 try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
2286 });
2287 }
2288 incomingUrls.forEach(url => URL.revokeObjectURL(url));
2289 incomingUrls.clear();
2290 incoming.clear();
2291 } else {
2292 outQueue.length = 0;
2293 /* Cancel the in-flight send too, if any. */
2294 if (currentSendId) abortedOut.add(currentSendId);
2295 }
2296 }
2297 function addOutgoingRow(id, name, size) {
2298 document.getElementById('files-out').appendChild(rowEl('out', id, name, size));
2299 }
2300 function updateOutgoingRow(id, sent, size) { updateRow('#files-out', id, sent, size); }
2301 function finishOutgoingRow(id) { markDone('#files-out', id); }
2302 function abortOutgoingRow(id, reason) {
2303 const row = document.querySelector('#files-out [data-id="' + CSS.escape(id) + '"]');
2304 if (row) row.querySelector('.dl').textContent = '(' + (reason || 'aborted') + ')';
2305 }
2306 function markQueued(sel, id) {
2307 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2308 if (row) row.querySelector('.dl').textContent = 'queued';
2309 }
2310 function clearQueuedMark(sel, id) {
2311 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2312 if (row && row.querySelector('.dl').textContent === 'queued') row.querySelector('.dl').textContent = '';
2313 }
2314 function addIncomingRow(id, name, size) {
2315 document.getElementById('files-in').appendChild(rowEl('in', id, name, size));
2316 }
2317 function updateIncomingRow(id, recv, size) { updateRow('#files-in', id, recv, size); }
2318 function abortIncomingRow(id) {
2319 const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
2320 if (row) row.querySelector('.dl').textContent = '(aborted)';
2321 }
2322 function finishIncoming(id, blob, name) {
2323 const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
2324 if (!row) return;
2325 const url = URL.createObjectURL(blob);
2326 incomingUrls.set(id, url);
2327 const a = document.createElement('a');
2328 a.href = url; a.download = name; a.textContent = 'Download';
2329 a.style.color = 'var(--accent)';
2330 row.querySelector('.dl').innerHTML = '';
2331 row.querySelector('.dl').appendChild(a);
2332 markDone('#files-in', id);
2333 }
2334 function updateRow(sel, id, cur, size) {
2335 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2336 if (!row) return;
2337 const pct = size ? Math.floor((cur / size) * 100) : 0;
2338 row.querySelector('.bytes').textContent = cur.toLocaleString();
2339 row.querySelector('.pct').textContent = pct;
2340 row.querySelector('.progress > div').style.width = pct + '%';
2341 }
2342 function markDone(sel, id) {
2343 const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2344 if (!row) return;
2345 row.querySelector('.progress > div').style.background = 'var(--ok)';
2346 }
2347
2348 return { attach, sendFile, clearAll, MAX_FILE };
2349})();
2350
2351/* -------------------------------------------------------------------------
2352 Stats
2353------------------------------------------------------------------------- */
2354App.stats = (() => {
2355 let timer = null;
2356 let consoleTimer = null;
2357 /* id -> { bytes, ts } from the previous tick. Used to compute live bitrate
2358 as a delta — the WebRTC stats objects don't expose a current bitrate for
2359 inbound, only cumulative bytes. */
2360 const prev = new Map();
2361
2362 function fmtBytes(n) {
2363 if (n == null || isNaN(n)) return '—';
2364 if (n < 1024) return n + ' B';
2365 if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
2366 if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(2) + ' MB';
2367 return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
2368 }
2369 function fmtBps(bps) {
2370 if (bps == null || isNaN(bps)) return '—';
2371 if (bps < 1000) return Math.round(bps) + ' bps';
2372 if (bps < 1_000_000) return (bps / 1000).toFixed(1) + ' kbps';
2373 return (bps / 1_000_000).toFixed(2) + ' Mbps';
2374 }
2375 function bitrateFor(id, bytes, ts) {
2376 const p = prev.get(id);
2377 prev.set(id, { bytes, ts });
2378 if (!p || ts <= p.ts) return null;
2379 return ((bytes - p.bytes) * 8 * 1000) / (ts - p.ts);
2380 }
2381
2382 async function tick() {
2383 if (!App.state.pc) return;
2384 const stats = await App.state.pc.getStats();
2385 const tbody = document.querySelector('#stats-table tbody');
2386 if (!tbody) return;
2387 const rows = collect(stats);
2388 /* Drop prev entries for stat ids that no longer appear in this report,
2389 so the cache doesn't grow unboundedly across long calls where the
2390 browser internally rotates RTP stream ids. */
2391 const liveIds = new Set();
2392 stats.forEach(r => { if (r.type === 'outbound-rtp' || r.type === 'inbound-rtp') liveIds.add(r.id); });
2393 for (const k of prev.keys()) if (!liveIds.has(k)) prev.delete(k);
2394 tbody.innerHTML = rows.map(r =>
2395 `<tr><td>${escapeHtml(r.k)}</td><td>${escapeHtml(String(r.v))}</td></tr>`).join('');
2396 }
2397
2398 function collect(stats) {
2399 const out = [];
2400 let selected = null;
2401 stats.forEach(r => {
2402 if (r.type === 'transport') {
2403 if (r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
2404 }
2405 });
2406 let pair = null, localCand = null, remoteCand = null;
2407 stats.forEach(r => {
2408 if (r.type === 'candidate-pair' && (r.nominated || r.selected || r.id === selected) && r.state === 'succeeded')
2409 pair = r;
2410 });
2411 if (pair) {
2412 stats.forEach(r => {
2413 if (r.id === pair.localCandidateId) localCand = r;
2414 if (r.id === pair.remoteCandidateId) remoteCand = r;
2415 });
2416 out.push({ k: 'rtt (ms)', v: pair.currentRoundTripTime ? Math.round(pair.currentRoundTripTime * 1000) : '—' });
2417 out.push({ k: 'bytes sent', v: fmtBytes(pair.bytesSent) });
2418 out.push({ k: 'bytes received', v: fmtBytes(pair.bytesReceived) });
2419 out.push({ k: 'available outgoing bw', v: pair.availableOutgoingBitrate ? fmtBps(pair.availableOutgoingBitrate) : '—' });
2420 if (localCand) out.push({ k: 'local candidate', v: `${localCand.candidateType} ${localCand.address || localCand.ip || ''}:${localCand.port || ''} ${localCand.protocol || ''}` });
2421 if (remoteCand) out.push({ k: 'remote candidate', v: `${remoteCand.candidateType} ${remoteCand.address || remoteCand.ip || ''}:${remoteCand.port || ''} ${remoteCand.protocol || ''}` });
2422 }
2423 /* outbound-rtp / inbound-rtp report kind='video' for both the cam and the
2424 screen-share transceivers, so we need to disambiguate by mid. */
2425 const midRole = new Map();
2426 if (App.state.micTransceiver && App.state.micTransceiver.mid != null) midRole.set(String(App.state.micTransceiver.mid), 'audio');
2427 if (App.state.camTransceiver && App.state.camTransceiver.mid != null) midRole.set(String(App.state.camTransceiver.mid), 'cam');
2428 if (App.state.screenTransceiver && App.state.screenTransceiver.mid != null) midRole.set(String(App.state.screenTransceiver.mid), 'screen');
2429 const roleOf = r => midRole.get(String(r.mid)) || r.kind;
2430 /* Pre-pass: build codecId → short codec label map so we can inline the
2431 active codec into each outbound/inbound row. The 'codec' records appear
2432 in arbitrary order relative to the rtp records, so we collect first. */
2433 const codecOf = new Map();
2434 stats.forEach(r => { if (r.type === 'codec') codecOf.set(r.id, r); });
2435 const codecLabel = id => {
2436 const c = codecOf.get(id);
2437 if (!c || !c.mimeType) return '';
2438 return ' [' + c.mimeType.split('/')[1] + ']';
2439 };
2440 stats.forEach(r => {
2441 if (r.type === 'outbound-rtp' && !r.isRemote) {
2442 const role = roleOf(r);
2443 const br = bitrateFor(r.id, r.bytesSent || 0, r.timestamp);
2444 const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
2445 out.push({ k: `↑ ${role} sent${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesSent)} / ${r.packetsSent} pkts @ ${fmtBps(br)}${fps}` });
2446 if (r.targetBitrate) out.push({ k: `↑ ${role} target br`, v: fmtBps(r.targetBitrate) });
2447 }
2448 if (r.type === 'inbound-rtp' && !r.isRemote) {
2449 const role = roleOf(r);
2450 const br = bitrateFor(r.id, r.bytesReceived || 0, r.timestamp);
2451 const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
2452 const lost = r.packetsLost ?? 0;
2453 const jit = r.jitter ? r.jitter.toFixed(3) : 0;
2454 out.push({ k: `↓ ${role} recv${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesReceived)} / ${r.packetsReceived} pkts @ ${fmtBps(br)}${fps} (lost ${lost}, jitter ${jit})` });
2455 }
2456 });
2457 return out;
2458 }
2459
2460 function start() {
2461 if (timer) return;
2462 timer = setInterval(tick, 1000);
2463 tick();
2464 }
2465 function stop() { if (timer) { clearInterval(timer); timer = null; } prev.clear(); }
2466
2467 async function exportAll() {
2468 if (!App.state.pc) return;
2469 const stats = await App.state.pc.getStats();
2470 const arr = [];
2471 stats.forEach(r => arr.push(r));
2472 const blob = new Blob([JSON.stringify(arr, null, 2)], { type: 'application/json' });
2473 const a = document.createElement('a');
2474 a.href = URL.createObjectURL(blob);
2475 a.download = 'webrtc-stats-' + Date.now() + '.json';
2476 a.click();
2477 }
2478
2479 function toggleConsoleStats() {
2480 if (consoleTimer) {
2481 clearInterval(consoleTimer); consoleTimer = null;
2482 App.log.info('stats', 'console poll stopped');
2483 } else {
2484 consoleTimer = setInterval(async () => {
2485 if (!App.state.pc) return;
2486 const stats = await App.state.pc.getStats();
2487 const rows = collect(stats);
2488 App.log.debug('stats', rows.map(r => r.k + '=' + r.v).join(' | '));
2489 }, 2000);
2490 App.log.info('stats', 'console poll started (2s)');
2491 }
2492 }
2493
2494 return { start, stop, exportAll, toggleConsoleStats };
2495})();
2496
2497function escapeHtml(s) {
2498 return s.replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]);
2499}
2500
2501/* -------------------------------------------------------------------------
2502 PeerConnection creation + event wiring
2503------------------------------------------------------------------------- */
2504/* Both Chromium and Firefox obfuscate the local IP of host ICE candidates by
2505 default (mDNS host-name obfuscation), which makes per-interface diagnosis
2506 of icecandidateerror impossible — the address either ends in ".local" or
2507 is empty/0.0.0.0. Detect this on the first host candidate we see and log a
2508 one-shot hint pointing at the relevant browser preference. */
2509let _obfuscationHintLogged = false;
2510function maybeWarnObfuscation(c) {
2511 if (_obfuscationHintLogged) return;
2512 if (c.type !== 'host') return;
2513 const addr = c.address || '';
2514 const obfuscated = !addr || addr.endsWith('.local') || addr === '0.0.0.0' || addr === '::';
2515 if (!obfuscated) return;
2516 _obfuscationHintLogged = true;
2517 App.log.info('pc',
2518 'host-candidate addresses are obfuscated (got "' + (addr || 'empty') + '"); ' +
2519 'icecandidateerror "from=" will not identify a real interface. To see real local IPs:\n' +
2520 ' • Firefox: about:config → media.peerconnection.ice.obfuscate_host_addresses = false\n' +
2521 ' • Chromium: chrome://flags/#enable-webrtc-hide-local-ips-with-mdns → Disabled');
2522}
2523
2524function newPc(label) {
2525 const cfg = { iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 };
2526 const pc = new RTCPeerConnection(cfg);
2527 pc.addEventListener('icegatheringstatechange', () =>
2528 App.log.debug('pc', label, 'iceGatheringState', pc.iceGatheringState));
2529 pc.addEventListener('iceconnectionstatechange', () =>
2530 App.log.info('pc', label, 'iceConnectionState', pc.iceConnectionState));
2531 pc.addEventListener('connectionstatechange', () => {
2532 App.log.info('pc', label, 'connectionState', pc.connectionState);
2533 updateConnPill();
2534 });
2535 pc.addEventListener('signalingstatechange', () =>
2536 App.log.debug('pc', label, 'signalingState', pc.signalingState));
2537 pc.addEventListener('icecandidate', e => {
2538 if (e.candidate) maybeWarnObfuscation(e.candidate);
2539 });
2540 pc.addEventListener('icecandidateerror', e => {
2541 const local = e.address ? (e.address + ':' + (e.port || '?')) : (e.hostCandidate || '?');
2542 App.log.warn('pc', label, 'iceCandidateError',
2543 e.errorCode, e.errorText || '(no text)',
2544 'server=' + (e.url || '(none)'),
2545 'from=' + local);
2546 });
2547 pc.addEventListener('negotiationneeded', () => {
2548 /* All three m-sections (mic/cam/screen) are pre-allocated in preallocate()
2549 with direction=sendrecv before the very first createOffer, so toggling
2550 a track via replaceTrack() never changes the SDP shape. The codec
2551 payload-types negotiated up front cover the encoder configurations we
2552 can reach via in-call settings (resolution, framerate, bitrate cap,
2553 channels). Therefore any negotiationneeded the browser fires is
2554 spurious for this app and safely ignored. */
2555 App.log.debug('pc', label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
2556 });
2557 pc.addEventListener('track', e => {
2558 App.log.info('pc', label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
2559 handleRemoteTrack(pc, e);
2560 });
2561 return pc;
2562}
2563
2564/* Local media state derived from the actual sender tracks / screen stream. */
2565function localMediaState() {
2566 return {
2567 mic: !!(App.state.micTransceiver && App.state.micTransceiver.sender.track),
2568 cam: !!(App.state.camTransceiver && App.state.camTransceiver.sender.track),
2569 screen: !!App.state.screenStream,
2570 };
2571}
2572/* Tell the peer about our current mic/cam/screen state. Sent over the chat
2573 data channel as a typed JSON message — replaceTrack(null) doesn't itself
2574 propagate any signal across the wire, so the receiver would otherwise just
2575 see frozen video on the last frame. */
2576function broadcastMediaState() {
2577 const dc = App.state.dcChat;
2578 if (!dc || dc.readyState !== 'open') return;
2579 const state = { kind: 'media-state', ...localMediaState() };
2580 try { dc.send(JSON.stringify(state)); }
2581 catch (e) { App.log.warn('media', 'state send failed', e.message); }
2582}
2583
2584/* Rebuild the remote streams by inspecting the current receivers — defensive
2585 alternative to relying solely on the 'track' event, which can fire at
2586 slightly different points across browsers (especially on the offerer side
2587 where receivers existed before negotiation). */
2588/* Map a receiver-side transceiver to its role using the stored references
2589 from preallocate() / adoptTransceiversFromRemote(). Identity comparison
2590 is the only reliable cue — relying on indexOf-by-kind misroutes cam and
2591 screen when only one of them has live frames (track events fire out of
2592 order across browsers / offerer-vs-joiner roles). */
2593function roleForTransceiver(t) {
2594 if (!t) return null;
2595 if (t === App.state.micTransceiver) return 'mic';
2596 if (t === App.state.camTransceiver) return 'cam';
2597 if (t === App.state.screenTransceiver) return 'screen';
2598 return null;
2599}
2600
2601/* Track listeners we attached, so we can remove the exact references later
2602 instead of leaking anonymous arrow closures over the lifetime of the page. */
2603const remoteTrackListeners = new WeakMap(); /* track -> { unmute, ended } */
2604
2605function attachRemoteTrackListeners(track, onEnded) {
2606 detachRemoteTrackListeners(track);
2607 const handlers = { unmute: App.media.refreshRemoteDisplay, ended: onEnded };
2608 track.addEventListener('unmute', handlers.unmute);
2609 track.addEventListener('ended', handlers.ended);
2610 remoteTrackListeners.set(track, handlers);
2611}
2612function detachRemoteTrackListeners(track) {
2613 const h = remoteTrackListeners.get(track);
2614 if (!h) return;
2615 track.removeEventListener('unmute', h.unmute);
2616 track.removeEventListener('ended', h.ended);
2617 remoteTrackListeners.delete(track);
2618}
2619
2620function rebuildRemoteStreams(pc) {
2621 if (!pc) return;
2622 let audio = null, cam = null, screen = null;
2623 for (const t of pc.getTransceivers()) {
2624 const tr = t.receiver && t.receiver.track;
2625 if (!tr) continue;
2626 const role = roleForTransceiver(t);
2627 if (role === 'mic' && !audio) audio = tr;
2628 if (role === 'cam' && !cam) cam = tr;
2629 if (role === 'screen' && !screen) screen = tr;
2630 }
2631 const remote = new MediaStream();
2632 if (audio) remote.addTrack(audio);
2633 if (cam) remote.addTrack(cam);
2634 App.state.remoteStream = remote;
2635 const remoteScreen = new MediaStream();
2636 if (screen) remoteScreen.addTrack(screen);
2637 App.state.remoteScreenStream = remoteScreen;
2638 /* React when a so-far-muted track gets actual frames. */
2639 const camStreamRef = remote;
2640 const screenStreamRef = remoteScreen;
2641 if (audio) attachRemoteTrackListeners(audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplay(); });
2642 if (cam) attachRemoteTrackListeners(cam, () => { camStreamRef.removeTrack(cam); App.media.refreshRemoteDisplay(); });
2643 if (screen) attachRemoteTrackListeners(screen, () => { screenStreamRef.removeTrack(screen); App.media.refreshRemoteDisplay(); });
2644 App.log.info('pc', 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
2645 App.media.refreshRemoteDisplay();
2646}
2647
2648function handleRemoteTrack(pc, e) {
2649 /* In loopback, the second pc (pcB) also fires track events but those
2650 represent our own outbound tracks being received on the synthetic peer
2651 — they must not feed the visible "remote" tile. */
2652 if (pc !== App.state.pc) return;
2653 const role = roleForTransceiver(e.transceiver);
2654 App.log.info('pc', 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
2655 if (role === 'mic' || role === 'cam') {
2656 if (!App.state.remoteStream) App.state.remoteStream = new MediaStream();
2657 App.state.remoteStream.addTrack(e.track);
2658 attachRemoteTrackListeners(e.track, () => {
2659 if (App.state.remoteStream) App.state.remoteStream.removeTrack(e.track);
2660 App.media.refreshRemoteDisplay();
2661 });
2662 App.media.refreshRemoteDisplay();
2663 } else if (role === 'screen') {
2664 if (!App.state.remoteScreenStream) App.state.remoteScreenStream = new MediaStream();
2665 App.state.remoteScreenStream.addTrack(e.track);
2666 attachRemoteTrackListeners(e.track, () => {
2667 if (App.state.remoteScreenStream) App.state.remoteScreenStream.removeTrack(e.track);
2668 App.media.refreshRemoteDisplay();
2669 });
2670 App.media.refreshRemoteDisplay();
2671 }
2672}
2673
2674function updateConnPill() {
2675 const pc = App.state.pc;
2676 const pill = document.getElementById('conn-pill');
2677 if (!pc) { pill.textContent = 'disconnected'; pill.className = 'pill'; return; }
2678 const st = pc.connectionState;
2679 pill.textContent = st;
2680 pill.className = 'pill ' + (
2681 st === 'connected' ? 'ok' :
2682 st === 'connecting' || st === 'new' ? 'warn' :
2683 'err'
2684 );
2685}
2686
2687/* -------------------------------------------------------------------------
2688 Signaling flows: initiator, joiner, loopback
2689------------------------------------------------------------------------- */
2690/* Invoked when the user clicks Cancel in the step modal. Tears down the
2691 in-flight setup (closes pc, aborts any long-poll fetch) and bounces back
2692 to the welcome view. The setup function will see signalAbort.aborted and
2693 throw 'cancelled', which cfg-continue's catch silently swallows. */
2694function cancelSetup() {
2695 App.log.info('app', 'setup cancelled by user');
2696 App.state.userCancelled = true;
2697 App.progress.hide();
2698 hangup({ sendBye: false });
2699}
2700
2701async function startInitiator() {
2702 const pc = newPc('A');
2703 App.state.pc = pc;
2704 App.media.preallocate(pc);
2705 App.media.applyVideoCodecPreference(pc);
2706 const ac = new AbortController();
2707 App.state.signalAbort = ac;
2708
2709 /* Data channels MUST be created on the initiator before createOffer
2710 so they're included in the SDP m-section list. */
2711 App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
2712 App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
2713 App.chat.attach(App.state.dcChat);
2714 App.files.attach(App.state.dcFiles);
2715
2716 App.progress.show('Creating offer…', 'Negotiating local SDP.');
2717 let offer = await pc.createOffer();
2718 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2719 await pc.setLocalDescription(offer);
2720 App.log.info('signal', 'offer created, waiting for ICE gathering…');
2721 App.progress.showModal('Gathering ICE candidates…',
2722 iceGatheringSubtitle(),
2723 { onCancel: cancelSetup });
2724 await App.signal.waitForIceComplete(pc, ac.signal);
2725 if (ac.signal.aborted) throw new Error('cancelled');
2726 App.log.info('signal', 'ICE gathering complete; offer ready to export');
2727
2728 App.progress.hide();
2729 renderInitiatorExchange();
2730}
2731
2732async function startJoiner() {
2733 const pc = newPc('A');
2734 App.state.pc = pc;
2735
2736 pc.ondatachannel = e => {
2737 /* If the user picked a new role mid-flight, App.state.pc may already be
2738 a different connection. Late events from the previous pc would
2739 otherwise overwrite the current dcChat/dcFiles with a closed channel. */
2740 if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
2741 App.log.info('signal', 'incoming data channel', e.channel.label);
2742 if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
2743 if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
2744 };
2745
2746 renderJoinerExchange();
2747}
2748
2749async function finishJoiner(offerObj) {
2750 const pc = App.state.pc;
2751 const ac = App.state.signalAbort || new AbortController();
2752 App.state.signalAbort = ac;
2753 App.progress.show('Applying remote offer…', 'Parsing your peer\'s SDP.');
2754 await pc.setRemoteDescription(offerObj);
2755 App.media.adoptTransceiversFromRemote(pc);
2756 App.media.applyVideoCodecPreference(pc);
2757 rebuildRemoteStreams(pc);
2758 App.progress.show('Creating answer…', 'Negotiating local SDP.');
2759 let answer = await pc.createAnswer();
2760 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2761 await pc.setLocalDescription(answer);
2762 App.progress.showModal('Gathering ICE candidates…',
2763 iceGatheringSubtitle(),
2764 { onCancel: cancelSetup });
2765 await App.signal.waitForIceComplete(pc, ac.signal);
2766 if (ac.signal.aborted) throw new Error('cancelled');
2767 App.progress.hide();
2768 showAnswerForJoiner();
2769}
2770
2771async function applyAnswerOnInitiator(answerObj) {
2772 const pc = App.state.pc;
2773 await pc.setRemoteDescription(answerObj);
2774 rebuildRemoteStreams(pc);
2775 App.log.info('signal', 'remote answer applied; waiting to connect…');
2776}
2777
2778/* -------------------------------------------------------------------------
2779 Auto signaling: HTTP relay (POST/long-poll GET) against a tiny server.
2780 See server/signal.c for the protocol. The relay never touches media or
2781 the data channels — those still flow peer-to-peer.
2782------------------------------------------------------------------------- */
2783async function signalPost(base, code, slot, body) {
2784 const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
2785 const r = await fetch(url, {
2786 method: 'POST',
2787 headers: { 'Content-Type': 'application/sdp' },
2788 body,
2789 });
2790 if (!r.ok) throw new Error('POST ' + slot + ' failed: ' + r.status);
2791}
2792
2793/* Long-poll a slot. The server holds the request open for ~10 s; on a 204
2794 No Content we retry. The total deadline is generous so a peer can take
2795 their time sharing the code. Aborts cleanly when hangup() is called by
2796 tracking a shared AbortController. (Status used to be 408, but Firefox
2797 silently auto-retries 408 internally per RFC 7231 §6.5.7 — JS only sees
2798 one fetch and CORS-fails after ~10 retries.) */
2799async function signalPoll(base, code, slot, totalDeadlineMs, signal) {
2800 const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
2801 const start = Date.now();
2802 while (!signal.aborted) {
2803 let r;
2804 try { r = await fetch(url, { signal }); }
2805 catch (e) {
2806 if (signal.aborted) throw e;
2807 throw new Error('GET ' + slot + ' failed: ' + e.message);
2808 }
2809 if (r.status === 200) return await r.text();
2810 if (r.status !== 204) throw new Error('GET ' + slot + ' status ' + r.status);
2811 if (Date.now() - start > totalDeadlineMs) throw new Error('peer did not respond within ' + Math.round(totalDeadlineMs/1000) + 's');
2812 /* 204 → server-side long-poll timed out; loop and reconnect. */
2813 }
2814 throw new Error('aborted');
2815}
2816
2817/* Initiator side: produce the offer the normal way, push it to the relay,
2818 then long-poll the answer slot. Skips the blob copy/paste exchange view. */
2819async function startInitiatorAuto(code) {
2820 const base = App.state.settings.signaling.serverUrl;
2821 const ac = new AbortController();
2822 App.state.signalAbort = ac;
2823
2824 const pc = newPc('A');
2825 App.state.pc = pc;
2826 App.media.preallocate(pc);
2827 App.media.applyVideoCodecPreference(pc);
2828 App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
2829 App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
2830 App.chat.attach(App.state.dcChat);
2831 App.files.attach(App.state.dcFiles);
2832
2833 App.progress.show('Creating offer…', 'Negotiating local SDP.');
2834 let offer = await pc.createOffer();
2835 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2836 await pc.setLocalDescription(offer);
2837 App.progress.showModal('Gathering ICE candidates…',
2838 iceGatheringSubtitle(),
2839 { onCancel: cancelSetup });
2840 await App.signal.waitForIceComplete(pc, ac.signal);
2841 if (ac.signal.aborted) throw new Error('cancelled');
2842
2843 const offerBlob = App.signal.encode(pc.localDescription);
2844 App.progress.show('Publishing offer…', 'Room ' + code + ' on ' + base);
2845 await signalPost(base, code, 'offer', offerBlob);
2846
2847 App.progress.showModal('Waiting for peer…',
2848 'Share the room code with them. They have 5 minutes to join.',
2849 { roomCode: code, onCancel: cancelSetup });
2850 const answerText = await signalPoll(base, code, 'answer', 5 * 60 * 1000, ac.signal);
2851 App.progress.show('Applying answer…', 'Finalizing the handshake.');
2852 const obj = App.signal.decode(answerText);
2853 if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
2854 await applyAnswerOnInitiator(obj);
2855 App.progress.hide();
2856 goToCall();
2857}
2858
2859/* Joiner side: long-poll the offer slot, apply it, push the answer back. */
2860async function startJoinerAuto(code) {
2861 const base = App.state.settings.signaling.serverUrl;
2862 const ac = new AbortController();
2863 App.state.signalAbort = ac;
2864
2865 const pc = newPc('A');
2866 App.state.pc = pc;
2867 pc.ondatachannel = e => {
2868 if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
2869 App.log.info('signal', 'incoming data channel', e.channel.label);
2870 if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
2871 if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
2872 };
2873
2874 App.progress.showModal('Waiting for offer…',
2875 'Polling the signaling server until your peer publishes their offer.',
2876 { roomCode: code, onCancel: cancelSetup });
2877 const offerText = await signalPoll(base, code, 'offer', 5 * 60 * 1000, ac.signal);
2878 if (ac.signal.aborted) throw new Error('cancelled');
2879 const obj = App.signal.decode(offerText);
2880 if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
2881
2882 App.progress.show('Applying offer…', 'Building answer.');
2883 await pc.setRemoteDescription(obj);
2884 App.media.adoptTransceiversFromRemote(pc);
2885 App.media.applyVideoCodecPreference(pc);
2886 rebuildRemoteStreams(pc);
2887
2888 let answer = await pc.createAnswer();
2889 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2890 await pc.setLocalDescription(answer);
2891 App.progress.showModal('Gathering ICE candidates…',
2892 iceGatheringSubtitle(),
2893 { onCancel: cancelSetup });
2894 await App.signal.waitForIceComplete(pc, ac.signal);
2895 if (ac.signal.aborted) throw new Error('cancelled');
2896
2897 App.progress.show('Publishing answer…', 'Room ' + code + ' on ' + base);
2898 await signalPost(base, code, 'answer', App.signal.encode(pc.localDescription));
2899 App.progress.hide();
2900 goToCall();
2901}
2902
2903async function startLoopback() {
2904 const pcA = newPc('A');
2905 const pcB = newPc('B');
2906 App.state.pc = pcA;
2907 App.state.pcB = pcB;
2908
2909 /* Trickle candidates between the two local PCs. Buffer candidates that
2910 arrive before the target has its remote description set — otherwise
2911 addIceCandidate rejects with InvalidStateError. */
2912 const pendingForA = [];
2913 const pendingForB = [];
2914 let remoteSetA = false, remoteSetB = false;
2915 const flush = (pc, queue) => { while (queue.length) pc.addIceCandidate(queue.shift()).catch(err => App.log.warn('loopback', 'flush', err.message)); };
2916 pcA.onicecandidate = e => {
2917 if (!e.candidate) return;
2918 if (remoteSetB) pcB.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'B add', err.message));
2919 else pendingForB.push(e.candidate);
2920 };
2921 pcB.onicecandidate = e => {
2922 if (!e.candidate) return;
2923 if (remoteSetA) pcA.addIceCandidate(e.candidate).catch(err => App.log.warn('loopback', 'A add', err.message));
2924 else pendingForA.push(e.candidate);
2925 };
2926
2927 pcB.ondatachannel = e => {
2928 /* In loopback the *visible* call uses pcA's POV. pcB is the synthetic
2929 peer; echoing the chat data channel back to A is how A learns about
2930 its "peer's" media state (which in loopback is itself) and how chat
2931 messages round-trip for verification. Files messages must NOT be
2932 echoed — that would feed A's outgoing file frames back into its own
2933 incoming-file accumulator, doubling memory and producing phantom
2934 "incoming" download rows. */
2935 e.channel.onmessage = ev => {
2936 App.log.debug('loopback', 'B got', e.channel.label, typeof ev.data === 'string' ? ev.data.slice(0, 80) : '(binary)');
2937 if (e.channel.label !== 'chat') return;
2938 try { e.channel.send(ev.data); } catch (_) {}
2939 };
2940 };
2941 pcB.ontrack = e => App.log.debug('loopback', 'B got track', e.track.kind);
2942
2943 App.media.preallocate(pcA);
2944 App.media.applyVideoCodecPreference(pcA);
2945 App.state.dcChat = pcA.createDataChannel('chat', { ordered: true });
2946 App.state.dcFiles = pcA.createDataChannel('files', { ordered: true });
2947 App.chat.attach(App.state.dcChat);
2948 App.files.attach(App.state.dcFiles);
2949
2950 App.progress.show('Negotiating local loopback…', 'Exchanging SDP between the two in-tab peers.');
2951 const offer = await pcA.createOffer();
2952 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2953 await pcA.setLocalDescription(offer);
2954 await pcB.setRemoteDescription(offer);
2955 remoteSetB = true; flush(pcB, pendingForB);
2956 /* Transceivers auto-created by setRemoteDescription default to recvonly
2957 because pcB has no local tracks yet. Force them to sendrecv so pcB can
2958 mirror tracks back to pcA when the user later toggles mic/cam/screen. */
2959 for (const t of pcB.getTransceivers()) {
2960 try { t.direction = 'sendrecv'; }
2961 catch (e) { App.log.warn('loopback', 'could not upgrade transceiver to sendrecv', e.message); }
2962 }
2963 App.media.applyVideoCodecPreference(pcB);
2964
2965 const answer = await pcB.createAnswer();
2966 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2967 await pcB.setLocalDescription(answer);
2968 await pcA.setRemoteDescription(answer);
2969 remoteSetA = true; flush(pcA, pendingForA);
2970 rebuildRemoteStreams(pcA);
2971 App.log.info('loopback', 'offer/answer exchanged locally');
2972 App.progress.hide();
2973 goToCall();
2974}
2975
2976/* -------------------------------------------------------------------------
2977 UI: views + event wiring
2978------------------------------------------------------------------------- */
2979const viewToHash = {
2980 'view-welcome': 'welcome',
2981 'view-configure': 'configure',
2982 'view-exchange': 'exchange',
2983 'view-sdp-inspect': 'sdp-inspect',
2984 'view-call': 'call',
2985};
2986const hashToView = Object.fromEntries(
2987 Object.entries(viewToHash).map(([v, h]) => [h, v])
2988);
2989
2990function clearViewInputs(viewEl) {
2991 viewEl.querySelectorAll('input, textarea').forEach(el => {
2992 const t = (el.type || '').toLowerCase();
2993 if (t === 'button' || t === 'submit' || t === 'reset' || t === 'file' ||
2994 t === 'checkbox' || t === 'radio') return;
2995 el.value = '';
2996 });
2997}
2998
2999/* Reset transient UI state held outside <input>/<textarea> elements. Called
3000 only for views the user is actually leaving — not on initial page load
3001 for views that were never visible — so App-global state like the log
3002 buffer isn't wiped on every fresh start. */
3003function resetViewState(id) {
3004 if (id === 'view-sdp-inspect') {
3005 document.getElementById('sdp-inspect-out')?.replaceChildren();
3006 const status = document.getElementById('sdp-inspect-status');
3007 if (status) { status.textContent = ''; status.className = 'pill'; }
3008 } else if (id === 'view-exchange') {
3009 document.getElementById('step-1-body')?.replaceChildren();
3010 document.getElementById('step-2-body')?.replaceChildren();
3011 document.getElementById('step-2-card')?.classList.add('hidden');
3012 document.getElementById('exch-progress')?.classList.add('hidden');
3013 } else if (id === 'view-configure') {
3014 document.getElementById('cfg-progress')?.classList.add('hidden');
3015 } else if (id === 'view-call') {
3016 document.getElementById('chat-log')?.replaceChildren();
3017 if (App.files && App.files.clearAll) {
3018 App.files.clearAll('out');
3019 App.files.clearAll('in');
3020 }
3021 if (App.log && App.log.clear) App.log.clear();
3022 document.getElementById('console-drawer')?.classList.add('hidden');
3023 }
3024}
3025
3026function showView(id, opts) {
3027 const wasVisible = new Set();
3028 document.querySelectorAll('.view').forEach(v => {
3029 if (!v.classList.contains('hidden')) wasVisible.add(v.id);
3030 v.classList.add('hidden');
3031 if (v.id !== id) clearViewInputs(v);
3032 });
3033 document.getElementById(id).classList.remove('hidden');
3034 wasVisible.forEach(vid => { if (vid !== id) resetViewState(vid); });
3035 if (opts && opts.push === false) return;
3036 const url = '#' + viewToHash[id];
3037 const state = { view: id };
3038 /* The call view holds a live RTCPeerConnection — we don't want a normal
3039 back/forward stop on it. Replace, so back lands where we came from. */
3040 if (id === 'view-call' || (history.state && history.state.view === id)) {
3041 history.replaceState(state, '', url);
3042 } else {
3043 history.pushState(state, '', url);
3044 }
3045}
3046
3047function currentView() {
3048 for (const id of Object.keys(viewToHash)) {
3049 const el = document.getElementById(id);
3050 if (el && !el.classList.contains('hidden')) return id;
3051 }
3052 return null;
3053}
3054
3055function onPopstate(event) {
3056 const wasOnCall = currentView() === 'view-call';
3057 const hashView = hashToView[(location.hash || '').replace(/^#/, '')];
3058 const target = (event.state && event.state.view) || hashView || 'view-welcome';
3059
3060 if (wasOnCall) {
3061 if (!confirm('Hang up and leave the call?')) {
3062 history.pushState({ view: 'view-call' }, '', '#call');
3063 return;
3064 }
3065 resetSession();
3066 history.replaceState({ view: 'view-welcome' }, '', '#welcome');
3067 showView('view-welcome', { push: false });
3068 return;
3069 }
3070
3071 /* Exchange and call need in-flight state (offer, live pc) that doesn't
3072 exist when reached via back/forward — redirect to welcome. */
3073 if (target === 'view-exchange' || target === 'view-call') {
3074 if (App.state.pc || App.state.pcB || App.state.role) resetSession();
3075 history.replaceState({ view: 'view-welcome' }, '', '#welcome');
3076 showView('view-welcome', { push: false });
3077 return;
3078 }
3079
3080 if (App.state.pc || App.state.pcB) resetSession();
3081 if (target === 'view-welcome' || target === 'view-sdp-inspect') {
3082 if (App.state.role) { App.state.role = null; updateRoleBadge(); }
3083 }
3084 showView(target, { push: false });
3085}
3086
3087function resolveInitialView() {
3088 const hash = (location.hash || '').replace(/^#/, '');
3089 const requested = hashToView[hash];
3090 const reloadSafe = requested === 'view-welcome' || requested === 'view-sdp-inspect';
3091 const target = reloadSafe ? requested : 'view-welcome';
3092 history.replaceState({ view: target }, '', '#' + viewToHash[target]);
3093 showView(target, { push: false });
3094}
3095
3096function updateRoleBadge() {
3097 const badge = document.getElementById('role-badge');
3098 const cfg = document.getElementById('role-title-cfg');
3099 const exch = document.getElementById('role-title-exch');
3100 const r = App.state.role;
3101 if (!r) { badge.classList.add('hidden'); return; }
3102 badge.classList.remove('hidden');
3103 badge.textContent = r;
3104 badge.className = 'role-badge ' + r;
3105 if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
3106 if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
3107}
3108
3109function pickRole(role) {
3110 /* If a pc from a previous role attempt is still around, tear it down so
3111 we don't leak it. Most paths reach pickRole via the welcome view where
3112 hangup() was already called, but the defensive close here covers cases
3113 where the user navigates back without going through exch-cancel. */
3114 if (App.state.pc || App.state.pcB) hangup();
3115 App.state.role = role;
3116 updateRoleBadge();
3117 if (role === 'loopback') {
3118 populateConfigInputs();
3119 showView('view-configure');
3120 document.getElementById('cfg-lede').textContent = 'Loopback mode: both peers run in this tab and skip the paste step.';
3121 } else {
3122 populateConfigInputs();
3123 showView('view-configure');
3124 document.getElementById('cfg-lede').textContent =
3125 role === 'initiator'
3126 ? 'You will generate an offer; your peer pastes it and sends back an answer.'
3127 : 'Your peer sends you an offer; you paste it and send back the generated answer.';
3128 }
3129 refreshInsecureWarning();
3130}
3131
3132function refreshInsecureWarning() {
3133 /* Browsers gate gUM/gDM behind a secure context. Surface this up-front so
3134 the user knows microphone/camera/screen-share won't be available — but
3135 the call itself still works as receive-only. */
3136 const banner = document.getElementById('insecure-warn');
3137 if (!banner) return;
3138 const noMedia = !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia;
3139 if (noMedia) banner.classList.remove('hidden');
3140 else banner.classList.add('hidden');
3141}
3142
3143/* ICE rows */
3144function renderIceRows() {
3145 const wrap = document.getElementById('ice-rows');
3146 wrap.innerHTML = '';
3147 App.state.settings.iceServers.forEach((s, i) => {
3148 const urls = Array.isArray(s.urls) ? s.urls.join(',') : (s.urls || '');
3149 const row = document.createElement('div');
3150 row.className = 'ice-row';
3151 row.innerHTML = `
3152 <input type="text" placeholder="stun:host:port or turn:host:port" value="${escapeAttr(urls)}">
3153 <input type="text" placeholder="username (optional)" value="${escapeAttr(s.username || '')}">
3154 <input type="text" placeholder="credential (optional)" value="${escapeAttr(s.credential || '')}">
3155 <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>`;
3156 const [u, un, cr, rm] = row.children;
3157 u.addEventListener('input', () => { s.urls = u.value.includes(',') ? u.value.split(',').map(x=>x.trim()) : u.value; });
3158 un.addEventListener('input', () => { s.username = un.value || undefined; });
3159 cr.addEventListener('input', () => { s.credential = cr.value || undefined; });
3160 rm.addEventListener('click', () => { App.state.settings.iceServers.splice(i, 1); renderIceRows(); });
3161 wrap.appendChild(row);
3162 });
3163}
3164function escapeAttr(s) { return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
3165
3166function populateCodecDropdown(selId) {
3167 const sel = document.getElementById(selId);
3168 if (!sel) return;
3169 /* Remove previously added options in reverse so each removal doesn't shift
3170 the indices of options we still need to inspect. */
3171 for (let i = sel.options.length - 1; i >= 0; i--) {
3172 if (sel.options[i].value !== 'auto') sel.remove(i);
3173 }
3174 if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
3175 const caps = RTCRtpSender.getCapabilities('video');
3176 if (!caps || !caps.codecs) return;
3177 /* Some codecs (H264, AV1) appear multiple times with different profile
3178 params — dedupe by subtype. RED/ULPFEC/rtx aren't real media codecs to
3179 pick, so filter them out. */
3180 const seen = new Set();
3181 const skip = new Set(['rtx', 'red', 'ulpfec', 'flexfec-03']);
3182 for (const c of caps.codecs) {
3183 const sub = (c.mimeType || '').split('/')[1] || '';
3184 const key = sub.toLowerCase();
3185 if (!sub || skip.has(key) || seen.has(key)) continue;
3186 seen.add(key);
3187 const opt = document.createElement('option');
3188 opt.value = sub;
3189 opt.textContent = sub;
3190 sel.appendChild(opt);
3191 }
3192}
3193
3194function populateConfigInputs() {
3195 renderIceRows();
3196 populateCodecDropdown('preferred-codec');
3197 populateCodecDropdown('send-codec');
3198 const s = App.state.settings;
3199 const $ = id => document.getElementById(id);
3200 $('o-stereo').checked = s.opus.stereo;
3201 $('o-fec').checked = s.opus.fec;
3202 $('o-dtx').checked = s.opus.dtx;
3203 $('o-cbr').checked = s.opus.cbr;
3204 $('o-maxbr').value = s.opus.maxAverageBitrate || '';
3205 /* Reflect the saved preference if it still exists in the populated list,
3206 otherwise fall back to auto. Compare case-insensitively so a saved
3207 "VP8" still matches a hypothetical browser that returns "vp8" — and
3208 preserve whatever casing the current browser actually gave us. */
3209 const setSel = (id, val) => {
3210 const el = $(id);
3211 if (!el) return;
3212 const want = (val || 'auto').toLowerCase();
3213 const match = Array.from(el.options).find(o => o.value.toLowerCase() === want);
3214 el.value = match ? match.value : 'auto';
3215 };
3216 setSel('preferred-codec', s.preferredVideoCodec);
3217 setSel('send-codec', s.sendVideoCodec);
3218
3219 /* Signaling mode UI */
3220 $('sig-server-url').value = s.signaling.serverUrl || '';
3221 $('sig-ice-timeout').value = Math.round((s.signaling.iceGatherTimeoutMs || 0) / 1000);
3222 applySignalingMode(s.signaling.mode);
3223 /* Loopback doesn't use signaling at all — hide the card so the user isn't
3224 given irrelevant choices. */
3225 $('signaling-card').classList.toggle('hidden', App.state.role === 'loopback');
3226}
3227
3228function applySignalingMode(mode) {
3229 const isAuto = mode === 'auto';
3230 App.state.settings.signaling.mode = isAuto ? 'auto' : 'manual';
3231 document.getElementById('sig-mode-manual').classList.toggle('active', !isAuto);
3232 document.getElementById('sig-mode-auto').classList.toggle('active', isAuto);
3233 document.getElementById('sig-help-manual').classList.toggle('hidden', isAuto);
3234 document.getElementById('sig-help-auto').classList.toggle('hidden', !isAuto);
3235 document.getElementById('sig-auto-fields').classList.toggle('hidden', !isAuto);
3236 /* The Continue button's wording reflects what happens next. */
3237 const cont = document.getElementById('cfg-continue');
3238 if (cont) cont.textContent = isAuto ? 'Connect →' : 'Continue to signaling →';
3239}
3240
3241function randomRoomCode() {
3242 /* Short and pronounceable-ish: 8 lowercase alphanumeric chars from a
3243 reduced alphabet that drops easily-confused glyphs. */
3244 const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
3245 const a = new Uint8Array(8);
3246 crypto.getRandomValues(a);
3247 let out = '';
3248 for (const b of a) out += alphabet[b % alphabet.length];
3249 return out;
3250}
3251
3252function readConfigInputs() {
3253 const s = App.state.settings;
3254 const $ = id => document.getElementById(id);
3255 s.opus.stereo = $('o-stereo').checked;
3256 s.opus.fec = $('o-fec').checked;
3257 s.opus.dtx = $('o-dtx').checked;
3258 s.opus.cbr = $('o-cbr').checked;
3259 s.opus.maxAverageBitrate = parseInt($('o-maxbr').value, 10) || 0;
3260 s.preferredVideoCodec = $('preferred-codec').value || 'auto';
3261 s.sendVideoCodec = $('send-codec').value || 'auto';
3262 s.signaling.serverUrl = ($('sig-server-url').value || '').trim().replace(/\/+$/, '');
3263 /* Seconds in the UI, milliseconds in state. 0 = no timeout; clamp negatives to 0. */
3264 const tSec = parseInt($('sig-ice-timeout').value, 10);
3265 s.signaling.iceGatherTimeoutMs = Number.isFinite(tSec) && tSec > 0 ? tSec * 1000 : 0;
3266 saveIce();
3267 saveSignaling();
3268}
3269
3270/* Wire the Upload button on a paste box: reads the chosen file as text into
3271 the `blob-in` textarea so the user can then click Apply. Does not auto-apply
3272 — the user still reviews and submits manually. */
3273function wireUploadButton(name) {
3274 const btn = document.getElementById('blob-upload');
3275 const file = document.getElementById('blob-upload-file');
3276 const ta = document.getElementById('blob-in');
3277 const statusEl = document.getElementById('blob-in-status');
3278 btn.addEventListener('click', () => file.click());
3279 file.addEventListener('change', async () => {
3280 const f = file.files && file.files[0];
3281 if (!f) return;
3282 try {
3283 ta.value = await f.text();
3284 statusEl.textContent = 'loaded ' + f.name; statusEl.className = 'pill ok';
3285 App.log.info('signal', name + ' loaded from ' + f.name);
3286 } catch (e) {
3287 statusEl.textContent = 'read failed: ' + e.message; statusEl.className = 'pill err';
3288 App.log.error('signal', 'file read failed', e.message);
3289 } finally {
3290 /* Reset so re-selecting the same file fires `change` again. */
3291 file.value = '';
3292 }
3293 });
3294}
3295
3296/* Render the outgoing-blob block (textarea + controls) into `body`. Owns its
3297 own copy/download/base64-toggle wiring; re-encodes from pc.localDescription
3298 when the toggle flips so the visible blob always matches the setting.
3299 `extraButtons` is an array of {id,label,cls,onClick} appended after Download. */
3300function mountOutgoingBlob(body, name, extraButtons) {
3301 const extras = (extraButtons || []).map(b =>
3302 `<button id="${b.id}" class="${b.cls || 'ghost'}">${b.label}</button>`).join('');
3303 body.innerHTML = `
3304 <textarea id="blob-out" readonly spellcheck="false"></textarea>
3305 <div class="blob-controls">
3306 <button id="blob-copy" class="primary">Copy</button>
3307 <button id="blob-download" class="ghost">Download</button>
3308 ${extras}
3309 <label class="row"><input type="checkbox" id="b64-toggle"> Base64-wrap <span class="small">(safer for paste channels that mangle whitespace)</span></label>
3310 <span class="pill" id="blob-out-stats"></span>
3311 </div>`;
3312
3313 const ta = body.querySelector('#blob-out');
3314 const stats = body.querySelector('#blob-out-stats');
3315 const b64 = body.querySelector('#b64-toggle');
3316 b64.checked = !!App.state.settings.base64;
3317
3318 let current = '';
3319 function refresh() {
3320 const desc = App.state.pc && App.state.pc.localDescription;
3321 if (!desc) return;
3322 current = App.signal.encode(desc);
3323 ta.value = current;
3324 stats.textContent = current.length + ' bytes';
3325 }
3326 refresh();
3327
3328 const copyBtn = body.querySelector('#blob-copy');
3329 if (!navigator.clipboard || !navigator.clipboard.writeText) {
3330 copyBtn.disabled = true;
3331 copyBtn.title = 'Clipboard API not available in this context (requires HTTPS or localhost). Select the text above and copy manually.';
3332 } else {
3333 copyBtn.addEventListener('click', async () => {
3334 try { await navigator.clipboard.writeText(current); App.log.info('signal', name + ' copied'); }
3335 catch (e) { App.log.warn('signal', 'clipboard write failed', e.message); }
3336 });
3337 }
3338
3339 body.querySelector('#blob-download').addEventListener('click', () => {
3340 const wrapped = App.state.settings.base64;
3341 const ext = wrapped ? 'txt' : 'json';
3342 const mime = wrapped ? 'text/plain' : 'application/json';
3343 const blob = new Blob([current], { type: mime });
3344 const url = URL.createObjectURL(blob);
3345 const a = document.createElement('a');
3346 a.href = url; a.download = `webrtc-${name}.${ext}`;
3347 document.body.appendChild(a); a.click(); a.remove();
3348 URL.revokeObjectURL(url);
3349 App.log.info('signal', name + ' downloaded as ' + a.download);
3350 });
3351
3352 (extraButtons || []).forEach(b => {
3353 body.querySelector('#' + b.id).addEventListener('click', b.onClick);
3354 });
3355
3356 b64.addEventListener('change', e => {
3357 App.state.settings.base64 = e.target.checked;
3358 refresh();
3359 });
3360}
3361
3362/* Exchange views */
3363function renderInitiatorExchange() {
3364 document.getElementById('step-1-h').textContent = 'Step 1: send this offer to your peer';
3365 mountOutgoingBlob(document.getElementById('step-1-body'), 'offer');
3366
3367 document.getElementById('step-2-h').textContent = 'Step 2: paste your peer\'s answer';
3368 const s2 = document.getElementById('step-2-body');
3369 s2.innerHTML = `
3370 <textarea id="blob-in" spellcheck="false" placeholder="Paste answer JSON here"></textarea>
3371 <div class="blob-controls">
3372 <button id="blob-apply" class="primary">Apply answer</button>
3373 <button id="blob-upload" class="ghost">Upload…</button>
3374 <input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
3375 <span class="pill" id="blob-in-status"></span>
3376 </div>`;
3377 wireUploadButton('answer');
3378 document.getElementById('blob-apply').addEventListener('click', async () => {
3379 const text = document.getElementById('blob-in').value;
3380 const statusEl = document.getElementById('blob-in-status');
3381 try {
3382 const obj = App.signal.decode(text);
3383 if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
3384 statusEl.textContent = 'applying…'; statusEl.className = 'pill warn';
3385 await applyAnswerOnInitiator(obj);
3386 statusEl.textContent = 'applied'; statusEl.className = 'pill ok';
3387 goToCall();
3388 } catch (e) {
3389 statusEl.textContent = e.message; statusEl.className = 'pill err';
3390 App.log.error('signal', 'apply answer failed', e.message);
3391 }
3392 });
3393
3394 showView('view-exchange');
3395}
3396
3397function renderJoinerExchange() {
3398 document.getElementById('step-1-h').textContent = 'Step 1: paste the offer from your peer';
3399 const s1 = document.getElementById('step-1-body');
3400 s1.innerHTML = `
3401 <textarea id="blob-in" spellcheck="false" placeholder="Paste offer JSON here"></textarea>
3402 <div class="blob-controls">
3403 <button id="blob-apply" class="primary">Apply offer & generate answer</button>
3404 <button id="blob-upload" class="ghost">Upload…</button>
3405 <input id="blob-upload-file" type="file" accept=".json,.txt,application/json,text/plain" hidden>
3406 <span class="pill" id="blob-in-status"></span>
3407 </div>`;
3408 document.getElementById('step-2-card').classList.add('hidden');
3409 wireUploadButton('offer');
3410
3411 document.getElementById('blob-apply').addEventListener('click', async () => {
3412 const text = document.getElementById('blob-in').value;
3413 const statusEl = document.getElementById('blob-in-status');
3414 try {
3415 const obj = App.signal.decode(text);
3416 if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
3417 statusEl.textContent = 'working…'; statusEl.className = 'pill warn';
3418 await finishJoiner(obj);
3419 statusEl.textContent = 'ready'; statusEl.className = 'pill ok';
3420 } catch (e) {
3421 statusEl.textContent = e.message; statusEl.className = 'pill err';
3422 App.log.error('signal', 'apply offer failed', e.message);
3423 App.progress.hide();
3424 }
3425 });
3426
3427 showView('view-exchange');
3428}
3429
3430function showAnswerForJoiner() {
3431 document.getElementById('step-2-card').classList.remove('hidden');
3432 document.getElementById('step-2-h').textContent = 'Step 2: send this answer back to your peer';
3433 mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', [
3434 { id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => goToCall() },
3435 ]);
3436}
3437
3438function goToCall() {
3439 showView('view-call');
3440 setInCallControlsEnabled(true);
3441 /* Pre-fill runtime settings panel from current values */
3442 const s = App.state.settings;
3443 const $ = id => document.getElementById(id);
3444 $('rt-v-w').value = s.video.width || 0;
3445 $('rt-v-h').value = s.video.height || 0;
3446 $('rt-v-fps').value = s.video.frameRate || 0;
3447 $('rt-v-maxbr').value = s.video.maxBitrateKbps || 0;
3448 $('rt-v-degrade').value = s.video.degradationPreference || 'balanced';
3449 populateCodecDropdown('rt-codec');
3450 const wantSend = (s.sendVideoCodec || 'auto').toLowerCase();
3451 const rtCodec = $('rt-codec');
3452 const rtMatch = Array.from(rtCodec.options).find(o => o.value.toLowerCase() === wantSend);
3453 rtCodec.value = rtMatch ? rtMatch.value : 'auto';
3454 $('rt-s-w').value = s.screen.width || 0;
3455 $('rt-s-h').value = s.screen.height || 0;
3456 $('rt-s-fps').value = s.screen.frameRate || 0;
3457 $('rt-s-maxbr').value = s.screen.maxBitrateKbps || 0;
3458 $('rt-s-degrade').value = s.screen.degradationPreference || 'maintain-resolution';
3459 $('rt-a-aec').checked = s.audio.echoCancellation;
3460 $('rt-a-ns').checked = s.audio.noiseSuppression;
3461 $('rt-a-agc').checked = s.audio.autoGainControl;
3462 $('rt-a-channels').value = String(s.audio.channelCount || 1);
3463 $('rt-a-rate').value = s.audio.sampleRate || 0;
3464 App.stats.start();
3465 /* Refresh the displays after the view is actually visible — some browsers
3466 don't render hidden video elements properly, so re-bind srcObject. */
3467 App.media.refreshLocalDisplay();
3468 App.media.refreshRemoteDisplay();
3469 applyMediaButtonAvailability();
3470}
3471
3472/* Disable mic/cam/screen toolbar buttons when they can't possibly succeed —
3473 either because the page isn't a secure context (gUM/gDM unavailable) or
3474 because no matching hardware is connected. Re-runs on devicechange so
3475 plugging in a webcam mid-call re-enables the button. */
3476let _deviceChangeBound = false;
3477async function applyMediaButtonAvailability() {
3478 const hasGum = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
3479 const hasGdm = !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia);
3480 const insecureTip = 'Unavailable in this context — requires HTTPS or localhost.';
3481 const micBtn = document.getElementById('tb-mic');
3482 const camBtn = document.getElementById('tb-cam');
3483 const screenBtn = document.getElementById('tb-screen');
3484 const micPick = document.getElementById('tb-mic-pick');
3485 const camPick = document.getElementById('tb-cam-pick');
3486
3487 if (!hasGdm) { screenBtn.disabled = true; screenBtn.title = insecureTip; }
3488 if (!hasGum) {
3489 micBtn.disabled = true; micBtn.title = insecureTip;
3490 camBtn.disabled = true; camBtn.title = insecureTip;
3491 if (micPick) micPick.disabled = true;
3492 if (camPick) camPick.disabled = true;
3493 return;
3494 }
3495
3496 /* Bind the devicechange listener once. Counts of audioinput/videoinput
3497 entries reflect presence even before permission is granted (the entries
3498 have empty labels but still exist), so this works on first call too. */
3499 if (!_deviceChangeBound && navigator.mediaDevices.addEventListener) {
3500 navigator.mediaDevices.addEventListener('devicechange', applyMediaButtonAvailability);
3501 _deviceChangeBound = true;
3502 }
3503
3504 let devs = [];
3505 try {
3506 devs = await navigator.mediaDevices.enumerateDevices();
3507 } catch (e) {
3508 App.log.warn('media', 'enumerateDevices failed', e.message);
3509 /* Fall through with permissive defaults — gUM may still work. */
3510 }
3511 const audioInputs = devs.filter(d => d.kind === 'audioinput');
3512 const videoInputs = devs.filter(d => d.kind === 'videoinput');
3513 const hasMic = devs.length === 0 || audioInputs.length > 0;
3514 const hasCam = devs.length === 0 || videoInputs.length > 0;
3515
3516 /* If the device disappears while in use, don't yank the button out from
3517 under the user — leave it clickable so they can turn the active track
3518 off. The track-end handler will reset the button when the OS releases
3519 the device. */
3520 const micOn = micBtn.classList.contains('on');
3521 const camOn = camBtn.classList.contains('on');
3522 micBtn.disabled = !hasMic && !micOn;
3523 micBtn.title = hasMic ? 'Enable microphone' : 'No microphone detected';
3524 camBtn.disabled = !hasCam && !camOn;
3525 camBtn.title = hasCam ? 'Enable camera' : 'No camera detected';
3526
3527 /* Device labels are only populated after the user has granted permission for
3528 that media kind. With blank labels the picker would just list anonymous
3529 "Microphone 1 / 2", which the user can't meaningfully choose between —
3530 gate the chevron until the input has been enabled at least once. */
3531 const micLabelled = audioInputs.some(d => d.label);
3532 const camLabelled = videoInputs.some(d => d.label);
3533 if (micPick) {
3534 micPick.disabled = !hasMic || !micLabelled;
3535 micPick.title = micPick.disabled
3536 ? 'Enable microphone first to choose a device'
3537 : 'Choose microphone';
3538 }
3539 if (camPick) {
3540 camPick.disabled = !hasCam || !camLabelled;
3541 camPick.title = camPick.disabled
3542 ? 'Enable camera first to choose a device'
3543 : 'Choose camera';
3544 }
3545
3546 renderDeviceMenus(devs);
3547}
3548
3549/* Rebuild the mic and camera popover lists from an enumerateDevices() snapshot.
3550 Devices without labels (no permission yet) show as "Microphone 1", etc., so
3551 the user can still see *how many* devices exist before granting permission. */
3552function renderDeviceMenus(devs) {
3553 renderOneDeviceMenu('tb-mic-menu', 'mic', devs.filter(d => d.kind === 'audioinput'),
3554 App.state.settings.audio.deviceId,
3555 App.state.micTransceiver && App.state.micTransceiver.sender);
3556 renderOneDeviceMenu('tb-cam-menu', 'cam', devs.filter(d => d.kind === 'videoinput'),
3557 App.state.settings.video.deviceId,
3558 App.state.camTransceiver && App.state.camTransceiver.sender);
3559}
3560
3561function renderOneDeviceMenu(menuId, kind, devs, savedId, sender) {
3562 const menu = document.getElementById(menuId);
3563 if (!menu) return;
3564 /* Some webcams (notably HP combo cameras) expose the RGB and IR sensors as
3565 two enumerateDevices entries with the *same* deviceId. gUM can't tell
3566 them apart with {exact: deviceId}, so listing both rows would let the
3567 user click a "different" device that's actually the same one. Dedup by
3568 deviceId, keeping the first label we saw. */
3569 const seenIds = new Set();
3570 devs = devs.filter(d => {
3571 if (!d.deviceId) return true; /* pre-permission entries — keep them */
3572 if (seenIds.has(d.deviceId)) return false;
3573 seenIds.add(d.deviceId);
3574 return true;
3575 });
3576 /* "active" = the device currently producing the live stream. If we passed
3577 {exact: savedId} to gUM and it succeeded, savedId IS what's live — trust
3578 that over getSettings().deviceId, which some webcams misreport. Only
3579 fall back to getSettings() when no preference is saved. */
3580 let activeId = '';
3581 if (sender && sender.track) {
3582 activeId = savedId
3583 || (sender.track.getSettings ? sender.track.getSettings().deviceId : '')
3584 || '';
3585 }
3586 const kindLabel = kind === 'mic' ? 'Microphone' : 'Camera';
3587 const rows = [];
3588
3589 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>';
3590 const sysActive = !savedId && activeId ? ` <span class="device-active">${activeDot} active</span>` : '';
3591 rows.push(`<label class="device-row" role="menuitemradio">
3592 <input type="radio" name="dev-${kind}" value="" ${savedId ? '' : 'checked'}>
3593 <span class="device-label">System default</span>${sysActive}
3594 </label>`);
3595
3596 if (devs.length === 0) {
3597 rows.push(`<div class="device-empty">No ${kindLabel.toLowerCase()}s detected.</div>`);
3598 } else {
3599 devs.forEach((d, i) => {
3600 const label = d.label || `${kindLabel} ${i + 1}`;
3601 const checked = d.deviceId === savedId ? 'checked' : '';
3602 const isActive = d.deviceId === activeId
3603 ? ` <span class="device-active">${activeDot} active</span>`
3604 : '';
3605 rows.push(`<label class="device-row" role="menuitemradio">
3606 <input type="radio" name="dev-${kind}" value="${escapeAttr(d.deviceId)}" ${checked}>
3607 <span class="device-label">${escapeHtml(label)}</span>${isActive}
3608 </label>`);
3609 });
3610 if (!devs[0].label) {
3611 rows.push(`<div class="device-empty">Enable ${kindLabel.toLowerCase()} to see device names.</div>`);
3612 }
3613 }
3614
3615 menu.innerHTML = rows.join('');
3616}
3617
3618/* Re-renders the picker lists with the current saved/active state. Used by
3619 the picker change handler so the radio + "active" marker reflect the new
3620 selection immediately, without waiting for the next devicechange event. */
3621function refreshDeviceMenus() {
3622 navigator.mediaDevices.enumerateDevices().then(renderDeviceMenus).catch(() => {});
3623}
3624
3625function setupDevicePickers() {
3626 setupOnePicker('mic');
3627 setupOnePicker('cam');
3628
3629 /* Close any open menu on outside click or Escape. */
3630 document.addEventListener('click', (e) => {
3631 document.querySelectorAll('.device-picker').forEach(group => {
3632 if (!group.contains(e.target)) closeMenuInGroup(group);
3633 });
3634 });
3635 document.addEventListener('keydown', (e) => {
3636 if (e.key === 'Escape') {
3637 document.querySelectorAll('.device-menu:not(.hidden)').forEach(m => {
3638 const chev = m.parentElement.querySelector('.device-chevron');
3639 m.classList.add('hidden');
3640 if (chev) chev.setAttribute('aria-expanded', 'false');
3641 });
3642 }
3643 });
3644}
3645
3646function closeMenuInGroup(group) {
3647 const menu = group.querySelector('.device-menu');
3648 const chev = group.querySelector('.device-chevron');
3649 if (menu) menu.classList.add('hidden');
3650 if (chev) chev.setAttribute('aria-expanded', 'false');
3651}
3652
3653function setupOnePicker(kind) {
3654 const chev = document.getElementById(`tb-${kind}-pick`);
3655 const menu = document.getElementById(`tb-${kind}-menu`);
3656 if (!chev || !menu) return;
3657
3658 chev.addEventListener('click', (e) => {
3659 e.stopPropagation();
3660 /* Close any other open menus first. */
3661 document.querySelectorAll('.device-picker').forEach(g => {
3662 if (!g.contains(chev)) closeMenuInGroup(g);
3663 });
3664 const opening = menu.classList.contains('hidden');
3665 menu.classList.toggle('hidden');
3666 chev.setAttribute('aria-expanded', opening ? 'true' : 'false');
3667 if (opening) {
3668 /* Re-enumerate on open so labels are fresh after a permission grant. */
3669 refreshDeviceMenus();
3670 }
3671 });
3672
3673 menu.addEventListener('change', async (e) => {
3674 const input = e.target.closest('input[type="radio"]');
3675 if (!input) return;
3676 const settings = kind === 'mic' ? App.state.settings.audio : App.state.settings.video;
3677 const newId = input.value || '';
3678 menu.classList.add('hidden');
3679 chev.setAttribute('aria-expanded', 'false');
3680
3681 const btn = document.getElementById(`tb-${kind}`);
3682 if (!btn.classList.contains('on')) {
3683 /* Input is off — just persist the choice for the next time the user
3684 turns it on. No gUM call. */
3685 settings.deviceId = newId;
3686 refreshDeviceMenus();
3687 return;
3688 }
3689 /* Input is live — swap the track in place. On failure, leave the saved
3690 preference unchanged so the menu reverts to the previously-working
3691 selection on next render. */
3692 const got = await App.media.switchInputDevice(kind, newId);
3693 if (got !== null) settings.deviceId = newId;
3694 refreshDeviceMenus();
3695 });
3696}
3697
3698
3699/* -------------------------------------------------------------------------
3700 Console drawer rendering
3701------------------------------------------------------------------------- */
3702function setupConsole() {
3703 const body = document.getElementById('console-body');
3704 const countEl = document.getElementById('console-count');
3705 const levelSel = document.getElementById('console-level');
3706 const filterEl = document.getElementById('console-filter');
3707 const drawer = document.getElementById('console-drawer');
3708 const toggle = document.getElementById('console-toggle');
3709
3710 const ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
3711 function shouldShow(e) {
3712 if (!e) return false;
3713 if (ORDER[e.level] < ORDER[levelSel.value]) return false;
3714 const q = filterEl.value.toLowerCase();
3715 if (q && !(e.label.toLowerCase().includes(q) || e.args.some(a => String(a).toLowerCase().includes(q)))) return false;
3716 return true;
3717 }
3718 function fmt(args) {
3719 return args.map(a => {
3720 if (a == null) return String(a);
3721 if (typeof a === 'string') return a;
3722 try { return JSON.stringify(a); } catch (_) { return String(a); }
3723 }).join(' ');
3724 }
3725 function append(entry) {
3726 if (!entry) { body.innerHTML = ''; countEl.textContent = '0 entries'; return; }
3727 if (!shouldShow(entry)) { countEl.textContent = App.log.snapshot().length + ' entries'; return; }
3728 const div = document.createElement('div');
3729 div.className = 'log-line ' + entry.level;
3730 const t = new Date(entry.ts);
3731 const ts = t.toTimeString().slice(0, 8) + '.' + String(t.getMilliseconds()).padStart(3, '0');
3732 div.innerHTML = `<span class="ts">${ts}</span><span class="lvl">${entry.level}</span><span class="label">${escapeHtml(entry.label)}</span><span class="text"></span>`;
3733 div.querySelector('.text').textContent = fmt(entry.args);
3734 const wasAtBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 20;
3735 body.appendChild(div);
3736 if (wasAtBottom) body.scrollTop = body.scrollHeight;
3737 countEl.textContent = App.log.snapshot().length + ' entries';
3738 }
3739 function rerender() {
3740 body.innerHTML = '';
3741 App.log.snapshot().forEach(append);
3742 }
3743
3744 App.log.subscribe(append);
3745 levelSel.addEventListener('change', rerender);
3746 filterEl.addEventListener('input', rerender);
3747 toggle.addEventListener('click', () => drawer.classList.toggle('hidden'));
3748 document.getElementById('console-close').addEventListener('click', () => drawer.classList.add('hidden'));
3749 document.getElementById('console-clear').addEventListener('click', () => App.log.clear());
3750 document.getElementById('console-export').addEventListener('click', () => {
3751 const blob = new Blob([JSON.stringify(App.log.snapshot(), null, 2)], { type: 'application/json' });
3752 const a = document.createElement('a');
3753 a.href = URL.createObjectURL(blob);
3754 a.download = 'webrtc-log-' + Date.now() + '.json';
3755 a.click();
3756 });
3757 document.getElementById('console-stats-toggle').addEventListener('click', () => App.stats.toggleConsoleStats());
3758
3759 /* Keyboard shortcut */
3760 document.addEventListener('keydown', e => {
3761 if ((e.ctrlKey || e.metaKey) && e.key === '`') {
3762 e.preventDefault(); drawer.classList.toggle('hidden');
3763 }
3764 });
3765}
3766
3767/* -------------------------------------------------------------------------
3768 SDP inspector: parse an offer/answer and render it as labelled sections
3769 with one-line explanations for the common attributes.
3770
3771 Input accepted: raw SDP (starts with "v="), the {type,sdp} JSON this tool
3772 emits, or its "b64:"-prefixed wrapped form. The renderer never uses
3773 innerHTML with user content — everything goes through textContent — so
3774 pasted SDP can't smuggle markup into the page.
3775------------------------------------------------------------------------- */
3776App.sdpInspect = (() => {
3777 /* Short explanations for the SDP attributes we render. Missing entries
3778 just render without help text. */
3779 const ATTR_HELP = {
3780 'group': 'Groups m= sections into one transport. "BUNDLE 0 1 2" multiplexes those mids onto a single ICE/DTLS connection.',
3781 'msid-semantic': 'Declares the meaning of msid values (WMS = WebRTC Media Stream).',
3782 'ice-ufrag': 'ICE username fragment — half of the STUN binding-request credentials.',
3783 'ice-pwd': 'ICE password — the other half of the ICE credentials. Treat as a short-lived secret.',
3784 'ice-options': 'ICE feature flags (e.g. "trickle" = candidates may arrive after the SDP).',
3785 'fingerprint': 'DTLS certificate fingerprint. The peer authenticates the cert against this value.',
3786 'setup': 'DTLS role: active (client), passive (server), or actpass (will negotiate during the handshake).',
3787 'mid': 'Media identifier for this m= section; referenced by BUNDLE and by the "mid" RTP header extension.',
3788 'extmap': 'RTP header extension: numeric id → URI describing what the extension carries.',
3789 'rtcp-mux': 'RTP and RTCP share one UDP port. Always present in WebRTC.',
3790 'rtcp-rsize': 'Allows reduced-size RTCP packets.',
3791 'rtcp': 'Explicit RTCP port (legacy; ignored when rtcp-mux is set).',
3792 'sendrecv': 'This side will both send and receive media on this m=.',
3793 'sendonly': 'This side will only send media on this m=.',
3794 'recvonly': 'This side will only receive media on this m=.',
3795 'inactive': 'Negotiated but neither side will send/receive media on this m=.',
3796 'rtpmap': 'Maps an RTP payload type to a codec/clock-rate/channels triple.',
3797 'fmtp': 'Per-payload-type format parameters — encoder hints (e.g. opus useinbandfec=1).',
3798 'rtcp-fb': 'RTCP feedback messages this payload type supports (nack, pli, transport-cc, …).',
3799 'candidate': 'An ICE candidate — one possible source/destination address pair for media.',
3800 'end-of-candidates':'No more candidates will be trickled.',
3801 'msid': 'Binds this m= to a MediaStream id and Track id used by the JS API.',
3802 'ssrc': 'Synchronization source ID for an RTP stream, plus metadata (cname, msid, …).',
3803 'ssrc-group': 'Groups SSRCs (FID = RTX retransmission pair; SIM = simulcast layers).',
3804 'rid': 'Restriction identifier for one simulcast layer.',
3805 'simulcast': 'Declares simulcast layer ids and directions.',
3806 'maxptime': 'Maximum packetization time (ms) the receiver will accept.',
3807 'ptime': 'Preferred packetization time (ms).',
3808 'extmap-allow-mixed':'Receiver accepts RTP packets that mix one-byte and two-byte header extensions in the same packet (RFC 8285).',
3809 'rtcp': 'Explicit RTCP address/port. Legacy — ignored when rtcp-mux is in effect (RFC 3605).',
3810 'bundle-only': 'This m= section is only usable when bundled via the BUNDLE group; port is 0 if not selected (RFC 8843).',
3811 'sctp-port': 'SCTP port for the data channel association. WebRTC always uses 5000 (RFC 8841).',
3812 'max-message-size': 'Maximum SCTP user message size (bytes) the receiver will accept (RFC 8841).',
3813 };
3814
3815 const CANDIDATE_TYPE_HELP = {
3816 host: 'Local interface address on this machine (LAN or loopback).',
3817 srflx: 'Server-reflexive: public address as seen by a STUN server (post-NAT).',
3818 prflx: 'Peer-reflexive: address discovered during connectivity checks.',
3819 relay: 'TURN relay; media flows through the TURN server.',
3820 };
3821
3822 /* Accept raw SDP, JSON wrappers, or b64-prefixed JSON. Throws on garbage. */
3823 function extractSdp(text) {
3824 text = (text || '').trim();
3825 if (!text) throw new Error('empty input');
3826 if (text.startsWith('b64:')) {
3827 try {
3828 const bin = atob(text.slice(4));
3829 const bytes = new Uint8Array(bin.length);
3830 for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
3831 text = new TextDecoder().decode(bytes);
3832 } catch (e) { throw new Error('bad base64: ' + e.message); }
3833 }
3834 if (text.startsWith('{')) {
3835 let obj;
3836 try { obj = JSON.parse(text); } catch (e) { throw new Error('not valid JSON: ' + e.message); }
3837 if (!obj || typeof obj.sdp !== 'string') throw new Error('JSON has no "sdp" string field');
3838 if (!obj.sdp.includes('v=')) throw new Error('"sdp" field is not SDP (missing v=)');
3839 return { type: obj.type || '(unknown)', sdp: obj.sdp };
3840 }
3841 if (/^v=/m.test(text)) return { type: '(raw)', sdp: text };
3842 throw new Error('unrecognized input — expected SDP, JSON, or b64:…');
3843 }
3844
3845 /* Tokenize SDP into a session + one block per m= section. */
3846 function parseSdp(sdp) {
3847 const session = { kind: 'session', lines: [], attrs: [], media: [] };
3848 let cur = session;
3849 for (const raw of sdp.split(/\r?\n/)) {
3850 const line = raw.replace(/\r$/, '');
3851 if (!line) continue;
3852 const m = line.match(/^([a-z])=(.*)$/);
3853 if (!m) continue;
3854 const key = m[1], val = m[2];
3855 if (key === 'm') {
3856 const parts = val.split(/\s+/);
3857 const media = {
3858 kind: 'media', type: parts[0] || '?', port: parts[1] || '?', proto: parts[2] || '?',
3859 payloadTypes: parts.slice(3), lines: [], attrs: [],
3860 };
3861 session.media.push(media);
3862 cur = media;
3863 cur.lines.push({ key, val });
3864 continue;
3865 }
3866 cur.lines.push({ key, val });
3867 if (key === 'a') {
3868 const colon = val.indexOf(':');
3869 cur.attrs.push({
3870 name: colon === -1 ? val : val.slice(0, colon),
3871 value: colon === -1 ? '' : val.slice(colon + 1),
3872 });
3873 }
3874 }
3875 return session;
3876 }
3877
3878 /* DOM helpers — everything textContent, no innerHTML on user input. */
3879 function el(tag, cls, text) {
3880 const e = document.createElement(tag);
3881 if (cls) e.className = cls;
3882 if (text != null) e.textContent = text;
3883 return e;
3884 }
3885 function kv(grid, k, v, help) {
3886 grid.appendChild(el('span', 'k', k));
3887 const vEl = el('span', 'v', v == null ? '' : String(v));
3888 if (help) vEl.title = help;
3889 grid.appendChild(vEl);
3890 }
3891 function section(title, sub) {
3892 const wrap = el('div', 'sdp-section');
3893 const head = el('div', 'sdp-head');
3894 head.appendChild(el('h3', null, title));
3895 if (sub) head.appendChild(el('span', 'sdp-sub', sub));
3896 wrap.appendChild(head);
3897 return wrap;
3898 }
3899 function helpLine(text) { return el('p', 'sdp-help', text); }
3900 function rawBlock(label, lines) {
3901 const d = el('details', 'sdp-rawblock');
3902 d.appendChild(el('summary', null, label));
3903 d.appendChild(el('pre', null, lines.map(l => l.key + '=' + l.val).join('\n')));
3904 return d;
3905 }
3906
3907 /* Render an a=candidate:... value into a structured single-line list item.
3908 RFC 5245 layout: <foundation> <component> <transport> <priority>
3909 <addr> <port> typ <type> [raddr <addr> rport <port>]
3910 [generation N] [tcptype …] [ufrag …] */
3911 function renderCandidate(value) {
3912 const t = value.split(/\s+/);
3913 const li = el('li');
3914 const out = [];
3915 out.push({ text: t[2] || '?', cls: 'tag', help: 'transport (udp/tcp)' });
3916 out.push({ text: (t[4] || '?') + ':' + (t[5] || '?'), help: 'local address & port (often obfuscated by the browser)' });
3917 const typeIdx = t.indexOf('typ');
3918 const type = typeIdx >= 0 ? t[typeIdx + 1] : '?';
3919 out.push({ text: type, cls: 'badge', help: CANDIDATE_TYPE_HELP[type] || 'ICE candidate type' });
3920 const raddrIdx = t.indexOf('raddr');
3921 if (raddrIdx >= 0) {
3922 const rportIdx = t.indexOf('rport');
3923 out.push({ text: 'related ' + t[raddrIdx + 1] + ':' + (rportIdx >= 0 ? t[rportIdx + 1] : '?'),
3924 cls: 'dim', help: 'related (base) address — for srflx/relay, the host address behind it' });
3925 }
3926 out.push({ text: 'prio ' + (t[3] || '?'), cls: 'dim', help: 'priority — higher wins during pair selection' });
3927 out.push({ text: 'foundation ' + (t[0] || '?'), cls: 'dim',
3928 help: 'foundation — candidates with the same foundation share a local interface/server pair' });
3929
3930 out.forEach((p, i) => {
3931 const span = el('span', p.cls || null, p.text);
3932 if (p.help) span.title = p.help;
3933 li.appendChild(span);
3934 if (i < out.length - 1) li.appendChild(document.createTextNode(' '));
3935 });
3936 return li;
3937 }
3938
3939 function attrsBy(attrs, name) { return attrs.filter(a => a.name === name); }
3940 function attrFirst(attrs, name) { const a = attrs.find(x => x.name === name); return a ? a.value : null; }
3941 function directionOf(attrs) {
3942 for (const d of ['sendrecv','sendonly','recvonly','inactive'])
3943 if (attrs.some(a => a.name === d)) return d;
3944 return null;
3945 }
3946
3947 function renderSession(parsed) {
3948 const out = document.createDocumentFragment();
3949
3950 /* Session-level summary */
3951 const sec = section('Session');
3952 const grid = el('div', 'sdp-kv');
3953 const o = parsed.lines.find(l => l.key === 'o');
3954 if (o) {
3955 const op = o.val.split(/\s+/);
3956 kv(grid, 'origin', op.join(' '),
3957 'o=<username> <sess-id> <sess-version> <nettype> <addrtype> <unicast-addr>');
3958 }
3959 const sName = parsed.lines.find(l => l.key === 's');
3960 if (sName) kv(grid, 'name (s=)', sName.val, 'Session name. WebRTC uses "-".');
3961 const t = parsed.lines.find(l => l.key === 't');
3962 if (t) kv(grid, 'time (t=)', t.val, 't=<start> <stop>; "0 0" means unbounded — usual for real-time sessions.');
3963 const c = parsed.lines.find(l => l.key === 'c');
3964 if (c) kv(grid, 'connection (c=)', c.val, 'c=<nettype> <addrtype> <connection-address>.');
3965
3966 const sessionAttrs = parsed.attrs;
3967 const group = attrFirst(sessionAttrs, 'group');
3968 if (group) kv(grid, 'group', group, ATTR_HELP['group']);
3969 const msidSem = attrFirst(sessionAttrs, 'msid-semantic');
3970 if (msidSem) kv(grid, 'msid-semantic', msidSem, ATTR_HELP['msid-semantic']);
3971 const fp = attrFirst(sessionAttrs, 'fingerprint');
3972 if (fp) kv(grid, 'fingerprint (session)', fp, ATTR_HELP['fingerprint']);
3973 const setup = attrFirst(sessionAttrs, 'setup');
3974 if (setup) kv(grid, 'setup (session)', setup, ATTR_HELP['setup']);
3975 const ufrag = attrFirst(sessionAttrs, 'ice-ufrag');
3976 if (ufrag) kv(grid, 'ice-ufrag (session)', ufrag, ATTR_HELP['ice-ufrag']);
3977 const pwd = attrFirst(sessionAttrs, 'ice-pwd');
3978 if (pwd) kv(grid, 'ice-pwd (session)', pwd, ATTR_HELP['ice-pwd']);
3979 const iceOpts = attrFirst(sessionAttrs, 'ice-options');
3980 if (iceOpts) kv(grid, 'ice-options', iceOpts, ATTR_HELP['ice-options']);
3981 if (sessionAttrs.some(a => a.name === 'extmap-allow-mixed'))
3982 kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
3983
3984 sec.appendChild(grid);
3985 out.appendChild(sec);
3986
3987 /* One section per m= */
3988 parsed.media.forEach((media, idx) => renderMedia(media, idx, sessionAttrs, out));
3989 return out;
3990 }
3991
3992 function renderMedia(media, idx, sessionAttrs, out) {
3993 const sub = media.proto + ' port ' + media.port + ' PTs: ' + media.payloadTypes.join(' ');
3994 const sec = section('m=' + media.type + ' [' + idx + ']', sub);
3995
3996 const grid = el('div', 'sdp-kv');
3997 const mid = attrFirst(media.attrs, 'mid');
3998 if (mid) kv(grid, 'mid', mid, ATTR_HELP['mid']);
3999 const dir = directionOf(media.attrs);
4000 if (dir) kv(grid, 'direction', dir, ATTR_HELP[dir]);
4001 const msid = attrFirst(media.attrs, 'msid');
4002 if (msid) kv(grid, 'msid', msid, ATTR_HELP['msid']);
4003 if (media.attrs.some(a => a.name === 'rtcp-mux')) kv(grid, 'rtcp-mux', 'yes', ATTR_HELP['rtcp-mux']);
4004 if (media.attrs.some(a => a.name === 'rtcp-rsize')) kv(grid, 'rtcp-rsize', 'yes', ATTR_HELP['rtcp-rsize']);
4005 if (media.attrs.some(a => a.name === 'extmap-allow-mixed'))
4006 kv(grid, 'extmap-allow-mixed', 'yes', ATTR_HELP['extmap-allow-mixed']);
4007 if (media.attrs.some(a => a.name === 'bundle-only'))
4008 kv(grid, 'bundle-only', 'yes', ATTR_HELP['bundle-only']);
4009 const rtcpAddr = attrFirst(media.attrs, 'rtcp');
4010 if (rtcpAddr) kv(grid, 'rtcp (legacy)', rtcpAddr, ATTR_HELP['rtcp']);
4011 const sctpPort = attrFirst(media.attrs, 'sctp-port');
4012 if (sctpPort) kv(grid, 'sctp-port', sctpPort, ATTR_HELP['sctp-port']);
4013 const maxMsg = attrFirst(media.attrs, 'max-message-size');
4014 if (maxMsg) kv(grid, 'max-message-size', maxMsg + ' bytes', ATTR_HELP['max-message-size']);
4015 const mFp = attrFirst(media.attrs, 'fingerprint');
4016 if (mFp) kv(grid, 'fingerprint', mFp, ATTR_HELP['fingerprint']);
4017 const mSetup = attrFirst(media.attrs, 'setup');
4018 if (mSetup) kv(grid, 'setup', mSetup, ATTR_HELP['setup']);
4019 const mUfrag = attrFirst(media.attrs, 'ice-ufrag');
4020 if (mUfrag) kv(grid, 'ice-ufrag', mUfrag, ATTR_HELP['ice-ufrag']);
4021 const mPwd = attrFirst(media.attrs, 'ice-pwd');
4022 if (mPwd) kv(grid, 'ice-pwd', mPwd, ATTR_HELP['ice-pwd']);
4023 sec.appendChild(grid);
4024
4025 /* Codecs */
4026 const rtpmaps = attrsBy(media.attrs, 'rtpmap');
4027 const fmtps = attrsBy(media.attrs, 'fmtp');
4028 const fbs = attrsBy(media.attrs, 'rtcp-fb');
4029 if (rtpmaps.length) {
4030 sec.appendChild(el('h3', null, 'Codecs'));
4031 sec.appendChild(helpLine('Each payload type (PT) maps to a codec definition. fmtp/rtcp-fb lines attach to a PT by id.'));
4032 const list = el('ul', 'sdp-list');
4033 rtpmaps.forEach(r => {
4034 const m = r.value.match(/^(\d+)\s+(.+)$/);
4035 if (!m) return;
4036 const pt = m[1], spec = m[2];
4037 const li = el('li');
4038 const tag = el('span', 'tag', pt);
4039 tag.title = 'RTP payload type number';
4040 li.appendChild(tag);
4041 li.appendChild(document.createTextNode(spec));
4042 const fmtp = fmtps.find(f => f.value.startsWith(pt + ' '));
4043 if (fmtp) {
4044 const b = el('span', 'badge', 'fmtp: ' + fmtp.value.slice(pt.length + 1));
4045 b.title = ATTR_HELP['fmtp'];
4046 li.appendChild(document.createTextNode(' '));
4047 li.appendChild(b);
4048 }
4049 const ptFbs = fbs.filter(f => f.value.startsWith(pt + ' ') || f.value.startsWith('* '));
4050 if (ptFbs.length) {
4051 const fbText = ptFbs.map(f => f.value.split(/\s+/).slice(1).join(' ')).join(' / ');
4052 const b = el('span', 'dim', 'fb: ' + fbText);
4053 b.title = ATTR_HELP['rtcp-fb'];
4054 li.appendChild(document.createTextNode(' '));
4055 li.appendChild(b);
4056 }
4057 list.appendChild(li);
4058 });
4059 sec.appendChild(list);
4060 }
4061
4062 /* RTP header extensions */
4063 const extmaps = attrsBy(media.attrs, 'extmap');
4064 if (extmaps.length) {
4065 sec.appendChild(el('h3', null, 'RTP header extensions'));
4066 sec.appendChild(helpLine(ATTR_HELP['extmap']));
4067 const list = el('ul', 'sdp-list');
4068 extmaps.forEach(e => {
4069 const m = e.value.match(/^(\d+)(?:\/(\S+))?\s+(.+)$/);
4070 const li = el('li');
4071 if (m) {
4072 li.appendChild(el('span', 'tag', m[1]));
4073 if (m[2]) { const dir = el('span', 'badge', m[2]); dir.title = 'extension direction'; li.appendChild(dir); }
4074 li.appendChild(document.createTextNode(' ' + m[3]));
4075 } else li.textContent = e.value;
4076 list.appendChild(li);
4077 });
4078 sec.appendChild(list);
4079 }
4080
4081 /* ICE candidates */
4082 const cands = attrsBy(media.attrs, 'candidate');
4083 if (cands.length) {
4084 sec.appendChild(el('h3', null, 'ICE candidates (' + cands.length + ')'));
4085 sec.appendChild(helpLine(ATTR_HELP['candidate']));
4086 const list = el('ul', 'sdp-list');
4087 cands.forEach(c => list.appendChild(renderCandidate(c.value)));
4088 sec.appendChild(list);
4089 }
4090 if (media.attrs.some(a => a.name === 'end-of-candidates'))
4091 sec.appendChild(helpLine('end-of-candidates present: ' + ATTR_HELP['end-of-candidates']));
4092
4093 /* SSRCs */
4094 const ssrcs = attrsBy(media.attrs, 'ssrc');
4095 const ssrcGroups = attrsBy(media.attrs, 'ssrc-group');
4096 if (ssrcs.length || ssrcGroups.length) {
4097 sec.appendChild(el('h3', null, 'SSRCs'));
4098 if (ssrcGroups.length) {
4099 const gl = el('ul', 'sdp-list');
4100 ssrcGroups.forEach(g => {
4101 const li = el('li');
4102 li.appendChild(el('span', 'badge', 'group'));
4103 li.appendChild(document.createTextNode(' ' + g.value));
4104 li.title = ATTR_HELP['ssrc-group'];
4105 gl.appendChild(li);
4106 });
4107 sec.appendChild(gl);
4108 }
4109 const byId = new Map();
4110 ssrcs.forEach(s => {
4111 const m = s.value.match(/^(\d+)\s+(\S+?)(?::(.*))?$/);
4112 if (!m) return;
4113 const id = m[1], attr = m[2], val = m[3] || '';
4114 if (!byId.has(id)) byId.set(id, []);
4115 byId.get(id).push(attr + (val ? '=' + val : ''));
4116 });
4117 const list = el('ul', 'sdp-list');
4118 byId.forEach((props, id) => {
4119 const li = el('li');
4120 li.appendChild(el('span', 'tag', id));
4121 li.appendChild(document.createTextNode(props.join(' ')));
4122 li.title = ATTR_HELP['ssrc'];
4123 list.appendChild(li);
4124 });
4125 sec.appendChild(list);
4126 }
4127
4128 /* Simulcast / rid */
4129 const rids = attrsBy(media.attrs, 'rid');
4130 const sim = attrFirst(media.attrs, 'simulcast');
4131 if (rids.length || sim) {
4132 sec.appendChild(el('h3', null, 'Simulcast'));
4133 sec.appendChild(helpLine(ATTR_HELP['simulcast']));
4134 if (sim) sec.appendChild(el('p', 'sdp-help', 'simulcast: ' + sim));
4135 if (rids.length) {
4136 const list = el('ul', 'sdp-list');
4137 rids.forEach(r => { const li = el('li'); li.textContent = r.value; li.title = ATTR_HELP['rid']; list.appendChild(li); });
4138 sec.appendChild(list);
4139 }
4140 }
4141
4142 /* Anything we didn't classify — show raw for completeness. */
4143 const handled = new Set([
4144 'mid','msid','rtcp-mux','rtcp-rsize','fingerprint','setup','ice-ufrag','ice-pwd',
4145 'sendrecv','sendonly','recvonly','inactive','rtpmap','fmtp','rtcp-fb','candidate',
4146 'end-of-candidates','ssrc','ssrc-group','rid','simulcast','extmap',
4147 'extmap-allow-mixed','bundle-only','rtcp','sctp-port','max-message-size',
4148 ]);
4149 const other = media.attrs.filter(a => !handled.has(a.name));
4150 if (other.length) {
4151 sec.appendChild(rawBlock('Other attributes (' + other.length + ')',
4152 other.map(a => ({ key: 'a', val: a.name + (a.value ? ':' + a.value : '') }))));
4153 }
4154 sec.appendChild(rawBlock('Raw lines for this m= (' + media.lines.length + ')', media.lines));
4155 out.appendChild(sec);
4156 }
4157
4158 function run(input, container, statusEl) {
4159 container.replaceChildren();
4160 try {
4161 const { type, sdp } = extractSdp(input);
4162 const parsed = parseSdp(sdp);
4163 const header = el('div', 'sdp-section');
4164 const head = el('div', 'sdp-head');
4165 head.appendChild(el('h3', null, 'Detected: ' + type));
4166 head.appendChild(el('span', 'sdp-sub',
4167 parsed.media.length + ' m= section(s) · ' + sdp.split(/\r?\n/).length + ' lines'));
4168 header.appendChild(head);
4169 container.appendChild(header);
4170 container.appendChild(renderSession(parsed));
4171 statusEl.textContent = 'parsed ' + parsed.media.length + ' section(s)';
4172 statusEl.className = 'pill ok';
4173 } catch (e) {
4174 statusEl.textContent = e.message;
4175 statusEl.className = 'pill err';
4176 }
4177 }
4178
4179 return { run };
4180})();
4181
4182/* -------------------------------------------------------------------------
4183 Wire up everything on DOMContentLoaded
4184------------------------------------------------------------------------- */
4185function wire() {
4186 window.addEventListener('popstate', onPopstate);
4187 resolveInitialView();
4188
4189 /* Welcome */
4190 document.querySelectorAll('#view-welcome .role-picker button').forEach(b =>
4191 b.addEventListener('click', () => pickRole(b.dataset.role)));
4192 document.getElementById('welcome-sdp-inspect').addEventListener('click', () => {
4193 /* Standalone tool — no role, no pc. Just swap the view. */
4194 showView('view-sdp-inspect');
4195 });
4196
4197 /* SDP inspector */
4198 const sdpIn = document.getElementById('sdp-in');
4199 const sdpOut = document.getElementById('sdp-inspect-out');
4200 const sdpStatus = document.getElementById('sdp-inspect-status');
4201 document.getElementById('sdp-inspect-go').addEventListener('click',
4202 () => App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus));
4203 document.getElementById('sdp-inspect-clear').addEventListener('click', () => {
4204 sdpIn.value = ''; sdpOut.replaceChildren(); sdpStatus.textContent = ''; sdpStatus.className = 'pill';
4205 });
4206 document.getElementById('sdp-inspect-back').addEventListener('click', () => showView('view-welcome'));
4207 const sdpFile = document.getElementById('sdp-inspect-file');
4208 document.getElementById('sdp-inspect-upload').addEventListener('click', () => sdpFile.click());
4209 sdpFile.addEventListener('change', async () => {
4210 const f = sdpFile.files && sdpFile.files[0];
4211 if (!f) return;
4212 try { sdpIn.value = await f.text(); App.sdpInspect.run(sdpIn.value, sdpOut, sdpStatus); }
4213 catch (e) { sdpStatus.textContent = 'read failed: ' + e.message; sdpStatus.className = 'pill err'; }
4214 finally { sdpFile.value = ''; }
4215 });
4216
4217 /* Theme */
4218 document.getElementById('theme-toggle').addEventListener('click', () => App.theme.toggle());
4219
4220 /* Peer-left dialog: OK closes; backdrop/Esc also close (native behavior). */
4221 document.getElementById('peer-left-ok').addEventListener('click', () => {
4222 document.getElementById('peer-left-dialog').close();
4223 });
4224
4225 /* Step dialog: Cancel and Esc both invoke the registered cancel handler.
4226 Listen on 'cancel' (fired by Esc) and 'close' as a belt-and-suspenders. */
4227 document.getElementById('step-dialog-cancel').addEventListener('click', () => {
4228 App.progress.triggerCancel();
4229 });
4230 document.getElementById('step-dialog').addEventListener('cancel', e => {
4231 /* Don't let the dialog close before we run the cancel handler — the
4232 handler itself calls dlg.close() through App.progress.hideModal(). */
4233 e.preventDefault();
4234 App.progress.triggerCancel();
4235 });
4236
4237 /* Best-effort hangup notification when the tab is closing or backgrounded
4238 to bfcache. Use pagehide (more reliable than beforeunload, especially
4239 on mobile) and only send if a chat channel is currently open. */
4240 window.addEventListener('pagehide', () => {
4241 if (App.chat && App.chat.sendBye) App.chat.sendBye();
4242 });
4243
4244 /* Configure */
4245 document.getElementById('ice-add').addEventListener('click', () => {
4246 App.state.settings.iceServers.push({ urls: '' });
4247 renderIceRows();
4248 });
4249 document.getElementById('ice-clear').addEventListener('click', () => {
4250 App.state.settings.iceServers = [];
4251 renderIceRows();
4252 });
4253 document.getElementById('ice-reset').addEventListener('click', () => {
4254 App.state.settings.iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
4255 renderIceRows();
4256 });
4257 document.getElementById('ice-toggle-json').addEventListener('click', () => {
4258 const w = document.getElementById('ice-json-wrap');
4259 document.getElementById('ice-json').value = JSON.stringify(App.state.settings.iceServers, null, 2);
4260 w.classList.toggle('hidden');
4261 });
4262 document.getElementById('ice-warmup').addEventListener('click', async () => {
4263 const status = document.getElementById('ice-warmup-status');
4264 const btn = document.getElementById('ice-warmup');
4265 if (App.state.iceWarmupStream) {
4266 App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
4267 App.state.iceWarmupStream = null;
4268 status.textContent = 'off'; status.className = 'pill';
4269 btn.textContent = 'Enable LAN connectivity';
4270 App.log.info('ice', 'LAN warmup stream stopped');
4271 return;
4272 }
4273 if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
4274 status.textContent = 'unavailable (needs HTTPS)'; status.className = 'pill err';
4275 return;
4276 }
4277 btn.disabled = true;
4278 status.textContent = 'requesting…'; status.className = 'pill warn';
4279 try {
4280 const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
4281 /* Mute the track but keep the stream alive — Firefox exposes LAN ICE
4282 candidates only while a gUM stream is in use. Stopping the track would
4283 revert to restricted candidates. */
4284 stream.getAudioTracks().forEach(t => t.enabled = false);
4285 App.state.iceWarmupStream = stream;
4286 status.textContent = 'on (mic in use, muted)'; status.className = 'pill ok';
4287 btn.textContent = 'Disable LAN connectivity';
4288 App.log.info('ice', 'LAN warmup stream active; LAN candidates unlocked');
4289 } catch (e) {
4290 status.textContent = 'denied: ' + e.message; status.className = 'pill err';
4291 App.log.warn('ice', 'LAN warmup denied', e.message);
4292 } finally {
4293 btn.disabled = false;
4294 }
4295 });
4296 document.getElementById('ice-json-apply').addEventListener('click', () => {
4297 try {
4298 const v = JSON.parse(document.getElementById('ice-json').value);
4299 if (!Array.isArray(v)) throw new Error('expected an array');
4300 App.state.settings.iceServers = v;
4301 renderIceRows();
4302 App.log.info('ice', 'applied JSON config', v.length, 'servers');
4303 } catch (e) { App.log.error('ice', 'bad JSON', e.message); alert('Bad JSON: ' + e.message); }
4304 });
4305 document.getElementById('cfg-back').addEventListener('click', () => {
4306 /* Tear down a partial pc/abort an in-flight signaling fetch if the
4307 user hits Back while auto-mode is waiting for the peer. */
4308 hangup({ sendBye: false });
4309 });
4310 /* Signaling mode toggle */
4311 document.getElementById('sig-mode-manual').addEventListener('click', () => {
4312 applySignalingMode('manual'); saveSignaling();
4313 });
4314 document.getElementById('sig-mode-auto').addEventListener('click', () => {
4315 applySignalingMode('auto'); saveSignaling();
4316 });
4317 document.getElementById('sig-room-gen').addEventListener('click', () => {
4318 document.getElementById('sig-room-code').value = randomRoomCode();
4319 });
4320 document.getElementById('sig-check').addEventListener('click', async () => {
4321 const btn = document.getElementById('sig-check');
4322 const status = document.getElementById('sig-check-status');
4323 const url = (document.getElementById('sig-server-url').value || '').trim().replace(/\/+$/, '');
4324 status.classList.remove('hidden');
4325 if (!url) { status.textContent = 'enter a URL first'; status.className = 'pill err'; return; }
4326 btn.disabled = true;
4327 status.textContent = 'checking…'; status.className = 'pill warn';
4328 /* Independent abort from the room-handshake one so cancelling Check doesn't
4329 affect anything else. 5 s is plenty for a healthy server. */
4330 const ac = new AbortController();
4331 const timer = setTimeout(() => ac.abort(), 5000);
4332 const t0 = performance.now();
4333 try {
4334 const r = await fetch(url + '/health', { signal: ac.signal, cache: 'no-store' });
4335 const ms = Math.round(performance.now() - t0);
4336 if (r.ok) { status.textContent = 'reachable (' + r.status + ', ' + ms + ' ms)'; status.className = 'pill ok'; }
4337 else { status.textContent = 'HTTP ' + r.status; status.className = 'pill err'; }
4338 } catch (e) {
4339 status.textContent = ac.signal.aborted ? 'timed out (5 s)' : 'unreachable: ' + e.message;
4340 status.className = 'pill err';
4341 } finally {
4342 clearTimeout(timer);
4343 btn.disabled = false;
4344 }
4345 });
4346 document.getElementById('sig-room-code').addEventListener('input', e => {
4347 /* Server validates the same character class — keep the input clean so the
4348 user notices invalid keystrokes immediately rather than at request time. */
4349 e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 15);
4350 });
4351
4352 document.getElementById('cfg-continue').addEventListener('click', async () => {
4353 readConfigInputs();
4354 saveIce();
4355 const auto = App.state.settings.signaling.mode === 'auto' && App.state.role !== 'loopback';
4356 try {
4357 if (auto) {
4358 const code = (document.getElementById('sig-room-code').value || '').trim();
4359 if (!code) { alert('Room code is required for auto mode.'); return; }
4360 if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); return; }
4361 if (App.state.role === 'initiator') await startInitiatorAuto(code);
4362 else if (App.state.role === 'joiner') await startJoinerAuto(code);
4363 } else {
4364 if (App.state.role === 'initiator') await startInitiator();
4365 else if (App.state.role === 'joiner') await startJoiner();
4366 else if (App.state.role === 'loopback') await startLoopback();
4367 }
4368 } catch (e) {
4369 App.progress.hide();
4370 if (App.state.userCancelled) { App.state.userCancelled = false; return; }
4371 App.log.error('setup', 'failed', e.message);
4372 alert('Setup failed: ' + e.message);
4373 }
4374 });
4375
4376 document.getElementById('exch-cancel').addEventListener('click', () => {
4377 hangup();
4378 App.state.role = null;
4379 updateRoleBadge();
4380 showView('view-welcome');
4381 });
4382
4383 /* Disable a button until its async handler resolves, so double-clicks
4384 during gUM/gDM don't interleave. Used for both the Apply buttons in the
4385 settings panel and the main toolbar Mic/Cam/Screen toggles. */
4386 function withReentryGuard(btnId, fn) {
4387 const btn = document.getElementById(btnId);
4388 btn.addEventListener('click', async () => {
4389 if (btn.disabled) return;
4390 btn.disabled = true;
4391 try { await fn(); }
4392 catch (e) { App.log.error('media', 'action failed', e.message); }
4393 finally { btn.disabled = false; }
4394 });
4395 }
4396
4397 /* Call: toolbar — mic/cam lazily call getUserMedia on first enable. */
4398 withReentryGuard('tb-mic', async () => {
4399 const btn = document.getElementById('tb-mic');
4400 await App.media.setMic(!btn.classList.contains('on'));
4401 });
4402 withReentryGuard('tb-cam', async () => {
4403 const btn = document.getElementById('tb-cam');
4404 await App.media.setCam(!btn.classList.contains('on'));
4405 });
4406 withReentryGuard('tb-screen', async () => {
4407 if (App.state.screenStream) await App.media.stopScreenshare();
4408 else {
4409 try { await App.media.startScreenshare(); }
4410 catch (e) { App.log.error('media', 'screenshare', e.message); App.chat.appendSystem?.('Screen share failed: ' + e.message); }
4411 }
4412 });
4413 document.getElementById('tb-hangup').addEventListener('click', hangup);
4414
4415 /* Click a tile that's showing a screen share → toggle fullscreen. */
4416 for (const id of ['tile-local', 'tile-remote']) {
4417 document.getElementById(id).addEventListener('click', e => {
4418 const tile = e.currentTarget;
4419 if (!tile.classList.contains('screen')) return;
4420 /* Don't trigger fullscreen for clicks on the PIP */
4421 if (e.target.closest('.pip')) return;
4422 const video = tile.querySelector(':scope > video');
4423 if (!video) return;
4424 if (document.fullscreenElement) {
4425 (document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
4426 .catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
4427 } else {
4428 (video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
4429 .catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
4430 }
4431 });
4432 }
4433
4434 /* Files: clear-all */
4435 document.getElementById('files-out-clear').addEventListener('click', () => App.files.clearAll('out'));
4436 document.getElementById('files-in-clear').addEventListener('click', () => App.files.clearAll('in'));
4437
4438 /* Call: sidebar tabs */
4439 document.querySelectorAll('.tabs button').forEach(b => {
4440 b.addEventListener('click', () => {
4441 document.querySelectorAll('.tabs button').forEach(x => x.classList.remove('active'));
4442 document.querySelectorAll('.tab-pane').forEach(x => x.classList.remove('active'));
4443 b.classList.add('active');
4444 document.querySelector(`.tab-pane[data-pane="${b.dataset.tab}"]`).classList.add('active');
4445 });
4446 });
4447
4448 /* Chat */
4449 const chatInput = document.getElementById('chat-text');
4450 const chatSend = document.getElementById('chat-send');
4451 const chatCounter = document.getElementById('chat-counter');
4452 const CHAT_MAX = App.chat.MAX_TEXT;
4453 function updateChatCounter() {
4454 const bytes = App.chat.utf8Length(chatInput.value);
4455 chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B';
4456 const over = bytes > CHAT_MAX;
4457 chatCounter.classList.toggle('over', over);
4458 chatSend.disabled = over || bytes === 0;
4459 }
4460 chatSend.addEventListener('click', sendChat);
4461 chatInput.addEventListener('input', updateChatCounter);
4462 chatInput.addEventListener('keydown', e => {
4463 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); }
4464 });
4465 updateChatCounter();
4466 function sendChat() {
4467 const t = chatInput.value.trim();
4468 if (!t) return;
4469 if (App.chat.utf8Length(t) > CHAT_MAX) return;
4470 App.chat.send(t);
4471 chatInput.value = '';
4472 updateChatCounter();
4473 }
4474
4475 /* Files */
4476 (() => {
4477 const n = App.files.MAX_FILE;
4478 let txt;
4479 if (n >= 1024 ** 3) txt = (n / 1024 ** 3) + ' GB';
4480 else if (n >= 1024 ** 2) txt = (n / 1024 ** 2) + ' MB';
4481 else txt = (n / 1024) + ' KB';
4482 document.getElementById('files-max').textContent = txt;
4483 })();
4484 const drop = document.getElementById('files-drop');
4485 drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('over'); });
4486 drop.addEventListener('dragleave', () => drop.classList.remove('over'));
4487 drop.addEventListener('drop', e => {
4488 e.preventDefault(); drop.classList.remove('over');
4489 const f = e.dataTransfer.files[0];
4490 if (f) App.files.sendFile(f);
4491 });
4492 document.getElementById('files-pick').addEventListener('click', e => {
4493 e.preventDefault();
4494 document.getElementById('files-input').click();
4495 });
4496 document.getElementById('files-input').addEventListener('change', e => {
4497 const f = e.target.files[0];
4498 if (f) App.files.sendFile(f);
4499 e.target.value = '';
4500 });
4501
4502 /* Runtime settings */
4503 withReentryGuard('rt-v-apply', async () => {
4504 const v = App.state.settings.video;
4505 const prevW = v.width, prevH = v.height, prevFps = v.frameRate;
4506 v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
4507 v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
4508 v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
4509 v.maxBitrateKbps = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
4510 v.degradationPreference = document.getElementById('rt-v-degrade').value;
4511 App.media.applyCamSendParams();
4512 /* Resolution / framerate only take effect on a fresh getUserMedia call —
4513 cycle the camera if it's currently on so the new constraints apply. */
4514 const camChanged = v.width !== prevW || v.height !== prevH || v.frameRate !== prevFps;
4515 const camOn = document.getElementById('tb-cam').classList.contains('on');
4516 if (camChanged && camOn) {
4517 App.log.info('media', 'restarting camera to apply new resolution/framerate');
4518 await App.media.setCam(false);
4519 await App.media.setCam(true);
4520 }
4521 });
4522 withReentryGuard('rt-s-apply', async () => {
4523 const s = App.state.settings.screen;
4524 const prevW = s.width, prevH = s.height, prevFps = s.frameRate;
4525 s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
4526 s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
4527 s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
4528 s.maxBitrateKbps = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
4529 s.degradationPreference = document.getElementById('rt-s-degrade').value;
4530 App.media.applyScreenSendParams();
4531 const dimsChanged = s.width !== prevW || s.height !== prevH || s.frameRate !== prevFps;
4532 const screenOn = document.getElementById('tb-screen').classList.contains('on');
4533 if (dimsChanged && screenOn) {
4534 App.log.info('media', 'restarting screen share to apply new resolution/framerate');
4535 await App.media.stopScreenshare();
4536 try { await App.media.startScreenshare(); }
4537 catch (e) { App.log.warn('media', 'restart screen failed', e.message); }
4538 }
4539 });
4540 withReentryGuard('rt-codec-apply', async () => {
4541 App.state.settings.sendVideoCodec = document.getElementById('rt-codec').value || 'auto';
4542 /* Push the new codec to both video senders at once. */
4543 App.media.applyCamSendParams();
4544 App.media.applyScreenSendParams();
4545 });
4546 withReentryGuard('rt-a-apply', async () => {
4547 const a = App.state.settings.audio;
4548 const prevCh = a.channelCount, prevRate = a.sampleRate;
4549 a.echoCancellation = document.getElementById('rt-a-aec').checked;
4550 a.noiseSuppression = document.getElementById('rt-a-ns').checked;
4551 a.autoGainControl = document.getElementById('rt-a-agc').checked;
4552 a.channelCount = parseInt(document.getElementById('rt-a-channels').value, 10) || 1;
4553 a.sampleRate = parseInt(document.getElementById('rt-a-rate').value, 10) || 0;
4554 App.media.applyAudioConstraints();
4555 const capChanged = a.channelCount !== prevCh || a.sampleRate !== prevRate;
4556 const micOn = document.getElementById('tb-mic').classList.contains('on');
4557 if (capChanged && micOn) {
4558 App.log.info('media', 'restarting microphone to apply new channels/sample rate');
4559 await App.media.setMic(false);
4560 await App.media.setMic(true);
4561 }
4562 });
4563
4564 /* Stats export */
4565 document.getElementById('stats-export').addEventListener('click', () => App.stats.exportAll());
4566
4567 setupConsole();
4568 setupDevicePickers();
4569 App.log.info('app', 'ready');
4570}
4571
4572/* Close the peer connection, stop local media, and reset call-tied UI
4573 state. Shared by hangup() (user-initiated, navigates away) and
4574 onPeerHangup() (remote-initiated, stays on call view). */
4575function teardownConnection(opts) {
4576 const sendBye = !opts || opts.sendBye !== false;
4577 if (sendBye && App.chat && App.chat.sendBye) App.chat.sendBye();
4578 /* If we're in the middle of auto-mode long-polling, cancel the fetch so
4579 the user isn't stuck for up to 30 s after clicking Cancel/Hang up. */
4580 if (App.state.signalAbort) { try { App.state.signalAbort.abort(); } catch (_) {} App.state.signalAbort = null; }
4581 App.stats.stop();
4582 try { if (App.state.dcChat) App.state.dcChat.close(); } catch (_) {}
4583 try { if (App.state.dcFiles) App.state.dcFiles.close(); } catch (_) {}
4584 try { if (App.state.pc) App.state.pc.close(); } catch (_) {}
4585 try { if (App.state.pcB) App.state.pcB.close(); } catch (_) {}
4586 if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
4587 if (App.state.screenStream) App.state.screenStream.getTracks().forEach(t => t.stop());
4588 if (App.state.iceWarmupStream) {
4589 App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
4590 App.state.iceWarmupStream = null;
4591 const wb = document.getElementById('ice-warmup');
4592 const ws = document.getElementById('ice-warmup-status');
4593 if (wb) wb.textContent = 'Enable LAN connectivity';
4594 if (ws) { ws.textContent = 'off'; ws.className = 'pill'; }
4595 }
4596 App.state.pc = null; App.state.pcB = null;
4597 App.state.dcChat = null; App.state.dcFiles = null;
4598 App.state.localStream = null; App.state.screenStream = null;
4599 App.state.remoteStream = null; App.state.remoteScreenStream = null;
4600 App.state.peerMediaState = { mic: false, cam: false, screen: false };
4601 App.state.micTransceiver = App.state.camTransceiver = App.state.screenTransceiver = null;
4602 for (const id of ['vid-local-main', 'vid-local-pip', 'vid-remote-main', 'vid-remote-pip', 'audio-remote']) {
4603 document.getElementById(id).srcObject = null;
4604 }
4605 for (const id of ['tile-local', 'tile-remote']) {
4606 const tile = document.getElementById(id);
4607 tile.classList.add('empty');
4608 tile.classList.remove('screen');
4609 }
4610 document.querySelectorAll('.video-tile .pip').forEach(el => el.classList.add('hidden'));
4611 /* Reset toolbar buttons back to the initial off state. */
4612 for (const [id, label] of [['tb-mic', 'Mic off'], ['tb-cam', 'Cam off'], ['tb-screen', 'Screen off']]) {
4613 const btn = document.getElementById(id);
4614 btn.classList.remove('on');
4615 btn.classList.add('off');
4616 btn.querySelector('.nowrap').textContent = label;
4617 }
4618 updateConnPill();
4619}
4620
4621/* Set the disabled state of the in-call controls that depend on an open
4622 peer connection (toolbar media buttons, chat input, file picker). After
4623 a peer hangup we leave the user on the call view so they can browse the
4624 chat log and downloaded files, but everything that needs the channel is
4625 disabled. */
4626function setInCallControlsEnabled(enabled) {
4627 for (const id of ['tb-mic', 'tb-cam', 'tb-screen']) {
4628 const btn = document.getElementById(id);
4629 if (btn) btn.disabled = !enabled;
4630 }
4631 const chatInput = document.getElementById('chat-text');
4632 const chatSend = document.getElementById('chat-send');
4633 const filesDrop = document.getElementById('files-drop');
4634 if (chatInput) chatInput.disabled = !enabled;
4635 /* When re-enabling, leave chatSend to updateChatCounter (which gates on
4636 byte count). When disabling, force it off. */
4637 if (chatSend && !enabled) chatSend.disabled = true;
4638 if (filesDrop) filesDrop.classList.toggle('disabled', !enabled);
4639}
4640
4641function resetSession(opts) {
4642 teardownConnection(opts);
4643 App.state.role = null;
4644 updateRoleBadge();
4645 const dlg = document.getElementById('peer-left-dialog');
4646 if (dlg && dlg.open) dlg.close();
4647}
4648
4649function hangup(opts) {
4650 App.log.info('app', 'hangup');
4651 resetSession(opts);
4652 showView('view-welcome');
4653}
4654
4655/* Peer told us they're leaving via the chat data channel. Show a modal,
4656 drop a chat-system breadcrumb, and tear down the connection — but keep
4657 the user on the call view so they can still browse chat history and any
4658 files that already finished transferring. */
4659function onPeerHangup() {
4660 if (App.state.peerHungUp) return; /* idempotent — bye may arrive twice */
4661 App.state.peerHungUp = true;
4662 App.log.info('app', 'peer hung up');
4663 if (App.chat && App.chat.appendSystem) App.chat.appendSystem('peer hung up');
4664 teardownConnection({ sendBye: false });
4665 setInCallControlsEnabled(false);
4666 App.state.peerHungUp = false;
4667 const dlg = document.getElementById('peer-left-dialog');
4668 if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
4669 try { dlg.showModal(); } catch (_) { /* already-open guard */ }
4670 }
4671}
4672
4673if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
4674else wire();
4675</script>
4676</body>
4677</html>
4678