multi-peer calls
MREADME.md
@@ -1,17 +1,22 @@
# WebRTC Tool
A single-page tool for direct peer-to-peer audio, video, chat, and file
transfer between two browsers, using WebRTC for the media/data path. The
transfer between browsers, using WebRTC for the media/data path. The
client is one self-contained `index.html` with no build step. An optional
signaling server (~500 lines of C, POSIX `poll`, no dependencies) lets
signaling server (~600 lines of C, POSIX `poll`, no dependencies) lets
peers connect by sharing a short room code instead of pasting SDP blobs.
## What it does
- **Two-peer call.** Microphone, camera, screen share. Standard WebRTC.
- **Chat** over a text data channel.
- **Multi-peer calls.** Microphone, camera, screen share. Add
participants one at a time; each is an independent pairwise connection,
and they tile into a conference grid. Standard WebRTC.
- **Chat** over a text data channel, fanned out to every connected peer.
- **File transfer** over a separate data channel with backpressure,
cancellable mid-upload.
cancellable mid-upload, sent to the peers you select. The receiver gets
an accept/deny prompt; on accept the file is streamed straight to a
chosen location on disk (via the File System Access API, with an
in-memory download fallback for browsers that lack it, e.g. Firefox)
- **Manual signaling.** Generate an offer, copy/paste it to the other
peer, paste back their answer. No backend required.
- **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.
## How it works
WebRTC requires the two peers to exchange Session Description Protocol
(SDP) blobs (offer/answer) before media can flow. After that exchange,
the connection is peer-to-peer; the signaling channel is no longer used.
WebRTC requires each pair of peers to exchange Session Description
Protocol (SDP) blobs (offer/answer) before media can flow. After that
exchange, the connection is peer-to-peer; the signaling channel is no
longer used. A call with more than two participants is simply several of
these pairwise connections, each set up independently.
This tool offers two ways to do that exchange:
@@ -35,9 +42,12 @@ This tool offers two ways to do that exchange:
2. **Auto.** Both peers enter the same room code into the signaling
server's UI. The server stores the offer briefly, hands it to the
other peer when they ask, and is forgotten as soon as the answer
has been delivered. Rooms expire after 5 minutes of inactivity.
has been delivered. Rooms expire after 5 minutes of inactivity. Each
room carries a single offer/answer pair — one pairwise link — so a
mesh of more than two peers is built one connection (one room code) at
a time.
The signaling server understands four endpoints:
The signaling server understands five endpoints:
```
POST /room/<code>/offer POST /room/<code>/answer
@@ -186,19 +196,7 @@ $HTTP["url"] =~ "^/(room/|health$)" {
- **Server capacity is hard-coded in `server/signal.c`:** 1024 rooms.
Rooms expire after 5 minutes of inactivity and are also deleted immediately
once the answer reaches the initiator; a GC sweep runs every 60 s.
Each connection pre-allocates a ~68 KB request buffer, so the BSS reaches
~140 MB — Linux only touches the pages on demand, so an idle server
uses around 10 MB resident.
## Repository layout
```
.
├── index.html # client (one self-contained page, no build)
├── server/
│ └── signal.c # signaling server (~500 lines, POSIX poll, no deps)
├── Containerfile # multi-stage build, static-musl binary, alpine runtime
├── compose.yml # podman/docker compose: builds image, exposes :8080
├── entrypoint.sh # copies index.html into the bind-mounted /static
└── README.md
```
- **ICE servers.** The client defaults to a public STUN server and lets you
add your own STUN/TURN entries in the configure screen. STUN is enough for
most networks, but two peers behind symmetric NATs won't connect without a
**TURN** relay — add one if connections stall in "checking".
Mindex.html
@@ -2,8 +2,33 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=500">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebRTC Tool</title>
<script>
/* Responsive viewport with a soft minimum: render at the real device width
when there's enough room (≥ MIN_WIDTH CSS px), otherwise lock the layout
viewport to MIN_WIDTH so a narrow device scales the page down to fit
instead of breaking layout. Re-runs on rotation / window resize so a
phone that lands above the threshold in landscape gets the responsive
layout there. */
(function () {
var MIN_WIDTH = 500;
var meta = document.querySelector('meta[name="viewport"]');
var responsive = 'width=device-width, initial-scale=1';
function update() {
/* `screen.width` reflects the physical device width in CSS pixels and
tracks orientation on every modern browser. It's independent of the
current viewport meta, so we can safely use it to *decide* whether
to switch viewports without feedback loops. */
var w = (window.screen && window.screen.width) || window.innerWidth;
var want = w < MIN_WIDTH ? 'width=' + MIN_WIDTH : responsive;
if (meta.getAttribute('content') !== want) meta.setAttribute('content', want);
}
update();
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
})();
</script>
<style>
:root {
--bg: #0d1117;
@@ -105,7 +130,7 @@
#app { height: 100%; display: flex; flex-direction: column; overflow: hidden; }
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.view.hidden { display: none; }
#view-welcome, #view-configure, #view-exchange, #view-sdp-inspect { overflow-y: auto; }
#view-welcome, #view-sdp-inspect { overflow-y: auto; }
/* SDP inspector */
.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 @@
@media (max-width: 900px) {
.call-body { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
}
/* Conference grid: a vertical stack of flex rows. App.tiles.relayout picks
the rows × cols that maximises tile area for the current container and
tile count, then partitions tiles into row containers and sets row
height + tile width inline. Each row centres its tiles, so an incomplete
last row (e.g. 3 tiles in a 2-col layout) is centred instead of
left-aligned. All tiles always visible; no scroll. */
.video-area {
background: #000;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
padding: 12px;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.video-tile { position: relative; background: #050608; border-radius: var(--radius); overflow: hidden; min-height: 0; display: flex; align-items: center; justify-content: center; }
.video-row {
display: flex;
flex-direction: row;
justify-content: center;
gap: 8px;
/* height set inline by relayout */
}
.video-tile {
position: relative;
background: #050608;
border-radius: var(--radius);
overflow: hidden;
display: flex; align-items: center; justify-content: center;
flex: none;
min-width: 0; min-height: 0;
height: 100%;
/* width set inline by relayout */
}
.video-tile > video { width: 100%; height: 100%; object-fit: contain; background: #050608; display: block; }
.video-tile > .vid-pip { display: none; } /* PIP video is inside .pip wrapper, not direct child */
/* Per-peer audio element sits invisibly inside the tile so its lifetime
matches the peer's. Hidden but autoplay still works. */
.video-tile > audio.audio-remote { display: none; }
.video-tile .leave-peer {
position: absolute; top: 6px; right: 6px;
background: rgba(0,0,0,0.55); color: white;
border: none; border-radius: 4px;
width: 22px; height: 22px; padding: 0;
cursor: pointer; opacity: 0; transition: opacity 120ms;
display: flex; align-items: center; justify-content: center;
z-index: 3;
}
.video-tile:hover .leave-peer { opacity: 1; }
.video-tile .leave-peer:hover { background: var(--err); }
.video-tile .conn-pill {
position: absolute; top: 6px; left: 6px;
background: rgba(0,0,0,0.55); color: white;
padding: 2px 8px; border-radius: 10px;
font-size: 10px; letter-spacing: 0.3px;
pointer-events: none;
}
.video-tile .conn-pill.connected { background: rgba(63,185,80,0.55); }
.video-tile .conn-pill.connecting,
.video-tile .conn-pill.new { background: rgba(210,153,34,0.55); }
.video-tile .conn-pill.failed,
.video-tile .conn-pill.disconnected,
.video-tile .conn-pill.closed { background: rgba(248,81,73,0.55); }
.video-tile .conn-pill.connected { display: none; } /* hide once steady */
/* In-call banner shown when there are no peers yet. Sits between the
topbar and call-body, full-width, dismisses permanently on first join. */
.call-banner {
flex: none;
display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 10px 16px;
background: rgba(47,129,247,0.10);
border-bottom: 1px solid rgba(47,129,247,0.30);
color: var(--text);
font-size: 13px;
}
.call-banner.hidden { display: none; }
.call-banner button { padding: 6px 12px; }
/* Add Participant dialog reuses the role-picker styles but in compact
form (smaller padding, single column). */
.role-picker.compact { grid-template-columns: 1fr; gap: 8px; margin-top: 8px; }
.role-picker.compact button { padding: 12px 14px; }
.role-picker.compact .role-title { font-size: 14px; }
.role-picker.compact .role-desc { font-size: 12px; }
.video-tile.screen > video { cursor: zoom-in; }
.video-tile.screen > video:fullscreen { cursor: zoom-out; }
.video-tile .tile-label {
@@ -367,7 +466,7 @@
.video-tile .pip.hidden { display: none; }
.video-tile .pip video { width: 100%; height: 100%; object-fit: cover; background: #050608; display: block; }
@media (max-width: 900px) {
.video-area { grid-template-columns: 1fr; grid-template-rows: repeat(2, minmax(0, 1fr)); }
.video-area { /* layout still computed in JS */ }
}
.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 @@
cursor: pointer; font-size: 14px; line-height: 1;
}
.file-item .row-close:hover { background: var(--bg-elev); color: var(--text); }
.file-item .req-actions { display: flex; gap: 6px; margin-top: 6px; }
.file-item .req-actions button {
border: 1px solid var(--border); border-radius: 4px; padding: 3px 12px;
font-size: 12px; cursor: pointer;
}
.file-item .req-actions .btn-accept { background: var(--accent); color: #fff; border-color: var(--accent); }
.file-item .req-actions .btn-deny { background: transparent; color: var(--text-dim); }
.file-item .req-actions button:hover { filter: brightness(1.1); }
/* Stats */
.stats-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; }
.stats-table td { padding: 4px 6px; border-bottom: 1px solid var(--border); }
.stats-table td:first-child { color: var(--text-dim); width: 45%; }
.stats-head { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; }
.stats-head h3 { margin: 0; flex: none; font-size: 14px; }
.stats-head select { flex: 1; min-width: 0; }
/* Recipient chip bar (chat + files) */
.recipients {
display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
margin: 6px 0 4px; padding: 4px 0;
font-size: 11.5px; color: var(--text-dim);
}
.recipients .label { color: var(--text-faint); margin-right: 4px; }
.recipients .chip {
display: inline-flex; align-items: center;
padding: 2px 8px; border-radius: 999px;
background: var(--bg-elev-2); border: 1px solid var(--border);
color: var(--text-dim);
cursor: pointer; user-select: none;
transition: background 80ms, border-color 80ms, color 80ms;
font-size: 11px;
}
.recipients .chip.on { background: rgba(47,129,247,0.18); border-color: var(--accent); color: var(--text); }
.recipients .chip:hover { background: var(--bg-elev); }
.recipients .chip.on:hover { filter: brightness(1.15); }
.recipients .empty { color: var(--text-faint); font-style: italic; }
/* Delivery popup (chat message click) */
.chat-msg.has-detail { cursor: pointer; }
.chat-msg .detail {
display: none;
margin-top: 6px; padding-top: 6px;
border-top: 1px solid var(--border);
font-size: 10.5px; color: var(--text-dim);
}
.chat-msg .detail .row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
.chat-msg .detail .state { font-variant-numeric: tabular-nums; }
.chat-msg .detail .state.acked { color: var(--ok); }
.chat-msg .detail .state.sent { color: var(--warn); }
.chat-msg .detail .state.failed,
.chat-msg .detail .state.closed { color: var(--err); }
.chat-msg.open .detail { display: block; }
/* Files row click-detail (per-recipient progress) */
.file-item.has-detail { cursor: pointer; }
.file-item .detail {
display: none;
margin-top: 6px; padding-top: 6px;
border-top: 1px solid var(--border);
font-size: 10.5px;
}
.file-item .detail .row { display: flex; justify-content: space-between; gap: 8px; padding: 1px 0; }
.file-item .detail .state { font-variant-numeric: tabular-nums; }
.file-item .detail .state.delivered { color: var(--ok); }
.file-item .detail .state.sent { color: var(--ok); }
.file-item .detail .state.sending { color: var(--warn); }
.file-item .detail .state.queued { color: var(--text-dim); }
.file-item .detail .state.failed,
.file-item .detail .state.closed,
.file-item .detail .state.cancelled { color: var(--err); }
.file-item.open .detail { display: block; }
/* Welcome: freestanding Start block (no card chrome). The input and the
Start button sit side-by-side and stretch to a matching height. */
.welcome-start { margin: 24px 0 32px; max-width: 520px; }
.welcome-start label.field { margin-bottom: 8px; }
.welcome-start-row {
display: flex;
gap: 8px;
align-items: stretch;
}
.welcome-start-row > input {
flex: 1;
min-width: 0;
font-size: 16px;
padding: 10px 14px;
}
.welcome-start-btn {
flex: none;
font-size: 16px;
padding: 10px 22px;
line-height: 1.4;
}
/* Settings: per-peer target picker */
.settings-target {
background: var(--bg-elev-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
margin: 24px 0 14px;
}
.settings-target label.field { margin-bottom: 0; }
.settings-target .small { margin-top: 4px; }
/* Modal dialog (native <dialog>) — used for peer-left notification. */
dialog {
@@ -499,7 +698,17 @@
dialog::backdrop { background: rgba(0,0,0,0.55); }
dialog h2 { margin: 0 0 8px; padding: 0; border: none; font-size: 17px; }
dialog p { margin: 0 0 16px; color: var(--text-dim); }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* Wide, scrollable variant of <dialog> for the configure step. */
dialog.view-modal {
width: min(720px, calc(100vw - 32px));
max-width: none;
max-height: calc(100vh - 32px);
overflow-y: auto;
padding: 24px 26px;
}
dialog.view-modal h1 { margin: 0 0 8px; padding: 0; border: none; font-size: 22px; }
dialog.view-modal .container { padding: 0; max-width: none; }
.step-dialog-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
.step-dialog-head h2 { margin: 0; }
.step-dialog-room {
@@ -617,28 +826,21 @@
<h1>WebRTC Tool</h1>
<p class="lede">
A direct peer-to-peer call tool — a fallback for video, screen share, chat, and file transfer
when your usual conferencing software isn't cooperating. Two peers can connect either via a
shared room code (using a small relay server that only shuttles the offer/answer) or by
pasting two short JSON blobs to each other. Browser-only on each end: no install, no account.
when your usual conferencing software isn't cooperating. Connect to one or more participants
either via a shared room code (using a small relay server that only shuttles the offer/answer)
or by pasting two short JSON blobs. Browser-only on each end: no install, no account.
</p>
<div class="card">
<h2>Pick your role</h2>
<p class="small">One side starts the call (creates the offer); the other side joins it.</p>
<div class="role-picker">
<button data-role="initiator">
<span class="role-title">Start a call</span>
<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>
</button>
<button data-role="joiner">
<span class="role-title">Join a call</span>
<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>
</button>
<button data-role="loopback" class="role-loopback">
<span class="role-title">Loopback test (same tab)</span>
<span class="role-desc">Run both peers in this tab. Useful for verifying that media and the call UI work locally.</span>
</button>
</div>
<div class="welcome-start">
<label class="field welcome-start-field">
<span>Your display name</span>
<div class="welcome-start-row">
<input type="text" id="welcome-username" placeholder="Anonymous" maxlength="64" autocomplete="off" spellcheck="false">
<button id="welcome-start" class="primary welcome-start-btn">Start call →</button>
</div>
</label>
<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>
<p class="small" style="margin-top:6px">Opens an empty call. Invite participants from inside the call (room code or SDP paste).</p>
</div>
<div class="card">
@@ -652,7 +854,7 @@
</section>
<!-- ============== CONFIGURE ============== -->
<section id="view-configure" class="view hidden">
<dialog id="view-configure" class="view-modal">
<div class="container">
<h1>Configure <span id="role-title-cfg" class="role-badge"></span></h1>
<p class="lede" id="cfg-lede">Set up media and ICE servers, then continue to the signaling step.</p>
@@ -826,10 +1028,10 @@
</span>
</div>
</div>
</section>
</dialog>
<!-- ============== EXCHANGE ============== -->
<section id="view-exchange" class="view hidden">
<dialog id="view-exchange" class="view-modal">
<div class="container">
<h1>Signaling exchange <span id="role-title-exch" class="role-badge"></span></h1>
<p class="lede" id="exch-lede"></p>
@@ -860,7 +1062,7 @@
</span>
</div>
</div>
</section>
</dialog>
<!-- ============== SDP INSPECTOR ============== -->
<section id="view-sdp-inspect" class="view hidden">
@@ -890,32 +1092,23 @@
<!-- ============== CALL ============== -->
<section id="view-call" class="view hidden">
<div id="call-banner" class="call-banner hidden">
<span>You're in an empty call. Add a participant to get started.</span>
<button id="call-banner-add" class="primary">+ Add participant</button>
</div>
<div class="call-body">
<div class="video-area">
<div class="video-area" id="video-area">
<div class="video-tile empty" id="tile-local">
<video id="vid-local-main" autoplay muted playsinline></video>
<video class="vid-main" id="vid-local-main" autoplay muted playsinline></video>
<div class="pip hidden" id="pip-local">
<video id="vid-local-pip" autoplay muted playsinline></video>
<video class="vid-pip" id="vid-local-pip" autoplay muted playsinline></video>
</div>
<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>
<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>
<span class="tile-label">you</span>
</div>
<div class="video-tile empty" id="tile-remote">
<video id="vid-remote-main" autoplay muted playsinline></video>
<div class="pip hidden" id="pip-remote">
<video id="vid-remote-pip" autoplay muted playsinline></video>
</div>
<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>
<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>
<span class="tile-label">peer</span>
<span class="tile-label" id="tile-local-label">you</span>
</div>
<!-- Per-peer tiles inserted here by App.tiles. -->
</div>
<!-- Plays the peer's audio independently of any video element so it
keeps working even when the remote tile is showing the empty state
(i.e. mic-only call) — display:none on a <video> suppresses audio
output in some browsers (notably Firefox). -->
<audio id="audio-remote" autoplay></audio>
<aside class="sidebar">
<div class="tabs">
@@ -927,6 +1120,7 @@
<div class="tab-pane active" data-pane="chat">
<div id="chat-log" class="chat-log"></div>
<div id="chat-recipients" class="recipients"></div>
<div class="chat-input">
<input type="text" id="chat-text" placeholder="Type a message and press Enter" autocomplete="off">
<button id="chat-send" class="primary">Send</button>
@@ -935,6 +1129,7 @@
</div>
<div class="tab-pane" data-pane="files">
<div id="files-recipients" class="recipients"></div>
<div id="files-drop" class="files-drop">
<p><strong>Drop a file here</strong> or <a href="#" id="files-pick">pick one</a>.</p>
<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 @@
</div>
<div class="tab-pane" data-pane="settings">
<h3>Video</h3>
<p class="small">Resolution and framerate are applied by restarting the camera. Bitrate and degradation preference apply instantly without touching the camera.</p>
<h3>Profile</h3>
<label class="field">
<span>Username</span>
<div class="row" style="gap:6px; align-items:stretch">
<input type="text" id="rt-username" maxlength="64" autocomplete="off" style="flex:1">
<button id="rt-username-apply" class="ghost">Save</button>
</div>
</label>
<p class="small">Shown to other participants in chat and on their video tile. Saved locally; broadcast over each open chat channel.</p>
<h3>Camera capture</h3>
<p class="small">Resolution and framerate restart the camera when applied. New peers inherit these as their initial values.</p>
<div class="grid-2">
<label class="field">
<span>Width (px, 0 = auto)</span>
@@ -962,48 +1167,77 @@
<span>Frame rate (fps, 0 = auto)</span>
<input type="number" id="rt-v-fps" min="0" max="120">
</label>
</div>
<button id="rt-v-cap-apply" class="ghost">Apply camera</button>
<h3>Screen capture</h3>
<p class="small">Resolution and framerate restart the share — you'll be re-prompted for the source.</p>
<div class="grid-2">
<label class="field">
<span>Max send bitrate (kbps, 0 = unset)</span>
<input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
<span>Width (px, 0 = auto)</span>
<input type="number" id="rt-s-w" min="0" max="7680">
</label>
<label class="field">
<span>Degradation preference</span>
<select id="rt-v-degrade">
<option value="balanced">balanced</option>
<option value="maintain-framerate">maintain-framerate</option>
<option value="maintain-resolution">maintain-resolution</option>
</select>
<span>Height (px, 0 = auto)</span>
<input type="number" id="rt-s-h" min="0" max="4320">
</label>
<label class="field">
<span>Frame rate (fps, 0 = auto)</span>
<input type="number" id="rt-s-fps" min="0" max="120">
</label>
</div>
<button id="rt-v-apply" class="ghost">Apply</button>
<button id="rt-s-cap-apply" class="ghost">Apply screen</button>
<h3>Send codec</h3>
<p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
<h3>Audio</h3>
<p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
<label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
<label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
<label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
<div class="grid-2">
<label class="field">
<span>Send codec</span>
<select id="rt-codec">
<option value="auto">auto</option>
<span>Channels</span>
<select id="rt-a-channels">
<option value="1">1 (mono)</option>
<option value="2">2 (stereo)</option>
</select>
</label>
<label class="field">
<span>Sample rate (Hz, 0 = auto)</span>
<input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
</label>
</div>
<button id="rt-codec-apply" class="ghost">Apply</button>
<button id="rt-a-apply" class="ghost">Apply audio</button>
<h3>Screen share</h3>
<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>
<div class="grid-2">
<div class="settings-target">
<label class="field">
<span>Width (px, 0 = auto)</span>
<input type="number" id="rt-s-w" min="0" max="7680">
<span>Per-peer encoder target</span>
<select id="settings-target">
<option value="all">All connected peers</option>
</select>
</label>
<p class="small">Encoder settings below apply to the selected target. Choosing "All connected peers" also updates the values new peers inherit.</p>
</div>
<h3>Camera encoder</h3>
<p class="small">Max bitrate and degradation preference are applied instantly without touching the camera.</p>
<div class="grid-2">
<label class="field">
<span>Height (px, 0 = auto)</span>
<input type="number" id="rt-s-h" min="0" max="4320">
<span>Max send bitrate (kbps, 0 = unset)</span>
<input type="number" id="rt-v-maxbr" min="0" max="20000" step="50">
</label>
<label class="field">
<span>Frame rate (fps, 0 = auto)</span>
<input type="number" id="rt-s-fps" min="0" max="120">
<span>Degradation preference</span>
<select id="rt-v-degrade">
<option value="balanced">balanced</option>
<option value="maintain-framerate">maintain-framerate</option>
<option value="maintain-resolution">maintain-resolution</option>
</select>
</label>
</div>
<button id="rt-v-enc-apply" class="ghost">Apply camera encoder</button>
<h3>Screen encoder</h3>
<div class="grid-2">
<label class="field">
<span>Max send bitrate (kbps, 0 = unset)</span>
<input type="number" id="rt-s-maxbr" min="0" max="50000" step="100">
@@ -1017,31 +1251,27 @@
</select>
</label>
</div>
<button id="rt-s-apply" class="ghost">Apply</button>
<button id="rt-s-enc-apply" class="ghost">Apply screen encoder</button>
<h3>Audio</h3>
<p class="small">Echo/noise/AGC apply live. Channels and sample rate take effect by restarting the microphone.</p>
<label class="row"><input type="checkbox" id="rt-a-aec"> Echo cancellation</label>
<label class="row"><input type="checkbox" id="rt-a-ns" > Noise suppression</label>
<label class="row"><input type="checkbox" id="rt-a-agc"> Auto gain control</label>
<h3>Send codec</h3>
<p class="small">Applies to both camera and screen-share encoders. Picks from the codecs negotiated at signaling time.</p>
<div class="grid-2">
<label class="field">
<span>Channels</span>
<select id="rt-a-channels">
<option value="1">1 (mono)</option>
<option value="2">2 (stereo)</option>
<span>Send codec</span>
<select id="rt-codec">
<option value="auto">auto</option>
</select>
</label>
<label class="field">
<span>Sample rate (Hz, 0 = auto)</span>
<input type="number" id="rt-a-rate" min="0" max="96000" step="1000">
</label>
</div>
<button id="rt-a-apply" class="ghost">Apply</button>
<button id="rt-codec-apply" class="ghost">Apply codec</button>
</div>
<div class="tab-pane" data-pane="stats">
<h3>Peer connection <button id="stats-export" class="ghost small" style="float:right">Export</button></h3>
<div class="stats-head">
<h3>Peer</h3>
<select id="stats-peer"><option value="">(no peers)</option></select>
<button id="stats-export" class="ghost small">Export</button>
</div>
<table class="stats-table" id="stats-table"><tbody></tbody></table>
</div>
</aside>
@@ -1060,6 +1290,7 @@
</div>
<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>
<span class="spacer"></span>
<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>
<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>
<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>
</div>
@@ -1086,11 +1317,28 @@
</aside>
</section>
<dialog id="peer-left-dialog">
<h2>Peer left the call</h2>
<p>The other person hung up. The call has ended.</p>
<dialog id="add-peer-dialog">
<h2>Add participant</h2>
<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>
<h3 style="margin-top:14px">Your role for this connection</h3>
<div class="role-picker compact">
<button type="button" data-role="initiator">
<span class="role-title">Start (create offer)</span>
<span class="role-desc">You'll generate the offer; the other side joins.</span>
</button>
<button type="button" data-role="joiner">
<span class="role-title">Join (apply offer)</span>
<span class="role-desc">The other side started; you apply their offer and send back the answer.</span>
</button>
<button type="button" data-role="loopback" class="role-loopback">
<span class="role-title">Loopback (same tab)</span>
<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>
</button>
</div>
<div class="dialog-actions">
<button id="peer-left-ok" class="primary">OK</button>
<button id="add-peer-cancel" class="ghost">Cancel</button>
</div>
</dialog>
@@ -1185,11 +1433,14 @@ App.theme = (() => {
App.progress = (() => {
let modalCancelHandler = null;
function active() {
/* Pick the progress widget inside the currently visible view. */
/* Pick the progress widget inside the currently visible view OR open
dialog (configure / exchange are <dialog>s now, not .view sections). */
const candidates = ['cfg-progress', 'exch-progress'];
for (const id of candidates) {
const el = document.getElementById(id);
if (!el) continue;
const dlg = el.closest('dialog');
if (dlg && dlg.open) return el;
const view = el.closest('.view');
if (view && !view.classList.contains('hidden')) return el;
}
@@ -1263,35 +1514,92 @@ App.progress = (() => {
/* -------------------------------------------------------------------------
State
Multi-peer model: each remote participant has its own PeerCtx (its own
RTCPeerConnection, data channels, transceivers, remote streams, and
peer-published media-state). Local media (mic/cam/screen capture) is
captured once and fanned out to every peer's senders via replaceTrack —
so capture state stays on App.state, not on a peer.
------------------------------------------------------------------------- */
App.state = {
role: null, /* 'initiator' | 'joiner' | 'loopback' */
pc: null, /* primary RTCPeerConnection */
pcB: null, /* loopback only: secondary RTCPeerConnection */
dcChat: null,
dcFiles: null,
micTransceiver: null,
camTransceiver: null,
screenTransceiver: null,
localStream: null, /* gUM result */
screenStream: null,
peers: new Map(), /* peerId (string) -> PeerCtx */
pendingPeer: null, /* PeerCtx being set up via the Add Participant dialog */
localStream: null, /* gUM result (mic + cam, synthetic) */
screenStream: null, /* gDM result */
micTrack: null, /* live local mic MediaStreamTrack, or null */
camTrack: null, /* live local cam MediaStreamTrack, or null */
iceWarmupStream: null, /* kept alive (muted) to unlock LAN ICE candidates in Firefox */
remoteStream: null, /* assembled from incoming audio + cam tracks */
remoteScreenStream: null,/* assembled from incoming screen track */
peerMediaState: { mic: false, cam: false, screen: false }, /* sent by peer over the chat dc */
username: 'Anonymous',
settings: defaultSettings(),
/* Banner shown when peers is empty. Once dismissed (auto on first join)
it stays dismissed for the rest of the session, even if all peers leave. */
bannerDismissed: false,
};
/* PeerCtx — everything specific to one remote participant. */
function newPeerCtx(opts) {
return {
id: opts.id,
role: opts.role, /* 'initiator' | 'joiner' | 'loopback' */
label: opts.label || opts.id, /* logger tag */
username: opts.username || 'Anonymous',
pc: null,
dcChat: null,
dcFiles: null,
micTransceiver: null,
camTransceiver: null,
screenTransceiver: null,
remoteStream: null, /* mic + cam from this peer */
remoteScreenStream: null, /* screen from this peer */
peerMediaState: { mic: false, cam: false, screen: false },
/* Per-peer media settings (setParameters-tunable, no renegotiation). */
videoMaxBitrateKbps: 0,
videoDegradationPreference: 'balanced',
screenMaxBitrateKbps: 0,
screenDegradationPreference: 'maintain-resolution',
sendVideoCodec: 'auto',
/* Loopback only: this peer's synthetic pcB. Each loopback peer has its
own pcB so multiple loopbacks can coexist. */
loopbackB: null,
/* Signaling lifecycle: an in-flight AbortController for auto-mode fetches
so cancelSetup / hangup can abort the long-poll cleanly. */
signalAbort: null,
/* True if the chat dc has already received a 'bye' from this peer, so we
don't run the leave-handler twice. */
leaveReceived: false,
};
}
function generatePeerId() {
/* 8 lowercase hex chars — enough to disambiguate "Anonymous" peers. */
const a = new Uint8Array(4);
crypto.getRandomValues(a);
return Array.from(a, b => b.toString(16).padStart(2, '0')).join('');
}
/* Convenience: iterate live peers (any state); callers usually want only
those with an open chat channel and a connected pc. */
function peerList() { return Array.from(App.state.peers.values()); }
function peersWithOpenChat() {
return peerList().filter(p => p.dcChat && p.dcChat.readyState === 'open');
}
function peersWithOpenFiles() {
return peerList().filter(p => p.dcFiles && p.dcFiles.readyState === 'open');
}
function defaultSettings() {
const stored = localStorage.getItem('webrtc-tool.iceServers');
let ice;
try { ice = stored ? JSON.parse(stored) : [{ urls: 'stun:stun.l.google.com:19302' }]; }
catch (_) { ice = [{ urls: 'stun:stun.l.google.com:19302' }]; }
/* Audio + video gUM constraints are global (one local capture, fanned out
to all peers). bitrate/codec/degradation are stored here as defaults for
newly-added peers, and copied into each PeerCtx on creation. */
return {
iceServers: ice,
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1, sampleRate: 0, deviceId: '' },
opus: { stereo: false, fec: true, dtx: true, cbr: false, maxAverageBitrate: 0 },
video: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'balanced', deviceId: '' },
screen: { width: 0, height: 0, frameRate: 0, maxBitrateKbps: 0, degradationPreference: 'maintain-resolution' },
preferredVideoCodec: 'auto',
sendVideoCodec: 'auto',
@@ -1300,6 +1608,17 @@ function defaultSettings() {
};
}
function loadUsername() {
try {
const raw = localStorage.getItem('webrtc-tool.username');
if (typeof raw === 'string' && raw.trim()) return raw.trim().slice(0, 64);
} catch (_) {}
return 'Anonymous';
}
function saveUsername(name) {
try { localStorage.setItem('webrtc-tool.username', name); } catch (_) {}
}
function loadSignaling() {
/* Default server URL is the page's own origin — the natural assumption is
that the relay is colocated with the static page. For file:// loads
@@ -1482,13 +1801,18 @@ App.codec = (() => {
/* -------------------------------------------------------------------------
Media: gUM/gDM, pre-allocated transceivers, replaceTrack
Local capture (mic, cam, screen) happens once on App.state and is fanned
out to every PeerCtx's sender via replaceTrack — so per-peer encoders
share a single source track. Per-peer encoder parameters (bitrate,
degradation, codec) live on the PeerCtx and are pushed via setParameters
without renegotiation.
------------------------------------------------------------------------- */
App.media = (() => {
/* Reorder the codec list so the user's preferred video codec is first.
Must be called before createOffer (initiator/loopback A) or createAnswer
(joiner / loopback B). With 'auto' we don't touch the list, letting the
browser's default order win. */
function applyVideoCodecPreference(pc) {
/* Reorder a peer's codec list so the user's preferred RECEIVE codec is first.
Must be called before createOffer or createAnswer for that peer. With
'auto' we don't touch the list, letting the browser's default order win. */
function applyVideoCodecPreference(peer) {
const pref = App.state.settings.preferredVideoCodec;
if (!pref || pref === 'auto') return;
if (typeof RTCRtpSender === 'undefined' || !RTCRtpSender.getCapabilities) return;
@@ -1500,46 +1824,70 @@ App.media = (() => {
const sub = (c.mimeType || '').toLowerCase().split('/')[1] || '';
(sub === wanted ? head : tail).push(c);
}
if (!head.length) { App.log.warn('media', 'preferred codec not available', pref); return; }
if (!head.length) { App.log.warn('media', peer.label, 'preferred codec not available', pref); return; }
const ordered = [...head, ...tail];
for (const t of pc.getTransceivers()) {
for (const t of peer.pc.getTransceivers()) {
const kind = (t.receiver && t.receiver.track && t.receiver.track.kind)
|| (t.sender && t.sender.track && t.sender.track.kind);
const isVideo = kind === 'video'
|| t === App.state.camTransceiver
|| t === App.state.screenTransceiver;
|| t === peer.camTransceiver
|| t === peer.screenTransceiver;
if (!isVideo || !t.setCodecPreferences) continue;
try { t.setCodecPreferences(ordered); }
catch (e) { App.log.warn('media', 'setCodecPreferences failed', e.message); }
catch (e) { App.log.warn('media', peer.label, 'setCodecPreferences failed', e.message); }
}
App.log.info('media', 'preferred video codec', pref);
App.log.info('media', peer.label, 'preferred video codec', pref);
}
/* Pre-allocate three transceivers on the initiator so all toolbar actions
are renegotiation-free (see plan). The joiner gets matching m-sections
from setRemoteDescription and we index transceivers by position. */
function preallocate(pc) {
App.state.micTransceiver = pc.addTransceiver('audio', { direction: 'sendrecv' });
App.state.camTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
App.state.screenTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
App.log.debug('media', 'pre-allocated 1 audio + 2 video transceivers');
/* Pre-allocate three transceivers on the initiator's pc so all toolbar
actions are renegotiation-free (see plan). The joiner gets matching
m-sections from setRemoteDescription and we index transceivers by
position. */
function preallocate(peer) {
peer.micTransceiver = peer.pc.addTransceiver('audio', { direction: 'sendrecv' });
peer.camTransceiver = peer.pc.addTransceiver('video', { direction: 'sendrecv' });
peer.screenTransceiver = peer.pc.addTransceiver('video', { direction: 'sendrecv' });
App.log.debug('media', peer.label, 'pre-allocated 1 audio + 2 video transceivers');
}
function adoptTransceiversFromRemote(pc) {
function adoptTransceiversFromRemote(peer) {
/* For the joiner: after setRemoteDescription, transceivers exist in
the same order the initiator added them (mid 0, 1, 2). They were
auto-created by SRD and default to recvonly because the joiner has
no local tracks yet — but later enabling mic/cam on a recvonly
transceiver would never send. Force sendrecv so the answer SDP
advertises bidirectional intent. */
const ts = pc.getTransceivers();
const ts = peer.pc.getTransceivers();
for (const t of ts) {
try { t.direction = 'sendrecv'; }
catch (e) { App.log.warn('media', 'could not upgrade transceiver to sendrecv', e.message); }
catch (e) { App.log.warn('media', peer.label, 'could not upgrade transceiver to sendrecv', e.message); }
}
peer.micTransceiver = ts[0] || null;
peer.camTransceiver = ts[1] || null;
peer.screenTransceiver = ts[2] || null;
App.log.debug('media', peer.label, 'adopted', ts.length, 'transceivers from remote SDP');
}
/* After a new peer's transceivers are set up, push the currently-live
local tracks into them so the peer immediately receives whatever the
user already enabled before they joined. */
async function publishLocalTracksTo(peer) {
if (peer.micTransceiver && App.state.micTrack) {
try { await peer.micTransceiver.sender.replaceTrack(App.state.micTrack); }
catch (e) { App.log.warn('media', peer.label, 'publish mic failed', e.message); }
}
if (peer.camTransceiver && App.state.camTrack) {
try { await peer.camTransceiver.sender.replaceTrack(App.state.camTrack); }
catch (e) { App.log.warn('media', peer.label, 'publish cam failed', e.message); }
}
if (peer.screenTransceiver && App.state.screenStream) {
const track = App.state.screenStream.getVideoTracks()[0];
if (track) {
try { await peer.screenTransceiver.sender.replaceTrack(track); }
catch (e) { App.log.warn('media', peer.label, 'publish screen failed', e.message); }
}
}
App.state.micTransceiver = ts[0] || null;
App.state.camTransceiver = ts[1] || null;
App.state.screenTransceiver = ts[2] || null;
App.log.debug('media', 'adopted', ts.length, 'transceivers from remote SDP');
applyCamSendParamsFor(peer);
applyScreenSendParamsFor(peer);
}
/* The local preview MediaStream is a synthetic view: we add/remove tracks
@@ -1552,6 +1900,8 @@ App.media = (() => {
const s = localStream();
s.getTracks().filter(t => t.kind === kind).forEach(t => s.removeTrack(t));
if (track) s.addTrack(track);
if (kind === 'audio') App.state.micTrack = track || null;
if (kind === 'video') App.state.camTrack = track || null;
refreshLocalDisplay();
}
/* Decide what plays in the main tile vs the corner PIP for the local side.
@@ -1561,6 +1911,7 @@ App.media = (() => {
const pip = document.getElementById('vid-local-pip');
const pipWrap = document.getElementById('pip-local');
const tile = document.getElementById('tile-local');
if (!main || !tile) return;
const camStream = App.state.localStream;
const hasCam = camStream && camStream.getVideoTracks().length > 0;
const screenStream = App.state.screenStream;
@@ -1580,26 +1931,31 @@ App.media = (() => {
pip.srcObject = null; pipWrap.classList.add('hidden');
}
tile.classList.toggle('empty', !hasCam && !hasScreen);
const micOn = !!(App.state.micTransceiver && App.state.micTransceiver.sender.track);
document.getElementById('mic-muted-local').classList.toggle('hidden', micOn);
}
function refreshRemoteDisplay() {
const main = document.getElementById('vid-remote-main');
const pip = document.getElementById('vid-remote-pip');
const pipWrap = document.getElementById('pip-remote');
const tile = document.getElementById('tile-remote');
const audioEl = document.getElementById('audio-remote');
const camStream = App.state.remoteStream;
const screenStream = App.state.remoteScreenStream;
/* Always route the peer's audio through the dedicated audio element so it
const micOn = !!App.state.micTrack;
const m = document.getElementById('mic-muted-local');
if (m) m.classList.toggle('hidden', micOn);
}
/* Refresh the per-peer remote tile (video / pip / mic-muted badge). The
tile DOM is created and managed by tilesUI; we just push current
srcObjects + visibility into it. */
function refreshRemoteDisplayFor(peer) {
const tile = document.getElementById('tile-' + peer.id);
if (!tile) return;
const main = tile.querySelector('.vid-main');
const pip = tile.querySelector('.vid-pip');
const pipWrap = tile.querySelector('.pip');
const audioEl = tile.querySelector('audio.audio-remote');
const camStream = peer.remoteStream;
const screenStream = peer.remoteScreenStream;
/* Always route the peer's audio through a dedicated audio element so it
plays regardless of whether any video is currently visible. */
if (audioEl && audioEl.srcObject !== camStream) audioEl.srcObject = camStream || null;
/* Track presence isn't enough — replaceTrack(null) on the sender leaves
the receiver's track in place (frozen on last frame). Trust the peer's
broadcast media state when deciding whether to show video. */
const peer = App.state.peerMediaState || { mic: false, cam: false, screen: false };
const hasCam = peer.cam && camStream && camStream.getVideoTracks().length > 0;
const hasScreen = peer.screen && screenStream && screenStream.getVideoTracks().length > 0;
const ms = peer.peerMediaState || { mic: false, cam: false, screen: false };
const hasCam = ms.cam && camStream && camStream.getVideoTracks().length > 0;
const hasScreen = ms.screen && screenStream && screenStream.getVideoTracks().length > 0;
if (hasScreen) {
main.srcObject = screenStream;
tile.classList.add('screen');
@@ -1617,20 +1973,43 @@ App.media = (() => {
pip.srcObject = null; pipWrap.classList.add('hidden');
}
tile.classList.toggle('empty', !hasCam && !hasScreen);
document.getElementById('mic-muted-remote').classList.toggle('hidden', peer.mic);
const m = tile.querySelector('.mic-muted');
if (m) m.classList.toggle('hidden', ms.mic);
}
function refreshRemoteDisplay() {
for (const peer of App.state.peers.values()) refreshRemoteDisplayFor(peer);
}
/* In loopback mode, each loopback peer's pcB needs to "see" the same tracks
pcA is sending so the remote tile gets video back. Called whenever a
sender track changes; iterates every loopback peer. */
function mirrorToLoopback() {
/* In loopback mode, pcB needs to "see" the same tracks pcA is sending so
the remote tile gets video back. Called whenever a sender track changes. */
if (!App.state.pcB) return;
const ts = App.state.pcB.getTransceivers();
const a = App.state.micTransceiver && App.state.micTransceiver.sender.track;
const v = App.state.camTransceiver && App.state.camTransceiver.sender.track;
const s = App.state.screenTransceiver && App.state.screenTransceiver.sender.track;
const fail = label => err => App.log.warn('loopback', 'mirror ' + label + ' failed', err.message);
if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic'));
if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam'));
if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen'));
const a = App.state.micTrack;
const v = App.state.camTrack;
const s = App.state.screenStream && App.state.screenStream.getVideoTracks()[0] || null;
const fail = (label, peerLabel) => err =>
App.log.warn('loopback', peerLabel + ' mirror ' + label + ' failed', err.message);
for (const peer of App.state.peers.values()) {
const pcB = peer.loopbackB;
if (!pcB) continue;
const ts = pcB.getTransceivers();
if (ts[0]) ts[0].sender.replaceTrack(a || null).catch(fail('mic', peer.label));
if (ts[1]) ts[1].sender.replaceTrack(v || null).catch(fail('cam', peer.label));
if (ts[2]) ts[2].sender.replaceTrack(s || null).catch(fail('screen', peer.label));
}
}
/* Fan-out the local track-state to every peer's sender. Failures on one
peer don't block the others. */
async function fanOutTrack(getTransceiver, track, label) {
const tasks = [];
for (const peer of App.state.peers.values()) {
const tr = getTransceiver(peer);
if (!tr) continue;
tasks.push(
tr.sender.replaceTrack(track).catch(e =>
App.log.warn('media', peer.label, 'replaceTrack ' + label + ' failed', e.message))
);
}
if (tasks.length) await Promise.allSettled(tasks);
}
function gumAvailable() {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
@@ -1638,14 +2017,13 @@ App.media = (() => {
/* Detect "device unplugged while active": the track fires 'ended'. Flip the
toolbar button off so the UI reflects reality and the user can pick a
different device. We guard with sender.track === track to ignore the
ended event that fires when *we* swap the track via replaceTrack(). */
different device. We guard with App.state.micTrack/camTrack === track to
ignore the ended event that fires when *we* swap the track via
replaceTrack(). */
function attachTrackEndedHandler(track, kind) {
const sender = kind === 'mic'
? App.state.micTransceiver && App.state.micTransceiver.sender
: App.state.camTransceiver && App.state.camTransceiver.sender;
track.addEventListener('ended', () => {
if (!sender || sender.track !== track) return;
const live = kind === 'mic' ? App.state.micTrack : App.state.camTrack;
if (live !== track) return;
const label = kind === 'mic' ? 'Microphone' : 'Camera';
App.log.warn('media', kind + ' track ended unexpectedly');
App.chat.appendSystem?.(label + ' disconnected.');
@@ -1654,8 +2032,6 @@ App.media = (() => {
}
async function setMic(on) {
const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
if (!sender) return;
const btn = document.getElementById('tb-mic');
if (on) {
if (!gumAvailable()) {
@@ -1677,19 +2053,14 @@ App.media = (() => {
},
});
const track = stream.getAudioTracks()[0];
/* If the call was torn down while gUM was waiting on the user's
permission prompt, the sender's pc is now closed and replaceTrack
would reject — stop the track instead so the OS-level mic
indicator turns off. */
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
throw new Error('call ended before mic prompt resolved');
}
await sender.replaceTrack(track);
setLocalTrack('audio', track);
attachTrackEndedHandler(track, 'mic');
await fanOutTrack(p => p.micTransceiver, track, 'mic');
mirrorToLoopback();
btn.classList.add('on'); btn.classList.remove('off');
btn.querySelector('.nowrap').textContent = 'Mic on';
if (btn) {
btn.classList.add('on'); btn.classList.remove('off');
btn.querySelector('.nowrap').textContent = 'Mic on';
}
App.log.info('media', 'mic on');
applyMediaButtonAvailability?.();
} catch (e) {
@@ -1704,20 +2075,21 @@ App.media = (() => {
if (stream) stream.getTracks().forEach(t => t.stop());
}
} else {
if (sender.track) sender.track.stop();
await sender.replaceTrack(null);
const t = App.state.micTrack;
if (t) t.stop();
setLocalTrack('audio', null);
await fanOutTrack(p => p.micTransceiver, null, 'mic');
mirrorToLoopback();
btn.classList.remove('on'); btn.classList.add('off');
btn.querySelector('.nowrap').textContent = 'Mic off';
if (btn) {
btn.classList.remove('on'); btn.classList.add('off');
btn.querySelector('.nowrap').textContent = 'Mic off';
}
App.log.info('media', 'mic off');
}
broadcastMediaState();
}
async function setCam(on) {
const sender = App.state.camTransceiver && App.state.camTransceiver.sender;
if (!sender) return;
const btn = document.getElementById('tb-cam');
if (on) {
if (!gumAvailable()) {
@@ -1737,16 +2109,15 @@ App.media = (() => {
},
});
const track = stream.getVideoTracks()[0];
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
throw new Error('call ended before camera prompt resolved');
}
await sender.replaceTrack(track);
setLocalTrack('video', track);
attachTrackEndedHandler(track, 'cam');
applyCamSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
await fanOutTrack(p => p.camTransceiver, track, 'cam');
applyCamSendParamsAll();
mirrorToLoopback();
btn.classList.add('on'); btn.classList.remove('off');
btn.querySelector('.nowrap').textContent = 'Cam on';
if (btn) {
btn.classList.add('on'); btn.classList.remove('off');
btn.querySelector('.nowrap').textContent = 'Cam on';
}
App.log.info('media', 'cam on');
applyMediaButtonAvailability?.();
} catch (e) {
@@ -1759,12 +2130,15 @@ App.media = (() => {
if (stream) stream.getTracks().forEach(t => t.stop());
}
} else {
if (sender.track) sender.track.stop();
await sender.replaceTrack(null);
const t = App.state.camTrack;
if (t) t.stop();
setLocalTrack('video', null);
await fanOutTrack(p => p.camTransceiver, null, 'cam');
mirrorToLoopback();
btn.classList.remove('on'); btn.classList.add('off');
btn.querySelector('.nowrap').textContent = 'Cam off';
if (btn) {
btn.classList.remove('on'); btn.classList.add('off');
btn.querySelector('.nowrap').textContent = 'Cam off';
}
App.log.info('media', 'cam off');
}
broadcastMediaState();
@@ -1783,22 +2157,18 @@ App.media = (() => {
},
audio: false,
});
/* If the call ended while the source picker was open, the user has
already selected a source — release it instead of leaving it active. */
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
ms.getTracks().forEach(t => t.stop());
throw new Error('call ended before screen share started');
}
App.state.screenStream = ms;
const track = ms.getVideoTracks()[0];
track.addEventListener('ended', () => stopScreenshare());
if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(track);
applyScreenSendParams(); /* sets both bitrate and degradationPreference in one roundtrip */
await fanOutTrack(p => p.screenTransceiver, track, 'screen');
applyScreenSendParamsAll();
mirrorToLoopback();
refreshLocalDisplay();
const sBtn = document.getElementById('tb-screen');
sBtn.classList.add('on'); sBtn.classList.remove('off');
sBtn.querySelector('.nowrap').textContent = 'Sharing';
if (sBtn) {
sBtn.classList.add('on'); sBtn.classList.remove('off');
sBtn.querySelector('.nowrap').textContent = 'Sharing';
}
App.log.info('media', 'screenshare started');
broadcastMediaState();
}
@@ -1806,28 +2176,33 @@ App.media = (() => {
const ms = App.state.screenStream;
if (ms) ms.getTracks().forEach(t => t.stop());
App.state.screenStream = null;
if (App.state.screenTransceiver) await App.state.screenTransceiver.sender.replaceTrack(null);
await fanOutTrack(p => p.screenTransceiver, null, 'screen');
mirrorToLoopback();
refreshLocalDisplay();
const sBtn = document.getElementById('tb-screen');
sBtn.classList.remove('on'); sBtn.classList.add('off');
sBtn.querySelector('.nowrap').textContent = 'Screen off';
if (sBtn) {
sBtn.classList.remove('on'); sBtn.classList.add('off');
sBtn.querySelector('.nowrap').textContent = 'Screen off';
}
App.log.info('media', 'screenshare stopped');
broadcastMediaState();
}
/* Collect the relevant senders for a given track kind ('cam' | 'screen'),
including pcB's mirrored sender in loopback so the cap is visible there. */
function sendersFor(kind) {
/* Collect the relevant sender for one peer (and its loopback mirror, if
any) for a given track kind ('cam' | 'screen'). */
function senderFor(peer, kind) {
const tr = kind === 'screen' ? peer.screenTransceiver : peer.camTransceiver;
return tr ? tr.sender : null;
}
/* For a loopback peer, returns pcB's reverse sender for this track kind
(the one that mirrors A's track back so peer.remoteStream picks it up).
Transceivers on pcB are in mic/cam/screen order via preallocate's m-section
ordering. */
function loopbackMirrorSender(peer, kind) {
if (!peer.loopbackB) return null;
const tIdx = kind === 'screen' ? 2 : 1;
const local = kind === 'screen' ? App.state.screenTransceiver : App.state.camTransceiver;
const out = [];
if (local) out.push(local.sender);
if (App.state.pcB) {
const t = App.state.pcB.getTransceivers()[tIdx];
if (t) out.push(t.sender);
}
return out;
const t = peer.loopbackB.getTransceivers()[tIdx];
return t ? t.sender : null;
}
/* setParameters is transactional via an internal transactionId attached to
the params object returned by getParameters. Two interleaved
@@ -1835,52 +2210,72 @@ App.media = (() => {
with InvalidModificationError. Coalesce bitrate + degradation into one
round-trip per sender, and serialize calls via a per-sender chain. */
const pendingByS = new WeakMap(); /* sender -> Promise (most recent set) */
function applyVideoSendParams(kind, logLabel) {
const cfg = kind === 'screen' ? App.state.settings.screen : App.state.settings.video;
const kbps = cfg.maxBitrateKbps;
const pref = cfg.degradationPreference;
const wantCodec = (App.state.settings.sendVideoCodec || 'auto').toLowerCase();
const senders = sendersFor(kind);
for (const sender of senders) {
const prev = pendingByS.get(sender) || Promise.resolve();
const next = prev.then(() => {
const params = sender.getParameters();
if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
else delete params.encodings[0].maxBitrate;
if (pref) params.degradationPreference = pref;
/* encodings[0].codec is the "set sending codec" API. Pick from the
negotiated codec list on the sender — if the codec we want isn't
there (e.g. peer didn't offer it, or browser doesn't expose
params.codecs yet), leave the field unset so the browser keeps
picking automatically. */
if (wantCodec === 'auto') {
delete params.encodings[0].codec;
} else if (params.codecs && params.codecs.length) {
const pick = params.codecs.find(c => {
const sub = (c.mimeType || '').split('/')[1] || '';
return sub.toLowerCase() === wantCodec;
});
if (pick) params.encodings[0].codec = pick;
else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
}
return sender.setParameters(params).then(
() => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
e => App.log.error('media', logLabel, 'setParameters failed', e.message)
);
});
pendingByS.set(sender, next);
}
function applyVideoSendParamsToSender(sender, kbps, pref, wantCodec, logLabel) {
if (!sender) return;
const prev = pendingByS.get(sender) || Promise.resolve();
const next = prev.then(() => {
const params = sender.getParameters();
if (!params.encodings || !params.encodings[0]) params.encodings = [{}];
if (kbps && kbps > 0) params.encodings[0].maxBitrate = kbps * 1000;
else delete params.encodings[0].maxBitrate;
if (pref) params.degradationPreference = pref;
/* encodings[0].codec is the "set sending codec" API. Pick from the
negotiated codec list on the sender — if the codec we want isn't
there (e.g. peer didn't offer it, or browser doesn't expose
params.codecs yet), leave the field unset so the browser keeps
picking automatically. */
if (wantCodec === 'auto') {
delete params.encodings[0].codec;
} else if (params.codecs && params.codecs.length) {
const pick = params.codecs.find(c => {
const sub = (c.mimeType || '').split('/')[1] || '';
return sub.toLowerCase() === wantCodec;
});
if (pick) params.encodings[0].codec = pick;
else App.log.warn('media', logLabel, 'send codec not in negotiated set', wantCodec);
}
return sender.setParameters(params).then(
() => App.log.info('media', logLabel, 'params', kbps ? kbps + ' kbps' : 'unset', pref || '', 'send', wantCodec),
e => App.log.error('media', logLabel, 'setParameters failed', e.message)
);
});
pendingByS.set(sender, next);
}
/* Apply this peer's per-peer cam encoding params. For loopback peers we
also push the same params onto pcB's mirror sender so the cap is visible
on the received-side stream too. */
function applyCamSendParamsFor(peer) {
const codec = (peer.sendVideoCodec || 'auto').toLowerCase();
applyVideoSendParamsToSender(
senderFor(peer, 'cam'),
peer.videoMaxBitrateKbps,
peer.videoDegradationPreference,
codec,
'cam[' + peer.label + ']');
const lb = loopbackMirrorSender(peer, 'cam');
if (lb) applyVideoSendParamsToSender(lb, peer.videoMaxBitrateKbps, peer.videoDegradationPreference, codec, 'cam[' + peer.label + '/B]');
}
function applyScreenSendParamsFor(peer) {
const codec = (peer.sendVideoCodec || 'auto').toLowerCase();
applyVideoSendParamsToSender(
senderFor(peer, 'screen'),
peer.screenMaxBitrateKbps,
peer.screenDegradationPreference,
codec,
'screen[' + peer.label + ']');
const lb = loopbackMirrorSender(peer, 'screen');
if (lb) applyVideoSendParamsToSender(lb, peer.screenMaxBitrateKbps, peer.screenDegradationPreference, codec, 'screen[' + peer.label + '/B]');
}
/* Fan out the current per-peer params to every live peer. Each peer's
loopback mirror (if any) is updated inside its applyCam/Screen call. */
function applyCamSendParamsAll() {
for (const peer of App.state.peers.values()) applyCamSendParamsFor(peer);
}
function applyScreenSendParamsAll() {
for (const peer of App.state.peers.values()) applyScreenSendParamsFor(peer);
}
/* Cam and screen each have a single "push current bitrate + degradation
preference" entry point. We don't split bitrate-only / degradation-only
because every call site already wants both, and a single setParameters
round-trip per sender is more efficient than two. */
function applyCamSendParams() { applyVideoSendParams('cam', 'cam'); }
function applyScreenSendParams() { applyVideoSendParams('screen', 'screen'); }
async function applyAudioConstraints() {
const sender = App.state.micTransceiver && App.state.micTransceiver.sender;
const t = sender && sender.track;
const t = App.state.micTrack;
if (!t) return;
const a = App.state.settings.audio;
try {
@@ -1895,14 +2290,13 @@ App.media = (() => {
}
}
/* Live device switch: grab the new track, replaceTrack to it, then stop the
old one. No off→on cycle, so no black-frame flicker for the remote peer
and no period where the OS mic indicator drops. Returns the new deviceId
on success, or null on failure (caller keeps prior selection). */
/* Live device switch: grab the new track, fan-out replaceTrack across all
peers, then stop the old one. No off→on cycle, so no black-frame flicker
for any peer and no period where the OS mic indicator drops. Returns the
new deviceId on success, or null on failure (caller keeps prior selection). */
async function switchInputDevice(kind, deviceId) {
const tr = kind === 'mic' ? App.state.micTransceiver : App.state.camTransceiver;
const sender = tr && tr.sender;
if (!sender || !sender.track) return null;
const oldTrack = kind === 'mic' ? App.state.micTrack : App.state.camTrack;
if (!oldTrack) return null;
if (!gumAvailable()) return null;
const a = App.state.settings.audio;
const vs = App.state.settings.video;
@@ -1925,16 +2319,13 @@ App.media = (() => {
} };
stream = await navigator.mediaDevices.getUserMedia(constraints);
const newTrack = kind === 'mic' ? stream.getAudioTracks()[0] : stream.getVideoTracks()[0];
if (!App.state.pc || App.state.pc.connectionState === 'closed') {
newTrack.stop();
throw new Error('call ended before device prompt resolved');
}
const oldTrack = sender.track;
await sender.replaceTrack(newTrack);
if (oldTrack && oldTrack !== newTrack) oldTrack.stop();
setLocalTrack(kind === 'mic' ? 'audio' : 'video', newTrack);
await fanOutTrack(
p => kind === 'mic' ? p.micTransceiver : p.camTransceiver,
newTrack, kind);
if (oldTrack !== newTrack) oldTrack.stop();
attachTrackEndedHandler(newTrack, kind);
if (kind === 'cam') applyCamSendParams();
if (kind === 'cam') applyCamSendParamsAll();
mirrorToLoopback();
App.log.info('media', kind + ' switched', { requested: deviceId || '(default)', got: newTrack.getSettings ? newTrack.getSettings().deviceId : '?' });
return deviceId || '';
@@ -1947,12 +2338,15 @@ App.media = (() => {
}
return {
preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference,
preallocate, adoptTransceiversFromRemote, applyVideoCodecPreference, publishLocalTracksTo,
refreshRemoteDisplayFor, applyCamSendParamsFor, applyScreenSendParamsFor,
applyCamSendParamsAll, applyScreenSendParamsAll,
startScreenshare, stopScreenshare,
setMic, setCam,
switchInputDevice,
applyCamSendParams, applyScreenSendParams, applyAudioConstraints,
applyAudioConstraints,
refreshLocalDisplay, refreshRemoteDisplay,
mirrorToLoopback,
};
})();
@@ -1965,72 +2359,184 @@ App.chat = (() => {
up to ~256 KB on the wire; checking bytes prevents that. */
const MAX_CHAT_MSG = 64 * 1024;
const MAX_TEXT = 8 * 1024;
const MAX_NAME = 64;
const ID_RE = /^[A-Za-z0-9_\-]{1,24}$/;
const utf8Length = s => new TextEncoder().encode(s).length;
function attach(dc) {
function newMsgId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/* Outgoing-message delivery tracking. msgId -> { recipients: Map<peerId,
'sent'|'acked'|'failed'|'closed'>, text, ts, el }. Used to render the
per-recipient delivery popup when the user clicks one of their own
messages. */
const outMsgs = new Map();
/* Each peer's chat data channel is attached here. The peer arg is the
PeerCtx so we can tag incoming messages with the sender and update
per-peer media-state. */
function attach(peer, dc) {
dc.binaryType = 'arraybuffer';
dc.onopen = () => {
App.log.info('chat', 'data channel open');
appendSystem('connected');
/* Push our current media state so the peer's UI matches reality from
the moment the channel opens. */
broadcastMediaState();
App.log.info('chat', peer.label, 'data channel open');
appendSystem(displayNameOf(peer) + ' connected');
/* Push our current media state + username so the peer's UI matches
reality from the moment the channel opens. */
sendMediaStateTo(peer);
sendUsernameTo(peer);
setInCallControlsEnabled();
};
dc.onclose = () => App.log.info('chat', 'data channel closed');
dc.onerror = e => App.log.error('chat', 'error', e.error ? e.error.message : e);
dc.onclose = () => { App.log.info('chat', peer.label, 'data channel closed'); setInCallControlsEnabled(); };
dc.onerror = e => App.log.error('chat', peer.label, 'error', e.error ? e.error.message : e);
dc.onmessage = e => {
if (typeof e.data !== 'string') { App.log.warn('chat', 'binary payload rejected'); return; }
/* Cheap UTF-16 prefilter — if the code-unit count already exceeds the
byte cap, the byte count cannot be smaller, so skip the encode. */
if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected', e.data.length); return; }
if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', 'oversized message rejected (bytes)'); return; }
if (typeof e.data !== 'string') { App.log.warn('chat', peer.label, 'binary payload rejected'); return; }
if (e.data.length > MAX_CHAT_MSG) { App.log.warn('chat', peer.label, 'oversized message rejected', e.data.length); return; }
if (utf8Length(e.data) > MAX_CHAT_MSG) { App.log.warn('chat', peer.label, 'oversized message rejected (bytes)'); return; }
let m;
try { m = JSON.parse(e.data); }
catch (err) { App.log.warn('chat', 'bad json', err.message); return; }
catch (err) { App.log.warn('chat', peer.label, 'bad json', err.message); return; }
if (!m || typeof m !== 'object' || typeof m.kind !== 'string') return;
if (m.kind === 'msg') {
if (typeof m.text !== 'string') return;
if (m.text.length > MAX_TEXT || utf8Length(m.text) > MAX_TEXT) return;
const ts = Number.isFinite(m.ts) ? m.ts : Date.now();
append(false, m.text, ts);
append(false, m.text, ts, displayNameOf(peer));
/* Ack the message so the sender can mark this peer 'delivered'. */
if (typeof m.id === 'string' && ID_RE.test(m.id)) {
try { dc.send(JSON.stringify({ kind: 'msg-ack', id: m.id })); }
catch (_) {}
}
} else if (m.kind === 'msg-ack') {
if (typeof m.id !== 'string' || !ID_RE.test(m.id)) return;
const rec = outMsgs.get(m.id);
if (!rec) return;
const r = rec.recipients.get(peer.id);
if (r && r !== 'failed' && r !== 'closed') {
rec.recipients.set(peer.id, 'acked');
refreshMsgDetail(m.id);
}
} else if (m.kind === 'media-state') {
App.state.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
App.media.refreshRemoteDisplay();
peer.peerMediaState = { mic: !!m.mic, cam: !!m.cam, screen: !!m.screen };
App.media.refreshRemoteDisplayFor(peer);
} else if (m.kind === 'username') {
if (typeof m.name !== 'string') return;
const cleaned = m.name.trim().slice(0, MAX_NAME) || 'Anonymous';
const old = displayNameOf(peer);
peer.username = cleaned;
if (old !== displayNameOf(peer)) {
App.tiles?.refreshLabel(peer);
App.ui.refreshPeerWidgets?.();
appendSystem(old + ' is now ' + displayNameOf(peer));
}
} else if (m.kind === 'bye') {
onPeerHangup();
onPeerLeft(peer, 'remote');
}
/* unknown kinds: silently ignore */
};
}
function send(text) {
const dc = App.state.dcChat;
if (!dc || dc.readyState !== 'open') { App.log.warn('chat', 'not open'); return; }
const msg = { kind: 'msg', text, ts: Date.now() };
dc.send(JSON.stringify(msg));
append(true, text, msg.ts);
/* Fan out a chat message to every peer with an open chat channel (or the
subset given via opts.peers). The returned message id is also embedded
in the wire payload so receivers can ack it back. */
function send(text, opts) {
const peers = (opts && opts.peers) || (App.ui.selectedChatPeers ? App.ui.selectedChatPeers() : peersWithOpenChat());
if (!peers.length) { App.log.warn('chat', 'no recipients selected'); return; }
const id = newMsgId();
const msg = { kind: 'msg', id, text, ts: Date.now() };
const payload = JSON.stringify(msg);
const recipients = new Map();
for (const peer of peers) {
try {
peer.dcChat.send(payload);
recipients.set(peer.id, 'sent');
} catch (e) {
App.log.warn('chat', peer.label, 'send failed', e.message);
recipients.set(peer.id, 'failed');
}
}
const el = append(true, text, msg.ts, 'you', id);
outMsgs.set(id, { recipients, text, ts: msg.ts, el });
refreshMsgDetail(id);
}
/* Application-level hangup signal. Sent best-effort right before we tear
the connection down so the peer can react instantly instead of waiting
a connection down so the peer can react instantly instead of waiting
for ICE consent freshness to time out (~10–30 s). Skipped in loopback
because pcB echoes chat messages back to pcA — a bye would round-trip
and spuriously trigger the peer-left dialog on the local user. */
function sendBye() {
if (App.state.role === 'loopback') return;
const dc = App.state.dcChat;
and spuriously trigger the peer-left handler on the local user. */
function sendByeTo(peer) {
if (peer.role === 'loopback') return;
const dc = peer.dcChat;
if (!dc || dc.readyState !== 'open') return;
try { dc.send(JSON.stringify({ kind: 'bye' })); } catch (_) {}
}
function append(mine, text, ts) {
function sendByeAll() {
for (const peer of App.state.peers.values()) sendByeTo(peer);
}
function sendUsernameTo(peer) {
const dc = peer.dcChat;
if (!dc || dc.readyState !== 'open') return;
try { dc.send(JSON.stringify({ kind: 'username', name: App.state.username })); }
catch (e) { App.log.warn('chat', peer.label, 'username send failed', e.message); }
}
function broadcastUsername() {
for (const peer of peersWithOpenChat()) sendUsernameTo(peer);
}
function append(mine, text, ts, who, msgId) {
const log = document.getElementById('chat-log');
if (!log) return null;
const el = document.createElement('div');
el.className = 'chat-msg' + (mine ? ' me' : '');
el.innerHTML = '<div class="text"></div><div class="meta"></div>';
el.className = 'chat-msg' + (mine ? ' me' : '') + (mine && msgId ? ' has-detail' : '');
const meta = mine ? new Date(ts).toLocaleTimeString()
: (who || 'peer') + ' · ' + new Date(ts).toLocaleTimeString();
el.innerHTML = '<div class="text"></div><div class="meta"></div><div class="detail"></div>';
el.querySelector('.text').textContent = text;
el.querySelector('.meta').textContent = new Date(ts).toLocaleTimeString();
el.querySelector('.meta').textContent = meta;
if (mine && msgId) {
el.dataset.msgId = msgId;
el.addEventListener('click', () => el.classList.toggle('open'));
}
log.appendChild(el);
log.scrollTop = log.scrollHeight;
return el;
}
/* Re-render the per-recipient delivery detail block for one outgoing msg. */
function refreshMsgDetail(id) {
const rec = outMsgs.get(id);
if (!rec || !rec.el) return;
const detail = rec.el.querySelector('.detail');
if (!detail) return;
const totals = { acked: 0, sent: 0, failed: 0, closed: 0 };
const rows = [];
for (const [peerId, state] of rec.recipients) {
totals[state] = (totals[state] || 0) + 1;
const peer = App.state.peers.get(peerId);
const name = peer ? displayNameOf(peer) : ('peer ' + peerId.slice(0, 4));
rows.push(`<div class="row"><span>${escapeHtml(name)}</span><span class="state ${state}">${state}</span></div>`);
}
/* One-line summary appears in .meta after the timestamp. */
const meta = rec.el.querySelector('.meta');
if (meta) {
const total = rec.recipients.size;
const acked = totals.acked || 0;
const failed = (totals.failed || 0) + (totals.closed || 0);
let summary = ' · ' + acked + '/' + total + ' delivered';
if (failed) summary += ', ' + failed + ' failed';
meta.textContent = new Date(rec.ts).toLocaleTimeString() + summary;
}
detail.innerHTML = rows.join('');
}
/* Called from removePeer / cleanupForPeer to mark outstanding messages as
closed for that recipient. */
function notePeerClosed(peer) {
for (const [id, rec] of outMsgs) {
const r = rec.recipients.get(peer.id);
if (r === 'sent') {
rec.recipients.set(peer.id, 'closed');
refreshMsgDetail(id);
}
}
}
function appendSystem(text) {
const log = document.getElementById('chat-log');
if (!log) return;
const el = document.createElement('div');
el.className = 'small';
el.style.textAlign = 'center';
@@ -2039,9 +2545,39 @@ App.chat = (() => {
log.appendChild(el);
log.scrollTop = log.scrollHeight;
}
return { attach, send, sendBye, append, appendSystem, MAX_TEXT, utf8Length };
/* Wipe the chat log AND the delivery-tracking map. Each outMsg entry
pins its `.chat-msg` DOM node, so clearing only the log would still
leak the detached nodes through outMsgs. */
function clearAll() {
outMsgs.clear();
document.getElementById('chat-log')?.replaceChildren();
}
return {
attach, send, sendByeTo, sendByeAll, broadcastUsername, sendUsernameTo,
append, appendSystem, notePeerClosed, clearAll, MAX_TEXT, utf8Length,
};
})();
/* Display name for a peer, with a short id suffix when colliding with
ours or with another peer (e.g. multiple Anonymous users). */
function displayNameOf(peer) {
const base = (peer.username || 'Anonymous').trim() || 'Anonymous';
/* Disambiguate when this name is shared with anyone else (other peers OR
ourselves). Suffix is taken from the peer's id. */
const collides = (App.state.username || '').trim() === base
|| peerList().some(p => p !== peer && (p.username || '').trim() === base);
return collides ? base + ' #' + peer.id.slice(0, 4) : base;
}
/* Push the local media-state to one peer's chat dc. */
function sendMediaStateTo(peer) {
const dc = peer.dcChat;
if (!dc || dc.readyState !== 'open') return;
const state = { kind: 'media-state', ...localMediaState() };
try { dc.send(JSON.stringify(state)); }
catch (e) { App.log.warn('media', peer.label, 'state send failed', e.message); }
}
/* -------------------------------------------------------------------------
Files: chunked transfer with backpressure
------------------------------------------------------------------------- */
@@ -2049,22 +2585,32 @@ App.files = (() => {
const CHUNK = 16 * 1024;
const HIGH_WATER = 1024 * 1024; /* 1 MB */
const LOW_WATER = 256 * 1024;
const incoming = new Map(); /* id -> { name, size, mime, received, chunks: [] } */
const outQueue = []; /* [{ id, file }] — pending sends */
let outBusy = false;
/* Outgoing cancellation: ids in this set cause doSend's loop to bail
between chunks and emit a file-abort to the peer. Receiver-initiated
cancels arrive as a file-cancel ctl and get folded into this set. */
const abortedOut = new Set();
let currentSendId = null;
function attach(dc) {
/* Per-peer receive state. Key = peer.id + '/' + fileId so two peers can
simultaneously send files with the same id without colliding. */
const incoming = new Map(); /* "<peerId>/<id>" -> { peer, id, name, size, mime, received, chunks } */
/* Per-peer send state. Each peer has its own queue, current send, abort
set, since each peer's dcFiles has its own backpressure and lifecycle. */
/* peer -> { queue: [{ id, file }], busy: bool, currentId: string|null,
aborted: Set<id>, pending: Map<id, resolveFn> } */
const sendState = new WeakMap();
function stateFor(peer) {
let s = sendState.get(peer);
if (!s) {
s = { queue: [], busy: false, currentId: null, aborted: new Set(), pending: new Map() };
sendState.set(peer, s);
}
return s;
}
function incKey(peer, id) { return peer.id + '/' + id; }
function attach(peer, dc) {
dc.binaryType = 'arraybuffer';
dc.bufferedAmountLowThreshold = LOW_WATER;
dc.onopen = () => App.log.info('files', 'data channel open');
dc.onclose = () => App.log.info('files', 'data channel closed');
dc.onerror = e => App.log.error('files', 'error', e.error ? e.error.message : e);
dc.onmessage = e => onMessage(e.data);
dc.onopen = () => { App.log.info('files', peer.label, 'data channel open'); setInCallControlsEnabled(); };
dc.onclose = () => { App.log.info('files', peer.label, 'data channel closed'); setInCallControlsEnabled(); };
dc.onerror = e => App.log.error('files', peer.label, 'error', e.error ? e.error.message : e);
dc.onmessage = e => onMessage(peer, e.data);
}
const MAX_CTRL = 8 * 1024;
@@ -2074,278 +2620,581 @@ App.files = (() => {
const ID_RE = /^[A-Za-z0-9_\-]{1,16}$/;
function validId(id) { return typeof id === 'string' && ID_RE.test(id); }
function onMessage(data) {
async function onMessage(peer, data) {
if (typeof data === 'string') {
if (data.length > MAX_CTRL) { App.log.warn('files', 'ctrl too large'); return; }
if (data.length > MAX_CTRL) { App.log.warn('files', peer.label, 'ctrl too large'); return; }
/* Tighten to byte-size so a non-ASCII file name can't bypass the cap. */
if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', 'ctrl too large (bytes)'); return; }
let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', 'bad ctl', e.message); return; }
if (new TextEncoder().encode(data).length > MAX_CTRL) { App.log.warn('files', peer.label, 'ctrl too large (bytes)'); return; }
let m; try { m = JSON.parse(data); } catch (e) { App.log.warn('files', peer.label, 'bad ctl', e.message); return; }
if (!m || typeof m !== 'object' || typeof m.kind !== 'string' || !validId(m.id)) return;
const key = incKey(peer, m.id);
if (m.kind === 'file-start') {
if (incoming.has(m.id)) { App.log.warn('files', 'duplicate file-start ignored', m.id); return; }
if (incoming.has(key)) { App.log.warn('files', peer.label, 'duplicate file-start ignored', m.id); return; }
const size = Number(m.size);
if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', 'invalid size', m.size); return; }
if (!Number.isFinite(size) || size < 0 || size > MAX_FILE) { App.log.warn('files', peer.label, 'invalid size', m.size); return; }
const name = typeof m.name === 'string' ? m.name.slice(0, MAX_NAME) : 'file';
const mime = typeof m.mime === 'string' ? m.mime.slice(0, MAX_MIME) : '';
incoming.set(m.id, { name, size, mime, received: 0, chunks: [] });
addIncomingRow(m.id, name, size);
App.log.info('files', 'incoming start', name, size);
/* Park the transfer as a pending request — the sender holds its chunks
until we reply file-accept/file-deny. mode/writer are filled in on
accept; chunks is only used by the in-memory fallback. */
incoming.set(key, { peer, id: m.id, name, size, mime, received: 0,
accepted: false, mode: null, writer: null,
writeChain: null, writeFailed: false, chunks: null });
addIncomingRequestRow(key, name, size, peer);
App.log.info('files', peer.label, 'incoming request', name, size);
} else if (m.kind === 'file-accept' || m.kind === 'file-deny') {
/* Sender side: the recipient answered our offer; release doSend's wait. */
const p = stateFor(peer).pending.get(m.id);
if (p) p(m.kind === 'file-accept' ? 'accept' : 'deny');
} else if (m.kind === 'file-end') {
const f = incoming.get(m.id);
if (!f) return;
const blob = new Blob(f.chunks, { type: f.mime || 'application/octet-stream' });
finishIncoming(m.id, blob, f.name);
incoming.delete(m.id);
App.log.info('files', 'incoming done', f.name);
const f = incoming.get(key);
if (!f || !f.accepted) return;
if (f.mode === 'disk') {
try { await f.writeChain; await f.writer.close(); markIncomingSaved(key); }
catch (e) { App.log.error('files', peer.label, 'finalizing failed', e.message); abortIncomingRow(key); }
} else {
finishIncoming(key, new Blob(f.chunks, { type: f.mime || 'application/octet-stream' }), f.name);
}
incoming.delete(key);
App.log.info('files', peer.label, 'incoming done', f.name);
/* Confirm receipt so the sender can mark this recipient delivered. */
try { peer.dcFiles.send(JSON.stringify({ kind: 'file-received', id: m.id })); }
catch (_) {}
} else if (m.kind === 'file-received') {
markOutgoingPerRecipient(m.id, peer, 'delivered');
} else if (m.kind === 'file-abort') {
const f = incoming.get(m.id);
const f = incoming.get(key);
if (f) {
App.log.warn('files', 'incoming aborted', f.name);
incoming.delete(m.id);
abortIncomingRow(m.id);
App.log.warn('files', peer.label, 'incoming aborted', f.name);
abortWriter(f);
incoming.delete(key);
abortIncomingRow(key);
}
} else if (m.kind === 'file-cancel') {
/* Receiver-initiated cancel: stop sending if this id is in-flight
or queued. The doSend loop will see abortedOut and bail. */
const qi = outQueue.findIndex(q => q.id === m.id);
/* Receiver-initiated cancel: stop sending if this id is queued, awaiting
acceptance, or in-flight for THIS peer. Other peers' sends of the same
id (a fan-out to multiple recipients) are unaffected. */
const s = stateFor(peer);
const qi = s.queue.findIndex(q => q.id === m.id);
if (qi >= 0) {
outQueue.splice(qi, 1);
abortOutgoingRow(m.id, 'cancelled by peer');
} else if (currentSendId === m.id) {
abortedOut.add(m.id);
App.log.info('files', 'send cancelled by peer', m.id);
s.queue.splice(qi, 1);
markOutgoingPerRecipient(m.id, peer, 'cancelled by peer');
} else if (s.currentId === m.id) {
s.aborted.add(m.id);
const p = s.pending.get(m.id);
if (p) p('cancelled');
App.log.info('files', peer.label, 'send cancelled by peer', m.id);
}
/* If id is unknown (already finished or never started), ignore. */
}
/* unknown kinds: silently ignore */
} else {
/* Binary: first 16 bytes ASCII id (right-padded), then payload. */
const view = new Uint8Array(data);
if (view.byteLength <= 16) return;
/* The sender pads ids to 16 ASCII bytes with '_', and the *padded* form
is what's used as the Map key. So leave underscores in place — only
strip NULs and whitespace (defensive). */
const idStr = new TextDecoder().decode(view.slice(0, 16)).replace(/\0+$/, '').trim();
if (!validId(idStr)) return;
const payload = view.slice(16);
const f = incoming.get(idStr);
if (!f) return;
const key = incKey(peer, idStr);
const f = incoming.get(key);
/* Drop chunks for unknown / not-yet-accepted transfers — a well-behaved
sender never sends before file-accept. */
if (!f || !f.accepted) return;
/* A peer claiming size N must not be able to push more than N bytes —
otherwise it can OOM the tab by chunking unbounded data under one id. */
otherwise it can OOM the tab (memory mode) or fill the disk. Tell the
sender to stop and discard whatever we've written. */
if (f.received + payload.byteLength > f.size) {
App.log.warn('files', 'chunk overflows declared size, aborting', idStr);
incoming.delete(idStr);
abortIncomingRow(idStr);
App.log.warn('files', peer.label, 'chunk overflows declared size, aborting', idStr);
try { peer.dcFiles.send(JSON.stringify({ kind: 'file-cancel', id: f.id })); } catch (_) {}
abortWriter(f);
incoming.delete(key);
abortIncomingRow(key);
return;
}
f.chunks.push(payload);
f.received += payload.byteLength;
updateIncomingRow(idStr, f.received, f.size);
if (f.mode === 'disk') {
/* Stream straight to disk. Serialize writes through a promise chain so
they land in order (the channel delivers in order); a single write
failure cancels the whole transfer. */
const writer = f.writer;
f.writeChain = f.writeChain.then(() => f.writeFailed ? null : writer.write(payload)).catch(err => {
if (f.writeFailed) return;
f.writeFailed = true;
App.log.error('files', peer.label, 'disk write failed', err.message);
try { peer.dcFiles.send(JSON.stringify({ kind: 'file-cancel', id: f.id })); } catch (_) {}
abortWriter(f);
incoming.delete(key);
abortIncomingRow(key);
});
} else {
f.chunks.push(payload);
}
updateIncomingRow(key, f.received, f.size);
}
}
function sendFile(file) {
const dc = App.state.dcFiles;
if (!dc || dc.readyState !== 'open') { App.log.warn('files', 'channel not open'); return; }
/* Receiver accepts an offered file: pick a destination and stream to it.
Falls back to in-memory buffering + a download link where the File System
Access API is unavailable (e.g. Firefox). */
async function acceptIncoming(key) {
const f = incoming.get(key);
if (!f || f.accepted) return;
if (window.showSaveFilePicker) {
let handle;
try {
handle = await window.showSaveFilePicker({ suggestedName: f.name });
} catch (e) {
/* AbortError = user dismissed the picker; treat as a deny. */
if (e && e.name !== 'AbortError') App.log.warn('files', f.peer.label, 'save picker failed', e.message);
denyIncoming(key);
return;
}
try {
f.writer = await handle.createWritable();
} catch (e) {
App.log.error('files', f.peer.label, 'could not open file for writing', e.message);
denyIncoming(key);
return;
}
f.mode = 'disk';
f.writeChain = Promise.resolve();
} else {
f.mode = 'memory';
f.chunks = [];
}
f.accepted = true;
/* Swap the request row for the normal progress row, then tell the sender. */
removeIncomingRowEl(key);
addIncomingRow(key, f.name, f.size, f.peer);
try { f.peer.dcFiles.send(JSON.stringify({ kind: 'file-accept', id: f.id })); }
catch (e) { App.log.warn('files', f.peer.label, 'file-accept send failed', e.message); }
App.log.info('files', f.peer.label, 'accepted', f.name, '(' + f.mode + ')');
}
function denyIncoming(key) {
const f = incoming.get(key);
if (!f) return;
try { f.peer.dcFiles.send(JSON.stringify({ kind: 'file-deny', id: f.id })); }
catch (_) {}
abortWriter(f);
incoming.delete(key);
removeIncomingRowEl(key);
App.log.info('files', f.peer.label, 'declined', f.name);
}
/* Abort and discard a half-written destination file. Idempotent. */
function abortWriter(rec) {
if (!rec || !rec.writer || rec.aborting) return;
rec.aborting = true;
const w = rec.writer;
Promise.resolve(rec.writeChain).catch(() => {}).then(() => w.abort().catch(() => {}));
}
/* Fan a file out to every selected peer (or to `opts.peers` if given).
One Blob is shared across the per-peer queues. */
function sendFile(file, opts) {
const recipients = (opts && opts.peers)
|| (App.ui.selectedFilesPeers ? App.ui.selectedFilesPeers() : peersWithOpenFiles());
if (!recipients.length) { App.log.warn('files', 'no recipients selected'); return; }
if (file.size > MAX_FILE) {
App.log.warn('files', 'file too large', file.size);
App.chat.appendSystem?.(`File "${file.name}" exceeds the 5 GB limit.`);
return;
}
const id = (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)).padEnd(16, '_').slice(0, 16);
addOutgoingRow(id, file.name, file.size);
outQueue.push({ id, file });
if (outQueue.length > 1 || outBusy) markQueued('#files-out', id);
pumpQueue();
addOutgoingRow(id, file.name, file.size, recipients);
for (const peer of recipients) {
const s = stateFor(peer);
s.queue.push({ id, file });
if (s.queue.length > 1 || s.busy) markRecipientQueued(id, peer);
pumpQueue(peer);
}
}
async function pumpQueue() {
if (outBusy) return;
const next = outQueue.shift();
async function pumpQueue(peer) {
const s = stateFor(peer);
if (s.busy) return;
const next = s.queue.shift();
if (!next) return;
outBusy = true;
s.busy = true;
try {
await doSend(next.id, next.file);
await doSend(peer, next.id, next.file);
} finally {
outBusy = false;
pumpQueue();
s.busy = false;
pumpQueue(peer);
}
}
async function doSend(id, file) {
const dc = App.state.dcFiles;
/* Wait for the recipient's file-accept/file-deny. Resolves 'accept', 'deny',
'cancelled' (we aborted / peer cancelled), or 'closed' (channel went away),
so the send loop never hangs waiting on a peer who never answers. */
function waitForDecision(s, dc, id) {
return new Promise(resolve => {
let settled = false;
const finish = v => {
if (settled) return;
settled = true;
s.pending.delete(id);
dc.removeEventListener('close', onGone);
dc.removeEventListener('error', onGone);
resolve(v);
};
const onGone = () => finish('closed');
if (s.aborted.has(id)) return finish('cancelled');
if (dc.readyState !== 'open') return finish('closed');
s.pending.set(id, finish);
dc.addEventListener('close', onGone);
dc.addEventListener('error', onGone);
});
}
async function doSend(peer, id, file) {
const s = stateFor(peer);
const dc = peer.dcFiles;
if (!dc || dc.readyState !== 'open') {
App.log.warn('files', 'channel closed before send', file.name);
abortOutgoingRow(id, 'channel closed');
abortedOut.delete(id);
App.log.warn('files', peer.label, 'channel closed before send', file.name);
markOutgoingPerRecipient(id, peer, 'channel closed');
s.aborted.delete(id);
return;
}
if (abortedOut.has(id)) {
/* Cancelled while still queued — never went on the wire. */
abortOutgoingRow(id, 'cancelled');
abortedOut.delete(id);
if (s.aborted.has(id)) {
markOutgoingPerRecipient(id, peer, 'cancelled');
s.aborted.delete(id);
return;
}
clearQueuedMark('#files-out', id);
currentSendId = id;
s.currentId = id;
const start = { kind: 'file-start', id, name: file.name, size: file.size, mime: file.type };
dc.send(JSON.stringify(start));
App.log.info('files', 'sending', file.name, file.size);
markOutgoingPerRecipient(id, peer, 'awaiting acceptance');
App.log.info('files', peer.label, 'offered', file.name, file.size);
/* Hold the chunks until the recipient accepts (or declines / disconnects /
we abort). The receiver opens its save target before answering. */
const decision = await waitForDecision(s, dc, id);
if (decision !== 'accept') {
const label = decision === 'deny' ? 'declined'
: decision === 'closed' ? 'channel closed' : 'cancelled';
markOutgoingPerRecipient(id, peer, label);
App.log.info('files', peer.label, decision === 'deny' ? 'recipient declined' : 'send ended before accept', file.name);
s.aborted.delete(id);
s.currentId = null;
return;
}
markOutgoingPerRecipient(id, peer, 'sending');
App.log.info('files', peer.label, 'sending', file.name, file.size);
const idBytes = new TextEncoder().encode(id);
let offset = 0;
let cancelled = false;
try {
while (offset < file.size) {
if (abortedOut.has(id)) { cancelled = true; break; }
if (s.aborted.has(id)) { cancelled = true; break; }
if (dc.bufferedAmount > HIGH_WATER) {
/* Wait for backpressure to clear — but also wake up if the channel
closes (peer disconnected, removePeer closed the dc, browser
tore it down). A closed RTCDataChannel never fires
bufferedamountlow, so without the close/error listeners this
promise could hang forever and freeze the per-peer queue. */
await new Promise(res => {
const h = () => { dc.removeEventListener('bufferedamountlow', h); res(); };
dc.addEventListener('bufferedamountlow', h);
const done = () => {
dc.removeEventListener('bufferedamountlow', done);
dc.removeEventListener('close', done);
dc.removeEventListener('error', done);
res();
};
dc.addEventListener('bufferedamountlow', done);
dc.addEventListener('close', done);
dc.addEventListener('error', done);
});
if (abortedOut.has(id)) { cancelled = true; break; }
if (dc.readyState !== 'open') {
App.log.warn('files', peer.label, 'channel closed during backpressure wait');
cancelled = true; break;
}
if (s.aborted.has(id)) { cancelled = true; break; }
}
const slice = await file.slice(offset, offset + CHUNK).arrayBuffer();
if (abortedOut.has(id)) { cancelled = true; break; }
if (s.aborted.has(id)) { cancelled = true; break; }
const buf = new Uint8Array(16 + slice.byteLength);
buf.set(idBytes, 0);
buf.set(new Uint8Array(slice), 16);
dc.send(buf.buffer);
offset += slice.byteLength;
updateOutgoingRow(id, offset, file.size);
updateOutgoingProgress(id, peer, offset, file.size);
}
if (cancelled) {
try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
abortOutgoingRow(id, 'cancelled');
App.log.info('files', 'send cancelled', file.name);
markOutgoingPerRecipient(id, peer, 'cancelled');
App.log.info('files', peer.label, 'send cancelled', file.name);
} else {
dc.send(JSON.stringify({ kind: 'file-end', id }));
finishOutgoingRow(id);
App.log.info('files', 'sent', file.name);
markOutgoingPerRecipient(id, peer, 'sent');
App.log.info('files', peer.label, 'sent', file.name);
}
} catch (e) {
App.log.error('files', 'send failed', e.message);
App.log.error('files', peer.label, 'send failed', e.message);
try { dc.send(JSON.stringify({ kind: 'file-abort', id })); } catch (_) {}
abortOutgoingRow(id, 'send failed');
markOutgoingPerRecipient(id, peer, 'send failed');
} finally {
currentSendId = null;
abortedOut.delete(id);
s.currentId = null;
s.aborted.delete(id);
}
}
/* UI row helpers */
/* Track blob URLs for incoming finished downloads so Clear can revoke them. */
const incomingUrls = new Map(); /* id -> objectURL string */
const incomingUrls = new Map(); /* key (peerId/id) -> objectURL string */
function rowEl(side, id, name, size) {
/* Outgoing row tracks per-recipient state in dataset JSON. The summary
line shows aggregated progress (min %) + a count of {recipients done}. */
/* perRecipient[peerId] = { state: 'queued'|'sending'|'sent'|'cancelled'|'channel closed'|'send failed', sent: number } */
const outgoingMeta = new Map(); /* outId -> { name, size, perRecipient: Map<peerId, ...> } */
function rowEl(side, key, name, size) {
const el = document.createElement('div');
el.className = 'file-item';
el.dataset.id = id;
el.className = 'file-item' + (side === 'out' ? ' has-detail' : '');
el.dataset.id = key;
el.innerHTML = `<button class="row-close" type="button" title="Cancel / remove" aria-label="Cancel or remove">×</button>
<div class="name"></div>
<div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%)</div>
<div class="meta"><span class="bytes">0</span> / <span class="total"></span> B (<span class="pct">0</span>%) <span class="recip"></span></div>
<div class="progress"><div></div></div>
<div class="dl"></div>`;
<div class="dl"></div>
<div class="detail"></div>`;
el.querySelector('.name').textContent = name;
el.querySelector('.total').textContent = size.toLocaleString();
el.querySelector('.row-close').addEventListener('click', () => removeRow(side, id));
el.querySelector('.row-close').addEventListener('click', e => {
e.stopPropagation();
removeRow(side, key);
});
if (side === 'out') {
el.addEventListener('click', e => {
if (e.target.closest('.row-close')) return;
el.classList.toggle('open');
});
}
return el;
}
function removeRow(side, id) {
function removeRow(side, key) {
const sel = side === 'in' ? '#files-in' : '#files-out';
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
if (row) row.remove();
if (side === 'in') {
/* If the transfer is still arriving, ask the sender to stop. */
if (incoming.has(id)) {
const dc = App.state.dcFiles;
const rec = incoming.get(key);
if (rec) {
/* Tell the sender to stop: file-deny while still a pending request,
file-cancel once we've accepted and chunks may be arriving. */
const dc = rec.peer.dcFiles;
if (dc && dc.readyState === 'open') {
try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
const kind = rec.accepted ? 'file-cancel' : 'file-deny';
try { dc.send(JSON.stringify({ kind, id: rec.id })); } catch (_) {}
}
abortWriter(rec);
}
const url = incomingUrls.get(id);
if (url) { URL.revokeObjectURL(url); incomingUrls.delete(id); }
incoming.delete(id);
const url = incomingUrls.get(key);
if (url) { URL.revokeObjectURL(url); incomingUrls.delete(key); }
incoming.delete(key);
} else {
const i = outQueue.findIndex(q => q.id === id);
if (i >= 0) outQueue.splice(i, 1);
/* If it's the in-flight send, flag it for cancellation. The doSend
loop will pick up the flag, emit file-abort, and clear it. */
if (currentSendId === id) abortedOut.add(id);
/* outgoing: cancel across every recipient that still has this id. */
outgoingMeta.delete(key);
for (const peer of App.state.peers.values()) {
const s = stateFor(peer);
const i = s.queue.findIndex(q => q.id === key);
if (i >= 0) s.queue.splice(i, 1);
if (s.currentId === key) {
s.aborted.add(key);
/* If we're parked awaiting acceptance, release that wait too. */
const p = s.pending.get(key);
if (p) p('cancelled');
}
}
}
}
function clearAll(side) {
const sel = side === 'in' ? '#files-in' : '#files-out';
document.querySelectorAll(sel + ' .file-item').forEach(el => el.remove());
if (side === 'in') {
/* Tell the peer to stop for any in-progress receives. */
const dc = App.state.dcFiles;
if (dc && dc.readyState === 'open') {
incoming.forEach((_f, id) => {
try { dc.send(JSON.stringify({ kind: 'file-cancel', id })); } catch (_) {}
});
}
/* Tell each peer to stop for any pending requests / in-progress receives. */
incoming.forEach((rec, _key) => {
const dc = rec.peer.dcFiles;
if (dc && dc.readyState === 'open') {
const kind = rec.accepted ? 'file-cancel' : 'file-deny';
try { dc.send(JSON.stringify({ kind, id: rec.id })); } catch (_) {}
}
abortWriter(rec);
});
incomingUrls.forEach(url => URL.revokeObjectURL(url));
incomingUrls.clear();
incoming.clear();
} else {
outQueue.length = 0;
/* Cancel the in-flight send too, if any. */
if (currentSendId) abortedOut.add(currentSendId);
outgoingMeta.clear();
for (const peer of App.state.peers.values()) {
const s = stateFor(peer);
s.queue.length = 0;
if (s.currentId) s.aborted.add(s.currentId);
}
}
}
function addOutgoingRow(id, name, size) {
document.getElementById('files-out').appendChild(rowEl('out', id, name, size));
}
function updateOutgoingRow(id, sent, size) { updateRow('#files-out', id, sent, size); }
function finishOutgoingRow(id) { markDone('#files-out', id); }
function abortOutgoingRow(id, reason) {
function addOutgoingRow(id, name, size, recipients) {
const row = rowEl('out', id, name, size);
document.getElementById('files-out').appendChild(row);
const perRecipient = new Map();
for (const peer of recipients) perRecipient.set(peer.id, { state: 'queued', sent: 0 });
outgoingMeta.set(id, { name, size, perRecipient });
refreshOutgoingRow(id);
}
function refreshOutgoingRow(id) {
const meta = outgoingMeta.get(id);
if (!meta) return;
const row = document.querySelector('#files-out [data-id="' + CSS.escape(id) + '"]');
if (row) row.querySelector('.dl').textContent = '(' + (reason || 'aborted') + ')';
}
function markQueued(sel, id) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
if (row) row.querySelector('.dl').textContent = 'queued';
if (!row) return;
const recips = Array.from(meta.perRecipient.entries());
const minSent = recips.reduce((m, [, r]) => Math.min(m, r.sent), Infinity);
const pct = meta.size ? Math.floor((minSent / meta.size) * 100) : 0;
row.querySelector('.bytes').textContent = (Number.isFinite(minSent) ? minSent : 0).toLocaleString();
row.querySelector('.pct').textContent = pct;
row.querySelector('.progress > div').style.width = pct + '%';
const doneStates = new Set(['sent', 'delivered']);
const failStates = new Set(['channel closed', 'send failed', 'cancelled', 'cancelled by peer', 'declined']);
const delivered = recips.filter(([, r]) => r.state === 'delivered').length;
const total = recips.length;
const failed = recips.filter(([, r]) => failStates.has(r.state)).length;
let recipText = ' · ' + delivered + '/' + total + ' delivered';
if (failed) recipText += ', ' + failed + ' failed';
row.querySelector('.recip').textContent = recipText;
const allDone = recips.every(([, r]) => doneStates.has(r.state) || failStates.has(r.state));
if (allDone && failed === 0) row.querySelector('.progress > div').style.background = 'var(--ok)';
/* Per-recipient detail block — only visible when the user clicks the row. */
const detail = row.querySelector('.detail');
if (detail) {
const STATE_CLASS = {
'queued': 'queued', 'awaiting acceptance': 'queued', 'sending': 'sending',
'sent': 'sent', 'delivered': 'delivered',
'channel closed': 'closed', 'send failed': 'failed',
'cancelled': 'cancelled', 'cancelled by peer': 'cancelled', 'declined': 'cancelled',
};
detail.innerHTML = recips.map(([peerId, r]) => {
const peer = App.state.peers.get(peerId);
const name = peer ? displayNameOf(peer) : ('peer ' + peerId.slice(0, 4));
const sizePct = meta.size ? Math.floor((r.sent / meta.size) * 100) : 0;
const stateClass = STATE_CLASS[r.state] || '';
const stateText = r.state === 'sending' ? r.state + ' ' + sizePct + '%' : r.state;
return `<div class="row"><span>${escapeHtml(name)}</span><span class="state ${stateClass}">${stateText}</span></div>`;
}).join('');
}
}
function clearQueuedMark(sel, id) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
if (row && row.querySelector('.dl').textContent === 'queued') row.querySelector('.dl').textContent = '';
function updateOutgoingProgress(id, peer, sent, _size) {
const meta = outgoingMeta.get(id);
if (!meta) return;
const r = meta.perRecipient.get(peer.id);
if (!r) return;
r.state = 'sending';
r.sent = sent;
refreshOutgoingRow(id);
}
function markOutgoingPerRecipient(id, peer, state) {
const meta = outgoingMeta.get(id);
if (!meta) return;
const r = meta.perRecipient.get(peer.id);
if (!r) return;
r.state = state;
refreshOutgoingRow(id);
}
function markRecipientQueued(id, peer) {
const meta = outgoingMeta.get(id);
if (!meta) return;
const r = meta.perRecipient.get(peer.id);
if (r) { r.state = 'queued'; refreshOutgoingRow(id); }
}
function addIncomingRow(key, name, size, peer) {
const row = rowEl('in', key, name, size);
row.querySelector('.recip').textContent = ' · from ' + displayNameOf(peer);
document.getElementById('files-in').appendChild(row);
}
/* The pending-request row: name + size + Accept/Deny, shown until the user
decides. Accept opens a save target and streams to it; Deny tells the
sender to drop the transfer. */
function addIncomingRequestRow(key, name, size, peer) {
const el = document.createElement('div');
el.className = 'file-item file-request';
el.dataset.id = key;
el.innerHTML = `<button class="row-close" type="button" title="Decline" aria-label="Decline">×</button>
<div class="name"></div>
<div class="meta"><span class="total"></span> B · from <span class="from"></span></div>
<div class="req-actions">
<button class="btn-accept" type="button">Accept</button>
<button class="btn-deny" type="button">Deny</button>
</div>`;
el.querySelector('.name').textContent = name;
el.querySelector('.total').textContent = size.toLocaleString();
el.querySelector('.from').textContent = displayNameOf(peer);
el.querySelector('.row-close').addEventListener('click', e => { e.stopPropagation(); denyIncoming(key); });
el.querySelector('.btn-deny').addEventListener('click', () => denyIncoming(key));
el.querySelector('.btn-accept').addEventListener('click', () => acceptIncoming(key));
document.getElementById('files-in').appendChild(el);
}
function removeIncomingRowEl(key) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
if (row) row.remove();
}
function addIncomingRow(id, name, size) {
document.getElementById('files-in').appendChild(rowEl('in', id, name, size));
function markIncomingSaved(key) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
if (!row) return;
const dl = row.querySelector('.dl');
if (dl) { dl.textContent = 'Saved to disk'; dl.style.color = 'var(--ok)'; }
markDone('#files-in', key);
}
function updateIncomingRow(id, recv, size) { updateRow('#files-in', id, recv, size); }
function abortIncomingRow(id) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
function updateIncomingRow(key, recv, size) { updateRow('#files-in', key, recv, size); }
function abortIncomingRow(key) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
if (row) row.querySelector('.dl').textContent = '(aborted)';
}
function finishIncoming(id, blob, name) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(id) + '"]');
function finishIncoming(key, blob, name) {
const row = document.querySelector('#files-in [data-id="' + CSS.escape(key) + '"]');
if (!row) return;
const url = URL.createObjectURL(blob);
incomingUrls.set(id, url);
incomingUrls.set(key, url);
const a = document.createElement('a');
a.href = url; a.download = name; a.textContent = 'Download';
a.style.color = 'var(--accent)';
row.querySelector('.dl').innerHTML = '';
row.querySelector('.dl').appendChild(a);
markDone('#files-in', id);
markDone('#files-in', key);
}
function updateRow(sel, id, cur, size) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
function updateRow(sel, key, cur, size) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
if (!row) return;
const pct = size ? Math.floor((cur / size) * 100) : 0;
row.querySelector('.bytes').textContent = cur.toLocaleString();
row.querySelector('.pct').textContent = pct;
row.querySelector('.progress > div').style.width = pct + '%';
}
function markDone(sel, id) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(id) + '"]');
function markDone(sel, key) {
const row = document.querySelector(sel + ' [data-id="' + CSS.escape(key) + '"]');
if (!row) return;
row.querySelector('.progress > div').style.background = 'var(--ok)';
}
return { attach, sendFile, clearAll, MAX_FILE };
/* Called when a peer disconnects — mark any in-flight incoming receives
from that peer as aborted, and any outgoing sends to that peer as
channel-closed. The per-peer queues are abandoned along with the peer. */
function cleanupForPeer(peer) {
for (const [key, rec] of incoming) {
if (rec.peer === peer) {
abortWriter(rec);
abortIncomingRow(key);
incoming.delete(key);
}
}
for (const meta of outgoingMeta.values()) {
const r = meta.perRecipient.get(peer.id);
if (r && r.state !== 'sent') {
r.state = 'channel closed';
}
}
/* Refresh every outgoing row's recipient summary. */
for (const id of outgoingMeta.keys()) refreshOutgoingRow(id);
sendState.delete(peer);
}
return { attach, sendFile, clearAll, cleanupForPeer, MAX_FILE };
})();
/* -------------------------------------------------------------------------
@@ -2379,12 +3228,30 @@ App.stats = (() => {
return ((bytes - p.bytes) * 8 * 1000) / (ts - p.ts);
}
/* Peer dropdown drives which connection we poll. Falls back to the first
live peer if the dropdown is empty / out of sync. */
function selectedPeer() {
const sel = document.getElementById('stats-peer');
const id = sel && sel.value;
if (id) {
const peer = App.state.peers.get(id);
if (peer) return peer;
}
return App.state.peers.values().next().value || null;
}
async function tick() {
if (!App.state.pc) return;
const stats = await App.state.pc.getStats();
const peer = selectedPeer();
const tbody = document.querySelector('#stats-table tbody');
if (!peer || !peer.pc) {
/* No peer selected: empty the table so stale values from a peer we
just disconnected from don't linger. */
if (tbody) tbody.innerHTML = '';
prev.clear();
return;
}
const stats = await peer.pc.getStats();
if (!tbody) return;
const rows = collect(stats);
const rows = collect(peer, stats);
/* Drop prev entries for stat ids that no longer appear in this report,
so the cache doesn't grow unboundedly across long calls where the
browser internally rotates RTP stream ids. */
@@ -2395,24 +3262,28 @@ App.stats = (() => {
`<tr><td>${escapeHtml(r.k)}</td><td>${escapeHtml(String(r.v))}</td></tr>`).join('');
}
function collect(stats) {
function collect(peer, stats) {
const out = [];
/* Single pass over the report: index every record by id (for candidate /
codec lookups), capture the transport's selected pair id, and collect the
candidate-pair and RTP records we'll emit. Avoids re-scanning the report
several times per tick. */
const byId = new Map();
const candidatePairs = [];
const rtpRecords = [];
let selected = null;
stats.forEach(r => {
if (r.type === 'transport') {
if (r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
}
});
let pair = null, localCand = null, remoteCand = null;
stats.forEach(r => {
if (r.type === 'candidate-pair' && (r.nominated || r.selected || r.id === selected) && r.state === 'succeeded')
pair = r;
byId.set(r.id, r);
if (r.type === 'transport' && r.selectedCandidatePairId) selected = r.selectedCandidatePairId;
else if (r.type === 'candidate-pair') candidatePairs.push(r);
else if ((r.type === 'outbound-rtp' || r.type === 'inbound-rtp') && !r.isRemote) rtpRecords.push(r);
});
let localCand = null, remoteCand = null;
const pair = candidatePairs.find(r =>
(r.nominated || r.selected || r.id === selected) && r.state === 'succeeded') || null;
if (pair) {
stats.forEach(r => {
if (r.id === pair.localCandidateId) localCand = r;
if (r.id === pair.remoteCandidateId) remoteCand = r;
});
localCand = byId.get(pair.localCandidateId) || null;
remoteCand = byId.get(pair.remoteCandidateId) || null;
out.push({ k: 'rtt (ms)', v: pair.currentRoundTripTime ? Math.round(pair.currentRoundTripTime * 1000) : '—' });
out.push({ k: 'bytes sent', v: fmtBytes(pair.bytesSent) });
out.push({ k: 'bytes received', v: fmtBytes(pair.bytesReceived) });
@@ -2423,29 +3294,25 @@ App.stats = (() => {
/* outbound-rtp / inbound-rtp report kind='video' for both the cam and the
screen-share transceivers, so we need to disambiguate by mid. */
const midRole = new Map();
if (App.state.micTransceiver && App.state.micTransceiver.mid != null) midRole.set(String(App.state.micTransceiver.mid), 'audio');
if (App.state.camTransceiver && App.state.camTransceiver.mid != null) midRole.set(String(App.state.camTransceiver.mid), 'cam');
if (App.state.screenTransceiver && App.state.screenTransceiver.mid != null) midRole.set(String(App.state.screenTransceiver.mid), 'screen');
if (peer.micTransceiver && peer.micTransceiver.mid != null) midRole.set(String(peer.micTransceiver.mid), 'audio');
if (peer.camTransceiver && peer.camTransceiver.mid != null) midRole.set(String(peer.camTransceiver.mid), 'cam');
if (peer.screenTransceiver && peer.screenTransceiver.mid != null) midRole.set(String(peer.screenTransceiver.mid), 'screen');
const roleOf = r => midRole.get(String(r.mid)) || r.kind;
/* Pre-pass: build codecId → short codec label map so we can inline the
active codec into each outbound/inbound row. The 'codec' records appear
in arbitrary order relative to the rtp records, so we collect first. */
const codecOf = new Map();
stats.forEach(r => { if (r.type === 'codec') codecOf.set(r.id, r); });
/* codecId → short codec label, resolved from the byId index built above
(the 'codec' records appear in arbitrary order relative to rtp records). */
const codecLabel = id => {
const c = codecOf.get(id);
const c = byId.get(id);
if (!c || !c.mimeType) return '';
return ' [' + c.mimeType.split('/')[1] + ']';
};
stats.forEach(r => {
if (r.type === 'outbound-rtp' && !r.isRemote) {
rtpRecords.forEach(r => {
if (r.type === 'outbound-rtp') {
const role = roleOf(r);
const br = bitrateFor(r.id, r.bytesSent || 0, r.timestamp);
const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
out.push({ k: `↑ ${role} sent${codecLabel(r.codecId)}`, v: `${fmtBytes(r.bytesSent)} / ${r.packetsSent} pkts @ ${fmtBps(br)}${fps}` });
if (r.targetBitrate) out.push({ k: `↑ ${role} target br`, v: fmtBps(r.targetBitrate) });
}
if (r.type === 'inbound-rtp' && !r.isRemote) {
} else {
const role = roleOf(r);
const br = bitrateFor(r.id, r.bytesReceived || 0, r.timestamp);
const fps = r.framesPerSecond ? `, ${r.framesPerSecond} fps` : '';
@@ -2463,17 +3330,23 @@ App.stats = (() => {
tick();
}
function stop() { if (timer) { clearInterval(timer); timer = null; } prev.clear(); }
/* Called from the peer-select onchange so the next tick treats the new
peer as a fresh poll instead of inheriting stale bitrate deltas. */
function refreshNow() { prev.clear(); tick(); }
async function exportAll() {
if (!App.state.pc) return;
const stats = await App.state.pc.getStats();
const peer = selectedPeer();
if (!peer || !peer.pc) return;
const stats = await peer.pc.getStats();
const arr = [];
stats.forEach(r => arr.push(r));
const blob = new Blob([JSON.stringify(arr, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.href = url;
a.download = 'webrtc-stats-' + Date.now() + '.json';
a.click();
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function toggleConsoleStats() {
@@ -2482,22 +3355,254 @@ App.stats = (() => {
App.log.info('stats', 'console poll stopped');
} else {
consoleTimer = setInterval(async () => {
if (!App.state.pc) return;
const stats = await App.state.pc.getStats();
const rows = collect(stats);
App.log.debug('stats', rows.map(r => r.k + '=' + r.v).join(' | '));
const peer = selectedPeer();
if (!peer || !peer.pc) return;
const stats = await peer.pc.getStats();
const rows = collect(peer, stats);
App.log.debug('stats', peer.label, rows.map(r => r.k + '=' + r.v).join(' | '));
}, 2000);
App.log.info('stats', 'console poll started (2s)');
}
}
return { start, stop, exportAll, toggleConsoleStats };
return { start, stop, exportAll, toggleConsoleStats, refreshNow };
})();
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]);
}
/* -------------------------------------------------------------------------
Per-peer tile factory. Each tile owns: a main video, a PIP video (for cam
when a screen share is in the main slot), a dedicated audio element so
audio plays even when the tile is in its empty state, a mic-muted badge,
a leave button, a label, and a connection-state pill.
------------------------------------------------------------------------- */
App.tiles = (() => {
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>';
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>';
/* --- Conference layout ---
Pick the rows × cols decomposition that maximises tile area for the
current container, with each tile constrained to a 16:9 box. Then size
the grid template + each tile explicitly so the browser doesn't second-
guess us. Re-runs on ResizeObserver and on every tile add/remove. */
const TILE_RATIO = 16 / 9;
const GAP = 8;
const PAD = 12;
function bestGrid(n, W, H) {
let best = { rows: 1, cols: n, tileW: 0, tileH: 0, area: 0 };
for (let rows = 1; rows <= n; rows++) {
const cols = Math.ceil(n / rows);
const cellW = (W - (cols - 1) * GAP) / cols;
const cellH = (H - (rows - 1) * GAP) / rows;
if (cellW <= 0 || cellH <= 0) continue;
let tileW = cellW, tileH = cellW / TILE_RATIO;
if (tileH > cellH) { tileH = cellH; tileW = cellH * TILE_RATIO; }
const area = tileW * tileH;
if (area > best.area) best = { rows, cols, tileW, tileH, area };
}
return best;
}
function relayout() {
const area = document.getElementById('video-area');
if (!area) return;
/* Skip while something is fullscreen — re-parenting the row containers
(which we do below) detaches the fullscreened <video> from the
document, and browsers immediately exit fullscreen when their target
element leaves the DOM. A fullscreenchange listener re-runs us when
the user exits. */
if (document.fullscreenElement) return;
/* Gather every tile, whether it's currently a direct child of the area
(newly added) or already inside a .video-row from a previous relayout. */
const tiles = Array.from(area.querySelectorAll('.video-tile'));
const n = tiles.length;
if (!n) return;
const innerW = Math.max(0, area.clientWidth - PAD * 2);
const innerH = Math.max(0, area.clientHeight - PAD * 2);
if (innerW === 0 || innerH === 0) return;
/* The flex column adds (rows-1) gaps vertically; the rowEl adds (perRow-1)
gaps horizontally. bestGrid already accounts for that. */
const { rows, cols, tileW, tileH } = bestGrid(n, innerW, innerH);
/* Remove any old row wrappers (but keep tile DOM identity so videos
and event listeners aren't recreated). */
area.querySelectorAll(':scope > .video-row').forEach(r => r.remove());
for (const t of tiles) {
t.style.width = tileW + 'px';
t.style.height = '100%';
}
for (let r = 0; r < rows; r++) {
const rowEl = document.createElement('div');
rowEl.className = 'video-row';
rowEl.style.height = tileH + 'px';
const start = r * cols;
const end = Math.min(n, start + cols);
for (let i = start; i < end; i++) rowEl.appendChild(tiles[i]);
area.appendChild(rowEl);
}
}
let _ro = null;
let _fsBound = false;
function ensureObserver() {
if (_ro) return;
const area = document.getElementById('video-area');
if (!area || typeof ResizeObserver !== 'function') return;
_ro = new ResizeObserver(() => relayout());
_ro.observe(area);
/* Re-run layout after the user exits fullscreen — the observer fired
during entry and we skipped it, so the post-exit shape needs catching
up. Bound once for the page lifetime. */
if (!_fsBound) {
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) relayout();
});
_fsBound = true;
}
}
function teardownObserver() {
if (!_ro) return;
try { _ro.disconnect(); } catch (_) {}
_ro = null;
}
function add(peer) {
if (document.getElementById('tile-' + peer.id)) return;
const area = document.getElementById('video-area');
if (!area) return;
const tile = document.createElement('div');
tile.className = 'video-tile empty';
tile.id = 'tile-' + peer.id;
tile.innerHTML = `
<video class="vid-main" autoplay playsinline></video>
<audio class="audio-remote" autoplay></audio>
<div class="pip hidden">
<video class="vid-pip" autoplay playsinline></video>
</div>
<div class="empty-state">${EMPTY_SVG}</div>
<div class="mic-muted hidden" title="microphone muted">${MIC_SVG}</div>
<button class="leave-peer" title="Disconnect from this peer" aria-label="Disconnect from this peer">×</button>
<span class="conn-pill new">connecting</span>
<span class="tile-label"></span>`;
area.appendChild(tile);
refreshLabel(peer);
refreshConnState(peer);
tile.querySelector('.leave-peer').addEventListener('click', e => {
e.stopPropagation();
removePeer(peer, { sendBye: true });
});
/* Click anywhere on a tile that's showing a screen share → fullscreen the
main video, matching the previous remote-tile fullscreen behaviour. */
tile.addEventListener('click', e => {
if (!tile.classList.contains('screen')) return;
if (e.target.closest('.pip')) return;
if (e.target.closest('.leave-peer')) return;
const video = tile.querySelector(':scope > .vid-main');
if (!video) return;
if (document.fullscreenElement) {
(document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
.catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
} else {
(video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
.catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
}
});
App.media.refreshRemoteDisplayFor(peer);
ensureObserver();
relayout();
}
function remove(peer) {
const tile = document.getElementById('tile-' + peer.id);
if (tile) tile.remove();
relayout();
}
function refreshLabel(peer) {
const tile = document.getElementById('tile-' + peer.id);
if (!tile) return;
const label = tile.querySelector('.tile-label');
if (label) label.textContent = displayNameOf(peer);
/* Re-run displayNameOf for every peer in case the disambiguation suffix
changed because of this peer's username update. */
for (const other of App.state.peers.values()) {
if (other === peer) continue;
const t = document.getElementById('tile-' + other.id);
const l = t && t.querySelector('.tile-label');
if (l) l.textContent = displayNameOf(other);
}
}
function refreshConnState(peer) {
const tile = document.getElementById('tile-' + peer.id);
if (!tile) return;
const pill = tile.querySelector('.conn-pill');
if (!pill) return;
const st = (peer.pc && peer.pc.connectionState) || 'closed';
pill.textContent = st;
pill.className = 'conn-pill ' + st;
}
function refreshAll() {
for (const peer of App.state.peers.values()) {
if (!document.getElementById('tile-' + peer.id)) add(peer);
refreshLabel(peer);
refreshConnState(peer);
App.media.refreshRemoteDisplayFor(peer);
}
ensureObserver();
relayout();
}
return { add, remove, refreshLabel, refreshConnState, refreshAll, relayout, teardownObserver };
})();
/* -------------------------------------------------------------------------
Empty-call banner — shown when no peers are connected, dismissed forever
(per session) on first successful join.
------------------------------------------------------------------------- */
App.banner = (() => {
function refresh() {
const el = document.getElementById('call-banner');
if (!el) return;
const noPeers = App.state.peers.size === 0;
const show = noPeers && !App.state.bannerDismissed;
el.classList.toggle('hidden', !show);
}
return { refresh };
})();
/* Tear down ONE peer's connection without leaving the call view. */
function removePeer(peer, opts) {
if (!peer) return;
const sendBye = !opts || opts.sendBye !== false;
if (sendBye) App.chat.sendByeTo(peer);
if (peer.signalAbort) { try { peer.signalAbort.abort(); } catch (_) {} peer.signalAbort = null; }
try { if (peer.dcChat) peer.dcChat.close(); } catch (_) {}
try { if (peer.dcFiles) peer.dcFiles.close(); } catch (_) {}
try { if (peer.pc) peer.pc.close(); } catch (_) {}
/* Tear down this peer's loopback pcB, if any. */
if (peer.loopbackB) {
try { peer.loopbackB.close(); } catch (_) {}
peer.loopbackB = null;
}
App.files.cleanupForPeer(peer);
App.chat.notePeerClosed?.(peer);
App.tiles.remove(peer);
App.state.peers.delete(peer.id);
if (App.state.pendingPeer === peer) App.state.pendingPeer = null;
App.chat.appendSystem?.(displayNameOf(peer) + ' left');
updateConnPill();
App.banner.refresh();
setInCallControlsEnabled();
App.ui.refreshPeerWidgets?.();
}
/* The remote side sent a 'bye', or its pc transitioned to a terminal state.
Same path as user-initiated removePeer, but suppresses bye fan-back to
avoid a round-trip on already-dead channels. */
function onPeerLeft(peer, reason) {
if (!App.state.peers.has(peer.id)) return; /* already gone */
if (peer.leaveReceived) return;
peer.leaveReceived = true;
App.log.info('app', peer.label, 'peer left', reason || '');
removePeer(peer, { sendBye: false });
}
/* -------------------------------------------------------------------------
PeerConnection creation + event wiring
------------------------------------------------------------------------- */
@@ -2521,25 +3626,35 @@ function maybeWarnObfuscation(c) {
' • Chromium: chrome://flags/#enable-webrtc-hide-local-ips-with-mdns → Disabled');
}
function newPc(label) {
/* Create the RTCPeerConnection for one PeerCtx, wiring all the lifecycle
handlers with that peer's identity baked in. */
function newPeerPc(peer) {
const cfg = { iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 };
const pc = new RTCPeerConnection(cfg);
peer.pc = pc;
pc.addEventListener('icegatheringstatechange', () =>
App.log.debug('pc', label, 'iceGatheringState', pc.iceGatheringState));
App.log.debug('pc', peer.label, 'iceGatheringState', pc.iceGatheringState));
pc.addEventListener('iceconnectionstatechange', () =>
App.log.info('pc', label, 'iceConnectionState', pc.iceConnectionState));
App.log.info('pc', peer.label, 'iceConnectionState', pc.iceConnectionState));
pc.addEventListener('connectionstatechange', () => {
App.log.info('pc', label, 'connectionState', pc.connectionState);
App.log.info('pc', peer.label, 'connectionState', pc.connectionState);
updateConnPill();
/* Tile state may need to swap an empty placeholder for a stalled badge. */
App.tiles?.refreshConnState(peer);
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
/* Surface as a leave so the tile is cleaned up. The peer-initiated
'bye' message gets there faster, but this is the backstop. */
onPeerLeft(peer, pc.connectionState);
}
});
pc.addEventListener('signalingstatechange', () =>
App.log.debug('pc', label, 'signalingState', pc.signalingState));
App.log.debug('pc', peer.label, 'signalingState', pc.signalingState));
pc.addEventListener('icecandidate', e => {
if (e.candidate) maybeWarnObfuscation(e.candidate);
});
pc.addEventListener('icecandidateerror', e => {
const local = e.address ? (e.address + ':' + (e.port || '?')) : (e.hostCandidate || '?');
App.log.warn('pc', label, 'iceCandidateError',
App.log.warn('pc', peer.label, 'iceCandidateError',
e.errorCode, e.errorText || '(no text)',
'server=' + (e.url || '(none)'),
'from=' + local);
@@ -2547,54 +3662,44 @@ function newPc(label) {
pc.addEventListener('negotiationneeded', () => {
/* All three m-sections (mic/cam/screen) are pre-allocated in preallocate()
with direction=sendrecv before the very first createOffer, so toggling
a track via replaceTrack() never changes the SDP shape. The codec
payload-types negotiated up front cover the encoder configurations we
can reach via in-call settings (resolution, framerate, bitrate cap,
channels). Therefore any negotiationneeded the browser fires is
spurious for this app and safely ignored. */
App.log.debug('pc', label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
a track via replaceTrack() never changes the SDP shape. Any
negotiationneeded the browser fires is spurious for this app and
safely ignored. */
App.log.debug('pc', peer.label, 'negotiationneeded fired (ignored — this app does not renegotiate)');
});
pc.addEventListener('track', e => {
App.log.info('pc', label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
handleRemoteTrack(pc, e);
App.log.info('pc', peer.label, 'track received', e.track.kind, 'mid=' + (e.transceiver && e.transceiver.mid));
handleRemoteTrack(peer, e);
});
return pc;
}
/* Local media state derived from the actual sender tracks / screen stream. */
/* Local media state derived from the live local tracks / screen stream. */
function localMediaState() {
return {
mic: !!(App.state.micTransceiver && App.state.micTransceiver.sender.track),
cam: !!(App.state.camTransceiver && App.state.camTransceiver.sender.track),
mic: !!App.state.micTrack,
cam: !!App.state.camTrack,
screen: !!App.state.screenStream,
};
}
/* Tell the peer about our current mic/cam/screen state. Sent over the chat
data channel as a typed JSON message — replaceTrack(null) doesn't itself
propagate any signal across the wire, so the receiver would otherwise just
see frozen video on the last frame. */
/* Tell every connected peer about our current mic/cam/screen state. Sent
over each chat data channel as a typed JSON message — replaceTrack(null)
doesn't itself propagate any signal across the wire, so the receiver
would otherwise just see frozen video on the last frame. */
function broadcastMediaState() {
const dc = App.state.dcChat;
if (!dc || dc.readyState !== 'open') return;
const state = { kind: 'media-state', ...localMediaState() };
try { dc.send(JSON.stringify(state)); }
catch (e) { App.log.warn('media', 'state send failed', e.message); }
for (const peer of App.state.peers.values()) sendMediaStateTo(peer);
}
/* Rebuild the remote streams by inspecting the current receivers — defensive
alternative to relying solely on the 'track' event, which can fire at
slightly different points across browsers (especially on the offerer side
where receivers existed before negotiation). */
/* Map a receiver-side transceiver to its role using the stored references
from preallocate() / adoptTransceiversFromRemote(). Identity comparison
is the only reliable cue — relying on indexOf-by-kind misroutes cam and
screen when only one of them has live frames (track events fire out of
order across browsers / offerer-vs-joiner roles). */
function roleForTransceiver(t) {
on the PeerCtx from preallocate() / adoptTransceiversFromRemote(). Identity
comparison is the only reliable cue — relying on indexOf-by-kind misroutes
cam and screen when only one of them has live frames (track events fire
out of order across browsers / offerer-vs-joiner roles). */
function roleForTransceiver(peer, t) {
if (!t) return null;
if (t === App.state.micTransceiver) return 'mic';
if (t === App.state.camTransceiver) return 'cam';
if (t === App.state.screenTransceiver) return 'screen';
if (t === peer.micTransceiver) return 'mic';
if (t === peer.camTransceiver) return 'cam';
if (t === peer.screenTransceiver) return 'screen';
return null;
}
@@ -2602,11 +3707,12 @@ function roleForTransceiver(t) {
instead of leaking anonymous arrow closures over the lifetime of the page. */
const remoteTrackListeners = new WeakMap(); /* track -> { unmute, ended } */
function attachRemoteTrackListeners(track, onEnded) {
function attachRemoteTrackListeners(peer, track, onEnded) {
detachRemoteTrackListeners(track);
const handlers = { unmute: App.media.refreshRemoteDisplay, ended: onEnded };
track.addEventListener('unmute', handlers.unmute);
track.addEventListener('ended', handlers.ended);
const refresh = () => App.media.refreshRemoteDisplayFor(peer);
const handlers = { unmute: refresh, ended: onEnded };
track.addEventListener('unmute', refresh);
track.addEventListener('ended', onEnded);
remoteTrackListeners.set(track, handlers);
}
function detachRemoteTrackListeners(track) {
@@ -2617,13 +3723,13 @@ function detachRemoteTrackListeners(track) {
remoteTrackListeners.delete(track);
}
function rebuildRemoteStreams(pc) {
if (!pc) return;
function rebuildRemoteStreamsFor(peer) {
if (!peer || !peer.pc) return;
let audio = null, cam = null, screen = null;
for (const t of pc.getTransceivers()) {
for (const t of peer.pc.getTransceivers()) {
const tr = t.receiver && t.receiver.track;
if (!tr) continue;
const role = roleForTransceiver(t);
const role = roleForTransceiver(peer, t);
if (role === 'mic' && !audio) audio = tr;
if (role === 'cam' && !cam) cam = tr;
if (role === 'screen' && !screen) screen = tr;
@@ -2631,148 +3737,183 @@ function rebuildRemoteStreams(pc) {
const remote = new MediaStream();
if (audio) remote.addTrack(audio);
if (cam) remote.addTrack(cam);
App.state.remoteStream = remote;
peer.remoteStream = remote;
const remoteScreen = new MediaStream();
if (screen) remoteScreen.addTrack(screen);
App.state.remoteScreenStream = remoteScreen;
/* React when a so-far-muted track gets actual frames. */
const camStreamRef = remote;
const screenStreamRef = remoteScreen;
if (audio) attachRemoteTrackListeners(audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplay(); });
if (cam) attachRemoteTrackListeners(cam, () => { camStreamRef.removeTrack(cam); App.media.refreshRemoteDisplay(); });
if (screen) attachRemoteTrackListeners(screen, () => { screenStreamRef.removeTrack(screen); App.media.refreshRemoteDisplay(); });
App.log.info('pc', 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
App.media.refreshRemoteDisplay();
peer.remoteScreenStream = remoteScreen;
if (audio) attachRemoteTrackListeners(peer, audio, () => { remote.removeTrack(audio); App.media.refreshRemoteDisplayFor(peer); });
if (cam) attachRemoteTrackListeners(peer, cam, () => { remote.removeTrack(cam); App.media.refreshRemoteDisplayFor(peer); });
if (screen) attachRemoteTrackListeners(peer, screen, () => { remoteScreen.removeTrack(screen); App.media.refreshRemoteDisplayFor(peer); });
App.log.info('pc', peer.label, 'remote streams rebuilt', 'audio', !!audio, 'cam', !!cam, 'screen', !!screen);
App.media.refreshRemoteDisplayFor(peer);
}
function handleRemoteTrack(pc, e) {
/* In loopback, the second pc (pcB) also fires track events but those
represent our own outbound tracks being received on the synthetic peer
— they must not feed the visible "remote" tile. */
if (pc !== App.state.pc) return;
const role = roleForTransceiver(e.transceiver);
App.log.info('pc', 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
function handleRemoteTrack(peer, e) {
const role = roleForTransceiver(peer, e.transceiver);
App.log.info('pc', peer.label, 'remote track', e.track.kind, 'role', role, 'muted', e.track.muted, 'mid', e.transceiver && e.transceiver.mid);
if (role === 'mic' || role === 'cam') {
if (!App.state.remoteStream) App.state.remoteStream = new MediaStream();
App.state.remoteStream.addTrack(e.track);
attachRemoteTrackListeners(e.track, () => {
if (App.state.remoteStream) App.state.remoteStream.removeTrack(e.track);
App.media.refreshRemoteDisplay();
if (!peer.remoteStream) peer.remoteStream = new MediaStream();
peer.remoteStream.addTrack(e.track);
attachRemoteTrackListeners(peer, e.track, () => {
if (peer.remoteStream) peer.remoteStream.removeTrack(e.track);
App.media.refreshRemoteDisplayFor(peer);
});
App.media.refreshRemoteDisplay();
App.media.refreshRemoteDisplayFor(peer);
} else if (role === 'screen') {
if (!App.state.remoteScreenStream) App.state.remoteScreenStream = new MediaStream();
App.state.remoteScreenStream.addTrack(e.track);
attachRemoteTrackListeners(e.track, () => {
if (App.state.remoteScreenStream) App.state.remoteScreenStream.removeTrack(e.track);
App.media.refreshRemoteDisplay();
if (!peer.remoteScreenStream) peer.remoteScreenStream = new MediaStream();
peer.remoteScreenStream.addTrack(e.track);
attachRemoteTrackListeners(peer, e.track, () => {
if (peer.remoteScreenStream) peer.remoteScreenStream.removeTrack(e.track);
App.media.refreshRemoteDisplayFor(peer);
});
App.media.refreshRemoteDisplay();
App.media.refreshRemoteDisplayFor(peer);
}
}
/* Connection pill summarizes the worst-of state across all peers. */
function updateConnPill() {
const pc = App.state.pc;
const pill = document.getElementById('conn-pill');
if (!pc) { pill.textContent = 'disconnected'; pill.className = 'pill'; return; }
const st = pc.connectionState;
pill.textContent = st;
if (!pill) return;
const peers = peerList();
if (!peers.length) { pill.textContent = 'no peers'; pill.className = 'pill'; return; }
const states = peers.map(p => p.pc ? p.pc.connectionState : 'closed');
const worst = states.includes('failed') ? 'failed'
: states.includes('disconnected') ? 'disconnected'
: states.includes('closed') ? 'closed'
: states.includes('connecting') || states.includes('new') ? 'connecting'
: 'connected';
const label = peers.length === 1 ? worst : worst + ' (' + peers.length + ')';
pill.textContent = label;
pill.className = 'pill ' + (
st === 'connected' ? 'ok' :
st === 'connecting' || st === 'new' ? 'warn' :
worst === 'connected' ? 'ok' :
worst === 'connecting' || worst === 'new' ? 'warn' :
'err'
);
}
/* -------------------------------------------------------------------------
Signaling flows: initiator, joiner, loopback
Each invocation builds a fresh PeerCtx, registers it in App.state.peers,
and runs the chosen handshake against that peer alone. Local media,
chat/files UI, etc. are shared across all peers — only the per-peer
pc/dc/transceivers/streams live on the ctx.
------------------------------------------------------------------------- */
/* Invoked when the user clicks Cancel in the step modal. Tears down the
in-flight setup (closes pc, aborts any long-poll fetch) and bounces back
to the welcome view. The setup function will see signalAbort.aborted and
throw 'cancelled', which cfg-continue's catch silently swallows. */
in-flight setup (closes pc, aborts any long-poll fetch). Leaves any
already-connected peers alone — only the pending peer is dropped. */
function cancelSetup() {
App.log.info('app', 'setup cancelled by user');
App.state.userCancelled = true;
App.progress.hide();
hangup({ sendBye: false });
const pending = App.state.pendingPeer;
App.state.pendingPeer = null;
if (pending) removePeer(pending, { sendBye: false });
}
/* Create + register a PeerCtx, wire its pc, return it. */
function beginPeer(opts) {
const peer = newPeerCtx({
id: opts.id || generatePeerId(),
role: opts.role,
label: opts.label,
username: opts.username || 'unknown',
});
/* Seed per-peer defaults from current settings — these are setParameters-
tunable later via the per-peer settings panel. */
const s = App.state.settings;
peer.videoMaxBitrateKbps = s.video.maxBitrateKbps;
peer.videoDegradationPreference = s.video.degradationPreference;
peer.screenMaxBitrateKbps = s.screen.maxBitrateKbps;
peer.screenDegradationPreference = s.screen.degradationPreference;
peer.sendVideoCodec = s.sendVideoCodec || 'auto';
newPeerPc(peer);
App.state.peers.set(peer.id, peer);
App.state.pendingPeer = peer;
return peer;
}
/* Promote pending → committed: invoked once the handshake reaches a point
where giving up would require cooperating with the remote side. */
function commitPeer(peer) {
if (App.state.pendingPeer === peer) App.state.pendingPeer = null;
App.tiles?.add(peer);
App.state.bannerDismissed = true;
App.banner?.refresh();
updateConnPill();
App.ui.refreshPeerWidgets?.();
}
async function startInitiator() {
const pc = newPc('A');
App.state.pc = pc;
App.media.preallocate(pc);
App.media.applyVideoCodecPreference(pc);
const peer = beginPeer({ role: 'initiator', label: 'init' });
App.media.preallocate(peer);
App.media.applyVideoCodecPreference(peer);
const ac = new AbortController();
App.state.signalAbort = ac;
peer.signalAbort = ac;
/* Data channels MUST be created on the initiator before createOffer
so they're included in the SDP m-section list. */
App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
App.chat.attach(App.state.dcChat);
App.files.attach(App.state.dcFiles);
peer.dcChat = peer.pc.createDataChannel('chat', { ordered: true });
peer.dcFiles = peer.pc.createDataChannel('files', { ordered: true });
App.chat.attach(peer, peer.dcChat);
App.files.attach(peer, peer.dcFiles);
App.progress.show('Creating offer…', 'Negotiating local SDP.');
let offer = await pc.createOffer();
let offer = await peer.pc.createOffer();
offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
await pc.setLocalDescription(offer);
App.log.info('signal', 'offer created, waiting for ICE gathering…');
await peer.pc.setLocalDescription(offer);
App.log.info('signal', peer.label, 'offer created, waiting for ICE gathering…');
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
await App.signal.waitForIceComplete(peer.pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
App.log.info('signal', 'ICE gathering complete; offer ready to export');
App.log.info('signal', peer.label, 'ICE gathering complete; offer ready to export');
App.progress.hide();
renderInitiatorExchange();
renderInitiatorExchange(peer);
}
async function startJoiner() {
const pc = newPc('A');
App.state.pc = pc;
pc.ondatachannel = e => {
/* If the user picked a new role mid-flight, App.state.pc may already be
a different connection. Late events from the previous pc would
otherwise overwrite the current dcChat/dcFiles with a closed channel. */
if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
App.log.info('signal', 'incoming data channel', e.channel.label);
if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
const peer = beginPeer({ role: 'joiner', label: 'join' });
peer.pc.ondatachannel = e => {
if (!App.state.peers.has(peer.id)) { App.log.warn('signal', peer.label, 'datachannel from stale pc, ignoring'); return; }
App.log.info('signal', peer.label, 'incoming data channel', e.channel.label);
if (e.channel.label === 'chat') { peer.dcChat = e.channel; App.chat.attach(peer, e.channel); }
if (e.channel.label === 'files') { peer.dcFiles = e.channel; App.files.attach(peer, e.channel); }
};
renderJoinerExchange();
renderJoinerExchange(peer);
}
async function finishJoiner(offerObj) {
const pc = App.state.pc;
const ac = App.state.signalAbort || new AbortController();
App.state.signalAbort = ac;
async function finishJoiner(peer, offerObj) {
const ac = peer.signalAbort || new AbortController();
peer.signalAbort = ac;
App.progress.show('Applying remote offer…', 'Parsing your peer\'s SDP.');
await pc.setRemoteDescription(offerObj);
App.media.adoptTransceiversFromRemote(pc);
App.media.applyVideoCodecPreference(pc);
rebuildRemoteStreams(pc);
await peer.pc.setRemoteDescription(offerObj);
App.media.adoptTransceiversFromRemote(peer);
App.media.applyVideoCodecPreference(peer);
rebuildRemoteStreamsFor(peer);
await App.media.publishLocalTracksTo(peer);
App.progress.show('Creating answer…', 'Negotiating local SDP.');
let answer = await pc.createAnswer();
let answer = await peer.pc.createAnswer();
answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
await pc.setLocalDescription(answer);
await peer.pc.setLocalDescription(answer);
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
await App.signal.waitForIceComplete(peer.pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
App.progress.hide();
showAnswerForJoiner();
showAnswerForJoiner(peer);
}
async function applyAnswerOnInitiator(answerObj) {
const pc = App.state.pc;
await pc.setRemoteDescription(answerObj);
rebuildRemoteStreams(pc);
App.log.info('signal', 'remote answer applied; waiting to connect…');
async function applyAnswerOnInitiator(peer, answerObj) {
await peer.pc.setRemoteDescription(answerObj);
rebuildRemoteStreamsFor(peer);
await App.media.publishLocalTracksTo(peer);
App.log.info('signal', peer.label, 'remote answer applied; waiting to connect…');
}
/* -------------------------------------------------------------------------
@@ -2780,12 +3921,13 @@ async function applyAnswerOnInitiator(answerObj) {
See server/signal.c for the protocol. The relay never touches media or
the data channels — those still flow peer-to-peer.
------------------------------------------------------------------------- */
async function signalPost(base, code, slot, body) {
async function signalPost(base, code, slot, body, signal) {
const url = base.replace(/\/+$/, '') + '/room/' + encodeURIComponent(code) + '/' + slot;
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body,
signal,
});
if (!r.ok) throw new Error('POST ' + slot + ' failed: ' + r.status);
}
@@ -2818,57 +3960,57 @@ async function signalPoll(base, code, slot, totalDeadlineMs, signal) {
then long-poll the answer slot. Skips the blob copy/paste exchange view. */
async function startInitiatorAuto(code) {
const base = App.state.settings.signaling.serverUrl;
const peer = beginPeer({ role: 'initiator', label: 'init/' + code });
const ac = new AbortController();
App.state.signalAbort = ac;
peer.signalAbort = ac;
const pc = newPc('A');
App.state.pc = pc;
App.media.preallocate(pc);
App.media.applyVideoCodecPreference(pc);
App.state.dcChat = pc.createDataChannel('chat', { ordered: true });
App.state.dcFiles = pc.createDataChannel('files', { ordered: true });
App.chat.attach(App.state.dcChat);
App.files.attach(App.state.dcFiles);
App.media.preallocate(peer);
App.media.applyVideoCodecPreference(peer);
peer.dcChat = peer.pc.createDataChannel('chat', { ordered: true });
peer.dcFiles = peer.pc.createDataChannel('files', { ordered: true });
App.chat.attach(peer, peer.dcChat);
App.files.attach(peer, peer.dcFiles);
App.progress.show('Creating offer…', 'Negotiating local SDP.');
let offer = await pc.createOffer();
let offer = await peer.pc.createOffer();
offer.sdp = App.codec.mungeOpus(offer.sdp, App.state.settings.opus);
await pc.setLocalDescription(offer);
await peer.pc.setLocalDescription(offer);
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
await App.signal.waitForIceComplete(peer.pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
const offerBlob = App.signal.encode(pc.localDescription);
const offerBlob = App.signal.encode(peer.pc.localDescription);
App.progress.show('Publishing offer…', 'Room ' + code + ' on ' + base);
await signalPost(base, code, 'offer', offerBlob);
await signalPost(base, code, 'offer', offerBlob, ac.signal);
App.progress.showModal('Waiting for peer…',
'Share the room code with them. They have 5 minutes to join.',
{ roomCode: code, onCancel: cancelSetup });
const answerText = await signalPoll(base, code, 'answer', 5 * 60 * 1000, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
App.progress.show('Applying answer…', 'Finalizing the handshake.');
const obj = App.signal.decode(answerText);
if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
await applyAnswerOnInitiator(obj);
await applyAnswerOnInitiator(peer, obj);
App.progress.hide();
commitPeer(peer);
goToCall();
}
/* Joiner side: long-poll the offer slot, apply it, push the answer back. */
async function startJoinerAuto(code) {
const base = App.state.settings.signaling.serverUrl;
const peer = beginPeer({ role: 'joiner', label: 'join/' + code });
const ac = new AbortController();
App.state.signalAbort = ac;
const pc = newPc('A');
App.state.pc = pc;
pc.ondatachannel = e => {
if (App.state.pc !== pc) { App.log.warn('signal', 'datachannel from stale pc, ignoring'); return; }
App.log.info('signal', 'incoming data channel', e.channel.label);
if (e.channel.label === 'chat') { App.state.dcChat = e.channel; App.chat.attach(e.channel); }
if (e.channel.label === 'files') { App.state.dcFiles = e.channel; App.files.attach(e.channel); }
peer.signalAbort = ac;
peer.pc.ondatachannel = e => {
if (!App.state.peers.has(peer.id)) { App.log.warn('signal', peer.label, 'datachannel from stale pc, ignoring'); return; }
App.log.info('signal', peer.label, 'incoming data channel', e.channel.label);
if (e.channel.label === 'chat') { peer.dcChat = e.channel; App.chat.attach(peer, e.channel); }
if (e.channel.label === 'files') { peer.dcFiles = e.channel; App.files.attach(peer, e.channel); }
};
App.progress.showModal('Waiting for offer…',
@@ -2880,31 +4022,38 @@ async function startJoinerAuto(code) {
if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
App.progress.show('Applying offer…', 'Building answer.');
await pc.setRemoteDescription(obj);
App.media.adoptTransceiversFromRemote(pc);
App.media.applyVideoCodecPreference(pc);
rebuildRemoteStreams(pc);
await peer.pc.setRemoteDescription(obj);
App.media.adoptTransceiversFromRemote(peer);
App.media.applyVideoCodecPreference(peer);
rebuildRemoteStreamsFor(peer);
await App.media.publishLocalTracksTo(peer);
let answer = await pc.createAnswer();
let answer = await peer.pc.createAnswer();
answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
await pc.setLocalDescription(answer);
await peer.pc.setLocalDescription(answer);
App.progress.showModal('Gathering ICE candidates…',
iceGatheringSubtitle(),
{ onCancel: cancelSetup });
await App.signal.waitForIceComplete(pc, ac.signal);
await App.signal.waitForIceComplete(peer.pc, ac.signal);
if (ac.signal.aborted) throw new Error('cancelled');
App.progress.show('Publishing answer…', 'Room ' + code + ' on ' + base);
await signalPost(base, code, 'answer', App.signal.encode(pc.localDescription));
await signalPost(base, code, 'answer', App.signal.encode(peer.pc.localDescription), ac.signal);
App.progress.hide();
commitPeer(peer);
goToCall();
}
async function startLoopback() {
const pcA = newPc('A');
const pcB = newPc('B');
App.state.pc = pcA;
App.state.pcB = pcB;
/* Each loopback adds one PeerCtx (the visible side, pcA) plus its own
synthetic pcB, stored on the peer. Multiple loopbacks can coexist —
useful for stress-testing the conference grid. */
const idx = peerList().filter(p => p.role === 'loopback').length + 1;
const username = idx === 1 ? 'Loopback' : 'Loopback ' + idx;
const peer = beginPeer({ role: 'loopback', label: 'loop-A/' + idx, username });
const pcA = peer.pc;
const pcB = new RTCPeerConnection({ iceServers: App.state.settings.iceServers || [], iceCandidatePoolSize: 0 });
peer.loopbackB = pcB;
/* Trickle candidates between the two local PCs. Buffer candidates that
arrive before the target has its remote description set — otherwise
@@ -2926,26 +4075,25 @@ async function startLoopback() {
pcB.ondatachannel = e => {
/* In loopback the *visible* call uses pcA's POV. pcB is the synthetic
peer; echoing the chat data channel back to A is how A learns about
its "peer's" media state (which in loopback is itself) and how chat
messages round-trip for verification. Files messages must NOT be
echoed — that would feed A's outgoing file frames back into its own
incoming-file accumulator, doubling memory and producing phantom
"incoming" download rows. */
peer; echoing both data channels back to A is how A learns about
its "peer's" media state (which in loopback is itself), how chat
messages round-trip, and how files appear as incoming rows for
verification. The file echo doubles memory while a transfer is
in flight, but loopback exists for testing so that trade-off is
acceptable. */
e.channel.onmessage = ev => {
App.log.debug('loopback', 'B got', e.channel.label, typeof ev.data === 'string' ? ev.data.slice(0, 80) : '(binary)');
if (e.channel.label !== 'chat') return;
try { e.channel.send(ev.data); } catch (_) {}
};
};
pcB.ontrack = e => App.log.debug('loopback', 'B got track', e.track.kind);
App.media.preallocate(pcA);
App.media.applyVideoCodecPreference(pcA);
App.state.dcChat = pcA.createDataChannel('chat', { ordered: true });
App.state.dcFiles = pcA.createDataChannel('files', { ordered: true });
App.chat.attach(App.state.dcChat);
App.files.attach(App.state.dcFiles);
App.media.preallocate(peer);
App.media.applyVideoCodecPreference(peer);
peer.dcChat = pcA.createDataChannel('chat', { ordered: true });
peer.dcFiles = pcA.createDataChannel('files', { ordered: true });
App.chat.attach(peer, peer.dcChat);
App.files.attach(peer, peer.dcFiles);
App.progress.show('Negotiating local loopback…', 'Exchanging SDP between the two in-tab peers.');
const offer = await pcA.createOffer();
@@ -2960,16 +4108,21 @@ async function startLoopback() {
try { t.direction = 'sendrecv'; }
catch (e) { App.log.warn('loopback', 'could not upgrade transceiver to sendrecv', e.message); }
}
App.media.applyVideoCodecPreference(pcB);
const answer = await pcB.createAnswer();
answer.sdp = App.codec.mungeOpus(answer.sdp, App.state.settings.opus);
await pcB.setLocalDescription(answer);
await pcA.setRemoteDescription(answer);
remoteSetA = true; flush(pcA, pendingForA);
rebuildRemoteStreams(pcA);
rebuildRemoteStreamsFor(peer);
await App.media.publishLocalTracksTo(peer);
/* If mic/cam/screen were already on before the user started loopback, the
mirror is empty — pcB has nothing to send back. Push the current tracks
onto pcB's senders now so the synthetic peer immediately echoes them. */
App.media.mirrorToLoopback();
App.log.info('loopback', 'offer/answer exchanged locally');
App.progress.hide();
commitPeer(peer);
goToCall();
}
@@ -2978,11 +4131,40 @@ async function startLoopback() {
------------------------------------------------------------------------- */
const viewToHash = {
'view-welcome': 'welcome',
'view-configure': 'configure',
'view-exchange': 'exchange',
'view-sdp-inspect': 'sdp-inspect',
'view-call': 'call',
};
/* The configure and exchange steps are modal <dialog>s, not routed views.
These helpers keep each dialog plus its progress row state in sync with
the caller. */
function openConfigureDialog() {
const dlg = document.getElementById('view-configure');
if (!dlg || typeof dlg.showModal !== 'function') return;
if (dlg.open) return;
document.getElementById('cfg-progress')?.classList.add('hidden');
try { dlg.showModal(); } catch (_) {}
}
function closeConfigureDialog() {
const dlg = document.getElementById('view-configure');
if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
document.getElementById('cfg-progress')?.classList.add('hidden');
}
function openExchangeDialog() {
const dlg = document.getElementById('view-exchange');
if (!dlg || typeof dlg.showModal !== 'function') return;
if (dlg.open) return;
document.getElementById('exch-progress')?.classList.add('hidden');
try { dlg.showModal(); } catch (_) {}
}
function closeExchangeDialog() {
const dlg = document.getElementById('view-exchange');
if (dlg && dlg.open) { try { dlg.close(); } catch (_) {} }
document.getElementById('exch-progress')?.classList.add('hidden');
/* Reset both step bodies so a future open starts clean. */
document.getElementById('step-1-body')?.replaceChildren();
document.getElementById('step-2-body')?.replaceChildren();
}
const hashToView = Object.fromEntries(
Object.entries(viewToHash).map(([v, h]) => [h, v])
);
@@ -3005,15 +4187,10 @@ function resetViewState(id) {
document.getElementById('sdp-inspect-out')?.replaceChildren();
const status = document.getElementById('sdp-inspect-status');
if (status) { status.textContent = ''; status.className = 'pill'; }
} else if (id === 'view-exchange') {
document.getElementById('step-1-body')?.replaceChildren();
document.getElementById('step-2-body')?.replaceChildren();
document.getElementById('step-2-card')?.classList.add('hidden');
document.getElementById('exch-progress')?.classList.add('hidden');
} else if (id === 'view-configure') {
document.getElementById('cfg-progress')?.classList.add('hidden');
} else if (id === 'view-call') {
document.getElementById('chat-log')?.replaceChildren();
/* App.chat.clearAll empties both the chat-log DOM and the outMsgs map
so detached message nodes can be GC'd. */
App.chat?.clearAll?.();
if (App.files && App.files.clearAll) {
App.files.clearAll('out');
App.files.clearAll('in');
@@ -3062,25 +4239,22 @@ function onPopstate(event) {
history.pushState({ view: 'view-call' }, '', '#call');
return;
}
resetSession();
teardownAll();
history.replaceState({ view: 'view-welcome' }, '', '#welcome');
showView('view-welcome', { push: false });
return;
}
/* Exchange and call need in-flight state (offer, live pc) that doesn't
exist when reached via back/forward — redirect to welcome. */
if (target === 'view-exchange' || target === 'view-call') {
if (App.state.pc || App.state.pcB || App.state.role) resetSession();
/* Call needs in-flight state (live pc) that doesn't exist when reached
via back/forward — redirect to welcome. */
if (target === 'view-call') {
if (App.state.peers.size || App.state.pendingPeer) teardownAll();
history.replaceState({ view: 'view-welcome' }, '', '#welcome');
showView('view-welcome', { push: false });
return;
}
if (App.state.pc || App.state.pcB) resetSession();
if (target === 'view-welcome' || target === 'view-sdp-inspect') {
if (App.state.role) { App.state.role = null; updateRoleBadge(); }
}
if (App.state.peers.size || App.state.pendingPeer) teardownAll();
showView(target, { push: false });
}
@@ -3093,39 +4267,43 @@ function resolveInitialView() {
showView(target, { push: false });
}
/* Role badge in the topbar reflects the role of the in-flight (pending)
peer setup, if any. Otherwise hidden. */
function updateRoleBadge() {
const badge = document.getElementById('role-badge');
const cfg = document.getElementById('role-title-cfg');
const exch = document.getElementById('role-title-exch');
const r = App.state.role;
if (!r) { badge.classList.add('hidden'); return; }
badge.classList.remove('hidden');
badge.textContent = r;
badge.className = 'role-badge ' + r;
if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
const r = App.state.pendingPeer ? App.state.pendingPeer.role : null;
if (!r) {
if (badge) badge.classList.add('hidden');
if (cfg) cfg.textContent = '';
if (exch) exch.textContent = '';
return;
}
if (badge) { badge.classList.remove('hidden'); badge.textContent = r; badge.className = 'role-badge ' + r; }
if (cfg) { cfg.textContent = r; cfg.className = 'role-badge ' + r; }
if (exch) { exch.textContent = r; exch.className = 'role-badge ' + r; }
}
function pickRole(role) {
/* If a pc from a previous role attempt is still around, tear it down so
we don't leak it. Most paths reach pickRole via the welcome view where
hangup() was already called, but the defensive close here covers cases
where the user navigates back without going through exch-cancel. */
if (App.state.pc || App.state.pcB) hangup();
App.state.role = role;
/* Open the Add Participant flow for the given role. The configure/exchange
views are reused (the existing layout), so the user can edit the per-add
signaling mode without leaving the call. On Cancel or after the new peer
joins, we return to the call view. */
function pickRoleForAdd(role) {
/* No global pc to tear down — adding a participant just spins up a new
PeerCtx. If a previous Add flow was abandoned, cancelSetup clears it. */
if (App.state.pendingPeer) cancelSetup();
/* Store the chosen role on a transient slot so cfg-continue knows what to
do. The actual PeerCtx is created in start*() once the user clicks
Continue. */
App.state.nextAddRole = role;
updateRoleBadge();
if (role === 'loopback') {
populateConfigInputs();
showView('view-configure');
document.getElementById('cfg-lede').textContent = 'Loopback mode: both peers run in this tab and skip the paste step.';
} else {
populateConfigInputs();
showView('view-configure');
document.getElementById('cfg-lede').textContent =
role === 'initiator'
? 'You will generate an offer; your peer pastes it and sends back an answer.'
: 'Your peer sends you an offer; you paste it and send back the generated answer.';
}
populateConfigInputs();
document.getElementById('cfg-lede').textContent =
role === 'loopback' ? 'Loopback mode: both peers run in this tab and skip the paste step.'
: role === 'initiator' ? 'You will generate an offer; your peer pastes it and sends back an answer.'
: 'Your peer sends you an offer; you paste it and send back the generated answer.';
openConfigureDialog();
refreshInsecureWarning();
}
@@ -3222,7 +4400,7 @@ function populateConfigInputs() {
applySignalingMode(s.signaling.mode);
/* Loopback doesn't use signaling at all — hide the card so the user isn't
given irrelevant choices. */
$('signaling-card').classList.toggle('hidden', App.state.role === 'loopback');
$('signaling-card').classList.toggle('hidden', App.state.nextAddRole === 'loopback');
}
function applySignalingMode(mode) {
@@ -3294,10 +4472,10 @@ function wireUploadButton(name) {
}
/* Render the outgoing-blob block (textarea + controls) into `body`. Owns its
own copy/download/base64-toggle wiring; re-encodes from pc.localDescription
own copy/download/base64-toggle wiring; re-encodes from peer.pc.localDescription
when the toggle flips so the visible blob always matches the setting.
`extraButtons` is an array of {id,label,cls,onClick} appended after Download. */
function mountOutgoingBlob(body, name, extraButtons) {
function mountOutgoingBlob(body, name, peer, extraButtons) {
const extras = (extraButtons || []).map(b =>
`<button id="${b.id}" class="${b.cls || 'ghost'}">${b.label}</button>`).join('');
body.innerHTML = `
@@ -3317,7 +4495,7 @@ function mountOutgoingBlob(body, name, extraButtons) {
let current = '';
function refresh() {
const desc = App.state.pc && App.state.pc.localDescription;
const desc = peer && peer.pc && peer.pc.localDescription;
if (!desc) return;
current = App.signal.encode(desc);
ta.value = current;
@@ -3360,10 +4538,14 @@ function mountOutgoingBlob(body, name, extraButtons) {
}
/* Exchange views */
function renderInitiatorExchange() {
function renderInitiatorExchange(peer) {
document.getElementById('step-1-h').textContent = 'Step 1: send this offer to your peer';
mountOutgoingBlob(document.getElementById('step-1-body'), 'offer');
mountOutgoingBlob(document.getElementById('step-1-body'), 'offer', peer);
/* The initiator needs BOTH steps visible: send the offer, then paste the
answer. Make sure step-2 is shown — a prior joiner flow may have hidden
it. */
document.getElementById('step-2-card').classList.remove('hidden');
document.getElementById('step-2-h').textContent = 'Step 2: paste your peer\'s answer';
const s2 = document.getElementById('step-2-body');
s2.innerHTML = `
@@ -3382,8 +4564,10 @@ function renderInitiatorExchange() {
const obj = App.signal.decode(text);
if (obj.type !== 'answer') throw new Error('expected an answer, got ' + obj.type);
statusEl.textContent = 'applying…'; statusEl.className = 'pill warn';
await applyAnswerOnInitiator(obj);
await applyAnswerOnInitiator(peer, obj);
statusEl.textContent = 'applied'; statusEl.className = 'pill ok';
commitPeer(peer);
closeExchangeDialog();
goToCall();
} catch (e) {
statusEl.textContent = e.message; statusEl.className = 'pill err';
@@ -3391,10 +4575,10 @@ function renderInitiatorExchange() {
}
});
showView('view-exchange');
openExchangeDialog();
}
function renderJoinerExchange() {
function renderJoinerExchange(peer) {
document.getElementById('step-1-h').textContent = 'Step 1: paste the offer from your peer';
const s1 = document.getElementById('step-1-body');
s1.innerHTML = `
@@ -3415,7 +4599,7 @@ function renderJoinerExchange() {
const obj = App.signal.decode(text);
if (obj.type !== 'offer') throw new Error('expected an offer, got ' + obj.type);
statusEl.textContent = 'working…'; statusEl.className = 'pill warn';
await finishJoiner(obj);
await finishJoiner(peer, obj);
statusEl.textContent = 'ready'; statusEl.className = 'pill ok';
} catch (e) {
statusEl.textContent = e.message; statusEl.className = 'pill err';
@@ -3424,21 +4608,22 @@ function renderJoinerExchange() {
}
});
showView('view-exchange');
openExchangeDialog();
}
function showAnswerForJoiner() {
function showAnswerForJoiner(peer) {
document.getElementById('step-2-card').classList.remove('hidden');
document.getElementById('step-2-h').textContent = 'Step 2: send this answer back to your peer';
mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', [
{ id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => goToCall() },
mountOutgoingBlob(document.getElementById('step-2-body'), 'answer', peer, [
{ id: 'blob-done', label: "I've sent it →", cls: 'ghost', onClick: () => { commitPeer(peer); closeExchangeDialog(); goToCall(); } },
]);
}
function goToCall() {
showView('view-call');
setInCallControlsEnabled(true);
/* Pre-fill runtime settings panel from current values */
/* Pre-fill runtime settings panel from current global defaults — these
apply to NEW peers; per-peer overrides will be added in a later task. */
const s = App.state.settings;
const $ = id => document.getElementById(id);
$('rt-v-w').value = s.video.width || 0;
@@ -3461,12 +4646,155 @@ function goToCall() {
$('rt-a-agc').checked = s.audio.autoGainControl;
$('rt-a-channels').value = String(s.audio.channelCount || 1);
$('rt-a-rate').value = s.audio.sampleRate || 0;
$('rt-username').value = App.state.username || 'Anonymous';
App.stats.start();
App.tiles.refreshAll();
App.banner.refresh();
App.ui.refreshPeerWidgets?.();
/* Refresh the displays after the view is actually visible — some browsers
don't render hidden video elements properly, so re-bind srcObject. */
App.media.refreshLocalDisplay();
App.media.refreshRemoteDisplay();
applyMediaButtonAvailability();
/* Set local tile label from current username. */
const localLabel = document.getElementById('tile-local-label');
if (localLabel) localLabel.textContent = (App.state.username || 'Anonymous') + ' (you)';
}
/* Reflect whether at least one peer with an open data channel exists.
Toolbar media buttons stay enabled even with no peers (the user might
enable mic/cam before adding anyone — fan-out is a no-op). Chat + files
inputs are gated by their own update*() helpers, which are wired in
wire() and registered on App.ui so this function can call them. */
function setInCallControlsEnabled() {
App.ui.updateChatGate?.();
App.ui.updateFilesGate?.();
/* The set of "peers with open data channels" just changed, so the recipient
bars need to add/remove their chips. */
if (typeof renderRecipientBar === 'function') {
renderRecipientBar('chat');
renderRecipientBar('files');
}
}
App.ui = App.ui || {};
/* Shared recipient-selection state for chat + files. Each set holds the
peerIds the user has EXCLUDED from the current selection — empty set ==
"all peers". This way, peers added later are included by default. */
App.ui.chatExcluded = new Set();
App.ui.filesExcluded = new Set();
/* Compute the active recipient list for a given channel based on the
excluded set and the currently-open data channels. */
App.ui.selectedChatPeers = function () {
return peersWithOpenChat().filter(p => !App.ui.chatExcluded.has(p.id));
};
App.ui.selectedFilesPeers = function () {
return peersWithOpenFiles().filter(p => !App.ui.filesExcluded.has(p.id));
};
/* Render one chip bar. `kind` is 'chat' or 'files'. */
function renderRecipientBar(kind) {
const el = document.getElementById(kind === 'chat' ? 'chat-recipients' : 'files-recipients');
if (!el) return;
const excluded = kind === 'chat' ? App.ui.chatExcluded : App.ui.filesExcluded;
const available = (kind === 'chat' ? peersWithOpenChat() : peersWithOpenFiles());
/* Drop excluded ids that no longer correspond to a connected peer. */
for (const id of Array.from(excluded)) {
if (!available.find(p => p.id === id)) excluded.delete(id);
}
el.innerHTML = '';
if (!available.length) {
const span = document.createElement('span');
span.className = 'empty';
span.textContent = 'No connected peers yet — add one from the toolbar.';
el.appendChild(span);
return;
}
const label = document.createElement('span');
label.className = 'label';
label.textContent = 'To:';
el.appendChild(label);
for (const peer of available) {
const chip = document.createElement('span');
const on = !excluded.has(peer.id);
chip.className = 'chip' + (on ? ' on' : '');
chip.textContent = displayNameOf(peer);
chip.title = on ? 'Click to exclude' : 'Click to include';
chip.addEventListener('click', () => {
if (excluded.has(peer.id)) excluded.delete(peer.id);
else excluded.add(peer.id);
renderRecipientBar(kind);
if (kind === 'chat') App.ui.updateChatGate?.();
if (kind === 'files') App.ui.updateFilesGate?.();
});
el.appendChild(chip);
}
}
/* Refresh every peer-aware widget. Called whenever peers join/leave or
their display names change. */
App.ui.refreshPeerWidgets = function () {
renderRecipientBar('chat');
renderRecipientBar('files');
refreshSettingsTargetDropdown();
refreshStatsPeerDropdown();
};
/* Settings target dropdown — keeps options for {defaults, all, ...peers}. */
function refreshSettingsTargetDropdown() {
const sel = document.getElementById('settings-target');
if (!sel) return;
const cur = sel.value || 'all';
const peers = peerList();
const ids = new Set(['all', ...peers.map(p => p.id)]);
/* Remove peer options that no longer exist. */
Array.from(sel.options).forEach(o => {
if (!ids.has(o.value)) sel.removeChild(o);
});
/* Ensure each peer has an option (preserve existing order). */
for (const peer of peers) {
if (!sel.querySelector('option[value="' + CSS.escape(peer.id) + '"]')) {
const o = document.createElement('option');
o.value = peer.id;
o.textContent = displayNameOf(peer);
sel.appendChild(o);
} else {
const o = sel.querySelector('option[value="' + CSS.escape(peer.id) + '"]');
o.textContent = displayNameOf(peer);
}
}
const fellBack = !(peers.find(p => p.id === cur) || cur === 'all');
sel.value = fellBack ? 'all' : cur;
/* Only reload the form if the effective target changed — otherwise we'd
overwrite values the user is in the middle of editing. */
if (fellBack && typeof App.ui.loadSettingsForTarget === 'function') {
App.ui.loadSettingsForTarget();
}
}
function refreshStatsPeerDropdown() {
const sel = document.getElementById('stats-peer');
if (!sel) return;
const cur = sel.value;
const peers = peerList();
sel.innerHTML = '';
if (!peers.length) {
const o = document.createElement('option');
o.value = ''; o.textContent = '(no peers)';
sel.appendChild(o);
App.stats?.refreshNow?.();
return;
}
for (const peer of peers) {
const o = document.createElement('option');
o.value = peer.id;
o.textContent = displayNameOf(peer);
sel.appendChild(o);
}
if (peers.find(p => p.id === cur)) sel.value = cur;
else sel.value = peers[0].id;
App.stats?.refreshNow?.();
}
/* Disable mic/cam/screen toolbar buttons when they can't possibly succeed —
@@ -3548,17 +4876,19 @@ async function applyMediaButtonAvailability() {
/* Rebuild the mic and camera popover lists from an enumerateDevices() snapshot.
Devices without labels (no permission yet) show as "Microphone 1", etc., so
the user can still see *how many* devices exist before granting permission. */
the user can still see *how many* devices exist before granting permission.
The "currently live" indicator is derived from App.state.micTrack/camTrack
(the local capture) rather than any per-peer sender. */
function renderDeviceMenus(devs) {
renderOneDeviceMenu('tb-mic-menu', 'mic', devs.filter(d => d.kind === 'audioinput'),
App.state.settings.audio.deviceId,
App.state.micTransceiver && App.state.micTransceiver.sender);
App.state.micTrack);
renderOneDeviceMenu('tb-cam-menu', 'cam', devs.filter(d => d.kind === 'videoinput'),
App.state.settings.video.deviceId,
App.state.camTransceiver && App.state.camTransceiver.sender);
App.state.camTrack);
}
function renderOneDeviceMenu(menuId, kind, devs, savedId, sender) {
function renderOneDeviceMenu(menuId, kind, devs, savedId, liveTrack) {
const menu = document.getElementById(menuId);
if (!menu) return;
/* Some webcams (notably HP combo cameras) expose the RGB and IR sensors as
@@ -3578,9 +4908,9 @@ function renderOneDeviceMenu(menuId, kind, devs, savedId, sender) {
that over getSettings().deviceId, which some webcams misreport. Only
fall back to getSettings() when no preference is saved. */
let activeId = '';
if (sender && sender.track) {
if (liveTrack) {
activeId = savedId
|| (sender.track.getSettings ? sender.track.getSettings().deviceId : '')
|| (liveTrack.getSettings ? liveTrack.getSettings().deviceId : '')
|| '';
}
const kindLabel = kind === 'mic' ? 'Microphone' : 'Camera';
@@ -3749,10 +5079,12 @@ function setupConsole() {
document.getElementById('console-clear').addEventListener('click', () => App.log.clear());
document.getElementById('console-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(App.log.snapshot(), null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.href = url;
a.download = 'webrtc-log-' + Date.now() + '.json';
a.click();
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
});
document.getElementById('console-stats-toggle').addEventListener('click', () => App.stats.toggleConsoleStats());
@@ -4187,13 +5519,44 @@ function wire() {
resolveInitialView();
/* Welcome */
document.querySelectorAll('#view-welcome .role-picker button').forEach(b =>
b.addEventListener('click', () => pickRole(b.dataset.role)));
const usernameInput = document.getElementById('welcome-username');
if (usernameInput) {
usernameInput.value = App.state.username === 'Anonymous' ? '' : App.state.username;
usernameInput.placeholder = 'Anonymous';
}
document.getElementById('welcome-start').addEventListener('click', () => {
const raw = (usernameInput?.value || '').trim().slice(0, 64);
App.state.username = raw || 'Anonymous';
if (raw) saveUsername(raw);
/* No peers yet — straight to the call view with the empty-state banner. */
App.state.bannerDismissed = false;
goToCall();
App.banner.refresh();
});
document.getElementById('welcome-sdp-inspect').addEventListener('click', () => {
/* Standalone tool — no role, no pc. Just swap the view. */
showView('view-sdp-inspect');
});
/* Add Participant dialog: each button picks a role and routes through the
existing configure → exchange flow, which knows how to attach the result
to a fresh PeerCtx. */
const addDlg = document.getElementById('add-peer-dialog');
function openAddDialog() {
if (!addDlg || typeof addDlg.showModal !== 'function' || addDlg.open) return;
try { addDlg.showModal(); } catch (_) {}
}
document.getElementById('tb-add-peer').addEventListener('click', openAddDialog);
document.getElementById('call-banner-add').addEventListener('click', openAddDialog);
document.getElementById('add-peer-cancel').addEventListener('click', () => {
if (addDlg && addDlg.open) addDlg.close();
});
addDlg.querySelectorAll('.role-picker button[data-role]').forEach(b => {
b.addEventListener('click', () => {
addDlg.close();
pickRoleForAdd(b.dataset.role);
});
});
/* SDP inspector */
const sdpIn = document.getElementById('sdp-in');
const sdpOut = document.getElementById('sdp-inspect-out');
@@ -4217,11 +5580,6 @@ function wire() {
/* Theme */
document.getElementById('theme-toggle').addEventListener('click', () => App.theme.toggle());
/* Peer-left dialog: OK closes; backdrop/Esc also close (native behavior). */
document.getElementById('peer-left-ok').addEventListener('click', () => {
document.getElementById('peer-left-dialog').close();
});
/* Step dialog: Cancel and Esc both invoke the registered cancel handler.
Listen on 'cancel' (fired by Esc) and 'close' as a belt-and-suspenders. */
document.getElementById('step-dialog-cancel').addEventListener('click', () => {
@@ -4236,9 +5594,9 @@ function wire() {
/* Best-effort hangup notification when the tab is closing or backgrounded
to bfcache. Use pagehide (more reliable than beforeunload, especially
on mobile) and only send if a chat channel is currently open. */
on mobile) and broadcast to every connected peer. */
window.addEventListener('pagehide', () => {
if (App.chat && App.chat.sendBye) App.chat.sendBye();
if (App.chat && App.chat.sendByeAll) App.chat.sendByeAll();
});
/* Configure */
@@ -4302,11 +5660,6 @@ function wire() {
App.log.info('ice', 'applied JSON config', v.length, 'servers');
} catch (e) { App.log.error('ice', 'bad JSON', e.message); alert('Bad JSON: ' + e.message); }
});
document.getElementById('cfg-back').addEventListener('click', () => {
/* Tear down a partial pc/abort an in-flight signaling fetch if the
user hits Back while auto-mode is waiting for the peer. */
hangup({ sendBye: false });
});
/* Signaling mode toggle */
document.getElementById('sig-mode-manual').addEventListener('click', () => {
applySignalingMode('manual'); saveSignaling();
@@ -4349,22 +5702,64 @@ function wire() {
e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 15);
});
document.getElementById('cfg-back').addEventListener('click', () => {
/* Cfg-back during Add: drop the pending peer (no live commit yet) and
return to the call view (or welcome, if no call started yet). */
cancelSetup();
App.state.nextAddRole = null;
updateRoleBadge();
closeConfigureDialog();
if (App.state.peers.size || currentView() === 'view-call') goToCall();
else showView('view-welcome');
});
/* Also handle native dialog close (Esc / backdrop). */
document.getElementById('view-configure').addEventListener('close', () => {
/* Only treat this as a cancellation when nothing is mid-handshake. */
if (!App.state.pendingPeer) {
App.state.nextAddRole = null;
updateRoleBadge();
}
});
/* Esc on the exchange dialog cancels the pending peer (a half-finished
handshake is unusable). */
document.getElementById('view-exchange').addEventListener('close', () => {
if (App.state.pendingPeer) {
cancelSetup();
App.state.nextAddRole = null;
updateRoleBadge();
}
});
document.getElementById('cfg-continue').addEventListener('click', async () => {
readConfigInputs();
saveIce();
const auto = App.state.settings.signaling.mode === 'auto' && App.state.role !== 'loopback';
const role = App.state.nextAddRole;
if (!role) { alert('No role chosen — open Add participant.'); return; }
const auto = App.state.settings.signaling.mode === 'auto' && role !== 'loopback';
try {
/* Close the dialog before the start* calls so the exchange view (or
progress modal) isn't rendered underneath the configure backdrop.
The dialog's `close` handler clears nextAddRole when no pendingPeer
exists, so when we re-open after a validation error we must restore
the role before showing the dialog again. */
closeConfigureDialog();
function reopenWithRole() {
App.state.nextAddRole = role;
updateRoleBadge();
openConfigureDialog();
}
if (auto) {
const code = (document.getElementById('sig-room-code').value || '').trim();
if (!code) { alert('Room code is required for auto mode.'); return; }
if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); return; }
if (App.state.role === 'initiator') await startInitiatorAuto(code);
else if (App.state.role === 'joiner') await startJoinerAuto(code);
if (!code) { alert('Room code is required for auto mode.'); reopenWithRole(); return; }
if (!App.state.settings.signaling.serverUrl) { alert('Server URL is required for auto mode.'); reopenWithRole(); return; }
if (role === 'initiator') await startInitiatorAuto(code);
else if (role === 'joiner') await startJoinerAuto(code);
} else {
if (App.state.role === 'initiator') await startInitiator();
else if (App.state.role === 'joiner') await startJoiner();
else if (App.state.role === 'loopback') await startLoopback();
if (role === 'initiator') await startInitiator();
else if (role === 'joiner') await startJoiner();
else if (role === 'loopback') await startLoopback();
}
App.state.nextAddRole = null;
updateRoleBadge();
} catch (e) {
App.progress.hide();
if (App.state.userCancelled) { App.state.userCancelled = false; return; }
@@ -4374,10 +5769,12 @@ function wire() {
});
document.getElementById('exch-cancel').addEventListener('click', () => {
hangup();
App.state.role = null;
cancelSetup();
App.state.nextAddRole = null;
updateRoleBadge();
showView('view-welcome');
closeExchangeDialog();
if (App.state.peers.size || currentView() === 'view-call') goToCall();
else showView('view-welcome');
});
/* Disable a button until its async handler resolves, so double-clicks
@@ -4412,24 +5809,22 @@ function wire() {
});
document.getElementById('tb-hangup').addEventListener('click', hangup);
/* Click a tile that's showing a screen share → toggle fullscreen. */
for (const id of ['tile-local', 'tile-remote']) {
document.getElementById(id).addEventListener('click', e => {
const tile = e.currentTarget;
if (!tile.classList.contains('screen')) return;
/* Don't trigger fullscreen for clicks on the PIP */
if (e.target.closest('.pip')) return;
const video = tile.querySelector(':scope > video');
if (!video) return;
if (document.fullscreenElement) {
(document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
.catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
} else {
(video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
.catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
}
});
}
/* Local tile screen-share fullscreen toggle. Remote tiles wire their own
click handler inside App.tiles.add(). */
document.getElementById('tile-local').addEventListener('click', e => {
const tile = e.currentTarget;
if (!tile.classList.contains('screen')) return;
if (e.target.closest('.pip')) return;
const video = tile.querySelector(':scope > .vid-main');
if (!video) return;
if (document.fullscreenElement) {
(document.exitFullscreen?.() || document.webkitExitFullscreen?.() || Promise.resolve())
.catch?.(err => App.log.warn('ui', 'exit fullscreen failed', err.message));
} else {
(video.requestFullscreen?.() || video.webkitRequestFullscreen?.() || Promise.resolve())
.catch?.(err => App.log.warn('ui', 'fullscreen failed', err.message));
}
});
/* Files: clear-all */
document.getElementById('files-out-clear').addEventListener('click', () => App.files.clearAll('out'));
@@ -4452,11 +5847,19 @@ function wire() {
const CHAT_MAX = App.chat.MAX_TEXT;
function updateChatCounter() {
const bytes = App.chat.utf8Length(chatInput.value);
chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B';
const totalOpen = peersWithOpenChat().length;
const selected = App.ui.selectedChatPeers ? App.ui.selectedChatPeers().length : totalOpen;
let suffix = '';
if (!totalOpen) suffix = ' · no connected peers';
else if (!selected) suffix = ' · no recipients selected';
else if (selected < totalOpen) suffix = ' · → ' + selected + '/' + totalOpen + ' peers';
chatCounter.textContent = bytes + ' / ' + CHAT_MAX + ' B' + suffix;
const over = bytes > CHAT_MAX;
chatCounter.classList.toggle('over', over);
chatSend.disabled = over || bytes === 0;
chatInput.disabled = !totalOpen;
chatSend.disabled = over || bytes === 0 || !selected;
}
App.ui.updateChatGate = updateChatCounter;
chatSend.addEventListener('click', sendChat);
chatInput.addEventListener('input', updateChatCounter);
chatInput.addEventListener('keydown', e => {
@@ -4467,7 +5870,9 @@ function wire() {
const t = chatInput.value.trim();
if (!t) return;
if (App.chat.utf8Length(t) > CHAT_MAX) return;
App.chat.send(t);
const peers = App.ui.selectedChatPeers();
if (!peers.length) return;
App.chat.send(t, { peers });
chatInput.value = '';
updateChatCounter();
}
@@ -4487,7 +5892,10 @@ function wire() {
drop.addEventListener('drop', e => {
e.preventDefault(); drop.classList.remove('over');
const f = e.dataTransfer.files[0];
if (f) App.files.sendFile(f);
if (!f) return;
const peers = App.ui.selectedFilesPeers();
if (!peers.length) return;
App.files.sendFile(f, { peers });
});
document.getElementById('files-pick').addEventListener('click', e => {
e.preventDefault();
@@ -4495,22 +5903,138 @@ function wire() {
});
document.getElementById('files-input').addEventListener('change', e => {
const f = e.target.files[0];
if (f) App.files.sendFile(f);
if (f) {
const peers = App.ui.selectedFilesPeers();
if (peers.length) App.files.sendFile(f, { peers });
}
e.target.value = '';
});
function updateFilesGate() {
const totalOpen = peersWithOpenFiles().length;
const selected = App.ui.selectedFilesPeers ? App.ui.selectedFilesPeers().length : totalOpen;
drop.classList.toggle('disabled', !selected);
}
App.ui.updateFilesGate = updateFilesGate;
updateFilesGate();
/* Target-aware writes for the per-peer encoder fields. `all` updates the
global settings and every connected peer (so new peers inherit too).
A specific peer id updates only that peer; the global "defaults" stay
unchanged in that case. */
function currentTarget() {
return document.getElementById('settings-target').value || 'all';
}
function targetPeers() {
const t = currentTarget();
if (t === 'all') return Array.from(App.state.peers.values());
const peer = App.state.peers.get(t);
return peer ? [peer] : [];
}
function writeVideoEncoder(maxKbps, degrade) {
const t = currentTarget();
if (t === 'all') {
App.state.settings.video.maxBitrateKbps = maxKbps;
App.state.settings.video.degradationPreference = degrade;
}
for (const p of targetPeers()) {
p.videoMaxBitrateKbps = maxKbps;
p.videoDegradationPreference = degrade;
}
if (t === 'all') App.media.applyCamSendParamsAll();
else {
const peer = App.state.peers.get(t);
if (peer) App.media.applyCamSendParamsFor(peer);
}
}
function writeScreenEncoder(maxKbps, degrade) {
const t = currentTarget();
if (t === 'all') {
App.state.settings.screen.maxBitrateKbps = maxKbps;
App.state.settings.screen.degradationPreference = degrade;
}
for (const p of targetPeers()) {
p.screenMaxBitrateKbps = maxKbps;
p.screenDegradationPreference = degrade;
}
if (t === 'all') App.media.applyScreenSendParamsAll();
else {
const peer = App.state.peers.get(t);
if (peer) App.media.applyScreenSendParamsFor(peer);
}
}
function writeSendCodec(codec) {
const t = currentTarget();
if (t === 'all') App.state.settings.sendVideoCodec = codec;
for (const p of targetPeers()) p.sendVideoCodec = codec;
if (t === 'all') {
App.media.applyCamSendParamsAll();
App.media.applyScreenSendParamsAll();
} else {
const peer = App.state.peers.get(t);
if (peer) {
App.media.applyCamSendParamsFor(peer);
App.media.applyScreenSendParamsFor(peer);
}
}
}
/* Populate the per-peer encoder inputs from the current target. The
resolution/fps and audio inputs are unaffected — they're always global. */
App.ui.loadSettingsForTarget = function () {
const t = currentTarget();
const $ = id => document.getElementById(id);
let v, s, codec;
if (t === 'all') {
v = { maxBitrateKbps: App.state.settings.video.maxBitrateKbps,
degradationPreference: App.state.settings.video.degradationPreference };
s = { maxBitrateKbps: App.state.settings.screen.maxBitrateKbps,
degradationPreference: App.state.settings.screen.degradationPreference };
codec = App.state.settings.sendVideoCodec || 'auto';
} else {
const peer = App.state.peers.get(t);
if (!peer) return;
v = { maxBitrateKbps: peer.videoMaxBitrateKbps,
degradationPreference: peer.videoDegradationPreference };
s = { maxBitrateKbps: peer.screenMaxBitrateKbps,
degradationPreference: peer.screenDegradationPreference };
codec = peer.sendVideoCodec || 'auto';
}
$('rt-v-maxbr').value = v.maxBitrateKbps || 0;
$('rt-v-degrade').value = v.degradationPreference || 'balanced';
$('rt-s-maxbr').value = s.maxBitrateKbps || 0;
$('rt-s-degrade').value = s.degradationPreference || 'maintain-resolution';
const rtCodec = $('rt-codec');
if (rtCodec) {
const want = codec.toLowerCase();
const match = Array.from(rtCodec.options).find(o => o.value.toLowerCase() === want);
rtCodec.value = match ? match.value : 'auto';
}
};
document.getElementById('settings-target').addEventListener('change', () => {
App.ui.loadSettingsForTarget();
});
/* Runtime settings */
withReentryGuard('rt-v-apply', async () => {
/* Username (always global). */
document.getElementById('rt-username-apply').addEventListener('click', () => {
const inp = document.getElementById('rt-username');
const name = (inp.value || '').trim().slice(0, 64) || 'Anonymous';
inp.value = name;
if (name === App.state.username) return;
App.state.username = name;
saveUsername(name);
/* Update local tile label + broadcast to all peers. */
const localLabel = document.getElementById('tile-local-label');
if (localLabel) localLabel.textContent = name + ' (you)';
App.chat.broadcastUsername?.();
});
/* Capture-side applies (always global). */
withReentryGuard('rt-v-cap-apply', async () => {
const v = App.state.settings.video;
const prevW = v.width, prevH = v.height, prevFps = v.frameRate;
v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
v.maxBitrateKbps = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
v.degradationPreference = document.getElementById('rt-v-degrade').value;
App.media.applyCamSendParams();
/* Resolution / framerate only take effect on a fresh getUserMedia call —
cycle the camera if it's currently on so the new constraints apply. */
v.width = parseInt(document.getElementById('rt-v-w').value, 10) || 0;
v.height = parseInt(document.getElementById('rt-v-h').value, 10) || 0;
v.frameRate = parseInt(document.getElementById('rt-v-fps').value, 10) || 0;
const camChanged = v.width !== prevW || v.height !== prevH || v.frameRate !== prevFps;
const camOn = document.getElementById('tb-cam').classList.contains('on');
if (camChanged && camOn) {
@@ -4519,15 +6043,12 @@ function wire() {
await App.media.setCam(true);
}
});
withReentryGuard('rt-s-apply', async () => {
withReentryGuard('rt-s-cap-apply', async () => {
const s = App.state.settings.screen;
const prevW = s.width, prevH = s.height, prevFps = s.frameRate;
s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
s.maxBitrateKbps = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
s.degradationPreference = document.getElementById('rt-s-degrade').value;
App.media.applyScreenSendParams();
s.width = parseInt(document.getElementById('rt-s-w').value, 10) || 0;
s.height = parseInt(document.getElementById('rt-s-h').value, 10) || 0;
s.frameRate = parseInt(document.getElementById('rt-s-fps').value, 10) || 0;
const dimsChanged = s.width !== prevW || s.height !== prevH || s.frameRate !== prevFps;
const screenOn = document.getElementById('tb-screen').classList.contains('on');
if (dimsChanged && screenOn) {
@@ -4537,11 +6058,20 @@ function wire() {
catch (e) { App.log.warn('media', 'restart screen failed', e.message); }
}
});
/* Encoder-side applies (per target). */
withReentryGuard('rt-v-enc-apply', async () => {
const maxBr = parseInt(document.getElementById('rt-v-maxbr').value, 10) || 0;
const degrade = document.getElementById('rt-v-degrade').value;
writeVideoEncoder(maxBr, degrade);
});
withReentryGuard('rt-s-enc-apply', async () => {
const maxBr = parseInt(document.getElementById('rt-s-maxbr').value, 10) || 0;
const degrade = document.getElementById('rt-s-degrade').value;
writeScreenEncoder(maxBr, degrade);
});
withReentryGuard('rt-codec-apply', async () => {
App.state.settings.sendVideoCodec = document.getElementById('rt-codec').value || 'auto';
/* Push the new codec to both video senders at once. */
App.media.applyCamSendParams();
App.media.applyScreenSendParams();
const codec = document.getElementById('rt-codec').value || 'auto';
writeSendCodec(codec);
});
withReentryGuard('rt-a-apply', async () => {
const a = App.state.settings.audio;
@@ -4561,7 +6091,8 @@ function wire() {
}
});
/* Stats export */
/* Stats: peer dropdown + export */
document.getElementById('stats-peer').addEventListener('change', () => App.stats.refreshNow());
document.getElementById('stats-export').addEventListener('click', () => App.stats.exportAll());
setupConsole();
@@ -4569,21 +6100,19 @@ function wire() {
App.log.info('app', 'ready');
}
/* Close the peer connection, stop local media, and reset call-tied UI
state. Shared by hangup() (user-initiated, navigates away) and
onPeerHangup() (remote-initiated, stays on call view). */
function teardownConnection(opts) {
/* Tear down ALL peers, stop local media, and reset call-tied UI state.
Called when the user leaves the call entirely. */
function teardownAll(opts) {
const sendBye = !opts || opts.sendBye !== false;
if (sendBye && App.chat && App.chat.sendBye) App.chat.sendBye();
/* If we're in the middle of auto-mode long-polling, cancel the fetch so
the user isn't stuck for up to 30 s after clicking Cancel/Hang up. */
if (App.state.signalAbort) { try { App.state.signalAbort.abort(); } catch (_) {} App.state.signalAbort = null; }
if (sendBye) App.chat.sendByeAll();
App.stats.stop();
try { if (App.state.dcChat) App.state.dcChat.close(); } catch (_) {}
try { if (App.state.dcFiles) App.state.dcFiles.close(); } catch (_) {}
try { if (App.state.pc) App.state.pc.close(); } catch (_) {}
try { if (App.state.pcB) App.state.pcB.close(); } catch (_) {}
if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
App.tiles.teardownObserver?.();
/* Snapshot peers first — removePeer mutates App.state.peers. removePeer
also closes each peer's loopback pcB if present. */
for (const peer of Array.from(App.state.peers.values())) {
removePeer(peer, { sendBye: false });
}
if (App.state.localStream) App.state.localStream.getTracks().forEach(t => t.stop());
if (App.state.screenStream) App.state.screenStream.getTracks().forEach(t => t.stop());
if (App.state.iceWarmupStream) {
App.state.iceWarmupStream.getTracks().forEach(t => t.stop());
@@ -4593,83 +6122,36 @@ function teardownConnection(opts) {
if (wb) wb.textContent = 'Enable LAN connectivity';
if (ws) { ws.textContent = 'off'; ws.className = 'pill'; }
}
App.state.pc = null; App.state.pcB = null;
App.state.dcChat = null; App.state.dcFiles = null;
App.state.localStream = null; App.state.screenStream = null;
App.state.remoteStream = null; App.state.remoteScreenStream = null;
App.state.peerMediaState = { mic: false, cam: false, screen: false };
App.state.micTransceiver = App.state.camTransceiver = App.state.screenTransceiver = null;
for (const id of ['vid-local-main', 'vid-local-pip', 'vid-remote-main', 'vid-remote-pip', 'audio-remote']) {
document.getElementById(id).srcObject = null;
}
for (const id of ['tile-local', 'tile-remote']) {
const tile = document.getElementById(id);
tile.classList.add('empty');
tile.classList.remove('screen');
}
document.querySelectorAll('.video-tile .pip').forEach(el => el.classList.add('hidden'));
App.state.localStream = null;
App.state.screenStream = null;
App.state.micTrack = null;
App.state.camTrack = null;
App.state.bannerDismissed = false;
const main = document.getElementById('vid-local-main');
const pip = document.getElementById('vid-local-pip');
if (main) main.srcObject = null;
if (pip) pip.srcObject = null;
const ltile = document.getElementById('tile-local');
if (ltile) { ltile.classList.add('empty'); ltile.classList.remove('screen'); }
document.querySelectorAll('#video-area .video-tile .pip').forEach(el => el.classList.add('hidden'));
/* Reset toolbar buttons back to the initial off state. */
for (const [id, label] of [['tb-mic', 'Mic off'], ['tb-cam', 'Cam off'], ['tb-screen', 'Screen off']]) {
const btn = document.getElementById(id);
if (!btn) continue;
btn.classList.remove('on');
btn.classList.add('off');
btn.querySelector('.nowrap').textContent = label;
}
updateConnPill();
}
/* Set the disabled state of the in-call controls that depend on an open
peer connection (toolbar media buttons, chat input, file picker). After
a peer hangup we leave the user on the call view so they can browse the
chat log and downloaded files, but everything that needs the channel is
disabled. */
function setInCallControlsEnabled(enabled) {
for (const id of ['tb-mic', 'tb-cam', 'tb-screen']) {
const btn = document.getElementById(id);
if (btn) btn.disabled = !enabled;
}
const chatInput = document.getElementById('chat-text');
const chatSend = document.getElementById('chat-send');
const filesDrop = document.getElementById('files-drop');
if (chatInput) chatInput.disabled = !enabled;
/* When re-enabling, leave chatSend to updateChatCounter (which gates on
byte count). When disabling, force it off. */
if (chatSend && !enabled) chatSend.disabled = true;
if (filesDrop) filesDrop.classList.toggle('disabled', !enabled);
}
function resetSession(opts) {
teardownConnection(opts);
App.state.role = null;
updateRoleBadge();
const dlg = document.getElementById('peer-left-dialog');
if (dlg && dlg.open) dlg.close();
App.banner.refresh();
}
function hangup(opts) {
App.log.info('app', 'hangup');
resetSession(opts);
App.log.info('app', 'hangup (leave call)');
teardownAll(opts);
showView('view-welcome');
}
/* Peer told us they're leaving via the chat data channel. Show a modal,
drop a chat-system breadcrumb, and tear down the connection — but keep
the user on the call view so they can still browse chat history and any
files that already finished transferring. */
function onPeerHangup() {
if (App.state.peerHungUp) return; /* idempotent — bye may arrive twice */
App.state.peerHungUp = true;
App.log.info('app', 'peer hung up');
if (App.chat && App.chat.appendSystem) App.chat.appendSystem('peer hung up');
teardownConnection({ sendBye: false });
setInCallControlsEnabled(false);
App.state.peerHungUp = false;
const dlg = document.getElementById('peer-left-dialog');
if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
try { dlg.showModal(); } catch (_) { /* already-open guard */ }
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
else wire();
</script>