| 1 | // Layout owns presentation only. room.js continues to own every media stream. |
| 2 | // |
| 3 | // 0.4.0: two layouts, Speaker and Grid (Speaker replaced Focus; a saved |
| 4 | // "focus" is read as Speaker). Speaker puts one tile on the stage — what |
| 5 | // the person pinned, else the newest shared screen, else whoever room.js |
| 6 | // says has the floor (#tiles' data-speaker, from speakerStep), else the |
| 7 | // first person in this viewer's order — with everything else in a |
| 8 | // filmstrip under it. Everyone, you included, can be put in this viewer's |
| 9 | // own order: dragged with a mouse or pen, or moved with the buttons each |
| 10 | // tile carries (Pin, Move earlier, Move later, Move to first), which are |
| 11 | // the keyboard's and a screen reader's way to do the same. The order is |
| 12 | // this tab's alone, kept in sessionStorage for the meeting so a rejoin |
| 13 | // in the same tab puts it back; it is never sent to anyone. The tiles are |
| 14 | // moved in the DOM itself, so reading order is the order on screen; a |
| 15 | // <video> moved within one task keeps playing. |
| 16 | // |
| 17 | // The call window (2026-09-28) adds three things the Mac's full-window |
| 18 | // call has: Grid fits every tile without scrolling (gridFit, the Mac's |
| 19 | // GridFit), the self view floats in the corner it is put in when it |
| 20 | // would be all the filmstrip holds (a call of two), and chat is a |
| 21 | // sidebar over the picture that starts closed, as the Mac's does. |
| 22 | (() => { |
| 23 | 'use strict'; |
| 24 | |
| 25 | // ---- pure rules, lifted by name in test/browser/room-layout.test.mjs ---- |
| 26 | |
| 27 | // storedLayout reads a kept layout: "grid" is Grid, and everything else |
| 28 | // — "speaker", the pre-0.4.0 "focus", nothing, nonsense — is Speaker. |
| 29 | function storedLayout(raw) { |
| 30 | return raw === 'grid' ? 'grid' : 'speaker'; |
| 31 | } |
| 32 | |
| 33 | // orderMerge takes the people here now into an order. `order` is |
| 34 | // {keys, present}: every key in order (remembered ones included), and |
| 35 | // who was here at the last merge. Someone who was here and is not now |
| 36 | // has left and is removed; a new arrival goes on the end; a remembered |
| 37 | // key that arrives takes its old place. Returns a new order. |
| 38 | function orderMerge(order, now) { |
| 39 | const here = new Set(now); |
| 40 | const left = new Set((order.present || []).filter((k) => !here.has(k))); |
| 41 | const keys = (order.keys || []).filter((k) => !left.has(k)); |
| 42 | for (const k of now) if (!keys.includes(k)) keys.push(k); |
| 43 | const present = []; |
| 44 | for (const k of now) if (keys.includes(k) && !present.includes(k)) present.push(k); |
| 45 | return { keys, present }; |
| 46 | } |
| 47 | |
| 48 | // orderShown is the people here, in order. |
| 49 | function orderShown(order) { |
| 50 | return order.keys.filter((k) => order.present.includes(k)); |
| 51 | } |
| 52 | |
| 53 | // orderMove puts `key` at `index` among the people shown (clamped), and |
| 54 | // leaves remembered absentees where they were. Returns a new order, or |
| 55 | // null when nothing moved. |
| 56 | function orderMove(order, key, index) { |
| 57 | const shown = orderShown(order); |
| 58 | const from = shown.indexOf(key); |
| 59 | if (from < 0) return null; |
| 60 | const list = shown.slice(); |
| 61 | list.splice(from, 1); |
| 62 | list.splice(Math.min(Math.max(index, 0), list.length), 0, key); |
| 63 | if (list.join('\n') === shown.join('\n')) return null; |
| 64 | let i = 0; |
| 65 | return { keys: order.keys.map((k) => (order.present.includes(k) ? list[i++] : k)), present: order.present.slice() }; |
| 66 | } |
| 67 | |
| 68 | // stageFor is Speaker view's stage, by tile key: a pin while it is |
| 69 | // still there, else the newest screen, else the speaker, else the first |
| 70 | // other person in order. `self` only by its own pin; alone, self. |
| 71 | function stageFor(pinned, speaker, others, shares, self) { |
| 72 | if (pinned && (others.includes(pinned) || shares.includes(pinned) || pinned === self)) return pinned; |
| 73 | if (shares.length) return shares[shares.length - 1]; |
| 74 | if (speaker && others.includes(speaker)) return speaker; |
| 75 | return others[0] || self || ''; |
| 76 | } |
| 77 | |
| 78 | // gridColumns is the Mac's GridFit.columns: the column count that |
| 79 | // makes `count` tiles of `aspect` (width over height) as large as they |
| 80 | // can be in width × height, `spacing` apart. A column count only wins |
| 81 | // by half a pixel or more, so a tie keeps the fewer columns. |
| 82 | function gridColumns(count, width, height, aspect, spacing) { |
| 83 | if (count <= 1 || width <= 0 || height <= 0) return 1; |
| 84 | let best = 1; |
| 85 | let bestWidth = 0; |
| 86 | for (let columns = 1; columns <= count; columns++) { |
| 87 | const rows = Math.ceil(count / columns); |
| 88 | const w = gridTileWidth(columns, rows, width, height, aspect, spacing); |
| 89 | if (w > bestWidth + 0.5) { |
| 90 | best = columns; |
| 91 | bestWidth = w; |
| 92 | } |
| 93 | } |
| 94 | return best; |
| 95 | } |
| 96 | |
| 97 | function gridTileWidth(columns, rows, width, height, aspect, spacing) { |
| 98 | const across = (width - (columns - 1) * spacing) / columns; |
| 99 | const down = (height - (rows - 1) * spacing) / rows; |
| 100 | return Math.max(0, Math.min(across, down * aspect)); |
| 101 | } |
| 102 | |
| 103 | // gridFit is the whole of Grid's geometry in a stage of width × |
| 104 | // height, as MacFullWindowCall's fullWindowGrid has it: 4px of margin |
| 105 | // and 4px between 16:9 tiles, every tile as large as fits, and no |
| 106 | // smaller than 200px wide — past that it keeps 200px tiles and |
| 107 | // scrolls. `lift` is how far a name at the bottom of the stage rises |
| 108 | // while the bar shows; the last row's names rise by as much of it as |
| 109 | // reaches over them, the grid being centred. |
| 110 | function gridFit(count, width, height, lift) { |
| 111 | const aspect = 16 / 9; |
| 112 | const w = width - 8; |
| 113 | const h = height - 8; |
| 114 | const n = Math.max(count, 1); |
| 115 | const columns = gridColumns(n, w, h, aspect, 4); |
| 116 | const fitted = gridTileWidth(columns, Math.ceil(n / columns), w, h, aspect, 4); |
| 117 | const tileW = Math.floor(fitted >= 200 ? fitted : 200); |
| 118 | const tileH = Math.floor(tileW / aspect); |
| 119 | const across = fitted >= 200 ? columns : Math.max(1, Math.floor((w + 4) / (tileW + 4))); |
| 120 | const rows = Math.ceil(n / across); |
| 121 | const gridHeight = rows * tileH + Math.max(rows - 1, 0) * 4; |
| 122 | const below = Math.max(0, (height - 8 - gridHeight) / 2) + 4; |
| 123 | return { columns: across, rows, tileW, tileH, lastRow: (rows - 1) * across, lastLift: Math.max(0, lift - below) }; |
| 124 | } |
| 125 | |
| 126 | // pipCorner is where a dropped self view settles: the quarter of the |
| 127 | // stage its centre was headed for when let go (PiPLayout.corner). |
| 128 | // `predicted` is where the drag's momentum would carry the centre, so |
| 129 | // a flick towards a corner reaches it without being dragged the whole |
| 130 | // way. |
| 131 | function pipCorner(center, predicted, width, height) { |
| 132 | const p = predicted || center; |
| 133 | return (p.y < height / 2 ? 'top-' : 'bottom-') + (p.x < width / 2 ? 'left' : 'right'); |
| 134 | } |
| 135 | |
| 136 | // ---- the page ---- |
| 137 | |
| 138 | const room = document.getElementById('room'); |
| 139 | const tiles = document.getElementById('tiles'); |
| 140 | const controls = document.getElementById('video-layout'); |
| 141 | const speakerButton = document.getElementById('layout-speaker'); |
| 142 | const gridButton = document.getElementById('layout-grid'); |
| 143 | const chat = document.getElementById('chat'); |
| 144 | const toggle = document.getElementById('toggle-chat'); |
| 145 | const overlay = document.getElementById('chat-overlay'); |
| 146 | const chatClose = document.getElementById('chat-close'); |
| 147 | const layoutToggle = document.getElementById('layout-toggle'); |
| 148 | const stageEl = document.getElementById('stage'); |
| 149 | const win = room ? room.closest('.calls-window') : null; |
| 150 | const log = document.getElementById('chat-log'); |
| 151 | const unreadLabel = document.getElementById('chat-unread'); |
| 152 | const unreadLive = document.getElementById('chat-unread-live'); |
| 153 | const layoutLive = document.getElementById('layout-live'); |
| 154 | const toolsTemplate = document.getElementById('tile-tools'); |
| 155 | const menu = document.getElementById('tile-menu'); |
| 156 | if (!room || !tiles || !controls || !chat || !toggle || !overlay) return; |
| 157 | |
| 158 | const LAYOUT_KEY = 'calls.videoLayout'; |
| 159 | // Where the floating self view was last put; kept, as the Mac keeps |
| 160 | // selfViewCorner. |
| 161 | const CORNER_KEY = 'calls.selfCorner'; |
| 162 | const ORDER_KEY = 'calls.order.' + (room.dataset.sqid || ''); |
| 163 | const reduced = () => window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 164 | |
| 165 | function load(storage, key) { |
| 166 | try { return window[storage].getItem(key); } catch { return null; } |
| 167 | } |
| 168 | function keep(storage, key, value) { |
| 169 | try { window[storage].setItem(key, value); } catch { /* private mode: this call only */ } |
| 170 | } |
| 171 | |
| 172 | let mode = storedLayout(load('localStorage', LAYOUT_KEY)); |
| 173 | if (load('localStorage', LAYOUT_KEY) === 'focus') keep('localStorage', LAYOUT_KEY, 'speaker'); |
| 174 | let order = { keys: [], present: [] }; |
| 175 | try { |
| 176 | const kept = JSON.parse(load('sessionStorage', ORDER_KEY) || '[]'); |
| 177 | if (Array.isArray(kept)) order.keys = kept.filter((k) => typeof k === 'string'); |
| 178 | } catch { /* a damaged value is no order at all */ } |
| 179 | let pinned = ''; |
| 180 | let sharesSeen = []; |
| 181 | let unread = 0; |
| 182 | // Chat starts closed, as the Mac's does: it is a sidebar over the |
| 183 | // picture, and opening it is one click (or a new message's badge). |
| 184 | let expanded = false; |
| 185 | let corner = /^(top|bottom)-(left|right)$/.test(load('localStorage', CORNER_KEY) || '') |
| 186 | ? load('localStorage', CORNER_KEY) : 'bottom-right'; |
| 187 | let pipDrag = null; |
| 188 | // The tile whose ⋯ menu is open, if any. |
| 189 | let menuFig = null; |
| 190 | let drag = null; |
| 191 | let suppressClick = false; |
| 192 | // Leaving — Leave, a reload, closing the tab — takes the tiles down |
| 193 | // one by one, which would read as everyone else leaving and wipe the |
| 194 | // order kept for the rejoin. Layout runs a task after the tiles change |
| 195 | // (below), so these are heard first. |
| 196 | let leaving = false; |
| 197 | window.addEventListener('pagehide', () => { leaving = true; }, { capture: true }); |
| 198 | document.getElementById('leave')?.addEventListener('click', () => { leaving = true; }, { capture: true }); |
| 199 | |
| 200 | const isShare = (fig) => fig.classList.contains('calls-tile-share'); |
| 201 | const isSelf = (fig) => fig.classList.contains('calls-tile-self'); |
| 202 | const figures = () => Array.from(tiles.children).filter((el) => el.tagName === 'FIGURE'); |
| 203 | const nameOf = (fig) => { |
| 204 | const span = fig.querySelector('.calls-tile-name > span'); |
| 205 | return isSelf(fig) ? 'You' : (fig.dataset.name || (span && span.textContent) || 'Someone'); |
| 206 | }; |
| 207 | |
| 208 | // stableKeys gives each person's tile the key its place is kept under: |
| 209 | // "self", or "name:" and their name — a peer id is new on every join, |
| 210 | // and the point of keeping the order is a rejoin. Two of one name are |
| 211 | // told apart by peer id, for this call only. |
| 212 | function stableKeys(people) { |
| 213 | const count = new Map(); |
| 214 | for (const fig of people) if (!isSelf(fig)) count.set(nameOf(fig), (count.get(nameOf(fig)) || 0) + 1); |
| 215 | const out = new Map(); |
| 216 | for (const fig of people) { |
| 217 | if (isSelf(fig)) out.set(fig, 'self'); |
| 218 | else { |
| 219 | const name = nameOf(fig); |
| 220 | out.set(fig, 'name:' + name + (count.get(name) > 1 ? '#' + fig.dataset.tile : '')); |
| 221 | } |
| 222 | } |
| 223 | return out; |
| 224 | } |
| 225 | |
| 226 | function say(text) { |
| 227 | if (!layoutLive || !text) return; |
| 228 | // A repeat of the same sentence is not re-read unless it changes. |
| 229 | layoutLive.textContent = layoutLive.textContent === text ? text + ' ' : text; |
| 230 | } |
| 231 | |
| 232 | function saveOrder() { |
| 233 | keep('sessionStorage', ORDER_KEY, JSON.stringify(order.keys)); |
| 234 | } |
| 235 | |
| 236 | // tools gives a tile its two buttons, once: a Pinned badge, shown |
| 237 | // only while it is pinned (pressing it unpins), and ⋯, which opens the |
| 238 | // tile's menu. Since 2026-09-29 the moves are in that menu rather than |
| 239 | // sitting on every tile. |
| 240 | function tools(fig) { |
| 241 | let box = fig.querySelector(':scope > .calls-tile-tools'); |
| 242 | if (box || !toolsTemplate) return box; |
| 243 | box = document.createElement('div'); |
| 244 | box.className = 'calls-tile-tools'; |
| 245 | box.setAttribute('role', 'group'); |
| 246 | box.append(toolsTemplate.content.cloneNode(true)); |
| 247 | box.addEventListener('click', (ev) => { |
| 248 | const button = ev.target.closest('button'); |
| 249 | if (!button) return; |
| 250 | ev.stopPropagation(); |
| 251 | if (button.classList.contains('calls-tool-more')) { |
| 252 | if (menuFig === fig) closeMenu(false); |
| 253 | else openMenu(fig, button); |
| 254 | } else if (button.dataset.act === 'pin') togglePin(fig); |
| 255 | }); |
| 256 | fig.append(box); |
| 257 | return box; |
| 258 | } |
| 259 | |
| 260 | function labelTools(fig, isPinned) { |
| 261 | const box = tools(fig); |
| 262 | if (!box) return; |
| 263 | const name = nameOf(fig); |
| 264 | box.setAttribute('aria-label', 'Arrange ' + name); |
| 265 | const pin = box.querySelector('[data-act="pin"]'); |
| 266 | if (pin) { |
| 267 | // Only on a change: `hidden` is watched below, and setting it to |
| 268 | // what it was is still a mutation that would bring layout back. |
| 269 | if (pin.hidden !== !isPinned) pin.hidden = !isPinned; |
| 270 | pin.setAttribute('aria-label', 'Unpin ' + name); |
| 271 | } |
| 272 | const more = box.querySelector('.calls-tool-more'); |
| 273 | if (more) { |
| 274 | const label = 'Options for ' + name; |
| 275 | if (more.getAttribute('aria-label') !== label) { |
| 276 | more.setAttribute('aria-label', label); |
| 277 | more.title = label; |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | // ---- the tile menu ---- |
| 283 | |
| 284 | // One menu for every tile, outside #tiles so a small filmstrip tile |
| 285 | // cannot clip it: Pin or Unpin, the three moves for a person, and |
| 286 | // Remove for a member, which is a link to its own confirm page in a |
| 287 | // new tab (room.js puts its address on the tile). |
| 288 | function fillMenu() { |
| 289 | if (!menu || !menuFig) return; |
| 290 | const fig = menuFig; |
| 291 | const name = nameOf(fig); |
| 292 | menu.setAttribute('aria-label', 'Options for ' + name); |
| 293 | const isPinned = pinned === fig.dataset.tile; |
| 294 | const pin = menu.querySelector('[data-act="pin"]'); |
| 295 | const word = isPinned ? 'Unpin' : 'Pin'; |
| 296 | if (pin.textContent !== word) pin.textContent = word; |
| 297 | pin.setAttribute('aria-label', word + ' ' + (isSelf(fig) ? 'yourself' : name)); |
| 298 | const people = figures().filter((f) => !isShare(f)); |
| 299 | const keys = stableKeys(people); |
| 300 | const place = orderShown(order).indexOf(keys.get(fig)); |
| 301 | for (const b of menu.querySelectorAll('[data-move]')) { |
| 302 | b.hidden = isShare(fig); |
| 303 | b.setAttribute('aria-label', { earlier: 'Move ' + name + ' earlier', later: 'Move ' + name + ' later', first: 'Move ' + name + ' to first' }[b.dataset.move]); |
| 304 | b.disabled = b.dataset.move === 'later' ? place >= people.length - 1 : place <= 0; |
| 305 | } |
| 306 | const remove = menu.querySelector('.calls-tile-remove'); |
| 307 | const href = fig.dataset.removeHref || ''; |
| 308 | remove.hidden = !href; |
| 309 | if (href) { |
| 310 | remove.href = href; |
| 311 | remove.setAttribute('aria-label', 'Remove ' + name + ' from the call'); |
| 312 | } else remove.removeAttribute('href'); |
| 313 | } |
| 314 | |
| 315 | function placeMenu() { |
| 316 | if (!menu || !menuFig || menu.hidden) return; |
| 317 | const button = menuFig.querySelector('.calls-tool-more'); |
| 318 | if (!button) return; |
| 319 | const r = room.getBoundingClientRect(); |
| 320 | const b = button.getBoundingClientRect(); |
| 321 | const w = menu.offsetWidth; |
| 322 | const h = menu.offsetHeight; |
| 323 | let top = b.bottom - r.top + 6; |
| 324 | if (top + h > r.height - 8) top = Math.max(8, b.top - r.top - h - 6); |
| 325 | const left = Math.min(Math.max(b.right - r.left - w, 8), r.width - w - 8); |
| 326 | menu.style.setProperty('--calls-menu-x', Math.round(left) + 'px'); |
| 327 | menu.style.setProperty('--calls-menu-y', Math.round(top) + 'px'); |
| 328 | } |
| 329 | |
| 330 | function openMenu(fig, button) { |
| 331 | if (menuFig) closeMenu(false); |
| 332 | menuFig = fig; |
| 333 | fillMenu(); |
| 334 | menu.hidden = false; |
| 335 | button.setAttribute('aria-expanded', 'true'); |
| 336 | fig.classList.add('calls-tile-menu-open'); |
| 337 | placeMenu(); |
| 338 | const first = menu.querySelector('button:not([hidden]):not([disabled]), a:not([hidden])'); |
| 339 | if (first) first.focus({ preventScroll: true }); |
| 340 | } |
| 341 | |
| 342 | function closeMenu(refocus) { |
| 343 | if (!menuFig) return; |
| 344 | const fig = menuFig; |
| 345 | menuFig = null; |
| 346 | menu.hidden = true; |
| 347 | fig.classList.remove('calls-tile-menu-open'); |
| 348 | const button = fig.querySelector('.calls-tool-more'); |
| 349 | if (button) { |
| 350 | button.setAttribute('aria-expanded', 'false'); |
| 351 | if (refocus && fig.isConnected) button.focus({ preventScroll: true }); |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | if (menu) { |
| 356 | menu.addEventListener('click', (ev) => { |
| 357 | const item = ev.target.closest('button, a'); |
| 358 | if (!item || !menuFig) return; |
| 359 | const fig = menuFig; |
| 360 | if (item.classList.contains('calls-tile-remove')) { |
| 361 | closeMenu(false); |
| 362 | return; |
| 363 | } |
| 364 | ev.preventDefault(); |
| 365 | if (item.dataset.act === 'pin') { |
| 366 | closeMenu(true); |
| 367 | togglePin(fig); |
| 368 | } else if (item.dataset.move) moveBy(fig, item.dataset.move, item); |
| 369 | }); |
| 370 | menu.addEventListener('keydown', (ev) => { |
| 371 | if (ev.key === 'Escape') { |
| 372 | ev.preventDefault(); |
| 373 | closeMenu(true); |
| 374 | } |
| 375 | }); |
| 376 | document.addEventListener('pointerdown', (ev) => { |
| 377 | if (!menuFig || menu.contains(ev.target)) return; |
| 378 | const more = menuFig.querySelector('.calls-tool-more'); |
| 379 | if (more && more.contains(ev.target)) return; |
| 380 | closeMenu(false); |
| 381 | }, true); |
| 382 | window.addEventListener('resize', placeMenu); |
| 383 | } |
| 384 | |
| 385 | function togglePin(fig) { |
| 386 | const key = fig.dataset.tile; |
| 387 | if (pinned === key) { |
| 388 | pinned = ''; |
| 389 | say('Unpinned. The stage follows whoever is speaking.'); |
| 390 | } else { |
| 391 | pinned = key; |
| 392 | if (mode !== 'speaker') setMode('speaker'); |
| 393 | say('Pinned ' + (isSelf(fig) ? 'yourself' : nameOf(fig)) + ' to the stage'); |
| 394 | } |
| 395 | layout(); |
| 396 | } |
| 397 | |
| 398 | function moveBy(fig, how, button) { |
| 399 | const people = figures().filter((f) => !isShare(f)); |
| 400 | const keys = stableKeys(people); |
| 401 | const key = keys.get(fig); |
| 402 | const shown = orderShown(order); |
| 403 | const at = shown.indexOf(key); |
| 404 | const to = how === 'first' ? 0 : how === 'earlier' ? at - 1 : at + 1; |
| 405 | const next = orderMove(order, key, to); |
| 406 | if (!next) return; |
| 407 | order = next; |
| 408 | saveOrder(); |
| 409 | layout(); |
| 410 | const place = orderShown(order).indexOf(key); |
| 411 | say(nameOf(fig) + ' moved to position ' + (place + 1) + ' of ' + people.length); |
| 412 | // The menu stays open on the tile, which has moved: keep the |
| 413 | // keyboard on the move, or on the one that is still possible. |
| 414 | if (button) { |
| 415 | const again = button.disabled ? menu.querySelector('[data-move]:not([disabled])') : button; |
| 416 | if (again) again.focus({ preventScroll: true }); |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | function setMode(next) { |
| 421 | mode = next; |
| 422 | keep('localStorage', LAYOUT_KEY, mode); |
| 423 | } |
| 424 | |
| 425 | // place puts the figures in `want` order with the fewest moves, and the |
| 426 | // keyboard back where it was if a move took it away. |
| 427 | function place(want) { |
| 428 | const active = document.activeElement; |
| 429 | let i = 0; |
| 430 | for (const fig of want) { |
| 431 | const here = tiles.children[i]; |
| 432 | if (here !== fig) tiles.insertBefore(fig, here || null); |
| 433 | i++; |
| 434 | } |
| 435 | if (active && active !== document.activeElement && tiles.contains(active)) active.focus(); |
| 436 | } |
| 437 | |
| 438 | function layout() { |
| 439 | tiles.dataset.layout = mode; |
| 440 | if (tiles.dataset.corner !== corner) tiles.dataset.corner = corner; |
| 441 | speakerButton.setAttribute('aria-pressed', String(mode === 'speaker')); |
| 442 | gridButton.setAttribute('aria-pressed', String(mode === 'grid')); |
| 443 | if (layoutToggle) layoutToggle.dataset.mode = mode; |
| 444 | const all = figures(); |
| 445 | const shares = all.filter(isShare); |
| 446 | const people = all.filter((f) => !isShare(f)); |
| 447 | // Screens in the order they arrived, newest last. |
| 448 | const shareKeys = shares.map((f) => f.dataset.tile); |
| 449 | sharesSeen = sharesSeen.filter((k) => shareKeys.includes(k)); |
| 450 | for (const k of shareKeys) if (!sharesSeen.includes(k)) sharesSeen.push(k); |
| 451 | const keys = stableKeys(people); |
| 452 | // Without our own tile we are not in the call — leaving, reloading, |
| 453 | // or not yet in — and the tiles going is not everyone else leaving: |
| 454 | // the order is left as it was kept, for the rejoin. |
| 455 | if (people.some(isSelf) && !leaving) { |
| 456 | const merged = orderMerge(order, people.map((f) => keys.get(f))); |
| 457 | if (merged.keys.join('\n') !== order.keys.join('\n')) { |
| 458 | order = merged; |
| 459 | saveOrder(); |
| 460 | } else order = merged; |
| 461 | } |
| 462 | const byKey = new Map(people.map((f) => [keys.get(f), f])); |
| 463 | const ordered = orderShown(order).map((k) => byKey.get(k)).filter(Boolean); |
| 464 | const byTile = new Map(all.map((f) => [f.dataset.tile, f])); |
| 465 | const sharesNewest = sharesSeen.slice().reverse().map((k) => byTile.get(k)).filter(Boolean); |
| 466 | if (!drag) place(sharesNewest.concat(ordered)); |
| 467 | |
| 468 | if (pinned && !byTile.has(pinned)) pinned = ''; |
| 469 | const self = people.find(isSelf); |
| 470 | const others = ordered.filter((f) => !isSelf(f)).map((f) => f.dataset.tile); |
| 471 | const stageKey = mode === 'speaker' |
| 472 | ? stageFor(pinned, tiles.dataset.speaker || '', others, sharesSeen, self ? self.dataset.tile : '') |
| 473 | : ''; |
| 474 | const stage = byTile.get(stageKey) || null; |
| 475 | // You float over the stage only when you would be all the filmstrip |
| 476 | // holds — a call of two — and only over a face with nothing of its |
| 477 | // own to say (a recovery note, a Retry or Play, a screen). |
| 478 | const strip = all.filter((f) => f !== stage); |
| 479 | const pip = !!stage && strip.length === 1 && strip[0] === self && !isShare(stage) && !isSelf(stage) && |
| 480 | !stage.querySelector('.calls-tile-note:not([hidden]), figcaption button:not([hidden])'); |
| 481 | tiles.toggleAttribute('data-pip', pip); |
| 482 | for (const fig of all) { |
| 483 | fig.classList.toggle('calls-tile-pip', pip && fig === self); |
| 484 | const onStage = fig === stage; |
| 485 | fig.toggleAttribute('data-stage', onStage); |
| 486 | fig.toggleAttribute('data-pinned', onStage && pinned === fig.dataset.tile); |
| 487 | let note = fig.querySelector(':scope > .calls-stage-note'); |
| 488 | if (onStage && !note) { |
| 489 | note = document.createElement('span'); |
| 490 | note.className = 'calls-sr-only calls-stage-note'; |
| 491 | note.textContent = 'On stage:'; |
| 492 | fig.prepend(note); |
| 493 | } else if (!onStage && note) note.remove(); |
| 494 | labelTools(fig, pinned === fig.dataset.tile); |
| 495 | } |
| 496 | fitGrid(); |
| 497 | if (menuFig) { |
| 498 | if (!menuFig.isConnected) closeMenu(false); |
| 499 | else { |
| 500 | fillMenu(); |
| 501 | placeMenu(); |
| 502 | } |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | // fitGrid writes gridFit's answer for the stage as it is now: the |
| 507 | // column count and tile size, which tiles are in the last row, and |
| 508 | // how far their names rise while the bar shows. |
| 509 | function fitGrid() { |
| 510 | if (!stageEl) return; |
| 511 | if (mode !== 'grid') { |
| 512 | for (const fig of figures()) fig.removeAttribute('data-last-row'); |
| 513 | return; |
| 514 | } |
| 515 | const all = figures(); |
| 516 | const reserve = win ? parseFloat(getComputedStyle(win).getPropertyValue('--calls-bar-reserve')) || 80 : 80; |
| 517 | const fit = gridFit(all.length, stageEl.clientWidth, stageEl.clientHeight, reserve - 8); |
| 518 | const set = (name, value) => { |
| 519 | if (tiles.style.getPropertyValue(name) !== value) tiles.style.setProperty(name, value); |
| 520 | }; |
| 521 | set('--calls-cols', String(fit.columns)); |
| 522 | set('--calls-tile-w', fit.tileW + 'px'); |
| 523 | set('--calls-tile-h', fit.tileH + 'px'); |
| 524 | set('--calls-grid-lift', fit.lastLift + 'px'); |
| 525 | all.forEach((fig, i) => fig.toggleAttribute('data-last-row', i >= fit.lastRow)); |
| 526 | } |
| 527 | if (stageEl && window.ResizeObserver) new ResizeObserver(() => fitGrid()).observe(stageEl); |
| 528 | |
| 529 | // ---- pointer drag ---- |
| 530 | |
| 531 | function personAt(x, y, except) { |
| 532 | return figures().find((f) => f !== except && !isShare(f) && (() => { |
| 533 | const r = f.getBoundingClientRect(); |
| 534 | return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; |
| 535 | })()); |
| 536 | } |
| 537 | |
| 538 | function follow(ev) { |
| 539 | const fig = drag.fig; |
| 540 | fig.style.translate = ''; |
| 541 | const r = fig.getBoundingClientRect(); |
| 542 | fig.style.translate = (ev.clientX - drag.grabX - r.left) + 'px ' + (ev.clientY - drag.grabY - r.top) + 'px'; |
| 543 | } |
| 544 | |
| 545 | // The floating self view is not reordered by a drag: it is moved, and |
| 546 | // settles in the corner it was headed for (MacFullWindowCall's |
| 547 | // selfView). Four pixels of movement make it a drag; less is a click. |
| 548 | tiles.addEventListener('pointerdown', (ev) => { |
| 549 | const fig = ev.target.closest('#tiles > figure.calls-tile-pip'); |
| 550 | if (!fig || ev.button !== 0 || ev.target.closest('button, a, summary, details, input, select')) return; |
| 551 | pipDrag = { fig, id: ev.pointerId, x: ev.clientX, y: ev.clientY, moving: false, last: [] }; |
| 552 | }, true); |
| 553 | tiles.addEventListener('pointermove', (ev) => { |
| 554 | if (!pipDrag || ev.pointerId !== pipDrag.id) return; |
| 555 | const dx = ev.clientX - pipDrag.x; |
| 556 | const dy = ev.clientY - pipDrag.y; |
| 557 | if (!pipDrag.moving) { |
| 558 | if (Math.hypot(dx, dy) < 4) return; |
| 559 | pipDrag.moving = true; |
| 560 | pipDrag.fig.classList.add('calls-tile-dragging'); |
| 561 | try { pipDrag.fig.setPointerCapture(ev.pointerId); } catch { /* released already */ } |
| 562 | } |
| 563 | pipDrag.last.push({ x: ev.clientX, y: ev.clientY, t: ev.timeStamp }); |
| 564 | if (pipDrag.last.length > 5) pipDrag.last.shift(); |
| 565 | pipDrag.fig.style.translate = dx + 'px ' + dy + 'px'; |
| 566 | ev.stopImmediatePropagation(); |
| 567 | }, true); |
| 568 | function pipDrop(ev) { |
| 569 | if (!pipDrag || ev.pointerId !== pipDrag.id) return; |
| 570 | const { fig, moving, last } = pipDrag; |
| 571 | pipDrag = null; |
| 572 | if (!moving) return; |
| 573 | ev.stopImmediatePropagation(); |
| 574 | suppressClick = true; |
| 575 | const r = fig.getBoundingClientRect(); |
| 576 | const s = stageEl.getBoundingClientRect(); |
| 577 | const center = { x: r.left + r.width / 2 - s.left, y: r.top + r.height / 2 - s.top }; |
| 578 | let predicted = null; |
| 579 | if (last.length >= 2) { |
| 580 | const a = last[0]; |
| 581 | const b = last[last.length - 1]; |
| 582 | const dt = Math.max(b.t - a.t, 1); |
| 583 | // Where a quarter of a second more at the release speed would |
| 584 | // have carried it. |
| 585 | predicted = { x: center.x + (b.x - a.x) / dt * 250, y: center.y + (b.y - a.y) / dt * 250 }; |
| 586 | } |
| 587 | corner = pipCorner(center, predicted, s.width, s.height); |
| 588 | keep('localStorage', CORNER_KEY, corner); |
| 589 | fig.classList.remove('calls-tile-dragging'); |
| 590 | fig.style.translate = ''; |
| 591 | layout(); |
| 592 | } |
| 593 | tiles.addEventListener('pointerup', pipDrop, true); |
| 594 | tiles.addEventListener('pointercancel', pipDrop, true); |
| 595 | |
| 596 | tiles.addEventListener('pointerdown', (ev) => { |
| 597 | if (ev.button !== 0 || ev.pointerType === 'touch') return; |
| 598 | const fig = ev.target.closest('#tiles > figure'); |
| 599 | if (fig && fig.classList.contains('calls-tile-pip')) return; |
| 600 | if (!fig || isShare(fig) || ev.target.closest('button, a, summary, details, input, select')) return; |
| 601 | const r = fig.getBoundingClientRect(); |
| 602 | drag = { fig, id: ev.pointerId, x: ev.clientX, y: ev.clientY, grabX: ev.clientX - r.left, grabY: ev.clientY - r.top, moving: false }; |
| 603 | }); |
| 604 | tiles.addEventListener('pointermove', (ev) => { |
| 605 | if (!drag || ev.pointerId !== drag.id) return; |
| 606 | if (!drag.moving) { |
| 607 | if (Math.hypot(ev.clientX - drag.x, ev.clientY - drag.y) < 6) return; |
| 608 | drag.moving = true; |
| 609 | drag.fig.classList.add('calls-tile-lifted'); |
| 610 | try { drag.fig.setPointerCapture(ev.pointerId); } catch { /* released already */ } |
| 611 | } |
| 612 | const target = personAt(ev.clientX, ev.clientY, drag.fig); |
| 613 | if (target) { |
| 614 | const people = figures().filter((f) => !isShare(f)); |
| 615 | const keys = stableKeys(people); |
| 616 | const next = orderMove(order, keys.get(drag.fig), orderShown(order).indexOf(keys.get(target))); |
| 617 | if (next) { |
| 618 | order = next; |
| 619 | // Into the target's place: after it when coming from before it. |
| 620 | const before = people.indexOf(drag.fig) < people.indexOf(target) ? target.nextSibling : target; |
| 621 | tiles.insertBefore(drag.fig, before); |
| 622 | } |
| 623 | } |
| 624 | follow(ev); |
| 625 | }); |
| 626 | function drop(ev) { |
| 627 | if (!drag || (ev && ev.pointerId !== drag.id)) return; |
| 628 | const { fig, moving } = drag; |
| 629 | drag = null; |
| 630 | if (!moving) return; |
| 631 | suppressClick = true; |
| 632 | fig.classList.remove('calls-tile-lifted'); |
| 633 | fig.style.translate = ''; |
| 634 | saveOrder(); |
| 635 | layout(); |
| 636 | const keys = stableKeys(figures().filter((f) => !isShare(f))); |
| 637 | say(nameOf(fig) + ' moved to position ' + (orderShown(order).indexOf(keys.get(fig)) + 1) + ' of ' + keys.size); |
| 638 | } |
| 639 | tiles.addEventListener('pointerup', drop); |
| 640 | tiles.addEventListener('pointercancel', drop); |
| 641 | |
| 642 | // A click on a filmstrip tile pins it; on the pinned stage, unpins. |
| 643 | tiles.addEventListener('click', (ev) => { |
| 644 | if (suppressClick) { |
| 645 | suppressClick = false; |
| 646 | return; |
| 647 | } |
| 648 | if (mode !== 'speaker') return; |
| 649 | const fig = ev.target.closest('#tiles > figure'); |
| 650 | if (!fig || ev.target.closest('button, a, summary, details, input, select')) return; |
| 651 | if (fig.classList.contains('calls-tile-pip')) return; |
| 652 | if (fig.hasAttribute('data-stage') && !fig.hasAttribute('data-pinned')) return; |
| 653 | togglePin(fig); |
| 654 | }); |
| 655 | |
| 656 | function chatState() { |
| 657 | // Mount readiness belongs to room.js; hiding the visual sidebar never |
| 658 | // changes its hidden attribute, draft, admission or send state. |
| 659 | toggle.hidden = chat.hidden; |
| 660 | const open = expanded && !chat.hidden; |
| 661 | if (overlay.hidden === open) overlay.hidden = !open; |
| 662 | chat.classList.toggle('calls-chat-collapsed', !expanded); |
| 663 | toggle.setAttribute('aria-expanded', String(open)); |
| 664 | toggle.setAttribute('aria-label', unread ? `Chat (${unread} new)` : 'Chat'); |
| 665 | toggle.dataset.tip = open ? 'Hide chat' : unread ? `Chat (${unread} new)` : 'Show chat'; |
| 666 | unreadLabel.textContent = unread ? String(unread) : ''; |
| 667 | if (unreadLive) { |
| 668 | // Only while collapsed, and only the count. Assigning the same |
| 669 | // string twice would re-announce it, so the guard is on the value |
| 670 | // and not on the event that got us here. |
| 671 | const said = !expanded && unread |
| 672 | ? (unread === 1 ? '1 new chat message' : unread + ' new chat messages') |
| 673 | : ''; |
| 674 | if (unreadLive.textContent !== said) unreadLive.textContent = said; |
| 675 | } |
| 676 | } |
| 677 | speakerButton.addEventListener('click', () => { setMode('speaker'); layout(); }); |
| 678 | gridButton.addEventListener('click', () => { setMode('grid'); layout(); }); |
| 679 | toggle.addEventListener('click', () => { |
| 680 | const inside = overlay.contains(document.activeElement); |
| 681 | expanded = !expanded; |
| 682 | if (expanded) unread = 0; |
| 683 | chatState(); |
| 684 | if (expanded) document.getElementById('chat-text')?.focus({ preventScroll: true }); |
| 685 | else if (inside) toggle.focus(); |
| 686 | }); |
| 687 | // The sidebar's own close, and Escape inside it: the same as the bar's |
| 688 | // button, and the keyboard goes back to that button. |
| 689 | chatClose?.addEventListener('click', () => toggle.click()); |
| 690 | overlay.addEventListener('keydown', (ev) => { |
| 691 | if (ev.key !== 'Escape' || !expanded) return; |
| 692 | ev.preventDefault(); |
| 693 | toggle.click(); |
| 694 | }); |
| 695 | // Our own moves are child-list changes too; layout() finds the order |
| 696 | // already right and moves nothing, so this settles at once. |
| 697 | // |
| 698 | // A task later rather than at once: room.js takes the tiles down inside |
| 699 | // its own pagehide listener, and the observer would run between that |
| 700 | // and ours; by the time a timer fires, `leaving` has been set. |
| 701 | // |
| 702 | // And never later than the next frame: a task alone let the browser |
| 703 | // paint the tiles once in their unplaced places first — a lone self |
| 704 | // tile drawn at filmstrip size, then the whole stage — which was a |
| 705 | // layout shift of 0.43 to 0.80 on joining (shared chrome R2, "the room |
| 706 | // layout reserved up front"). Whichever of the timer and the frame |
| 707 | // comes first lays out; both still come after the pagehide listeners. |
| 708 | let pending = false; |
| 709 | let pendingTimer = 0; |
| 710 | let pendingFrame = 0; |
| 711 | const settle = () => { |
| 712 | if (!pending) return; |
| 713 | pending = false; |
| 714 | clearTimeout(pendingTimer); |
| 715 | cancelAnimationFrame(pendingFrame); |
| 716 | if (!drag || !drag.moving) layout(); |
| 717 | }; |
| 718 | new MutationObserver(() => { |
| 719 | if (pending || (drag && drag.moving)) return; |
| 720 | pending = true; |
| 721 | pendingTimer = setTimeout(settle, 0); |
| 722 | pendingFrame = requestAnimationFrame(settle); |
| 723 | }) |
| 724 | .observe(tiles, {childList: true, subtree: true, attributes: true, attributeFilter: ['hidden', 'data-speaker']}); |
| 725 | new MutationObserver(chatState).observe(chat, {attributes: true, attributeFilter: ['hidden']}); |
| 726 | if (log) new MutationObserver(records => { |
| 727 | if (!expanded) { |
| 728 | for (const record of records) { |
| 729 | for (const node of record.addedNodes) { |
| 730 | if (node.nodeType === 1 && !node.classList.contains('chat-line-self')) unread++; |
| 731 | } |
| 732 | } |
| 733 | chatState(); |
| 734 | } |
| 735 | }).observe(log, {childList: true}); |
| 736 | controls.hidden = false; |
| 737 | // For the gate and for a look from the console: what this tab has |
| 738 | // arranged. Presentation only; nothing here is sent anywhere. |
| 739 | window.callsLayout = { |
| 740 | state: () => ({ mode, pinned, order: orderShown(order), keys: order.keys.slice(), speaker: tiles.dataset.speaker || '', |
| 741 | corner, chat: expanded && !chat.hidden }), |
| 742 | reduced, |
| 743 | }; |
| 744 | layout(); |
| 745 | chatState(); |
| 746 | })(); |
| 747 | |