| index 966d3de..ee0f852 100644 |
| --- a/internal/meet/static/room.js |
| +++ b/internal/meet/static/room.js |
| @@ -17,9 +17,16 @@ |
| // §5.3 renewal per connection, at the server's `renewAt`, and |
| // complete only when a new ICE username fragment |
| // is on the selected candidate pair |
| +// §5.4 screen share getDisplayMedia as one sendonly transceiver |
| +// per connection, a `share` message so peers can |
| +// label the tile, and a stop that ends the |
| +// receiver's track rather than muting it |
| // §5.5 the floor an empty list is the floor: host candidates |
| // only, and the failed tile says whether a relay |
| // was there to fail |
| +// §6 chat the broadcast lane and the panel: chat.js owns |
| +// the words and the log, this file owns the wire |
| +// and when the panel is usable |
| // |
| // House rules this file is written under: no library, no inline script |
| // (the page's CSP is `default-src 'self'`), nothing built with |
| @@ -53,8 +60,13 @@ |
| const muteButton = $('mute'); |
| const cameraButton = $('camera'); |
| const leaveButton = $('leave'); |
| + const shareButton = $('share'); |
| const muteState = $('mute-state'); |
| const cameraState = $('camera-state'); |
| + const shareState = $('share-state'); |
| + // The 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'); |
| |
| const DEVICE_KEY = 'meet.devices'; |
| const BACKOFF_MIN = 500; |
| @@ -111,6 +123,15 @@ |
| // state().ice rather than looking idle for no reason (W3-12). |
| const MIN_MARGIN = 1000; |
| const PEER_ID = /^[0-9a-f]{32}$/; |
| + // §4.3's two untargeted messages ride one queue of their own, and this |
| + // is its key: not a peer id, and it cannot collide with one, because |
| + // every peer id is thirty-two hex characters. |
| + const BROADCAST = '*'; |
| + // §5.4: the tile key a peer's screen gets, built by shareKey. |
| + const SHARE_SUFFIX = '#share'; |
| + const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' && |
| + !!navigator.mediaDevices && |
| + typeof navigator.mediaDevices.getDisplayMedia === 'function'; |
| const HAS_SINK_ID = typeof HTMLMediaElement !== 'undefined' && |
| typeof HTMLMediaElement.prototype.setSinkId === 'function'; |
| |
| @@ -175,6 +196,17 @@ |
| let pendingRosterAt = 0; // the tick that roster arrived on |
| let tick = 0; |
| let local = null; // the local MediaStream, or null |
| + // §5.4: the display capture while this tab is sharing, and nothing |
| + // when it is not. One capture, whatever the mesh's size — every |
| + // connection carries the same track on a transceiver of its own. |
| + let share = null; // {stream, track} while sharing |
| + // §6: the mounted panel, once there is one. `chatSent` counts lines |
| + // the hub accepted, not lines typed, and `chatReceived` counts lines |
| + // put in the log — the two facts a gate cannot read off the DOM |
| + // without knowing the panel's own markup. |
| + let chat = null; |
| + let chatSent = 0; |
| + let chatReceived = 0; |
| let prefs = loadPrefs(); |
| let es = null; // the live EventSource |
| let backoff = BACKOFF_MIN; |
| @@ -301,7 +333,11 @@ |
| // --------------------------------------------------------------- |
| |
| const DOWNSTREAM = ['hello', 'roster', 'peer', 'gone', 'knock', 'knock-gone', |
| - 'full', 'lost', 'pair', 'gap', 'state', 'offer', 'answer', 'candidate']; |
| + 'full', 'lost', 'pair', 'gap', 'state', 'offer', 'answer', 'candidate', |
| + // §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']; |
| |
| function streamPath(peer) { |
| let path = '/m/' + encodeURIComponent(sqid) + '/stream'; |
| @@ -601,6 +637,8 @@ |
| case 'offer': return onOffer(msg); |
| case 'answer': return onAnswer(msg); |
| case 'candidate': return onCandidate(msg); |
| + case 'chat': return onChat(msg); |
| + case 'share': return onShare(msg); |
| default: return undefined; |
| } |
| } |
| @@ -846,6 +884,11 @@ |
| names.set(w.peer, w.name || ''); |
| return { peer: w.peer, name: w.name || '' }; |
| }); |
| + // §6: a roster is admission, and admission is when the chat panel |
| + // appears and becomes usable. Mounting is idempotent, so every later |
| + // roster costs one `setEnabled(true)` and nothing else. |
| + mountChat(); |
| + chatEnabled(true); |
| renderTiles(); |
| renderQueue(); |
| renderStatus(); |
| @@ -948,6 +991,21 @@ |
| delivered: 0, |
| received: 0, |
| added: 0, |
| + // §5.4, sending: the transceiver this connection carries our |
| + // display track on, while we are sharing. |
| + shareTx: null, |
| + // §5.4, receiving: what this peer's camera came in on, and the |
| + // screen once it arrives. `cameraMid` and `cameraStream` are |
| + // recorded from the *first* video track — the camera, by |
| + // definition — and `classifyTrack` reads them to tell the second |
| + // video track apart from it. `sharing` is the `share {on:true}` |
| + // message, which usually beats the track here by a renegotiation, |
| + // and is what lets the tile carry the label before there is |
| + // anything to show in it. |
| + cameraMid: '', |
| + cameraStream: '', |
| + sharing: false, |
| + share: null, |
| }; |
| pcs.set(id, entry); |
| |
| @@ -981,9 +1039,7 @@ |
| |
| pc.addEventListener('track', (ev) => { |
| if (pcs.get(id) !== entry) return; |
| - entry.stream = (ev.streams && ev.streams[0]) || entry.stream || new MediaStream([ev.track]); |
| - if (!ev.streams || !ev.streams.length) entry.stream.addTrack(ev.track); |
| - renderTiles(); |
| + onRemoteTrack(id, entry, ev); |
| }); |
| |
| pc.addEventListener('connectionstatechange', () => { |
| @@ -1029,6 +1085,11 @@ |
| // offered to, which the other side will do if it has media. |
| } |
| } |
| + // §5.4: a connection built while this tab is already sharing gets |
| + // the screen with everything else, rather than waiting for the next |
| + // time the button is pressed. Someone joining a call mid-share sees |
| + // the screen. |
| + addShareTo(entry); |
| |
| return entry; |
| } |
| @@ -1069,6 +1130,7 @@ |
| lastIn.delete(id); |
| names.delete(id); |
| frames.delete(id); |
| + frames.delete(shareKey(id)); |
| } |
| } |
| |
| @@ -2132,6 +2194,148 @@ |
| requestReset(to); |
| } |
| |
| + // --------------------------------------------------------------- |
| + // The broadcast lane (§4.3's chat and share) |
| + // --------------------------------------------------------------- |
| + // |
| + // `send` above is §4.6: per target, with a seq, a generation and a |
| + // pair to reset when it stops working. Chat and screen share are |
| + // neither targeted nor paired — the hub fans them out to every other |
| + // admitted peer and rebuilds the envelope itself — so they ride a |
| + // queue of their own under the sentinel key '*', with the same pump |
| + // discipline and none of §4.6's bookkeeping: no `to`, no `seq`, no |
| + // `gen` on the wire, and `stale`/`gen` mean nothing here. |
| + // |
| + // The one thing this lane has that the other does not is an answer: |
| + // the chat panel echoes a line into the log only when the hub took |
| + // it, so every message resolves exactly once, and only a 200 resolves |
| + // true. |
| + |
| + // broadcastBody is the whole wire shape of an untargeted message. It |
| + // is its own function because what is *not* in it is the contract: |
| + // any `to`, `seq` or `gen` here is a 400 from /signal. |
| + function broadcastBody(peer, type, payload) { |
| + return { peer, type, payload }; |
| + } |
| + |
| + // broadcastVerdict reads one POST answer, in the wire contract's own |
| + // terms. Pure, so the node test can lift it by name. |
| + function broadcastVerdict(res) { |
| + // signedOut before the status, for the reason pump gives: an |
| + // expired session answers 303 to /signin and fetch follows it, so |
| + // the sign-in page comes back 200. |
| + if (res.signedOut) return 'signedout'; |
| + if (res.status === 200) return 'sent'; |
| + if (res.status === 429) return 'wait'; |
| + if (res.status === 404) return 'recover'; |
| + // 400 is a bug in this file, 413 is a message the server will never |
| + // take however often it is offered, and 409 belongs to the targeted |
| + // lane and cannot mean anything here. None of the three is retried. |
| + if (res.status === 400 || res.status === 413 || res.status === 409) return 'drop'; |
| + // A 5xx, a network failure, or a status the contract does not name: |
| + // worth trying again inside the deadline. |
| + return 'retry'; |
| + } |
| + |
| + // accepted is the answer the sender gets, and the only true in it is |
| + // a 200. chat.js echoes a line into the log on exactly `true` and on |
| + // nothing else, so anything looser here would put a message on the |
| + // screen that nobody received. |
| + function accepted(verdict) { |
| + return verdict === 'sent'; |
| + } |
| + |
| + // broadcast queues one untargeted message and resolves when the hub |
| + // has answered for it: true if it was accepted, false for every other |
| + // ending, including a deadline that ran out with nothing decided. |
| + function broadcast(type, payload) { |
| + if (!state.peer || state.stopped) return Promise.resolve(false); |
| + return new Promise((resolve) => { |
| + const q = queue.get(BROADCAST) || []; |
| + q.push({ body: broadcastBody(state.peer, type, payload), resolve }); |
| + queue.set(BROADCAST, q); |
| + pumpBroadcast(); |
| + }); |
| + } |
| + |
| + // pumpBroadcast is pump's discipline for the lane with no target: one |
| + // POST in flight, in order, each message with its own absolute thirty |
| + // seconds covering the requests, the backoff and the 429s. |
| + async function pumpBroadcast() { |
| + if (inflight.has(BROADCAST)) return; |
| + inflight.add(BROADCAST); |
| + let wait = BACKOFF_MIN; |
| + try { |
| + for (;;) { |
| + const q = queue.get(BROADCAST); |
| + if (!q || !q.length) return; |
| + if (state.stopped) { |
| + // Nothing more will be sent, and a sender waiting on a |
| + // promise that never settles is a chat box that never comes |
| + // back. Answer every one of them. |
| + drainBroadcast(q); |
| + return; |
| + } |
| + const head = q[0]; |
| + if (!head.deadline) head.deadline = Date.now() + SEND_DEADLINE; |
| + if (Date.now() >= head.deadline) { |
| + // Out of time with nothing decided: the sender is told so, and |
| + // the next message in the lane starts on a budget of its own. |
| + settle(q, false); |
| + wait = BACKOFF_MIN; |
| + continue; |
| + } |
| + const res = await postSignal(head.body, { deadline: head.deadline }); |
| + if (queue.get(BROADCAST) !== q) return; // the queue was dropped underneath us |
| + const verdict = broadcastVerdict(res); |
| + if (verdict === 'signedout') { |
| + drainBroadcast(q); |
| + terminal('signedout'); |
| + return; |
| + } |
| + if (verdict === 'sent' || verdict === 'drop') { |
| + settle(q, accepted(verdict)); |
| + wait = BACKOFF_MIN; |
| + continue; |
| + } |
| + if (verdict === 'recover') { |
| + // The participation is gone, or was never admitted. Nothing |
| + // queued here can be delivered by anyone, so every sender is |
| + // answered before the probe-first diagnosis decides what this |
| + // tab does next — with no target to pass it, because a |
| + // fan-out message never had one. |
| + queue.set(BROADCAST, []); |
| + drainBroadcast(q); |
| + await recoverFromNotFound(res.data.boot, null, { deadline: head.deadline }); |
| + return; |
| + } |
| + const ms = verdict === 'wait' ? retryAfter(res.retryAfter) : wait; |
| + if (!(await waitWithin(ms, head.deadline))) { |
| + settle(q, false); |
| + wait = BACKOFF_MIN; |
| + continue; |
| + } |
| + if (verdict !== 'wait') wait = Math.min(wait * 2, BACKOFF_MAX); |
| + } |
| + } finally { |
| + inflight.delete(BROADCAST); |
| + const rest = queue.get(BROADCAST); |
| + if (!state.stopped && rest && rest.length) pumpBroadcast(); |
| + } |
| + } |
| + |
| + // settle answers the message at the head of the lane and drops it. |
| + function settle(q, ok) { |
| + const head = q.shift(); |
| + if (head && head.resolve) head.resolve(ok); |
| + } |
| + |
| + // drainBroadcast answers everything still queued with false: the lane |
| + // is finished, and nothing in it was delivered. |
| + function drainBroadcast(q) { |
| + while (q.length) settle(q, false); |
| + } |
| + |
| function rosterGen(id) { |
| if (!lastRoster) return 0; |
| for (const rp of lastRoster.peers || []) if (rp.peer === id) return rp.gen || 0; |
| @@ -2386,6 +2590,309 @@ |
| connectStream(); |
| } |
| |
| + // --------------------------------------------------------------- |
| + // Chat (§6) and screen share (§5.4) |
| + // --------------------------------------------------------------- |
| + // |
| + // The panel is chat.js's: it owns the log, the composer, the two |
| + // sentences it can say and every word on a line. This file owns four |
| + // things about it — the wire underneath it, the name on the local |
| + // echo, when it appears, and when it is usable — and nothing else. |
| + // Nothing below reads inside `#chat`. |
| + // |
| + // Screen share is §5.4 in full: one display capture, carried to every |
| + // connection on a `sendonly` transceiver of its own so the camera |
| + // m-line is untouched, announced with a `share` message so the far |
| + // side can label the tile before the track lands, and stopped by |
| + // stopping the transceiver — which ends the receiver's track rather |
| + // than leaving a frozen last frame on the screen. |
| + |
| + const SHARE_REFUSED = 'Screen sharing was not allowed.'; |
| + |
| + // mountChat hands chat.js the panel and the one thing it cannot know: |
| + // how to put a line on the wire. It is called on admission and on the |
| + // first arriving message, whichever comes first — a `chat` can be |
| + // fanned out to us while §5.2's fetch still has our own roster |
| + // waiting behind it, and a message that reached this tab is a message |
| + // a person should see. |
| + // |
| + // Un-hiding is this file's job and not the panel's: `mount` never |
| + // touches `hidden`, because when a panel is usable is the host's |
| + // fact. And it is only un-hidden if chat.js is actually here — a |
| + // visible composer with no script behind it would submit its form and |
| + // navigate away from the call. |
| + function mountChat() { |
| + if (chat) return chat; |
| + if (!chatEl || !window.Chat || typeof window.Chat.mount !== 'function') return null; |
| + const panel = window.Chat.mount(chatEl, { name: selfName, send: sendChat }); |
| + if (!panel) return null; |
| + chat = panel; |
| + chatEl.hidden = false; |
| + chat.setEnabled(!!state.admitted && !state.stopped); |
| + return chat; |
| + } |
| + |
| + // chatEnabled is the panel's gate, and it moves with admission: the |
| + // roster turns it on, terminal and leave turn it off. It is not the |
| + // same thing as `hidden` — a panel that has been usable stays |
| + // readable after the call ends. |
| + function chatEnabled(on) { |
| + if (!chat) return; |
| + chat.setEnabled(!!on); |
| + } |
| + |
| + // sendChat is the panel's `send`: chat.js hands it text it has |
| + // already trimmed and measured, and reads back a strict boolean. True |
| + // is the only answer that echoes the line into the log, so anything |
| + // this file is unsure about has to be false. |
| + async function sendChat(text) { |
| + const ok = await broadcast('chat', { text }); |
| + if (ok === true) chatSent += 1; |
| + return ok === true; |
| + } |
| + |
| + // onChat renders one arrived message (§6). The name comes from the |
| + // envelope and from nowhere else: the hub put the sender's own name |
| + // on it, and a peer that is not in this tab's roster — one admitted |
| + // while our own roster was in flight — is still someone the hub says |
| + // is in the room. The text is re-validated as a string here, because |
| + // what reaches `append` reaches the DOM. |
| + function onChat(msg) { |
| + const panel = mountChat(); |
| + if (!panel) return; |
| + const text = msg.payload && msg.payload.text; |
| + if (typeof text !== 'string' || !text) return; |
| + chatReceived += 1; |
| + panel.append({ |
| + from: msg.from || '', |
| + name: msg.name || '', |
| + text, |
| + at: Date.now(), |
| + self: false, |
| + }); |
| + } |
| + |
| + // onShare is the label arriving ahead of the media, or the removal |
| + // arriving ahead of the track's own `ended` — §5.4 says whichever |
| + // comes first wins, and both endings meet in dropShare. |
| + function onShare(msg) { |
| + const from = msg.from; |
| + const entry = from ? pcs.get(from) : null; |
| + if (!entry) return; |
| + const on = !!(msg.payload && msg.payload.on === true); |
| + if (!on) { |
| + dropShare(entry); |
| + return; |
| + } |
| + entry.sharing = true; |
| + renderTiles(); |
| + } |
| + |
| + // shareKey is the tile key a peer's screen gets: the peer's own key |
| + // with a suffix that no peer id can carry, so one peer holds two |
| + // tiles and every map keyed by tile key — `tiles`, `frames` — keeps |
| + // them apart without a second structure. |
| + function shareKey(id) { |
| + return id + SHARE_SUFFIX; |
| + } |
| + |
| + // classifyTrack tells a peer's screen from its camera, which is the |
| + // one judgement §5.4's receiving half turns on. Pure, and lifted by |
| + // name in the node test. |
| + // |
| + // The camera is the first video track a peer sends: by then nothing |
| + // has been recorded, so there is nothing to be a second of. What was |
| + // recorded — the transceiver's `mid`, and the id of the stream the |
| + // track arrived in — is then what a later video track is compared |
| + // with. The `mid` is preferred: it is assigned once and survives |
| + // every renegotiation and ICE restart, where a stream id is only as |
| + // good as the sender's having named a stream at all. A second video |
| + // track that matches neither is the screen; one that matches the |
| + // camera's mid is the camera again, which is what a replaced camera |
| + // track looks like. |
| + function classifyTrack(known, kind, streamId, mid) { |
| + if (kind !== 'video') return 'audio'; |
| + if (!known.cameraMid && !known.cameraStream) return 'camera'; |
| + if (known.cameraMid && mid) return mid === known.cameraMid ? 'camera' : 'share'; |
| + if (known.cameraStream && streamId) return streamId === known.cameraStream ? 'camera' : 'share'; |
| + // Neither a mid nor a stream id to compare it with, and a camera |
| + // already known: a second video track is the screen. |
| + return 'share'; |
| + } |
| + |
| + // onRemoteTrack is the `track` handler's whole body. A camera track |
| + // goes on the peer's own tile, exactly as it always did; a screen |
| + // goes on a tile of its own and leaves the camera alone. |
| + function onRemoteTrack(id, entry, ev) { |
| + const track = ev.track; |
| + const stream = (ev.streams && ev.streams.length && ev.streams[0]) || null; |
| + const mid = (ev.transceiver && ev.transceiver.mid) || ''; |
| + const what = classifyTrack(entry, track.kind, (stream && stream.id) || '', mid); |
| + if (what === 'share') { |
| + entry.sharing = true; |
| + entry.share = { stream: stream || new MediaStream([track]), track }; |
| + if (!stream) entry.share.stream.addTrack(track); |
| + // §5.4's first ending: the sender stopped the transceiver, so |
| + // this track ends rather than freezing. The listener is dropped |
| + // with the connection, and dropShare is idempotent, so the `share |
| + // {on:false}` that usually arrives alongside it costs nothing. |
| + track.addEventListener('ended', () => { |
| + if (pcs.get(id) !== entry) return; |
| + dropShare(entry); |
| + }); |
| + renderTiles(); |
| + return; |
| + } |
| + if (what === 'camera') { |
| + // Recorded from the first video track and never revised: this is |
| + // what every later video track is judged against. |
| + if (mid) entry.cameraMid = mid; |
| + if (stream) entry.cameraStream = stream.id; |
| + } |
| + entry.stream = stream || entry.stream || new MediaStream([track]); |
| + if (!stream) entry.stream.addTrack(track); |
| + renderTiles(); |
| + } |
| + |
| + // dropShare removes one peer's screen, from either ending. The frame |
| + // count is deliberately left behind: it says the screen was really |
| + // arriving before it went away, which is exactly what a gate watching |
| + // a stop needs to be able to see afterwards. |
| + function dropShare(entry) { |
| + if (!entry.sharing && !entry.share) return; |
| + entry.sharing = false; |
| + entry.share = null; |
| + renderTiles(); |
| + } |
| + |
| + // hasShare: this peer holds a share tile. The message alone is enough |
| + // — it beats the media by a renegotiation — so the tile can carry the |
| + // label before there is anything to show in it. |
| + function hasShare(entry) { |
| + return !!(entry.sharing || entry.share); |
| + } |
| + |
| + function shareCaption(name) { |
| + return name + '’s screen'; |
| + } |
| + |
| + // startSharing is the button's on half. The capture is asked for |
| + // first: a person who changes their mind at the browser's own picker |
| + // has shared nothing, and nothing should have been announced. |
| + async function startSharing() { |
| + if (share || state.stopped || !HAS_DISPLAY_MEDIA) return; |
| + let stream; |
| + try { |
| + stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); |
| + } catch { |
| + // Refused at the picker, or not permitted by policy. One |
| + // sentence, and no diagnosis of which. |
| + say(SHARE_REFUSED); |
| + return; |
| + } |
| + const track = stream.getVideoTracks()[0]; |
| + if (!track || state.stopped) { |
| + for (const t of stream.getTracks()) t.stop(); |
| + return; |
| + } |
| + share = { stream, track }; |
| + // The browser's own "Stop sharing" bar ends the track without |
| + // telling this file anything else. §5.4 gives it the same ending as |
| + // the button. |
| + track.addEventListener('ended', () => stopSharing()); |
| + for (const entry of pcs.values()) addShareTo(entry); |
| + renderShare(); |
| + // The label, not the media: peers use it to draw the tile before |
| + // the renegotiation lands, and the track carries no name of its own. |
| + broadcast('share', { on: true }); |
| + } |
| + |
| + // addShareTo gives one connection the display track on a transceiver |
| + // of its own. `sendonly` and a stream of its own is what keeps it |
| + // clear of the camera at the far end, and adding it fires |
| + // `negotiationneeded`, so §5.1's perfect negotiation carries the |
| + // offer with no help from here. |
| + function addShareTo(entry) { |
| + if (!share || !entry || entry.shareTx) return; |
| + try { |
| + entry.shareTx = entry.pc.addTransceiver(share.track, { |
| + direction: 'sendonly', |
| + streams: [share.stream], |
| + }); |
| + } catch { |
| + // A browser without addTransceiver, or a connection already |
| + // closing: that peer simply never sees this screen, and the call |
| + // carries on. |
| + } |
| + } |
| + |
| + // stopSharing is the off half, and every way out uses it: the button, |
| + // the browser's own stop bar, and the end of the call. |
| + function stopSharing() { |
| + if (!share) return; |
| + const held = share; |
| + share = null; |
| + for (const entry of pcs.values()) removeShareFrom(entry); |
| + renderShare(); |
| + broadcast('share', { on: false }); |
| + // Last, so the transceivers are stopped and the message is queued |
| + // before the capture goes: stopping the track first would fire its |
| + // own `ended` into a path that has already run. |
| + for (const t of held.stream.getTracks()) { |
| + try { |
| + t.stop(); |
| + } catch { |
| + // Already stopped. |
| + } |
| + } |
| + } |
| + |
| + // removeShareFrom ends one connection's share m-line. `stop()` is |
| + // §5.4's own instruction and the only one that makes the receiver's |
| + // track *end*; the fallback is for a browser whose transceivers |
| + // cannot be stopped, where nulling the sender and removing it at |
| + // least stops the media and renegotiates. |
| + function removeShareFrom(entry) { |
| + const tx = entry.shareTx; |
| + if (!tx) return; |
| + entry.shareTx = null; |
| + try { |
| + if (typeof tx.stop === 'function') { |
| + tx.stop(); |
| + return; |
| + } |
| + } catch { |
| + // Fall through to the older way. |
| + } |
| + try { |
| + const sender = tx.sender; |
| + if (!sender) return; |
| + if (typeof sender.replaceTrack === 'function') { |
| + const replaced = sender.replaceTrack(null); |
| + if (replaced && typeof replaced.catch === 'function') replaced.catch(() => {}); |
| + } |
| + entry.pc.removeTrack(sender); |
| + } catch { |
| + // A connection that is closing: there is nothing left to remove. |
| + } |
| + } |
| + |
| + // renderShare moves the button's state three ways and never by |
| + // colour, exactly as the other two toggles do: aria-pressed, the word |
| + // in the button, and meet.css's inset outline. |
| + function renderShare() { |
| + if (!shareButton) return; |
| + shareButton.setAttribute('aria-pressed', String(!!share)); |
| + if (shareState) shareState.textContent = share ? 'On' : 'Off'; |
| + } |
| + |
| + // sharingPeers: the peers holding a share tile, for the gate. |
| + function sharingPeers() { |
| + const out = []; |
| + for (const [id, entry] of pcs) if (hasShare(entry)) out.push(id); |
| + return out; |
| + } |
| + |
| // --------------------------------------------------------------- |
| // Terminal states (§4.5, §4.2) |
| // --------------------------------------------------------------- |
| @@ -2424,9 +2931,16 @@ |
| closeStream(); |
| closeAllPCs(); |
| stopLocal(); |
| + // §5.4: whatever is left of a share goes with the call. The |
| + // transceivers are already closed with their connections, so this is |
| + // the capture itself and the button's own state. |
| + stopSharing(); |
| renderTiles(); |
| iceWarning(false); |
| controlsEnabled(false); |
| + // §6: nothing can be sent from here, so the composer says so. The |
| + // log stays readable — what was said is still worth reading. |
| + chatEnabled(false); |
| say(TERMINAL[reason] || TERMINAL.ended); |
| wayOut(reason); |
| } |
| @@ -2461,6 +2975,11 @@ |
| muteButton.disabled = !on; |
| cameraButton.disabled = !on; |
| leaveButton.disabled = !on; |
| + // A browser with no getDisplayMedia is a browser where Share screen |
| + // is a control that could only ever do nothing, so it is never |
| + // enabled — the same rule the two device toggles follow when there |
| + // is no track for them to move. |
| + if (shareButton) shareButton.disabled = !on || !HAS_DISPLAY_MEDIA; |
| } |
| |
| // --------------------------------------------------------------- |
| @@ -2502,7 +3021,14 @@ |
| function renderTiles() { |
| const wanted = []; |
| if (local) wanted.push('self'); |
| - for (const id of pcs.keys()) wanted.push(id); |
| + for (const [id, entry] of pcs) { |
| + wanted.push(id); |
| + // §5.4: a peer sharing a screen holds two tiles, and the second |
| + // one exists from the `share` message rather than from the track |
| + // — the message beats the media by a renegotiation, and a tile |
| + // that is about to fill is better than a grid that jumps. |
| + if (hasShare(entry)) wanted.push(shareKey(id)); |
| + } |
| for (const key of Array.from(tiles.keys())) { |
| if (!wanted.includes(key)) { |
| tiles.get(key).figure.remove(); |
| @@ -2511,7 +3037,14 @@ |
| } |
| if (local) tile('self', selfName, local, true, ''); |
| for (const [id, entry] of pcs) { |
| - tile(id, names.get(id) || 'Someone', entry.stream, false, tileNote(id, entry)); |
| + const name = names.get(id) || 'Someone'; |
| + tile(id, name, entry.stream, false, tileNote(id, entry)); |
| + // 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. |
| + if (hasShare(entry)) { |
| + tile(shareKey(id), shareCaption(name), entry.share && entry.share.stream, false, ''); |
| + } |
| } |
| renderStatus(); |
| } |
| @@ -2552,6 +3085,12 @@ |
| video.muted = true; |
| figure.classList.add('meet-tile-self'); |
| } |
| + // §5.4: a screen is not a face. The class is the one thing tile() |
| + // knows about sharing, and it exists so meet.css can fit the whole |
| + // screen into the tile instead of cropping it to fill. |
| + if (key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX) { |
| + figure.classList.add('meet-tile-share'); |
| + } |
| const label = document.createElement('figcaption'); |
| label.className = 'meet-tile-name'; |
| const nameEl = document.createElement('span'); |
| @@ -2751,6 +3290,22 @@ |
| applyToggles(); |
| }); |
| |
| + // §5.4's gesture. The handler is not async: a rejection escaping one |
| + // goes where neither a person nor a gate could see it, so the work is |
| + // a named function with a catch of its own — the same shape as the |
| + // pageshow handler's. |
| + if (shareButton) { |
| + shareButton.addEventListener('click', () => { |
| + if (share) { |
| + stopSharing(); |
| + return; |
| + } |
| + startSharing().catch((err) => { |
| + console.error('meet: share', err); |
| + }); |
| + }); |
| + } |
| + |
| leaveButton.addEventListener('click', () => { |
| leave(); |
| go('/m/' + encodeURIComponent(sqid) + '/join'); |
| @@ -2778,6 +3333,8 @@ |
| closeStream(); |
| closeAllPCs(); |
| stopLocal(); |
| + stopSharing(); |
| + chatEnabled(false); |
| } |
| |
| // A page going into the back/forward cache is not a person leaving: |
| @@ -2926,6 +3483,10 @@ |
| // no reset is asked for until a roster settles it. |
| stale: Array.from(stale), |
| peers: Array.from(pcs.keys()), |
| + // Keyed by tile, so a peer sharing a screen has two entries: its |
| + // own id and `shareKey(id)`. A share tile's count is left behind |
| + // when the tile goes, which is how a gate can tell "the screen |
| + // was arriving and then stopped" from "the screen never came". |
| frames: new Map(frames), |
| signaling, |
| connection, |
| @@ -2959,6 +3520,15 @@ |
| // fragment the proof found — a generation name, not a |
| // credential. |
| renewals: renewalState(), |
| + // §6: lines the hub accepted from this tab, and lines this tab |
| + // put in the log. Neither is readable from the panel's markup |
| + // without knowing it, and the panel is not this file's to know. |
| + chat: { sent: chatSent, received: chatReceived }, |
| + // §5.4: whether this tab is sharing, and which peers are showing |
| + // a screen here — the label counts, not just the media, because |
| + // the message is what draws the tile. |
| + sharing: !!share, |
| + shares: sharingPeers(), |
| }; |
| }, |
| }; |
| @@ -2969,6 +3539,7 @@ |
| |
| async function start() { |
| controlsEnabled(true); |
| + renderShare(); |
| // The relay warning's live region is built now, empty and hidden, |
| // rather than at the moment it has something to say: a live region |
| // inserted along with its own text is a region the reader was not |