oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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