| 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'); |
| + }); |
| })(); |