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