oficina / meet Public

Show who is muted and whose camera is off on web tiles

Each remote tile gets the vendored badge ("Muted") with a slashed
microphone beside the name, and a "Camera off" cover over the video
(over, not instead: the element also plays the audio). Changes are said
once in a polite live region of their own, since #status is rewritten
on every render; a state learned from a roster is not announced. The
tab reports its own state on admission, when a roster disagrees, and on
every toggle or device change; no microphone counts as muted.

Gated by room-mute.test.mjs and a `mute` browser scenario, which caught
the event missing from the EventSource subscriptions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev bd1af6b707d197db576671ea5082dce56fda2b7a parent 6738937
9 files changed, +541 −2
  • Makefile +1 −0
  • internal/calls/icons/icons.go +2 −0
  • internal/calls/static/calls.css +48 −0
  • internal/calls/static/room.js +187 −2
  • internal/calls/templates/room.html +14 −0
  • internal/callstest/browser_test.go +1 −0
  • test/browser/call.mjs +111 −0
  • test/browser/negotiation.test.mjs +3 −0
  • test/browser/room-mute.test.mjs +174 −0
diff --git a/Makefile b/Makefile
index a15a497..d87f6c7 100644
--- a/Makefile
+++ b/Makefile
@@ -147,6 +147,7 @@ browser:
node --test test/browser/negotiation.test.mjs
node --test test/browser/chat.test.mjs
node --test test/browser/room-share.test.mjs
+ node --test test/browser/room-mute.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
diff --git a/internal/calls/icons/icons.go b/internal/calls/icons/icons.go
index 0289e41..a1f5590 100644
--- a/internal/calls/icons/icons.go
+++ b/internal/calls/icons/icons.go
@@ -21,7 +21,9 @@ import "html/template"
var icons = map[string]template.HTML{
"mic": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8"/></svg>`,
+ "mic-off": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 10v2a7 7 0 0 0 12 5M15 9.34V5a3 3 0 0 0-5.68-1.33M9 9v3a3 3 0 0 0 5.12 2.12M18.89 13.23A7.12 7.12 0 0 0 19 12v-2M12 19v3M2 2l20 20"/></svg>`,
"video": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="12" height="14" rx="2"/><path d="m15 10 6-4v12l-6-4"/></svg>`,
+ "video-off": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.66 6H14a2 2 0 0 1 2 2v2.5l5.25-3.06A.5.5 0 0 1 22 7.87v8.2M16 16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2M2 2l20 20"/></svg>`,
"monitor": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
"message-circle": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11a9 9 0 0 1-9 9H3l1-5a9 9 0 1 1 17-4Z"/></svg>`,
"sliders-horizontal": `<svg class="icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg>`,
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index da333b5..782e9a8 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -160,6 +160,54 @@
cursor: pointer;
}
+/* Spec §15.19: a muted peer's badge sits beside their name — the
+ slashed microphone and the vendored badge's word, so the state is a
+ shape and a word and never a colour. It is a flex item of the caption
+ row, so it never covers the name. */
+.calls-tile-muted {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--rst-sp-1);
+ flex: none;
+}
+
+.calls-tile-muted .icon {
+ inline-size: 1em;
+ block-size: 1em;
+}
+
+.calls-tile-muted[hidden],
+.calls-tile-off[hidden] {
+ display: none;
+}
+
+/* A camera that is off sends black frames, or holds the last one. The
+ cover says what that is instead of leaving a frozen face or a black
+ rectangle to be read as a fault. It lies over the video rather than
+ hiding it: the element is also what plays the peer's audio. */
+.calls-tile-off {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--rst-sp-2);
+ background: var(--rst-surface-2);
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+}
+
+.calls-tile-off .icon {
+ inline-size: 2rem;
+ block-size: 2rem;
+}
+
+/* The caption stays above the cover. */
+.calls-tile-camera-off .calls-tile-name {
+ z-index: 1;
+}
+
/* Never colour alone: a failed connection says so in words, and the
weight is only a second cue on top of the sentence. */
.calls-tile-note {
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 8b3b909..49e10a5 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -27,6 +27,9 @@
// §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`
//
// House rules this file is written under: no library, no inline script
// (the page's CSP is `default-src 'self'`), nothing built with
@@ -64,6 +67,13 @@
const muteState = $('mute-state');
const cameraState = $('camera-state');
const shareState = $('share-state');
+ // §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');
@@ -241,6 +251,12 @@
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 = '';
// §5.2's list and §5.3's clocks. `expiresAt`, `renewAt`, `fetchedAt`
// and `lastFetchAt` are all milliseconds since the epoch: the server
@@ -504,6 +520,11 @@
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() {
@@ -838,7 +859,10 @@
// §4.3's fan-out pair. They carry no `to`, no `seq` and no `gen`, so
// nothing about them goes through §4.6's inbound rules: the hub
// rebuilt the envelope and there is nothing to dedupe against.
- 'chat', 'share'];
+ 'chat', 'share',
+ // §15.19: one peer's media state changed. State, not an event —
+ // the roster carries it too — so a missed one costs nothing.
+ 'media'];
function streamPath(peer) {
let path = '/m/' + encodeURIComponent(sqid) + '/stream';
@@ -1140,6 +1164,7 @@
case 'candidate': return onCandidate(msg);
case 'chat': return onChat(msg);
case 'share': return onShare(msg);
+ case 'media': return onMedia(msg);
default: return undefined;
}
}
@@ -1207,12 +1232,14 @@
const rp = (msg.roster && msg.roster.peers && msg.roster.peers[0]) || {};
const g = rp.gen || 1;
if (rp.name) names.set(id, rp.name);
+ learnMedia(id, rp.media);
ensurePC(id, g);
renderTiles();
}
function onGone(msg) {
if (!msg.from) return;
+ remoteMedia.delete(msg.from);
closePC(msg.from, true);
dropWaiting(msg.from);
renderTiles();
@@ -1390,6 +1417,8 @@
// roster costs one `setEnabled(true)` and nothing else.
mountChat();
chatEnabled(true);
+ // §15.19: before the tiles are drawn, so they are drawn right.
+ rosterMedia(roster);
renderTiles();
renderQueue();
renderStatus();
@@ -3879,6 +3908,144 @@
return out;
}
+ // ---------------------------------------------------------------
+ // Media state (§15.19): who is muted, whose camera is off
+ // ---------------------------------------------------------------
+ //
+ // State, not an event. The hub keeps each peer's last report and puts
+ // it in every roster, so what a tile shows is always the roster's word
+ // or a `media` since; nothing here is replayed or reconstructed. A
+ // peer that never reports — a client from before this — is unknown,
+ // and unknown is drawn as neither muted nor camera-off.
+
+ // mediaFrom accepts exactly the two booleans and nothing looser, for a
+ // roster entry or a `media` payload alike. Pure, and lifted by name in
+ // the node test.
+ function mediaFrom(value) {
+ if (!value || typeof value !== 'object') return null;
+ if (typeof value.muted !== 'boolean' || typeof value.cameraOff !== 'boolean') return null;
+ return { muted: value.muted, cameraOff: value.cameraOff };
+ }
+
+ function sameMedia(a, b) {
+ return !!a && !!b && a.muted === b.muted && a.cameraOff === b.cameraOff;
+ }
+
+ function mediaKey(m) {
+ return (m.muted ? 'm' : '-') + (m.cameraOff ? 'c' : '-');
+ }
+
+ // localMedia is what this tab is actually sending. No microphone at
+ // all is muted as far as anyone listening can tell, and no camera is
+ // off; the preference alone would claim a microphone that is not there.
+ function localMedia() {
+ const hasAudio = !!(local && local.getAudioTracks().length);
+ const hasVideo = !!(local && local.getVideoTracks().length);
+ return { muted: !!prefs.muted || !hasAudio, cameraOff: !!prefs.cameraOff || !hasVideo };
+ }
+
+ // reportMedia puts our state on the broadcast lane, once per change,
+ // and only once admitted: before that the hub refuses it, and the
+ // roster that admits us is where it is sent from. A refusal forgets
+ // what was sent, so the next toggle or roster tries again — which
+ // against a server from before §15.19 is a quiet 400 each time.
+ function reportMedia() {
+ if (!state.admitted || state.stopped || !state.peer) return;
+ const now = localMedia();
+ const key = mediaKey(now);
+ if (key === mediaSent) return;
+ mediaSent = key;
+ broadcast('media', now).then((ok) => {
+ if (!ok && mediaSent === key) mediaSent = '';
+ });
+ }
+
+ // rosterMedia takes a roster's word on everybody's media. On ours as
+ // well: missing is a participation the hub has never heard from — a
+ // fresh join, or a restart — and different is a report lost while the
+ // stream was down, and either way it is told again.
+ function rosterMedia(roster) {
+ for (const rp of roster.peers || []) {
+ if (!rp || !rp.peer) continue;
+ if (rp.peer !== state.peer) {
+ learnMedia(rp.peer, rp.media);
+ continue;
+ }
+ const now = localMedia();
+ mediaSent = sameMedia(mediaFrom(rp.media), now) ? mediaKey(now) : '';
+ }
+ reportMedia();
+ }
+
+ // learnMedia records a roster's or a peer event's word on one peer.
+ // Absent is unknown, and forgets anything older: the roster is the
+ // authority, and a participation that has not reported has not.
+ function learnMedia(id, value) {
+ const m = mediaFrom(value);
+ if (m) remoteMedia.set(id, m);
+ else remoteMedia.delete(id);
+ }
+
+ // onMedia is one peer's change. Only a peer we hold a connection to
+ // is drawn; the roster will carry the rest.
+ function onMedia(msg) {
+ const id = msg.from;
+ const next = mediaFrom(msg.payload);
+ if (!id || id === state.peer || !next) return;
+ const before = remoteMedia.get(id) || null;
+ remoteMedia.set(id, next);
+ announceMedia(mediaChange(msg.name || names.get(id) || 'Someone', before, next));
+ markTile(id, next);
+ }
+
+ // mediaChange is the sentence a change earns, or '' for none. A first
+ // report is not news — it is somebody arriving the way they arrived —
+ // so only a known state that moved is said out loud. Pure, and lifted
+ // by name in the node test.
+ function mediaChange(name, before, after) {
+ if (!before || !after) return '';
+ const parts = [];
+ if (before.muted !== after.muted) {
+ parts.push(name + (after.muted ? ' muted their microphone.' : ' unmuted their microphone.'));
+ }
+ if (before.cameraOff !== after.cameraOff) {
+ parts.push(name + (after.cameraOff ? ' turned their camera off.' : ' turned their camera on.'));
+ }
+ return parts.join(' ');
+ }
+
+ function announceMedia(text) {
+ if (!text || !mediaLive) return;
+ mediaLive.textContent = text;
+ }
+
+ // tileMarks builds one tile's pair of marks from the server's
+ // templates: the muted badge beside the name, and the camera-off cover
+ // over the video. The cover sits over the element rather than hiding
+ // it — the element is also what plays the peer's audio.
+ function tileMarks() {
+ const muted = document.createElement('span');
+ muted.className = 'calls-tile-muted';
+ muted.hidden = true;
+ if (mutedMark) muted.append(mutedMark.content.cloneNode(true));
+ else muted.textContent = 'Muted';
+ const off = document.createElement('div');
+ off.className = 'calls-tile-off';
+ off.hidden = true;
+ if (cameraOffMark) off.append(cameraOffMark.content.cloneNode(true));
+ else off.textContent = 'Camera off';
+ return { muted, off };
+ }
+
+ // markTile shows or hides one tile's marks. `media` null is unknown.
+ function markTile(key, media) {
+ const t = tiles.get(key);
+ if (!t || !t.muted) return;
+ t.muted.hidden = !(media && media.muted);
+ t.off.hidden = !(media && media.cameraOff);
+ t.figure.classList.toggle('calls-tile-camera-off', !!(media && media.cameraOff));
+ }
+
// ---------------------------------------------------------------
// Terminal states (§4.5, §4.2)
// ---------------------------------------------------------------
@@ -3921,6 +4088,7 @@
// transceivers are already closed with their connections, so this is
// the capture itself and the button's own state.
stopSharing();
+ remoteMedia.clear();
renderTiles();
iceWarning(false);
controlsEnabled(false);
@@ -4029,7 +4197,10 @@
tiles.delete(key);
}
}
- if (local) tile('self', selfName, local, true, '');
+ if (local) {
+ tile('self', selfName, local, true, '');
+ markTile('self', localMedia());
+ }
for (const [id, entry] of pcs) {
const name = names.get(id) || 'Someone';
// The track as well as the stream: which video track a tile is
@@ -4039,6 +4210,7 @@
// which case tile() falls back to the stream's own first video
// track, which is unambiguous by then.
tile(id, name, entry.stream, false, tileNote(id, entry), entry.cameraTrack);
+ markTile(id, remoteMedia.get(id) || null);
// The screen's own tile carries no note: §5.3's and §5.5's
// sentences are about a connection, and this is the same
// connection as the camera tile above, which is already saying it.
@@ -4078,6 +4250,7 @@
if (!t) {
const figure = document.createElement('figure');
figure.className = 'calls-tile';
+ figure.dataset.tile = key;
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
@@ -4113,12 +4286,20 @@
});
label.append(nameEl, note, retry, play);
figure.append(video, label);
+ // §15.19's two marks, hidden until markTile says otherwise. A
+ // screen has neither: it has no microphone, and it is never "off".
+ const marks = key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX ? null : tileMarks();
+ if (marks) {
+ nameEl.after(marks.muted);
+ figure.append(marks.off);
+ }
tilesEl.append(figure);
// `self` is kept on the record because rearmFrames reads it: the
// local preview is never counted, and only tile() knows which
// tile is ours.
t = {
figure, video, label, nameEl, note, retry, play,
+ muted: marks && marks.muted, off: marks && marks.off,
remove: null, stream: null, track: null, self: isSelf,
// What judgeRender remembers between polls: the element's own
// clock, the decoder's count, and how long the two have
@@ -5092,6 +5273,10 @@
// the message is what draws the tile.
sharing: !!share,
shares: sharingPeers(),
+ // §15.19: what this tab reports, and what it has been told, by
+ // peer id. Peers that never reported are absent.
+ media: localMedia(),
+ remoteMedia: Object.fromEntries(remoteMedia),
};
},
};
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index 992527f..ff1fb23 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -87,6 +87,20 @@
</div>
<div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
<p id="status" class="calls-status" role="status"></p>
+ {{/* Spec §15.19. Someone muting or turning their camera off, said
+ politely and once. A region of its own for the reason
+ #device-note has one: renderStatus rewrites #status on every
+ render, and would take the sentence away before it was read.
+ Present from the first paint, empty, so a reader is already
+ watching it when room.js first writes to it. */}}
+ <p id="media-live" class="calls-sr-only" role="status" aria-live="polite"></p>
+ {{/* The two tile marks room.js clones onto each tile, rendered here so
+ they are the vendored badge and this app's own icons rather than a
+ copy of either in script. The badge's word is the state, for a
+ reader and for anyone who cannot tell a slashed microphone from a
+ plain one; the icon is on top of it, never instead. */}}
+ <template id="tile-muted">{{icon "mic-off"}}{{template "badge" dict "Label" "Muted"}}</template>
+ <template id="tile-camera-off">{{icon "video-off"}}<span>Camera off</span></template>
</div>
{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
{{end}}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index d139c84..33b92d8 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -84,6 +84,7 @@ var scenarios = []string{
"layout",
"devices",
"call",
+ "mute",
"switching",
"share",
"mutualshare",
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 819b624..399a780 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -1136,6 +1136,31 @@ function shareControl(page) {
});
}
+// tileMarks is what spec §15.19 draws on one peer's tile, read the way a
+// person reads it: whether the muted badge and the camera-off cover are
+// showing, and the words each carries — the words, not the colour or
+// the icon, being the part AGENTS.md makes the contract.
+function tileMarks(page, key) {
+ return page.evaluate((k) => {
+ const f = document.querySelector('#tiles figure[data-tile="' + k + '"]');
+ if (!f) return null;
+ const muted = f.querySelector('.calls-tile-muted');
+ const off = f.querySelector('.calls-tile-off');
+ const shown = (el) => !!(el && !el.hidden && el.getClientRects().length);
+ return {
+ muted: shown(muted),
+ mutedText: muted ? (muted.textContent || '').trim() : '',
+ cameraOff: shown(off),
+ offText: off ? (off.textContent || '').trim() : '',
+ caption: ((f.querySelector('figcaption') || {}).textContent || '').trim(),
+ };
+ }, key);
+}
+
+function mediaLive(page) {
+ return page.evaluate(() => ((document.getElementById('media-live') || {}).textContent || '').trim());
+}
+
// ── the scenarios ────────────────────────────────────────────────────
const run = {
@@ -1221,6 +1246,92 @@ const run = {
await ctxB.close();
},
+ // Spec §15.19: one peer mutes and turns the camera off, and the other
+ // sees both on that peer's tile — in words, not only an icon — and
+ // hears about it through the polite live region. Then the watcher
+ // leaves and comes back, and the state is on the tile from the roster
+ // alone, with no event to carry it: that is the late-joiner and resume
+ // path, which is the reason the state lives in the roster at all.
+ async mute() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA);
+ let pageB = await openRoom(ctxB);
+
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ const a = pair.a.peer;
+ const nameA = await selfName(pageA);
+
+ // Both report on admission, so each knows the other is on — known,
+ // not merely unknown — before anybody touches anything.
+ const known = await until(async () => {
+ const s = await pageB.evaluate(() => window.meet.state().remoteMedia);
+ return s[a] && s[a].muted === false && s[a].cameraOff === false ? s : false;
+ }, 10000);
+ check(known, 'B has A’s first report: on, not unknown (' + JSON.stringify(known) + ')');
+ const before = await tileMarks(pageB, a);
+ check(before && !before.muted && !before.cameraOff, 'A’s tile starts with neither mark (' + JSON.stringify(before) + ')');
+
+ await pageA.click('#mute');
+ const muted = await until(async () => {
+ const m = await tileMarks(pageB, a);
+ return m && m.muted ? m : false;
+ }, 10000);
+ check(muted, 'B sees A’s muted badge after A mutes (' + JSON.stringify(muted || (await tileMarks(pageB, a))) + ')');
+ check(muted && /muted/i.test(muted.mutedText), 'the badge says Muted in words (' + (muted && muted.mutedText) + ')');
+ check(muted && !muted.cameraOff, 'muting alone does not cover the video');
+ const said = await until(async () => {
+ const t = await mediaLive(pageB);
+ return t.includes('muted their microphone') ? t : false;
+ }, 5000);
+ check(said && said.startsWith(nameA), 'B’s live region says so, politely, by name (' + (said || (await mediaLive(pageB))) + ')');
+ const selfMark = await tileMarks(pageA, 'self');
+ check(selfMark && selfMark.muted, 'A’s own tile carries the badge too (' + JSON.stringify(selfMark) + ')');
+
+ await pageA.click('#camera');
+ const off = await until(async () => {
+ const m = await tileMarks(pageB, a);
+ return m && m.cameraOff ? m : false;
+ }, 10000);
+ check(off, 'B sees A’s camera-off cover (' + JSON.stringify(off || (await tileMarks(pageB, a))) + ')');
+ check(off && off.offText === 'Camera off', 'the cover says Camera off in words (' + (off && off.offText) + ')');
+ check(off && off.muted, 'and A is still muted');
+ // For a person to look at, as guestdirect keeps the settings page.
+ await pageB.screenshot({ path: '/tmp/calls-mute.png', fullPage: true });
+
+ // The watcher comes back: the roster alone must carry both.
+ await leaveRoom(pageB);
+ await pageB.close();
+ pageB = await openRoom(ctxB);
+ const again = await until(async () => {
+ const s = await state(pageB);
+ if (!s.admitted || !s.peers.includes(a)) return false;
+ const m = await tileMarks(pageB, a);
+ return m && m.muted && m.cameraOff ? m : false;
+ }, 25000);
+ check(again, 'B, rejoining, reads both marks from the roster (' + JSON.stringify(again || (await tileMarks(pageB, a))) + ')');
+ check((await mediaLive(pageB)) === '', 'a state learned from the roster is not announced as news');
+
+ // And back on: both marks go.
+ await pageA.click('#mute');
+ await pageA.click('#camera');
+ const clear = await until(async () => {
+ const m = await tileMarks(pageB, a);
+ return m && !m.muted && !m.cameraOff ? m : false;
+ }, 10000);
+ check(clear, 'unmuting and turning the camera on clears both marks (' + JSON.stringify(clear || (await tileMarks(pageB, a))) + ')');
+ const fb = await waitFrames(pageB, a);
+ check(fb, 'B is still decoding A’s video afterwards');
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
// Changing a device without leaving the call. The panel opens, the
// microphone and then the camera are chosen again from the list, and
// the far side's media is watched across both.
diff --git a/test/browser/negotiation.test.mjs b/test/browser/negotiation.test.mjs
index 79a7aec..af952ba 100644
--- a/test/browser/negotiation.test.mjs
+++ b/test/browser/negotiation.test.mjs
@@ -615,6 +615,8 @@ const reconciler = new Function(
'state', 'stale', 'names', 'pcs', 'gen', 'seqOut', 'lastIn', 'queue', 'frames',
'renewals', 'ensurePC', 'renderTiles', 'renderQueue', 'renderStatus',
'rosters', 'lastRoster', 'waiting', 'mountChat', 'chatEnabled', 'SHARE_SUFFIX',
+ // §15.19's roster half is its own function, gated in room-mute.test.mjs.
+ 'rosterMedia',
// shareKey comes in as source rather than as a stub: closePC forgets a
// peer's share tile through it (§5.4), and a stub would be testing the
// stub.
@@ -631,6 +633,7 @@ function reconcileOver(pcs, seqOut, lastIn, frames) {
frames || new Map(),
new Map(), (id, g) => built.push([id, g]), () => {}, () => {}, () => {},
0, null, [], () => chat.push('mount'), (on) => chat.push(on), '#share',
+ () => {},
);
return { reconcile, closePC, built, chat };
}
diff --git a/test/browser/room-mute.test.mjs b/test/browser/room-mute.test.mjs
new file mode 100644
index 0000000..927b0ea
--- /dev/null
+++ b/test/browser/room-mute.test.mjs
@@ -0,0 +1,174 @@
+// room-mute.test.mjs — room.js's media state (spec §15.19), gated
+// without a browser.
+//
+// node --test test/browser/room-mute.test.mjs
+//
+// Same trick as room-share.test.mjs: the functions are lifted out of the
+// shipped room.js by name, so a rename fails here loudly instead of
+// passing against a copy.
+//
+// mediaFrom what counts as a report: exactly two booleans, from a
+// roster entry or a `media` payload, and nothing looser —
+// an absent or malformed one is unknown, never "on"
+// sameMedia whether the hub's word on us already matches ours
+// mediaKey the dedupe key a toggle is compared on
+// mediaChange the sentence the live region says, and when it says
+// nothing at all
+// rosterMedia with reportMedia: when our own report goes — on the
+// roster that admits us, on one that disagrees, on a
+// toggle, never before admission, and again after a 400
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const roomJS = fileURLToPath(new URL('../../internal/calls/static/room.js', import.meta.url));
+const src = readFileSync(roomJS, 'utf8');
+
+function lift(source, name) {
+ const start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not in room.js any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name + ' out of room.js');
+}
+
+const build = (name) =>
+ // eslint-disable-next-line no-new-func
+ new Function(lift(src, name) + '; return ' + name + ';')();
+
+const mediaFrom = build('mediaFrom');
+const sameMedia = build('sameMedia');
+const mediaKey = build('mediaKey');
+const mediaChange = build('mediaChange');
+
+test('mediaFrom takes exactly two booleans and nothing looser', () => {
+ assert.deepEqual(mediaFrom({ muted: true, cameraOff: false }), { muted: true, cameraOff: false });
+ // Extra keys are dropped rather than carried: what is drawn is what
+ // was checked.
+ assert.deepEqual(mediaFrom({ muted: false, cameraOff: true, extra: 1 }), { muted: false, cameraOff: true });
+ for (const bad of [
+ undefined, null, 'muted', 1, [],
+ {}, { muted: true }, { cameraOff: true },
+ { muted: 'true', cameraOff: false }, { muted: 1, cameraOff: 0 }, { muted: null, cameraOff: false },
+ ]) {
+ assert.equal(mediaFrom(bad), null, JSON.stringify(bad) + ' is unknown');
+ }
+});
+
+test('sameMedia never matches unknown', () => {
+ const on = { muted: false, cameraOff: false };
+ assert.equal(sameMedia(on, { muted: false, cameraOff: false }), true);
+ assert.equal(sameMedia(on, { muted: true, cameraOff: false }), false);
+ assert.equal(sameMedia(on, { muted: false, cameraOff: true }), false);
+ // The roster's silence about us is not agreement with "on": a fresh
+ // participation must be told, or a peer who joined unmuted stays
+ // unknown to everyone for the whole call.
+ assert.equal(sameMedia(null, on), false);
+ assert.equal(sameMedia(on, null), false);
+});
+
+test('mediaKey tells every state apart', () => {
+ const keys = new Set();
+ for (const muted of [false, true]) {
+ for (const cameraOff of [false, true]) keys.add(mediaKey({ muted, cameraOff }));
+ }
+ assert.equal(keys.size, 4);
+ // And never collides with the empty string mediaSent starts at.
+ assert.equal(keys.has(''), false);
+});
+
+test('mediaChange says what moved, and nothing for a first report', () => {
+ const on = { muted: false, cameraOff: false };
+ assert.equal(mediaChange('Bea', null, { muted: true, cameraOff: false }), '',
+ 'a first report is somebody arriving, not news');
+ assert.equal(mediaChange('Bea', on, on), '');
+ assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: false }), 'Bea muted their microphone.');
+ assert.equal(mediaChange('Bea', { muted: true, cameraOff: false }, on), 'Bea unmuted their microphone.');
+ assert.equal(mediaChange('Bea', on, { muted: false, cameraOff: true }), 'Bea turned their camera off.');
+ assert.equal(mediaChange('Bea', { muted: false, cameraOff: true }, on), 'Bea turned their camera on.');
+ assert.equal(mediaChange('Bea', on, { muted: true, cameraOff: true }),
+ 'Bea muted their microphone. Bea turned their camera off.');
+});
+
+test('the dispatch routes media, and the report rides the broadcast lane', () => {
+ // Wire-level facts a lift cannot reach: the event name the hub sends,
+ // and that the report is an untargeted broadcast rather than a send()
+ // with a seq and a generation the hub would refuse.
+ assert.match(src, /case 'media': return onMedia\(msg\);/);
+ // An EventSource hears only the names it listens for: the dispatch
+ // above is dead without this, and the roster path hides it (the
+ // browser gate caught exactly that).
+ assert.match(src, /const DOWNSTREAM = \[[^\]]*'media'\]/);
+ assert.match(src, /broadcast\('media', now\)/);
+});
+
+// rosterMedia and reportMedia, lifted together over real Maps with only
+// the wire and the local stream stubbed: the decision under test is when
+// a report goes, and that is the pair of them.
+function reporter({ admitted = true, local = { muted: false, cameraOff: false }, answer = true } = {}) {
+ const sent = [];
+ const env = { local, answer };
+ const made = new Function(
+ 'state', 'remoteMedia', 'localMedia', 'broadcast',
+ ['mediaFrom', 'sameMedia', 'mediaKey', 'learnMedia', 'reportMedia', 'rosterMedia']
+ .map((n) => lift(src, n)).join('\n') +
+ '\nlet mediaSent = "";' +
+ '\nreturn { rosterMedia, reportMedia, memo: () => mediaSent };',
+ )(
+ { admitted, stopped: false, peer: 'me' },
+ new Map(),
+ () => env.local,
+ (type, payload) => {
+ sent.push([type, payload]);
+ return Promise.resolve(env.answer);
+ },
+ );
+ return { ...made, sent, env };
+}
+
+test('a roster that has never heard from us is answered with a report', () => {
+ const r = reporter({ local: { muted: true, cameraOff: false } });
+ r.rosterMedia({ peers: [{ peer: 'me' }, { peer: 'bea' }] });
+ assert.deepEqual(r.sent, [['media', { muted: true, cameraOff: false }]]);
+});
+
+test('a roster that already agrees costs nothing, and a toggle then goes once', () => {
+ const r = reporter();
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: false, cameraOff: false } }] });
+ assert.equal(r.sent.length, 0);
+ r.env.local = { muted: true, cameraOff: false };
+ r.reportMedia();
+ r.reportMedia();
+ assert.equal(r.sent.length, 1, 'a second call with nothing changed must not send again');
+});
+
+test('a roster that disagrees — a report lost while the stream was down — is corrected', () => {
+ const r = reporter({ local: { muted: true, cameraOff: true } });
+ r.reportMedia();
+ assert.equal(r.sent.length, 1);
+ r.rosterMedia({ peers: [{ peer: 'me', media: { muted: true, cameraOff: false } }] });
+ assert.equal(r.sent.length, 2);
+ assert.deepEqual(r.sent[1][1], { muted: true, cameraOff: true });
+});
+
+test('nothing is reported before admission', () => {
+ const r = reporter({ admitted: false, local: { muted: true, cameraOff: false } });
+ r.reportMedia();
+ assert.equal(r.sent.length, 0);
+});
+
+test('a refused report is forgotten, so the next chance tries again', async () => {
+ const r = reporter({ answer: false, local: { muted: true, cameraOff: false } });
+ r.reportMedia();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(r.memo(), '', 'an old server’s 400 must not be remembered as sent');
+ r.reportMedia();
+ assert.equal(r.sent.length, 2);
+});