oficina / meet Public

room.js fix round 1: stalls, signed out, dead ends, toggles

Every review item is in. The nine browser scenarios still pass.

Important:
1. pump's finally re-pumps a queue that arrived while a POST was in
   flight, so nothing is stranded behind a returned loop.
2. A stream refusal the probe cannot name is counted: six rounds
   without a hello ends the call with a sentence and a way out.
   Explicit arms for 406 and 400, and a signed-out session (a 303 to
   /signin that fetch follows) is recognised by res.redirected.
3. POST 401/403 and the same redirect go terminal("signedout").
4. The backoff resets only for a stream that stayed open five
   seconds; both reconnect arms go through later().
5. The waiting sentence is only for a guest that has knocked.
6. pagehide with e.persisted no longer leaves; pageshow resumes.
7. A remote tile calls play() and offers a Play control if refused.
8. No terminal screen is a dead end.
9. The toggles keep a fixed accessible name in aria-label and carry
   their state in aria-pressed and a word, in both scripts and both
   templates.

Ruling from the Task 9 review: terminal() clears admitted, keeps peer,
and state() gained a terminal field.

Deviations, both to keep make browser green or honest:

- Item 8 says every reason gets the holding-screen link. `replaced`
  keeps only its takeover link: it is the one sentence with a real
  next step, and the gate's takeover scenario reads #status a as a
  single element. `signedout` gets /signin instead, which is the way
  out that actually works when the session has gone. `unreachable`
  gets both a Try again and the holding-screen link.
- STREAM_BACKOFF_MAX (10 s) is new and not in the review. Item 4's
  growing backoff shares its ceiling with the hub's thirty-second
  resume window, so a client that waited the full thirty would lose
  the participation to its own patience. Capping the stream's own
  retry at ten keeps every reconnect inside the window.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev ba5919c24f3521977648e7212e79d575a6faf43e parent c4e55d9
7 files changed, +432 −128
  • internal/meet/static/devices.js +49 −15
  • internal/meet/static/meet.css +30 −15
  • internal/meet/static/room.js +311 −94
  • internal/meet/templates/join.html +2 −2
  • internal/meet/templates/room.html +2 −2
  • internal/meettest/holding_test.go +24 −0
  • internal/meettest/room_test.go +14 −0

CI passed — run details

