multi-peer calls

AuthorKonata <konata@posteo.jp>
Date
Commit1a072b68d50f203d09896fa77d805110650f3c0a
Parent66f7289
2 files changed, 2412 insertions(+), 932 deletions(-)
MREADME.md
@@ -1,17 +1,22 @@
11 # WebRTC Tool
22
33 A single-page tool for direct peer-to-peer audio, video, chat, and file
4-transfer between two browsers, using WebRTC for the media/data path. The
4+transfer between browsers, using WebRTC for the media/data path. The
55 client is one self-contained `index.html` with no build step. An optional
6-signaling server (~500 lines of C, POSIX `poll`, no dependencies) lets
6+signaling server (~600 lines of C, POSIX `poll`, no dependencies) lets
77 peers connect by sharing a short room code instead of pasting SDP blobs.
88
99 ## What it does
1010
11-- **Two-peer call.** Microphone, camera, screen share. Standard WebRTC.
12-- **Chat** over a text data channel.
11+- **Multi-peer calls.** Microphone, camera, screen share. Add
12+ participants one at a time; each is an independent pairwise connection,
13+ and they tile into a conference grid. Standard WebRTC.
14+- **Chat** over a text data channel, fanned out to every connected peer.
1315 - **File transfer** over a separate data channel with backpressure,
14- cancellable mid-upload.
16+ cancellable mid-upload, sent to the peers you select. The receiver gets
17+ an accept/deny prompt; on accept the file is streamed straight to a
18+ chosen location on disk (via the File System Access API, with an
19+ in-memory download fallback for browsers that lack it, e.g. Firefox)
1520 - **Manual signaling.** Generate an offer, copy/paste it to the other
1621 peer, paste back their answer. No backend required.
1722 - **Auto signaling.** Optional: both peers enter the same room code and
@@ -22,9 +27,11 @@ peers connect by sharing a short room code instead of pasting SDP blobs.
2227
2328 ## How it works
2429
25-WebRTC requires the two peers to exchange Session Description Protocol
26-(SDP) blobs (offer/answer) before media can flow. After that exchange,
27-the connection is peer-to-peer; the signaling channel is no longer used.
30+WebRTC requires each pair of peers to exchange Session Description
31+Protocol (SDP) blobs (offer/answer) before media can flow. After that
32+exchange, the connection is peer-to-peer; the signaling channel is no
33+longer used. A call with more than two participants is simply several of
34+these pairwise connections, each set up independently.
2835
2936 This tool offers two ways to do that exchange:
3037
@@ -35,9 +42,12 @@ This tool offers two ways to do that exchange:
3542 2. **Auto.** Both peers enter the same room code into the signaling
3643 server's UI. The server stores the offer briefly, hands it to the
3744 other peer when they ask, and is forgotten as soon as the answer
38- has been delivered. Rooms expire after 5 minutes of inactivity.
45+ has been delivered. Rooms expire after 5 minutes of inactivity. Each
46+ room carries a single offer/answer pair — one pairwise link — so a
47+ mesh of more than two peers is built one connection (one room code) at
48+ a time.
3949
40-The signaling server understands four endpoints:
50+The signaling server understands five endpoints:
4151
4252 ```
4353 POST /room/<code>/offer POST /room/<code>/answer
@@ -186,19 +196,7 @@ $HTTP["url"] =~ "^/(room/|health$)" {
186196 - **Server capacity is hard-coded in `server/signal.c`:** 1024 rooms.
187197 Rooms expire after 5 minutes of inactivity and are also deleted immediately
188198 once the answer reaches the initiator; a GC sweep runs every 60 s.
189- Each connection pre-allocates a ~68 KB request buffer, so the BSS reaches
190- ~140 MB — Linux only touches the pages on demand, so an idle server
191- uses around 10 MB resident.
192-
193-## Repository layout
194-
195-```
196-.
197-├── index.html # client (one self-contained page, no build)
198-├── server/
199-│ └── signal.c # signaling server (~500 lines, POSIX poll, no deps)
200-├── Containerfile # multi-stage build, static-musl binary, alpine runtime
201-├── compose.yml # podman/docker compose: builds image, exposes :8080
202-├── entrypoint.sh # copies index.html into the bind-mounted /static
203-└── README.md
204-```
199+- **ICE servers.** The client defaults to a public STUN server and lets you
200+ add your own STUN/TURN entries in the configure screen. STUN is enough for
201+ most networks, but two peers behind symmetric NATs won't connect without a
202+ **TURN** relay — add one if connections stall in "checking".
Mindex.html
@@ -2,8 +2,33 @@
22 <html lang="en" data-theme="dark">
33 <head>
44 <meta charset="utf-8">
5-<meta name="viewport" content="width=500">
5+<meta name="viewport" content="width=device-width, initial-scale=1">
66 <title>WebRTC Tool</title>
7+<script>
8+ /* Responsive viewport with a soft minimum: render at the real device width
9+ when there's enough room (≥ MIN_WIDTH CSS px), otherwise lock the layout
10+ viewport to MIN_WIDTH so a narrow device scales the page down to fit
11+ instead of breaking layout. Re-runs on rotation / window resize so a
12+ phone that lands above the threshold in landscape gets the responsive
13+ layout there. */
14+ (function () {
15+ var MIN_WIDTH = 500;
16+ var meta = document.querySelector('meta[name="viewport"]');
17+ var responsive = 'width=device-width, initial-scale=1';
18+ function update() {
19+ /* `screen.width` reflects the physical device width in CSS pixels and
20+ tracks orientation on every modern browser. It's independent of the
21+ current viewport meta, so we can safely use it to *decide* whether
22+ to switch viewports without feedback loops. */
23+ var w = (window.screen && window.screen.width) || window.innerWidth;
24+ var want = w < MIN_WIDTH ? 'width=' + MIN_WIDTH : responsive;
25+ if (meta.getAttribute('content') !== want) meta.setAttribute('content', want);
26+ }
27+ update();
28+ window.addEventListener('resize', update);
29+ window.addEventListener('orientationchange', update);
30+ })();
31+</script>
732 <style>
833 :root {
934 --bg: #0d1117;
@@ -105,7 +130,7 @@
105130 #app { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
106131 .view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
107132 .view.hidden { display: none; }
108- #view-welcome, #view-configure, #view-exchange, #view-sdp-inspect { overflow-y: auto; }
133+ #view-welcome, #view-sdp-inspect { overflow-y: auto; }
109134
110135 /* SDP inspector */
111136 .sdp-section { background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; margin-bottom: 14px; }
@@ -306,20 +331,94 @@
306331 @media (max-width: 900px) {
307332 .call-body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
308333 }
334+ /* Conference grid: a vertical stack of flex rows. App.tiles.relayout picks
335+ the rows × cols that maximises tile area for the current container and
336+ tile count, then partitions tiles into row containers and sets row
337+ height + tile width inline. Each row centres its tiles, so an incomplete
338+ last row (e.g. 3 tiles in a 2-col layout) is centred instead of
339+ left-aligned. All tiles always visible; no scroll. */
309340 .video-area {
310341 background: #000;
311342 position: relative;
312- display: grid;
313- grid-template-columns: 1fr 1fr;
314- grid-template-rows: minmax(0, 1fr);
343+ display: flex;
344+ flex-direction: column;
345+ justify-content: center;
346+ align-items: center;
315347 gap: 8px;
316348 padding: 12px;
317349 min-height: 0;
318350 min-width: 0;
319351 overflow: hidden;
320352 }
321- .video-tile { position: relative; background: #050608; border-radius: var(--radius); overflow: hidden; min-height: 0; display: flex; align-items: center; justify-content: center; }
353+ .video-row {
354+ display: flex;
355+ flex-direction: row;
356+ justify-content: center;
357+ gap: 8px;
358+ /* height set inline by relayout */
359+ }
360+ .video-tile {
361+ position: relative;
362+ background: #050608;
363+ border-radius: var(--radius);
364+ overflow: hidden;
365+ display: flex; align-items: center; justify-content: center;
366+ flex: none;
367+ min-width: 0; min-height: 0;
368+ height: 100%;
369+ /* width set inline by relayout */
370+ }
322371 .video-tile > video { width: 100%; height: 100%; object-fit: contain; background: #050608; display: block; }
372+ .video-tile > .vid-pip { display: none; } /* PIP video is inside .pip wrapper, not direct child */
373+ /* Per-peer audio element sits invisibly inside the tile so its lifetime
374+ matches the peer's. Hidden but autoplay still works. */
375+ .video-tile > audio.audio-remote { display: none; }
376+ .video-tile .leave-peer {
377+ position: absolute; top: 6px; right: 6px;
378+ background: rgba(0,0,0,0.55); color: white;
379+ border: none; border-radius: 4px;
380+ width: 22px; height: 22px; padding: 0;
381+ cursor: pointer; opacity: 0; transition: opacity 120ms;
382+ display: flex; align-items: center; justify-content: center;
383+ z-index: 3;
384+ }
385+ .video-tile:hover .leave-peer { opacity: 1; }
386+ .video-tile .leave-peer:hover { background: var(--err); }
387+ .video-tile .conn-pill {
388+ position: absolute; top: 6px; left: 6px;
389+ background: rgba(0,0,0,0.55); color: white;
390+ padding: 2px 8px; border-radius: 10px;
391+ font-size: 10px; letter-spacing: 0.3px;
392+ pointer-events: none;
393+ }
394+ .video-tile .conn-pill.connected { background: rgba(63,185,80,0.55); }
395+ .video-tile .conn-pill.connecting,
396+ .video-tile .conn-pill.new { background: rgba(210,153,34,0.55); }
397+ .video-tile .conn-pill.failed,
398+ .video-tile .conn-pill.disconnected,
399+ .video-tile .conn-pill.closed { background: rgba(248,81,73,0.55); }
400+ .video-tile .conn-pill.connected { display: none; } /* hide once steady */
401+
402+ /* In-call banner shown when there are no peers yet. Sits between the
403+ topbar and call-body, full-width, dismisses permanently on first join. */
404+ .call-banner {
405+ flex: none;
406+ display: flex; align-items: center; justify-content: center; gap: 12px;
407+ padding: 10px 16px;
408+ background: rgba(47,129,247,0.10);
409+ border-bottom: 1px solid rgba(47,129,247,0.30);
410+ color: var(--text);
411+ font-size: 13px;
412+ }
413+ .call-banner.hidden { display: none; }
414+ .call-banner button { padding: 6px 12px; }
415+
416+ /* Add Participant dialog reuses the role-picker styles but in compact
417+ form (smaller padding, single column). */
418+ .role-picker.compact { grid-template-columns: 1fr; gap: 8px; margin-top: 8px; }
419+ .role-picker.compact button { padding: 12px 14px; }
420+ .role-picker.compact .role-title { font-size: 14px; }
421+ .role-picker.compact .role-desc { font-size: 12px; }
323422 .video-tile.screen > video { cursor: zoom-in; }
324423 .video-tile.screen > video:fullscreen { cursor: zoom-out; }
325424 .video-tile .tile-label {
@@ -367,7 +466,7 @@
367466 .video-tile .pip.hidden { display: none; }
368467 .video-tile .pip video { width: 100%; height: 100%; object-fit: cover; background: #050608; display: block; }
369468 @media (max-width: 900px) {
370- .video-area { grid-template-columns: 1fr; grid-template-rows: repeat(2, minmax(0, 1fr)); }
469+ .video-area { /* layout still computed in JS */ }
371470 }
372471
373472 .sidebar { background: var(--bg-elev); border-left: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; min-width: 0; overflow: hidden; }
@@ -480,11 +579,111 @@
480579 cursor: pointer; font-size: 14px; line-height: 1;
481580 }
482581 .file-item .row-close:hover { background: var(--bg-elev); color: var(--text); }
582+ .file-item .req-actions { display: flex; gap: 6px; margin-top: 6px; }
583+ .file-item .req-actions button {
584+ border: 1px solid var(--border); border-radius: 4px; padding: 3px 12px;
585+ font-size: 12px; cursor: pointer;
586+ }
587+ .file-item .req-actions .btn-accept { background: var(--accent); color: #fff; border-color: var(--accent); }
588+ .file-item .req-actions .btn-deny { background: transparent; color: var(--text-dim); }
589+ .file-item .req-actions button:hover { filter: brightness(1.1); }
483590
484591 /* Stats */
485592 .stats-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
486593 .stats-table td { padding: 4px 6px; border-bottom: 1px solid var(--border); }
487594 .stats-table td:first-child { color: var(--text-dim); width: 45%; }
595+ .stats-head { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; }
596+ .stats-head h3 { margin: 0; flex: none; font-size: 14px; }
597+ .stats-head select { flex: 1; min-width: 0; }
598+
599+ /* Recipient chip bar (chat + files) */
600+ .recipients {
601+ display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
602+ margin: 6px 0 4px; padding: 4px 0;
603+ font-size: 11.5px; color: var(--text-dim);
604+ }
605+ .recipients .label { color: var(--text-faint); margin-right: 4px; }
606+ .recipients .chip {
607+ display: inline-flex; align-items: center;
608+ padding: 2px 8px; border-radius: 999px;
609+ background: var(--bg-elev-2); border: 1px solid var(--border);
610+ color: var(--text-dim);
611+ cursor: pointer; user-select: none;
612+ transition: background 80ms, border-color 80ms, color 80ms;
613+ font-size: 11px;
614+ }
615+ .recipients .chip.on { background: rgba(47,129,247,0.18); border-color: var(--accent); color: var(--text); }
616+ .recipients .chip:hover { background: var(--bg-elev); }
617+ .recipients .chip.on:hover { filter: brightness(1.15); }
618+ .recipients .empty { color: var(--text-faint); font-style: italic; }
619+
620+ /* Delivery popup (chat message click) */
621+ .chat-msg.has-detail { cursor: pointer; }
622+ .chat-msg .detail {
623+ display: none;
624+ margin-top: 6px; padding-top: 6px;
625+ border-top: 1px solid var(--border);
626+ font-size: 10.5px; color: var(--text-dim);
627+ }
628+ .chat-msg .detail .row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
629+ .chat-msg .detail .state { font-variant-numeric: tabular-nums; }
630+ .chat-msg .detail .state.acked { color: var(--ok); }
631+ .chat-msg .detail .state.sent { color: var(--warn); }
632+ .chat-msg .detail .state.failed,
633+ .chat-msg .detail .state.closed { color: var(--err); }
634+ .chat-msg.open .detail { display: block; }
635+
636+ /* Files row click-detail (per-recipient progress) */
637+ .file-item.has-detail { cursor: pointer; }
638+ .file-item .detail {
639+ display: none;
640+ margin-top: 6px; padding-top: 6px;
641+ border-top: 1px solid var(--border);
642+ font-size: 10.5px;
643+ }
644+ .file-item .detail .row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
645+ .file-item .detail .state { font-variant-numeric: tabular-nums; }
646+ .file-item .detail .state.delivered { color: var(--ok); }
647+ .file-item .detail .state.sent { color: var(--ok); }
648+ .file-item .detail .state.sending { color: var(--warn); }
649+ .file-item .detail .state.queued { color: var(--text-dim); }
650+ .file-item .detail .state.failed,
651+ .file-item .detail .state.closed,
652+ .file-item .detail .state.cancelled { color: var(--err); }
653+ .file-item.open .detail { display: block; }
654+
655+ /* Welcome: freestanding Start block (no card chrome). The input and the
656+ Start button sit side-by-side and stretch to a matching height. */
657+ .welcome-start { margin: 24px 0 32px; max-width: 520px; }
658+ .welcome-start label.field { margin-bottom: 8px; }
659+ .welcome-start-row {
660+ display: flex;
661+ gap: 8px;
662+ align-items: stretch;
663+ }
664+ .welcome-start-row > input {
665+ flex: 1;
666+ min-width: 0;
667+ font-size: 16px;
668+ padding: 10px 14px;
669+ }
670+ .welcome-start-btn {
671+ flex: none;
672+ font-size: 16px;
673+ padding: 10px 22px;
674+ line-height: 1.4;
675+ }
676+
677+ /* Settings: per-peer target picker */
678+ .settings-target {
679+ background: var(--bg-elev-2);
680+ border: 1px solid var(--border);
681+ border-radius: var(--radius-sm);
682+ padding: 8px 10px;
683+ margin: 24px 0 14px;
684+ }
685+ .settings-target label.field { margin-bottom: 0; }
686+ .settings-target .small { margin-top: 4px; }
488687
489688 /* Modal dialog (native <dialog>) — used for peer-left notification. */
490689 dialog {
@@ -499,7 +698,17 @@
499698 dialog::backdrop { background: rgba(0,0,0,0.55); }
500699 dialog h2 { margin: 0 0 8px; padding: 0; border: none; font-size: 17px; }
501700 dialog p { margin: 0 0 16px; color: var(--text-dim); }
502- .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
701+ .dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
702+ /* Wide, scrollable variant of <dialog> for the configure step. */
703+ dialog.view-modal {
704+ width: min(720px, calc(100vw - 32px));
705+ max-width: none;
706+ max-height: calc(100vh - 32px);
707+ overflow-y: auto;
708+ padding: 24px 26px;
709+ }
710+ dialog.view-modal h1 { margin: 0 0 8px; padding: 0; border: none; font-size: 22px; }
711+ dialog.view-modal .container { padding: 0; max-width: none; }
503712 .step-dialog-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
504713 .step-dialog-head h2 { margin: 0; }
505714 .step-dialog-room {
@@ -617,28 +826,21 @@
617826 <h1>WebRTC Tool</h1>
618827 <p class="lede">
619828 A direct peer-to-peer call tool — a fallback for video, screen share, chat, and file transfer
620- when your usual conferencing software isn't cooperating. Two peers can connect either via a
621- shared room code (using a small relay server that only shuttles the offer/answer) or by
622- pasting two short JSON blobs to each other. Browser-only on each end: no install, no account.
829+ when your usual conferencing software isn't cooperating. Connect to one or more participants
830+ either via a shared room code (using a small relay server that only shuttles the offer/answer)
831+ or by pasting two short JSON blobs. Browser-only on each end: no install, no account.
623832 </p>
624833
625- <div class="card">
626- <h2>Pick your role</h2>
627- <p class="small">One side starts the call (creates the offer); the other side joins it.</p>
628- <div class="role-picker">
629- <button data-role="initiator">
630- <span class="role-title">Start a call</span>
631- <span class="role-desc">You'll generate the offer. Connect via a shared room code, or send your peer the JSON blob and apply the answer they send back.</span>
632- </button>
633- <button data-role="joiner">
634- <span class="role-title">Join a call</span>
635- <span class="role-desc">You'll apply your peer's offer. Connect via the room code they share, or paste the JSON blob they send and reply with the generated answer.</span>
636- </button>
637- <button data-role="loopback" class="role-loopback">
638- <span class="role-title">Loopback test (same tab)</span>
639- <span class="role-desc">Run both peers in this tab. Useful for verifying that media and the call UI work locally.</span>
640- </button>
641- </div>
834+ <div class="welcome-start">
835+ <label class="field welcome-start-field">
836+ <span>Your display name</span>
837+ <div class="welcome-start-row">
838+ <input type="text" id="welcome-username" placeholder="Anonymous" maxlength="64" autocomplete="off" spellcheck="false">
839+ <button id="welcome-start" class="primary welcome-start-btn">Start call →</button>
840+ </div>
841+ </label>
842+ <p class="small">Shown to other participants in chat and on their video tile. Saved locally; can be changed later from the call's Settings tab.</p>
843+ <p class="small" style="margin-top:6px">Opens an empty call. Invite participants from inside the call (room code or SDP paste).</p>
642844 </div>
643845
644846 <div class="card">
@@ -652,7 +854,7 @@
652854 </section>
653855
654856 <!-- ============== CONFIGURE ============== -->
655- <section id="view-configure" class="view hidden">
857+ <dialog id="view-configure" class="view-modal">
656858 <div class="container">
657859 <h1>Configure <span id="role-title-cfg" class="role-badge"></span></h1>
658860 <p class="lede" id="cfg-lede">Set up media and ICE servers, then continue to the signaling step.</p>
@@ -826,10 +1028,10 @@
8261028 </span>
8271029 </div>
8281030 </div>
829- </section>
1031+ </dialog>
8301032
8311033 <!-- ============== EXCHANGE ============== -->
832- <section id="view-exchange" class="view hidden">
1034+ <dialog id="view-exchange" class="view-modal">
8331035 <div class="container">
8341036 <h1>Signaling exchange <span id="role-title-exch" class="role-badge"></span></h1>
8351037 <p class="lede" id="exch-lede"></p>
@@ -860,7 +1062,7 @@
8601062 </span>
8611063 </div>
8621064 </div>
863- </section>
1065+ </dialog>
8641066
8651067 <!-- ============== SDP INSPECTOR ============== -->
8661068 <section id="view-sdp-inspect" class="view hidden">
@@ -890,32 +1092,23 @@
8901092
8911093 <!-- ============== CALL ============== -->
8921094 <section id="view-call" class="view hidden">
1095+ <div id="call-banner" class="call-banner hidden">
1096+ <span>You're in an empty call. Add a participant to get started.</span>
1097+ <button id="call-banner-add" class="primary">+ Add participant</button>
1098+ </div>
8931099 <div class="call-body">
894- <div class="video-area">
1100+ <div class="video-area" id="video-area">
8951101 <div class="video-tile empty" id="tile-local">
896- <video id="vid-local-main" autoplay muted playsinline></video>
1102+ <video class="vid-main" id="vid-local-main" autoplay muted playsinline></video>
8971103 <div class="pip hidden" id="pip-local">
898- <video id="vid-local-pip" autoplay muted playsinline></video>
1104+ <video class="vid-pip" id="vid-local-pip" autoplay muted playsinline></video>
8991105 </div>
9001106 <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>
9011107 <div class="mic-muted hidden" id="mic-muted-local" title="microphone muted"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="muted"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg></div>
902- <span class="tile-label">you</span>
903- </div>
904- <div class="video-tile empty" id="tile-remote">
905- <video id="vid-remote-main" autoplay muted playsinline></video>
906- <div class="pip hidden" id="pip-remote">
907- <video id="vid-remote-pip" autoplay muted playsinline></video>
908- </div>
909- <div class="empty-state"><svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-label="no video"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/></svg></div>
910- <div class="mic-muted hidden" id="mic-muted-remote" title="microphone muted"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="muted"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg></div>
911- <span class="tile-label">peer</span>
1108+ <span class="tile-label" id="tile-local-label">you</span>
9121109 </div>
1110+ <!-- Per-peer tiles inserted here by App.tiles. -->
9131111 </div>
914- <!-- Plays the peer's audio independently of any video element so it
915- keeps working even when the remote tile is showing the empty state
916- (i.e. mic-only call) — display:none on a <video> suppresses audio
917- output in some browsers (notably Firefox). -->
918- <audio id="audio-remote" autoplay></audio>
9191112
9201113 <aside class="sidebar">
9211114 <div class="tabs">
@@ -927,6 +1120,7 @@
9271120
9281121 <div class="tab-pane active" data-pane="chat">
9291122 <div id="chat-log" class="chat-log"></div>
1123+ <div id="chat-recipients" class="recipients"></div>
9301124 <div class="chat-input">
9311125 <input type="text" id="chat-text" placeholder="Type a message and press Enter" autocomplete="off">
9321126 <button id="chat-send" class="primary">Send</button>
@@ -935,6 +1129,7 @@
9351129 </div>
9361130
9371131 <div class="tab-pane" data-pane="files">
1132+ <div id="files-recipients" class="recipients"></div>
9381133 <div id="files-drop" class="files-drop">
9391134 <p><strong>Drop a file here</strong> or <a href="#" id="files-pick">pick one</a>.</p>
9401135 <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>
@@ -947,8 +1142,18 @@
9471142 </div>
9481143
9491144 <div class="tab-pane" data-pane="settings">
950- <h3>Video</h3>
951- <p class="small">Resolution and framerate are applied by restarting the camera. Bitrate and degradation preference apply instantly without touching the camera.</p>
1145+ <h3>Profile</h3>
1146+ <label class="field">
1147+ <span>Username</span>
1148+ <div class="row" style="gap:6px; align-items:stretch">
1149+ <input type="text" id="rt-username" maxlength="64" autocomplete="off" style="flex:1">
1150+ <button id="rt-username-apply" class="ghost">Save</button>
1151+ </div>
1152+ </label>
1153+ <p class="small">Shown to other participants in chat and on their video tile. Saved locally; broadcast over each open chat channel.</p>
1154+
1155+ <h3>Camera capture</h3>
1156+ <p class="small">Resolution and framerate restart the camera when applied. New peers inherit these as their initial values.</p>
9521157 <div class="grid-2">
9531158 <label class="field">
9541159 <span>Width (px, 0 = auto)</span>
@@ -962,48 +1167,77 @@
9621167 <span>Frame rate (fps, 0 = auto)</span>
9631168 <input type="number" id="rt-v-fps" min="0" max="120">
9641169 </label>
1170+ </div>
1171+ <button id="rt-v-cap-apply" class="ghost">Apply camera</button>
1172+
1173+ <h3>Screen capture</h3>
1174+ <p class="small">Resolution and framerate restart the share — you'll be re-prompted for the source.</p>
1175+ <div class="grid-2">
9651176 <label class="field">
966- <span>Max send bitrate (kbps, 0 = unset)</span>
967- <input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
1177+ <span>Width (px, 0 = auto)</span>
1178+ <input type="number" id="rt-s-w" min="0" max="7680">
9681179 </label>
9691180 <label class="field">
970- <span>Degradation preference</span>
971- <select id="rt-v-degrade">
972- <option value="balanced">balanced</option>
973- <option value="maintain-framerate">maintain-framerate</option>
974- <option value="maintain-resolution">maintain-resolution</option>
975- </select>
1181+ <span>Height (px, 0 = auto)</span>
1182+ <input type="number" id="rt-s-h" min="0" max="4320">
1183+ </label>
1184+ <label class="field">
1185+ <span>Frame rate (fps, 0 = auto)</span>
1186+ <input type="number" id="rt-s-fps" min="0" max="120">
9761187 </label>
9771188 </div>
978- <button id="rt-v-apply" class="ghost">Apply</button>
1189+ <button id="rt-s-cap-apply" class="ghost">Apply screen</button>
9791190
980- <h3>Send codec</h3>
981- <p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
1191+ <h3>Audio</h3>
1192+ <p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
1193+ <label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
1194+ <label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
1195+ <label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
9821196 <div class="grid-2">
9831197 <label class="field">
984- <span>Send codec</span>
985- <select id="rt-codec">
986- <option value="auto">auto</option>
1198+ <span>Channels</span>
1199+ <select id="rt-a-channels">
1200+ <option value="1">1 (mono)</option>
1201+ <option value="2">2 (stereo)</option>
9871202 </select>
9881203 </label>
1204+ <label class="field">
1205+ <span>Sample rate (Hz, 0 = auto)</span>
1206+ <input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
1207+ </label>
9891208 </div>
990- <button id="rt-codec-apply" class="ghost">Apply</button>
1209+ <button id="rt-a-apply" class="ghost">Apply audio</button>
9911210
992- <h3>Screen share</h3>
993- <p class="small">Resolution and framerate are applied by restarting the share — you'll be re-prompted for the source. Bitrate and degradation preference apply instantly.</p>
994- <div class="grid-2">
1211+ <div class="settings-target">
9951212 <label class="field">
996- <span>Width (px, 0 = auto)</span>
997- <input type="number" id="rt-s-w" min="0" max="7680">
1213+ <span>Per-peer encoder target</span>
1214+ <select id="settings-target">
1215+ <option value="all">All connected peers</option>
1216+ </select>
9981217 </label>
1218+ <p class="small">Encoder settings below apply to the selected target. Choosing "All connected peers" also updates the values new peers inherit.</p>
1219+ </div>
1220+
1221+ <h3>Camera encoder</h3>
1222+ <p class="small">Max bitrate and degradation preference are applied instantly without touching the camera.</p>
1223+ <div class="grid-2">
9991224 <label class="field">
1000- <span>Height (px, 0 = auto)</span>
1001- <input type="number" id="rt-s-h" min="0" max="4320">
1225+ <span>Max send bitrate (kbps, 0 = unset)</span>
1226+ <input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
10021227 </label>
10031228 <label class="field">
1004- <span>Frame rate (fps, 0 = auto)</span>
1005- <input type="number" id="rt-s-fps" min="0" max="120">
1229+ <span>Degradation preference</span>
1230+ <select id="rt-v-degrade">
1231+ <option value="balanced">balanced</option>
1232+ <option value="maintain-framerate">maintain-framerate</option>
1233+ <option value="maintain-resolution">maintain-resolution</option>
1234+ </select>
10061235 </label>
1236+ </div>
1237+ <button id="rt-v-enc-apply" class="ghost">Apply camera encoder</button>
1238+
1239+ <h3>Screen encoder</h3>
1240+ <div class="grid-2">
10071241 <label class="field">
10081242 <span>Max send bitrate (kbps, 0 = unset)</span>
10091243 <input type="number" id="rt-s-maxbr" min="0" max="50000" step="100">
@@ -1017,31 +1251,27 @@
10171251 </select>
10181252 </label>
10191253 </div>
1020- <button id="rt-s-apply" class="ghost">Apply</button>
1254+ <button id="rt-s-enc-apply" class="ghost">Apply screen encoder</button>
10211255
1022- <h3>Audio</h3>
1023- <p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
1024- <label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
1025- <label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
1026- <label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
1256+ <h3>Send codec</h3>
1257+ <p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
10271258 <div class="grid-2">
10281259 <label class="field">
1029- <span>Channels</span>
1030- <select id="rt-a-channels">
1031- <option value="1">1 (mono)</option>
1032- <option value="2">2 (stereo)</option>
1260+ <span>Send codec</span>
1261+ <select id="rt-codec">
1262+ <option value="auto">auto</option>
10331263 </select>
10341264 </label>
1035- <label class="field">
1036- <span>Sample rate (Hz, 0 = auto)</span>
1037- <input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
1038- </label>
10391265 </div>
1040- <button id="rt-a-apply" class="ghost">Apply</button>
1266+ <button id="rt-codec-apply" class="ghost">Apply codec</button>
10411267 </div>
10421268
10431269 <div class="tab-pane" data-pane="stats">
1044- <h3>Peer connection <button id="stats-export" class="ghost small" style="float:right">Export</button></h3>
1270+ <div class="stats-head">
1271+ <h3>Peer</h3>
1272+ <select id="stats-peer"><option value="">(no peers)</option></select>
1273+ <button id="stats-export" class="ghost small">Export</button>
1274+ </div>
10451275 <table class="stats-table" id="stats-table"><tbody></tbody></table>
10461276 </div>
10471277 </aside>
@@ -1060,6 +1290,7 @@
10601290 </div>
10611291 <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>
10621292 <span class="spacer"></span>
1293+ <button id="tb-add-peer" class="primary" title="Add participant"><svg class="ic" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg><span class="nowrap">Add participant</span></button>
10631294 <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>
10641295 <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>
10651296 </div>
@@ -1086,11 +1317,28 @@
10861317 </aside>
10871318 </section>
10881319
1089- <dialog id="peer-left-dialog">
1090- <h2>Peer left the call</h2>
1091- <p>The other person hung up. The call has ended.</p>
1320+ <dialog id="add-peer-dialog">
1321+ <h2>Add participant</h2>
1322+ <p class="small">Set up one new pairwise connection. Each participant you add is independent — chat and files fan out to everyone connected to you.</p>
1323+
1324+ <h3 style="margin-top:14px">Your role for this connection</h3>
1325+ <div class="role-picker compact">
1326+ <button type="button" data-role="initiator">
1327+ <span class="role-title">Start (create offer)</span>
1328+ <span class="role-desc">You'll generate the offer; the other side joins.</span>
1329+ </button>
1330+ <button type="button" data-role="joiner">
1331+ <span class="role-title">Join (apply offer)</span>
1332+ <span class="role-desc">The other side started; you apply their offer and send back the answer.</span>
1333+ </button>
1334+ <button type="button" data-role="loopback" class="role-loopback">
1335+ <span class="role-title">Loopback (same tab)</span>
1336+ <span class="role-desc">Add a synthetic peer in this tab. Useful for testing media and the conference layout locally. Can be added multiple times.</span>
1337+ </button>
1338+ </div>
1339+
10921340 <div class="dialog-actions">
1093- <button id="peer-left-ok" class="primary">OK</button>
1341+ <button id="add-peer-cancel" class="ghost">Cancel</button>
10941342 </div>
10951343 </dialog>
10961344
@@ -1185,11 +1433,14 @@ App.theme = (() => {
11851433 App.progress = (() => {
11861434 let modalCancelHandler = null;
11871435 function active() {
1188- /* Pick the progress widget inside the currently visible view. */
1436+ /* Pick the progress widget inside the currently visible view OR open
1437+ dialog (configure / exchange are <dialog>s now, not .view sections). */
11891438 const candidates = ['cfg-progress', 'exch-progress'];
11901439 for (const id of candidates) {
11911440 const el = document.getElementById(id);
11921441 if (!el) continue;
1442+ const dlg = el.closest('dialog');
1443+ if (dlg && dlg.open) return el;
11931444 const view = el.closest('.view');
11941445 if (view && !view.classList.contains('hidden')) return el;
11951446 }
@@ -1263,35 +1514,92 @@ App.progress = (() => {
12631514
12641515 /* -------------------------------------------------------------------------
12651516 State
1517+
1518+ Multi-peer model: each remote participant has its own PeerCtx (its own
1519+ RTCPeerConnection, data channels, transceivers, remote streams, and
1520+ peer-published media-state). Local media (mic/cam/screen capture) is
1521+ captured once and fanned out to every peer's senders via replaceTrack —
1522+ so capture state stays on App.state, not on a peer.
12661523 ------------------------------------------------------------------------- */
12671524 App.state = {
1268- role: null, /* 'initiator' | 'joiner' | 'loopback' */
1269- pc: null, /* primary RTCPeerConnection */
1270- pcB: null, /* loopback only: secondary RTCPeerConnection */
1271- dcChat: null,
1272- dcFiles: null,
1273- micTransceiver: null,
1274- camTransceiver: null,
1275- screenTransceiver: null,
1276- localStream: null, /* gUM result */
1277- screenStream: null,
1525+ peers: new Map(), /* peerId (string) -> PeerCtx */
1526+ pendingPeer: null, /* PeerCtx being set up via the Add Participant dialog */
1527+ localStream: null, /* gUM result (mic + cam, synthetic) */
1528+ screenStream: null, /* gDM result */
1529+ micTrack: null, /* live local mic MediaStreamTrack, or null */
1530+ camTrack: null, /* live local cam MediaStreamTrack, or null */
12781531 iceWarmupStream: null, /* kept alive (muted) to unlock LAN ICE candidates in Firefox */
1279- remoteStream: null, /* assembled from incoming audio + cam tracks */
1280- remoteScreenStream: null,/* assembled from incoming screen track */
1281- peerMediaState: { mic: false, cam: false, screen: false }, /* sent by peer over the chat dc */
1532+ username: 'Anonymous',
12821533 settings: defaultSettings(),
1534+ /* Banner shown when peers is empty. Once dismissed (auto on first join)
1535+ it stays dismissed for the rest of the session, even if all peers leave. */
1536+ bannerDismissed: false,
12831537 };
12841538
1539+/* PeerCtx — everything specific to one remote participant. */
1540+function newPeerCtx(opts) {
1541+ return {
1542+ id: opts.id,
1543+ role: opts.role, /* 'initiator' | 'joiner' | 'loopback' */
1544+ label: opts.label || opts.id, /* logger tag */
1545+ username: opts.username || 'Anonymous',
1546+ pc: null,
1547+ dcChat: null,
1548+ dcFiles: null,
1549+ micTransceiver: null,
1550+ camTransceiver: null,
1551+ screenTransceiver: null,
1552+ remoteStream: null, /* mic + cam from this peer */
1553+ remoteScreenStream: null, /* screen from this peer */
1554+ peerMediaState: { mic: false, cam: false, screen: false },
1555+ /* Per-peer media settings (setParameters-tunable, no renegotiation). */
1556+ videoMaxBitrateKbps: 0,
1557+ videoDegradationPreference: 'balanced',
1558+ screenMaxBitrateKbps: 0,
1559+ screenDegradationPreference: 'maintain-resolution',
1560+ sendVideoCodec: 'auto',
1561+ /* Loopback only: this peer's synthetic pcB. Each loopback peer has its
1562+ own pcB so multiple loopbacks can coexist. */
1563+ loopbackB: null,
1564+ /* Signaling lifecycle: an in-flight AbortController for auto-mode fetches
1565+ so cancelSetup / hangup can abort the long-poll cleanly. */
1566+ signalAbort: null,
1567+ /* True if the chat dc has already received a 'bye' from this peer, so we
1568+ don't run the leave-handler twice. */
1569+ leaveReceived: false,
1570+ };
1571+}
1572+
1573+function generatePeerId() {
1574+ /* 8 lowercase hex chars — enough to disambiguate "Anonymous" peers. */
1575+ const a = new Uint8Array(4);
1576+ crypto.getRandomValues(a);
1577+ return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
1578+}
1579+
1580+/* Convenience: iterate live peers (any state); callers usually want only
1581+ those with an open chat channel and a connected pc. */
1582+function peerList() { return Array.from(App.state.peers.values()); }
1583+function peersWithOpenChat() {
1584+ return peerList().filter(p => p.dcChat && p.dcChat.readyState === 'open');
1585+}
1586+function peersWithOpenFiles() {
1587+ return peerList().filter(p => p.dcFiles && p.dcFiles.readyState === 'open');
1588+}
1589+
12851590 function defaultSettings() {
12861591 const stored = localStorage.getItem('webrtc-tool.iceServers');
12871592 let ice;
12881593 try { ice = stored ? JSON.parse(stored) : [{ urls: 'stun:stun.l.google.com:19302' }]; }
12891594 catch (_) { ice = [{ urls: 'stun:stun.l.google.com:19302' }]; }
1595+ /* Audio + video gUM constraints are global (one local capture, fanned out
1596+ to all peers). bitrate/codec/degradation are stored here as defaults for
1597+ newly-added peers, and copied into each PeerCtx on creation. */
12901598 return {
12911599 iceServers: ice,
1292- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
1293- opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
1294- video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
1600+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
1601+ opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
1602+ video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
12951603 screen: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'maintain-resolution' },
12961604 preferredVideoCodec: 'auto',
12971605 sendVideoCodec: 'auto',
@@ -1300,6 +1608,17 @@ function defaultSettings() {
13001608 };
13011609 }
13021610
1611+function loadUsername() {
1612+ try {
1613+ const raw = localStorage.getItem('webrtc-tool.username');
1614+ if (typeof raw === 'string' && raw.trim()) return raw.trim().slice(0, 64);
1615+ } catch (_) {}
1616+ return 'Anonymous';
1617+}
1618+function saveUsername(name) {
1619+ try { localStorage.setItem('webrtc-tool.username', name); } catch (_) {}
1620+}
1621+
13031622 function loadSignaling() {
13041623 /* Default server URL is the page's own origin — the natural assumption is
13051624 that the relay is colocated with the static page. For file:// loads
@@ -1482,13 +1801,18 @@ App.codec = (() => {
14821801
14831802 /* -------------------------------------------------------------------------
14841803 Media: gUM/gDM, pre-allocated transceivers, replaceTrack
1804+
1805+ Local capture (mic, cam, screen) happens once on App.state and is fanned
1806+ out to every PeerCtx's sender via replaceTrack — so per-peer encoders
1807+ share a single source track. Per-peer encoder parameters (bitrate,
1808+ degradation, codec) live on the PeerCtx and are pushed via setParameters
1809+ without renegotiation.
14851810 ------------------------------------------------------------------------- */
14861811 App.media = (() => {
1487- /* Reorder the codec list so the user's preferred video codec is first.
1488- Must be called before createOffer (initiator/loopback A) or createAnswer
1489- (joiner / loopback B). With 'auto' we don't touch the list, letting the
1490- browser's default order win. */
1491- function applyVideoCodecPreference(pc) {
1812+ /* Reorder a peer's codec list so the user's preferred RECEIVE codec is first.
1813+ Must be called before createOffer or createAnswer for that peer. With
1814+ 'auto' we don't touch the list, letting the browser's default order win. */
1815+ function applyVideoCodecPreference(peer) {
14921816 const pref = App.state.settings.preferredVideoCodec;
14931817 if (!pref || pref === 'auto') return;
14941818 if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
@@ -1500,46 +1824,70 @@ App.media = (() => {
15001824 const sub = (c.mimeType || '').toLowerCase().split('/')[1] || '';
15011825 (sub === wanted ? head : tail).push(c);
15021826 }
1503- if (!head.length) { App.log.warn('media', 'preferred codec not available', pref); return; }
1827+ if (!head.length) { App.log.warn('media', peer.label, 'preferred codec not available', pref); return; }
15041828 const ordered = [...head, ...tail];
1505- for (const t of pc.getTransceivers()) {
1829+ for (const t of peer.pc.getTransceivers()) {
15061830 const kind = (t.receiver && t.receiver.track && t.receiver.track.kind)
15071831 || (t.sender && t.sender.track && t.sender.track.kind);
15081832 const isVideo = kind === 'video'
1509- || t === App.state.camTransceiver
1510- || t === App.state.screenTransceiver;
1833+ || t === peer.camTransceiver
1834+ || t === peer.screenTransceiver;
15111835 if (!isVideo || !t.setCodecPreferences) continue;
15121836 try { t.setCodecPreferences(ordered); }
1513- catch (e) { App.log.warn('media', 'setCodecPreferences failed', e.message); }
1837+ catch (e) { App.log.warn('media', peer.label, 'setCodecPreferences failed', e.message); }
15141838 }
1515- App.log.info('media', 'preferred video codec', pref);
1839+ App.log.info('media', peer.label, 'preferred video codec', pref);
15161840 }
15171841
1518- /* Pre-allocate three transceivers on the initiator so all toolbar actions
1519- are renegotiation-free (see plan). The joiner gets matching m-sections
1520- from setRemoteDescription and we index transceivers by position. */
1521- function preallocate(pc) {
1522- App.state.micTransceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
1523- App.state.camTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
1524- App.state.screenTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
1525- App.log.debug('media', 'pre-allocated 1 audio + 2 video transceivers');
1842+ /* Pre-allocate three transceivers on the initiator's pc so all toolbar
1843+ actions are renegotiation-free (see plan). The joiner gets matching
1844+ m-sections from setRemoteDescription and we index transceivers by
1845+ position. */
1846+ function preallocate(peer) {
1847+ peer.micTransceiver = peer.pc.addTransceiver('audio', { direction: 'sendrecv' });
1848+ peer.camTransceiver = peer.pc.addTransceiver('video', { direction: 'sendrecv' });
1849+ peer.screenTransceiver = peer.pc.addTransceiver('video', { direction: 'sendrecv' });
1850+ App.log.debug('media', peer.label, 'pre-allocated 1 audio + 2 video transceivers');
15261851 }
1527- function adoptTransceiversFromRemote(pc) {
1852+ function adoptTransceiversFromRemote(peer) {
15281853 /* For the joiner: after setRemoteDescription, transceivers exist in
15291854 the same order the initiator added them (mid 0, 1, 2). They were
15301855 auto-created by SRD and default to recvonly because the joiner has
15311856 no local tracks yet — but later enabling mic/cam on a recvonly
15321857 transceiver would never send. Force sendrecv so the answer SDP
15331858 advertises bidirectional intent. */
1534- const ts = pc.getTransceivers();
1859+ const ts = peer.pc.getTransceivers();
15351860 for (const t of ts) {
15361861 try { t.direction = 'sendrecv'; }
1537- catch (e) { App.log.warn('media', 'could not upgrade transceiver to sendrecv', e.message); }
1862+ catch (e) { App.log.warn('media', peer.label, 'could not upgrade transceiver to sendrecv', e.message); }
1863+ }
1864+ peer.micTransceiver = ts[0] || null;
1865+ peer.camTransceiver = ts[1] || null;
1866+ peer.screenTransceiver = ts[2] || null;
1867+ App.log.debug('media', peer.label, 'adopted', ts.length, 'transceivers from remote SDP');
1868+ }
1869+
1870+ /* After a new peer's transceivers are set up, push the currently-live
1871+ local tracks into them so the peer immediately receives whatever the
1872+ user already enabled before they joined. */
1873+ async function publishLocalTracksTo(peer) {
1874+ if (peer.micTransceiver && App.state.micTrack) {
1875+ try { await peer.micTransceiver.sender.replaceTrack(App.state.micTrack); }
1876+ catch (e) { App.log.warn('media', peer.label, 'publish mic failed', e.message); }
1877+ }
1878+ if (peer.camTransceiver && App.state.camTrack) {
1879+ try { await peer.camTransceiver.sender.replaceTrack(App.state.camTrack); }
1880+ catch (e) { App.log.warn('media', peer.label, 'publish cam failed', e.message); }
1881+ }
1882+ if (peer.screenTransceiver && App.state.screenStream) {
1883+ const track = App.state.screenStream.getVideoTracks()[0];
1884+ if (track) {
1885+ try { await peer.screenTransceiver.sender.replaceTrack(track); }
1886+ catch (e) { App.log.warn('media', peer.label, 'publish screen failed', e.message); }
1887+ }
15381888 }
1539- App.state.micTransceiver = ts[0] || null;
1540- App.state.camTransceiver = ts[1] || null;
1541- App.state.screenTransceiver = ts[2] || null;
1542- App.log.debug('media', 'adopted', ts.length, 'transceivers from remote SDP');
1889+ applyCamSendParamsFor(peer);
1890+ applyScreenSendParamsFor(peer);
15431891 }
15441892
15451893 /* The local preview MediaStream is a synthetic view: we add/remove tracks
@@ -1552,6 +1900,8 @@ App.media = (() => {
15521900 const s = localStream();
15531901 s.getTracks().filter(t => t.kind === kind).forEach(t => s.removeTrack(t));
15541902 if (track) s.addTrack(track);
1903+ if (kind === 'audio') App.state.micTrack = track || null;
1904+ if (kind === 'video') App.state.camTrack = track || null;
15551905 refreshLocalDisplay();
15561906 }
15571907 /* Decide what plays in the main tile vs the corner PIP for the local side.
@@ -1561,6 +1911,7 @@ App.media = (() => {
15611911 const pip = document.getElementById('vid-local-pip');
15621912 const pipWrap = document.getElementById('pip-local');
15631913 const tile = document.getElementById('tile-local');
1914+ if (!main || !tile) return;
15641915 const camStream = App.state.localStream;
15651916 const hasCam = camStream && camStream.getVideoTracks().length > 0;
15661917 const screenStream = App.state.screenStream;
@@ -1580,26 +1931,31 @@ App.media = (() => {
15801931 pip.srcObject = null; pipWrap.classList.add('hidden');
15811932 }
15821933 tile.classList.toggle('empty', !hasCam && !hasScreen);
1583- const micOn = !!(App.state.micTransceiver && App.state.micTransceiver.sender.track);
1584- document.getElementById('mic-muted-local').classList.toggle('hidden', micOn);
1585- }
1586- function refreshRemoteDisplay() {
1587- const main = document.getElementById('vid-remote-main');
1588- const pip = document.getElementById('vid-remote-pip');
1589- const pipWrap = document.getElementById('pip-remote');
1590- const tile = document.getElementById('tile-remote');
1591- const audioEl = document.getElementById('audio-remote');
1592- const camStream = App.state.remoteStream;
1593- const screenStream = App.state.remoteScreenStream;
1594- /* Always route the peer's audio through the dedicated audio element so it
1934+ const micOn = !!App.state.micTrack;
1935+ const m = document.getElementById('mic-muted-local');
1936+ if (m) m.classList.toggle('hidden', micOn);
1937+ }
1938+ /* Refresh the per-peer remote tile (video / pip / mic-muted badge). The
1939+ tile DOM is created and managed by tilesUI; we just push current
1940+ srcObjects + visibility into it. */
1941+ function refreshRemoteDisplayFor(peer) {
1942+ const tile = document.getElementById('tile-' + peer.id);
1943+ if (!tile) return;
1944+ const main = tile.querySelector('.vid-main');
1945+ const pip = tile.querySelector('.vid-pip');
1946+ const pipWrap = tile.querySelector('.pip');
1947+ const audioEl = tile.querySelector('audio.audio-remote');
1948+ const camStream = peer.remoteStream;
1949+ const screenStream = peer.remoteScreenStream;
1950+ /* Always route the peer's audio through a dedicated audio element so it
15951951 plays regardless of whether any video is currently visible. */
15961952 if (audioEl && audioEl.srcObject !== camStream) audioEl.srcObject = camStream || null;
15971953 /* Track presence isn't enough — replaceTrack(null) on the sender leaves
15981954 the receiver's track in place (frozen on last frame). Trust the peer's
15991955 broadcast media state when deciding whether to show video. */
1600- const peer = App.state.peerMediaState || { mic: false, cam: false, screen: false };
1601- const hasCam = peer.cam && camStream && camStream.getVideoTracks().length > 0;
1602- const hasScreen = peer.screen && screenStream && screenStream.getVideoTracks().length > 0;
1956+ const ms = peer.peerMediaState || { mic: false, cam: false, screen: false };
1957+ const hasCam = ms.cam && camStream && camStream.getVideoTracks().length > 0;
1958+ const hasScreen = ms.screen && screenStream && screenStream.getVideoTracks().length > 0;
16031959 if (hasScreen) {
16041960 main.srcObject = screenStream;
16051961 tile.classList.add('screen');
@@ -1617,20 +1973,43 @@ App.media = (() => {
16171973 pip.srcObject = null; pipWrap.classList.add('hidden');
16181974 }
16191975 tile.classList.toggle('empty', !hasCam && !hasScreen);
1620- document.getElementById('mic-muted-remote').classList.toggle('hidden', peer.mic);
1976+ const m = tile.querySelector('.mic-muted');
1977+ if (m) m.classList.toggle('hidden', ms.mic);
1978+ }
1979+ function refreshRemoteDisplay() {
1980+ for (const peer of App.state.peers.values()) refreshRemoteDisplayFor(peer);
16211981 }
1982+ /* In loopback mode, each loopback peer's pcB needs to "see" the same tracks
1983+ pcA is sending so the remote tile gets video back. Called whenever a
1984+ sender track changes; iterates every loopback peer. */
16221985 function mirrorToLoopback() {
1623- /* In loopback mode, pcB needs to "see" the same tracks pcA is sending so
1624- the remote tile gets video back. Called whenever a sender track changes. */
1625- if (!App.state.pcB) return;
1626- const ts = App.state.pcB.getTransceivers();
1627- const a = App.state.micTransceiver && App.state.micTransceiver.sender.track;
1628- const v = App.state.camTransceiver && App.state.camTransceiver.sender.track;
1629- const s = App.state.screenTransceiver && App.state.screenTransceiver.sender.track;
1630- const fail = label => err => App.log.warn('loopback', 'mirror ' + label + ' failed', err.message);
1631- if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic'));
1632- if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam'));
1633- if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen'));
1986+ const a = App.state.micTrack;
1987+ const v = App.state.camTrack;
1988+ const s = App.state.screenStream && App.state.screenStream.getVideoTracks()[0] || null;
1989+ const fail = (label, peerLabel) => err =>
1990+ App.log.warn('loopback', peerLabel + ' mirror ' + label + ' failed', err.message);
1991+ for (const peer of App.state.peers.values()) {
1992+ const pcB = peer.loopbackB;
1993+ if (!pcB) continue;
1994+ const ts = pcB.getTransceivers();
1995+ if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic', peer.label));
1996+ if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam', peer.label));
1997+ if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen', peer.label));
1998+ }
1999+ }
2000+ /* Fan-out the local track-state to every peer's sender. Failures on one
2001+ peer don't block the others. */
2002+ async function fanOutTrack(getTransceiver, track, label) {
2003+ const tasks = [];
2004+ for (const peer of App.state.peers.values()) {
2005+ const tr = getTransceiver(peer);
2006+ if (!tr) continue;
2007+ tasks.push(
2008+ tr.sender.replaceTrack(track).catch(e =>
2009+ App.log.warn('media', peer.label, 'replaceTrack ' + label + ' failed', e.message))
2010+ );
2011+ }
2012+ if (tasks.length) await Promise.allSettled(tasks);
16342013 }
16352014 function gumAvailable() {
16362015 return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
@@ -1638,14 +2017,13 @@ App.media = (() => {
16382017
16392018 /* Detect "device unplugged while active": the track fires 'ended'. Flip the
16402019 toolbar button off so the UI reflects reality and the user can pick a
1641- different device. We guard with sender.track === track to ignore the
1642- ended event that fires when *we* swap the track via replaceTrack(). */
2020+ different device. We guard with App.state.micTrack/camTrack === track to
2021+ ignore the ended event that fires when *we* swap the track via
2022+ replaceTrack(). */
16432023 function attachTrackEndedHandler(track, kind) {
1644- const sender = kind === 'mic'
1645- ? App.state.micTransceiver && App.state.micTransceiver.sender
1646- : App.state.camTransceiver && App.state.camTransceiver.sender;
16472024 track.addEventListener('ended', () => {
1648- if (!sender || sender.track !== track) return;
2025+ const live = kind === 'mic' ? App.state.micTrack : App.state.camTrack;
2026+ if (live !== track) return;
16492027 const label = kind === 'mic' ? 'Microphone' : 'Camera';
16502028 App.log.warn('media', kind + ' track ended unexpectedly');
16512029 App.chat.appendSystem?.(label + ' disconnected.');
@@ -1654,8 +2032,6 @@ App.media = (() => {
16542032 }
16552033
16562034 async function setMic(on) {
1657- const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
1658- if (!sender) return;
16592035 const btn = document.getElementById('tb-mic');
16602036 if (on) {
16612037 if (!gumAvailable()) {
@@ -1677,19 +2053,14 @@ App.media = (() => {
16772053 },
16782054 });
16792055 const track = stream.getAudioTracks()[0];
1680- /* If the call was torn down while gUM was waiting on the user's
1681- permission prompt, the sender's pc is now closed and replaceTrack
1682- would reject — stop the track instead so the OS-level mic
1683- indicator turns off. */
1684- if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1685- throw new Error('call ended before mic prompt resolved');
1686- }
1687- await sender.replaceTrack(track);
16882056 setLocalTrack('audio', track);
16892057 attachTrackEndedHandler(track, 'mic');
2058+ await fanOutTrack(p => p.micTransceiver, track, 'mic');
16902059 mirrorToLoopback();
1691- btn.classList.add('on'); btn.classList.remove('off');
1692- btn.querySelector('.nowrap').textContent = 'Mic on';
2060+ if (btn) {
2061+ btn.classList.add('on'); btn.classList.remove('off');
2062+ btn.querySelector('.nowrap').textContent = 'Mic on';
2063+ }
16932064 App.log.info('media', 'mic on');
16942065 applyMediaButtonAvailability?.();
16952066 } catch (e) {
@@ -1704,20 +2075,21 @@ App.media = (() => {
17042075 if (stream) stream.getTracks().forEach(t => t.stop());
17052076 }
17062077 } else {
1707- if (sender.track) sender.track.stop();
1708- await sender.replaceTrack(null);
2078+ const t = App.state.micTrack;
2079+ if (t) t.stop();
17092080 setLocalTrack('audio', null);
2081+ await fanOutTrack(p => p.micTransceiver, null, 'mic');
17102082 mirrorToLoopback();
1711- btn.classList.remove('on'); btn.classList.add('off');
1712- btn.querySelector('.nowrap').textContent = 'Mic off';
2083+ if (btn) {
2084+ btn.classList.remove('on'); btn.classList.add('off');
2085+ btn.querySelector('.nowrap').textContent = 'Mic off';
2086+ }
17132087 App.log.info('media', 'mic off');
17142088 }
17152089 broadcastMediaState();
17162090 }
17172091
17182092 async function setCam(on) {
1719- const sender = App.state.camTransceiver && App.state.camTransceiver.sender;
1720- if (!sender) return;
17212093 const btn = document.getElementById('tb-cam');
17222094 if (on) {
17232095 if (!gumAvailable()) {
@@ -1737,16 +2109,15 @@ App.media = (() => {
17372109 },
17382110 });
17392111 const track = stream.getVideoTracks()[0];
1740- if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1741- throw new Error('call ended before camera prompt resolved');
1742- }
1743- await sender.replaceTrack(track);
17442112 setLocalTrack('video', track);
17452113 attachTrackEndedHandler(track, 'cam');
1746- applyCamSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
2114+ await fanOutTrack(p => p.camTransceiver, track, 'cam');
2115+ applyCamSendParamsAll();
17472116 mirrorToLoopback();
1748- btn.classList.add('on'); btn.classList.remove('off');
1749- btn.querySelector('.nowrap').textContent = 'Cam on';
2117+ if (btn) {
2118+ btn.classList.add('on'); btn.classList.remove('off');
2119+ btn.querySelector('.nowrap').textContent = 'Cam on';
2120+ }
17502121 App.log.info('media', 'cam on');
17512122 applyMediaButtonAvailability?.();
17522123 } catch (e) {
@@ -1759,12 +2130,15 @@ App.media = (() => {
17592130 if (stream) stream.getTracks().forEach(t => t.stop());
17602131 }
17612132 } else {
1762- if (sender.track) sender.track.stop();
1763- await sender.replaceTrack(null);
2133+ const t = App.state.camTrack;
2134+ if (t) t.stop();
17642135 setLocalTrack('video', null);
2136+ await fanOutTrack(p => p.camTransceiver, null, 'cam');
17652137 mirrorToLoopback();
1766- btn.classList.remove('on'); btn.classList.add('off');
1767- btn.querySelector('.nowrap').textContent = 'Cam off';
2138+ if (btn) {
2139+ btn.classList.remove('on'); btn.classList.add('off');
2140+ btn.querySelector('.nowrap').textContent = 'Cam off';
2141+ }
17682142 App.log.info('media', 'cam off');
17692143 }
17702144 broadcastMediaState();
@@ -1783,22 +2157,18 @@ App.media = (() => {
17832157 },
17842158 audio: false,
17852159 });
1786- /* If the call ended while the source picker was open, the user has
1787- already selected a source — release it instead of leaving it active. */
1788- if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1789- ms.getTracks().forEach(t => t.stop());
1790- throw new Error('call ended before screen share started');
1791- }
17922160 App.state.screenStream = ms;
17932161 const track = ms.getVideoTracks()[0];
17942162 track.addEventListener('ended', () => stopScreenshare());
1795- if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(track);
1796- applyScreenSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
2163+ await fanOutTrack(p => p.screenTransceiver, track, 'screen');
2164+ applyScreenSendParamsAll();
17972165 mirrorToLoopback();
17982166 refreshLocalDisplay();
17992167 const sBtn = document.getElementById('tb-screen');
1800- sBtn.classList.add('on'); sBtn.classList.remove('off');
1801- sBtn.querySelector('.nowrap').textContent = 'Sharing';
2168+ if (sBtn) {
2169+ sBtn.classList.add('on'); sBtn.classList.remove('off');
2170+ sBtn.querySelector('.nowrap').textContent = 'Sharing';
2171+ }
18022172 App.log.info('media', 'screenshare started');
18032173 broadcastMediaState();
18042174 }
@@ -1806,28 +2176,33 @@ App.media = (() => {
18062176 const ms = App.state.screenStream;
18072177 if (ms) ms.getTracks().forEach(t => t.stop());
18082178 App.state.screenStream = null;
1809- if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(null);
2179+ await fanOutTrack(p => p.screenTransceiver, null, 'screen');
18102180 mirrorToLoopback();
18112181 refreshLocalDisplay();
18122182 const sBtn = document.getElementById('tb-screen');
1813- sBtn.classList.remove('on'); sBtn.classList.add('off');
1814- sBtn.querySelector('.nowrap').textContent = 'Screen off';
2183+ if (sBtn) {
2184+ sBtn.classList.remove('on'); sBtn.classList.add('off');
2185+ sBtn.querySelector('.nowrap').textContent = 'Screen off';
2186+ }
18152187 App.log.info('media', 'screenshare stopped');
18162188 broadcastMediaState();
18172189 }
18182190
1819- /* Collect the relevant senders for a given track kind ('cam' | 'screen'),
1820- including pcB's mirrored sender in loopback so the cap is visible there. */
1821- function sendersFor(kind) {
2191+ /* Collect the relevant sender for one peer (and its loopback mirror, if
2192+ any) for a given track kind ('cam' | 'screen'). */
2193+ function senderFor(peer, kind) {
2194+ const tr = kind === 'screen' ? peer.screenTransceiver : peer.camTransceiver;
2195+ return tr ? tr.sender : null;
2196+ }
2197+ /* For a loopback peer, returns pcB's reverse sender for this track kind
2198+ (the one that mirrors A's track back so peer.remoteStream picks it up).
2199+ Transceivers on pcB are in mic/cam/screen order via preallocate's m-section
2200+ ordering. */
2201+ function loopbackMirrorSender(peer, kind) {
2202+ if (!peer.loopbackB) return null;
18222203 const tIdx = kind === 'screen' ? 2 : 1;
1823- const local = kind === 'screen' ? App.state.screenTransceiver : App.state.camTransceiver;
1824- const out = [];
1825- if (local) out.push(local.sender);
1826- if (App.state.pcB) {
1827- const t = App.state.pcB.getTransceivers()[tIdx];
1828- if (t) out.push(t.sender);
1829- }
1830- return out;
2204+ const t = peer.loopbackB.getTransceivers()[tIdx];
2205+ return t ? t.sender : null;
18312206 }
18322207 /* setParameters is transactional via an internal transactionId attached to
18332208 the params object returned by getParameters. Two interleaved
@@ -1835,52 +2210,72 @@ App.media = (() => {
18352210 with InvalidModificationError. Coalesce bitrate + degradation into one
18362211 round-trip per sender, and serialize calls via a per-sender chain. */
18372212 const pendingByS = new WeakMap(); /* sender -> Promise (most recent set) */
1838- function applyVideoSendParams(kind, logLabel) {
1839- const cfg = kind === 'screen' ? App.state.settings.screen : App.state.settings.video;
1840- const kbps = cfg.maxBitrateKbps;
1841- const pref = cfg.degradationPreference;
1842- const wantCodec = (App.state.settings.sendVideoCodec || 'auto').toLowerCase();
1843- const senders = sendersFor(kind);
1844- for (const sender of senders) {
1845- const prev = pendingByS.get(sender) || Promise.resolve();
1846- const next = prev.then(() => {
1847- const params = sender.getParameters();
1848- if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
1849- if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
1850- else delete params.encodings[0].maxBitrate;
1851- if (pref) params.degradationPreference = pref;
1852- /* encodings[0].codec is the "set sending codec" API. Pick from the
1853- negotiated codec list on the sender — if the codec we want isn't
1854- there (e.g. peer didn't offer it, or browser doesn't expose
1855- params.codecs yet), leave the field unset so the browser keeps
1856- picking automatically. */
1857- if (wantCodec === 'auto') {
1858- delete params.encodings[0].codec;
1859- } else if (params.codecs && params.codecs.length) {
1860- const pick = params.codecs.find(c => {
1861- const sub = (c.mimeType || '').split('/')[1] || '';
1862- return sub.toLowerCase() === wantCodec;
1863- });
1864- if (pick) params.encodings[0].codec = pick;
1865- else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
1866- }
1867- return sender.setParameters(params).then(
1868- () => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
1869- e => App.log.error('media', logLabel, 'setParameters failed', e.message)
1870- );
1871- });
1872- pendingByS.set(sender, next);
1873- }
2213+ function applyVideoSendParamsToSender(sender, kbps, pref, wantCodec, logLabel) {
2214+ if (!sender) return;
2215+ const prev = pendingByS.get(sender) || Promise.resolve();
2216+ const next = prev.then(() => {
2217+ const params = sender.getParameters();
2218+ if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
2219+ if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
2220+ else delete params.encodings[0].maxBitrate;
2221+ if (pref) params.degradationPreference = pref;
2222+ /* encodings[0].codec is the "set sending codec" API. Pick from the
2223+ negotiated codec list on the sender — if the codec we want isn't
2224+ there (e.g. peer didn't offer it, or browser doesn't expose
2225+ params.codecs yet), leave the field unset so the browser keeps
2226+ picking automatically. */
2227+ if (wantCodec === 'auto') {
2228+ delete params.encodings[0].codec;
2229+ } else if (params.codecs && params.codecs.length) {
2230+ const pick = params.codecs.find(c => {
2231+ const sub = (c.mimeType || '').split('/')[1] || '';
2232+ return sub.toLowerCase() === wantCodec;
2233+ });
2234+ if (pick) params.encodings[0].codec = pick;
2235+ else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
2236+ }
2237+ return sender.setParameters(params).then(
2238+ () => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
2239+ e => App.log.error('media', logLabel, 'setParameters failed', e.message)
2240+ );
2241+ });
2242+ pendingByS.set(sender, next);
2243+ }
2244+ /* Apply this peer's per-peer cam encoding params. For loopback peers we
2245+ also push the same params onto pcB's mirror sender so the cap is visible
2246+ on the received-side stream too. */
2247+ function applyCamSendParamsFor(peer) {
2248+ const codec = (peer.sendVideoCodec || 'auto').toLowerCase();
2249+ applyVideoSendParamsToSender(
2250+ senderFor(peer, 'cam'),
2251+ peer.videoMaxBitrateKbps,
2252+ peer.videoDegradationPreference,
2253+ codec,
2254+ 'cam[' + peer.label + ']');
2255+ const lb = loopbackMirrorSender(peer, 'cam');
2256+ if (lb) applyVideoSendParamsToSender(lb, peer.videoMaxBitrateKbps, peer.videoDegradationPreference, codec, 'cam[' + peer.label + '/B]');
2257+ }
2258+ function applyScreenSendParamsFor(peer) {
2259+ const codec = (peer.sendVideoCodec || 'auto').toLowerCase();
2260+ applyVideoSendParamsToSender(
2261+ senderFor(peer, 'screen'),
2262+ peer.screenMaxBitrateKbps,
2263+ peer.screenDegradationPreference,
2264+ codec,
2265+ 'screen[' + peer.label + ']');
2266+ const lb = loopbackMirrorSender(peer, 'screen');
2267+ if (lb) applyVideoSendParamsToSender(lb, peer.screenMaxBitrateKbps, peer.screenDegradationPreference, codec, 'screen[' + peer.label + '/B]');
2268+ }
2269+ /* Fan out the current per-peer params to every live peer. Each peer's
2270+ loopback mirror (if any) is updated inside its applyCam/Screen call. */
2271+ function applyCamSendParamsAll() {
2272+ for (const peer of App.state.peers.values()) applyCamSendParamsFor(peer);
2273+ }
2274+ function applyScreenSendParamsAll() {
2275+ for (const peer of App.state.peers.values()) applyScreenSendParamsFor(peer);
18742276 }
1875- /* Cam and screen each have a single "push current bitrate + degradation
1876- preference" entry point. We don't split bitrate-only / degradation-only
1877- because every call site already wants both, and a single setParameters
1878- round-trip per sender is more efficient than two. */
1879- function applyCamSendParams() { applyVideoSendParams('cam', 'cam'); }
1880- function applyScreenSendParams() { applyVideoSendParams('screen', 'screen'); }
18812277 async function applyAudioConstraints() {
1882- const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
1883- const t = sender && sender.track;
2278+ const t = App.state.micTrack;
18842279 if (!t) return;
18852280 const a = App.state.settings.audio;
18862281 try {
@@ -1895,14 +2290,13 @@ App.media = (() => {
18952290 }
18962291 }
18972292
1898- /* Live device switch: grab the new track, replaceTrack to it, then stop the
1899- old one. No off→on cycle, so no black-frame flicker for the remote peer
1900- and no period where the OS mic indicator drops. Returns the new deviceId
1901- on success, or null on failure (caller keeps prior selection). */
2293+ /* Live device switch: grab the new track, fan-out replaceTrack across all
2294+ peers, then stop the old one. No off→on cycle, so no black-frame flicker
2295+ for any peer and no period where the OS mic indicator drops. Returns the
2296+ new deviceId on success, or null on failure (caller keeps prior selection). */
19022297 async function switchInputDevice(kind, deviceId) {
1903- const tr = kind === 'mic' ? App.state.micTransceiver : App.state.camTransceiver;
1904- const sender = tr && tr.sender;
1905- if (!sender || !sender.track) return null;
2298+ const oldTrack = kind === 'mic' ? App.state.micTrack : App.state.camTrack;
2299+ if (!oldTrack) return null;
19062300 if (!gumAvailable()) return null;
19072301 const a = App.state.settings.audio;
19082302 const vs = App.state.settings.video;
@@ -1925,16 +2319,13 @@ App.media = (() => {
19252319 } };
19262320 stream = await navigator.mediaDevices.getUserMedia(constraints);
19272321 const newTrack = kind === 'mic' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0];
1928- if (!App.state.pc || App.state.pc.connectionState === 'closed') {
1929- newTrack.stop();
1930- throw new Error('call ended before device prompt resolved');
1931- }
1932- const oldTrack = sender.track;
1933- await sender.replaceTrack(newTrack);
1934- if (oldTrack && oldTrack !== newTrack) oldTrack.stop();
19352322 setLocalTrack(kind === 'mic' ? 'audio' : 'video', newTrack);
2323+ await fanOutTrack(
2324+ p => kind === 'mic' ? p.micTransceiver : p.camTransceiver,
2325+ newTrack, kind);
2326+ if (oldTrack !== newTrack) oldTrack.stop();
19362327 attachTrackEndedHandler(newTrack, kind);
1937- if (kind === 'cam') applyCamSendParams();
2328+ if (kind === 'cam') applyCamSendParamsAll();
19382329 mirrorToLoopback();
19392330 App.log.info('media', kind + ' switched', { requested: deviceId || '(default)', got: newTrack.getSettings ? newTrack.getSettings().deviceId : '?' });
19402331 return deviceId || '';
@@ -1947,12 +2338,15 @@ App.media = (() => {
19472338 }
19482339
19492340 return {
1950- preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference,
2341+ preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference, publishLocalTracksTo,
2342+ refreshRemoteDisplayFor, applyCamSendParamsFor, applyScreenSendParamsFor,
2343+ applyCamSendParamsAll, applyScreenSendParamsAll,
19512344 startScreenshare, stopScreenshare,
19522345 setMic, setCam,
19532346 switchInputDevice,
1954- applyCamSendParams, applyScreenSendParams, applyAudioConstraints,
2347+ applyAudioConstraints,
19552348 refreshLocalDisplay, refreshRemoteDisplay,
2349+ mirrorToLoopback,
19562350 };
19572351 })();
19582352
@@ -1965,72 +2359,184 @@ App.chat = (() => {
19652359 up to ~256 KB on the wire; checking bytes prevents that. */
19662360 const MAX_CHAT_MSG = 64 * 1024;
19672361 const MAX_TEXT = 8 * 1024;
2362+ const MAX_NAME = 64;
2363+ const ID_RE = /^[A-Za-z0-9_\-]{1,24}$/;
19682364 const utf8Length = s => new TextEncoder().encode(s).length;
1969- function attach(dc) {
2365+ function newMsgId() {
2366+ return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
2367+ }
2368+ /* Outgoing-message delivery tracking. msgId -> { recipients: Map<peerId,
2369+ 'sent'|'acked'|'failed'|'closed'>, text, ts, el }. Used to render the
2370+ per-recipient delivery popup when the user clicks one of their own
2371+ messages. */
2372+ const outMsgs = new Map();
2373+
2374+ /* Each peer's chat data channel is attached here. The peer arg is the
2375+ PeerCtx so we can tag incoming messages with the sender and update
2376+ per-peer media-state. */
2377+ function attach(peer, dc) {
19702378 dc.binaryType = 'arraybuffer';
19712379 dc.onopen = () => {
1972- App.log.info('chat', 'data channel open');
1973- appendSystem('connected');
1974- /* Push our current media state so the peer's UI matches reality from
1975- the moment the channel opens. */
1976- broadcastMediaState();
2380+ App.log.info('chat', peer.label, 'data channel open');
2381+ appendSystem(displayNameOf(peer) + ' connected');
2382+ /* Push our current media state + username so the peer's UI matches
2383+ reality from the moment the channel opens. */
2384+ sendMediaStateTo(peer);
2385+ sendUsernameTo(peer);
2386+ setInCallControlsEnabled();
19772387 };
1978- dc.onclose = () => App.log.info('chat', 'data channel closed');
1979- dc.onerror = e => App.log.error('chat', 'error', e.error ? e.error.message : e);
2388+ dc.onclose = () => { App.log.info('chat', peer.label, 'data channel closed'); setInCallControlsEnabled(); };
2389+ dc.onerror = e => App.log.error('chat', peer.label, 'error', e.error ? e.error.message : e);
19802390 dc.onmessage = e => {
1981- if (typeof e.data !== 'string') { App.log.warn('chat', 'binary payload rejected'); return; }
1982- /* Cheap UTF-16 prefilter — if the code-unit count already exceeds the
1983- byte cap, the byte count cannot be smaller, so skip the encode. */
1984- if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected', e.data.length); return; }
1985- if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected (bytes)'); return; }
2391+ if (typeof e.data !== 'string') { App.log.warn('chat', peer.label, 'binary payload rejected'); return; }
2392+ if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', peer.label, 'oversized message rejected', e.data.length); return; }
2393+ if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', peer.label, 'oversized message rejected (bytes)'); return; }
19862394 let m;
19872395 try { m = JSON.parse(e.data); }
1988- catch (err) { App.log.warn('chat', 'bad json', err.message); return; }
2396+ catch (err) { App.log.warn('chat', peer.label, 'bad json', err.message); return; }
19892397 if (!m || typeof m !== 'object' || typeof m.kind !== 'string') return;
19902398 if (m.kind === 'msg') {
19912399 if (typeof m.text !== 'string') return;
19922400 if (m.text.length > MAX_TEXT || utf8Length(m.text) > MAX_TEXT) return;
19932401 const ts = Number.isFinite(m.ts) ? m.ts : Date.now();
1994- append(false, m.text, ts);
2402+ append(false, m.text, ts, displayNameOf(peer));
2403+ /* Ack the message so the sender can mark this peer 'delivered'. */
2404+ if (typeof m.id === 'string' && ID_RE.test(m.id)) {
2405+ try { dc.send(JSON.stringify({ kind: 'msg-ack', id: m.id })); }
2406+ catch (_) {}
2407+ }
2408+ } else if (m.kind === 'msg-ack') {
2409+ if (typeof m.id !== 'string' || !ID_RE.test(m.id)) return;
2410+ const rec = outMsgs.get(m.id);
2411+ if (!rec) return;
2412+ const r = rec.recipients.get(peer.id);
2413+ if (r && r !== 'failed' && r !== 'closed') {
2414+ rec.recipients.set(peer.id, 'acked');
2415+ refreshMsgDetail(m.id);
2416+ }
19952417 } else if (m.kind === 'media-state') {
1996- App.state.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
1997- App.media.refreshRemoteDisplay();
2418+ peer.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
2419+ App.media.refreshRemoteDisplayFor(peer);
2420+ } else if (m.kind === 'username') {
2421+ if (typeof m.name !== 'string') return;
2422+ const cleaned = m.name.trim().slice(0, MAX_NAME) || 'Anonymous';
2423+ const old = displayNameOf(peer);
2424+ peer.username = cleaned;
2425+ if (old !== displayNameOf(peer)) {
2426+ App.tiles?.refreshLabel(peer);
2427+ App.ui.refreshPeerWidgets?.();
2428+ appendSystem(old + ' is now ' + displayNameOf(peer));
2429+ }
19982430 } else if (m.kind === 'bye') {
1999- onPeerHangup();
2431+ onPeerLeft(peer, 'remote');
20002432 }
20012433 /* unknown kinds: silently ignore */
20022434 };
20032435 }
2004- function send(text) {
2005- const dc = App.state.dcChat;
2006- if (!dc || dc.readyState !== 'open') { App.log.warn('chat', 'not open'); return; }
2007- const msg = { kind: 'msg', text, ts: Date.now() };
2008- dc.send(JSON.stringify(msg));
2009- append(true, text, msg.ts);
2436+ /* Fan out a chat message to every peer with an open chat channel (or the
2437+ subset given via opts.peers). The returned message id is also embedded
2438+ in the wire payload so receivers can ack it back. */
2439+ function send(text, opts) {
2440+ const peers = (opts && opts.peers) || (App.ui.selectedChatPeers ? App.ui.selectedChatPeers() : peersWithOpenChat());
2441+ if (!peers.length) { App.log.warn('chat', 'no recipients selected'); return; }
2442+ const id = newMsgId();
2443+ const msg = { kind: 'msg', id, text, ts: Date.now() };
2444+ const payload = JSON.stringify(msg);
2445+ const recipients = new Map();
2446+ for (const peer of peers) {
2447+ try {
2448+ peer.dcChat.send(payload);
2449+ recipients.set(peer.id, 'sent');
2450+ } catch (e) {
2451+ App.log.warn('chat', peer.label, 'send failed', e.message);
2452+ recipients.set(peer.id, 'failed');
2453+ }
2454+ }
2455+ const el = append(true, text, msg.ts, 'you', id);
2456+ outMsgs.set(id, { recipients, text, ts: msg.ts, el });
2457+ refreshMsgDetail(id);
20102458 }
20112459 /* Application-level hangup signal. Sent best-effort right before we tear
2012- the connection down so the peer can react instantly instead of waiting
2460+ a connection down so the peer can react instantly instead of waiting
20132461 for ICE consent freshness to time out (~10–30 s). Skipped in loopback
20142462 because pcB echoes chat messages back to pcA — a bye would round-trip
2015- and spuriously trigger the peer-left dialog on the local user. */
2016- function sendBye() {
2017- if (App.state.role === 'loopback') return;
2018- const dc = App.state.dcChat;
2463+ and spuriously trigger the peer-left handler on the local user. */
2464+ function sendByeTo(peer) {
2465+ if (peer.role === 'loopback') return;
2466+ const dc = peer.dcChat;
20192467 if (!dc || dc.readyState !== 'open') return;
20202468 try { dc.send(JSON.stringify({ kind: 'bye' })); } catch (_) {}
20212469 }
2022- function append(mine, text, ts) {
2470+ function sendByeAll() {
2471+ for (const peer of App.state.peers.values()) sendByeTo(peer);
2472+ }
2473+ function sendUsernameTo(peer) {
2474+ const dc = peer.dcChat;
2475+ if (!dc || dc.readyState !== 'open') return;
2476+ try { dc.send(JSON.stringify({ kind: 'username', name: App.state.username })); }
2477+ catch (e) { App.log.warn('chat', peer.label, 'username send failed', e.message); }
2478+ }
2479+ function broadcastUsername() {
2480+ for (const peer of peersWithOpenChat()) sendUsernameTo(peer);
2481+ }
2482+ function append(mine, text, ts, who, msgId) {
20232483 const log = document.getElementById('chat-log');
2484+ if (!log) return null;
20242485 const el = document.createElement('div');
2025- el.className = 'chat-msg' + (mine ? ' me' : '');
2026- el.innerHTML = '<div class="text"></div><div class="meta"></div>';
2486+ el.className = 'chat-msg' + (mine ? ' me' : '') + (mine && msgId ? ' has-detail' : '');
2487+ const meta = mine ? new Date(ts).toLocaleTimeString()
2488+ : (who || 'peer') + ' · ' + new Date(ts).toLocaleTimeString();
2489+ el.innerHTML = '<div class="text"></div><div class="meta"></div><div class="detail"></div>';
20272490 el.querySelector('.text').textContent = text;
2028- el.querySelector('.meta').textContent = new Date(ts).toLocaleTimeString();
2491+ el.querySelector('.meta').textContent = meta;
2492+ if (mine && msgId) {
2493+ el.dataset.msgId = msgId;
2494+ el.addEventListener('click', () => el.classList.toggle('open'));
2495+ }
20292496 log.appendChild(el);
20302497 log.scrollTop = log.scrollHeight;
2498+ return el;
2499+ }
2500+ /* Re-render the per-recipient delivery detail block for one outgoing msg. */
2501+ function refreshMsgDetail(id) {
2502+ const rec = outMsgs.get(id);
2503+ if (!rec || !rec.el) return;
2504+ const detail = rec.el.querySelector('.detail');
2505+ if (!detail) return;
2506+ const totals = { acked: 0, sent: 0, failed: 0, closed: 0 };
2507+ const rows = [];
2508+ for (const [peerId, state] of rec.recipients) {
2509+ totals[state] = (totals[state] || 0) + 1;
2510+ const peer = App.state.peers.get(peerId);
2511+ const name = peer ? displayNameOf(peer) : ('peer ' + peerId.slice(0, 4));
2512+ rows.push(`<div class="row"><span>${escapeHtml(name)}</span><span class="state ${state}">${state}</span></div>`);
2513+ }
2514+ /* One-line summary appears in .meta after the timestamp. */
2515+ const meta = rec.el.querySelector('.meta');
2516+ if (meta) {
2517+ const total = rec.recipients.size;
2518+ const acked = totals.acked || 0;
2519+ const failed = (totals.failed || 0) + (totals.closed || 0);
2520+ let summary = ' · ' + acked + '/' + total + ' delivered';
2521+ if (failed) summary += ', ' + failed + ' failed';
2522+ meta.textContent = new Date(rec.ts).toLocaleTimeString() + summary;
2523+ }
2524+ detail.innerHTML = rows.join('');
2525+ }
2526+ /* Called from removePeer / cleanupForPeer to mark outstanding messages as
2527+ closed for that recipient. */
2528+ function notePeerClosed(peer) {
2529+ for (const [id, rec] of outMsgs) {
2530+ const r = rec.recipients.get(peer.id);
2531+ if (r === 'sent') {
2532+ rec.recipients.set(peer.id, 'closed');
2533+ refreshMsgDetail(id);
2534+ }
2535+ }
20312536 }
20322537 function appendSystem(text) {
20332538 const log = document.getElementById('chat-log');
2539+ if (!log) return;
20342540 const el = document.createElement('div');
20352541 el.className = 'small';
20362542 el.style.textAlign = 'center';
@@ -2039,9 +2545,39 @@ App.chat = (() => {
20392545 log.appendChild(el);
20402546 log.scrollTop = log.scrollHeight;
20412547 }
2042- return { attach, send, sendBye, append, appendSystem, MAX_TEXT, utf8Length };
2548+ /* Wipe the chat log AND the delivery-tracking map. Each outMsg entry
2549+ pins its `.chat-msg` DOM node, so clearing only the log would still
2550+ leak the detached nodes through outMsgs. */
2551+ function clearAll() {
2552+ outMsgs.clear();
2553+ document.getElementById('chat-log')?.replaceChildren();
2554+ }
2555+ return {
2556+ attach, send, sendByeTo, sendByeAll, broadcastUsername, sendUsernameTo,
2557+ append, appendSystem, notePeerClosed, clearAll, MAX_TEXT, utf8Length,
2558+ };
20432559 })();
20442560
2561+/* Display name for a peer, with a short id suffix when colliding with
2562+ ours or with another peer (e.g. multiple Anonymous users). */
2563+function displayNameOf(peer) {
2564+ const base = (peer.username || 'Anonymous').trim() || 'Anonymous';
2565+ /* Disambiguate when this name is shared with anyone else (other peers OR
2566+ ourselves). Suffix is taken from the peer's id. */
2567+ const collides = (App.state.username || '').trim() === base
2568+ || peerList().some(p => p !== peer && (p.username || '').trim() === base);
2569+ return collides ? base + ' #' + peer.id.slice(0, 4) : base;
2570+}
2571+
2572+/* Push the local media-state to one peer's chat dc. */
2573+function sendMediaStateTo(peer) {
2574+ const dc = peer.dcChat;
2575+ if (!dc || dc.readyState !== 'open') return;
2576+ const state = { kind: 'media-state', ...localMediaState() };
2577+ try { dc.send(JSON.stringify(state)); }
2578+ catch (e) { App.log.warn('media', peer.label, 'state send failed', e.message); }
2579+}
2580+
20452581 /* -------------------------------------------------------------------------
20462582 Files: chunked transfer with backpressure
20472583 ------------------------------------------------------------------------- */
@@ -2049,22 +2585,32 @@ App.files = (() => {
20492585 const CHUNK = 16 * 1024;
20502586 const HIGH_WATER = 1024 * 1024; /* 1 MB */
20512587 const LOW_WATER = 256 * 1024;
2052- const incoming = new Map(); /* id -> { name, size, mime, received, chunks: [] } */
2053- const outQueue = []; /* [{ id, file }] — pending sends */
2054- let outBusy = false;
2055- /* Outgoing cancellation: ids in this set cause doSend's loop to bail
2056- between chunks and emit a file-abort to the peer. Receiver-initiated
2057- cancels arrive as a file-cancel ctl and get folded into this set. */
2058- const abortedOut = new Set();
2059- let currentSendId = null;
2060-
2061- function attach(dc) {
2588+ /* Per-peer receive state. Key = peer.id + '/' + fileId so two peers can
2589+ simultaneously send files with the same id without colliding. */
2590+ const incoming = new Map(); /* "<peerId>/<id>" -> { peer, id, name, size, mime, received, chunks } */
2591+ /* Per-peer send state. Each peer has its own queue, current send, abort
2592+ set, since each peer's dcFiles has its own backpressure and lifecycle. */
2593+ /* peer -> { queue: [{ id, file }], busy: bool, currentId: string|null,
2594+ aborted: Set<id>, pending: Map<id, resolveFn> } */
2595+ const sendState = new WeakMap();
2596+
2597+ function stateFor(peer) {
2598+ let s = sendState.get(peer);
2599+ if (!s) {
2600+ s = { queue: [], busy: false, currentId: null, aborted: new Set(), pending: new Map() };
2601+ sendState.set(peer, s);
2602+ }
2603+ return s;
2604+ }
2605+ function incKey(peer, id) { return peer.id + '/' + id; }
2606+
2607+ function attach(peer, dc) {
20622608 dc.binaryType = 'arraybuffer';
20632609 dc.bufferedAmountLowThreshold = LOW_WATER;
2064- dc.onopen = () => App.log.info('files', 'data channel open');
2065- dc.onclose = () => App.log.info('files', 'data channel closed');
2066- dc.onerror = e => App.log.error('files', 'error', e.error ? e.error.message : e);
2067- dc.onmessage = e => onMessage(e.data);
2610+ dc.onopen = () => { App.log.info('files', peer.label, 'data channel open'); setInCallControlsEnabled(); };
2611+ dc.onclose = () => { App.log.info('files', peer.label, 'data channel closed'); setInCallControlsEnabled(); };
2612+ dc.onerror = e => App.log.error('files', peer.label, 'error', e.error ? e.error.message : e);
2613+ dc.onmessage = e => onMessage(peer, e.data);
20682614 }
20692615
20702616 const MAX_CTRL = 8 * 1024;
@@ -2074,278 +2620,581 @@ App.files = (() => {
20742620 const ID_RE = /^[A-Za-z0-9_\-]{1,16}$/;
20752621 function validId(id) { return typeof id === 'string' && ID_RE.test(id); }
20762622
2077- function onMessage(data) {
2623+ async function onMessage(peer, data) {
20782624 if (typeof data === 'string') {
2079- if (data.length > MAX_CTRL) { App.log.warn('files', 'ctrl too large'); return; }
2625+ if (data.length > MAX_CTRL) { App.log.warn('files', peer.label, 'ctrl too large'); return; }
20802626 /* Tighten to byte-size so a non-ASCII file name can't bypass the cap. */
2081- if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', 'ctrl too large (bytes)'); return; }
2082- let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', 'bad ctl', e.message); return; }
2627+ if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', peer.label, 'ctrl too large (bytes)'); return; }
2628+ let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', peer.label, 'bad ctl', e.message); return; }
20832629 if (!m || typeof m !== 'object' || typeof m.kind !== 'string' || !validId(m.id)) return;
2630+ const key = incKey(peer, m.id);
20842631 if (m.kind === 'file-start') {
2085- if (incoming.has(m.id)) { App.log.warn('files', 'duplicate file-start ignored', m.id); return; }
2632+ if (incoming.has(key)) { App.log.warn('files', peer.label, 'duplicate file-start ignored', m.id); return; }
20862633 const size = Number(m.size);
2087- if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', 'invalid size', m.size); return; }
2634+ if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', peer.label, 'invalid size', m.size); return; }
20882635 const name = typeof m.name === 'string' ? m.name.slice(0, MAX_NAME) : 'file';
20892636 const mime = typeof m.mime === 'string' ? m.mime.slice(0, MAX_MIME) : '';
2090- incoming.set(m.id, { name, size, mime, received: 0, chunks: [] });
2091- addIncomingRow(m.id, name, size);
2092- App.log.info('files', 'incoming start', name, size);
2637+ /* Park the transfer as a pending request — the sender holds its chunks
2638+ until we reply file-accept/file-deny. mode/writer are filled in on
2639+ accept; chunks is only used by the in-memory fallback. */
2640+ incoming.set(key, { peer, id: m.id, name, size, mime, received: 0,
2641+ accepted: false, mode: null, writer: null,
2642+ writeChain: null, writeFailed: false, chunks: null });
2643+ addIncomingRequestRow(key, name, size, peer);
2644+ App.log.info('files', peer.label, 'incoming request', name, size);
2645+ } else if (m.kind === 'file-accept' || m.kind === 'file-deny') {
2646+ /* Sender side: the recipient answered our offer; release doSend's wait. */
2647+ const p = stateFor(peer).pending.get(m.id);
2648+ if (p) p(m.kind === 'file-accept' ? 'accept' : 'deny');
20932649 } else if (m.kind === 'file-end') {
2094- const f = incoming.get(m.id);
2095- if (!f) return;
2096- const blob = new Blob(f.chunks, { type: f.mime || 'application/octet-stream' });
2097- finishIncoming(m.id, blob, f.name);
2098- incoming.delete(m.id);
2099- App.log.info('files', 'incoming done', f.name);
2650+ const f = incoming.get(key);
2651+ if (!f || !f.accepted) return;
2652+ if (f.mode === 'disk') {
2653+ try { await f.writeChain; await f.writer.close(); markIncomingSaved(key); }
2654+ catch (e) { App.log.error('files', peer.label, 'finalizing failed', e.message); abortIncomingRow(key); }
2655+ } else {
2656+ finishIncoming(key, new Blob(f.chunks, { type: f.mime || 'application/octet-stream' }), f.name);
2657+ }
2658+ incoming.delete(key);
2659+ App.log.info('files', peer.label, 'incoming done', f.name);
2660+ /* Confirm receipt so the sender can mark this recipient delivered. */
2661+ try { peer.dcFiles.send(JSON.stringify({ kind: 'file-received', id: m.id })); }
2662+ catch (_) {}
2663+ } else if (m.kind === 'file-received') {
2664+ markOutgoingPerRecipient(m.id, peer, 'delivered');
21002665 } else if (m.kind === 'file-abort') {
2101- const f = incoming.get(m.id);
2666+ const f = incoming.get(key);
21022667 if (f) {
2103- App.log.warn('files', 'incoming aborted', f.name);
2104- incoming.delete(m.id);
2105- abortIncomingRow(m.id);
2668+ App.log.warn('files', peer.label, 'incoming aborted', f.name);
2669+ abortWriter(f);
2670+ incoming.delete(key);
2671+ abortIncomingRow(key);
21062672 }
21072673 } else if (m.kind === 'file-cancel') {
2108- /* Receiver-initiated cancel: stop sending if this id is in-flight
2109- or queued. The doSend loop will see abortedOut and bail. */
2110- const qi = outQueue.findIndex(q => q.id === m.id);
2674+ /* Receiver-initiated cancel: stop sending if this id is queued, awaiting
2675+ acceptance, or in-flight for THIS peer. Other peers' sends of the same
2676+ id (a fan-out to multiple recipients) are unaffected. */
2677+ const s = stateFor(peer);
2678+ const qi = s.queue.findIndex(q => q.id === m.id);
21112679 if (qi >= 0) {
2112- outQueue.splice(qi, 1);
2113- abortOutgoingRow(m.id, 'cancelled by peer');
2114- } else if (currentSendId === m.id) {
2115- abortedOut.add(m.id);
2116- App.log.info('files', 'send cancelled by peer', m.id);
2680+ s.queue.splice(qi, 1);
2681+ markOutgoingPerRecipient(m.id, peer, 'cancelled by peer');
2682+ } else if (s.currentId === m.id) {
2683+ s.aborted.add(m.id);
2684+ const p = s.pending.get(m.id);
2685+ if (p) p('cancelled');
2686+ App.log.info('files', peer.label, 'send cancelled by peer', m.id);
21172687 }
2118- /* If id is unknown (already finished or never started), ignore. */
21192688 }
21202689 /* unknown kinds: silently ignore */
21212690 } else {
21222691 /* Binary: first 16 bytes ASCII id (right-padded), then payload. */
21232692 const view = new Uint8Array(data);
21242693 if (view.byteLength <= 16) return;
2125- /* The sender pads ids to 16 ASCII bytes with '_', and the *padded* form
2126- is what's used as the Map key. So leave underscores in place — only
2127- strip NULs and whitespace (defensive). */
21282694 const idStr = new TextDecoder().decode(view.slice(0, 16)).replace(/\0+$/, '').trim();
21292695 if (!validId(idStr)) return;
21302696 const payload = view.slice(16);
2131- const f = incoming.get(idStr);
2132- if (!f) return;
2697+ const key = incKey(peer, idStr);
2698+ const f = incoming.get(key);
2699+ /* Drop chunks for unknown / not-yet-accepted transfers — a well-behaved
2700+ sender never sends before file-accept. */
2701+ if (!f || !f.accepted) return;
21332702 /* A peer claiming size N must not be able to push more than N bytes —
2134- otherwise it can OOM the tab by chunking unbounded data under one id. */
2703+ otherwise it can OOM the tab (memory mode) or fill the disk. Tell the
2704+ sender to stop and discard whatever we've written. */
21352705 if (f.received + payload.byteLength > f.size) {
2136- App.log.warn('files', 'chunk overflows declared size, aborting', idStr);
2137- incoming.delete(idStr);
2138- abortIncomingRow(idStr);
2706+ App.log.warn('files', peer.label, 'chunk overflows declared size, aborting', idStr);
2707+ try { peer.dcFiles.send(JSON.stringify({ kind: 'file-cancel', id: f.id })); } catch (_) {}
2708+ abortWriter(f);
2709+ incoming.delete(key);
2710+ abortIncomingRow(key);
21392711 return;
21402712 }
2141- f.chunks.push(payload);
21422713 f.received += payload.byteLength;
2143- updateIncomingRow(idStr, f.received, f.size);
2714+ if (f.mode === 'disk') {
2715+ /* Stream straight to disk. Serialize writes through a promise chain so
2716+ they land in order (the channel delivers in order); a single write
2717+ failure cancels the whole transfer. */
2718+ const writer = f.writer;
2719+ f.writeChain = f.writeChain.then(() => f.writeFailed ? null : writer.write(payload)).catch(err => {
2720+ if (f.writeFailed) return;
2721+ f.writeFailed = true;
2722+ App.log.error('files', peer.label, 'disk write failed', err.message);
2723+ try { peer.dcFiles.send(JSON.stringify({ kind: 'file-cancel', id: f.id })); } catch (_) {}
2724+ abortWriter(f);
2725+ incoming.delete(key);
2726+ abortIncomingRow(key);
2727+ });
2728+ } else {
2729+ f.chunks.push(payload);
2730+ }
2731+ updateIncomingRow(key, f.received, f.size);
21442732 }
21452733 }
21462734
2147- function sendFile(file) {
2148- const dc = App.state.dcFiles;
2149- if (!dc || dc.readyState !== 'open') { App.log.warn('files', 'channel not open'); return; }
2735+ /* Receiver accepts an offered file: pick a destination and stream to it.
2736+ Falls back to in-memory buffering + a download link where the File System
2737+ Access API is unavailable (e.g. Firefox). */
2738+ async function acceptIncoming(key) {
2739+ const f = incoming.get(key);
2740+ if (!f || f.accepted) return;
2741+ if (window.showSaveFilePicker) {
2742+ let handle;
2743+ try {
2744+ handle = await window.showSaveFilePicker({ suggestedName: f.name });
2745+ } catch (e) {
2746+ /* AbortError = user dismissed the picker; treat as a deny. */
2747+ if (e && e.name !== 'AbortError') App.log.warn('files', f.peer.label, 'save picker failed', e.message);
2748+ denyIncoming(key);
2749+ return;
2750+ }
2751+ try {
2752+ f.writer = await handle.createWritable();
2753+ } catch (e) {
2754+ App.log.error('files', f.peer.label, 'could not open file for writing', e.message);
2755+ denyIncoming(key);
2756+ return;
2757+ }
2758+ f.mode = 'disk';
2759+ f.writeChain = Promise.resolve();
2760+ } else {
2761+ f.mode = 'memory';
2762+ f.chunks = [];
2763+ }
2764+ f.accepted = true;
2765+ /* Swap the request row for the normal progress row, then tell the sender. */
2766+ removeIncomingRowEl(key);
2767+ addIncomingRow(key, f.name, f.size, f.peer);
2768+ try { f.peer.dcFiles.send(JSON.stringify({ kind: 'file-accept', id: f.id })); }
2769+ catch (e) { App.log.warn('files', f.peer.label, 'file-accept send failed', e.message); }
2770+ App.log.info('files', f.peer.label, 'accepted', f.name, '(' + f.mode + ')');
2771+ }
2772+
2773+ function denyIncoming(key) {
2774+ const f = incoming.get(key);
2775+ if (!f) return;
2776+ try { f.peer.dcFiles.send(JSON.stringify({ kind: 'file-deny', id: f.id })); }
2777+ catch (_) {}
2778+ abortWriter(f);
2779+ incoming.delete(key);
2780+ removeIncomingRowEl(key);
2781+ App.log.info('files', f.peer.label, 'declined', f.name);
2782+ }
2783+
2784+ /* Abort and discard a half-written destination file. Idempotent. */
2785+ function abortWriter(rec) {
2786+ if (!rec || !rec.writer || rec.aborting) return;
2787+ rec.aborting = true;
2788+ const w = rec.writer;
2789+ Promise.resolve(rec.writeChain).catch(() => {}).then(() => w.abort().catch(() => {}));
2790+ }
2791+
2792+ /* Fan a file out to every selected peer (or to `opts.peers` if given).
2793+ One Blob is shared across the per-peer queues. */
2794+ function sendFile(file, opts) {
2795+ const recipients = (opts && opts.peers)
2796+ || (App.ui.selectedFilesPeers ? App.ui.selectedFilesPeers() : peersWithOpenFiles());
2797+ if (!recipients.length) { App.log.warn('files', 'no recipients selected'); return; }
21502798 if (file.size > MAX_FILE) {
21512799 App.log.warn('files', 'file too large', file.size);
21522800 App.chat.appendSystem?.(`File "${file.name}" exceeds the 5 GB limit.`);
21532801 return;
21542802 }
21552803 const id = (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)).padEnd(16, '_').slice(0, 16);
2156- addOutgoingRow(id, file.name, file.size);
2157- outQueue.push({ id, file });
2158- if (outQueue.length > 1 || outBusy) markQueued('#files-out', id);
2159- pumpQueue();
2804+ addOutgoingRow(id, file.name, file.size, recipients);
2805+ for (const peer of recipients) {
2806+ const s = stateFor(peer);
2807+ s.queue.push({ id, file });
2808+ if (s.queue.length > 1 || s.busy) markRecipientQueued(id, peer);
2809+ pumpQueue(peer);
2810+ }
21602811 }
21612812
2162- async function pumpQueue() {
2163- if (outBusy) return;
2164- const next = outQueue.shift();
2813+ async function pumpQueue(peer) {
2814+ const s = stateFor(peer);
2815+ if (s.busy) return;
2816+ const next = s.queue.shift();
21652817 if (!next) return;
2166- outBusy = true;
2818+ s.busy = true;
21672819 try {
2168- await doSend(next.id, next.file);
2820+ await doSend(peer, next.id, next.file);
21692821 } finally {
2170- outBusy = false;
2171- pumpQueue();
2822+ s.busy = false;
2823+ pumpQueue(peer);
21722824 }
21732825 }
21742826
2175- async function doSend(id, file) {
2176- const dc = App.state.dcFiles;
2827+ /* Wait for the recipient's file-accept/file-deny. Resolves 'accept', 'deny',
2828+ 'cancelled' (we aborted / peer cancelled), or 'closed' (channel went away),
2829+ so the send loop never hangs waiting on a peer who never answers. */
2830+ function waitForDecision(s, dc, id) {
2831+ return new Promise(resolve => {
2832+ let settled = false;
2833+ const finish = v => {
2834+ if (settled) return;
2835+ settled = true;
2836+ s.pending.delete(id);
2837+ dc.removeEventListener('close', onGone);
2838+ dc.removeEventListener('error', onGone);
2839+ resolve(v);
2840+ };
2841+ const onGone = () => finish('closed');
2842+ if (s.aborted.has(id)) return finish('cancelled');
2843+ if (dc.readyState !== 'open') return finish('closed');
2844+ s.pending.set(id, finish);
2845+ dc.addEventListener('close', onGone);
2846+ dc.addEventListener('error', onGone);
2847+ });
2848+ }
2849+
2850+ async function doSend(peer, id, file) {
2851+ const s = stateFor(peer);
2852+ const dc = peer.dcFiles;
21772853 if (!dc || dc.readyState !== 'open') {
2178- App.log.warn('files', 'channel closed before send', file.name);
2179- abortOutgoingRow(id, 'channel closed');
2180- abortedOut.delete(id);
2854+ App.log.warn('files', peer.label, 'channel closed before send', file.name);
2855+ markOutgoingPerRecipient(id, peer, 'channel closed');
2856+ s.aborted.delete(id);
21812857 return;
21822858 }
2183- if (abortedOut.has(id)) {
2184- /* Cancelled while still queued — never went on the wire. */
2185- abortOutgoingRow(id, 'cancelled');
2186- abortedOut.delete(id);
2859+ if (s.aborted.has(id)) {
2860+ markOutgoingPerRecipient(id, peer, 'cancelled');
2861+ s.aborted.delete(id);
21872862 return;
21882863 }
2189- clearQueuedMark('#files-out', id);
2190- currentSendId = id;
2864+ s.currentId = id;
21912865 const start = { kind: 'file-start', id, name: file.name, size: file.size, mime: file.type };
21922866 dc.send(JSON.stringify(start));
2193- App.log.info('files', 'sending', file.name, file.size);
2867+ markOutgoingPerRecipient(id, peer, 'awaiting acceptance');
2868+ App.log.info('files', peer.label, 'offered', file.name, file.size);
2869+
2870+ /* Hold the chunks until the recipient accepts (or declines / disconnects /
2871+ we abort). The receiver opens its save target before answering. */
2872+ const decision = await waitForDecision(s, dc, id);
2873+ if (decision !== 'accept') {
2874+ const label = decision === 'deny' ? 'declined'
2875+ : decision === 'closed' ? 'channel closed' : 'cancelled';
2876+ markOutgoingPerRecipient(id, peer, label);
2877+ App.log.info('files', peer.label, decision === 'deny' ? 'recipient declined' : 'send ended before accept', file.name);
2878+ s.aborted.delete(id);
2879+ s.currentId = null;
2880+ return;
2881+ }
2882+ markOutgoingPerRecipient(id, peer, 'sending');
2883+ App.log.info('files', peer.label, 'sending', file.name, file.size);
21942884
21952885 const idBytes = new TextEncoder().encode(id);
21962886 let offset = 0;
21972887 let cancelled = false;
21982888 try {
21992889 while (offset < file.size) {
2200- if (abortedOut.has(id)) { cancelled = true; break; }
2890+ if (s.aborted.has(id)) { cancelled = true; break; }
22012891 if (dc.bufferedAmount > HIGH_WATER) {
2892+ /* Wait for backpressure to clear — but also wake up if the channel
2893+ closes (peer disconnected, removePeer closed the dc, browser
2894+ tore it down). A closed RTCDataChannel never fires
2895+ bufferedamountlow, so without the close/error listeners this
2896+ promise could hang forever and freeze the per-peer queue. */
22022897 await new Promise(res => {
2203- const h = () => { dc.removeEventListener('bufferedamountlow', h); res(); };
2204- dc.addEventListener('bufferedamountlow', h);
2898+ const done = () => {
2899+ dc.removeEventListener('bufferedamountlow', done);
2900+ dc.removeEventListener('close', done);
2901+ dc.removeEventListener('error', done);
2902+ res();
2903+ };
2904+ dc.addEventListener('bufferedamountlow', done);
2905+ dc.addEventListener('close', done);
2906+ dc.addEventListener('error', done);
22052907 });
2206- if (abortedOut.has(id)) { cancelled = true; break; }
2908+ if (dc.readyState !== 'open') {
2909+ App.log.warn('files', peer.label, 'channel closed during backpressure wait');
2910+ cancelled = true; break;
2911+ }
2912+ if (s.aborted.has(id)) { cancelled = true; break; }
22072913 }
22082914 const slice = await file.slice(offset, offset + CHUNK).arrayBuffer();
2209- if (abortedOut.has(id)) { cancelled = true; break; }
2915+ if (s.aborted.has(id)) { cancelled = true; break; }
22102916 const buf = new Uint8Array(16 + slice.byteLength);
22112917 buf.set(idBytes, 0);
22122918 buf.set(new Uint8Array(slice), 16);
22132919 dc.send(buf.buffer);
22142920 offset += slice.byteLength;
2215- updateOutgoingRow(id, offset, file.size);
2921+ updateOutgoingProgress(id, peer, offset, file.size);
22162922 }
22172923 if (cancelled) {
22182924 try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2219- abortOutgoingRow(id, 'cancelled');
2220- App.log.info('files', 'send cancelled', file.name);
2925+ markOutgoingPerRecipient(id, peer, 'cancelled');
2926+ App.log.info('files', peer.label, 'send cancelled', file.name);
22212927 } else {
22222928 dc.send(JSON.stringify({ kind: 'file-end', id }));
2223- finishOutgoingRow(id);
2224- App.log.info('files', 'sent', file.name);
2929+ markOutgoingPerRecipient(id, peer, 'sent');
2930+ App.log.info('files', peer.label, 'sent', file.name);
22252931 }
22262932 } catch (e) {
2227- App.log.error('files', 'send failed', e.message);
2933+ App.log.error('files', peer.label, 'send failed', e.message);
22282934 try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
2229- abortOutgoingRow(id, 'send failed');
2935+ markOutgoingPerRecipient(id, peer, 'send failed');
22302936 } finally {
2231- currentSendId = null;
2232- abortedOut.delete(id);
2937+ s.currentId = null;
2938+ s.aborted.delete(id);
22332939 }
22342940 }
22352941
22362942 /* UI row helpers */
22372943 /* Track blob URLs for incoming finished downloads so Clear can revoke them. */
2238- const incomingUrls = new Map(); /* id -> objectURL string */
2944+ const incomingUrls = new Map(); /* key (peerId/id) -> objectURL string */
22392945
2240- function rowEl(side, id, name, size) {
2946+ /* Outgoing row tracks per-recipient state in dataset JSON. The summary
2947+ line shows aggregated progress (min %) + a count of {recipients done}. */
2948+ /* perRecipient[peerId] = { state: 'queued'|'sending'|'sent'|'cancelled'|'channel closed'|'send failed', sent: number } */
2949+ const outgoingMeta = new Map(); /* outId -> { name, size, perRecipient: Map<peerId, ...> } */
2950+
2951+ function rowEl(side, key, name, size) {
22412952 const el = document.createElement('div');
2242- el.className = 'file-item';
2243- el.dataset.id = id;
2953+ el.className = 'file-item' + (side === 'out' ? ' has-detail' : '');
2954+ el.dataset.id = key;
22442955 el.innerHTML = `<button class="row-close" type="button" title="Cancel / remove" aria-label="Cancel or remove">×</button>
22452956 <div class="name"></div>
2246- <div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%)</div>
2957+ <div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%) <span class="recip"></span></div>
22472958 <div class="progress"><div></div></div>
2248- <div class="dl"></div>`;
2959+ <div class="dl"></div>
2960+ <div class="detail"></div>`;
22492961 el.querySelector('.name').textContent = name;
22502962 el.querySelector('.total').textContent = size.toLocaleString();
2251- el.querySelector('.row-close').addEventListener('click', () => removeRow(side, id));
2963+ el.querySelector('.row-close').addEventListener('click', e => {
2964+ e.stopPropagation();
2965+ removeRow(side, key);
2966+ });
2967+ if (side === 'out') {
2968+ el.addEventListener('click', e => {
2969+ if (e.target.closest('.row-close')) return;
2970+ el.classList.toggle('open');
2971+ });
2972+ }
22522973 return el;
22532974 }
2254- function removeRow(side, id) {
2975+ function removeRow(side, key) {
22552976 const sel = side === 'in' ? '#files-in' : '#files-out';
2256- const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2977+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
22572978 if (row) row.remove();
22582979 if (side === 'in') {
2259- /* If the transfer is still arriving, ask the sender to stop. */
2260- if (incoming.has(id)) {
2261- const dc = App.state.dcFiles;
2980+ const rec = incoming.get(key);
2981+ if (rec) {
2982+ /* Tell the sender to stop: file-deny while still a pending request,
2983+ file-cancel once we've accepted and chunks may be arriving. */
2984+ const dc = rec.peer.dcFiles;
22622985 if (dc && dc.readyState === 'open') {
2263- try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
2986+ const kind = rec.accepted ? 'file-cancel' : 'file-deny';
2987+ try { dc.send(JSON.stringify({ kind, id: rec.id })); } catch (_) {}
22642988 }
2989+ abortWriter(rec);
22652990 }
2266- const url = incomingUrls.get(id);
2267- if (url) { URL.revokeObjectURL(url); incomingUrls.delete(id); }
2268- incoming.delete(id);
2991+ const url = incomingUrls.get(key);
2992+ if (url) { URL.revokeObjectURL(url); incomingUrls.delete(key); }
2993+ incoming.delete(key);
22692994 } else {
2270- const i = outQueue.findIndex(q => q.id === id);
2271- if (i >= 0) outQueue.splice(i, 1);
2272- /* If it's the in-flight send, flag it for cancellation. The doSend
2273- loop will pick up the flag, emit file-abort, and clear it. */
2274- if (currentSendId === id) abortedOut.add(id);
2995+ /* outgoing: cancel across every recipient that still has this id. */
2996+ outgoingMeta.delete(key);
2997+ for (const peer of App.state.peers.values()) {
2998+ const s = stateFor(peer);
2999+ const i = s.queue.findIndex(q => q.id === key);
3000+ if (i >= 0) s.queue.splice(i, 1);
3001+ if (s.currentId === key) {
3002+ s.aborted.add(key);
3003+ /* If we're parked awaiting acceptance, release that wait too. */
3004+ const p = s.pending.get(key);
3005+ if (p) p('cancelled');
3006+ }
3007+ }
22753008 }
22763009 }
22773010 function clearAll(side) {
22783011 const sel = side === 'in' ? '#files-in' : '#files-out';
22793012 document.querySelectorAll(sel + ' .file-item').forEach(el => el.remove());
22803013 if (side === 'in') {
2281- /* Tell the peer to stop for any in-progress receives. */
2282- const dc = App.state.dcFiles;
2283- if (dc && dc.readyState === 'open') {
2284- incoming.forEach((_f, id) => {
2285- try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
2286- });
2287- }
3014+ /* Tell each peer to stop for any pending requests / in-progress receives. */
3015+ incoming.forEach((rec, _key) => {
3016+ const dc = rec.peer.dcFiles;
3017+ if (dc && dc.readyState === 'open') {
3018+ const kind = rec.accepted ? 'file-cancel' : 'file-deny';
3019+ try { dc.send(JSON.stringify({ kind, id: rec.id })); } catch (_) {}
3020+ }
3021+ abortWriter(rec);
3022+ });
22883023 incomingUrls.forEach(url => URL.revokeObjectURL(url));
22893024 incomingUrls.clear();
22903025 incoming.clear();
22913026 } else {
2292- outQueue.length = 0;
2293- /* Cancel the in-flight send too, if any. */
2294- if (currentSendId) abortedOut.add(currentSendId);
3027+ outgoingMeta.clear();
3028+ for (const peer of App.state.peers.values()) {
3029+ const s = stateFor(peer);
3030+ s.queue.length = 0;
3031+ if (s.currentId) s.aborted.add(s.currentId);
3032+ }
22953033 }
22963034 }
2297- function addOutgoingRow(id, name, size) {
2298- document.getElementById('files-out').appendChild(rowEl('out', id, name, size));
2299- }
2300- function updateOutgoingRow(id, sent, size) { updateRow('#files-out', id, sent, size); }
2301- function finishOutgoingRow(id) { markDone('#files-out', id); }
2302- function abortOutgoingRow(id, reason) {
3035+ function addOutgoingRow(id, name, size, recipients) {
3036+ const row = rowEl('out', id, name, size);
3037+ document.getElementById('files-out').appendChild(row);
3038+ const perRecipient = new Map();
3039+ for (const peer of recipients) perRecipient.set(peer.id, { state: 'queued', sent: 0 });
3040+ outgoingMeta.set(id, { name, size, perRecipient });
3041+ refreshOutgoingRow(id);
3042+ }
3043+ function refreshOutgoingRow(id) {
3044+ const meta = outgoingMeta.get(id);
3045+ if (!meta) return;
23033046 const row = document.querySelector('#files-out [data-id="' + CSS.escape(id) + '"]');
2304- if (row) row.querySelector('.dl').textContent = '(' + (reason || 'aborted') + ')';
2305- }
2306- function markQueued(sel, id) {
2307- const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2308- if (row) row.querySelector('.dl').textContent = 'queued';
3047+ if (!row) return;
3048+ const recips = Array.from(meta.perRecipient.entries());
3049+ const minSent = recips.reduce((m, [, r]) => Math.min(m, r.sent), Infinity);
3050+ const pct = meta.size ? Math.floor((minSent / meta.size) * 100) : 0;
3051+ row.querySelector('.bytes').textContent = (Number.isFinite(minSent) ? minSent : 0).toLocaleString();
3052+ row.querySelector('.pct').textContent = pct;
3053+ row.querySelector('.progress > div').style.width = pct + '%';
3054+ const doneStates = new Set(['sent', 'delivered']);
3055+ const failStates = new Set(['channel closed', 'send failed', 'cancelled', 'cancelled by peer', 'declined']);
3056+ const delivered = recips.filter(([, r]) => r.state === 'delivered').length;
3057+ const total = recips.length;
3058+ const failed = recips.filter(([, r]) => failStates.has(r.state)).length;
3059+ let recipText = ' · ' + delivered + '/' + total + ' delivered';
3060+ if (failed) recipText += ', ' + failed + ' failed';
3061+ row.querySelector('.recip').textContent = recipText;
3062+ const allDone = recips.every(([, r]) => doneStates.has(r.state) || failStates.has(r.state));
3063+ if (allDone && failed === 0) row.querySelector('.progress > div').style.background = 'var(--ok)';
3064+ /* Per-recipient detail block — only visible when the user clicks the row. */
3065+ const detail = row.querySelector('.detail');
3066+ if (detail) {
3067+ const STATE_CLASS = {
3068+ 'queued': 'queued', 'awaiting acceptance': 'queued', 'sending': 'sending',
3069+ 'sent': 'sent', 'delivered': 'delivered',
3070+ 'channel closed': 'closed', 'send failed': 'failed',
3071+ 'cancelled': 'cancelled', 'cancelled by peer': 'cancelled', 'declined': 'cancelled',
3072+ };
3073+ detail.innerHTML = recips.map(([peerId, r]) => {
3074+ const peer = App.state.peers.get(peerId);
3075+ const name = peer ? displayNameOf(peer) : ('peer ' + peerId.slice(0, 4));
3076+ const sizePct = meta.size ? Math.floor((r.sent / meta.size) * 100) : 0;
3077+ const stateClass = STATE_CLASS[r.state] || '';
3078+ const stateText = r.state === 'sending' ? r.state + ' ' + sizePct + '%' : r.state;
3079+ return `<div class="row"><span>${escapeHtml(name)}</span><span class="state ${stateClass}">${stateText}</span></div>`;
3080+ }).join('');
3081+ }
23093082 }
2310- function clearQueuedMark(sel, id) {
2311- const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
2312- if (row && row.querySelector('.dl').textContent === 'queued') row.querySelector('.dl').textContent = '';
3083+ function updateOutgoingProgress(id, peer, sent, _size) {
3084+ const meta = outgoingMeta.get(id);
3085+ if (!meta) return;
3086+ const r = meta.perRecipient.get(peer.id);
3087+ if (!r) return;
3088+ r.state = 'sending';
3089+ r.sent = sent;
3090+ refreshOutgoingRow(id);
3091+ }
3092+ function markOutgoingPerRecipient(id, peer, state) {
3093+ const meta = outgoingMeta.get(id);
3094+ if (!meta) return;
3095+ const r = meta.perRecipient.get(peer.id);
3096+ if (!r) return;
3097+ r.state = state;
3098+ refreshOutgoingRow(id);
3099+ }
3100+ function markRecipientQueued(id, peer) {
3101+ const meta = outgoingMeta.get(id);
3102+ if (!meta) return;
3103+ const r = meta.perRecipient.get(peer.id);
3104+ if (r) { r.state = 'queued'; refreshOutgoingRow(id); }
3105+ }
3106+ function addIncomingRow(key, name, size, peer) {
3107+ const row = rowEl('in', key, name, size);
3108+ row.querySelector('.recip').textContent = ' · from ' + displayNameOf(peer);
3109+ document.getElementById('files-in').appendChild(row);
3110+ }
3111+ /* The pending-request row: name + size + Accept/Deny, shown until the user
3112+ decides. Accept opens a save target and streams to it; Deny tells the
3113+ sender to drop the transfer. */
3114+ function addIncomingRequestRow(key, name, size, peer) {
3115+ const el = document.createElement('div');
3116+ el.className = 'file-item file-request';
3117+ el.dataset.id = key;
3118+ el.innerHTML = `<button class="row-close" type="button" title="Decline" aria-label="Decline">×</button>
3119+ <div class="name"></div>
3120+ <div class="meta"><span class="total"></span> B · from <span class="from"></span></div>
3121+ <div class="req-actions">
3122+ <button class="btn-accept" type="button">Accept</button>
3123+ <button class="btn-deny" type="button">Deny</button>
3124+ </div>`;
3125+ el.querySelector('.name').textContent = name;
3126+ el.querySelector('.total').textContent = size.toLocaleString();
3127+ el.querySelector('.from').textContent = displayNameOf(peer);
3128+ el.querySelector('.row-close').addEventListener('click', e => { e.stopPropagation(); denyIncoming(key); });
3129+ el.querySelector('.btn-deny').addEventListener('click', () => denyIncoming(key));
3130+ el.querySelector('.btn-accept').addEventListener('click', () => acceptIncoming(key));
3131+ document.getElementById('files-in').appendChild(el);
3132+ }
3133+ function removeIncomingRowEl(key) {
3134+ const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
3135+ if (row) row.remove();
23133136 }
2314- function addIncomingRow(id, name, size) {
2315- document.getElementById('files-in').appendChild(rowEl('in', id, name, size));
3137+ function markIncomingSaved(key) {
3138+ const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
3139+ if (!row) return;
3140+ const dl = row.querySelector('.dl');
3141+ if (dl) { dl.textContent = 'Saved to disk'; dl.style.color = 'var(--ok)'; }
3142+ markDone('#files-in', key);
23163143 }
2317- function updateIncomingRow(id, recv, size) { updateRow('#files-in', id, recv, size); }
2318- function abortIncomingRow(id) {
2319- const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
3144+ function updateIncomingRow(key, recv, size) { updateRow('#files-in', key, recv, size); }
3145+ function abortIncomingRow(key) {
3146+ const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
23203147 if (row) row.querySelector('.dl').textContent = '(aborted)';
23213148 }
2322- function finishIncoming(id, blob, name) {
2323- const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
3149+ function finishIncoming(key, blob, name) {
3150+ const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
23243151 if (!row) return;
23253152 const url = URL.createObjectURL(blob);
2326- incomingUrls.set(id, url);
3153+ incomingUrls.set(key, url);
23273154 const a = document.createElement('a');
23283155 a.href = url; a.download = name; a.textContent = 'Download';
23293156 a.style.color = 'var(--accent)';
23303157 row.querySelector('.dl').innerHTML = '';
23313158 row.querySelector('.dl').appendChild(a);
2332- markDone('#files-in', id);
3159+ markDone('#files-in', key);
23333160 }
2334- function updateRow(sel, id, cur, size) {
2335- const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
3161+ function updateRow(sel, key, cur, size) {
3162+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
23363163 if (!row) return;
23373164 const pct = size ? Math.floor((cur / size) * 100) : 0;
23383165 row.querySelector('.bytes').textContent = cur.toLocaleString();
23393166 row.querySelector('.pct').textContent = pct;
23403167 row.querySelector('.progress > div').style.width = pct + '%';
23413168 }
2342- function markDone(sel, id) {
2343- const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
3169+ function markDone(sel, key) {
3170+ const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
23443171 if (!row) return;
23453172 row.querySelector('.progress > div').style.background = 'var(--ok)';
23463173 }
23473174
2348- return { attach, sendFile, clearAll, MAX_FILE };
3175+ /* Called when a peer disconnects — mark any in-flight incoming receives
3176+ from that peer as aborted, and any outgoing sends to that peer as
3177+ channel-closed. The per-peer queues are abandoned along with the peer. */
3178+ function cleanupForPeer(peer) {
3179+ for (const [key, rec] of incoming) {
3180+ if (rec.peer === peer) {
3181+ abortWriter(rec);
3182+ abortIncomingRow(key);
3183+ incoming.delete(key);
3184+ }
3185+ }
3186+ for (const meta of outgoingMeta.values()) {
3187+ const r = meta.perRecipient.get(peer.id);
3188+ if (r && r.state !== 'sent') {
3189+ r.state = 'channel closed';
3190+ }
3191+ }
3192+ /* Refresh every outgoing row's recipient summary. */
3193+ for (const id of outgoingMeta.keys()) refreshOutgoingRow(id);
3194+ sendState.delete(peer);
3195+ }
3196+
3197+ return { attach, sendFile, clearAll, cleanupForPeer, MAX_FILE };
23493198 })();
23503199
23513200 /* -------------------------------------------------------------------------
@@ -2379,12 +3228,30 @@ App.stats = (() => {
23793228 return ((bytes - p.bytes) * 8 * 1000) / (ts - p.ts);
23803229 }
23813230
3231+ /* Peer dropdown drives which connection we poll. Falls back to the first
3232+ live peer if the dropdown is empty / out of sync. */
3233+ function selectedPeer() {
3234+ const sel = document.getElementById('stats-peer');
3235+ const id = sel && sel.value;
3236+ if (id) {
3237+ const peer = App.state.peers.get(id);
3238+ if (peer) return peer;
3239+ }
3240+ return App.state.peers.values().next().value || null;
3241+ }
23823242 async function tick() {
2383- if (!App.state.pc) return;
2384- const stats = await App.state.pc.getStats();
3243+ const peer = selectedPeer();
23853244 const tbody = document.querySelector('#stats-table tbody');
3245+ if (!peer || !peer.pc) {
3246+ /* No peer selected: empty the table so stale values from a peer we
3247+ just disconnected from don't linger. */
3248+ if (tbody) tbody.innerHTML = '';
3249+ prev.clear();
3250+ return;
3251+ }
3252+ const stats = await peer.pc.getStats();
23863253 if (!tbody) return;
2387- const rows = collect(stats);
3254+ const rows = collect(peer, stats);
23883255 /* Drop prev entries for stat ids that no longer appear in this report,
23893256 so the cache doesn't grow unboundedly across long calls where the
23903257 browser internally rotates RTP stream ids. */
@@ -2395,24 +3262,28 @@ App.stats = (() => {
23953262 `<tr><td>${escapeHtml(r.k)}</td><td>${escapeHtml(String(r.v))}</td></tr>`).join('');
23963263 }
23973264
2398- function collect(stats) {
3265+ function collect(peer, stats) {
23993266 const out = [];
3267+ /* Single pass over the report: index every record by id (for candidate /
3268+ codec lookups), capture the transport's selected pair id, and collect the
3269+ candidate-pair and RTP records we'll emit. Avoids re-scanning the report
3270+ several times per tick. */
3271+ const byId = new Map();
3272+ const candidatePairs = [];
3273+ const rtpRecords = [];
24003274 let selected = null;
24013275 stats.forEach(r => {
2402- if (r.type === 'transport') {
2403- if (r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
2404- }
2405- });
2406- let pair = null, localCand = null, remoteCand = null;
2407- stats.forEach(r => {
2408- if (r.type === 'candidate-pair' && (r.nominated || r.selected || r.id === selected) && r.state === 'succeeded')
2409- pair = r;
3276+ byId.set(r.id, r);
3277+ if (r.type === 'transport' && r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
3278+ else if (r.type === 'candidate-pair') candidatePairs.push(r);
3279+ else if ((r.type === 'outbound-rtp' || r.type === 'inbound-rtp') && !r.isRemote) rtpRecords.push(r);
24103280 });
3281+ let localCand = null, remoteCand = null;
3282+ const pair = candidatePairs.find(r =>
3283+ (r.nominated || r.selected || r.id === selected) && r.state === 'succeeded') || null;
24113284 if (pair) {
2412- stats.forEach(r => {
2413- if (r.id === pair.localCandidateId) localCand = r;
2414- if (r.id === pair.remoteCandidateId) remoteCand = r;
2415- });
3285+ localCand = byId.get(pair.localCandidateId) || null;
3286+ remoteCand = byId.get(pair.remoteCandidateId) || null;
24163287 out.push({ k: 'rtt (ms)', v: pair.currentRoundTripTime ? Math.round(pair.currentRoundTripTime * 1000) : '—' });
24173288 out.push({ k: 'bytes sent', v: fmtBytes(pair.bytesSent) });
24183289 out.push({ k: 'bytes received', v: fmtBytes(pair.bytesReceived) });
@@ -2423,29 +3294,25 @@ App.stats = (() => {
24233294 /* outbound-rtp / inbound-rtp report kind='video' for both the cam and the
24243295 screen-share transceivers, so we need to disambiguate by mid. */
24253296 const midRole = new Map();
2426- if (App.state.micTransceiver && App.state.micTransceiver.mid != null) midRole.set(String(App.state.micTransceiver.mid), 'audio');
2427- if (App.state.camTransceiver && App.state.camTransceiver.mid != null) midRole.set(String(App.state.camTransceiver.mid), 'cam');
2428- if (App.state.screenTransceiver && App.state.screenTransceiver.mid != null) midRole.set(String(App.state.screenTransceiver.mid), 'screen');
3297+ if (peer.micTransceiver && peer.micTransceiver.mid != null) midRole.set(String(peer.micTransceiver.mid), 'audio');
3298+ if (peer.camTransceiver && peer.camTransceiver.mid != null) midRole.set(String(peer.camTransceiver.mid), 'cam');
3299+ if (peer.screenTransceiver && peer.screenTransceiver.mid != null) midRole.set(String(peer.screenTransceiver.mid), 'screen');
24293300 const roleOf = r => midRole.get(String(r.mid)) || r.kind;
2430- /* Pre-pass: build codecId → short codec label map so we can inline the
2431- active codec into each outbound/inbound row. The 'codec' records appear
2432- in arbitrary order relative to the rtp records, so we collect first. */
2433- const codecOf = new Map();
2434- stats.forEach(r => { if (r.type === 'codec') codecOf.set(r.id, r); });
3301+ /* codecId → short codec label, resolved from the byId index built above
3302+ (the 'codec' records appear in arbitrary order relative to rtp records). */
24353303 const codecLabel = id => {
2436- const c = codecOf.get(id);
3304+ const c = byId.get(id);
24373305 if (!c || !c.mimeType) return '';
24383306 return ' [' + c.mimeType.split('/')[1] + ']';
24393307 };
2440- stats.forEach(r => {
2441- if (r.type === 'outbound-rtp' && !r.isRemote) {
3308+ rtpRecords.forEach(r => {
3309+ if (r.type === 'outbound-rtp') {
24423310 const role = roleOf(r);
24433311 const br = bitrateFor(r.id, r.bytesSent || 0, r.timestamp);
24443312 const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
24453313 out.push({ k: `↑ ${role} sent${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesSent)} / ${r.packetsSent} pkts @ ${fmtBps(br)}${fps}` });
24463314 if (r.targetBitrate) out.push({ k: `↑ ${role} target br`, v: fmtBps(r.targetBitrate) });
2447- }
2448- if (r.type === 'inbound-rtp' && !r.isRemote) {
3315+ } else {
24493316 const role = roleOf(r);
24503317 const br = bitrateFor(r.id, r.bytesReceived || 0, r.timestamp);
24513318 const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
@@ -2463,17 +3330,23 @@ App.stats = (() => {
24633330 tick();
24643331 }
24653332 function stop() { if (timer) { clearInterval(timer); timer = null; } prev.clear(); }
3333+ /* Called from the peer-select onchange so the next tick treats the new
3334+ peer as a fresh poll instead of inheriting stale bitrate deltas. */
3335+ function refreshNow() { prev.clear(); tick(); }
24663336
24673337 async function exportAll() {
2468- if (!App.state.pc) return;
2469- const stats = await App.state.pc.getStats();
3338+ const peer = selectedPeer();
3339+ if (!peer || !peer.pc) return;
3340+ const stats = await peer.pc.getStats();
24703341 const arr = [];
24713342 stats.forEach(r => arr.push(r));
24723343 const blob = new Blob([JSON.stringify(arr, null, 2)], { type: 'application/json' });
3344+ const url = URL.createObjectURL(blob);
24733345 const a = document.createElement('a');
2474- a.href = URL.createObjectURL(blob);
3346+ a.href = url;
24753347 a.download = 'webrtc-stats-' + Date.now() + '.json';
2476- a.click();
3348+ document.body.appendChild(a); a.click(); a.remove();
3349+ URL.revokeObjectURL(url);
24773350 }
24783351
24793352 function toggleConsoleStats() {
@@ -2482,22 +3355,254 @@ App.stats = (() => {
24823355 App.log.info('stats', 'console poll stopped');
24833356 } else {
24843357 consoleTimer = setInterval(async () => {
2485- if (!App.state.pc) return;
2486- const stats = await App.state.pc.getStats();
2487- const rows = collect(stats);
2488- App.log.debug('stats', rows.map(r => r.k + '=' + r.v).join(' | '));
3358+ const peer = selectedPeer();
3359+ if (!peer || !peer.pc) return;
3360+ const stats = await peer.pc.getStats();
3361+ const rows = collect(peer, stats);
3362+ App.log.debug('stats', peer.label, rows.map(r => r.k + '=' + r.v).join(' | '));
24893363 }, 2000);
24903364 App.log.info('stats', 'console poll started (2s)');
24913365 }
24923366 }
24933367
2494- return { start, stop, exportAll, toggleConsoleStats };
3368+ return { start, stop, exportAll, toggleConsoleStats, refreshNow };
24953369 })();
24963370
24973371 function escapeHtml(s) {
24983372 return s.replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]);
24993373 }
25003374
3375+/* -------------------------------------------------------------------------
3376+ Per-peer tile factory. Each tile owns: a main video, a PIP video (for cam
3377+ when a screen share is in the main slot), a dedicated audio element so
3378+ audio plays even when the tile is in its empty state, a mic-muted badge,
3379+ a leave button, a label, and a connection-state pill.
3380+------------------------------------------------------------------------- */
3381+App.tiles = (() => {
3382+ const EMPTY_SVG = '<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-label="no video"><path d="M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
3383+ const MIC_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="muted"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/></svg>';
3384+
3385+ /* --- Conference layout ---
3386+ Pick the rows × cols decomposition that maximises tile area for the
3387+ current container, with each tile constrained to a 16:9 box. Then size
3388+ the grid template + each tile explicitly so the browser doesn't second-
3389+ guess us. Re-runs on ResizeObserver and on every tile add/remove. */
3390+ const TILE_RATIO = 16 / 9;
3391+ const GAP = 8;
3392+ const PAD = 12;
3393+ function bestGrid(n, W, H) {
3394+ let best = { rows: 1, cols: n, tileW: 0, tileH: 0, area: 0 };
3395+ for (let rows = 1; rows <= n; rows++) {
3396+ const cols = Math.ceil(n / rows);
3397+ const cellW = (W - (cols - 1) * GAP) / cols;
3398+ const cellH = (H - (rows - 1) * GAP) / rows;
3399+ if (cellW <= 0 || cellH <= 0) continue;
3400+ let tileW = cellW, tileH = cellW / TILE_RATIO;
3401+ if (tileH > cellH) { tileH = cellH; tileW = cellH * TILE_RATIO; }
3402+ const area = tileW * tileH;
3403+ if (area > best.area) best = { rows, cols, tileW, tileH, area };
3404+ }
3405+ return best;
3406+ }
3407+ function relayout() {
3408+ const area = document.getElementById('video-area');
3409+ if (!area) return;
3410+ /* Skip while something is fullscreen — re-parenting the row containers
3411+ (which we do below) detaches the fullscreened <video> from the
3412+ document, and browsers immediately exit fullscreen when their target
3413+ element leaves the DOM. A fullscreenchange listener re-runs us when
3414+ the user exits. */
3415+ if (document.fullscreenElement) return;
3416+ /* Gather every tile, whether it's currently a direct child of the area
3417+ (newly added) or already inside a .video-row from a previous relayout. */
3418+ const tiles = Array.from(area.querySelectorAll('.video-tile'));
3419+ const n = tiles.length;
3420+ if (!n) return;
3421+ const innerW = Math.max(0, area.clientWidth - PAD * 2);
3422+ const innerH = Math.max(0, area.clientHeight - PAD * 2);
3423+ if (innerW === 0 || innerH === 0) return;
3424+ /* The flex column adds (rows-1) gaps vertically; the rowEl adds (perRow-1)
3425+ gaps horizontally. bestGrid already accounts for that. */
3426+ const { rows, cols, tileW, tileH } = bestGrid(n, innerW, innerH);
3427+ /* Remove any old row wrappers (but keep tile DOM identity so videos
3428+ and event listeners aren't recreated). */
3429+ area.querySelectorAll(':scope > .video-row').forEach(r => r.remove());
3430+ for (const t of tiles) {
3431+ t.style.width = tileW + 'px';
3432+ t.style.height = '100%';
3433+ }
3434+ for (let r = 0; r < rows; r++) {
3435+ const rowEl = document.createElement('div');
3436+ rowEl.className = 'video-row';
3437+ rowEl.style.height = tileH + 'px';
3438+ const start = r * cols;
3439+ const end = Math.min(n, start + cols);
3440+ for (let i = start; i < end; i++) rowEl.appendChild(tiles[i]);
3441+ area.appendChild(rowEl);
3442+ }
3443+ }
3444+ let _ro = null;
3445+ let _fsBound = false;
3446+ function ensureObserver() {
3447+ if (_ro) return;
3448+ const area = document.getElementById('video-area');
3449+ if (!area || typeof ResizeObserver !== 'function') return;
3450+ _ro = new ResizeObserver(() => relayout());
3451+ _ro.observe(area);
3452+ /* Re-run layout after the user exits fullscreen — the observer fired
3453+ during entry and we skipped it, so the post-exit shape needs catching
3454+ up. Bound once for the page lifetime. */
3455+ if (!_fsBound) {
3456+ document.addEventListener('fullscreenchange', () => {
3457+ if (!document.fullscreenElement) relayout();
3458+ });
3459+ _fsBound = true;
3460+ }
3461+ }
3462+ function teardownObserver() {
3463+ if (!_ro) return;
3464+ try { _ro.disconnect(); } catch (_) {}
3465+ _ro = null;
3466+ }
3467+
3468+ function add(peer) {
3469+ if (document.getElementById('tile-' + peer.id)) return;
3470+ const area = document.getElementById('video-area');
3471+ if (!area) return;
3472+ const tile = document.createElement('div');
3473+ tile.className = 'video-tile empty';
3474+ tile.id = 'tile-' + peer.id;
3475+ tile.innerHTML = `
3476+ <video class="vid-main" autoplay playsinline></video>
3477+ <audio class="audio-remote" autoplay></audio>
3478+ <div class="pip hidden">
3479+ <video class="vid-pip" autoplay playsinline></video>
3480+ </div>
3481+ <div class="empty-state">${EMPTY_SVG}</div>
3482+ <div class="mic-muted hidden" title="microphone muted">${MIC_SVG}</div>
3483+ <button class="leave-peer" title="Disconnect from this peer" aria-label="Disconnect from this peer">×</button>
3484+ <span class="conn-pill new">connecting</span>
3485+ <span class="tile-label"></span>`;
3486+ area.appendChild(tile);
3487+ refreshLabel(peer);
3488+ refreshConnState(peer);
3489+ tile.querySelector('.leave-peer').addEventListener('click', e => {
3490+ e.stopPropagation();
3491+ removePeer(peer, { sendBye: true });
3492+ });
3493+ /* Click anywhere on a tile that's showing a screen share → fullscreen the
3494+ main video, matching the previous remote-tile fullscreen behaviour. */
3495+ tile.addEventListener('click', e => {
3496+ if (!tile.classList.contains('screen')) return;
3497+ if (e.target.closest('.pip')) return;
3498+ if (e.target.closest('.leave-peer')) return;
3499+ const video = tile.querySelector(':scope > .vid-main');
3500+ if (!video) return;
3501+ if (document.fullscreenElement) {
3502+ (document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
3503+ .catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
3504+ } else {
3505+ (video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
3506+ .catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
3507+ }
3508+ });
3509+ App.media.refreshRemoteDisplayFor(peer);
3510+ ensureObserver();
3511+ relayout();
3512+ }
3513+ function remove(peer) {
3514+ const tile = document.getElementById('tile-' + peer.id);
3515+ if (tile) tile.remove();
3516+ relayout();
3517+ }
3518+ function refreshLabel(peer) {
3519+ const tile = document.getElementById('tile-' + peer.id);
3520+ if (!tile) return;
3521+ const label = tile.querySelector('.tile-label');
3522+ if (label) label.textContent = displayNameOf(peer);
3523+ /* Re-run displayNameOf for every peer in case the disambiguation suffix
3524+ changed because of this peer's username update. */
3525+ for (const other of App.state.peers.values()) {
3526+ if (other === peer) continue;
3527+ const t = document.getElementById('tile-' + other.id);
3528+ const l = t && t.querySelector('.tile-label');
3529+ if (l) l.textContent = displayNameOf(other);
3530+ }
3531+ }
3532+ function refreshConnState(peer) {
3533+ const tile = document.getElementById('tile-' + peer.id);
3534+ if (!tile) return;
3535+ const pill = tile.querySelector('.conn-pill');
3536+ if (!pill) return;
3537+ const st = (peer.pc && peer.pc.connectionState) || 'closed';
3538+ pill.textContent = st;
3539+ pill.className = 'conn-pill ' + st;
3540+ }
3541+ function refreshAll() {
3542+ for (const peer of App.state.peers.values()) {
3543+ if (!document.getElementById('tile-' + peer.id)) add(peer);
3544+ refreshLabel(peer);
3545+ refreshConnState(peer);
3546+ App.media.refreshRemoteDisplayFor(peer);
3547+ }
3548+ ensureObserver();
3549+ relayout();
3550+ }
3551+ return { add, remove, refreshLabel, refreshConnState, refreshAll, relayout, teardownObserver };
3552+})();
3553+
3554+/* -------------------------------------------------------------------------
3555+ Empty-call banner — shown when no peers are connected, dismissed forever
3556+ (per session) on first successful join.
3557+------------------------------------------------------------------------- */
3558+App.banner = (() => {
3559+ function refresh() {
3560+ const el = document.getElementById('call-banner');
3561+ if (!el) return;
3562+ const noPeers = App.state.peers.size === 0;
3563+ const show = noPeers && !App.state.bannerDismissed;
3564+ el.classList.toggle('hidden', !show);
3565+ }
3566+ return { refresh };
3567+})();
3568+
3569+/* Tear down ONE peer's connection without leaving the call view. */
3570+function removePeer(peer, opts) {
3571+ if (!peer) return;
3572+ const sendBye = !opts || opts.sendBye !== false;
3573+ if (sendBye) App.chat.sendByeTo(peer);
3574+ if (peer.signalAbort) { try { peer.signalAbort.abort(); } catch (_) {} peer.signalAbort = null; }
3575+ try { if (peer.dcChat) peer.dcChat.close(); } catch (_) {}
3576+ try { if (peer.dcFiles) peer.dcFiles.close(); } catch (_) {}
3577+ try { if (peer.pc) peer.pc.close(); } catch (_) {}
3578+ /* Tear down this peer's loopback pcB, if any. */
3579+ if (peer.loopbackB) {
3580+ try { peer.loopbackB.close(); } catch (_) {}
3581+ peer.loopbackB = null;
3582+ }
3583+ App.files.cleanupForPeer(peer);
3584+ App.chat.notePeerClosed?.(peer);
3585+ App.tiles.remove(peer);
3586+ App.state.peers.delete(peer.id);
3587+ if (App.state.pendingPeer === peer) App.state.pendingPeer = null;
3588+ App.chat.appendSystem?.(displayNameOf(peer) + ' left');
3589+ updateConnPill();
3590+ App.banner.refresh();
3591+ setInCallControlsEnabled();
3592+ App.ui.refreshPeerWidgets?.();
3593+}
3594+
3595+/* The remote side sent a 'bye', or its pc transitioned to a terminal state.
3596+ Same path as user-initiated removePeer, but suppresses bye fan-back to
3597+ avoid a round-trip on already-dead channels. */
3598+function onPeerLeft(peer, reason) {
3599+ if (!App.state.peers.has(peer.id)) return; /* already gone */
3600+ if (peer.leaveReceived) return;
3601+ peer.leaveReceived = true;
3602+ App.log.info('app', peer.label, 'peer left', reason || '');
3603+ removePeer(peer, { sendBye: false });
3604+}
3605+
25013606 /* -------------------------------------------------------------------------
25023607 PeerConnection creation + event wiring
25033608 ------------------------------------------------------------------------- */
@@ -2521,25 +3626,35 @@ function maybeWarnObfuscation(c) {
25213626 ' • Chromium: chrome://flags/#enable-webrtc-hide-local-ips-with-mdns → Disabled');
25223627 }
25233628
2524-function newPc(label) {
3629+/* Create the RTCPeerConnection for one PeerCtx, wiring all the lifecycle
3630+ handlers with that peer's identity baked in. */
3631+function newPeerPc(peer) {
25253632 const cfg = { iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 };
25263633 const pc = new RTCPeerConnection(cfg);
3634+ peer.pc = pc;
25273635 pc.addEventListener('icegatheringstatechange', () =>
2528- App.log.debug('pc', label, 'iceGatheringState', pc.iceGatheringState));
3636+ App.log.debug('pc', peer.label, 'iceGatheringState', pc.iceGatheringState));
25293637 pc.addEventListener('iceconnectionstatechange', () =>
2530- App.log.info('pc', label, 'iceConnectionState', pc.iceConnectionState));
3638+ App.log.info('pc', peer.label, 'iceConnectionState', pc.iceConnectionState));
25313639 pc.addEventListener('connectionstatechange', () => {
2532- App.log.info('pc', label, 'connectionState', pc.connectionState);
3640+ App.log.info('pc', peer.label, 'connectionState', pc.connectionState);
25333641 updateConnPill();
3642+ /* Tile state may need to swap an empty placeholder for a stalled badge. */
3643+ App.tiles?.refreshConnState(peer);
3644+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
3645+ /* Surface as a leave so the tile is cleaned up. The peer-initiated
3646+ 'bye' message gets there faster, but this is the backstop. */
3647+ onPeerLeft(peer, pc.connectionState);
3648+ }
25343649 });
25353650 pc.addEventListener('signalingstatechange', () =>
2536- App.log.debug('pc', label, 'signalingState', pc.signalingState));
3651+ App.log.debug('pc', peer.label, 'signalingState', pc.signalingState));
25373652 pc.addEventListener('icecandidate', e => {
25383653 if (e.candidate) maybeWarnObfuscation(e.candidate);
25393654 });
25403655 pc.addEventListener('icecandidateerror', e => {
25413656 const local = e.address ? (e.address + ':' + (e.port || '?')) : (e.hostCandidate || '?');
2542- App.log.warn('pc', label, 'iceCandidateError',
3657+ App.log.warn('pc', peer.label, 'iceCandidateError',
25433658 e.errorCode, e.errorText || '(no text)',
25443659 'server=' + (e.url || '(none)'),
25453660 'from=' + local);
@@ -2547,54 +3662,44 @@ function newPc(label) {
25473662 pc.addEventListener('negotiationneeded', () => {
25483663 /* All three m-sections (mic/cam/screen) are pre-allocated in preallocate()
25493664 with direction=sendrecv before the very first createOffer, so toggling
2550- a track via replaceTrack() never changes the SDP shape. The codec
2551- payload-types negotiated up front cover the encoder configurations we
2552- can reach via in-call settings (resolution, framerate, bitrate cap,
2553- channels). Therefore any negotiationneeded the browser fires is
2554- spurious for this app and safely ignored. */
2555- App.log.debug('pc', label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
3665+ a track via replaceTrack() never changes the SDP shape. Any
3666+ negotiationneeded the browser fires is spurious for this app and
3667+ safely ignored. */
3668+ App.log.debug('pc', peer.label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
25563669 });
25573670 pc.addEventListener('track', e => {
2558- App.log.info('pc', label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
2559- handleRemoteTrack(pc, e);
3671+ App.log.info('pc', peer.label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
3672+ handleRemoteTrack(peer, e);
25603673 });
25613674 return pc;
25623675 }
25633676
2564-/* Local media state derived from the actual sender tracks / screen stream. */
3677+/* Local media state derived from the live local tracks / screen stream. */
25653678 function localMediaState() {
25663679 return {
2567- mic: !!(App.state.micTransceiver && App.state.micTransceiver.sender.track),
2568- cam: !!(App.state.camTransceiver && App.state.camTransceiver.sender.track),
3680+ mic: !!App.state.micTrack,
3681+ cam: !!App.state.camTrack,
25693682 screen: !!App.state.screenStream,
25703683 };
25713684 }
2572-/* Tell the peer about our current mic/cam/screen state. Sent over the chat
2573- data channel as a typed JSON message — replaceTrack(null) doesn't itself
2574- propagate any signal across the wire, so the receiver would otherwise just
2575- see frozen video on the last frame. */
3685+/* Tell every connected peer about our current mic/cam/screen state. Sent
3686+ over each chat data channel as a typed JSON message — replaceTrack(null)
3687+ doesn't itself propagate any signal across the wire, so the receiver
3688+ would otherwise just see frozen video on the last frame. */
25763689 function broadcastMediaState() {
2577- const dc = App.state.dcChat;
2578- if (!dc || dc.readyState !== 'open') return;
2579- const state = { kind: 'media-state', ...localMediaState() };
2580- try { dc.send(JSON.stringify(state)); }
2581- catch (e) { App.log.warn('media', 'state send failed', e.message); }
3690+ for (const peer of App.state.peers.values()) sendMediaStateTo(peer);
25823691 }
25833692
2584-/* Rebuild the remote streams by inspecting the current receivers — defensive
2585- alternative to relying solely on the 'track' event, which can fire at
2586- slightly different points across browsers (especially on the offerer side
2587- where receivers existed before negotiation). */
25883693 /* Map a receiver-side transceiver to its role using the stored references
2589- from preallocate() / adoptTransceiversFromRemote(). Identity comparison
2590- is the only reliable cue — relying on indexOf-by-kind misroutes cam and
2591- screen when only one of them has live frames (track events fire out of
2592- order across browsers / offerer-vs-joiner roles). */
2593-function roleForTransceiver(t) {
3694+ on the PeerCtx from preallocate() / adoptTransceiversFromRemote(). Identity
3695+ comparison is the only reliable cue — relying on indexOf-by-kind misroutes
3696+ cam and screen when only one of them has live frames (track events fire
3697+ out of order across browsers / offerer-vs-joiner roles). */
3698+function roleForTransceiver(peer, t) {
25943699 if (!t) return null;
2595- if (t === App.state.micTransceiver) return 'mic';
2596- if (t === App.state.camTransceiver) return 'cam';
2597- if (t === App.state.screenTransceiver) return 'screen';
3700+ if (t === peer.micTransceiver) return 'mic';
3701+ if (t === peer.camTransceiver) return 'cam';
3702+ if (t === peer.screenTransceiver) return 'screen';
25983703 return null;
25993704 }
26003705
@@ -2602,11 +3707,12 @@ function roleForTransceiver(t) {
26023707 instead of leaking anonymous arrow closures over the lifetime of the page. */
26033708 const remoteTrackListeners = new WeakMap(); /* track -> { unmute, ended } */
26043709
2605-function attachRemoteTrackListeners(track, onEnded) {
3710+function attachRemoteTrackListeners(peer, track, onEnded) {
26063711 detachRemoteTrackListeners(track);
2607- const handlers = { unmute: App.media.refreshRemoteDisplay, ended: onEnded };
2608- track.addEventListener('unmute', handlers.unmute);
2609- track.addEventListener('ended', handlers.ended);
3712+ const refresh = () => App.media.refreshRemoteDisplayFor(peer);
3713+ const handlers = { unmute: refresh, ended: onEnded };
3714+ track.addEventListener('unmute', refresh);
3715+ track.addEventListener('ended', onEnded);
26103716 remoteTrackListeners.set(track, handlers);
26113717 }
26123718 function detachRemoteTrackListeners(track) {
@@ -2617,13 +3723,13 @@ function detachRemoteTrackListeners(track) {
26173723 remoteTrackListeners.delete(track);
26183724 }
26193725
2620-function rebuildRemoteStreams(pc) {
2621- if (!pc) return;
3726+function rebuildRemoteStreamsFor(peer) {
3727+ if (!peer || !peer.pc) return;
26223728 let audio = null, cam = null, screen = null;
2623- for (const t of pc.getTransceivers()) {
3729+ for (const t of peer.pc.getTransceivers()) {
26243730 const tr = t.receiver && t.receiver.track;
26253731 if (!tr) continue;
2626- const role = roleForTransceiver(t);
3732+ const role = roleForTransceiver(peer, t);
26273733 if (role === 'mic' && !audio) audio = tr;
26283734 if (role === 'cam' && !cam) cam = tr;
26293735 if (role === 'screen' && !screen) screen = tr;
@@ -2631,148 +3737,183 @@ function rebuildRemoteStreams(pc) {
26313737 const remote = new MediaStream();
26323738 if (audio) remote.addTrack(audio);
26333739 if (cam) remote.addTrack(cam);
2634- App.state.remoteStream = remote;
3740+ peer.remoteStream = remote;
26353741 const remoteScreen = new MediaStream();
26363742 if (screen) remoteScreen.addTrack(screen);
2637- App.state.remoteScreenStream = remoteScreen;
2638- /* React when a so-far-muted track gets actual frames. */
2639- const camStreamRef = remote;
2640- const screenStreamRef = remoteScreen;
2641- if (audio) attachRemoteTrackListeners(audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplay(); });
2642- if (cam) attachRemoteTrackListeners(cam, () => { camStreamRef.removeTrack(cam); App.media.refreshRemoteDisplay(); });
2643- if (screen) attachRemoteTrackListeners(screen, () => { screenStreamRef.removeTrack(screen); App.media.refreshRemoteDisplay(); });
2644- App.log.info('pc', 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
2645- App.media.refreshRemoteDisplay();
3743+ peer.remoteScreenStream = remoteScreen;
3744+ if (audio) attachRemoteTrackListeners(peer, audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplayFor(peer); });
3745+ if (cam) attachRemoteTrackListeners(peer, cam, () => { remote.removeTrack(cam); App.media.refreshRemoteDisplayFor(peer); });
3746+ if (screen) attachRemoteTrackListeners(peer, screen, () => { remoteScreen.removeTrack(screen); App.media.refreshRemoteDisplayFor(peer); });
3747+ App.log.info('pc', peer.label, 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
3748+ App.media.refreshRemoteDisplayFor(peer);
26463749 }
26473750
2648-function handleRemoteTrack(pc, e) {
2649- /* In loopback, the second pc (pcB) also fires track events but those
2650- represent our own outbound tracks being received on the synthetic peer
2651- — they must not feed the visible "remote" tile. */
2652- if (pc !== App.state.pc) return;
2653- const role = roleForTransceiver(e.transceiver);
2654- App.log.info('pc', 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
3751+function handleRemoteTrack(peer, e) {
3752+ const role = roleForTransceiver(peer, e.transceiver);
3753+ App.log.info('pc', peer.label, 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
26553754 if (role === 'mic' || role === 'cam') {
2656- if (!App.state.remoteStream) App.state.remoteStream = new MediaStream();
2657- App.state.remoteStream.addTrack(e.track);
2658- attachRemoteTrackListeners(e.track, () => {
2659- if (App.state.remoteStream) App.state.remoteStream.removeTrack(e.track);
2660- App.media.refreshRemoteDisplay();
3755+ if (!peer.remoteStream) peer.remoteStream = new MediaStream();
3756+ peer.remoteStream.addTrack(e.track);
3757+ attachRemoteTrackListeners(peer, e.track, () => {
3758+ if (peer.remoteStream) peer.remoteStream.removeTrack(e.track);
3759+ App.media.refreshRemoteDisplayFor(peer);
26613760 });
2662- App.media.refreshRemoteDisplay();
3761+ App.media.refreshRemoteDisplayFor(peer);
26633762 } else if (role === 'screen') {
2664- if (!App.state.remoteScreenStream) App.state.remoteScreenStream = new MediaStream();
2665- App.state.remoteScreenStream.addTrack(e.track);
2666- attachRemoteTrackListeners(e.track, () => {
2667- if (App.state.remoteScreenStream) App.state.remoteScreenStream.removeTrack(e.track);
2668- App.media.refreshRemoteDisplay();
3763+ if (!peer.remoteScreenStream) peer.remoteScreenStream = new MediaStream();
3764+ peer.remoteScreenStream.addTrack(e.track);
3765+ attachRemoteTrackListeners(peer, e.track, () => {
3766+ if (peer.remoteScreenStream) peer.remoteScreenStream.removeTrack(e.track);
3767+ App.media.refreshRemoteDisplayFor(peer);
26693768 });
2670- App.media.refreshRemoteDisplay();
3769+ App.media.refreshRemoteDisplayFor(peer);
26713770 }
26723771 }
26733772
3773+/* Connection pill summarizes the worst-of state across all peers. */
26743774 function updateConnPill() {
2675- const pc = App.state.pc;
26763775 const pill = document.getElementById('conn-pill');
2677- if (!pc) { pill.textContent = 'disconnected'; pill.className = 'pill'; return; }
2678- const st = pc.connectionState;
2679- pill.textContent = st;
3776+ if (!pill) return;
3777+ const peers = peerList();
3778+ if (!peers.length) { pill.textContent = 'no peers'; pill.className = 'pill'; return; }
3779+ const states = peers.map(p => p.pc ? p.pc.connectionState : 'closed');
3780+ const worst = states.includes('failed') ? 'failed'
3781+ : states.includes('disconnected') ? 'disconnected'
3782+ : states.includes('closed') ? 'closed'
3783+ : states.includes('connecting') || states.includes('new') ? 'connecting'
3784+ : 'connected';
3785+ const label = peers.length === 1 ? worst : worst + ' (' + peers.length + ')';
3786+ pill.textContent = label;
26803787 pill.className = 'pill ' + (
2681- st === 'connected' ? 'ok' :
2682- st === 'connecting' || st === 'new' ? 'warn' :
3788+ worst === 'connected' ? 'ok' :
3789+ worst === 'connecting' || worst === 'new' ? 'warn' :
26833790 'err'
26843791 );
26853792 }
26863793
26873794 /* -------------------------------------------------------------------------
26883795 Signaling flows: initiator, joiner, loopback
3796+
3797+ Each invocation builds a fresh PeerCtx, registers it in App.state.peers,
3798+ and runs the chosen handshake against that peer alone. Local media,
3799+ chat/files UI, etc. are shared across all peers — only the per-peer
3800+ pc/dc/transceivers/streams live on the ctx.
26893801 ------------------------------------------------------------------------- */
26903802 /* Invoked when the user clicks Cancel in the step modal. Tears down the
2691- in-flight setup (closes pc, aborts any long-poll fetch) and bounces back
2692- to the welcome view. The setup function will see signalAbort.aborted and
2693- throw 'cancelled', which cfg-continue's catch silently swallows. */
3803+ in-flight setup (closes pc, aborts any long-poll fetch). Leaves any
3804+ already-connected peers alone — only the pending peer is dropped. */
26943805 function cancelSetup() {
26953806 App.log.info('app', 'setup cancelled by user');
26963807 App.state.userCancelled = true;
26973808 App.progress.hide();
2698- hangup({ sendBye: false });
3809+ const pending = App.state.pendingPeer;
3810+ App.state.pendingPeer = null;
3811+ if (pending) removePeer(pending, { sendBye: false });
3812+}
3813+
3814+/* Create + register a PeerCtx, wire its pc, return it. */
3815+function beginPeer(opts) {
3816+ const peer = newPeerCtx({
3817+ id: opts.id || generatePeerId(),
3818+ role: opts.role,
3819+ label: opts.label,
3820+ username: opts.username || 'unknown',
3821+ });
3822+ /* Seed per-peer defaults from current settings — these are setParameters-
3823+ tunable later via the per-peer settings panel. */
3824+ const s = App.state.settings;
3825+ peer.videoMaxBitrateKbps = s.video.maxBitrateKbps;
3826+ peer.videoDegradationPreference = s.video.degradationPreference;
3827+ peer.screenMaxBitrateKbps = s.screen.maxBitrateKbps;
3828+ peer.screenDegradationPreference = s.screen.degradationPreference;
3829+ peer.sendVideoCodec = s.sendVideoCodec || 'auto';
3830+ newPeerPc(peer);
3831+ App.state.peers.set(peer.id, peer);
3832+ App.state.pendingPeer = peer;
3833+ return peer;
3834+}
3835+
3836+/* Promote pending → committed: invoked once the handshake reaches a point
3837+ where giving up would require cooperating with the remote side. */
3838+function commitPeer(peer) {
3839+ if (App.state.pendingPeer === peer) App.state.pendingPeer = null;
3840+ App.tiles?.add(peer);
3841+ App.state.bannerDismissed = true;
3842+ App.banner?.refresh();
3843+ updateConnPill();
3844+ App.ui.refreshPeerWidgets?.();
26993845 }
27003846
27013847 async function startInitiator() {
2702- const pc = newPc('A');
2703- App.state.pc = pc;
2704- App.media.preallocate(pc);
2705- App.media.applyVideoCodecPreference(pc);
3848+ const peer = beginPeer({ role: 'initiator', label: 'init' });
3849+ App.media.preallocate(peer);
3850+ App.media.applyVideoCodecPreference(peer);
27063851 const ac = new AbortController();
2707- App.state.signalAbort = ac;
3852+ peer.signalAbort = ac;
27083853
27093854 /* Data channels MUST be created on the initiator before createOffer
27103855 so they're included in the SDP m-section list. */
2711- App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
2712- App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
2713- App.chat.attach(App.state.dcChat);
2714- App.files.attach(App.state.dcFiles);
3856+ peer.dcChat = peer.pc.createDataChannel('chat', { ordered: true });
3857+ peer.dcFiles = peer.pc.createDataChannel('files', { ordered: true });
3858+ App.chat.attach(peer, peer.dcChat);
3859+ App.files.attach(peer, peer.dcFiles);
27153860
27163861 App.progress.show('Creating offer…', 'Negotiating local SDP.');
2717- let offer = await pc.createOffer();
3862+ let offer = await peer.pc.createOffer();
27183863 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2719- await pc.setLocalDescription(offer);
2720- App.log.info('signal', 'offer created, waiting for ICE gathering…');
3864+ await peer.pc.setLocalDescription(offer);
3865+ App.log.info('signal', peer.label, 'offer created, waiting for ICE gathering…');
27213866 App.progress.showModal('Gathering ICE candidates…',
27223867 iceGatheringSubtitle(),
27233868 { onCancel: cancelSetup });
2724- await App.signal.waitForIceComplete(pc, ac.signal);
3869+ await App.signal.waitForIceComplete(peer.pc, ac.signal);
27253870 if (ac.signal.aborted) throw new Error('cancelled');
2726- App.log.info('signal', 'ICE gathering complete; offer ready to export');
3871+ App.log.info('signal', peer.label, 'ICE gathering complete; offer ready to export');
27273872
27283873 App.progress.hide();
2729- renderInitiatorExchange();
3874+ renderInitiatorExchange(peer);
27303875 }
27313876
27323877 async function startJoiner() {
2733- const pc = newPc('A');
2734- App.state.pc = pc;
2735-
2736- pc.ondatachannel = e => {
2737- /* If the user picked a new role mid-flight, App.state.pc may already be
2738- a different connection. Late events from the previous pc would
2739- otherwise overwrite the current dcChat/dcFiles with a closed channel. */
2740- if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
2741- App.log.info('signal', 'incoming data channel', e.channel.label);
2742- if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
2743- if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
3878+ const peer = beginPeer({ role: 'joiner', label: 'join' });
3879+
3880+ peer.pc.ondatachannel = e => {
3881+ if (!App.state.peers.has(peer.id)) { App.log.warn('signal', peer.label, 'datachannel from stale pc, ignoring'); return; }
3882+ App.log.info('signal', peer.label, 'incoming data channel', e.channel.label);
3883+ if (e.channel.label === 'chat') { peer.dcChat = e.channel; App.chat.attach(peer, e.channel); }
3884+ if (e.channel.label === 'files') { peer.dcFiles = e.channel; App.files.attach(peer, e.channel); }
27443885 };
27453886
2746- renderJoinerExchange();
3887+ renderJoinerExchange(peer);
27473888 }
27483889
2749-async function finishJoiner(offerObj) {
2750- const pc = App.state.pc;
2751- const ac = App.state.signalAbort || new AbortController();
2752- App.state.signalAbort = ac;
3890+async function finishJoiner(peer, offerObj) {
3891+ const ac = peer.signalAbort || new AbortController();
3892+ peer.signalAbort = ac;
27533893 App.progress.show('Applying remote offer…', 'Parsing your peer\'s SDP.');
2754- await pc.setRemoteDescription(offerObj);
2755- App.media.adoptTransceiversFromRemote(pc);
2756- App.media.applyVideoCodecPreference(pc);
2757- rebuildRemoteStreams(pc);
3894+ await peer.pc.setRemoteDescription(offerObj);
3895+ App.media.adoptTransceiversFromRemote(peer);
3896+ App.media.applyVideoCodecPreference(peer);
3897+ rebuildRemoteStreamsFor(peer);
3898+ await App.media.publishLocalTracksTo(peer);
27583899 App.progress.show('Creating answer…', 'Negotiating local SDP.');
2759- let answer = await pc.createAnswer();
3900+ let answer = await peer.pc.createAnswer();
27603901 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2761- await pc.setLocalDescription(answer);
3902+ await peer.pc.setLocalDescription(answer);
27623903 App.progress.showModal('Gathering ICE candidates…',
27633904 iceGatheringSubtitle(),
27643905 { onCancel: cancelSetup });
2765- await App.signal.waitForIceComplete(pc, ac.signal);
3906+ await App.signal.waitForIceComplete(peer.pc, ac.signal);
27663907 if (ac.signal.aborted) throw new Error('cancelled');
27673908 App.progress.hide();
2768- showAnswerForJoiner();
3909+ showAnswerForJoiner(peer);
27693910 }
27703911
2771-async function applyAnswerOnInitiator(answerObj) {
2772- const pc = App.state.pc;
2773- await pc.setRemoteDescription(answerObj);
2774- rebuildRemoteStreams(pc);
2775- App.log.info('signal', 'remote answer applied; waiting to connect…');
3912+async function applyAnswerOnInitiator(peer, answerObj) {
3913+ await peer.pc.setRemoteDescription(answerObj);
3914+ rebuildRemoteStreamsFor(peer);
3915+ await App.media.publishLocalTracksTo(peer);
3916+ App.log.info('signal', peer.label, 'remote answer applied; waiting to connect…');
27763917 }
27773918
27783919 /* -------------------------------------------------------------------------
@@ -2780,12 +3921,13 @@ async function applyAnswerOnInitiator(answerObj) {
27803921 See server/signal.c for the protocol. The relay never touches media or
27813922 the data channels — those still flow peer-to-peer.
27823923 ------------------------------------------------------------------------- */
2783-async function signalPost(base, code, slot, body) {
3924+async function signalPost(base, code, slot, body, signal) {
27843925 const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
27853926 const r = await fetch(url, {
27863927 method: 'POST',
27873928 headers: { 'Content-Type': 'application/sdp' },
27883929 body,
3930+ signal,
27893931 });
27903932 if (!r.ok) throw new Error('POST ' + slot + ' failed: ' + r.status);
27913933 }
@@ -2818,57 +3960,57 @@ async function signalPoll(base, code, slot, totalDeadlineMs, signal) {
28183960 then long-poll the answer slot. Skips the blob copy/paste exchange view. */
28193961 async function startInitiatorAuto(code) {
28203962 const base = App.state.settings.signaling.serverUrl;
3963+ const peer = beginPeer({ role: 'initiator', label: 'init/' + code });
28213964 const ac = new AbortController();
2822- App.state.signalAbort = ac;
3965+ peer.signalAbort = ac;
28233966
2824- const pc = newPc('A');
2825- App.state.pc = pc;
2826- App.media.preallocate(pc);
2827- App.media.applyVideoCodecPreference(pc);
2828- App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
2829- App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
2830- App.chat.attach(App.state.dcChat);
2831- App.files.attach(App.state.dcFiles);
3967+ App.media.preallocate(peer);
3968+ App.media.applyVideoCodecPreference(peer);
3969+ peer.dcChat = peer.pc.createDataChannel('chat', { ordered: true });
3970+ peer.dcFiles = peer.pc.createDataChannel('files', { ordered: true });
3971+ App.chat.attach(peer, peer.dcChat);
3972+ App.files.attach(peer, peer.dcFiles);
28323973
28333974 App.progress.show('Creating offer…', 'Negotiating local SDP.');
2834- let offer = await pc.createOffer();
3975+ let offer = await peer.pc.createOffer();
28353976 offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
2836- await pc.setLocalDescription(offer);
3977+ await peer.pc.setLocalDescription(offer);
28373978 App.progress.showModal('Gathering ICE candidates…',
28383979 iceGatheringSubtitle(),
28393980 { onCancel: cancelSetup });
2840- await App.signal.waitForIceComplete(pc, ac.signal);
3981+ await App.signal.waitForIceComplete(peer.pc, ac.signal);
28413982 if (ac.signal.aborted) throw new Error('cancelled');
28423983
2843- const offerBlob = App.signal.encode(pc.localDescription);
3984+ const offerBlob = App.signal.encode(peer.pc.localDescription);
28443985 App.progress.show('Publishing offer…', 'Room ' + code + ' on ' + base);
2845- await signalPost(base, code, 'offer', offerBlob);
3986+ await signalPost(base, code, 'offer', offerBlob, ac.signal);
28463987
28473988 App.progress.showModal('Waiting for peer…',
28483989 'Share the room code with them. They have 5 minutes to join.',
28493990 { roomCode: code, onCancel: cancelSetup });
28503991 const answerText = await signalPoll(base, code, 'answer', 5 * 60 * 1000, ac.signal);
3992+ if (ac.signal.aborted) throw new Error('cancelled');
28513993 App.progress.show('Applying answer…', 'Finalizing the handshake.');
28523994 const obj = App.signal.decode(answerText);
28533995 if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
2854- await applyAnswerOnInitiator(obj);
3996+ await applyAnswerOnInitiator(peer, obj);
28553997 App.progress.hide();
3998+ commitPeer(peer);
28563999 goToCall();
28574000 }
28584001
28594002 /* Joiner side: long-poll the offer slot, apply it, push the answer back. */
28604003 async function startJoinerAuto(code) {
28614004 const base = App.state.settings.signaling.serverUrl;
4005+ const peer = beginPeer({ role: 'joiner', label: 'join/' + code });
28624006 const ac = new AbortController();
2863- App.state.signalAbort = ac;
2864-
2865- const pc = newPc('A');
2866- App.state.pc = pc;
2867- pc.ondatachannel = e => {
2868- if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
2869- App.log.info('signal', 'incoming data channel', e.channel.label);
2870- if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
2871- if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
4007+ peer.signalAbort = ac;
4008+
4009+ peer.pc.ondatachannel = e => {
4010+ if (!App.state.peers.has(peer.id)) { App.log.warn('signal', peer.label, 'datachannel from stale pc, ignoring'); return; }
4011+ App.log.info('signal', peer.label, 'incoming data channel', e.channel.label);
4012+ if (e.channel.label === 'chat') { peer.dcChat = e.channel; App.chat.attach(peer, e.channel); }
4013+ if (e.channel.label === 'files') { peer.dcFiles = e.channel; App.files.attach(peer, e.channel); }
28724014 };
28734015
28744016 App.progress.showModal('Waiting for offer…',
@@ -2880,31 +4022,38 @@ async function startJoinerAuto(code) {
28804022 if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
28814023
28824024 App.progress.show('Applying offer…', 'Building answer.');
2883- await pc.setRemoteDescription(obj);
2884- App.media.adoptTransceiversFromRemote(pc);
2885- App.media.applyVideoCodecPreference(pc);
2886- rebuildRemoteStreams(pc);
4025+ await peer.pc.setRemoteDescription(obj);
4026+ App.media.adoptTransceiversFromRemote(peer);
4027+ App.media.applyVideoCodecPreference(peer);
4028+ rebuildRemoteStreamsFor(peer);
4029+ await App.media.publishLocalTracksTo(peer);
28874030
2888- let answer = await pc.createAnswer();
4031+ let answer = await peer.pc.createAnswer();
28894032 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
2890- await pc.setLocalDescription(answer);
4033+ await peer.pc.setLocalDescription(answer);
28914034 App.progress.showModal('Gathering ICE candidates…',
28924035 iceGatheringSubtitle(),
28934036 { onCancel: cancelSetup });
2894- await App.signal.waitForIceComplete(pc, ac.signal);
4037+ await App.signal.waitForIceComplete(peer.pc, ac.signal);
28954038 if (ac.signal.aborted) throw new Error('cancelled');
28964039
28974040 App.progress.show('Publishing answer…', 'Room ' + code + ' on ' + base);
2898- await signalPost(base, code, 'answer', App.signal.encode(pc.localDescription));
4041+ await signalPost(base, code, 'answer', App.signal.encode(peer.pc.localDescription), ac.signal);
28994042 App.progress.hide();
4043+ commitPeer(peer);
29004044 goToCall();
29014045 }
29024046
29034047 async function startLoopback() {
2904- const pcA = newPc('A');
2905- const pcB = newPc('B');
2906- App.state.pc = pcA;
2907- App.state.pcB = pcB;
4048+ /* Each loopback adds one PeerCtx (the visible side, pcA) plus its own
4049+ synthetic pcB, stored on the peer. Multiple loopbacks can coexist —
4050+ useful for stress-testing the conference grid. */
4051+ const idx = peerList().filter(p => p.role === 'loopback').length + 1;
4052+ const username = idx === 1 ? 'Loopback' : 'Loopback ' + idx;
4053+ const peer = beginPeer({ role: 'loopback', label: 'loop-A/' + idx, username });
4054+ const pcA = peer.pc;
4055+ const pcB = new RTCPeerConnection({ iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 });
4056+ peer.loopbackB = pcB;
29084057
29094058 /* Trickle candidates between the two local PCs. Buffer candidates that
29104059 arrive before the target has its remote description set — otherwise
@@ -2926,26 +4075,25 @@ async function startLoopback() {
29264075
29274076 pcB.ondatachannel = e => {
29284077 /* In loopback the *visible* call uses pcA's POV. pcB is the synthetic
2929- peer; echoing the chat data channel back to A is how A learns about
2930- its "peer's" media state (which in loopback is itself) and how chat
2931- messages round-trip for verification. Files messages must NOT be
2932- echoed — that would feed A's outgoing file frames back into its own
2933- incoming-file accumulator, doubling memory and producing phantom
2934- "incoming" download rows. */
4078+ peer; echoing both data channels back to A is how A learns about
4079+ its "peer's" media state (which in loopback is itself), how chat
4080+ messages round-trip, and how files appear as incoming rows for
4081+ verification. The file echo doubles memory while a transfer is
4082+ in flight, but loopback exists for testing so that trade-off is
4083+ acceptable. */
29354084 e.channel.onmessage = ev => {
29364085 App.log.debug('loopback', 'B got', e.channel.label, typeof ev.data === 'string' ? ev.data.slice(0, 80) : '(binary)');
2937- if (e.channel.label !== 'chat') return;
29384086 try { e.channel.send(ev.data); } catch (_) {}
29394087 };
29404088 };
29414089 pcB.ontrack = e => App.log.debug('loopback', 'B got track', e.track.kind);
29424090
2943- App.media.preallocate(pcA);
2944- App.media.applyVideoCodecPreference(pcA);
2945- App.state.dcChat = pcA.createDataChannel('chat', { ordered: true });
2946- App.state.dcFiles = pcA.createDataChannel('files', { ordered: true });
2947- App.chat.attach(App.state.dcChat);
2948- App.files.attach(App.state.dcFiles);
4091+ App.media.preallocate(peer);
4092+ App.media.applyVideoCodecPreference(peer);
4093+ peer.dcChat = pcA.createDataChannel('chat', { ordered: true });
4094+ peer.dcFiles = pcA.createDataChannel('files', { ordered: true });
4095+ App.chat.attach(peer, peer.dcChat);
4096+ App.files.attach(peer, peer.dcFiles);
29494097
29504098 App.progress.show('Negotiating local loopback…', 'Exchanging SDP between the two in-tab peers.');
29514099 const offer = await pcA.createOffer();
@@ -2960,16 +4108,21 @@ async function startLoopback() {
29604108 try { t.direction = 'sendrecv'; }
29614109 catch (e) { App.log.warn('loopback', 'could not upgrade transceiver to sendrecv', e.message); }
29624110 }
2963- App.media.applyVideoCodecPreference(pcB);
29644111
29654112 const answer = await pcB.createAnswer();
29664113 answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
29674114 await pcB.setLocalDescription(answer);
29684115 await pcA.setRemoteDescription(answer);
29694116 remoteSetA = true; flush(pcA, pendingForA);
2970- rebuildRemoteStreams(pcA);
4117+ rebuildRemoteStreamsFor(peer);
4118+ await App.media.publishLocalTracksTo(peer);
4119+ /* If mic/cam/screen were already on before the user started loopback, the
4120+ mirror is empty — pcB has nothing to send back. Push the current tracks
4121+ onto pcB's senders now so the synthetic peer immediately echoes them. */
4122+ App.media.mirrorToLoopback();
29714123 App.log.info('loopback', 'offer/answer exchanged locally');
29724124 App.progress.hide();
4125+ commitPeer(peer);
29734126 goToCall();
29744127 }
29754128
@@ -2978,11 +4131,40 @@ async function startLoopback() {
29784131 ------------------------------------------------------------------------- */
29794132 const viewToHash = {
29804133 'view-welcome': 'welcome',
2981- 'view-configure': 'configure',
2982- 'view-exchange': 'exchange',
29834134 'view-sdp-inspect': 'sdp-inspect',
29844135 'view-call': 'call',
29854136 };
4137+
4138+/* The configure and exchange steps are modal <dialog>s, not routed views.
4139+ These helpers keep each dialog plus its progress row state in sync with
4140+ the caller. */
4141+function openConfigureDialog() {
4142+ const dlg = document.getElementById('view-configure');
4143+ if (!dlg || typeof dlg.showModal !== 'function') return;
4144+ if (dlg.open) return;
4145+ document.getElementById('cfg-progress')?.classList.add('hidden');
4146+ try { dlg.showModal(); } catch (_) {}
4147+}
4148+function closeConfigureDialog() {
4149+ const dlg = document.getElementById('view-configure');
4150+ if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
4151+ document.getElementById('cfg-progress')?.classList.add('hidden');
4152+}
4153+function openExchangeDialog() {
4154+ const dlg = document.getElementById('view-exchange');
4155+ if (!dlg || typeof dlg.showModal !== 'function') return;
4156+ if (dlg.open) return;
4157+ document.getElementById('exch-progress')?.classList.add('hidden');
4158+ try { dlg.showModal(); } catch (_) {}
4159+}
4160+function closeExchangeDialog() {
4161+ const dlg = document.getElementById('view-exchange');
4162+ if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
4163+ document.getElementById('exch-progress')?.classList.add('hidden');
4164+ /* Reset both step bodies so a future open starts clean. */
4165+ document.getElementById('step-1-body')?.replaceChildren();
4166+ document.getElementById('step-2-body')?.replaceChildren();
4167+}
29864168 const hashToView = Object.fromEntries(
29874169 Object.entries(viewToHash).map(([v, h]) => [h, v])
29884170 );
@@ -3005,15 +4187,10 @@ function resetViewState(id) {
30054187 document.getElementById('sdp-inspect-out')?.replaceChildren();
30064188 const status = document.getElementById('sdp-inspect-status');
30074189 if (status) { status.textContent = ''; status.className = 'pill'; }
3008- } else if (id === 'view-exchange') {
3009- document.getElementById('step-1-body')?.replaceChildren();
3010- document.getElementById('step-2-body')?.replaceChildren();
3011- document.getElementById('step-2-card')?.classList.add('hidden');
3012- document.getElementById('exch-progress')?.classList.add('hidden');
3013- } else if (id === 'view-configure') {
3014- document.getElementById('cfg-progress')?.classList.add('hidden');
30154190 } else if (id === 'view-call') {
3016- document.getElementById('chat-log')?.replaceChildren();
4191+ /* App.chat.clearAll empties both the chat-log DOM and the outMsgs map
4192+ so detached message nodes can be GC'd. */
4193+ App.chat?.clearAll?.();
30174194 if (App.files && App.files.clearAll) {
30184195 App.files.clearAll('out');
30194196 App.files.clearAll('in');
@@ -3062,25 +4239,22 @@ function onPopstate(event) {
30624239 history.pushState({ view: 'view-call' }, '', '#call');
30634240 return;
30644241 }
3065- resetSession();
4242+ teardownAll();
30664243 history.replaceState({ view: 'view-welcome' }, '', '#welcome');
30674244 showView('view-welcome', { push: false });
30684245 return;
30694246 }
30704247
3071- /* Exchange and call need in-flight state (offer, live pc) that doesn't
3072- exist when reached via back/forward — redirect to welcome. */
3073- if (target === 'view-exchange' || target === 'view-call') {
3074- if (App.state.pc || App.state.pcB || App.state.role) resetSession();
4248+ /* Call needs in-flight state (live pc) that doesn't exist when reached
4249+ via back/forward — redirect to welcome. */
4250+ if (target === 'view-call') {
4251+ if (App.state.peers.size || App.state.pendingPeer) teardownAll();
30754252 history.replaceState({ view: 'view-welcome' }, '', '#welcome');
30764253 showView('view-welcome', { push: false });
30774254 return;
30784255 }
30794256
3080- if (App.state.pc || App.state.pcB) resetSession();
3081- if (target === 'view-welcome' || target === 'view-sdp-inspect') {
3082- if (App.state.role) { App.state.role = null; updateRoleBadge(); }
3083- }
4257+ if (App.state.peers.size || App.state.pendingPeer) teardownAll();
30844258 showView(target, { push: false });
30854259 }
30864260
@@ -3093,39 +4267,43 @@ function resolveInitialView() {
30934267 showView(target, { push: false });
30944268 }
30954269
4270+/* Role badge in the topbar reflects the role of the in-flight (pending)
4271+ peer setup, if any. Otherwise hidden. */
30964272 function updateRoleBadge() {
30974273 const badge = document.getElementById('role-badge');
30984274 const cfg = document.getElementById('role-title-cfg');
30994275 const exch = document.getElementById('role-title-exch');
3100- const r = App.state.role;
3101- if (!r) { badge.classList.add('hidden'); return; }
3102- badge.classList.remove('hidden');
3103- badge.textContent = r;
3104- badge.className = 'role-badge ' + r;
3105- if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
3106- if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
4276+ const r = App.state.pendingPeer ? App.state.pendingPeer.role : null;
4277+ if (!r) {
4278+ if (badge) badge.classList.add('hidden');
4279+ if (cfg) cfg.textContent = '';
4280+ if (exch) exch.textContent = '';
4281+ return;
4282+ }
4283+ if (badge) { badge.classList.remove('hidden'); badge.textContent = r; badge.className = 'role-badge ' + r; }
4284+ if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
4285+ if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
31074286 }
31084287
3109-function pickRole(role) {
3110- /* If a pc from a previous role attempt is still around, tear it down so
3111- we don't leak it. Most paths reach pickRole via the welcome view where
3112- hangup() was already called, but the defensive close here covers cases
3113- where the user navigates back without going through exch-cancel. */
3114- if (App.state.pc || App.state.pcB) hangup();
3115- App.state.role = role;
4288+/* Open the Add Participant flow for the given role. The configure/exchange
4289+ views are reused (the existing layout), so the user can edit the per-add
4290+ signaling mode without leaving the call. On Cancel or after the new peer
4291+ joins, we return to the call view. */
4292+function pickRoleForAdd(role) {
4293+ /* No global pc to tear down — adding a participant just spins up a new
4294+ PeerCtx. If a previous Add flow was abandoned, cancelSetup clears it. */
4295+ if (App.state.pendingPeer) cancelSetup();
4296+ /* Store the chosen role on a transient slot so cfg-continue knows what to
4297+ do. The actual PeerCtx is created in start*() once the user clicks
4298+ Continue. */
4299+ App.state.nextAddRole = role;
31164300 updateRoleBadge();
3117- if (role === 'loopback') {
3118- populateConfigInputs();
3119- showView('view-configure');
3120- document.getElementById('cfg-lede').textContent = 'Loopback mode: both peers run in this tab and skip the paste step.';
3121- } else {
3122- populateConfigInputs();
3123- showView('view-configure');
3124- document.getElementById('cfg-lede').textContent =
3125- role === 'initiator'
3126- ? 'You will generate an offer; your peer pastes it and sends back an answer.'
3127- : 'Your peer sends you an offer; you paste it and send back the generated answer.';
3128- }
4301+ populateConfigInputs();
4302+ document.getElementById('cfg-lede').textContent =
4303+ role === 'loopback' ? 'Loopback mode: both peers run in this tab and skip the paste step.'
4304+ : role === 'initiator' ? 'You will generate an offer; your peer pastes it and sends back an answer.'
4305+ : 'Your peer sends you an offer; you paste it and send back the generated answer.';
4306+ openConfigureDialog();
31294307 refreshInsecureWarning();
31304308 }
31314309
@@ -3222,7 +4400,7 @@ function populateConfigInputs() {
32224400 applySignalingMode(s.signaling.mode);
32234401 /* Loopback doesn't use signaling at all — hide the card so the user isn't
32244402 given irrelevant choices. */
3225- $('signaling-card').classList.toggle('hidden', App.state.role === 'loopback');
4403+ $('signaling-card').classList.toggle('hidden', App.state.nextAddRole === 'loopback');
32264404 }
32274405
32284406 function applySignalingMode(mode) {
@@ -3294,10 +4472,10 @@ function wireUploadButton(name) {
32944472 }
32954473
32964474 /* Render the outgoing-blob block (textarea + controls) into `body`. Owns its
3297- own copy/download/base64-toggle wiring; re-encodes from pc.localDescription
4475+ own copy/download/base64-toggle wiring; re-encodes from peer.pc.localDescription
32984476 when the toggle flips so the visible blob always matches the setting.
32994477 `extraButtons` is an array of {id,label,cls,onClick} appended after Download. */
3300-function mountOutgoingBlob(body, name, extraButtons) {
4478+function mountOutgoingBlob(body, name, peer, extraButtons) {
33014479 const extras = (extraButtons || []).map(b =>
33024480 `<button id="${b.id}" class="${b.cls || 'ghost'}">${b.label}</button>`).join('');
33034481 body.innerHTML = `
@@ -3317,7 +4495,7 @@ function mountOutgoingBlob(body, name, extraButtons) {
33174495
33184496 let current = '';
33194497 function refresh() {
3320- const desc = App.state.pc && App.state.pc.localDescription;
4498+ const desc = peer && peer.pc && peer.pc.localDescription;
33214499 if (!desc) return;
33224500 current = App.signal.encode(desc);
33234501 ta.value = current;
@@ -3360,10 +4538,14 @@ function mountOutgoingBlob(body, name, extraButtons) {
33604538 }
33614539
33624540 /* Exchange views */
3363-function renderInitiatorExchange() {
4541+function renderInitiatorExchange(peer) {
33644542 document.getElementById('step-1-h').textContent = 'Step 1: send this offer to your peer';
3365- mountOutgoingBlob(document.getElementById('step-1-body'), 'offer');
4543+ mountOutgoingBlob(document.getElementById('step-1-body'), 'offer', peer);
33664544
4545+ /* The initiator needs BOTH steps visible: send the offer, then paste the
4546+ answer. Make sure step-2 is shown — a prior joiner flow may have hidden
4547+ it. */
4548+ document.getElementById('step-2-card').classList.remove('hidden');
33674549 document.getElementById('step-2-h').textContent = 'Step 2: paste your peer\'s answer';
33684550 const s2 = document.getElementById('step-2-body');
33694551 s2.innerHTML = `
@@ -3382,8 +4564,10 @@ function renderInitiatorExchange() {
33824564 const obj = App.signal.decode(text);
33834565 if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
33844566 statusEl.textContent = 'applying…'; statusEl.className = 'pill warn';
3385- await applyAnswerOnInitiator(obj);
4567+ await applyAnswerOnInitiator(peer, obj);
33864568 statusEl.textContent = 'applied'; statusEl.className = 'pill ok';
4569+ commitPeer(peer);
4570+ closeExchangeDialog();
33874571 goToCall();
33884572 } catch (e) {
33894573 statusEl.textContent = e.message; statusEl.className = 'pill err';
@@ -3391,10 +4575,10 @@ function renderInitiatorExchange() {
33914575 }
33924576 });
33934577
3394- showView('view-exchange');
4578+ openExchangeDialog();
33954579 }
33964580
3397-function renderJoinerExchange() {
4581+function renderJoinerExchange(peer) {
33984582 document.getElementById('step-1-h').textContent = 'Step 1: paste the offer from your peer';
33994583 const s1 = document.getElementById('step-1-body');
34004584 s1.innerHTML = `
@@ -3415,7 +4599,7 @@ function renderJoinerExchange() {
34154599 const obj = App.signal.decode(text);
34164600 if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
34174601 statusEl.textContent = 'working…'; statusEl.className = 'pill warn';
3418- await finishJoiner(obj);
4602+ await finishJoiner(peer, obj);
34194603 statusEl.textContent = 'ready'; statusEl.className = 'pill ok';
34204604 } catch (e) {
34214605 statusEl.textContent = e.message; statusEl.className = 'pill err';
@@ -3424,21 +4608,22 @@ function renderJoinerExchange() {
34244608 }
34254609 });
34264610
3427- showView('view-exchange');
4611+ openExchangeDialog();
34284612 }
34294613
3430-function showAnswerForJoiner() {
4614+function showAnswerForJoiner(peer) {
34314615 document.getElementById('step-2-card').classList.remove('hidden');
34324616 document.getElementById('step-2-h').textContent = 'Step 2: send this answer back to your peer';
3433- mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', [
3434- { id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => goToCall() },
4617+ mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', peer, [
4618+ { id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => { commitPeer(peer); closeExchangeDialog(); goToCall(); } },
34354619 ]);
34364620 }
34374621
34384622 function goToCall() {
34394623 showView('view-call');
34404624 setInCallControlsEnabled(true);
3441- /* Pre-fill runtime settings panel from current values */
4625+ /* Pre-fill runtime settings panel from current global defaults — these
4626+ apply to NEW peers; per-peer overrides will be added in a later task. */
34424627 const s = App.state.settings;
34434628 const $ = id => document.getElementById(id);
34444629 $('rt-v-w').value = s.video.width || 0;
@@ -3461,12 +4646,155 @@ function goToCall() {
34614646 $('rt-a-agc').checked = s.audio.autoGainControl;
34624647 $('rt-a-channels').value = String(s.audio.channelCount || 1);
34634648 $('rt-a-rate').value = s.audio.sampleRate || 0;
4649+ $('rt-username').value = App.state.username || 'Anonymous';
34644650 App.stats.start();
4651+ App.tiles.refreshAll();
4652+ App.banner.refresh();
4653+ App.ui.refreshPeerWidgets?.();
34654654 /* Refresh the displays after the view is actually visible — some browsers
34664655 don't render hidden video elements properly, so re-bind srcObject. */
34674656 App.media.refreshLocalDisplay();
34684657 App.media.refreshRemoteDisplay();
34694658 applyMediaButtonAvailability();
4659+ /* Set local tile label from current username. */
4660+ const localLabel = document.getElementById('tile-local-label');
4661+ if (localLabel) localLabel.textContent = (App.state.username || 'Anonymous') + ' (you)';
4662+}
4663+
4664+/* Reflect whether at least one peer with an open data channel exists.
4665+ Toolbar media buttons stay enabled even with no peers (the user might
4666+ enable mic/cam before adding anyone — fan-out is a no-op). Chat + files
4667+ inputs are gated by their own update*() helpers, which are wired in
4668+ wire() and registered on App.ui so this function can call them. */
4669+function setInCallControlsEnabled() {
4670+ App.ui.updateChatGate?.();
4671+ App.ui.updateFilesGate?.();
4672+ /* The set of "peers with open data channels" just changed, so the recipient
4673+ bars need to add/remove their chips. */
4674+ if (typeof renderRecipientBar === 'function') {
4675+ renderRecipientBar('chat');
4676+ renderRecipientBar('files');
4677+ }
4678+}
4679+App.ui = App.ui || {};
4680+
4681+/* Shared recipient-selection state for chat + files. Each set holds the
4682+ peerIds the user has EXCLUDED from the current selection — empty set ==
4683+ "all peers". This way, peers added later are included by default. */
4684+App.ui.chatExcluded = new Set();
4685+App.ui.filesExcluded = new Set();
4686+
4687+/* Compute the active recipient list for a given channel based on the
4688+ excluded set and the currently-open data channels. */
4689+App.ui.selectedChatPeers = function () {
4690+ return peersWithOpenChat().filter(p => !App.ui.chatExcluded.has(p.id));
4691+};
4692+App.ui.selectedFilesPeers = function () {
4693+ return peersWithOpenFiles().filter(p => !App.ui.filesExcluded.has(p.id));
4694+};
4695+
4696+/* Render one chip bar. `kind` is 'chat' or 'files'. */
4697+function renderRecipientBar(kind) {
4698+ const el = document.getElementById(kind === 'chat' ? 'chat-recipients' : 'files-recipients');
4699+ if (!el) return;
4700+ const excluded = kind === 'chat' ? App.ui.chatExcluded : App.ui.filesExcluded;
4701+ const available = (kind === 'chat' ? peersWithOpenChat() : peersWithOpenFiles());
4702+ /* Drop excluded ids that no longer correspond to a connected peer. */
4703+ for (const id of Array.from(excluded)) {
4704+ if (!available.find(p => p.id === id)) excluded.delete(id);
4705+ }
4706+ el.innerHTML = '';
4707+ if (!available.length) {
4708+ const span = document.createElement('span');
4709+ span.className = 'empty';
4710+ span.textContent = 'No connected peers yet — add one from the toolbar.';
4711+ el.appendChild(span);
4712+ return;
4713+ }
4714+ const label = document.createElement('span');
4715+ label.className = 'label';
4716+ label.textContent = 'To:';
4717+ el.appendChild(label);
4718+ for (const peer of available) {
4719+ const chip = document.createElement('span');
4720+ const on = !excluded.has(peer.id);
4721+ chip.className = 'chip' + (on ? ' on' : '');
4722+ chip.textContent = displayNameOf(peer);
4723+ chip.title = on ? 'Click to exclude' : 'Click to include';
4724+ chip.addEventListener('click', () => {
4725+ if (excluded.has(peer.id)) excluded.delete(peer.id);
4726+ else excluded.add(peer.id);
4727+ renderRecipientBar(kind);
4728+ if (kind === 'chat') App.ui.updateChatGate?.();
4729+ if (kind === 'files') App.ui.updateFilesGate?.();
4730+ });
4731+ el.appendChild(chip);
4732+ }
4733+}
4734+
4735+/* Refresh every peer-aware widget. Called whenever peers join/leave or
4736+ their display names change. */
4737+App.ui.refreshPeerWidgets = function () {
4738+ renderRecipientBar('chat');
4739+ renderRecipientBar('files');
4740+ refreshSettingsTargetDropdown();
4741+ refreshStatsPeerDropdown();
4742+};
4743+
4744+/* Settings target dropdown — keeps options for {defaults, all, ...peers}. */
4745+function refreshSettingsTargetDropdown() {
4746+ const sel = document.getElementById('settings-target');
4747+ if (!sel) return;
4748+ const cur = sel.value || 'all';
4749+ const peers = peerList();
4750+ const ids = new Set(['all', ...peers.map(p => p.id)]);
4751+ /* Remove peer options that no longer exist. */
4752+ Array.from(sel.options).forEach(o => {
4753+ if (!ids.has(o.value)) sel.removeChild(o);
4754+ });
4755+ /* Ensure each peer has an option (preserve existing order). */
4756+ for (const peer of peers) {
4757+ if (!sel.querySelector('option[value="' + CSS.escape(peer.id) + '"]')) {
4758+ const o = document.createElement('option');
4759+ o.value = peer.id;
4760+ o.textContent = displayNameOf(peer);
4761+ sel.appendChild(o);
4762+ } else {
4763+ const o = sel.querySelector('option[value="' + CSS.escape(peer.id) + '"]');
4764+ o.textContent = displayNameOf(peer);
4765+ }
4766+ }
4767+ const fellBack = !(peers.find(p => p.id === cur) || cur === 'all');
4768+ sel.value = fellBack ? 'all' : cur;
4769+ /* Only reload the form if the effective target changed — otherwise we'd
4770+ overwrite values the user is in the middle of editing. */
4771+ if (fellBack && typeof App.ui.loadSettingsForTarget === 'function') {
4772+ App.ui.loadSettingsForTarget();
4773+ }
4774+}
4775+
4776+function refreshStatsPeerDropdown() {
4777+ const sel = document.getElementById('stats-peer');
4778+ if (!sel) return;
4779+ const cur = sel.value;
4780+ const peers = peerList();
4781+ sel.innerHTML = '';
4782+ if (!peers.length) {
4783+ const o = document.createElement('option');
4784+ o.value = ''; o.textContent = '(no peers)';
4785+ sel.appendChild(o);
4786+ App.stats?.refreshNow?.();
4787+ return;
4788+ }
4789+ for (const peer of peers) {
4790+ const o = document.createElement('option');
4791+ o.value = peer.id;
4792+ o.textContent = displayNameOf(peer);
4793+ sel.appendChild(o);
4794+ }
4795+ if (peers.find(p => p.id === cur)) sel.value = cur;
4796+ else sel.value = peers[0].id;
4797+ App.stats?.refreshNow?.();
34704798 }
34714799
34724800 /* Disable mic/cam/screen toolbar buttons when they can't possibly succeed —
@@ -3548,17 +4876,19 @@ async function applyMediaButtonAvailability() {
35484876
35494877 /* Rebuild the mic and camera popover lists from an enumerateDevices() snapshot.
35504878 Devices without labels (no permission yet) show as "Microphone 1", etc., so
3551- the user can still see *how many* devices exist before granting permission. */
4879+ the user can still see *how many* devices exist before granting permission.
4880+ The "currently live" indicator is derived from App.state.micTrack/camTrack
4881+ (the local capture) rather than any per-peer sender. */
35524882 function renderDeviceMenus(devs) {
35534883 renderOneDeviceMenu('tb-mic-menu', 'mic', devs.filter(d => d.kind === 'audioinput'),
35544884 App.state.settings.audio.deviceId,
3555- App.state.micTransceiver && App.state.micTransceiver.sender);
4885+ App.state.micTrack);
35564886 renderOneDeviceMenu('tb-cam-menu', 'cam', devs.filter(d => d.kind === 'videoinput'),
35574887 App.state.settings.video.deviceId,
3558- App.state.camTransceiver && App.state.camTransceiver.sender);
4888+ App.state.camTrack);
35594889 }
35604890
3561-function renderOneDeviceMenu(menuId, kind, devs, savedId, sender) {
4891+function renderOneDeviceMenu(menuId, kind, devs, savedId, liveTrack) {
35624892 const menu = document.getElementById(menuId);
35634893 if (!menu) return;
35644894 /* Some webcams (notably HP combo cameras) expose the RGB and IR sensors as
@@ -3578,9 +4908,9 @@ function renderOneDeviceMenu(menuId, kind, devs, savedId, sender) {
35784908 that over getSettings().deviceId, which some webcams misreport. Only
35794909 fall back to getSettings() when no preference is saved. */
35804910 let activeId = '';
3581- if (sender && sender.track) {
4911+ if (liveTrack) {
35824912 activeId = savedId
3583- || (sender.track.getSettings ? sender.track.getSettings().deviceId : '')
4913+ || (liveTrack.getSettings ? liveTrack.getSettings().deviceId : '')
35844914 || '';
35854915 }
35864916 const kindLabel = kind === 'mic' ? 'Microphone' : 'Camera';
@@ -3749,10 +5079,12 @@ function setupConsole() {
37495079 document.getElementById('console-clear').addEventListener('click', () => App.log.clear());
37505080 document.getElementById('console-export').addEventListener('click', () => {
37515081 const blob = new Blob([JSON.stringify(App.log.snapshot(), null, 2)], { type: 'application/json' });
5082+ const url = URL.createObjectURL(blob);
37525083 const a = document.createElement('a');
3753- a.href = URL.createObjectURL(blob);
5084+ a.href = url;
37545085 a.download = 'webrtc-log-' + Date.now() + '.json';
3755- a.click();
5086+ document.body.appendChild(a); a.click(); a.remove();
5087+ URL.revokeObjectURL(url);
37565088 });
37575089 document.getElementById('console-stats-toggle').addEventListener('click', () => App.stats.toggleConsoleStats());
37585090
@@ -4187,13 +5519,44 @@ function wire() {
41875519 resolveInitialView();
41885520
41895521 /* Welcome */
4190- document.querySelectorAll('#view-welcome .role-picker button').forEach(b =>
4191- b.addEventListener('click', () => pickRole(b.dataset.role)));
5522+ const usernameInput = document.getElementById('welcome-username');
5523+ if (usernameInput) {
5524+ usernameInput.value = App.state.username === 'Anonymous' ? '' : App.state.username;
5525+ usernameInput.placeholder = 'Anonymous';
5526+ }
5527+ document.getElementById('welcome-start').addEventListener('click', () => {
5528+ const raw = (usernameInput?.value || '').trim().slice(0, 64);
5529+ App.state.username = raw || 'Anonymous';
5530+ if (raw) saveUsername(raw);
5531+ /* No peers yet — straight to the call view with the empty-state banner. */
5532+ App.state.bannerDismissed = false;
5533+ goToCall();
5534+ App.banner.refresh();
5535+ });
41925536 document.getElementById('welcome-sdp-inspect').addEventListener('click', () => {
4193- /* Standalone tool — no role, no pc. Just swap the view. */
41945537 showView('view-sdp-inspect');
41955538 });
41965539
5540+ /* Add Participant dialog: each button picks a role and routes through the
5541+ existing configure → exchange flow, which knows how to attach the result
5542+ to a fresh PeerCtx. */
5543+ const addDlg = document.getElementById('add-peer-dialog');
5544+ function openAddDialog() {
5545+ if (!addDlg || typeof addDlg.showModal !== 'function' || addDlg.open) return;
5546+ try { addDlg.showModal(); } catch (_) {}
5547+ }
5548+ document.getElementById('tb-add-peer').addEventListener('click', openAddDialog);
5549+ document.getElementById('call-banner-add').addEventListener('click', openAddDialog);
5550+ document.getElementById('add-peer-cancel').addEventListener('click', () => {
5551+ if (addDlg && addDlg.open) addDlg.close();
5552+ });
5553+ addDlg.querySelectorAll('.role-picker button[data-role]').forEach(b => {
5554+ b.addEventListener('click', () => {
5555+ addDlg.close();
5556+ pickRoleForAdd(b.dataset.role);
5557+ });
5558+ });
5559+
41975560 /* SDP inspector */
41985561 const sdpIn = document.getElementById('sdp-in');
41995562 const sdpOut = document.getElementById('sdp-inspect-out');
@@ -4217,11 +5580,6 @@ function wire() {
42175580 /* Theme */
42185581 document.getElementById('theme-toggle').addEventListener('click', () => App.theme.toggle());
42195582
4220- /* Peer-left dialog: OK closes; backdrop/Esc also close (native behavior). */
4221- document.getElementById('peer-left-ok').addEventListener('click', () => {
4222- document.getElementById('peer-left-dialog').close();
4223- });
4224-
42255583 /* Step dialog: Cancel and Esc both invoke the registered cancel handler.
42265584 Listen on 'cancel' (fired by Esc) and 'close' as a belt-and-suspenders. */
42275585 document.getElementById('step-dialog-cancel').addEventListener('click', () => {
@@ -4236,9 +5594,9 @@ function wire() {
42365594
42375595 /* Best-effort hangup notification when the tab is closing or backgrounded
42385596 to bfcache. Use pagehide (more reliable than beforeunload, especially
4239- on mobile) and only send if a chat channel is currently open. */
5597+ on mobile) and broadcast to every connected peer. */
42405598 window.addEventListener('pagehide', () => {
4241- if (App.chat && App.chat.sendBye) App.chat.sendBye();
5599+ if (App.chat && App.chat.sendByeAll) App.chat.sendByeAll();
42425600 });
42435601
42445602 /* Configure */
@@ -4302,11 +5660,6 @@ function wire() {
43025660 App.log.info('ice', 'applied JSON config', v.length, 'servers');
43035661 } catch (e) { App.log.error('ice', 'bad JSON', e.message); alert('Bad JSON: ' + e.message); }
43045662 });
4305- document.getElementById('cfg-back').addEventListener('click', () => {
4306- /* Tear down a partial pc/abort an in-flight signaling fetch if the
4307- user hits Back while auto-mode is waiting for the peer. */
4308- hangup({ sendBye: false });
4309- });
43105663 /* Signaling mode toggle */
43115664 document.getElementById('sig-mode-manual').addEventListener('click', () => {
43125665 applySignalingMode('manual'); saveSignaling();
@@ -4349,22 +5702,64 @@ function wire() {
43495702 e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 15);
43505703 });
43515704
5705+ document.getElementById('cfg-back').addEventListener('click', () => {
5706+ /* Cfg-back during Add: drop the pending peer (no live commit yet) and
5707+ return to the call view (or welcome, if no call started yet). */
5708+ cancelSetup();
5709+ App.state.nextAddRole = null;
5710+ updateRoleBadge();
5711+ closeConfigureDialog();
5712+ if (App.state.peers.size || currentView() === 'view-call') goToCall();
5713+ else showView('view-welcome');
5714+ });
5715+ /* Also handle native dialog close (Esc / backdrop). */
5716+ document.getElementById('view-configure').addEventListener('close', () => {
5717+ /* Only treat this as a cancellation when nothing is mid-handshake. */
5718+ if (!App.state.pendingPeer) {
5719+ App.state.nextAddRole = null;
5720+ updateRoleBadge();
5721+ }
5722+ });
5723+ /* Esc on the exchange dialog cancels the pending peer (a half-finished
5724+ handshake is unusable). */
5725+ document.getElementById('view-exchange').addEventListener('close', () => {
5726+ if (App.state.pendingPeer) {
5727+ cancelSetup();
5728+ App.state.nextAddRole = null;
5729+ updateRoleBadge();
5730+ }
5731+ });
43525732 document.getElementById('cfg-continue').addEventListener('click', async () => {
43535733 readConfigInputs();
43545734 saveIce();
4355- const auto = App.state.settings.signaling.mode === 'auto' && App.state.role !== 'loopback';
5735+ const role = App.state.nextAddRole;
5736+ if (!role) { alert('No role chosen — open Add participant.'); return; }
5737+ const auto = App.state.settings.signaling.mode === 'auto' && role !== 'loopback';
43565738 try {
5739+ /* Close the dialog before the start* calls so the exchange view (or
5740+ progress modal) isn't rendered underneath the configure backdrop.
5741+ The dialog's `close` handler clears nextAddRole when no pendingPeer
5742+ exists, so when we re-open after a validation error we must restore
5743+ the role before showing the dialog again. */
5744+ closeConfigureDialog();
5745+ function reopenWithRole() {
5746+ App.state.nextAddRole = role;
5747+ updateRoleBadge();
5748+ openConfigureDialog();
5749+ }
43575750 if (auto) {
43585751 const code = (document.getElementById('sig-room-code').value || '').trim();
4359- if (!code) { alert('Room code is required for auto mode.'); return; }
4360- if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); return; }
4361- if (App.state.role === 'initiator') await startInitiatorAuto(code);
4362- else if (App.state.role === 'joiner') await startJoinerAuto(code);
5752+ if (!code) { alert('Room code is required for auto mode.'); reopenWithRole(); return; }
5753+ if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); reopenWithRole(); return; }
5754+ if (role === 'initiator') await startInitiatorAuto(code);
5755+ else if (role === 'joiner') await startJoinerAuto(code);
43635756 } else {
4364- if (App.state.role === 'initiator') await startInitiator();
4365- else if (App.state.role === 'joiner') await startJoiner();
4366- else if (App.state.role === 'loopback') await startLoopback();
5757+ if (role === 'initiator') await startInitiator();
5758+ else if (role === 'joiner') await startJoiner();
5759+ else if (role === 'loopback') await startLoopback();
43675760 }
5761+ App.state.nextAddRole = null;
5762+ updateRoleBadge();
43685763 } catch (e) {
43695764 App.progress.hide();
43705765 if (App.state.userCancelled) { App.state.userCancelled = false; return; }
@@ -4374,10 +5769,12 @@ function wire() {
43745769 });
43755770
43765771 document.getElementById('exch-cancel').addEventListener('click', () => {
4377- hangup();
4378- App.state.role = null;
5772+ cancelSetup();
5773+ App.state.nextAddRole = null;
43795774 updateRoleBadge();
4380- showView('view-welcome');
5775+ closeExchangeDialog();
5776+ if (App.state.peers.size || currentView() === 'view-call') goToCall();
5777+ else showView('view-welcome');
43815778 });
43825779
43835780 /* Disable a button until its async handler resolves, so double-clicks
@@ -4412,24 +5809,22 @@ function wire() {
44125809 });
44135810 document.getElementById('tb-hangup').addEventListener('click', hangup);
44145811
4415- /* Click a tile that's showing a screen share → toggle fullscreen. */
4416- for (const id of ['tile-local', 'tile-remote']) {
4417- document.getElementById(id).addEventListener('click', e => {
4418- const tile = e.currentTarget;
4419- if (!tile.classList.contains('screen')) return;
4420- /* Don't trigger fullscreen for clicks on the PIP */
4421- if (e.target.closest('.pip')) return;
4422- const video = tile.querySelector(':scope > video');
4423- if (!video) return;
4424- if (document.fullscreenElement) {
4425- (document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
4426- .catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
4427- } else {
4428- (video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
4429- .catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
4430- }
4431- });
4432- }
5812+ /* Local tile screen-share fullscreen toggle. Remote tiles wire their own
5813+ click handler inside App.tiles.add(). */
5814+ document.getElementById('tile-local').addEventListener('click', e => {
5815+ const tile = e.currentTarget;
5816+ if (!tile.classList.contains('screen')) return;
5817+ if (e.target.closest('.pip')) return;
5818+ const video = tile.querySelector(':scope > .vid-main');
5819+ if (!video) return;
5820+ if (document.fullscreenElement) {
5821+ (document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
5822+ .catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
5823+ } else {
5824+ (video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
5825+ .catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
5826+ }
5827+ });
44335828
44345829 /* Files: clear-all */
44355830 document.getElementById('files-out-clear').addEventListener('click', () => App.files.clearAll('out'));
@@ -4452,11 +5847,19 @@ function wire() {
44525847 const CHAT_MAX = App.chat.MAX_TEXT;
44535848 function updateChatCounter() {
44545849 const bytes = App.chat.utf8Length(chatInput.value);
4455- chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B';
5850+ const totalOpen = peersWithOpenChat().length;
5851+ const selected = App.ui.selectedChatPeers ? App.ui.selectedChatPeers().length : totalOpen;
5852+ let suffix = '';
5853+ if (!totalOpen) suffix = ' · no connected peers';
5854+ else if (!selected) suffix = ' · no recipients selected';
5855+ else if (selected < totalOpen) suffix = ' · → ' + selected + '/' + totalOpen + ' peers';
5856+ chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B' + suffix;
44565857 const over = bytes > CHAT_MAX;
44575858 chatCounter.classList.toggle('over', over);
4458- chatSend.disabled = over || bytes === 0;
5859+ chatInput.disabled = !totalOpen;
5860+ chatSend.disabled = over || bytes === 0 || !selected;
44595861 }
5862+ App.ui.updateChatGate = updateChatCounter;
44605863 chatSend.addEventListener('click', sendChat);
44615864 chatInput.addEventListener('input', updateChatCounter);
44625865 chatInput.addEventListener('keydown', e => {
@@ -4467,7 +5870,9 @@ function wire() {
44675870 const t = chatInput.value.trim();
44685871 if (!t) return;
44695872 if (App.chat.utf8Length(t) > CHAT_MAX) return;
4470- App.chat.send(t);
5873+ const peers = App.ui.selectedChatPeers();
5874+ if (!peers.length) return;
5875+ App.chat.send(t, { peers });
44715876 chatInput.value = '';
44725877 updateChatCounter();
44735878 }
@@ -4487,7 +5892,10 @@ function wire() {
44875892 drop.addEventListener('drop', e => {
44885893 e.preventDefault(); drop.classList.remove('over');
44895894 const f = e.dataTransfer.files[0];
4490- if (f) App.files.sendFile(f);
5895+ if (!f) return;
5896+ const peers = App.ui.selectedFilesPeers();
5897+ if (!peers.length) return;
5898+ App.files.sendFile(f, { peers });
44915899 });
44925900 document.getElementById('files-pick').addEventListener('click', e => {
44935901 e.preventDefault();
@@ -4495,22 +5903,138 @@ function wire() {
44955903 });
44965904 document.getElementById('files-input').addEventListener('change', e => {
44975905 const f = e.target.files[0];
4498- if (f) App.files.sendFile(f);
5906+ if (f) {
5907+ const peers = App.ui.selectedFilesPeers();
5908+ if (peers.length) App.files.sendFile(f, { peers });
5909+ }
44995910 e.target.value = '';
45005911 });
5912+ function updateFilesGate() {
5913+ const totalOpen = peersWithOpenFiles().length;
5914+ const selected = App.ui.selectedFilesPeers ? App.ui.selectedFilesPeers().length : totalOpen;
5915+ drop.classList.toggle('disabled', !selected);
5916+ }
5917+ App.ui.updateFilesGate = updateFilesGate;
5918+ updateFilesGate();
5919+
5920+ /* Target-aware writes for the per-peer encoder fields. `all` updates the
5921+ global settings and every connected peer (so new peers inherit too).
5922+ A specific peer id updates only that peer; the global "defaults" stay
5923+ unchanged in that case. */
5924+ function currentTarget() {
5925+ return document.getElementById('settings-target').value || 'all';
5926+ }
5927+ function targetPeers() {
5928+ const t = currentTarget();
5929+ if (t === 'all') return Array.from(App.state.peers.values());
5930+ const peer = App.state.peers.get(t);
5931+ return peer ? [peer] : [];
5932+ }
5933+ function writeVideoEncoder(maxKbps, degrade) {
5934+ const t = currentTarget();
5935+ if (t === 'all') {
5936+ App.state.settings.video.maxBitrateKbps = maxKbps;
5937+ App.state.settings.video.degradationPreference = degrade;
5938+ }
5939+ for (const p of targetPeers()) {
5940+ p.videoMaxBitrateKbps = maxKbps;
5941+ p.videoDegradationPreference = degrade;
5942+ }
5943+ if (t === 'all') App.media.applyCamSendParamsAll();
5944+ else {
5945+ const peer = App.state.peers.get(t);
5946+ if (peer) App.media.applyCamSendParamsFor(peer);
5947+ }
5948+ }
5949+ function writeScreenEncoder(maxKbps, degrade) {
5950+ const t = currentTarget();
5951+ if (t === 'all') {
5952+ App.state.settings.screen.maxBitrateKbps = maxKbps;
5953+ App.state.settings.screen.degradationPreference = degrade;
5954+ }
5955+ for (const p of targetPeers()) {
5956+ p.screenMaxBitrateKbps = maxKbps;
5957+ p.screenDegradationPreference = degrade;
5958+ }
5959+ if (t === 'all') App.media.applyScreenSendParamsAll();
5960+ else {
5961+ const peer = App.state.peers.get(t);
5962+ if (peer) App.media.applyScreenSendParamsFor(peer);
5963+ }
5964+ }
5965+ function writeSendCodec(codec) {
5966+ const t = currentTarget();
5967+ if (t === 'all') App.state.settings.sendVideoCodec = codec;
5968+ for (const p of targetPeers()) p.sendVideoCodec = codec;
5969+ if (t === 'all') {
5970+ App.media.applyCamSendParamsAll();
5971+ App.media.applyScreenSendParamsAll();
5972+ } else {
5973+ const peer = App.state.peers.get(t);
5974+ if (peer) {
5975+ App.media.applyCamSendParamsFor(peer);
5976+ App.media.applyScreenSendParamsFor(peer);
5977+ }
5978+ }
5979+ }
5980+
5981+ /* Populate the per-peer encoder inputs from the current target. The
5982+ resolution/fps and audio inputs are unaffected — they're always global. */
5983+ App.ui.loadSettingsForTarget = function () {
5984+ const t = currentTarget();
5985+ const $ = id => document.getElementById(id);
5986+ let v, s, codec;
5987+ if (t === 'all') {
5988+ v = { maxBitrateKbps: App.state.settings.video.maxBitrateKbps,
5989+ degradationPreference: App.state.settings.video.degradationPreference };
5990+ s = { maxBitrateKbps: App.state.settings.screen.maxBitrateKbps,
5991+ degradationPreference: App.state.settings.screen.degradationPreference };
5992+ codec = App.state.settings.sendVideoCodec || 'auto';
5993+ } else {
5994+ const peer = App.state.peers.get(t);
5995+ if (!peer) return;
5996+ v = { maxBitrateKbps: peer.videoMaxBitrateKbps,
5997+ degradationPreference: peer.videoDegradationPreference };
5998+ s = { maxBitrateKbps: peer.screenMaxBitrateKbps,
5999+ degradationPreference: peer.screenDegradationPreference };
6000+ codec = peer.sendVideoCodec || 'auto';
6001+ }
6002+ $('rt-v-maxbr').value = v.maxBitrateKbps || 0;
6003+ $('rt-v-degrade').value = v.degradationPreference || 'balanced';
6004+ $('rt-s-maxbr').value = s.maxBitrateKbps || 0;
6005+ $('rt-s-degrade').value = s.degradationPreference || 'maintain-resolution';
6006+ const rtCodec = $('rt-codec');
6007+ if (rtCodec) {
6008+ const want = codec.toLowerCase();
6009+ const match = Array.from(rtCodec.options).find(o => o.value.toLowerCase() === want);
6010+ rtCodec.value = match ? match.value : 'auto';
6011+ }
6012+ };
6013+ document.getElementById('settings-target').addEventListener('change', () => {
6014+ App.ui.loadSettingsForTarget();
6015+ });
45016016
4502- /* Runtime settings */
4503- withReentryGuard('rt-v-apply', async () => {
6017+ /* Username (always global). */
6018+ document.getElementById('rt-username-apply').addEventListener('click', () => {
6019+ const inp = document.getElementById('rt-username');
6020+ const name = (inp.value || '').trim().slice(0, 64) || 'Anonymous';
6021+ inp.value = name;
6022+ if (name === App.state.username) return;
6023+ App.state.username = name;
6024+ saveUsername(name);
6025+ /* Update local tile label + broadcast to all peers. */
6026+ const localLabel = document.getElementById('tile-local-label');
6027+ if (localLabel) localLabel.textContent = name + ' (you)';
6028+ App.chat.broadcastUsername?.();
6029+ });
6030+
6031+ /* Capture-side applies (always global). */
6032+ withReentryGuard('rt-v-cap-apply', async () => {
45046033 const v = App.state.settings.video;
45056034 const prevW = v.width, prevH = v.height, prevFps = v.frameRate;
4506- v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
4507- v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
4508- v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
4509- v.maxBitrateKbps = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
4510- v.degradationPreference = document.getElementById('rt-v-degrade').value;
4511- App.media.applyCamSendParams();
4512- /* Resolution / framerate only take effect on a fresh getUserMedia call —
4513- cycle the camera if it's currently on so the new constraints apply. */
6035+ v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
6036+ v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
6037+ v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
45146038 const camChanged = v.width !== prevW || v.height !== prevH || v.frameRate !== prevFps;
45156039 const camOn = document.getElementById('tb-cam').classList.contains('on');
45166040 if (camChanged && camOn) {
@@ -4519,15 +6043,12 @@ function wire() {
45196043 await App.media.setCam(true);
45206044 }
45216045 });
4522- withReentryGuard('rt-s-apply', async () => {
6046+ withReentryGuard('rt-s-cap-apply', async () => {
45236047 const s = App.state.settings.screen;
45246048 const prevW = s.width, prevH = s.height, prevFps = s.frameRate;
4525- s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
4526- s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
4527- s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
4528- s.maxBitrateKbps = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
4529- s.degradationPreference = document.getElementById('rt-s-degrade').value;
4530- App.media.applyScreenSendParams();
6049+ s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
6050+ s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
6051+ s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
45316052 const dimsChanged = s.width !== prevW || s.height !== prevH || s.frameRate !== prevFps;
45326053 const screenOn = document.getElementById('tb-screen').classList.contains('on');
45336054 if (dimsChanged && screenOn) {
@@ -4537,11 +6058,20 @@ function wire() {
45376058 catch (e) { App.log.warn('media', 'restart screen failed', e.message); }
45386059 }
45396060 });
6061+ /* Encoder-side applies (per target). */
6062+ withReentryGuard('rt-v-enc-apply', async () => {
6063+ const maxBr = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
6064+ const degrade = document.getElementById('rt-v-degrade').value;
6065+ writeVideoEncoder(maxBr, degrade);
6066+ });
6067+ withReentryGuard('rt-s-enc-apply', async () => {
6068+ const maxBr = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
6069+ const degrade = document.getElementById('rt-s-degrade').value;
6070+ writeScreenEncoder(maxBr, degrade);
6071+ });
45406072 withReentryGuard('rt-codec-apply', async () => {
4541- App.state.settings.sendVideoCodec = document.getElementById('rt-codec').value || 'auto';
4542- /* Push the new codec to both video senders at once. */
4543- App.media.applyCamSendParams();
4544- App.media.applyScreenSendParams();
6073+ const codec = document.getElementById('rt-codec').value || 'auto';
6074+ writeSendCodec(codec);
45456075 });
45466076 withReentryGuard('rt-a-apply', async () => {
45476077 const a = App.state.settings.audio;
@@ -4561,7 +6091,8 @@ function wire() {
45616091 }
45626092 });
45636093
4564- /* Stats export */
6094+ /* Stats: peer dropdown + export */
6095+ document.getElementById('stats-peer').addEventListener('change', () => App.stats.refreshNow());
45656096 document.getElementById('stats-export').addEventListener('click', () => App.stats.exportAll());
45666097
45676098 setupConsole();
@@ -4569,21 +6100,19 @@ function wire() {
45696100 App.log.info('app', 'ready');
45706101 }
45716102
4572-/* Close the peer connection, stop local media, and reset call-tied UI
4573- state. Shared by hangup() (user-initiated, navigates away) and
4574- onPeerHangup() (remote-initiated, stays on call view). */
4575-function teardownConnection(opts) {
6103+/* Tear down ALL peers, stop local media, and reset call-tied UI state.
6104+ Called when the user leaves the call entirely. */
6105+function teardownAll(opts) {
45766106 const sendBye = !opts || opts.sendBye !== false;
4577- if (sendBye && App.chat && App.chat.sendBye) App.chat.sendBye();
4578- /* If we're in the middle of auto-mode long-polling, cancel the fetch so
4579- the user isn't stuck for up to 30 s after clicking Cancel/Hang up. */
4580- if (App.state.signalAbort) { try { App.state.signalAbort.abort(); } catch (_) {} App.state.signalAbort = null; }
6107+ if (sendBye) App.chat.sendByeAll();
45816108 App.stats.stop();
4582- try { if (App.state.dcChat) App.state.dcChat.close(); } catch (_) {}
4583- try { if (App.state.dcFiles) App.state.dcFiles.close(); } catch (_) {}
4584- try { if (App.state.pc) App.state.pc.close(); } catch (_) {}
4585- try { if (App.state.pcB) App.state.pcB.close(); } catch (_) {}
4586- if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
6109+ App.tiles.teardownObserver?.();
6110+ /* Snapshot peers first — removePeer mutates App.state.peers. removePeer
6111+ also closes each peer's loopback pcB if present. */
6112+ for (const peer of Array.from(App.state.peers.values())) {
6113+ removePeer(peer, { sendBye: false });
6114+ }
6115+ if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
45876116 if (App.state.screenStream) App.state.screenStream.getTracks().forEach(t => t.stop());
45886117 if (App.state.iceWarmupStream) {
45896118 App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
@@ -4593,83 +6122,36 @@ function teardownConnection(opts) {
45936122 if (wb) wb.textContent = 'Enable LAN connectivity';
45946123 if (ws) { ws.textContent = 'off'; ws.className = 'pill'; }
45956124 }
4596- App.state.pc = null; App.state.pcB = null;
4597- App.state.dcChat = null; App.state.dcFiles = null;
4598- App.state.localStream = null; App.state.screenStream = null;
4599- App.state.remoteStream = null; App.state.remoteScreenStream = null;
4600- App.state.peerMediaState = { mic: false, cam: false, screen: false };
4601- App.state.micTransceiver = App.state.camTransceiver = App.state.screenTransceiver = null;
4602- for (const id of ['vid-local-main', 'vid-local-pip', 'vid-remote-main', 'vid-remote-pip', 'audio-remote']) {
4603- document.getElementById(id).srcObject = null;
4604- }
4605- for (const id of ['tile-local', 'tile-remote']) {
4606- const tile = document.getElementById(id);
4607- tile.classList.add('empty');
4608- tile.classList.remove('screen');
4609- }
4610- document.querySelectorAll('.video-tile .pip').forEach(el => el.classList.add('hidden'));
6125+ App.state.localStream = null;
6126+ App.state.screenStream = null;
6127+ App.state.micTrack = null;
6128+ App.state.camTrack = null;
6129+ App.state.bannerDismissed = false;
6130+ const main = document.getElementById('vid-local-main');
6131+ const pip = document.getElementById('vid-local-pip');
6132+ if (main) main.srcObject = null;
6133+ if (pip) pip.srcObject = null;
6134+ const ltile = document.getElementById('tile-local');
6135+ if (ltile) { ltile.classList.add('empty'); ltile.classList.remove('screen'); }
6136+ document.querySelectorAll('#video-area .video-tile .pip').forEach(el => el.classList.add('hidden'));
46116137 /* Reset toolbar buttons back to the initial off state. */
46126138 for (const [id, label] of [['tb-mic', 'Mic off'], ['tb-cam', 'Cam off'], ['tb-screen', 'Screen off']]) {
46136139 const btn = document.getElementById(id);
6140+ if (!btn) continue;
46146141 btn.classList.remove('on');
46156142 btn.classList.add('off');
46166143 btn.querySelector('.nowrap').textContent = label;
46176144 }
46186145 updateConnPill();
4619-}
4620-
4621-/* Set the disabled state of the in-call controls that depend on an open
4622- peer connection (toolbar media buttons, chat input, file picker). After
4623- a peer hangup we leave the user on the call view so they can browse the
4624- chat log and downloaded files, but everything that needs the channel is
4625- disabled. */
4626-function setInCallControlsEnabled(enabled) {
4627- for (const id of ['tb-mic', 'tb-cam', 'tb-screen']) {
4628- const btn = document.getElementById(id);
4629- if (btn) btn.disabled = !enabled;
4630- }
4631- const chatInput = document.getElementById('chat-text');
4632- const chatSend = document.getElementById('chat-send');
4633- const filesDrop = document.getElementById('files-drop');
4634- if (chatInput) chatInput.disabled = !enabled;
4635- /* When re-enabling, leave chatSend to updateChatCounter (which gates on
4636- byte count). When disabling, force it off. */
4637- if (chatSend && !enabled) chatSend.disabled = true;
4638- if (filesDrop) filesDrop.classList.toggle('disabled', !enabled);
4639-}
4640-
4641-function resetSession(opts) {
4642- teardownConnection(opts);
4643- App.state.role = null;
4644- updateRoleBadge();
4645- const dlg = document.getElementById('peer-left-dialog');
4646- if (dlg && dlg.open) dlg.close();
6146+ App.banner.refresh();
46476147 }
46486148
46496149 function hangup(opts) {
4650- App.log.info('app', 'hangup');
4651- resetSession(opts);
6150+ App.log.info('app', 'hangup (leave call)');
6151+ teardownAll(opts);
46526152 showView('view-welcome');
46536153 }
46546154
4655-/* Peer told us they're leaving via the chat data channel. Show a modal,
4656- drop a chat-system breadcrumb, and tear down the connection — but keep
4657- the user on the call view so they can still browse chat history and any
4658- files that already finished transferring. */
4659-function onPeerHangup() {
4660- if (App.state.peerHungUp) return; /* idempotent — bye may arrive twice */
4661- App.state.peerHungUp = true;
4662- App.log.info('app', 'peer hung up');
4663- if (App.chat && App.chat.appendSystem) App.chat.appendSystem('peer hung up');
4664- teardownConnection({ sendBye: false });
4665- setInCallControlsEnabled(false);
4666- App.state.peerHungUp = false;
4667- const dlg = document.getElementById('peer-left-dialog');
4668- if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
4669- try { dlg.showModal(); } catch (_) { /* already-open guard */ }
4670- }
4671-}
4672-
46736155 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
46746156 else wire();
46756157 </script>