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>
9 files changed,
+541
−2
Makefile+1 −0internal/calls/icons/icons.go+2 −0internal/calls/static/calls.css+48 −0internal/calls/static/room.js+187 −2internal/calls/templates/room.html+14 −0internal/callstest/browser_test.go+1 −0test/browser/call.mjs+111 −0test/browser/negotiation.test.mjs+3 −0test/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); |
| +}); |