// room.js: the browser half of a call. // // Spec sections this implements, and the order they appear below: // // §4.1 transport one SSE stream down, POST /signal up // §4.2 peer identity the server issues `peer`; one peer per session // per room, and nothing here ever displaces // another tab automatically // §4.3 messages the event table, including the roster as the // authority and the admission queue // §4.5 resume the probe, the replay, the four refusals and // the terminal reasons // §4.6 delivery per-target outbound queue with `seq`, pair // generations, inbound dedupe, candidate buffer // §5.1 negotiation the W3C perfect-negotiation pattern // §5.2 ICE servers GET /m/{sqid}/ice before the first connection // §5.3 renewal per connection, at the server's `renewAt`, and // complete only when a new ICE username fragment // is on the selected candidate pair // §5.4 screen share getDisplayMedia as one sendonly transceiver // per connection, a `share` message so peers can // label the tile, and a stop that ends the // receiver's track rather than muting it // §5.5 the floor an empty list is the floor: host candidates // only, and the failed tile says whether a relay // was there to fail // §6 chat the broadcast lane and the panel: chat.js owns // the words and the log, this file owns the wire // and when the panel is usable // §15.19 media state who is muted and whose camera is off: our own // reported on the broadcast lane, everyone else's // read from the roster and `media` // §15.26 reports the anonymous quality report every thirty // seconds and at leave, and "Report a problem" // (report.js's sheet) with the diagnostics // // House rules this file is written under: no library, no inline script // (the page's CSP is `default-src 'self'`), nothing built with // innerHTML, and every string a peer or the server sends is written // with textContent. `data-is-member` on #room is a rendering hint only // — the server re-derives authority on every request, and the roster // it sends is what this file actually believes. (() => { 'use strict'; const root = document.getElementById('room'); if (!root) return; // Revealing the room is the first job: the server renders it hidden // so a scriptless browser sees the fallback paragraph instead. root.hidden = false; const sqid = root.dataset.sqid || ''; const selfName = root.dataset.selfName || ''; const memberHint = root.dataset.isMember === '1'; // §5.5: whether this instance has a relay at all. It changes one // sentence — the one a failed tile shows — and nothing else. Like // data-is-member it is a rendering hint: the ICE list the server // actually mints is what decides whether a call has a relay to use. const relayConfigured = root.dataset.relay === '1'; const $ = (id) => document.getElementById(id); const tilesEl = $('tiles'); const queueEl = $('queue'); const knocksEl = $('knocks'); const statusEl = $('status'); const muteButton = $('mute'); const cameraButton = $('camera'); const leaveButton = $('leave'); const shareButton = $('share'); const muteState = $('mute-state'); const cameraState = $('camera-state'); const shareState = $('share-state'); // The sharing notice and its own Stop sharing button (2026-09-28). const shareNotice = $('share-notice'); const shareStop = $('share-stop'); const shareRecoveryEl = $('share-recovery'); const copyDiagnostics = $('copy-diagnostics'); // §15.26: "Report a problem", in the Devices panel beside Copy call // diagnostics and in the More menu, and the person's own switch for // the automatic reports. const reportProblem = $('report-problem'); const reportProblemMenu = $('report-problem-menu'); const moreToggle = $('call-more-toggle'); const morePanel = $('call-more'); const qualityRow = $('quality-row'); const qualityInput = qualityRow ? qualityRow.querySelector('input[type="checkbox"]') : null; // §15.19: the tile marks, rendered by the server so they are the // vendored badge and the app's own icons rather than a copy of either, // and the polite region that says a change out loud. #status is no // use for that: renderStatus rewrites it on every render. const mutedMark = $('tile-muted'); const cameraOffMark = $('tile-camera-off'); const mediaLive = $('media-live'); // The chat panel's root (§6). chat.js owns everything inside it; this // file owns the wire under it and the moment it becomes usable. const chatEl = $('chat'); // The device panel and its controls. Every one of these is optional // as far as this file is concerned — the room renders without them in // a browser that cannot enumerate devices, and switchDevice and // fillDevices both check before they reach for one. const devicesToggle = $('devices-toggle'); const devicesPanel = $('room-devices'); const deviceNote = $('device-note'); const pickCamera = $('pick-camera'); const pickMic = $('pick-mic'); const pickSpeaker = $('pick-speaker'); const cameraRow = $('camera-row'); const micRow = $('mic-row'); const speakerRow = $('speaker-row'); const rawRow = $('raw-row'); const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null; const shareModeRow = $('share-mode-row'); const pickShareMode = $('pick-share-mode'); const DEVICE_KEY = 'meet.devices'; const BACKOFF_MIN = 500; const BACKOFF_MAX = 30000; // The stream's own ceiling is deliberately well under the hub's // thirty-second resume window: a client that waited the full thirty // would lose the participation to its own patience, and come back to // a fresh peer id and a renegotiated mesh for no reason. const STREAM_BACKOFF_MAX = 10000; // §4.6: a target whose POSTs have failed for this long is treated as // unreachable — the pair is reset and the connection rebuilt. const SEND_DEADLINE = 30000; const MAX_BUFFERED_CANDIDATES = 256; // §4.3: `full` is not a refusal, it is "not yet" — a member waits and // retries the join on this interval. const FULL_RETRY = 5000; // A stream refusal the probe cannot name — a full room behind a 204, // a 500, a `last` the server would not parse — would otherwise retry // for ever behind "Connecting to the call." After this many rounds // that produced no `hello`, and this long since the last one, the // client stops and says so. Both conditions, because either alone // gives up on something worth waiting for: a count alone would quit on // a handful of fast failures at the start of the backoff ladder, and a // clock alone would quit on one slow round that was still going to // succeed. const MAX_STALLS = 8; const STALL_WINDOW = 60000; // A stream that lived at least this long was a working stream, not a // refusal: only that resets the backoff. A stream dying moments after // its `hello` would otherwise reconnect in a tight loop. const HEALTHY_STREAM = 5000; // §5.3: a fetch of /ice that fails is retried every minute, and the // warning stands from the first failure until one succeeds. The gate // shortens the *schedule* through X-Calls-Test-Ice-TTL's effect on // `renewAt`, never this: there is no client knob for it. const ICE_RETRY = 60000; // §5.3 step 2: each ICE restart gets thirty seconds to prove itself, // and a connection gets three of them. const RENEW_ATTEMPTS = 3; const RENEW_WINDOW = 30000; const RENEW_POLL = 500; // The floor under a renewal delay. Nothing the server can send should // schedule a renewal sooner than this: a list with no margin left in // it would otherwise fetch, schedule for now, and fetch again. const MIN_RENEW_DELAY = 1000; // And the floor under the margin itself. A list whose renew time and // expiry are less than a second apart cannot be renewed on any // sensible schedule: the delay floor above would turn it into a fetch // roughly once a second for as long as the tab stayed open. The // server clamps what it will mint, so this can only arrive through // the development-only TTL header — but a client that answers a // nonsense schedule with a 1 Hz loop is a client that can be made to // hammer its own server, so it declines instead, and says so in // state().ice rather than looking idle for no reason (W3-12). const MIN_MARGIN = 1000; const PEER_ID = /^[0-9a-f]{32}$/; // §4.3's two untargeted messages ride one queue of their own, and this // is its key: not a peer id, and it cannot collide with one, because // every peer id is thirty-two hex characters. const BROADCAST = '*'; // A 404 on the broadcast lane is diagnosed inside the message's own // deadline — but a POST that spent all thirty seconds getting that 404 // leaves nothing to diagnose with, and a recovery with no budget // decides nothing at all. This is the floor it gets instead: enough // for one probe and one retry, and short enough that a tab which has // really lost its participation is not left looking live. const MIN_RECOVERY = 5000; // §5.1: how long the polite peer waits before offering. Perfect // negotiation's answer to glare is a rollback on the polite side, and // in Chromium rolling back an offer that added an m-line rebuilds the // whole connection's send streams: the camera's comes back on the same // SSRC having encoded nothing, on an m-line that goes on reading // sendrecv with a live track, and it never encodes again. Two peers // sharing a screen in the same moment is how a call finds that, and // the gate's mutualshare scenario is where it was measured. // // So the peer that would roll back yields instead. A remote offer that // was already on its way arrives while this side is still stable and // is answered rather than collided with, and no rollback happens at // all. The cost is this much delay before the polite side offers, and // only when the other side has nothing to offer. The wait itself is // unconditional — the check that reads the connection happens after // it, not during — so what a remote offer arriving buys is not a // shorter wait but a negotiation this side no longer has to make. const POLITE_YIELD = 300; // How many times in a row the polite side may give way without an // offer of its own going out. A boolean could not answer both "give // the allowance back when the exchange that took our turn completes" // and "never let giving way become a habit"; a small counter answers // both (Codex S4-4, and the Task 4 re-review's item 1). const POLITE_YIELDS = 2; // Polls in a row that must report no new encoded frames before a // camera counts as stopped, and repairs on one connection before the // pair is reset instead. At one poll a second that is two seconds of // a live track on a sending m-line encoding nothing, then a // replaceTrack cycle, then two more seconds, then a reset (Codex // S4-5, and the Task 4 re-review's item 2). const CAMERA_STALL_POLLS = 2; const CAMERA_REPAIRS = 1; // And the same shape for the last link in the chain: how many polls a // tile may hold a live, unmuted track that its connection is decoding // frames for and paint none of them, before the element is handed its // media again — and how many of those before the pair is rebuilt // instead (Codex round 2, the branch after S4-9). const RENDER_STALL_POLLS = 3; const RENDER_REPLAYS = 2; // How far an element's own clock must advance in a poll to count as // playing rather than twitching. A second of video is a second of // clock; a single frame is thirty milliseconds, and a tile painting // one frame every three seconds is a stall with a tic, not a picture. // Reading any advance at all as health let such a tile reset the // budget below for ever and never earn its reset. const RENDER_MOVED = 0.25; // The ceiling studio audio asks for, in bits per second. Opus is // transparent for speech well below it; the number is picked so a // microphone someone turned the processing off for is not then // squeezed down a pipe built for speech. It is a ceiling and not a // floor — the encoder still spends only what the link will carry. const RAW_AUDIO_BITRATE = 128000; // §15.20: the hearing watch. How often the levels are read — five // times a second, which is as fast as a bar is worth moving and slow // enough that one small getStats per connection costs nothing a // person could notice. const LEVEL_EVERY = 200; // An audioLevel below this is silence: -80 dBFS, well under any room's // own noise floor, so a microphone reading it is not hearing the room // at all. Anything at or above it counts as sound. const FLAT_LEVEL = 0.0001; // How long a stream that is arriving must stay that flat before it is // called silent — long enough to cover somebody simply not talking in // a quiet room, short enough to catch "can you hear me?" early. const NO_SOUND_AFTER = 5000; // Packets count as arriving if one came this recently. Wider than a // poll, because a sender using DTX sends a packet every 400 ms or so // through silence, and silence with packets is exactly the case the // watch is for. const ARRIVING_WINDOW = 1500; // The speaking highlight, with hysteresis: on at SPEAK_ON, and off only // after SPEAK_HOLD polls below SPEAK_OFF, so a tile does not flicker // between syllables. const SPEAK_ON = 0.04; const SPEAK_OFF = 0.015; const SPEAK_HOLD = 4; // The speaking light on a tile's name, which replaced the ring round // the tile and the meter beside the name (0.3.7), and since 0.4.3 is // the warm rim, as the native apps have it (SpeakingGlow.swift, // WarmRim.swift): a level by decibels between GLOW_QUIET_DB and // GLOW_LOUD_DB; lit only once sound at SPEAK_ON has kept up for // GLOW_ONSET ms, so a click never lights it; the level rising over // GLOW_ATTACK ms and falling over GLOW_RELEASE ms, which is a CSS // transition on --lvl, with --on going out once it has fallen; and one // steady GLOW_STEADY for anyone who has asked for reduced motion. const GLOW_QUIET_DB = -42; const GLOW_LOUD_DB = -12; const GLOW_ONSET = 150; const GLOW_ATTACK = 50; const GLOW_RELEASE = 300; const GLOW_STEADY = 0.6; // Speaker view's stage (0.4.0), as the native apps have it // (ActiveSpeaker in SpeakerView.swift): someone takes the stage only // after SPEAKER_DOMINANCE ms as the loudest speaker, and whoever is on it // keeps it at least SPEAKER_HOLD ms, so an interjection, a laugh or two // people talking over each other never make it flap. Never yourself. const SPEAKER_DOMINANCE = 1300; const SPEAKER_HOLD = 3000; // §4.3's limit on the list the hub will take. const MAX_NO_SOUND = 15; // §5.4: the tile key a peer's screen gets, built by shareKey. const SHARE_SUFFIX = '#share'; // §15.21, the screen watchdogs (2026-09-28). A screen that has decoded // nothing new for SCREEN_STALL_AFTER while its sender says it is // sharing is stalled here, and reported as `screenStalled`; every // sender repeats a still screen at least once a second, so this long // without a frame is not a still screen. Held for at most // SCREEN_STALL_GIVE_UP, so a screen nobody can recover is not a report // that never ends. A sharer named in someone's report asks its encoder // for a keyframe, at most once every SCREEN_KEYFRAME_EVERY, and rests // SCREEN_KEYFRAME_REST after SCREEN_KEYFRAME_TRIES of them. const SCREEN_STALL_AFTER = 4000; const SCREEN_STALL_GIVE_UP = 180000; const SCREEN_KEYFRAME_EVERY = 4000; const SCREEN_KEYFRAME_TRIES = 3; const SCREEN_KEYFRAME_REST = 30000; // §15.22 (2026-09-30), when keyframes do not answer. The receiver // hands a stalled screen's picture to its element again once, // SCREEN_REATTACH_AFTER into the stall. The sharer, still named // SCREEN_REBUILD_AFTER after its first keyframe, rebuilds that // connection's screen (a new transceiver: a new encoder here, a new // receiver and decoder there); still named SCREEN_RESHARE_AFTER after // that, shares again for everyone from the same capture; and // SCREEN_GIVE_UP_AFTER after that, says it could not. A rebuild at most // once every SCREEN_REBUILD_EVERY per person, a share again at most // once every SCREEN_RESHARE_EVERY, and neither unless at least // SCREEN_ESCALATE_SENT frames left for that person since the last step: // a receiver cannot be failing to decode frames that were never sent. const SCREEN_REATTACH_AFTER = 4000; const SCREEN_REBUILD_AFTER = 6000; const SCREEN_RESHARE_AFTER = 10000; const SCREEN_GIVE_UP_AFTER = 10000; const SCREEN_REBUILD_EVERY = 60000; const SCREEN_RESHARE_EVERY = 300000; const SCREEN_ESCALATE_SENT = 3; // The screen's timeline, for "Copy call diagnostics": its last lines. const SCREEN_LOG_LINES = 200; // §15.23 (2026-09-30), "Reconnecting…" only for a problem that lasts: // the stream down RECONNECT_STREAM_AFTER (the whole call); a connection // disconnected or failed RECONNECT_CONNECTION_AFTER, or no audio packets // from someone unmuted RECONNECT_AUDIO_AFTER (that person's tile, or the // whole call when it is everyone). Said for RECONNECT_HOLD past the // recovery, so it does not flicker. A stream renewal — ended on purpose // and resumed in half a second — is never said at all. const RECONNECT_STREAM_AFTER = 4000; const RECONNECT_CONNECTION_AFTER = 3000; const RECONNECT_AUDIO_AFTER = 2000; const RECONNECT_HOLD = 1000; const RECONNECT_EVERY = 200; // §15.24 (2026-10-01), " is connecting and can't hear you yet": // said on a person's tile from the moment they appear until their own // report says they hear us — but only once CONNECTING_GRACE has passed // without that, so an ordinary join says nothing, and never past // CONNECTING_CAP, after which §15.20's and §15.23's words take over. const CONNECTING_GRACE = 1000; const CONNECTING_CAP = 20000; // §15.26 (2026-10-03), the automatic quality report: one every // QUALITY_EVERY while admitted, and one more at leave. The gate // shortens the cadence through window.meet.qualityEveryForTest, never // below QUALITY_EVERY_MIN. QUALITY_KEY is the person's own switch // ("Send anonymous quality reports"), stored as "off" when off. // The bounds are protocol/quality.go's, so a report is clamped here to // what the server would accept rather than refused there. const QUALITY_EVERY = 30000; const QUALITY_EVERY_MIN = 1000; const QUALITY_KEY = 'calls.qualityReports'; const QUALITY_MAX_PEERS = 4; const QUALITY_MAX_SECONDS = 600; const QUALITY_MAX_MILLIS = 600000; const QUALITY_MAX_COUNT = 1e9; const QUALITY_MAX_KBPS = 1e6; const QUALITY_MAX_PIXELS = 16384; const QUALITY_MAX_FPS = 240; const QUALITY_MAX_BODY = 16384; // The longest stretch between two looks of the event watch that is // counted as time a notice was showing: a suspended tab's timers stop, // and the gap when they start again is not a notice someone saw. const QUALITY_LOOK_MAX = 1000; // §5.4, sending: "Optimise shared screen for". What a screen gives up // first when the uplink or the encoder cannot carry all of it. The // first is the default; sharePlan says what each one means. const SHARE_MODES = [ ['balanced', 'Balanced'], ['detail', 'Text and detail'], ['motion', 'Motion and video'], ]; const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' && !!navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function'; const HAS_SINK_ID = typeof HTMLMediaElement !== 'undefined' && typeof HTMLMediaElement.prototype.setSinkId === 'function'; // --------------------------------------------------------------- // State (§4.2, §4.6) // --------------------------------------------------------------- const state = { peer: null, // this stream's server-issued peer id boot: root.dataset.boot || '', admitted: false, // a roster has arrived, so we are in the mesh joined: false, // `join` has been accepted at least once full: false, // the last `full` event, cleared by a roster stopped: false, // terminal, or waiting on a takeover: no reconnect terminal: null, // the reason this call ended, once it has leaveSent: false, // §4.3: `leave` is not idempotent kind: memberHint ? 'member' : 'guest', }; const pcs = new Map(); // peer id -> connection record const gen = new Map(); // peer id -> pair generation (§4.6) const seqOut = new Map(); // peer id -> last seq we sent (never reset) const lastIn = new Map(); // peer id -> highest seq received (resume cursor) const queue = new Map(); // peer id -> [{seq, body}] outbound, in order const inflight = new Set();// peer ids with a POST in flight // Targets the hub has answered 404 for while our own participation is // alive: they have left, and our roster has not caught up yet. Nothing // is sent to them and no reset is asked for until a roster settles it — // otherwise every message to a departed peer starts its own 404 → // probe → reset → 404 cycle, each with a fresh budget (Codex S2-11). const stale = new Set(); const resetting = new Set(); // targets with a reset already in flight const names = new Map(); // peer id -> display name, from the server only const frames = new Map(); // peer id -> frames rendered, for the test hook const tiles = new Map(); // tile key -> {figure, video} // §15.19: peer id -> {muted, cameraOff}, for the peers that have // reported. A peer absent here is unknown and is drawn as neither. const remoteMedia = new Map(); // The state this tab last put on the broadcast lane, as mediaKey // writes it, so a toggle that changes nothing sends nothing. let mediaSent = ''; // §15.20: whether the report may carry the two optional keys. A server // from before them answers 400 to anything but the two booleans, and // the first such answer turns them off for the life of the page. let mediaExtras = true; // §15.21: share key -> {decoded, movedAt}, the receiver's watch; the // peers last reported stalled; peer -> {at, tries, restUntil}, the // sharer's keyframe answers; and the sharer's quiet notice. const screenSeen = new Map(); let screenStalled = []; const screenAsks = new Map(); let shareRecovery = ''; let screenStallTestUntil = 0; // §15.22: each arriving screen's statistics at the last poll and when // it last moved (share key -> screenReading); pictures handed back; // when each peer's screen was last rebuilt, and everyone's last shared // again; and the timeline itself. const screenReadings = new Map(); const screenMoving = new Map(); let screenReattaches = 0; const shareRebuiltAt = new Map(); let shareResharedAt = 0; const shareCounts = { keyframes: 0, rebuilds: 0, reshares: 0, failures: 0 }; const screenLines = []; // §15.26: the same ladder and mending, per peer, taken by each quality // report (peer id -> counts). shareCounts above is the whole call's, // for the diagnostics. const shareCountsBy = new Map(); const reattachesBy = new Map(); // §15.23: when the stream went down (0 while it is up), and the watch. let streamDownSince = 0; const reconnect = reconnectRecord(); // §15.24: who can hear us yet (connectingStep's record), and the // `hearing` list as last reported, joined, so a change is seen. const connecting = connectingRecord(); let hearingKey = ''; // §15.20: peer id -> what this tab is hearing from that peer, as // pollLevels reads it (hearingRecord). selfHearing is the same record // for our own microphone, read off the sender. const hearing = new Map(); const selfHearing = hearingRecord(); // Who has the floor, for Speaker view's stage (speakerStep). const stageSpeaker = speakerRecord(); // Tile keys whose element the browser refused to play. Non-empty is // `audioBlocked`: the page banner shows, and the report says so. const refusedTiles = new Set(); let audioBlocked = false; // The autoplay policy as the browser states it in advance, where it // will (Firefox). A hint that puts the banner up early; the refusal // is still the authority, and a play that succeeds takes it down. let autoplayDoubted = autoplayPolicyDoubtful(); let soundBanner = null; // {box, text, button} once built let levelTimer = 0; let levelsBusy = false; // The noSoundFrom list as last computed, joined, so a change is seen. let noSoundKey = ''; // §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt` // and `lastFetchAt` are all milliseconds since the epoch: the server // sends unix seconds and fetchIce is the one place they are converted, // so everything below — and everything the test hook exposes — // compares them with Date.now() and with nothing else. const ice = { servers: [], // the RTCIceServer list, [] on the floor (§5.5) expiresAt: 0, // the credential's hard deadline on the relay renewAt: 0, // when the server asks us to come back margin: 0, // expiresAt - renewAt: a duration both clocks agree on fetchedAt: 0, // when the list we hold arrived failedSince: 0, // the first failure of the current run, or 0 lastTryAt: 0, // the most recent failed attempt, for the catch-up fetches: 0, // *successful* fetches, for the gate lastFetchAt: 0, }; // peer id -> §5.3's record for one connection's renewal. const renewals = new Map(); let waiting = []; // the admission queue, members only (§4.3) let lastRoster = null; // The newest roster this browser has been sent and has not applied // yet. Only §5.2's fetch can leave anything in it for longer than a // microtask. let pendingRoster = null; let pendingRosterAt = 0; // the tick that roster arrived on let tick = 0; let local = null; // the local MediaStream, or null // §5.4: the display capture while this tab is sharing, and nothing // when it is not. One capture, whatever the mesh's size — every // connection carries the same track on a transceiver of its own. let share = null; // {stream, track} while sharing // True from the moment getDisplayMedia is asked until it answers. The // picker takes as long as a person takes, and without this a second // click during it takes a second capture that nothing ever stops. let sharePending = false; // §6: the mounted panel, once there is one. `chatSent` counts lines // the hub accepted, not lines typed, and `chatReceived` counts lines // put in the log — the two facts a gate cannot read off the DOM // without knowing the panel's own markup. let chat = null; let chatSent = 0; let chatReceived = 0; let prefs = loadPrefs(); let es = null; // the live EventSource let backoff = BACKOFF_MIN; let stalls = 0; // consecutive stream rounds that produced no hello // When this browser last reached the room. It starts at load rather // than at zero: a page that has never once got a `hello` has been // trying since it opened, and that is the stretch STALL_WINDOW means. let lastHelloAt = Date.now(); let reconnectTimer = 0; let reconnectWatchTimer = 0; let statsTimer = 0; let backdropTimer = 0; let fullTimer = 0; let renewTimer = 0; // §5.3: the scheduled renewal, at `renewAt` let iceRetryTimer = 0; // §5.3 step 1: the one-minute retry let expiryTimer = 0; // §5.3 step 3: the must-succeed-by verdict let icePrimed = false; // a first fetch has been made (success or not) let firstIce = null; // that fetch, shared by concurrent callers let renewing = false; // a renewal fetch is in flight let renewDue = false; // the current run is a renewal, not a first fetch let iceWarningEl = null; // §15.26: the report token the latest `hello` carried — the authority // for both report endpoints, kept after leaving because it goes on // working for ten minutes after — and the quality report's own state: // when the current window began (0 until admitted), each connection's // reading at the last report (peer id -> {pc, reading}), the event // watch, the output device's kind as last enumerated, and what has // been sent. A 204 is the server's kill switch, and stops the reports // for the rest of this page. let reportToken = ''; let qualityEvery = QUALITY_EVERY; let qualityFrom = 0; const qualityPrev = new Map(); const qualityEvents = qualityWatchRecord(); let qualityOutput = 'unknown'; let qualityServerOff = false; let qualityLocalOff = false; // the switch, where there is no storage to keep it in let qualityFinalSent = false; const quality = { sent: 0, accepted: 0, status: 0 }; // How many rosters this tab has applied. Nothing in the page reads // it: it is the one fact a gate cannot infer from the DOM or from // `peers`, because a connection can be closed locally without any // roster having arrived, and "recovered" has to mean the server said // so (Codex S2-16). let rosters = 0; // --------------------------------------------------------------- // Preferences, stored by the holding screen (devices.js) // --------------------------------------------------------------- function loadPrefs() { try { return JSON.parse(localStorage.getItem(DEVICE_KEY) || '{}') || {}; } catch { return {}; } } function savePrefs() { try { localStorage.setItem(DEVICE_KEY, JSON.stringify(prefs)); } catch { // Private browsing or a full quota: the toggle still works for // this call, it just will not be remembered for the next one. } } // --------------------------------------------------------------- // Local media (§3.3's stored choices, §5.1's tracks) // --------------------------------------------------------------- // audioConstraints builds the microphone half of a getUserMedia // request out of the stored preferences. It is a pure function of // `prefs` so the gate can lift it out of both files and check the two // copies still agree (test/browser/audio.test.mjs). room.js carries // it verbatim, because the room asks for a stream of its own at join // — the holding screen's is stopped by then — and a choice that held // on one page and not the other would be worse than no choice. // // The three flags are the browser's voice pipeline: echo // cancellation, noise suppression and automatic gain. Every browser // turns all three on for a microphone unless told otherwise, which is // right for a laptop on a kitchen table and wrong for an interface in // a room someone has treated — gain riding a held note, noise // suppression taking a cello for a fan. `rawAudio` is the person // saying which of those two rooms they are in. // // They are asked for as ideals and never as `exact`. A browser that // cannot turn its processing off should hand over a processed // microphone, not refuse getUserMedia and cost the person their // voice — the same rule the device ladder is built on. It is also why // the holding screen checks afterwards what it was actually given // (rawHonoured) instead of trusting that it got what it asked for. // // voiceIsolation is the newer, heavier filter in Safari and Chrome — // on a Mac it is Apple's own system Voice Isolation, reached through // the browser. It is only ever asked for *off*, and never on. Asked // on, it runs on top of the noise suppression above rather than // instead of it, and two suppressors chewing one voice is not twice // as clean: it is a growl with the keystrokes still coming through, // which is what a call on 2026-09-17 came back as. The pipeline above // is every browser's own default and is the one this app asks for. // // Off it still belongs here, because someone may have turned Voice // Isolation on for the whole machine and studio audio has to be able // to say no to it. An unknown constraint name in this basic, // non-exact form is dropped rather than refused, so naming it costs // nothing in a browser that has never heard of it. function audioConstraints(prefs) { const processed = !prefs.rawAudio; const constraints = { echoCancellation: processed, noiseSuppression: processed, autoGainControl: processed, }; if (!processed) constraints.voiceIsolation = false; if (prefs.mic) constraints.deviceId = { exact: prefs.mic }; return constraints; } // tuneAudioSender raises the audio ceiling on one connection, and is // the other half of what studio audio promises: turning the filters // off buys nothing if the result then goes out through Opus at its // default of roughly 32 kbit/s mono, which is a codec a good // microphone is heard *through* rather than heard. // // It is a sender-side change, so it needs no SDP of our own and no // renegotiation, and §5.1's perfect negotiation above never learns it // happened. Stereo is deliberately not attempted: it lives in the // fmtp line, and reaching it would mean building offers by hand // instead of the argumentless setLocalDescription §5.1 is written on // — a large risk to the one part of this file that must not wobble, // bought for a microphone that is almost always mono anyway. // // Every step is optional in some browser that is otherwise fine, so // the whole thing is best effort: a browser that will not take the // parameters carries the call at its own bitrate rather than losing // the connection over it. The encodings array is read right after // addTrack, which is where Chromium has already built it; a browser // that fills it in later simply keeps its default here, and that is a // quieter microphone and not a broken one. function tuneAudioSender(pc) { let sender; try { sender = pc.getSenders().find((s) => s.track && s.track.kind === 'audio'); } catch { return; } if (!sender || typeof sender.getParameters !== 'function') return; let params; try { params = sender.getParameters(); } catch { return; } // A sender the browser has not built encodings for yet is not one to // invent an encodings array for: that is a different request, and // not the one this function is making. if (!params.encodings || !params.encodings.length) return; // Both directions. Studio audio turned off mid-call has to give the // ceiling back, and `undefined` — not zero, which is a request to // send nothing — is how the API spells "the encoder's own default". if (prefs.rawAudio) params.encodings[0].maxBitrate = RAW_AUDIO_BITRATE; else delete params.encodings[0].maxBitrate; Promise.resolve().then(() => sender.setParameters(params)).catch(() => { // Chromium refuses parameters that did not come from the most // recent getParameters, which a renegotiation in flight can cause. // The call is unharmed, and the next connection gets its own try. }); } // sharePlan is what a shared screen is sent as, for one "Optimise // for" mode and the capture's size. Pure, and lifted by name in the // node test; the native app's ScreenSendPolicy is its twin, and the two // are meant to agree number for number. // // Why it exists (2026-09-24, "the screenshare isn't keeping up"): a // screen capture left to the browser's defaults keeps every pixel and // drops frames, at the encoder's default ceiling of about 2.5 Mbit/s // for 1080p. On anything but a fast uplink that is a few frames a // second of someone scrolling. // // detail full resolution, frames give way — slides, code, text // balanced frame rate held, full size while the link carries it // and smaller rather than a slideshow when it cannot // motion frame rate held, at most 1280 on the long side // // Measured in the native gate (ScreenSendPolicy has the numbers): on a // 1.5 Mbit/s uplink a scrolling 1080p page arrived at 2 fps as before, // 13 in detail, 19 in balanced and 30 in motion. // // contentHint is the browser's own name for the same choice, and // degradationPreference the sender's; both are set because browsers // honour one or the other. function sharePlan(mode, width, height) { const longest = Math.max(width || 0, height || 0); const fit = (side) => (longest > side ? longest / side : 1); const high = { priority: 'high', networkPriority: 'high', maxFramerate: 30 }; if (mode === 'detail') { return { contentHint: 'detail', degradationPreference: 'maintain-resolution', encoding: Object.assign({ maxBitrate: 3500000, scaleResolutionDownBy: fit(1920) }, high), }; } if (mode === 'motion') { return { contentHint: 'motion', degradationPreference: 'maintain-framerate', encoding: Object.assign({ maxBitrate: 2500000, scaleResolutionDownBy: fit(1280) }, high), }; } return { contentHint: 'detail', degradationPreference: 'maintain-framerate', encoding: Object.assign({ maxBitrate: 3000000, scaleResolutionDownBy: fit(1920) }, high), }; } // cameraCap is the camera's encoding while this tab shares a screen, // and its defaults again when it stops: the screen is what everyone is // looking at, and it should have the uplink. `undefined` rather than a // number is how the API spells "the encoder's own choice". Pure, and // lifted by name in the node test. function cameraCap(sharing, height) { if (!sharing) return { maxBitrate: undefined, scaleResolutionDownBy: 1 }; return { maxBitrate: 500000, scaleResolutionDownBy: height > 360 ? height / 360 : 1 }; } function shareMode() { return SHARE_MODES.some(([m]) => m === prefs.shareMode) ? prefs.shareMode : SHARE_MODES[0][0]; } // tuneSender sets one sender's first encoding (and, where given, its // degradation preference). Best effort for the same reasons as // tuneAudioSender: a sender parameter needs no SDP and no // renegotiation, so §5.1 never learns it happened, and a browser that // refuses carries the call at its own defaults. function tuneSender(sender, encoding, degradationPreference) { if (!sender || typeof sender.getParameters !== 'function') return; let params; try { params = sender.getParameters(); } catch { return; } if (!params.encodings || !params.encodings.length) return; for (const [key, value] of Object.entries(encoding)) { if (value === undefined) delete params.encodings[0][key]; else params.encodings[0][key] = value; } if (degradationPreference) params.degradationPreference = degradationPreference; Promise.resolve().then(() => sender.setParameters(params)).catch(() => { // Refused, or raced a renegotiation: the next call to this for the // same sender (a mode change, the next connection) tries again. }); } // tuneShare puts one connection's screen and camera where the mode // wants them: the screen at its plan, the camera capped beside it — // or, with nothing shared, the camera back at its defaults. function tuneShare(entry) { if (!entry) return; const sharing = !!(share && entry.shareTx); if (sharing) { const s = share.track.getSettings ? share.track.getSettings() : {}; const plan = sharePlan(shareMode(), s.width, s.height); tuneSender(entry.shareTx.sender, plan.encoding, plan.degradationPreference); } const camera = senderFor(entry, 'video'); const track = camera && camera.sender && camera.sender.track; if (!track) return; const settings = track.getSettings ? track.getSettings() : {}; tuneSender(camera.sender, cameraCap(sharing, settings.height || 0)); } // startLocal walks down a ladder: the remembered devices, then the // browser's defaults, then audio alone. A camera that is missing or // in use should not cost the call its microphone as well, and a // person who reaches the bottom rung joins with nothing, which the // room is built to allow. async function startLocal() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { applyToggles(); return; } const exact = (id) => ({ deviceId: { exact: id } }); const remembered = !!(prefs.mic || prefs.camera); // The processing choice rides every rung. Falling back to the // browser's default *device* is not a reason to put the voice // pipeline back on someone who asked for it off, so only the // remembered device id is dropped on the way down — by // forgetDevices, which touches mic and camera and never rawAudio. const raw = { rawAudio: prefs.rawAudio }; const attempts = [ { audio: audioConstraints(prefs), video: prefs.camera ? exact(prefs.camera) : true }, { audio: audioConstraints(raw), video: true }, { audio: audioConstraints(raw), video: false }, ]; for (let i = 0; i < attempts.length; i++) { try { local = await navigator.mediaDevices.getUserMedia(attempts[i]); if (i > 0 && remembered) forgetDevices(); break; } catch { // Try the next rung. Nothing is said on the way down: the only // thing worth telling a person is where they ended up. } } applyToggles(); } // forgetDevices drops remembered ids the browser would not honour, so // the next visit does not repeat the same failed attempt. devices.js // does the same on the holding screen; this is the room's copy for // the case where the device went between the two pages. function forgetDevices() { delete prefs.mic; delete prefs.camera; savePrefs(); } function applyToggles() { if (local) { for (const track of local.getAudioTracks()) track.enabled = !prefs.muted; for (const track of local.getVideoTracks()) track.enabled = !prefs.cameraOff; } // The accessible name is fixed, in aria-label on the server's own // markup; a toggle whose name changes under the reader is announced // as a different control every time it is used. The state moves // three ways and never by colour: aria-pressed, the word in the // button, and calls.css's inset outline. muteButton.setAttribute('aria-pressed', String(!!prefs.muted)); cameraButton.setAttribute('aria-pressed', String(!!prefs.cameraOff)); if (muteState) muteState.textContent = prefs.muted ? 'Muted' : 'On'; if (cameraState) cameraState.textContent = prefs.cameraOff ? 'Off' : 'On'; // With no stream there is no track to enable, so the two toggles // are controls that would do nothing. Leave is still live. const hasAudio = !!(local && local.getAudioTracks().length); const hasVideo = !!(local && local.getVideoTracks().length); if (!state.stopped) { muteButton.disabled = !hasAudio; cameraButton.disabled = !hasVideo; } // §15.19: every path that can change what we send ends here — the // two toggles, a device switch, a stream that lost a track — so the // report is made here too, and the tile marks follow. reportMedia(); if (tiles.has('self')) markTile('self', localMedia()); } function stopLocal() { if (!local) return; for (const track of local.getTracks()) track.stop(); local = null; } // --------------------------------------------------------------- // Changing a device mid-call (§3.3's stored choice, made movable) // --------------------------------------------------------------- // // The holding screen picks the devices; until now that was the only // place they could be picked, and a microphone chosen wrongly — or // unplugged, or wanted for a headset halfway through — cost the // person the call to change. Everything below is that choice, applied // to a call already running. // // The whole thing rests on replaceTrack, which swaps the media a // sender is sending without touching the m-line it sends on. That is // the part §5.1 never has to hear about: no offer, no answer, no // rollback, and no chance of the glare the negotiation above spends // 300 ms avoiding. The one case that does renegotiate is named at // sendTrack, and it is a direction change on an m-line that already // exists rather than a new one. // Two clicks on a picker are two switches, and they must not run at // once. devices.js can settle that with a generation counter alone // because the only thing it is racing for is one