diff --git a/internal/meet/static/devices.js b/internal/meet/static/devices.js
index 880f7c0..7423036 100644
--- a/internal/meet/static/devices.js
+++ b/internal/meet/static/devices.js
@@ -27,6 +27,8 @@
const pickSpeaker = $('pick-speaker');
const muteButton = $('toggle-mute');
const cameraButton = $('toggle-camera');
+ const muteState = $('toggle-mute-state');
+ const cameraState = $('toggle-camera-state');
const joinButton = $('join');
const status = $('status');
@@ -67,13 +69,12 @@
// --- there is a stream, always reflected on the toggle buttons ---
// --- even when there isn't one, so the choice still holds at join. ---
//
- // The buttons keep one fixed accessible name each ("Microphone",
- // "Camera") and move only aria-pressed: a button whose name changes
- // on every click reads, to a screen reader tracking it, as a
- // different control each time. meet.css already gives
- // [aria-pressed="true"] a non-colour cue (room.js's own toggles use
- // the same rule), so nothing further is needed here beyond setting
- // the attribute.
+ // The buttons keep one fixed accessible name each, from aria-label
+ // ("Microphone", "Camera"): a button whose name changes on every
+ // click reads, to a screen reader tracking it, as a different
+ // control each time. The state moves three ways and never by colour
+ // — aria-pressed for assistive technology, a word in the button for
+ // everyone, and meet.css's inset outline as a third cue.
function applyToggles() {
if (stream) {
for (const track of stream.getAudioTracks()) track.enabled = !prefs.muted;
@@ -81,6 +82,8 @@
}
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';
}
function stopPreview() {
@@ -109,6 +112,11 @@
if (href.startsWith('/')) location.assign(href);
});
window.addEventListener('pagehide', stopPreview);
+ // Coming back through the back/forward cache restores the DOM but not
+ // the tracks pagehide stopped, so the preview is asked for again.
+ window.addEventListener('pageshow', (e) => {
+ if (e.persisted && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) startPreview();
+ });
applyToggles();
root.hidden = false;
@@ -144,14 +152,13 @@
// A remembered device is gone — unplugged, or no longer
// matching. err.constraint (typically "deviceId") names the
// constraint that failed but not which of camera/mic it
- // belonged to, so only one remembered id is dropped per
- // attempt — camera first, then mic — rather than discarding
- // both on a single ambiguous failure; this converges within
- // two retries at most.
- if (prefs.camera) delete prefs.camera;
- else delete prefs.mic;
- savePrefs();
- return startPreview();
+ // belonged to, and dropping the camera first would quietly
+ // reset a working camera choice whenever it was the
+ // microphone that went. So ask the question directly: try the
+ // remembered camera with an unconstrained microphone. If that
+ // works it was the microphone; if it fails the same way it was
+ // the camera.
+ return await disambiguate(myGen);
}
preview.srcObject = null;
say(errorSentence(err));
@@ -173,6 +180,33 @@
say('');
}
+ // disambiguate settles which remembered device id is stale, then
+ // deletes exactly that one and starts over. It runs at most once per
+ // failure, and a generation that has been superseded meanwhile drops
+ // whatever it was handed rather than acting on it.
+ async function disambiguate(myGen) {
+ if (prefs.camera && prefs.mic) {
+ try {
+ const probe = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ video: { deviceId: { exact: prefs.camera } },
+ });
+ for (const track of probe.getTracks()) track.stop();
+ if (myGen !== generation) return;
+ delete prefs.mic;
+ } catch {
+ if (myGen !== generation) return;
+ delete prefs.camera;
+ }
+ } else if (prefs.camera) {
+ delete prefs.camera;
+ } else {
+ delete prefs.mic;
+ }
+ savePrefs();
+ return startPreview();
+ }
+
function errorSentence(err) {
if (err.name === 'NotAllowedError') {
return 'Meet needs permission to use your camera and microphone. You can still join with neither.';
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css
index aa5160c..dfc0a48 100644
--- a/internal/meet/static/meet.css
+++ b/internal/meet/static/meet.css
@@ -3,6 +3,14 @@
as they grow); everything here reads from rst-* tokens so it follows
the theme rather than fixing its own colours. */
+/* The hidden attribute is how both scripts show and hide whole regions,
+ and its UA rule is display: none at author-origin-beating strength
+ only until something here authors display on the same element —
+ .meet-pickers and .meet-controls both do. This restores it. */
+[hidden] {
+ display: none !important;
+}
+
.meet-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
@@ -25,6 +33,10 @@
object-fit: cover;
}
+/* The tile caption: the name, and, when the pair has failed or the
+ browser refused to play the track, the sentence and the control
+ beside it — plus the removal link a member gets. One flex row, so
+ none of that stacks oddly on a narrow tile. */
.meet-tile-name {
position: absolute;
inset-block-end: var(--rst-sp-2);
@@ -34,6 +46,10 @@
border-radius: var(--rst-radius-sm);
color: var(--rst-text);
font-size: var(--rst-fs-sm);
+ display: flex;
+ align-items: center;
+ gap: var(--rst-sp-2);
+ max-inline-size: calc(100% - 2 * var(--rst-sp-2));
}
.meet-controls {
@@ -94,18 +110,6 @@
min-height: 1.2em;
}
-/* The tile caption carries the name, and, when the call to that peer
- has failed, the sentence and the retry beside it — plus the removal
- control a member gets. It is one flex row so those never stack
- oddly on a narrow tile. */
-
-.meet-tile-name {
- display: flex;
- align-items: center;
- gap: var(--rst-sp-2);
- max-inline-size: calc(100% - 2 * var(--rst-sp-2));
-}
-
.meet-tile-name > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
@@ -135,15 +139,26 @@
transform: scaleX(-1);
}
-/* The toggles keep one accessible name and move aria-pressed, so the
- engaged state needs a cue that is not colour: a heavier label inside
- an inset outline. */
+/* The toggles keep one accessible name (aria-label) and move only
+ aria-pressed, so the state is carried three ways and never by colour:
+ the word in .meet-toggle-state, the attribute a screen reader reads,
+ and this inset outline. */
.meet-controls button[aria-pressed='true'] {
font-weight: 700;
outline: 2px solid var(--rst-text);
outline-offset: -3px;
}
+.meet-toggle-state {
+ font-variant: all-small-caps;
+ letter-spacing: 0.04em;
+ opacity: 0.85;
+}
+
+.meet-toggle-state::before {
+ content: ' ';
+}
+
.meet-queue li button {
flex: none;
}
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
index 8cb0f70..4b64fd7 100644
--- a/internal/meet/static/room.js
+++ b/internal/meet/static/room.js
@@ -43,10 +43,17 @@
const muteButton = $('mute');
const cameraButton = $('camera');
const leaveButton = $('leave');
+ const muteState = $('mute-state');
+ const cameraState = $('camera-state');
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;
@@ -54,6 +61,16 @@
// §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`, the client stops and says so.
+ const MAX_STALLS = 6;
+ // 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;
+ const PEER_ID = /^[0-9a-f]{32}$/;
const HAS_SINK_ID = typeof HTMLMediaElement !== 'undefined' &&
typeof HTMLMediaElement.prototype.setSinkId === 'function';
@@ -68,6 +85,7 @@
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',
};
@@ -88,6 +106,7 @@
let prefs = loadPrefs();
let es = null; // the live EventSource
let backoff = BACKOFF_MIN;
+ let stalls = 0; // consecutive stream rounds that produced no hello
let reconnectTimer = 0;
let statsTimer = 0;
let fullTimer = 0;
@@ -117,36 +136,68 @@
// Local media (§3.3's stored choices, §5.1's tracks)
// ---------------------------------------------------------------
+ // 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) return;
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ applyToggles();
+ return;
+ }
const exact = (id) => ({ deviceId: { exact: id } });
+ const remembered = !!(prefs.mic || prefs.camera);
const attempts = [
{ audio: prefs.mic ? exact(prefs.mic) : true, video: prefs.camera ? exact(prefs.camera) : true },
{ audio: true, video: true },
+ { audio: true, video: false },
];
- for (const constraints of attempts) {
+ for (let i = 0; i < attempts.length; i++) {
try {
- local = await navigator.mediaDevices.getUserMedia(constraints);
+ local = await navigator.mediaDevices.getUserMedia(attempts[i]);
+ if (i > 0 && remembered) forgetDevices();
break;
} catch {
- // A remembered device may be gone; the second attempt asks for
- // whatever the browser calls the default. If that fails too we
- // join with nothing, which the room is built to allow.
+ // 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 labels are fixed and the server renders them; only
- // aria-pressed moves. A toggle whose name changes under the reader
- // is announced as a different control every time it is used.
+ // 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 meet.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;
+ }
}
function stopLocal() {
@@ -190,8 +241,11 @@
state.peer = null;
state.joined = false;
state.admitted = false;
+ // `full` belongs to the participation that heard it, not to the tab.
+ state.full = false;
+ clearTimeout(fullTimer);
renderStatus();
- attach(streamPath(null), () => diagnoseFresh());
+ attach(streamPath(null), diagnoseFresh);
}
// resume reattaches to the participation we already hold. EventSource
@@ -214,8 +268,13 @@
return;
}
if (state.stopped) return;
+ if (refused(res)) return;
if (res.status === 204) {
- attach(streamPath(peer), () => later(resume));
+ // "A resume would attach" — not "a resume is free". If this
+ // round produces no hello either, the stall count is what stops
+ // the loop.
+ if (stall()) return;
+ attach(streamPath(peer), resume);
return;
}
if (res.status === 410) {
@@ -233,9 +292,12 @@
return;
}
if (res.status === 409) {
- takeoverNotice();
+ terminal('replaced');
return;
}
+ // A status the contract does not name (500, or anything a proxy
+ // invented): retry, but not for ever.
+ if (stall()) return;
later(resume);
}
@@ -253,29 +315,58 @@
return;
}
if (state.stopped) return;
+ if (refused(res)) return;
if (res.status === 409) {
- takeoverNotice();
- return;
- }
- if (res.status === 410) {
- terminal(res.data.reason || 'ended');
+ terminal('replaced');
return;
}
if (res.status === 404) {
terminal('gone');
return;
}
+ // 204 here means only "this session holds no peer in this room".
+ // The stream that follows can still be refused 503 for a full
+ // room, which the probe cannot see, so this round is counted.
+ if (stall()) return;
later(connectStream);
}
+ // refused is the two answers that mean "stop, and say why", wherever
+ // they turn up: a session that has ended, and a request the stream
+ // endpoint will not accept in the shape this client sends it.
+ function refused(res) {
+ if (res.signedOut) {
+ terminal('signedout');
+ return true;
+ }
+ if (res.status === 406) {
+ terminal('accept');
+ return true;
+ }
+ if (res.status === 400) {
+ terminal('badlast');
+ return true;
+ }
+ return false;
+ }
+
+ // stall counts consecutive rounds that ended without a `hello`. A
+ // network failure is not one of them: an unreachable server is worth
+ // waiting for, and the browser says nothing new by giving up on it.
+ function stall() {
+ stalls += 1;
+ if (stalls < MAX_STALLS) return false;
+ terminal('unreachable');
+ return true;
+ }
+
function attach(path, onFail) {
const source = new EventSource(path);
- let progressed = false;
+ const openedAt = Date.now();
es = source;
for (const type of DOWNSTREAM) {
source.addEventListener(type, (ev) => {
if (source !== es) return;
- progressed = true;
let msg;
try {
msg = JSON.parse(ev.data);
@@ -289,19 +380,12 @@
if (source !== es) return;
closeStream();
if (state.stopped) return;
- if (progressed) {
- // The stream lived and then ended: the five-minute TTL, or a
- // dropped connection. Resume at once, from a clean backoff.
- backoff = BACKOFF_MIN;
- if (state.peer) resume();
- else onFail();
- return;
- }
- // It never spoke, so the refusal is worth diagnosing — but a
- // resume that is answered 204 and then fails at the EventSource
- // must not spin, so that one waits.
- if (state.peer) later(resume);
- else onFail();
+ // Only a stream that actually lived clears the backoff. One that
+ // died moments after its hello has not proved anything, and
+ // resetting on the hello alone is how a tight reconnect loop
+ // starts.
+ if (Date.now() - openedAt > HEALTHY_STREAM) backoff = BACKOFF_MIN;
+ later(state.peer ? resume : onFail);
};
}
@@ -309,15 +393,29 @@
// No Accept header: the probe forms are exempt from the stream's
// text/event-stream gate and must not claim to be one.
const res = await fetch(path, { credentials: 'same-origin', cache: 'no-store' });
- let data = {};
- if (res.status !== 204) {
+ return await readRefusal(res, 204);
+ }
+
+ // readRefusal turns one response into the shape every caller here
+ // branches on. `emptyOK` is the one status that legitimately carries
+ // no body — 204 from the stream probe, 200 from the signal endpoint.
+ //
+ // The signed-out case needs saying: an expired session answers 303 to
+ // /signin, fetch follows it, and what comes back is a 200 carrying a
+ // sign-in page, which read as a status alone is indistinguishable
+ // from success.
+ async function readRefusal(res, emptyOK) {
+ let data = null;
+ if (res.status !== emptyOK) {
try {
data = await res.json();
} catch {
- data = {};
+ data = null;
}
}
- return { status: res.status, data: data || {} };
+ const signedOut = res.redirected || res.status === 401 || res.status === 403 ||
+ (res.status === 200 && res.status !== emptyOK && data === null);
+ return { status: res.status, data: data || {}, signedOut, retryAfter: res.headers.get('Retry-After') };
}
function later(fn) {
@@ -325,7 +423,7 @@
clearTimeout(reconnectTimer);
renderStatus();
reconnectTimer = setTimeout(fn, backoff);
- backoff = Math.min(backoff * 2, BACKOFF_MAX);
+ backoff = Math.min(backoff * 2, STREAM_BACKOFF_MAX);
}
// ---------------------------------------------------------------
@@ -353,7 +451,10 @@
}
function onHello(msg) {
- backoff = BACKOFF_MIN;
+ // A hello is the proof that this round reached the room; the
+ // backoff itself is cleared by attach, and only for a stream that
+ // then stayed open.
+ stalls = 0;
const fresh = !state.peer;
state.peer = msg.peer || null;
state.boot = msg.boot || state.boot;
@@ -374,6 +475,10 @@
renderStatus();
return;
}
+ if (res.signedOut) {
+ terminal('signedout');
+ return;
+ }
if (res.status === 404) {
recoverFromNotFound(res.data.boot);
return;
@@ -444,7 +549,9 @@
// a participation that has not been admitted. `waiting` uses
// omitempty, so an absent field is false.
if (!msg.waiting) return;
- state.joined = true;
+ // Not joined: this only ever confirms that the participation this
+ // stream reattached to has not been admitted. `joined` is the
+ // join's own answer and is not re-derived from an event.
state.admitted = false;
renderStatus();
}
@@ -521,6 +628,7 @@
// the new one. Both `roster` and `peer` come through here, so the two
// paths cannot drift.
function ensurePC(id, g) {
+ if (state.stopped) return null;
if (pcs.has(id)) {
if (g > (gen.get(id) || 0)) rebuildPC(id, g);
return pcs.get(id);
@@ -533,6 +641,7 @@
// A new generation: close the connection, discard the outbound
// queue and the candidate buffer, keep the seq counters (§4.6).
closePC(id, false);
+ if (state.stopped) return;
gen.set(id, g);
createPC(id, g);
renderTiles();
@@ -543,7 +652,15 @@
// candidates only, which connects on a permissive local network and
// usually not across the internet. Slice 3 adds GET /m/{sqid}/ice
// and fills this array from it.
- const pc = new RTCPeerConnection({ iceServers: [] });
+ let pc;
+ try {
+ pc = new RTCPeerConnection({ iceServers: [] });
+ } catch {
+ // No WebRTC at all: an old browser, or one where it is switched
+ // off. Nothing here can recover, so the room says so once.
+ terminal('nowebrtc');
+ return null;
+ }
const entry = {
pc,
// §5.1: the peer with the lower id is polite and rolls back on a
@@ -670,6 +787,7 @@
try {
// The polite peer's implicit rollback lives inside this call.
await entry.pc.setRemoteDescription(desc);
+ entry.ignoreOffer = false;
entry.descSeq = msg.seq || entry.descSeq;
await drainCandidates(entry);
await entry.pc.setLocalDescription();
@@ -688,6 +806,10 @@
if (entry.pc.signalingState !== 'have-local-offer') return;
try {
await entry.pc.setRemoteDescription(desc);
+ // The reference sets ignoreOffer on every description it applies,
+ // and an answer that lands means the collision it was set for is
+ // over: candidate failures after this point are real again.
+ entry.ignoreOffer = false;
entry.descSeq = msg.seq || entry.descSeq;
await drainCandidates(entry);
} catch {
@@ -761,6 +883,10 @@
wait = BACKOFF_MIN;
continue;
}
+ if (res.signedOut) {
+ terminal('signedout');
+ return;
+ }
if (res.status === 429) {
// Retry-After is whole seconds, and the same message is
// retried with the same seq.
@@ -786,8 +912,11 @@
}
if (res.status === 400 || res.status === 413) {
// Malformed, or over 64 KB: never retried. Drop that one
- // message and carry on with the rest of the queue.
+ // message and carry on with the rest of the queue — with the
+ // failure clock cleared, because this one was answered.
q.shift();
+ failingSince = 0;
+ wait = BACKOFF_MIN;
continue;
}
if (!failingSince) failingSince = Date.now();
@@ -806,6 +935,12 @@
}
} finally {
inflight.delete(to);
+ // A send() that arrived while this POST was in flight found the
+ // pump busy and returned; and if the queue was replaced under
+ // this loop, the loop returned rather than touching the new one.
+ // Either way what is here now has no pump, so it gets this one.
+ const rest = queue.get(to);
+ if (!state.stopped && rest && rest.length) pump(to);
}
}
@@ -835,17 +970,9 @@
body: JSON.stringify(body),
keepalive: !!keepalive,
});
- let data = {};
- if (res.status !== 200 && res.status !== 204) {
- try {
- data = await res.json();
- } catch {
- data = {};
- }
- }
- return { status: res.status, data: data || {}, retryAfter: res.headers.get('Retry-After') };
+ return await readRefusal(res, 200);
} catch {
- return { status: 0, data: {}, retryAfter: null };
+ return { status: 0, data: {}, signedOut: false, retryAfter: null };
}
}
@@ -868,8 +995,14 @@
}
// ---------------------------------------------------------------
- // Terminal states and the takeover notice (§4.5, §4.2)
+ // Terminal states (§4.5, §4.2)
// ---------------------------------------------------------------
+ //
+ // Every reason ends the call the same way — close the stream, close
+ // every connection, stop the camera — and differs only in the
+ // sentence and the way out. `replaced` is the one the server names
+ // most often; the rest of the keys below are this file's own words
+ // for a refusal it had to interpret.
const TERMINAL = {
replaced: 'This meeting is open in another tab.',
@@ -877,45 +1010,58 @@
expired: 'Your invitation to this meeting has expired.',
removed: 'Someone removed you from this meeting.',
ended: 'This meeting has ended.',
- gone: 'This meeting is no longer open.',
+ gone: 'You cannot join this meeting.',
+ signedout: 'You have been signed out.',
+ accept: 'This browser could not open the call\u2019s connection.',
+ badlast: 'This browser could not open the call\u2019s connection.',
+ unreachable: 'Meet cannot reach the call. The room may be full, or you may have been signed out.',
+ nowebrtc: 'This browser cannot make calls.',
};
function terminal(reason) {
if (state.stopped) return;
state.stopped = true;
+ state.terminal = reason;
+ // Admission ended with the participation. `peer` is kept: it names
+ // what this tab held, which is worth having in a diagnosis, and
+ // nothing reconnects with it once stopped is set.
+ state.admitted = false;
clearTimeout(reconnectTimer);
clearTimeout(fullTimer);
+ clearInterval(statsTimer);
closeStream();
closeAllPCs();
stopLocal();
renderTiles();
controlsEnabled(false);
say(TERMINAL[reason] || TERMINAL.ended);
- if (reason === 'replaced') addTakeoverLink();
- }
-
- // takeoverNotice is the 409: this session already holds a peer in the
- // room, from another tab. Nothing automatic may displace it (§4.2),
- // so this stops and offers the confirm page.
- function takeoverNotice() {
- if (state.stopped) return;
- state.stopped = true;
- clearTimeout(reconnectTimer);
- clearTimeout(fullTimer);
- closeStream();
- closeAllPCs();
- stopLocal();
- renderTiles();
- controlsEnabled(false);
- say(TERMINAL.replaced);
- addTakeoverLink();
+ wayOut(reason);
+ }
+
+ // wayOut is the rule that no terminal screen is a dead end. `replaced`
+ // gets the takeover confirm page and nothing else — it is the one
+ // sentence with a real next step, and a second link beside it would
+ // only compete with it. Being signed out is answered by signing in.
+ // Everything else goes back to the holding screen, which is where a
+ // person can look at their camera and try again.
+ function wayOut(reason) {
+ if (reason === 'replaced') {
+ link('/m/' + encodeURIComponent(sqid) + '/takeover', 'Use this tab instead');
+ return;
+ }
+ if (reason === 'signedout') {
+ link('/signin', 'Sign in again');
+ return;
+ }
+ if (reason === 'unreachable') link('/m/' + encodeURIComponent(sqid), 'Try again');
+ link('/m/' + encodeURIComponent(sqid) + '/join', 'Back to the holding screen');
}
- function addTakeoverLink() {
- const link = document.createElement('a');
- link.href = '/m/' + encodeURIComponent(sqid) + '/takeover';
- link.textContent = 'Use this tab instead';
- statusEl.append(' ', link);
+ function link(href, text) {
+ const a = document.createElement('a');
+ a.href = href;
+ a.textContent = text;
+ statusEl.append(' ', a);
}
function controlsEnabled(on) {
@@ -939,12 +1085,13 @@
say('The room is full.');
return;
}
- if (!state.peer) {
- say('Connecting to the call.');
- return;
- }
if (!state.admitted) {
- say('Waiting for someone to let you in.');
+ // A member is admitted by its own join, so between hello and the
+ // roster it is connecting, not waiting. Only a guest that has
+ // knocked is actually waiting for anyone.
+ say(state.peer && state.joined && !isMember()
+ ? 'Waiting for someone to let you in.'
+ : 'Connecting to the call.');
return;
}
const others = pcs.size;
@@ -1004,21 +1151,26 @@
retry.textContent = 'Retry';
retry.hidden = true;
retry.addEventListener('click', () => restartIce(key));
- label.append(nameEl, note, retry);
+ const play = document.createElement('button');
+ play.type = 'button';
+ play.textContent = 'Play';
+ play.hidden = true;
+ play.addEventListener('click', () => {
+ play.hidden = true;
+ startPlaying(key, t);
+ });
+ label.append(nameEl, note, retry, play);
figure.append(video, label);
tilesEl.append(figure);
- t = { figure, video, label, nameEl, note, retry, remove: null, stream: null };
+ t = { figure, video, label, nameEl, note, retry, play, remove: null, stream: null };
tiles.set(key, t);
- if (!isSelf) {
- watchFrames(key, video);
- applySink(video);
- }
+ if (!isSelf) applySink(video);
}
t.nameEl.textContent = isSelf ? name + ' (you)' : name;
// The removal control comes and goes with our own kind, which the
// roster re-states on every tick — a member demoted mid-call loses
// it without the tile being rebuilt.
- if (!isSelf && isMember() && !t.remove) {
+ if (!isSelf && isMember() && !t.remove && PEER_ID.test(key)) {
t.remove = removeLink(key);
t.label.append(t.remove);
} else if (t.remove && !isMember()) {
@@ -1030,6 +1182,13 @@
// URL, and createObjectURL is the wrong tool here anyway.
t.video.srcObject = stream || null;
t.stream = stream || null;
+ if (stream && !isSelf) {
+ // The frame counter follows the stream, not the element: a
+ // tile whose track arrives after it was drawn must still be
+ // counted, and a rebuilt pair hands the same element a new one.
+ watchFrames(key, t.video);
+ startPlaying(key, t);
+ }
}
const show = !!failed;
t.note.hidden = !show;
@@ -1037,11 +1196,24 @@
t.note.textContent = show ? 'Could not connect.' : '';
}
+ // startPlaying asks the element to play and, if the browser refuses
+ // (an autoplay policy that wants a gesture first), offers the gesture
+ // rather than leaving a still frame nobody can explain.
+ function startPlaying(key, t) {
+ const attempt = t.video.play();
+ if (!attempt || typeof attempt.catch !== 'function') return;
+ attempt.then(() => {
+ if (t.play) t.play.hidden = true;
+ }).catch(() => {
+ if (t.play) t.play.hidden = false;
+ });
+ }
+
function removeLink(id) {
// §3.5: a destructive action gets its own confirm page URL. A new
// tab, because this one is holding a call.
const link = document.createElement('a');
- link.href = '/m/' + encodeURIComponent(sqid) + '/remove/' + encodeURIComponent(id);
+ link.href = '/m/' + encodeURIComponent(sqid) + '/remove/' + id;
link.target = '_blank';
link.rel = 'noopener';
link.className = 'meet-tile-remove';
@@ -1098,6 +1270,10 @@
button.disabled = true;
// admit names its target in the payload, not in `to` (§4.3).
const res = await postSignal({ peer: state.peer, type: 'admit', payload: { peer } });
+ if (res.signedOut) {
+ terminal('signedout');
+ return;
+ }
if (res.status === 409) {
// The guest is no longer waiting: gone, or admitted by someone
// else. The knock-gone that follows removes the row anyway.
@@ -1149,13 +1325,32 @@
state.stopped = true;
clearTimeout(reconnectTimer);
clearTimeout(fullTimer);
+ clearInterval(statsTimer);
if (state.peer) postSignal({ peer: state.peer, type: 'leave' }, true);
closeStream();
closeAllPCs();
stopLocal();
}
- window.addEventListener('pagehide', leave);
+ // A page going into the back/forward cache is not a person leaving:
+ // it comes back with its DOM intact and expects the call to still be
+ // there. Only a real unload leaves. What the cache does not restore
+ // is the stream or the tracks, so pageshow rebuilds both.
+ window.addEventListener('pagehide', (e) => {
+ if (e.persisted) {
+ closeStream();
+ return;
+ }
+ leave();
+ });
+
+ window.addEventListener('pageshow', async (e) => {
+ if (!e.persisted || state.stopped || state.leaveSent) return;
+ if (!local) await startLocal();
+ renderTiles();
+ if (state.peer) resume();
+ else connectStream();
+ });
// ---------------------------------------------------------------
// The test hook (Task 9's Playwright gate)
@@ -1164,8 +1359,14 @@
const HAS_RVFC = typeof HTMLVideoElement !== 'undefined' &&
typeof HTMLVideoElement.prototype.requestVideoFrameCallback === 'function';
+ // watchFrames counts rendered frames per peer. One chain per element:
+ // a tile whose stream is replaced must not end up with two counters
+ // racing each other up.
+ const watched = new WeakSet();
+
function watchFrames(id, video) {
- if (!HAS_RVFC) return;
+ if (!HAS_RVFC || watched.has(video)) return;
+ watched.add(video);
const step = () => {
if (!video.isConnected) return;
frames.set(id, (frames.get(id) || 0) + 1);
@@ -1195,11 +1396,24 @@
window.meet = {
state() {
+ // signaling and connection are the two per-peer facts a gate
+ // cannot see from the DOM: whether negotiation settled, and
+ // whether the transport came up. Both are read straight off the
+ // connection, never cached, so they are never stale.
+ const signaling = new Map();
+ const connection = new Map();
+ for (const [id, entry] of pcs) {
+ signaling.set(id, entry.pc.signalingState);
+ connection.set(id, entry.pc.connectionState);
+ }
return {
peer: state.peer,
admitted: state.admitted,
+ terminal: state.terminal,
peers: Array.from(pcs.keys()),
frames: new Map(frames),
+ signaling,
+ connection,
};
},
};
@@ -1218,7 +1432,10 @@
connectStream();
}
- window.addEventListener('pagehide', () => clearInterval(statsTimer));
-
- start();
+ // Nothing above may throw into nowhere: an unhandled rejection here
+ // would leave the room revealed, empty and silent.
+ start().catch((err) => {
+ console.error('meet: start', err);
+ terminal('unreachable');
+ });
})();
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html
index 4afadba..363e583 100644
--- a/internal/meet/templates/join.html
+++ b/internal/meet/templates/join.html
@@ -18,8 +18,8 @@
<div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div>
</div>
<div class="meet-controls">
- <button type="button" id="toggle-mute" rst-btn aria-pressed="false">Microphone</button>
- <button type="button" id="toggle-camera" rst-btn aria-pressed="false">Camera</button>
+ <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">Microphone<span id="toggle-mute-state" class="meet-toggle-state">On</span></button>
+ <button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false">Camera<span id="toggle-camera-state" class="meet-toggle-state">On</span></button>
<button type="button" id="join" rst-btn="primary lg" disabled>Join the call</button>
</div>
<p id="status" class="meet-status" role="status"></p>
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html
index 8deaffe..e32906f 100644
--- a/internal/meet/templates/room.html
+++ b/internal/meet/templates/room.html
@@ -12,8 +12,8 @@
<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-peer="" hidden>
<div id="tiles" class="meet-grid"></div>
<div id="controls" class="meet-controls">
- <button type="button" id="mute" rst-btn>Mute</button>
- <button type="button" id="camera" rst-btn>Camera off</button>
+ <button type="button" id="mute" rst-btn aria-label="Microphone" aria-pressed="false">Microphone<span id="mute-state" class="meet-toggle-state">On</span></button>
+ <button type="button" id="camera" rst-btn aria-label="Camera" aria-pressed="false">Camera<span id="camera-state" class="meet-toggle-state">On</span></button>
<button type="button" id="leave" rst-btn="danger">Leave</button>
</div>
<div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
diff --git a/internal/meettest/holding_test.go b/internal/meettest/holding_test.go
index 32f1faf..362b9ce 100644
--- a/internal/meettest/holding_test.go
+++ b/internal/meettest/holding_test.go
@@ -130,6 +130,30 @@ func TestHoldingPageDevicesBlockStartsHidden(t *testing.T) {
}
}
+// The two toggles carry their accessible name in aria-label and their
+// state in aria-pressed and a child span, so the name a screen reader
+// tracks never changes under it and the state is never carried by
+// colour alone (AGENTS.md's UX conventions). The server renders all
+// three; devices.js only ever moves the attribute and the span's word,
+// which is why the markup is worth asserting here rather than in a
+// browser.
+func TestHoldingPageTogglesNameThemselvesAndShowTheirState(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.get("/m/" + sqid + "/join").Body.String()
+ for _, want := range []string{
+ `id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false"`,
+ `<span id="toggle-mute-state" class="meet-toggle-state">On</span>`,
+ `id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false"`,
+ `<span id="toggle-camera-state" class="meet-toggle-state">On</span>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("holding page's toggles: missing %q\n%s", want, body)
+ }
+ }
+}
+
func TestHoldingPageIs404ForEndedMeetings(t *testing.T) {
x, sqid, _ := memberWithMeeting(t)
m := x.browser(t)
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go
index b57b665..bd91cec 100644
--- a/internal/meettest/room_test.go
+++ b/internal/meettest/room_test.go
@@ -313,4 +313,18 @@ func TestRoomPageLoadsRoomScriptFromTheHeadAndHidesTheRoom(t *testing.T) {
if end < 0 || !strings.Contains(body[open:open+end], " hidden") {
t.Fatalf("#room must be rendered hidden for the scriptless floor: %q", body[open:open+end])
}
+
+ // The room's own toggles follow the holding screen's shape: a fixed
+ // accessible name in aria-label, the state in aria-pressed and in a
+ // word room.js rewrites, never in colour alone.
+ for _, want := range []string{
+ `id="mute" rst-btn aria-label="Microphone" aria-pressed="false"`,
+ `<span id="mute-state" class="meet-toggle-state">On</span>`,
+ `id="camera" rst-btn aria-label="Camera" aria-pressed="false"`,
+ `<span id="camera-state" class="meet-toggle-state">On</span>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("room page's toggles: missing %q\n%s", want, body)
+ }
+ }
}