oficina / meet Public

Merge branch 'feat/speaker-view-and-reorder' into main

Speaker view (replacing Focus), pinning, and the viewer's own order of
participants on Mac, iPhone/iPad and web. Calls 0.4.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 583d5940d3b996ac8843c93fe83c5aba31f710a5 parent 869b2f2parent c8fcad8
31 files changed, +2811 −388
  • Makefile +1 −0
  • internal/calls/icons/icons.go +8 −2
  • internal/calls/static/call-layout.js +391 −15
  • internal/calls/static/calls.css +68 −7
  • internal/calls/static/room.js +65 −0
  • internal/calls/templates/room.html +10 −1
  • internal/callstest/browser_test.go +1 −0
  • native/DESIGN.md +81 −2
  • native/README.md +5 −4
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +38 −0
  • native/apple/Sources/CallsInterop/Interop.swift +6 −0
  • native/apple/Sources/CallsKit/ArrangeTiles.swift +249 −0
  • native/apple/Sources/CallsKit/AudioMeter.swift +11 −1
  • native/apple/Sources/CallsKit/Call.swift +6 −1
  • native/apple/Sources/CallsKit/CallArrangement.swift +183 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +143 −98
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +82 −62
  • native/apple/Sources/CallsKit/MacVideoTile.swift +13 −3
  • native/apple/Sources/CallsKit/MiniWindow.swift +14 −9
  • native/apple/Sources/CallsKit/ScreenShare.swift +2 −21
  • native/apple/Sources/CallsKit/SpeakerView.swift +349 −0
  • native/apple/Tests/CallsKitTests/ScreenShareTests.swift +14 −10
  • native/apple/Tests/CallsKitTests/SpeakerViewTests.swift +370 −0
  • native/apple/Tests/CallsKitTests/WindowMoveTests.swift +30 −1
  • native/ios/Sources/CallsPhone.swift +124 −68
  • native/ios/Sources/PhoneCallFixture.swift +11 −2
  • native/ios/Sources/PhoneFullScreenCall.swift +96 −76
  • native/ios/UITests/CallsPhoneUITests.swift +84 −0
  • native/ios/project.yml +1 −1
  • test/browser/call.mjs +187 −4
  • test/browser/room-layout.test.mjs +168 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
ok  	amadan.net/oficina/calls/callscore	(cached)
?   	amadan.net/oficina/calls/cmd/calls	[no test files]
ok  	amadan.net/oficina/calls/internal/calls	1.504s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	8.626s
?   	amadan.net/oficina/calls/protocol	[no test files]
=== migration-check ===
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
=== race ===
CGO_ENABLED=1 go test -race ./internal/...
ok  	amadan.net/oficina/calls/internal/calls	13.179s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	30.876s
diff --git a/Makefile b/Makefile
index f14d226..4b9a1ed 100644
--- a/Makefile
+++ b/Makefile
@@ -151,6 +151,7 @@ browser:
node --test test/browser/room-space.test.mjs
node --test test/browser/room-hearing.test.mjs
node --test test/browser/room-glow.test.mjs
+ node --test test/browser/room-layout.test.mjs
node --test test/browser/room-lane.test.mjs
node --test test/browser/media.test.mjs
node --test test/browser/audio.test.mjs
diff --git a/internal/calls/icons/icons.go b/internal/calls/icons/icons.go
index a1f5590..11a700c 100644
--- a/internal/calls/icons/icons.go
+++ b/internal/calls/icons/icons.go
@@ -44,8 +44,14 @@ var icons = map[string]template.HTML{
"menu": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
"plus": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></svg>`,
"search": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
- "x": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
- "x-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`,
+ // Speaker view's tile buttons (0.4.0): Pin, and Move to first,
+ // earlier and later.
+ "pin": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>`,
+ "arrow-left": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>`,
+ "arrow-right": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>`,
+ "arrow-left-to-line": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M3 19V5"/><path d="m13 6-6 6 6 6"/><path d="M7 12h14"/></svg>`,
+ "x": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
+ "x-circle": `<svg class="icon" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`,
}
// Icon renders one icon by its Lucide slug. An unknown slug renders
diff --git a/internal/calls/static/call-layout.js b/internal/calls/static/call-layout.js
index f807540..948226a 100644
--- a/internal/calls/static/call-layout.js
+++ b/internal/calls/static/call-layout.js
@@ -1,33 +1,389 @@
// Layout owns presentation only. room.js continues to own every media stream.
+//
+// 0.4.0: two layouts, Speaker and Grid (Speaker replaced Focus; a saved
+// "focus" is read as Speaker). Speaker puts one tile on the stage — what
+// the person pinned, else the newest shared screen, else whoever room.js
+// says has the floor (#tiles' data-speaker, from speakerStep), else the
+// first person in this viewer's order — with everything else in a
+// filmstrip under it. Everyone, you included, can be put in this viewer's
+// own order: dragged with a mouse or pen, or moved with the buttons each
+// tile carries (Pin, Move earlier, Move later, Move to first), which are
+// the keyboard's and a screen reader's way to do the same. The order is
+// this tab's alone, kept in sessionStorage for the meeting so a rejoin
+// in the same tab puts it back; it is never sent to anyone. The tiles are
+// moved in the DOM itself, so reading order is the order on screen; a
+// <video> moved within one task keeps playing.
(() => {
'use strict';
+
+ // ---- pure rules, lifted by name in test/browser/room-layout.test.mjs ----
+
+ // storedLayout reads a kept layout: "grid" is Grid, and everything else
+ // — "speaker", the pre-0.4.0 "focus", nothing, nonsense — is Speaker.
+ function storedLayout(raw) {
+ return raw === 'grid' ? 'grid' : 'speaker';
+ }
+
+ // orderMerge takes the people here now into an order. `order` is
+ // {keys, present}: every key in order (remembered ones included), and
+ // who was here at the last merge. Someone who was here and is not now
+ // has left and is removed; a new arrival goes on the end; a remembered
+ // key that arrives takes its old place. Returns a new order.
+ function orderMerge(order, now) {
+ const here = new Set(now);
+ const left = new Set((order.present || []).filter((k) => !here.has(k)));
+ const keys = (order.keys || []).filter((k) => !left.has(k));
+ for (const k of now) if (!keys.includes(k)) keys.push(k);
+ const present = [];
+ for (const k of now) if (keys.includes(k) && !present.includes(k)) present.push(k);
+ return { keys, present };
+ }
+
+ // orderShown is the people here, in order.
+ function orderShown(order) {
+ return order.keys.filter((k) => order.present.includes(k));
+ }
+
+ // orderMove puts `key` at `index` among the people shown (clamped), and
+ // leaves remembered absentees where they were. Returns a new order, or
+ // null when nothing moved.
+ function orderMove(order, key, index) {
+ const shown = orderShown(order);
+ const from = shown.indexOf(key);
+ if (from < 0) return null;
+ const list = shown.slice();
+ list.splice(from, 1);
+ list.splice(Math.min(Math.max(index, 0), list.length), 0, key);
+ if (list.join('\n') === shown.join('\n')) return null;
+ let i = 0;
+ return { keys: order.keys.map((k) => (order.present.includes(k) ? list[i++] : k)), present: order.present.slice() };
+ }
+
+ // stageFor is Speaker view's stage, by tile key: a pin while it is
+ // still there, else the newest screen, else the speaker, else the first
+ // other person in order. `self` only by its own pin; alone, self.
+ function stageFor(pinned, speaker, others, shares, self) {
+ if (pinned && (others.includes(pinned) || shares.includes(pinned) || pinned === self)) return pinned;
+ if (shares.length) return shares[shares.length - 1];
+ if (speaker && others.includes(speaker)) return speaker;
+ return others[0] || self || '';
+ }
+
+ // ---- the page ----
+
const room = document.getElementById('room');
const tiles = document.getElementById('tiles');
const controls = document.getElementById('video-layout');
- const focus = document.getElementById('layout-focus');
- const grid = document.getElementById('layout-grid');
+ const speakerButton = document.getElementById('layout-speaker');
+ const gridButton = document.getElementById('layout-grid');
const chat = document.getElementById('chat');
const toggle = document.getElementById('toggle-chat');
const workspace = document.getElementById('call-workspace');
const log = document.getElementById('chat-log');
const unreadLabel = document.getElementById('chat-unread');
const unreadLive = document.getElementById('chat-unread-live');
+ const layoutLive = document.getElementById('layout-live');
+ const toolsTemplate = document.getElementById('tile-tools');
if (!room || !tiles || !controls || !chat || !toggle || !workspace) return;
- let mode = 'focus';
+
+ const LAYOUT_KEY = 'calls.videoLayout';
+ const ORDER_KEY = 'calls.order.' + (room.dataset.sqid || '');
+ const reduced = () => window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ function load(storage, key) {
+ try { return window[storage].getItem(key); } catch { return null; }
+ }
+ function keep(storage, key, value) {
+ try { window[storage].setItem(key, value); } catch { /* private mode: this call only */ }
+ }
+
+ let mode = storedLayout(load('localStorage', LAYOUT_KEY));
+ if (load('localStorage', LAYOUT_KEY) === 'focus') keep('localStorage', LAYOUT_KEY, 'speaker');
+ let order = { keys: [], present: [] };
+ try {
+ const kept = JSON.parse(load('sessionStorage', ORDER_KEY) || '[]');
+ if (Array.isArray(kept)) order.keys = kept.filter((k) => typeof k === 'string');
+ } catch { /* a damaged value is no order at all */ }
+ let pinned = '';
+ let sharesSeen = [];
let unread = 0;
let expanded = true;
+ let drag = null;
+ let suppressClick = false;
+ // Leaving — Leave, a reload, closing the tab — takes the tiles down
+ // one by one, which would read as everyone else leaving and wipe the
+ // order kept for the rejoin. Layout runs a task after the tiles change
+ // (below), so these are heard first.
+ let leaving = false;
+ window.addEventListener('pagehide', () => { leaving = true; }, { capture: true });
+ document.getElementById('leave')?.addEventListener('click', () => { leaving = true; }, { capture: true });
+
+ const isShare = (fig) => fig.classList.contains('calls-tile-share');
+ const isSelf = (fig) => fig.classList.contains('calls-tile-self');
+ const figures = () => Array.from(tiles.children).filter((el) => el.tagName === 'FIGURE');
+ const nameOf = (fig) => {
+ const span = fig.querySelector('.calls-tile-name > span');
+ return isSelf(fig) ? 'You' : (fig.dataset.name || (span && span.textContent) || 'Someone');
+ };
+
+ // stableKeys gives each person's tile the key its place is kept under:
+ // "self", or "name:" and their name — a peer id is new on every join,
+ // and the point of keeping the order is a rejoin. Two of one name are
+ // told apart by peer id, for this call only.
+ function stableKeys(people) {
+ const count = new Map();
+ for (const fig of people) if (!isSelf(fig)) count.set(nameOf(fig), (count.get(nameOf(fig)) || 0) + 1);
+ const out = new Map();
+ for (const fig of people) {
+ if (isSelf(fig)) out.set(fig, 'self');
+ else {
+ const name = nameOf(fig);
+ out.set(fig, 'name:' + name + (count.get(name) > 1 ? '#' + fig.dataset.tile : ''));
+ }
+ }
+ return out;
+ }
+
+ function say(text) {
+ if (!layoutLive || !text) return;
+ // A repeat of the same sentence is not re-read unless it changes.
+ layoutLive.textContent = layoutLive.textContent === text ? text + ' ' : text;
+ }
+
+ function saveOrder() {
+ keep('sessionStorage', ORDER_KEY, JSON.stringify(order.keys));
+ }
+
+ // tools gives a tile its buttons, once: Pin, and for a person, the
+ // three moves. They are the keyboard's and a screen reader's way to do
+ // what a drag does, and a click's way to pin from Grid.
+ function tools(fig) {
+ let box = fig.querySelector(':scope > .calls-tile-tools');
+ if (box || !toolsTemplate) return box;
+ box = document.createElement('div');
+ box.className = 'calls-tile-tools';
+ box.setAttribute('role', 'group');
+ box.append(toolsTemplate.content.cloneNode(true));
+ if (isShare(fig)) box.querySelectorAll('[data-move]').forEach((b) => b.remove());
+ box.addEventListener('click', (ev) => {
+ const button = ev.target.closest('button');
+ if (!button) return;
+ ev.stopPropagation();
+ if (button.dataset.act === 'pin') togglePin(fig);
+ else moveBy(fig, button.dataset.move, button);
+ });
+ fig.append(box);
+ return box;
+ }
+
+ function labelTools(fig, isPinned, place, count) {
+ const box = tools(fig);
+ if (!box) return;
+ const name = nameOf(fig);
+ box.setAttribute('aria-label', 'Arrange ' + name);
+ const pin = box.querySelector('[data-act="pin"]');
+ if (pin) {
+ pin.setAttribute('aria-label', (isPinned ? 'Unpin ' : 'Pin ') + name);
+ pin.setAttribute('aria-pressed', String(isPinned));
+ const text = pin.querySelector('.calls-tool-text');
+ // Only on a change: writing text is a child-list mutation, which
+ // would bring the observer below straight back here.
+ const word = isPinned ? 'Pinned' : 'Pin';
+ if (text && text.textContent !== word) text.textContent = word;
+ }
+ for (const b of box.querySelectorAll('[data-move]')) {
+ const verb = { earlier: 'Move ' + name + ' earlier', later: 'Move ' + name + ' later', first: 'Move ' + name + ' to first' }[b.dataset.move];
+ b.setAttribute('aria-label', verb);
+ b.title = verb;
+ b.disabled = b.dataset.move === 'later' ? place >= count - 1 : place <= 0;
+ }
+ }
+
+ function togglePin(fig) {
+ const key = fig.dataset.tile;
+ if (pinned === key) {
+ pinned = '';
+ say('Unpinned. The stage follows whoever is speaking.');
+ } else {
+ pinned = key;
+ if (mode !== 'speaker') setMode('speaker');
+ say('Pinned ' + (isSelf(fig) ? 'yourself' : nameOf(fig)) + ' to the stage');
+ }
+ layout();
+ }
+
+ function moveBy(fig, how, button) {
+ const people = figures().filter((f) => !isShare(f));
+ const keys = stableKeys(people);
+ const key = keys.get(fig);
+ const shown = orderShown(order);
+ const at = shown.indexOf(key);
+ const to = how === 'first' ? 0 : how === 'earlier' ? at - 1 : at + 1;
+ const next = orderMove(order, key, to);
+ if (!next) return;
+ order = next;
+ saveOrder();
+ layout();
+ const place = orderShown(order).indexOf(key);
+ say(nameOf(fig) + ' moved to position ' + (place + 1) + ' of ' + people.length);
+ // The button went with its tile; keep the keyboard on it, or on the
+ // move that is still possible.
+ if (button) {
+ const again = button.disabled ? fig.querySelector('[data-move]:not([disabled])') : button;
+ if (again) again.focus();
+ }
+ }
+
+ function setMode(next) {
+ mode = next;
+ keep('localStorage', LAYOUT_KEY, mode);
+ }
+
+ // place puts the figures in `want` order with the fewest moves, and the
+ // keyboard back where it was if a move took it away.
+ function place(want) {
+ const active = document.activeElement;
+ let i = 0;
+ for (const fig of want) {
+ const here = tiles.children[i];
+ if (here !== fig) tiles.insertBefore(fig, here || null);
+ i++;
+ }
+ if (active && active !== document.activeElement && tiles.contains(active)) active.focus();
+ }
+
function layout() {
tiles.dataset.layout = mode;
- focus.setAttribute('aria-pressed', String(mode === 'focus'));
- grid.setAttribute('aria-pressed', String(mode === 'grid'));
- const all = Array.from(tiles.children);
- const main = all.find(tile => tile.classList.contains('calls-tile-share')) ||
- all.find(tile => !tile.classList.contains('calls-tile-self')) || all[0];
- tiles.toggleAttribute('data-pip', !!main && !main.classList.contains('calls-tile-self') &&
- !main.classList.contains('calls-tile-share') &&
- !main.querySelector('.calls-tile-note:not([hidden]), button:not([hidden])'));
- for (const tile of all) tile.toggleAttribute('data-focused', tile === main);
+ speakerButton.setAttribute('aria-pressed', String(mode === 'speaker'));
+ gridButton.setAttribute('aria-pressed', String(mode === 'grid'));
+ const all = figures();
+ const shares = all.filter(isShare);
+ const people = all.filter((f) => !isShare(f));
+ // Screens in the order they arrived, newest last.
+ const shareKeys = shares.map((f) => f.dataset.tile);
+ sharesSeen = sharesSeen.filter((k) => shareKeys.includes(k));
+ for (const k of shareKeys) if (!sharesSeen.includes(k)) sharesSeen.push(k);
+ const keys = stableKeys(people);
+ // Without our own tile we are not in the call — leaving, reloading,
+ // or not yet in — and the tiles going is not everyone else leaving:
+ // the order is left as it was kept, for the rejoin.
+ if (people.some(isSelf) && !leaving) {
+ const merged = orderMerge(order, people.map((f) => keys.get(f)));
+ if (merged.keys.join('\n') !== order.keys.join('\n')) {
+ order = merged;
+ saveOrder();
+ } else order = merged;
+ }
+ const byKey = new Map(people.map((f) => [keys.get(f), f]));
+ const ordered = orderShown(order).map((k) => byKey.get(k)).filter(Boolean);
+ const byTile = new Map(all.map((f) => [f.dataset.tile, f]));
+ const sharesNewest = sharesSeen.slice().reverse().map((k) => byTile.get(k)).filter(Boolean);
+ if (!drag) place(sharesNewest.concat(ordered));
+
+ if (pinned && !byTile.has(pinned)) pinned = '';
+ const self = people.find(isSelf);
+ const others = ordered.filter((f) => !isSelf(f)).map((f) => f.dataset.tile);
+ const stageKey = mode === 'speaker'
+ ? stageFor(pinned, tiles.dataset.speaker || '', others, sharesSeen, self ? self.dataset.tile : '')
+ : '';
+ const stage = byTile.get(stageKey) || null;
+ // You float over the stage only when you would be all the filmstrip
+ // holds — a call of two — and only over a face with nothing of its
+ // own to say (a recovery note, a Retry or Play, a screen).
+ const strip = all.filter((f) => f !== stage);
+ const pip = !!stage && strip.length === 1 && strip[0] === self && !isShare(stage) && !isSelf(stage) &&
+ !stage.querySelector('.calls-tile-note:not([hidden]), figcaption button:not([hidden])');
+ tiles.toggleAttribute('data-pip', pip);
+ const count = ordered.length;
+ for (const fig of all) {
+ const onStage = fig === stage;
+ fig.toggleAttribute('data-stage', onStage);
+ fig.toggleAttribute('data-pinned', onStage && pinned === fig.dataset.tile);
+ let note = fig.querySelector(':scope > .calls-stage-note');
+ if (onStage && !note) {
+ note = document.createElement('span');
+ note.className = 'calls-sr-only calls-stage-note';
+ note.textContent = 'On stage:';
+ fig.prepend(note);
+ } else if (!onStage && note) note.remove();
+ labelTools(fig, pinned === fig.dataset.tile, ordered.indexOf(fig), count);
+ }
+ }
+
+ // ---- pointer drag ----
+
+ function personAt(x, y, except) {
+ return figures().find((f) => f !== except && !isShare(f) && (() => {
+ const r = f.getBoundingClientRect();
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
+ })());
+ }
+
+ function follow(ev) {
+ const fig = drag.fig;
+ fig.style.translate = '';
+ const r = fig.getBoundingClientRect();
+ fig.style.translate = (ev.clientX - drag.grabX - r.left) + 'px ' + (ev.clientY - drag.grabY - r.top) + 'px';
}
+
+ tiles.addEventListener('pointerdown', (ev) => {
+ if (ev.button !== 0 || ev.pointerType === 'touch') return;
+ const fig = ev.target.closest('#tiles > figure');
+ if (!fig || isShare(fig) || ev.target.closest('button, a, summary, details, input, select')) return;
+ const r = fig.getBoundingClientRect();
+ drag = { fig, id: ev.pointerId, x: ev.clientX, y: ev.clientY, grabX: ev.clientX - r.left, grabY: ev.clientY - r.top, moving: false };
+ });
+ tiles.addEventListener('pointermove', (ev) => {
+ if (!drag || ev.pointerId !== drag.id) return;
+ if (!drag.moving) {
+ if (Math.hypot(ev.clientX - drag.x, ev.clientY - drag.y) < 6) return;
+ drag.moving = true;
+ drag.fig.classList.add('calls-tile-lifted');
+ try { drag.fig.setPointerCapture(ev.pointerId); } catch { /* released already */ }
+ }
+ const target = personAt(ev.clientX, ev.clientY, drag.fig);
+ if (target) {
+ const people = figures().filter((f) => !isShare(f));
+ const keys = stableKeys(people);
+ const next = orderMove(order, keys.get(drag.fig), orderShown(order).indexOf(keys.get(target)));
+ if (next) {
+ order = next;
+ // Into the target's place: after it when coming from before it.
+ const before = people.indexOf(drag.fig) < people.indexOf(target) ? target.nextSibling : target;
+ tiles.insertBefore(drag.fig, before);
+ }
+ }
+ follow(ev);
+ });
+ function drop(ev) {
+ if (!drag || (ev && ev.pointerId !== drag.id)) return;
+ const { fig, moving } = drag;
+ drag = null;
+ if (!moving) return;
+ suppressClick = true;
+ fig.classList.remove('calls-tile-lifted');
+ fig.style.translate = '';
+ saveOrder();
+ layout();
+ const keys = stableKeys(figures().filter((f) => !isShare(f)));
+ say(nameOf(fig) + ' moved to position ' + (orderShown(order).indexOf(keys.get(fig)) + 1) + ' of ' + keys.size);
+ }
+ tiles.addEventListener('pointerup', drop);
+ tiles.addEventListener('pointercancel', drop);
+
+ // A click on a filmstrip tile pins it; on the pinned stage, unpins.
+ tiles.addEventListener('click', (ev) => {
+ if (suppressClick) {
+ suppressClick = false;
+ return;
+ }
+ if (mode !== 'speaker') return;
+ const fig = ev.target.closest('#tiles > figure');
+ if (!fig || ev.target.closest('button, a, summary, details, input, select')) return;
+ if (fig.hasAttribute('data-stage') && !fig.hasAttribute('data-pinned')) return;
+ togglePin(fig);
+ });
+
function chatState() {
// Mount readiness belongs to room.js; hiding the visual sidebar never
// changes its hidden attribute, draft, admission or send state.
@@ -47,15 +403,29 @@
if (unreadLive.textContent !== said) unreadLive.textContent = said;
}
}
- focus.addEventListener('click', () => { mode = 'focus'; layout(); });
- grid.addEventListener('click', () => { mode = 'grid'; layout(); });
+ speakerButton.addEventListener('click', () => { setMode('speaker'); layout(); });
+ gridButton.addEventListener('click', () => { setMode('grid'); layout(); });
toggle.addEventListener('click', () => {
expanded = !expanded;
if (expanded) unread = 0;
chatState();
if (expanded) document.getElementById('chat-text')?.focus();
});
- new MutationObserver(layout).observe(tiles, {childList: true, subtree: true, attributes: true, attributeFilter: ['hidden']});
+ // Our own moves are child-list changes too; layout() finds the order
+ // already right and moves nothing, so this settles at once.
+ //
+ // A task later rather than at once: room.js takes the tiles down inside
+ // its own pagehide listener, and the observer would run between that
+ // and ours; by the time a timer fires, `leaving` has been set.
+ let pending = 0;
+ new MutationObserver(() => {
+ if (pending || (drag && drag.moving)) return;
+ pending = setTimeout(() => {
+ pending = 0;
+ if (!drag || !drag.moving) layout();
+ }, 0);
+ })
+ .observe(tiles, {childList: true, subtree: true, attributes: true, attributeFilter: ['hidden', 'data-speaker']});
new MutationObserver(chatState).observe(chat, {attributes: true, attributeFilter: ['hidden']});
if (log) new MutationObserver(records => {
if (!expanded) {
@@ -68,6 +438,12 @@
}
}).observe(log, {childList: true});
controls.hidden = false;
+ // For the gate and for a look from the console: what this tab has
+ // arranged. Presentation only; nothing here is sent anywhere.
+ window.callsLayout = {
+ state: () => ({ mode, pinned, order: orderShown(order), keys: order.keys.slice(), speaker: tiles.dataset.speaker || '' }),
+ reduced,
+ };
layout();
chatState();
})();
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 4b0960a..9ae6ef5 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -291,9 +291,9 @@
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
margin: 0;
}
-.calls-grid[data-layout='focus'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
-.calls-grid[data-layout='focus'] [data-focused] { grid-column: 1 / -1; grid-row: 1; order: -1; }
-.calls-grid[data-layout='focus'] .calls-tile:not([data-focused]) { aspect-ratio: 16 / 10; }
+.calls-grid[data-layout='speaker'] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.calls-grid[data-layout='speaker'] [data-stage] { grid-column: 1 / -1; grid-row: 1; order: -1; }
+.calls-grid[data-layout='speaker'] .calls-tile:not([data-stage]) { aspect-ratio: 16 / 10; }
.calls-tile {
margin: 0;
min-inline-size: 0;
@@ -352,10 +352,10 @@
}
}
@media (max-width: 35rem) {
- .calls-grid[data-layout='focus'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .calls-grid[data-layout='speaker'] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.calls-controls #leave { margin-inline-start: 0; }
}
-.calls-grid[data-layout='focus'][data-pip] .calls-tile-self {
+.calls-grid[data-layout='speaker'][data-pip] .calls-tile-self {
grid-row: 1;
grid-column: 3;
align-self: end;
@@ -367,7 +367,7 @@
.calls-controls #toggle-chat { margin-inline-start: auto; }
.calls-controls #leave { margin-inline-start: 0; }
@media (max-width: 35rem) {
- .calls-grid[data-layout='focus'][data-pip] .calls-tile-self {
+ .calls-grid[data-layout='speaker'][data-pip] .calls-tile-self {
grid-column: 2;
max-inline-size: 60%;
justify-self: end;
@@ -378,7 +378,7 @@
.calls-preview[hidden] { display: none; }
#call-workspace .calls-controls { grid-column: 1 / -1; }
-.calls-grid[data-layout='focus'][data-pip] [data-focused] .calls-tile-name { max-inline-size: 60%; }
+.calls-grid[data-layout='speaker'][data-pip] [data-stage] .calls-tile-name { max-inline-size: 60%; }
@media (min-width: 64rem) {
#call-workspace .calls-controls { grid-row: 2; }
#call-workspace[estilo-call-workspace='sidebar'] .chat { grid-column: 2; grid-row: 1; }
@@ -591,3 +591,64 @@
.calls-tile-name .calls-tile-note { color: #fff; }
/* No hairline round a tile either: the picture's own edge is the edge. */
.calls-tile { border: 0; }
+
+/* Speaker view's filmstrip and the viewer's order (0.4.0). Everything
+ not on the stage is in the rows under it, in this viewer's order;
+ call-layout.js moves the tiles themselves, so what is read is what is
+ seen. Each tile carries its arrangement buttons (Pin, Move to first,
+ earlier, later) in its top corner: shown on hover and whenever the
+ keyboard is in them, and always on a screen with no hover. */
+.calls-grid > figure { cursor: default; }
+.calls-grid[data-layout='speaker'] > figure:not([data-stage]) { cursor: pointer; }
+.calls-tile-tools {
+ position: absolute;
+ inset-block-start: 6px;
+ inset-inline-end: 6px;
+ display: flex;
+ gap: 4px;
+ opacity: 0;
+ z-index: 2;
+}
+.calls-tile:hover > .calls-tile-tools,
+.calls-tile:focus-within > .calls-tile-tools { opacity: 1; }
+@media (hover: none) { .calls-tile-tools { opacity: 1; } }
+.calls-tool {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ min-inline-size: 28px;
+ min-block-size: 28px;
+ padding: 2px 6px;
+ border: 0;
+ border-radius: 14px;
+ background: #000000b3;
+ color: #fff;
+ font: inherit;
+ font-size: var(--rst-fs-xs);
+ cursor: pointer;
+}
+.calls-tool .icon { inline-size: 16px; block-size: 16px; }
+.calls-tool:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
+.calls-tool[disabled] { opacity: 0.4; cursor: default; }
+/* A pinned stage says so whether or not the pointer is over it: the pin
+ stays up, reading "Pinned", and pressing it unpins. */
+.calls-tile[data-pinned] > .calls-tile-tools { opacity: 1; }
+.calls-tile[data-pinned] > .calls-tile-tools .calls-tool:not(.calls-tool-pin) { display: none; }
+.calls-tile[data-pinned] .calls-tool-pin { background: #fff; color: #000; }
+.calls-tile:hover[data-pinned] > .calls-tile-tools .calls-tool:not(.calls-tool-pin),
+.calls-tile[data-pinned]:focus-within > .calls-tile-tools .calls-tool:not(.calls-tool-pin) { display: inline-flex; }
+/* Grid and the stage need no Pin word on every tile; the filmstrip's
+ buttons stay icons too. */
+.calls-tile:not([data-pinned]) .calls-tool-text { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); }
+/* The tile being dragged: lifted over the others, with the accent edge,
+ following the pointer (its translate is set as it moves); the others
+ make room as it passes over their places. Reduced motion: no growth
+ and no easing back. */
+.calls-tile-lifted {
+ z-index: 3;
+ box-shadow: 0 10px 30px #0009, 0 0 0 3px rgb(46, 204, 219);
+ cursor: grabbing;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .calls-tile-lifted { scale: 1.04; }
+}
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 5dc5ab5..f641cc7 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -252,6 +252,13 @@
const GLOW_ATTACK = 50;
const GLOW_RELEASE = 300;
const GLOW_STEADY = 0.6;
+ // Speaker view's stage (0.4.0), as the native apps have it
+ // (ActiveSpeaker in SpeakerView.swift): someone takes the stage only
+ // after SPEAKER_DOMINANCE ms as the loudest speaker, and whoever is on it
+ // keeps it at least SPEAKER_HOLD ms, so an interjection, a laugh or two
+ // people talking over each other never make it flap. Never yourself.
+ const SPEAKER_DOMINANCE = 1300;
+ const SPEAKER_HOLD = 3000;
// §4.3's limit on the list the hub will take.
const MAX_NO_SOUND = 15;
// §5.4: the tile key a peer's screen gets, built by shareKey.
@@ -317,6 +324,8 @@
// for our own microphone, read off the sender.
const hearing = new Map();
const selfHearing = hearingRecord();
+ // Who has the floor, for Speaker view's stage (speakerStep).
+ const stageSpeaker = speakerRecord();
// Tile keys whose element the browser refused to play. Non-empty is
// `audioBlocked`: the page banner shows, and the report says so.
const refusedTiles = new Set();
@@ -4633,6 +4642,46 @@
return found;
}
+ // speakerRecord is who has the floor, for Speaker view's stage.
+ function speakerRecord() {
+ return { current: '', since: -Infinity, candidate: '', candidateSince: 0 };
+ }
+
+ // speakerStep takes one poll's voices — [{peer, speaking, level}], the
+ // speaking gate and level the glow already use, for everyone but us —
+ // and who is still here, and moves the floor only once someone has
+ // been the loudest speaker for SPEAKER_DOMINANCE and whoever had it has
+ // had SPEAKER_HOLD. A pause, or the speaker speaking, is nobody taking
+ // over. Someone who left loses it at once. Returns whether it moved.
+ // Pure, and lifted by name in test/browser/room-layout.test.mjs.
+ function speakerStep(rec, voices, present, now) {
+ const before = rec.current;
+ if (rec.current && !present.has(rec.current)) {
+ rec.current = '';
+ rec.since = -Infinity;
+ }
+ let loudest = '';
+ let level = -1;
+ for (const v of voices) {
+ if (v.speaking && present.has(v.peer) && v.level > level) {
+ loudest = v.peer;
+ level = v.level;
+ }
+ }
+ if (!loudest || loudest === rec.current) {
+ rec.candidate = '';
+ } else if (loudest !== rec.candidate) {
+ rec.candidate = loudest;
+ rec.candidateSince = now;
+ }
+ if (rec.candidate && now - rec.candidateSince >= SPEAKER_DOMINANCE && now - rec.since >= SPEAKER_HOLD) {
+ rec.current = rec.candidate;
+ rec.since = now;
+ rec.candidate = '';
+ }
+ return rec.current !== before;
+ }
+
// sampleInto takes one report into one record, and returns the sample.
function sampleInto(rec, report, now) {
const s = audioSample(rec.sampled ? rec : null, report);
@@ -4707,6 +4756,17 @@
judgeGlow(selfHearing, 0, now);
judgeSilence(selfHearing, now, false, false, false);
}
+ // Who has the floor, for call-layout.js's Speaker view: on #tiles,
+ // where its observer sees it change a few times a minute at most.
+ const voices = [];
+ for (const id of pcs.keys()) {
+ const rec = hearing.get(id);
+ if (rec) voices.push({ peer: id, speaking: !!rec.speaking, level: rec.level || 0 });
+ }
+ if (speakerStep(stageSpeaker, voices, new Set(pcs.keys()), now)) {
+ if (stageSpeaker.current) tilesEl.dataset.speaker = stageSpeaker.current;
+ else delete tilesEl.dataset.speaker;
+ }
const key = noSoundList().join(',');
if (key !== noSoundKey) {
noSoundKey = key;
@@ -5107,6 +5167,9 @@
if (!isSelf) applySink(video);
}
t.nameEl.textContent = isSelf ? name + ' (you)' : name;
+ // call-layout.js keeps this viewer's order by name, which survives a
+ // rejoin where a peer id does not.
+ if (t.figure.dataset.name !== name) t.figure.dataset.name = name;
// The removal control comes and goes with our own kind, which the
// roster re-states on every tick — a member demoted mid-call loses
// it without the tile being rebuilt.
@@ -6183,6 +6246,8 @@
audioBlocked,
soundBanner: !!(soundBanner && !soundBanner.box.hidden),
noSoundFrom: noSoundList(),
+ // Speaker view: who has the floor (speakerStep), by peer id.
+ speaker: stageSpeaker.current,
mediaExtras,
};
},
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index 50117f0..f8d8628 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -10,7 +10,7 @@
<div estilo-call-header>
{{template "page-header" dict "Title" .Title}}
<div id="video-layout" estilo-call-segmented role="group" aria-label="Video layout" hidden>
-<button type="button" id="layout-focus" aria-pressed="true">Focus</button>
+<button type="button" id="layout-speaker" aria-pressed="true">Speaker</button>
<button type="button" id="layout-grid" aria-pressed="false">Grid</button>
</div>
</div>
@@ -53,6 +53,11 @@
writes it only while collapsed — with the panel open its own log
announces arrivals, and saying it twice is worse than once. */}}
<p id="chat-unread-live" class="calls-sr-only" role="status" aria-live="polite"></p>
+ {{/* Speaker view and the viewer's order (0.4.0): what call-layout.js
+ says when the person pins, unpins or moves someone. An automatic
+ change of speaker is never said: in a lively call it would talk
+ over the talking, and the stage tile says "On stage" itself. */}}
+ <p id="layout-live" class="calls-sr-only" role="status" aria-live="polite"></p>
{{/* The device panel, closed until asked for. A call's own screen is
the video and the controls; three selects permanently under them
would be furniture the whole call pays for so that someone can
@@ -105,6 +110,10 @@
plain one; the icon is on top of it, never instead. */}}
<template id="tile-muted">{{icon "mic-off"}}{{template "badge" dict "Label" "Muted"}}</template>
<template id="tile-camera-off">{{icon "video-off"}}<span>Camera off</span></template>
+ {{/* Each tile's arrangement buttons, which call-layout.js clones and
+ names ("Pin Ana", "Move Ana earlier"): the way to do without a
+ drag what a drag does. A screen's tile keeps only Pin. */}}
+ <template id="tile-tools"><button type="button" class="calls-tool calls-tool-pin" data-act="pin" aria-pressed="false">{{icon "pin"}}<span class="calls-tool-text">Pin</span></button><button type="button" class="calls-tool" data-move="first">{{icon "arrow-left-to-line"}}</button><button type="button" class="calls-tool" data-move="earlier">{{icon "arrow-left"}}</button><button type="button" class="calls-tool" data-move="later">{{icon "arrow-right"}}</button></template>
</div>
{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
{{end}}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index d69bc59..bb259d9 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -98,6 +98,7 @@ var scenarios = []string{
"icefail",
"icestuck",
"guest",
+ "speaker",
"chat",
"removed",
"allowlist",
diff --git a/native/DESIGN.md b/native/DESIGN.md
index b25df2a..a4175cf 100644
--- a/native/DESIGN.md
+++ b/native/DESIGN.md
@@ -20,8 +20,8 @@ while their call layouts suit the available space.
Settings scene after the mini-window settings, and the whole of the iPhone's
settings sheet, reached by the home screen's gear.
- `MacCallView` defaults to a remote participant filling the stage with a local
- thumbnail. Focus/Grid switches layouts; a participant menu selects the focus
- in group calls. Video crops to fill while preserving its aspect ratio. Chat
+ thumbnail. Speaker/Grid switches layouts (Focus until 0.4.0; see below); a
+ pin menu chooses what is on the stage in group calls. Video crops to fill while preserving its aspect ratio. Chat
starts hidden and can open as a sidebar or a separate window; its draft survives
moving and closing. The compact, coloured icon controls remain below the stage. On iPhone, chat remains a sheet and controls remain
in the bottom safe area.
@@ -193,3 +193,82 @@ draws no Liquid Glass); the iPhone's are
`CallsPhoneUITests.testGlowAndGlassSnapshots` (`-CallsFixtureSpeaking`,
`-CallsFixtureNoGlass`).
+
+## Speaker view and your own order · 2026-09-27 (0.4.0)
+
+Paul asked whether he could rearrange the people in Grid, and for a Speaker
+view: the speaker in the main window, everyone else underneath.
+
+- **Two layouts: Speaker and Grid.** Speaker replaced Focus. Focus was "one
+ person on the stage, chosen by hand", which is Speaker with that person
+ pinned, so keeping both would have been two names for one thing with a
+ worse default. Choosing a pin (from the layout menu, a tile's menu or a
+ click on a filmstrip tile) switches to Speaker. The choice of layout is now
+ kept (`VideoLayout.key`, `videoLayout` in UserDefaults; `calls.videoLayout`
+ in the browser's localStorage), and a kept `focus` reads as Speaker.
+ Nothing had kept one before 0.4.0 — every call opened in Focus — so the
+ migration is a rule in `VideoLayout.stored`/`storedLayout` rather than data
+ to move. The iPhone's ⋯ sheet says Speaker/Grid and "On the stage".
+- **The stage** is, in order: what you pinned while it is there; otherwise the
+ newest shared screen (as before, so a new screen takes the stage unless you
+ have pinned something); otherwise whoever has the floor; otherwise the first
+ other person in your order (`Stage.speaker`). You are on it only if you pin
+ yourself.
+- **Who has the floor** is `ActiveSpeaker`, fed five times a second from the
+ per-peer levels and speaking gate the glow already uses. Someone takes the
+ stage once they have been the loudest speaker for `dominance` (1.3 s), and
+ whoever is on it keeps it for at least `minimumHold` (3 s). A pause keeps
+ the speaker; an interjection shorter than 1.3 s never takes it; two people
+ trading sentences faster than that do not make it flap; someone who leaves
+ loses it at once. You are never the speaker. room.js's `speakerStep` is
+ the same rule with the same constants (`SPEAKER_DOMINANCE`, `SPEAKER_HOLD`),
+ and `room-layout.test.mjs` holds them equal.
+- **The filmstrip** holds everything not on the stage — other screens first,
+ then everyone in your order, you included. On the Mac it runs along the
+ bottom of the window, tiles a sixth of the window high (72–120 pt; five fit
+ across the smallest window, 760 × 540, and more scroll sideways), and rises
+ clear of the bar while the controls show, as the self view does; with the
+ controls hidden nothing lies over the stage. On iPhone it is a sideways
+ scroller of upright 3:4 tiles above the home indicator (4:3 in landscape),
+ rising with the bar in the same way. Tiles in it keep the glass name
+ labels, the Muted mark and the speaking glow.
+- **You in the filmstrip, not floating** — except in a call of two. The
+ order is one list shared by Grid and the filmstrip, and you are in it
+ like anyone else, so you can put yourself first or last; a floating self
+ view would have been the one tile that ignored the order. In a call of
+ two the filmstrip would hold nobody but you, so there you float in the
+ corner exactly as since 0.3.4 (`Stage.selfFloats`), and the full-window
+ look of the commonest call is unchanged.
+- **Pinning.** A click or tap on a filmstrip tile, or Pin in a tile's menu
+ (Mac context menu) or its accessibility actions, pins it. A pinned stage
+ carries a **Pinned** badge; pressing it, or Unpin, goes back to following
+ the speaker. On the Mac a single click on a filmstrip tile pins once the
+ double-click interval has passed (a double click zooms the window, as on
+ the picture).
+- **Your own order.** Drag a tile onto another's place — in Grid or in the
+ filmstrip — and the others make room; the dragged tile lifts (larger, with
+ a shadow and the accent edge) and follows the pointer. On iPhone and iPad a
+ long press picks it up. With Reduce Motion nothing grows or springs. Every
+ platform has a way without dragging: the Mac's tile context menu, and on
+ every platform the tile's accessibility actions (Pin/Unpin, Move earlier,
+ Move later, Move to first); the browser gives each tile those as buttons.
+ The order is yours alone and never sent. It is kept per meeting for the
+ rest of the app's run (`ParticipantOrders`; the browser's sessionStorage,
+ `calls.order.<meeting>`), under keys that survive a rejoin — "self" and the
+ person's name, since a peer id is new on every join. New arrivals go on the
+ end; someone who leaves is removed; a remembered person who comes back
+ takes their old place (`ParticipantOrder`).
+- **VoiceOver** hears the stage tile as "On stage: Tony" (", pinned" when
+ it is), and a pin, an unpin or a move is announced once, politely. An
+ automatic change of speaker is not announced: in a lively call it would
+ talk over the talking.
+- **The mini window** shows Speaker view's stage, whichever layout the call
+ window is in.
+
+Snapshots: `CallsInterop --speaker-snapshots <dir>` (Mac, real windows: the
+filmstrip with the controls up and down and at 760 × 540, a pin, a shared
+screen, a drag half way in Speaker and in Grid, Grid after a reorder, and a
+call of two); `CallsPhoneUITests.testSpeakerViewSnapshots` (iPhone, portrait
+and landscape, with `-CallsFixtureLayout`, `-CallsFixtureSpeaker`,
+`-CallsFixturePinned`, `-CallsFixtureLift` and `-CallsFixtureOrder`); and the
+browser gate's `speaker` scenario with `CALLS_SPEAKER_SNAPSHOTS=<dir>`.
diff --git a/native/README.md b/native/README.md
index 459ee6f..5f14254 100644
--- a/native/README.md
+++ b/native/README.md
@@ -594,10 +594,11 @@ not. It asks the server the way the Share button does, which returns the
meeting's current sealed invitation and never replaces or revokes one. It is
off outside a call; a guest pressing it is told only members can share. An
iPad's hardware keyboard has the same shortcut. The browser room does not:
-⌘⇧I is Chrome's developer tools. It shows what the call window's stage would show by
-default: the newest shared screen, otherwise the first other participant with
-their muted and camera-off state (there is no active-speaker tracking yet), or
-your own camera while you are alone. It is not
+⌘⇧I is Chrome's developer tools. It shows what Speaker view's stage shows (0.4.0), whichever layout the call
+window is in: whatever you pinned, otherwise the newest shared screen, otherwise
+whoever is speaking (see [DESIGN.md](DESIGN.md#speaker-view-and-your-own-order--2026-09-27-040)),
+otherwise the first other participant in your order, with their muted and
+camera-off state, or your own camera while you are alone. It is not
shown for a minimised call window, and ⌘H hides it with the rest of the app. The
call window is hidden by transparency rather than ordered out, because SwiftUI
treats an ordered-out window as closed, which would leave the call. The mini
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift
index 2d1336a..2892a0b 100644
--- a/native/apple/Sources/CallsInterop/CallSnapshots.swift
+++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift
@@ -116,6 +116,44 @@ import SwiftUI
}
}
+ /// Speaker view and the viewer's order (0.4.0), as the window server
+ /// draws them: a call of four in Speaker view with Tom speaking and the
+ /// other three in the filmstrip (controls up and down, and at the
+ /// smallest window, 760 × 540), Li pinned, a shared screen with the
+ /// sharer's camera in the filmstrip, a drag half way in Grid, and Grid
+ /// after a reorder.
+ static func saveSpeaker(to folder: URL) async throws {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ func shot(
+ _ name: String, _ fixture: CallFixture = .grid, grid: Bool = false, shown: Bool = true,
+ size: CGSize = CGSize(width: 1000, height: 640), speaker: String? = "tom",
+ pinned: StageItem? = nil, lift: String? = nil, order: [String] = []
+ ) async throws {
+ let call = Call(playAudio: false)
+ fixture.apply(to: call)
+ call.levels.speaker.showFixture(speaker)
+ call.arrangement.showPinned(pinned)
+ call.arrangement.showLift(lift)
+ call.arrangement.showOrder(order)
+ try await renderLive(
+ MacCallView(
+ call: call, grid: grid, style: .fullWindow, controlsShown: shown, leave: {}, rejoin: {}
+ )
+ .tint(CallsAppearance.accent),
+ size: size, to: folder.appendingPathComponent("mac-\(name).png"))
+ }
+ try await shot("speaker-filmstrip-controls")
+ try await shot("speaker-filmstrip-hidden", shown: false)
+ try await shot("speaker-filmstrip-760x540", size: CGSize(width: 760, height: 540))
+ try await shot("speaker-pinned", pinned: .camera("li"))
+ try await shot("speaker-screen", .screen, shown: false)
+ try await shot("speaker-drag", lift: "me")
+ try await shot("grid-drag", grid: true, lift: "li")
+ try await shot("grid-reordered", grid: true, order: ["li", "tom", "me", "ana"])
+ try await shot("speaker-two", .focus, shown: false, speaker: nil)
+ }
+
/// A real window below the desktop, captured by the window server.
static func renderLive(_ content: some View, size: CGSize, to file: URL) async throws {
let view = NSHostingView(rootView: content.environment(\.colorScheme, .dark))
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index 06d4e4b..c38d11f 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -36,6 +36,12 @@ struct Input: Decodable {
} catch { exit(1) }
return
}
+ if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--speaker-snapshots" {
+ do {
+ try await CallSnapshots.saveSpeaker(to: URL(fileURLWithPath: CommandLine.arguments[2]))
+ } catch { exit(1) }
+ return
+ }
if CommandLine.arguments.count == 2, CommandLine.arguments[1] == "--audio-start-stall" {
exit(await AudioStartStallHarness.run() ? 0 : 1)
}
diff --git a/native/apple/Sources/CallsKit/ArrangeTiles.swift b/native/apple/Sources/CallsKit/ArrangeTiles.swift
new file mode 100644
index 0000000..485de5d
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ArrangeTiles.swift
@@ -0,0 +1,249 @@
+import SwiftUI
+
+// 0.4.0: the tiles of Grid and of Speaker view's filmstrip can be put in
+// the viewer's own order by dragging one onto another's place (a long
+// press first on a touch screen), and pinned to the stage. Every drag has
+// a way round it: the tile's context menu and its accessibility actions
+// say Pin or Unpin, Move earlier, Move later and Move to first.
+//
+// The dragged tile lifts — a little larger, with a shadow and an accent
+// edge — and follows the pointer; the others make room for it as it
+// passes over their places, so where it will land is always where the gap
+// is. With Reduce Motion nothing grows or springs: the tile still follows
+// the pointer and the others change places without easing.
+
+/// A tile being dragged: whose, where it was picked up (the pointer's
+/// offset from the tile's corner) and where the pointer is now, in the
+/// arrangement's coordinate space.
+public struct TileDrag: Equatable {
+ var peer: String
+ var grab: CGSize
+ var pointer: CGPoint
+}
+
+/// Where each tile is, in the arrangement's coordinate space, by key: a
+/// person's peer id, or "screen:" and theirs for a screen.
+public struct TileFramesKey: PreferenceKey {
+ public static let defaultValue: [String: CGRect] = [:]
+ public static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
+ value.merge(nextValue(), uniquingKeysWith: { _, new in new })
+ }
+}
+
+extension StageItem {
+ /// The tile's key among the frames.
+ var tileKey: String {
+ switch self {
+ case .camera(let peer): peer
+ case .screen(let peer): "screen:" + peer
+ }
+ }
+}
+
+/// The coordinate space the tiles of one arrangement are measured in.
+public let arrangeSpace = "arrange"
+
+/// What one tile of Grid or the filmstrip can do: be picked up and moved
+/// among the others, be pinned, and say both to VoiceOver.
+public struct ArrangeableTile: ViewModifier {
+ let item: StageItem
+ /// The name the menus use: "Ana García", "You", "Ana García’s screen".
+ let title: String
+ @ObservedObject var arrangement: CallArrangement
+ @Binding var drag: TileDrag?
+ let frames: [String: CGRect]
+ /// A click or tap pins it (the filmstrip); in Grid it does not.
+ let tapToPin: Bool
+ /// Touch: a long press picks it up. Pointer: a drag of a few points.
+ let touch: Bool
+ let reduceMotion: Bool
+ /// A fixture holding this tile lifted, for the snapshots.
+ var fixtureLift: CGSize? = nil
+
+ public init(
+ item: StageItem, title: String, arrangement: CallArrangement, drag: Binding<TileDrag?>,
+ frames: [String: CGRect], tapToPin: Bool, touch: Bool, reduceMotion: Bool,
+ fixtureLift: CGSize? = nil
+ ) {
+ self.item = item
+ self.title = title
+ self.arrangement = arrangement
+ _drag = drag
+ self.frames = frames
+ self.tapToPin = tapToPin
+ self.touch = touch
+ self.reduceMotion = reduceMotion
+ self.fixtureLift = fixtureLift
+ }
+
+ private var peer: String? {
+ if case .camera(let peer) = item { return peer }
+ return nil
+ }
+ private var lifted: Bool { (peer != nil && drag?.peer == peer) || fixtureLift != nil }
+
+ public func body(content: Content) -> some View {
+ let pinned = arrangement.pinned == item
+ content
+ .background(
+ GeometryReader { proxy in
+ Color.clear.preference(
+ key: TileFramesKey.self, value: [item.tileKey: proxy.frame(in: .named(arrangeSpace))])
+ }
+ )
+ .overlay {
+ if lifted {
+ RoundedRectangle(cornerRadius: 8).strokeBorder(CallsAppearance.glow, lineWidth: 3)
+ .allowsHitTesting(false)
+ }
+ }
+ .scaleEffect(lifted && !reduceMotion ? 1.06 : 1)
+ .shadow(color: .black.opacity(lifted ? 0.5 : 0), radius: lifted ? 16 : 0, y: lifted ? 8 : 0)
+ .offset(offset)
+ .zIndex(lifted ? 1 : 0)
+ .contentShape(Rectangle())
+ // In the filmstrip a click or tap pins, once it is plainly not the
+ // start of a drag; in Grid a click on a tile is a click on the
+ // picture, which shows the controls (a tap toggles them), so there
+ // the tile takes only the drag.
+ // In the filmstrip a click or tap pins; in Grid a click on a tile
+ // is a click on the picture, which shows the controls (a tap
+ // toggles them), so there the tile takes only the drag. The masks
+ // are `.subviews`, never `.none`, which would switch off every
+ // gesture inside as well.
+ .gesture(
+ TapGesture().onEnded { withAnimation(animation) { arrangement.togglePin(item) } },
+ including: tapToPin ? .all : .subviews)
+ .gesture(pickUp, including: peer != nil ? .all : .subviews)
+ #if os(macOS)
+ // On a touch screen a long press is the drag; there the menu's
+ // choices are the accessibility actions below, and Pin is a tap.
+ .contextMenu { menu(pinned: pinned) }
+ #endif
+ .accessibilityAction(named: pinned ? StageWords.unpin(title) : StageWords.pin(title)) {
+ arrangement.togglePin(item)
+ }
+ .modifier(MoveActions(peer: peer, arrangement: arrangement))
+ }
+
+ @ViewBuilder private func menu(pinned: Bool) -> some View {
+ Button(pinned ? "Unpin" : "Pin to the stage", systemImage: pinned ? "pin.slash" : "pin") {
+ withAnimation(animation) { arrangement.togglePin(item) }
+ }
+ if let peer {
+ let can = arrangement.canMove(peer)
+ Divider()
+ Button(StageWords.moveToFirst, systemImage: "arrow.up.to.line") {
+ withAnimation(animation) { _ = arrangement.moveToFirst(peer) }
+ }.disabled(!can.earlier)
+ Button(StageWords.moveEarlier, systemImage: "arrow.backward") {
+ withAnimation(animation) { _ = arrangement.moveEarlier(peer) }
+ }.disabled(!can.earlier)
+ Button(StageWords.moveLater, systemImage: "arrow.forward") {
+ withAnimation(animation) { _ = arrangement.moveLater(peer) }
+ }.disabled(!can.later)
+ }
+ }
+
+ private var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.3) }
+
+ /// Where the lifted tile is drawn: under the pointer, wherever its slot
+ /// has moved to meanwhile.
+ private var offset: CGSize {
+ if let fixtureLift { return fixtureLift }
+ guard let drag, let peer, drag.peer == peer, let slot = frames[peer] else { return .zero }
+ return CGSize(
+ width: drag.pointer.x - drag.grab.width - slot.minX,
+ height: drag.pointer.y - drag.grab.height - slot.minY)
+ }
+
+ private var pickUp: some Gesture {
+ #if os(iOS)
+ LongPressGesture(minimumDuration: 0.35)
+ .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .named(arrangeSpace)))
+ .onChanged { value in
+ if case .second(true, let drag?) = value { moved(drag) }
+ }
+ .onEnded { _ in dropped() }
+ #else
+ DragGesture(minimumDistance: 4, coordinateSpace: .named(arrangeSpace))
+ .onChanged(moved)
+ .onEnded { _ in dropped() }
+ #endif
+ }
+
+ private func moved(_ value: DragGesture.Value) {
+ guard let peer else { return }
+ if drag?.peer != peer {
+ let slot = frames[peer] ?? .zero
+ drag = TileDrag(
+ peer: peer,
+ grab: CGSize(width: value.startLocation.x - slot.minX, height: value.startLocation.y - slot.minY),
+ pointer: value.location)
+ #if os(iOS)
+ UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ #endif
+ } else {
+ drag?.pointer = value.location
+ }
+ // Over another person's place: take it, and they make room.
+ let people = arrangement.people
+ guard
+ let target = frames.first(where: { key, frame in
+ key != peer && people.contains(key) && frame.contains(value.location)
+ })?.key,
+ let index = people.firstIndex(of: target)
+ else { return }
+ withAnimation(animation) { _ = arrangement.move(peer, to: index) }
+ }
+
+ private func dropped() {
+ guard let peer, drag?.peer == peer else { return }
+ withAnimation(animation) { drag = nil }
+ if let at = arrangement.people.firstIndex(of: peer) {
+ AudioAnnouncer.say(StageWords.moved(title, to: at, of: arrangement.people.count))
+ }
+ }
+}
+
+/// Move earlier, Move later and Move to first, as accessibility actions,
+/// on a person's tile (a screen has none).
+private struct MoveActions: ViewModifier {
+ let peer: String?
+ @ObservedObject var arrangement: CallArrangement
+
+ func body(content: Content) -> some View {
+ if let peer {
+ content
+ .accessibilityAction(named: StageWords.moveEarlier) { arrangement.moveEarlier(peer) }
+ .accessibilityAction(named: StageWords.moveLater) { arrangement.moveLater(peer) }
+ .accessibilityAction(named: StageWords.moveToFirst) { arrangement.moveToFirst(peer) }
+ } else {
+ content
+ }
+ }
+}
+
+/// The pin on a pinned stage: says it is pinned, and unpins when pressed.
+public struct PinBadge: View {
+ let title: String
+ let unpin: () -> Void
+ public init(title: String, unpin: @escaping () -> Void) {
+ self.title = title
+ self.unpin = unpin
+ }
+
+ public var body: some View {
+ Button(action: unpin) {
+ Label("Pinned", systemImage: "pin.fill")
+ .font(.callout.weight(.semibold)).foregroundStyle(.white)
+ .padding(.horizontal, 10).padding(.vertical, 6)
+ .callOverlaySurface(Capsule())
+ }
+ .buttonStyle(.plain)
+ .help("Unpin \(title): the stage follows whoever is speaking")
+ .accessibilityLabel(StageWords.unpin(title))
+ .accessibilityHint("The stage follows whoever is speaking")
+ .accessibilityIdentifier("unpin")
+ }
+}
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift
index 7f7840f..0b68e02 100644
--- a/native/apple/Sources/CallsKit/AudioMeter.swift
+++ b/native/apple/Sources/CallsKit/AudioMeter.swift
@@ -96,6 +96,9 @@ import SwiftUI
public let anyoneSpeaking = SpeakingNow()
/// The glow of the loudest of them, for the same tab.
public let othersGlow = GlowLevel()
+ /// Who has the floor, for Speaker view's stage (see ActiveSpeaker).
+ public let speaker = SpeakerNow()
+ private var lastLevel: [String: Float] = [:]
public init() {}
@@ -127,6 +130,7 @@ import SwiftUI
let before = watch.state
let state = watch.update(sample, now: now)
watches[id] = watch
+ lastLevel[id] = sample.level
let model = peer(id)
let fresh = notices.filter { !model.notices.contains($0) }
model.show(state: state, speaking: watch.speaking, notices: notices)
@@ -138,9 +142,10 @@ import SwiftUI
/// Forgets everyone not in `present`, and says whether anyone left is
/// speaking, and how brightly the loudest of them glows.
- func settle(present: Set<String>) {
+ func settle(present: Set<String>, now: Double = ProcessInfo.processInfo.systemUptime) {
for id in Array(watches.keys) where !present.contains(id) {
watches.removeValue(forKey: id)
+ lastLevel.removeValue(forKey: id)
peers[id]?.show(state: .meter(0), speaking: false, notices: [])
peers[id]?.glow.reset()
}
@@ -148,6 +153,9 @@ import SwiftUI
let loudest = watches.keys.compactMap { peers[$0]?.glow.glow }
.filter(\.lit).max { $0.target < $1.target }
othersGlow.follow(loudest ?? SpeakingGlow())
+ speaker.update(
+ watches.map { ActiveSpeaker.Voice(peer: $0.key, speaking: $0.value.speaking, level: lastLevel[$0.key] ?? 0) },
+ present: present, now: now)
}
/// The people whose sound has gone flat, in a stable order: what this
@@ -165,8 +173,10 @@ import SwiftUI
peers[id]?.glow.reset()
}
watches = [:]
+ lastLevel = [:]
anyoneSpeaking.set(false)
othersGlow.reset()
+ speaker.reset()
}
}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index a0fb899..bdaa5ac 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -27,7 +27,11 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
@MainActor public final class Call: ObservableObject {
- @Published public private(set) var state: RoomState?
+ @Published public private(set) var state: RoomState? {
+ didSet { arrangement.roster(state) }
+ }
+ /// This viewer's layout, pin and order of people (0.4.0).
+ public let arrangement = CallArrangement()
@Published public private(set) var title = "Calls"
@Published public private(set) var standing = ""
@Published public private(set) var status = "Ready to join"
@@ -199,6 +203,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
events = listener
bridge = client
+ arrangement.begin(meeting: origin + "/" + room)
muted = false
cameraOff = false
chat = []
diff --git a/native/apple/Sources/CallsKit/CallArrangement.swift b/native/apple/Sources/CallsKit/CallArrangement.swift
new file mode 100644
index 0000000..129d843
--- /dev/null
+++ b/native/apple/Sources/CallsKit/CallArrangement.swift
@@ -0,0 +1,183 @@
+import Foundation
+import SwiftUI
+
+/// How this viewer has the call arranged: the layout, what is pinned, and
+/// the order of the people. One per `Call`, so the call window, the mini
+/// window and a rejoin all see the same arrangement; the order is kept for
+/// the meeting for as long as the app runs (`ParticipantOrders`).
+///
+/// Who is speaking is not here: it is measured with the levels
+/// (`AudioLevels.speaker`), and `stage(active:)` takes it as an input.
+@MainActor public final class CallArrangement: ObservableObject {
+ @Published public private(set) var layout: VideoLayout
+ /// What the person pinned to the stage in Speaker view. Nil follows the
+ /// speaker.
+ @Published public private(set) var pinned: StageItem?
+ @Published public private(set) var order = ParticipantOrder()
+ /// You, as the roster names you.
+ @Published public private(set) var me: String?
+ private var keyOf: [String: String] = [:]
+ private var peerOf: [String: String] = [:]
+ private var names: [String: String] = [:]
+ private(set) var meeting = ""
+ private let defaults: UserDefaults
+
+ public init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ layout = VideoLayout.load(defaults)
+ }
+
+ // MARK: Layout and pin
+
+ /// The person chose a layout: kept for the next call too.
+ public func choose(_ next: VideoLayout) {
+ guard next != layout else { return }
+ layout = next
+ next.save(defaults)
+ }
+
+ /// A layout for this call only (fixtures, tests).
+ @_spi(Fixtures) public func show(_ next: VideoLayout) { layout = next }
+
+ /// Pins `item` to the stage and says so; nil unpins. Pinning is a
+ /// Speaker view thing, so it switches to Speaker.
+ public func pin(_ item: StageItem?) {
+ guard item != pinned else { return }
+ pinned = item
+ if item != nil { choose(.speaker) }
+ AudioAnnouncer.say(item.map { StageWords.pinned(title(of: $0)) } ?? StageWords.unpinned)
+ }
+
+ /// Pins what is not pinned, and unpins what is.
+ public func togglePin(_ item: StageItem) { pin(pinned == item ? nil : item) }
+
+ /// Pinned without a word, for fixtures.
+ @_spi(Fixtures) public func showPinned(_ item: StageItem?) { pinned = item }
+
+ /// A person's tile held lifted, as if half way through a drag, for the
+ /// snapshots.
+ @Published public private(set) var fixtureLift: String?
+ @_spi(Fixtures) public func showLift(_ peer: String?) { fixtureLift = peer }
+ /// An order for the snapshots: `peers` first, in that order.
+ @_spi(Fixtures) public func showOrder(_ peers: [String]) {
+ for (index, peer) in peers.enumerated() { _ = change(peer, announce: false) { $0.move($1, to: index) } }
+ }
+
+ // MARK: The roster
+
+ /// The call started, for `meeting` (server and meeting id): puts back
+ /// the order kept for it, if any.
+ func begin(meeting: String) {
+ self.meeting = meeting
+ order = ParticipantOrders.load(meeting: meeting)
+ pinned = nil
+ }
+
+ /// Takes the roster as it is. Only while admitted: a roster while
+ /// reconnecting or waiting is not a statement about who has left.
+ func roster(_ state: RoomState?) {
+ guard let state else {
+ me = nil
+ pinned = nil
+ return
+ }
+ if me != state.peer { me = state.peer }
+ guard state.phase == "admitted" else { return }
+ let keyed = ParticipantKeys.keys(
+ roster: state.peers.map { ($0.peer, $0.name) }, me: state.peer)
+ keyOf = Dictionary(keyed.map { ($0.peer, $0.key) }, uniquingKeysWith: { a, _ in a })
+ peerOf = Dictionary(keyed.map { ($0.key, $0.peer) }, uniquingKeysWith: { a, _ in a })
+ names = Dictionary(state.peers.map { ($0.peer, $0.name) }, uniquingKeysWith: { a, _ in a })
+ var next = order
+ next.merge(present: keyed.map(\.key))
+ if next != order {
+ order = next
+ ParticipantOrders.save(order, meeting: meeting)
+ }
+ }
+
+ /// Everyone here, you included, in this viewer's order, as peer ids.
+ public var people: [String] { order.shown.compactMap { peerOf[$0] } }
+ /// Everyone but you, in order.
+ public var others: [String] { people.filter { $0 != me } }
+
+ // MARK: Moving people
+
+ /// Moves `peer` to `index` in `people`. Returns whether it moved.
+ @discardableResult
+ public func move(_ peer: String, to index: Int, announce: Bool = false) -> Bool {
+ change(peer, announce: announce) { $0.move($1, to: index) }
+ }
+ @discardableResult
+ public func moveEarlier(_ peer: String) -> Bool {
+ change(peer, announce: true) { $0.moveEarlier($1) }
+ }
+ @discardableResult
+ public func moveLater(_ peer: String) -> Bool {
+ change(peer, announce: true) { $0.moveLater($1) }
+ }
+ @discardableResult
+ public func moveToFirst(_ peer: String) -> Bool {
+ change(peer, announce: true) { $0.moveToFirst($1) }
+ }
+ public func canMove(_ peer: String) -> (earlier: Bool, later: Bool) {
+ guard let key = keyOf[peer] else { return (false, false) }
+ return order.canMove(key)
+ }
+
+ private func change(
+ _ peer: String, announce: Bool, _ step: (inout ParticipantOrder, String) -> Bool
+ ) -> Bool {
+ guard let key = keyOf[peer] else { return false }
+ var next = order
+ guard step(&next, key) else { return false }
+ order = next
+ ParticipantOrders.save(order, meeting: meeting)
+ if announce, let at = people.firstIndex(of: peer) {
+ AudioAnnouncer.say(StageWords.moved(name(of: peer), to: at, of: people.count))
+ }
+ return true
+ }
+
+ // MARK: The stage
+
+ /// What Speaker view's stage shows, with `active` the speaker.
+ public func stage(active: String?, screens: [String]) -> StageItem? {
+ Stage.speaker(pinned: pinned, active: active, others: others, screens: screens, me: me)
+ }
+
+ /// The filmstrip under that stage.
+ public func filmstrip(stage: StageItem?, screens: [String]) -> [StageItem] {
+ Stage.filmstrip(stage: stage, order: people, screens: screens)
+ }
+
+ func name(of peer: String) -> String { peer == me ? "You" : names[peer] ?? "Participant" }
+ func title(of item: StageItem) -> String {
+ switch item {
+ case .camera(let peer): peer == me ? "yourself" : name(of: peer)
+ case .screen(let peer): screenCaption(names[peer] ?? "Participant")
+ }
+ }
+}
+
+/// Who is speaking, observed alone: publishes only when the stage should
+/// move to someone else, a few times a minute in a lively call.
+@MainActor public final class SpeakerNow: ObservableObject {
+ @Published public private(set) var peer: String?
+ private var rule = ActiveSpeaker()
+
+ public init() {}
+
+ func update(_ voices: [ActiveSpeaker.Voice], present: Set<String>, now: Double) {
+ rule.update(voices, present: present, me: nil, now: now)
+ if peer != rule.current { peer = rule.current }
+ }
+
+ func reset() {
+ rule.reset()
+ if peer != nil { peer = nil }
+ }
+
+ /// Someone on the stage as the speaker, for the snapshots.
+ @_spi(Fixtures) public func showFixture(_ who: String?) { peer = who }
+}
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index 2a1c893..4e629bc 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -6,9 +6,12 @@
@ObservedObject var call: Call
let leave: () -> Void
let rejoin: (() -> Void)?
- @State var grid: Bool
+ @ObservedObject var arrangement: CallArrangement
+ @ObservedObject var speakerNow: SpeakerNow
@State var showingChat: Bool
- @State var chosen: StageItem?
+ /// A tile being dragged to a new place, and where every tile is.
+ @State var drag: TileDrag?
+ @State var tileFrames: [String: CGRect] = [:]
@State var readChatCount = 0
@State var showingDevices: Bool
@State var showingLayout = false
@@ -32,11 +35,13 @@
private let style: CallLayoutStyle?
/// - Parameters:
+ /// - grid: Grid (true) or Speaker (false) for this call, when not
+ /// the person's own choice (fixtures).
/// - style: the layout, when not the person's own choice (fixtures).
/// - controlsShown: pins the full-window controls shown or hidden
/// (fixtures); nil lets the pointer decide.
public init(
- call: Call, grid: Bool = false, showingChat: Bool = false, showingDevices: Bool = false,
+ call: Call, grid: Bool? = nil, showingChat: Bool = false, showingDevices: Bool = false,
style: CallLayoutStyle? = nil, controlsShown: Bool? = nil, leave: @escaping () -> Void,
rejoin: (() -> Void)? = nil
) {
@@ -44,7 +49,9 @@
self.leave = leave
self.rejoin = rejoin
self.style = style
- _grid = State(initialValue: grid)
+ if let grid { call.arrangement.show(grid ? .grid : .speaker) }
+ arrangement = call.arrangement
+ speakerNow = call.levels.speaker
_showingChat = State(initialValue: showingChat)
_showingDevices = State(initialValue: showingDevices)
_chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .pointer, pinned: controlsShown))
@@ -73,11 +80,6 @@
.onChange(of: call.receivedChatCount) { _, count in
if showingChat || chatWindow.isOpen { readChatCount = count }
}
- .onChange(of: call.screens) { before, after in
- // Somebody starting to share is asking to be looked at, so a new
- // screen takes the stage from whatever was chosen before it.
- if after.contains(where: { !before.contains($0) }) { chosen = nil }
- }
.onChange(of: showingChat) { _, visible in
if visible { readChatCount = call.receivedChatCount }
}
@@ -109,21 +111,13 @@
Spacer()
if !grid, peers.count > 1 || !call.screens.isEmpty {
Menu {
- ForEach(call.screens, id: \.self) { peer in
- Button(screenCaption(call.name(of: peer))) { chosen = .screen(peer) }
- }
- ForEach(peers) { peer in
- Button(peer.name) { chosen = .camera(peer.peer) }
- }
+ pinChoices
} label: {
- Label("Choose participant", systemImage: "person.2")
+ Label("Pin to the stage", systemImage: arrangement.pinned == nil ? "pin" : "pin.fill")
}
- .help("Choose the participant or screen to show on the main stage")
+ .help("Pin a participant or screen to the main stage, or follow whoever is speaking")
}
- Picker("Video layout", selection: $grid) {
- Label("Focus", systemImage: "pip").tag(false)
- Label("Grid", systemImage: "square.grid.2x2").tag(true)
- }.pickerStyle(.segmented).fixedSize()
+ layoutPicker.pickerStyle(.segmented).fixedSize()
}
if call.floor {
Text("Direct connections only. People on some networks may be unable to connect.")
@@ -180,12 +174,105 @@
}
}
+ var grid: Bool { arrangement.layout == .grid }
+
+ /// Everyone else, in this viewer's order.
var peers: [RosterPeer] {
- call.state?.peers.filter { $0.peer != call.state?.peer } ?? []
+ let roster = call.state?.peers ?? []
+ return arrangement.others.compactMap { id in roster.first { $0.peer == id } }
}
- var focused: StageItem? {
- Stage.focus(chosen: chosen, peers: peers.map(\.peer), screens: call.screens)
+ /// What Speaker view's stage shows.
+ var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) }
+ /// Speaker view's filmstrip: everything not on the stage.
+ var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) }
+ /// You float over the stage, rather than sit in the filmstrip, when
+ /// you would be all there is in it (see Stage.selfFloats).
+ var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) }
+ /// Grid's tiles: the screens, newest first, then everyone in order.
+ var gridItems: [StageItem] {
+ call.screens.reversed().map(StageItem.screen) + arrangement.people.map(StageItem.camera)
}
+
+ /// A tile for anything the call can show, as it is seen now.
+ func macTile(
+ _ item: StageItem, cornerRadius: CGFloat = 16, compact: Bool = false, labelLift: CGFloat = 0,
+ backdrop: Color = .black, onStage: Bool = false, iconOnly: Bool = false
+ ) -> MacVideoTile {
+ let pinned = onStage && arrangement.pinned == item
+ switch item {
+ case .screen(let peer):
+ return MacVideoTile(
+ track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true,
+ cornerRadius: cornerRadius, compact: compact, labelLift: labelLift, backdrop: backdrop,
+ onStage: onStage, pinned: pinned, iconOnly: iconOnly)
+ case .camera(let peer) where peer == arrangement.me:
+ return MacVideoTile(
+ track: call.localTrack, label: "You", muted: call.muted, mirrored: true,
+ cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius,
+ compact: compact, labelLift: labelLift, backdrop: backdrop, audio: call.selfAudio,
+ onStage: onStage, pinned: pinned, iconOnly: iconOnly)
+ case .camera(let peer):
+ return MacVideoTile(
+ track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer),
+ cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact,
+ labelLift: labelLift, backdrop: backdrop, audio: call.tileAudio(peer), onStage: onStage,
+ pinned: pinned, iconOnly: iconOnly)
+ }
+ }
+
+ /// The name a menu or VoiceOver uses for `item`.
+ func itemTitle(_ item: StageItem) -> String {
+ switch item {
+ case .screen(let peer): screenCaption(call.name(of: peer))
+ case .camera(let peer): peer == arrangement.me ? "You" : call.name(of: peer)
+ }
+ }
+
+ /// A tile of Grid or the filmstrip: dragged to a new place, pinned by
+ /// a click (the filmstrip) or its menu, and both said to VoiceOver.
+ func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile {
+ ArrangeableTile(
+ item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag,
+ frames: tileFrames, tapToPin: tapToPin, touch: false, reduceMotion: reduceMotion,
+ fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 36, height: -22) : nil)
+ }
+
+ var layoutPicker: some View {
+ Picker(
+ "Video layout",
+ selection: Binding(get: { arrangement.layout }, set: { arrangement.choose($0) })
+ ) {
+ ForEach(VideoLayout.allCases) { layout in
+ Label(layout.title, systemImage: layout.symbol).tag(layout)
+ }
+ }
+ }
+
+ /// Automatic, then every screen and every person, as pin choices.
+ @ViewBuilder var pinChoices: some View {
+ Button {
+ arrangement.pin(nil)
+ } label: {
+ Label(
+ "Whoever is speaking",
+ systemImage: arrangement.pinned == nil ? "checkmark" : "waveform")
+ }
+ ForEach(call.screens.reversed(), id: \.self) { peer in
+ pinChoice(.screen(peer))
+ }
+ ForEach(arrangement.people, id: \.self) { peer in
+ pinChoice(.camera(peer))
+ }
+ }
+
+ private func pinChoice(_ item: StageItem) -> some View {
+ Button {
+ arrangement.pin(item)
+ } label: {
+ Label(itemTitle(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin")
+ }
+ }
+
var stageShowsScreen: Bool {
if case .screen = focused { return true }
return false
@@ -195,42 +282,26 @@
if grid {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
- ForEach(call.screens, id: \.self) { peer in
- MacVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true
- )
- .aspectRatio(16 / 9, contentMode: .fit)
- }
- MacVideoTile(
- track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff,
- placeholder: call.selfPlaceholder, audio: call.selfAudio
- )
- .aspectRatio(16 / 9, contentMode: .fit)
- ForEach(peers) { peer in
- MacVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer),
- audio: call.tileAudio(peer.peer)
- )
- .aspectRatio(16 / 9, contentMode: .fit)
+ ForEach(gridItems, id: \.self) { item in
+ macTile(item)
+ .aspectRatio(16 / 9, contentMode: .fit)
+ .modifier(arrangeable(item, tapToPin: false))
}
}
}
+ .coordinateSpace(name: arrangeSpace)
+ .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
} else {
GeometryReader { geometry in
ZStack(alignment: .bottomTrailing) {
- switch focused {
- case .screen(let peer):
- MacVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true)
- case .camera(let peer):
- MacVideoTile(
- track: call.remoteTracks[peer], label: call.name(of: peer),
- muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer),
- audio: call.tileAudio(peer))
- case nil:
+ if let focused {
+ macTile(focused, onStage: true)
+ .overlay(alignment: .topTrailing) {
+ if arrangement.pinned == focused {
+ PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) }.padding(10)
+ }
+ }
+ } else {
ContentUnavailableView(
"Waiting for others", systemImage: "person.2",
description: Text("Your meeting is ready. Others will appear here when they join.")
@@ -238,27 +309,21 @@
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 16))
}
- // With a screen on the stage every camera would otherwise be
- // off it, so the people come down to thumbnails beside yours.
- let others = stageShowsScreen ? peers : []
- let size = thumbnail(count: others.count + 1, in: geometry.size)
+ // Everyone not on the stage, as thumbnails up its trailing
+ // edge; in a call of two, just you.
+ let strip = selfFloats ? [StageItem.camera(arrangement.me ?? "")] : filmstrip
+ let size = thumbnail(count: strip.count, in: geometry.size)
VStack(spacing: 10) {
- ForEach(others) { peer in
- MacVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer),
- audio: call.tileAudio(peer.peer)
- )
- .frame(width: size.width, height: size.height)
+ ForEach(strip, id: \.self) { item in
+ macTile(item)
+ .frame(width: size.width, height: size.height)
+ .modifier(arrangeable(item, tapToPin: !selfFloats))
}
- MacVideoTile(
- track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff,
- placeholder: call.selfPlaceholder, audio: call.selfAudio
- )
- .frame(width: size.width, height: size.height)
}
.shadow(color: .black.opacity(0.25), radius: 8, y: 3).padding(14)
}
+ .coordinateSpace(name: arrangeSpace)
+ .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
}
}
}
@@ -369,31 +434,23 @@
}.frame(maxWidth: .infinity)
}
- /// Focus or Grid, and in Focus, who is on the stage: the classic
- /// view's picker and participant menu, folded into one bar button and
+ /// Speaker or Grid, and in Speaker, what is pinned to the stage: the
+ /// classic view's picker and pin menu, folded into one bar button and
/// the small popover it opens.
private var layoutMenu: some View {
Button(
- "Video layout", systemImage: grid ? "square.grid.2x2" : "pip"
+ "Video layout", systemImage: arrangement.layout.symbol
) { showingLayout.toggle() }
.buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal))
- .help("Video layout: Focus or Grid, and who is on the main stage")
+ .help("Video layout: Speaker or Grid, and who is pinned to the stage")
.accessibilityIdentifier("videoLayout")
.popover(isPresented: $showingLayout, arrowEdge: .top) {
VStack(alignment: .leading, spacing: 12) {
- Picker("Video layout", selection: $grid) {
- Label("Focus", systemImage: "pip").tag(false)
- Label("Grid", systemImage: "square.grid.2x2").tag(true)
- }
- .pickerStyle(.segmented).labelsHidden().fixedSize()
+ layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize()
if peers.count > 1 || !call.screens.isEmpty {
- Text("Show on the main stage").font(.headline)
- ForEach(call.screens, id: \.self) { peer in
- stageChoice(screenCaption(call.name(of: peer)), .screen(peer))
- }
- ForEach(peers) { peer in
- stageChoice(peer.name, .camera(peer.peer))
- }
+ Text("On the stage").font(.headline)
+ VStack(alignment: .leading, spacing: 6) { pinChoices }
+ .buttonStyle(.borderless)
}
}
.padding(14)
@@ -401,18 +458,6 @@
}
}
- private func stageChoice(_ title: String, _ item: StageItem) -> some View {
- Button {
- chosen = item
- grid = false
- showingLayout = false
- } label: {
- Label(title, systemImage: !grid && focused == item ? "checkmark" : "person")
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .buttonStyle(.borderless)
- }
-
/// The full-window bar's look for a button in `state`; the classic
/// row keeps its own colours.
private func tone(_ fullWindow: Bool, _ state: CallOverlayButtonStyle.Tone)
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
index fdd5a54..f350f4f 100644
--- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift
+++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift
@@ -38,7 +38,7 @@
} else {
fullWindowStage(in: size).movesWindow()
}
- if !grid { selfView(in: size) }
+ if !grid, selfFloats { selfView(in: size) }
topShade
// Above the picture, below the title and everything else: what
// is left of the strip once they have their clicks drags.
@@ -118,21 +118,41 @@
// MARK: Stage
@ViewBuilder private func fullWindowStage(in size: CGSize) -> some View {
- if grid {
- fullWindowGrid(in: size)
- } else {
- ZStack(alignment: .topTrailing) {
- switch focused {
- case .screen(let peer):
- MacVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true, cornerRadius: 0, compact: true, labelLift: lift)
- case .camera(let peer):
- MacVideoTile(
- track: call.remoteTracks[peer], label: call.name(of: peer),
- muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer),
- cornerRadius: 0, compact: true, labelLift: lift, audio: call.tileAudio(peer))
- case nil:
+ Group {
+ if grid {
+ fullWindowGrid(in: size)
+ } else {
+ speakerStage(in: size)
+ }
+ }
+ .coordinateSpace(name: arrangeSpace)
+ .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
+ }
+
+ /// Speaker view: the stage above, and everyone else in a filmstrip
+ /// across the bottom of the window, in this viewer's order. The
+ /// filmstrip rises clear of the bar while the controls show, as the
+ /// self view does, and settles back to the window's edge when they go,
+ /// so with the controls hidden nothing lies over the stage at all. In
+ /// a call of two there is no filmstrip: the other person fills the
+ /// window and you float in a corner, as since 0.3.4.
+ private func speakerStage(in size: CGSize) -> some View {
+ let strip = selfFloats ? [] : filmstrip
+ let thumb = Self.filmstripTile(in: size)
+ let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding
+ return ZStack(alignment: .bottom) {
+ Group {
+ if let focused {
+ macTile(focused, cornerRadius: 0, compact: true, labelLift: lift, onStage: true)
+ .overlay(alignment: .bottomTrailing) {
+ if arrangement.pinned == focused {
+ PinBadge(title: itemTitle(focused)) {
+ withAnimation(panelAnimation) { arrangement.pin(nil) }
+ }
+ .padding(8).padding(.bottom, lift)
+ }
+ }
+ } else {
ContentUnavailableView(
"Waiting for others", systemImage: "person.2",
description: Text("Your meeting is ready. Others will appear here when they join.")
@@ -141,39 +161,57 @@
.environment(\.colorScheme, .dark)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
- // With a screen on the stage every camera would otherwise be
- // off it, so the people come down to a strip at the side. You
- // are the floating self view, as always.
- if stageShowsScreen, !peers.isEmpty {
- let thumb = strip(in: size)
- VStack(spacing: 8) {
- ForEach(peers) { peer in
- MacVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer),
- cornerRadius: 10, compact: true, backdrop: Self.tileBackdrop,
- audio: call.tileAudio(peer.peer)
- )
- .frame(width: thumb.width, height: thumb.height)
- }
- }
- .shadow(color: .black.opacity(0.3), radius: 6, y: 2)
- .padding(.top, Self.titleBarHeight + 12).padding(.trailing, 12)
- }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.bottom, band)
+ if !strip.isEmpty {
+ filmstripView(strip, tile: thumb, width: size.width)
+ .padding(.bottom, chrome.visible ? Self.barReserve - 8 : 0)
+ .animation(fade, value: chrome.visible)
}
}
}
- /// The camera thumbnails beside a shared screen.
- private func strip(in size: CGSize) -> CGSize {
- let room = size.height - Self.titleBarHeight - 12 - Self.barReserve
- return thumbnail(count: peers.count, in: CGSize(width: size.width, height: max(room, 120)))
+ /// Room above and below the filmstrip's tiles.
+ static let filmstripPadding: CGFloat = 8
+
+ /// A filmstrip tile's size: a sixth of the window's height, between
+ /// 72 and 120 pt tall, at 16:9. At the smallest window (760 × 540)
+ /// that is 86 pt, and five of them fit across without scrolling.
+ static func filmstripTile(in size: CGSize) -> CGSize {
+ let height = min(120, max(72, (size.height / 6).rounded()))
+ return CGSize(width: (height * 16 / 9).rounded(), height: height)
+ }
+
+ /// The filmstrip: centred while it fits, scrolling sideways once it
+ /// does not.
+ private func filmstripView(_ strip: [StageItem], tile: CGSize, width: CGFloat) -> some View {
+ ScrollView(.horizontal) {
+ HStack(spacing: 8) {
+ ForEach(strip, id: \.self) { item in
+ macTile(item, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop, iconOnly: true)
+ .frame(width: tile.width, height: tile.height)
+ .modifier(arrangeable(item, tapToPin: true))
+ }
+ }
+ .padding(.horizontal, 12).padding(.vertical, Self.filmstripPadding)
+ .frame(minWidth: width)
+ // The click that brings the window forward pins too, as it does
+ // on the picture; it does not reach through a scroll view unless
+ // the content asks again.
+ .takesActivatingClicks()
+ }
+ .scrollIndicators(.automatic)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Filmstrip")
+ .accessibilityIdentifier("filmstrip")
}
/// Every tile as large as it can be without scrolling, until there are
/// so many that they would be too small to see, when it scrolls.
@ViewBuilder private func fullWindowGrid(in size: CGSize) -> some View {
- let count = call.screens.count + 1 + peers.count
+ let items = gridItems
+ let count = items.count
let area = CGSize(width: size.width - 8, height: size.height - 8)
let columns = GridFit.columns(count: count, in: area, aspect: 16 / 9)
let fitted = GridFit.tile(count: count, columns: columns, in: area, aspect: 16 / 9)
@@ -193,30 +231,13 @@
columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across),
spacing: 4
) {
- ForEach(Array(call.screens.enumerated()), id: \.element) { index, peer in
- MacVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true, cornerRadius: 6, compact: true,
+ ForEach(Array(items.enumerated()), id: \.element) { index, item in
+ macTile(
+ item, cornerRadius: 6, compact: true,
labelLift: index >= firstOfLastRow ? lastLift : 0, backdrop: Self.tileBackdrop
)
.frame(width: tile.width, height: tile.height)
- }
- MacVideoTile(
- track: call.localTrack, label: "You", muted: call.muted, mirrored: true,
- cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 6,
- compact: true, labelLift: call.screens.count >= firstOfLastRow ? lastLift : 0,
- backdrop: Self.tileBackdrop, audio: call.selfAudio
- )
- .frame(width: tile.width, height: tile.height)
- ForEach(Array(peers.enumerated()), id: \.element.id) { index, peer in
- MacVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer),
- cornerRadius: 6, compact: true,
- labelLift: call.screens.count + 1 + index >= firstOfLastRow ? lastLift : 0,
- backdrop: Self.tileBackdrop, audio: call.tileAudio(peer.peer)
- )
- .frame(width: tile.width, height: tile.height)
+ .modifier(arrangeable(item, tapToPin: false))
}
}
.padding(4)
@@ -268,7 +289,6 @@
private func selfInsets(in size: CGSize) -> EdgeInsets {
var trailing: CGFloat = 0
if showingChat { trailing += Self.panelWidth + 12 }
- if stageShowsScreen, !peers.isEmpty { trailing += strip(in: size).width + 12 }
// The title pill sits beside the traffic lights while the controls
// show; a self view in that corner goes below it.
let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index 0648f67..f6f45e1 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -31,6 +31,14 @@
/// remote person's with "No sound from" and their notices. Nil for a
/// screen.
var audio: TileAudio? = nil
+ /// Speaker view's stage: VoiceOver hears "On stage: Ana", and whether
+ /// it was pinned there.
+ var onStage = false
+ var pinned = false
+ /// A filmstrip thumbnail: a picture that is not there is its symbol
+ /// alone, since the words would sit under the name. VoiceOver still
+ /// hears them as the tile's value.
+ var iconOnly = false
var body: some View {
TileAudioReader(audio) { info in tile(info) }
@@ -47,8 +55,10 @@
systemName: cameraOff
? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle"
)
- .font(.largeTitle)
- Text(waiting).font(.callout).multilineTextAlignment(.center).padding(.horizontal, 12)
+ .font(iconOnly ? .title2 : .largeTitle)
+ if !iconOnly {
+ Text(waiting).font(.callout).multilineTextAlignment(.center).padding(.horizontal, 12)
+ }
}.foregroundStyle(.white.opacity(0.8))
}
}
@@ -75,7 +85,7 @@
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } }
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.accessibilityElement(children: .ignore)
- .accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info))
+ .accessibilityLabel(stageLabel(tileAccessibilityLabel(label, muted: muted, audio: info), onStage: onStage, pinned: pinned))
.accessibilityHint(info?.detail ?? "")
.accessibilityValue(cameraOff ? "Camera off" : track == nil || placeholder != nil ? waiting : "Video on")
}
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift
index 43654cb..8f82d84 100644
--- a/native/apple/Sources/CallsKit/MiniWindow.swift
+++ b/native/apple/Sources/CallsKit/MiniWindow.swift
@@ -374,7 +374,9 @@
} else {
ZStack {
if let call = content.call {
- MiniCallStage(call: call, showingControls: showingControls, actions: actions)
+ MiniCallStage(
+ call: call, arrangement: call.arrangement, speakerNow: call.levels.speaker,
+ showingControls: showingControls, actions: actions)
CallToast(call: call).padding(8)
.frame(maxHeight: .infinity, alignment: .top)
} else {
@@ -403,18 +405,21 @@
private struct MiniCallStage: View {
@ObservedObject var call: Call
+ @ObservedObject var arrangement: CallArrangement
+ @ObservedObject var speakerNow: SpeakerNow
let showingControls: Bool
let actions: MiniWindowActions
- /// What the call window's stage shows when nobody has chosen: the
- /// newest shared screen, since a person who shares is asking to be
- /// looked at, and otherwise the first person who is not you. Nobody
- /// else there: your own camera, since a window of nothing reads as a
- /// broken one. No active-speaker tracking exists yet to do better.
+ /// What Speaker view's stage shows (0.4.0): what the person pinned,
+ /// or else the newest shared screen, since a person who shares is
+ /// asking to be looked at, or else whoever is speaking, or before
+ /// anyone has, the first person in the viewer's order. Nobody else
+ /// there: your own camera, since a window of nothing reads as a
+ /// broken one. The same whichever layout the call window is in.
private var subject: StageItem? {
- let me = call.state?.peer
- let peers = (call.state?.peers ?? []).map(\.peer).filter { $0 != me }
- return Stage.focus(chosen: nil, peers: peers, screens: call.screens)
+ let item = arrangement.stage(active: speakerNow.peer, screens: call.screens)
+ if case .camera(let peer) = item, peer == arrangement.me { return nil }
+ return item
}
var body: some View {
diff --git a/native/apple/Sources/CallsKit/ScreenShare.swift b/native/apple/Sources/CallsKit/ScreenShare.swift
index c3661f1..90007a0 100644
--- a/native/apple/Sources/CallsKit/ScreenShare.swift
+++ b/native/apple/Sources/CallsKit/ScreenShare.swift
@@ -97,24 +97,5 @@ public enum StageItem: Hashable, Sendable {
}
}
-public enum Stage {
- /// What the main stage shows.
- ///
- /// A choice the person made stands for as long as it is still there to
- /// show. Otherwise a screen beats a camera, and the newest screen beats
- /// an older one: somebody who starts sharing is asking to be looked at,
- /// and a call with a screen on it is usually a call about the screen.
- /// With no screens it is the first camera, which is where the stage
- /// always was.
- public static func focus(chosen: StageItem?, peers: [String], screens: [String]) -> StageItem? {
- if let chosen {
- switch chosen {
- case .camera(let peer) where peers.contains(peer): return chosen
- case .screen(let peer) where screens.contains(peer): return chosen
- default: break
- }
- }
- if let newest = screens.last { return .screen(newest) }
- return peers.first.map(StageItem.camera)
- }
-}
+/// What the main stage shows; Speaker view's rules are in SpeakerView.swift.
+public enum Stage {}
diff --git a/native/apple/Sources/CallsKit/SpeakerView.swift b/native/apple/Sources/CallsKit/SpeakerView.swift
new file mode 100644
index 0000000..8222e73
--- /dev/null
+++ b/native/apple/Sources/CallsKit/SpeakerView.swift
@@ -0,0 +1,349 @@
+import Foundation
+
+// 0.4.0: Speaker view and a viewer's own order of the people in a call.
+// The rules, apart from any view, on the caller's clock, so tests hold
+// them with made-up times: which layout, whose tile comes where, who is
+// on the stage, and when the stage moves to somebody else. The browser
+// carries the same rules with the same constants: room.js's speakerStep,
+// and call-layout.js's orderMerge, orderMove and stageFor.
+
+// MARK: - Layout
+
+/// The call's two layouts. **Speaker** is one person (or a screen) on the
+/// stage with everyone else in a filmstrip underneath; **Grid** is
+/// everyone the same size. Speaker replaced Focus: Focus was "one chosen
+/// person on the stage", which is Speaker with that person pinned.
+public enum VideoLayout: String, CaseIterable, Identifiable, Sendable {
+ case speaker
+ case grid
+
+ /// Where the choice is kept (the browser keeps its own, in localStorage
+ /// as `calls.videoLayout`).
+ public static let key = "videoLayout"
+ public static let defaultLayout = VideoLayout.speaker
+ public var id: String { rawValue }
+ public var title: String {
+ switch self {
+ case .speaker: "Speaker"
+ case .grid: "Grid"
+ }
+ }
+ public var symbol: String {
+ switch self {
+ case .speaker: "rectangle.bottomthird.inset.filled"
+ case .grid: "square.grid.2x2"
+ }
+ }
+
+ /// A kept value, read back: "focus" (the layout before 0.4.0) is
+ /// Speaker, and anything unknown is the default. Nothing is lost by it:
+ /// what Focus did, Speaker does with a pin.
+ public static func stored(_ raw: String?) -> VideoLayout {
+ switch raw {
+ case "grid": .grid
+ case "speaker", "focus": .speaker
+ default: defaultLayout
+ }
+ }
+
+ /// Reads the kept layout, rewriting a pre-0.4.0 "focus" as "speaker"
+ /// so the migration happens once and the stored value says what it is.
+ public static func load(_ defaults: UserDefaults = .standard) -> VideoLayout {
+ let raw = defaults.string(forKey: key)
+ let layout = stored(raw)
+ if raw != nil, raw != layout.rawValue { defaults.set(layout.rawValue, forKey: key) }
+ return layout
+ }
+
+ public func save(_ defaults: UserDefaults = .standard) {
+ defaults.set(rawValue, forKey: Self.key)
+ }
+}
+
+// MARK: - The viewer's order
+
+/// The order this viewer has put the people in a call in: one list,
+/// shared by Grid and Speaker's filmstrip, local to this viewer and never
+/// sent to anyone.
+///
+/// Keys are stable across a rejoin (see `ParticipantKeys`), so the order
+/// can be kept for the meeting and put back when the person comes back to
+/// it. It may hold keys of people who are not here: remembered ones not
+/// yet seen since the order was put back. Someone seen and then gone has
+/// left, and is removed; a new arrival goes on the end.
+public struct ParticipantOrder: Equatable, Codable, Sendable {
+ /// Every key in order, remembered ones included.
+ public private(set) var keys: [String]
+ /// Who was here at the last merge: a key in this and not in the next
+ /// one has left.
+ private var present: [String]
+
+ public init(keys: [String] = []) {
+ var seen = Set<String>()
+ self.keys = keys.filter { seen.insert($0).inserted }
+ present = []
+ }
+
+ /// The people here now, in this viewer's order.
+ public var shown: [String] { keys.filter(present.contains) }
+
+ /// Takes the roster as it is now. Leavers — here at the last merge and
+ /// not now — are removed; new arrivals are added at the end, in the
+ /// order they are given; a remembered key that arrives takes its old
+ /// place.
+ public mutating func merge(present now: [String]) {
+ let here = Set(now)
+ let left = Set(present).subtracting(here)
+ keys.removeAll(where: left.contains)
+ for key in now where !keys.contains(key) { keys.append(key) }
+ present = now.filter { key in keys.contains(key) }
+ var seen = Set<String>()
+ present = present.filter { seen.insert($0).inserted }
+ }
+
+ /// Moves `key` to `index` among the people shown: 0 is first; an index
+ /// at or past the end is last. Nothing happens for a key not shown.
+ /// Returns whether the order changed.
+ @discardableResult
+ public mutating func move(_ key: String, to index: Int) -> Bool {
+ var list = shown
+ guard let from = list.firstIndex(of: key) else { return false }
+ list.remove(at: from)
+ let to = min(max(index, 0), list.count)
+ list.insert(key, at: to)
+ guard list != shown else { return false }
+ // The shown keys take the slots the shown keys had; remembered keys
+ // that are not here keep theirs.
+ var slots = list.makeIterator()
+ keys = keys.map { present.contains($0) ? slots.next()! : $0 }
+ return true
+ }
+
+ /// "Move earlier": one place towards the start.
+ @discardableResult
+ public mutating func moveEarlier(_ key: String) -> Bool {
+ guard let at = shown.firstIndex(of: key), at > 0 else { return false }
+ return move(key, to: at - 1)
+ }
+
+ /// "Move later": one place towards the end.
+ @discardableResult
+ public mutating func moveLater(_ key: String) -> Bool {
+ let list = shown
+ guard let at = list.firstIndex(of: key), at < list.count - 1 else { return false }
+ return move(key, to: at + 1)
+ }
+
+ /// "Move to first".
+ @discardableResult
+ public mutating func moveToFirst(_ key: String) -> Bool { move(key, to: 0) }
+
+ /// Which of the three moves make sense for `key` where it is now.
+ public func canMove(_ key: String) -> (earlier: Bool, later: Bool) {
+ let list = shown
+ guard let at = list.firstIndex(of: key) else { return (false, false) }
+ return (at > 0, at < list.count - 1)
+ }
+
+ /// Where an order is kept, per meeting: `meeting` is the server and the
+ /// meeting's id. The same shape as room.js's sessionStorage key.
+ public static func storageKey(meeting: String) -> String { "calls.order." + meeting }
+
+ public static func == (a: Self, b: Self) -> Bool { a.keys == b.keys && a.present == b.present }
+
+ enum CodingKeys: String, CodingKey { case keys }
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.init(keys: try container.decode([String].self, forKey: .keys))
+ }
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(keys, forKey: .keys)
+ }
+}
+
+/// The orders kept for this run of the app, by meeting: the app's
+/// equivalent of the browser's sessionStorage. A rejoin puts the order
+/// back; quitting forgets it.
+@MainActor public enum ParticipantOrders {
+ private static var kept: [String: ParticipantOrder] = [:]
+ public static func load(meeting: String) -> ParticipantOrder {
+ kept[ParticipantOrder.storageKey(meeting: meeting)].map { ParticipantOrder(keys: $0.keys) }
+ ?? ParticipantOrder()
+ }
+ public static func save(_ order: ParticipantOrder, meeting: String) {
+ guard !meeting.isEmpty else { return }
+ kept[ParticipantOrder.storageKey(meeting: meeting)] = order
+ }
+ static func forgetAll() { kept = [:] }
+}
+
+/// A person's key in an order: stable across a rejoin, which a peer id is
+/// not (every join is a new peer). You are "self"; anyone else is their
+/// name, with "#2", "#3" for a second and third of the same name, counted
+/// in roster order.
+public enum ParticipantKeys {
+ public static let me = "self"
+
+ /// Keys for `roster` (peer ids with their names, in roster order),
+ /// with `me` the peer id that is you.
+ public static func keys(roster: [(peer: String, name: String)], me: String) -> [(peer: String, key: String)] {
+ var count: [String: Int] = [:]
+ return roster.map { entry in
+ if entry.peer == me { return (entry.peer, Self.me) }
+ let n = (count[entry.name] ?? 0) + 1
+ count[entry.name] = n
+ return (entry.peer, "name:" + entry.name + (n > 1 ? "#\(n)" : ""))
+ }
+ }
+}
+
+// MARK: - The active speaker
+
+/// Who is speaking, for the stage: the person who has been the loudest
+/// speaker for long enough, held there through pauses and interjections.
+///
+/// Fed a few times a second with who is speaking and how loudly (the
+/// speaking gate and level the glow already use), and the time. The
+/// stage moves to someone only once they have been the dominant speaker
+/// for `dominance`, and not until whoever is on it has had `minimumHold`
+/// there — so a laugh, a "yes" or a cough never takes it, and two people
+/// talking over each other do not make it flap. You are never the active
+/// speaker: you know what you look like.
+public struct ActiveSpeaker: Equatable, Sendable {
+ /// How long someone must be the loudest speaker before the stage goes
+ /// to them. Most interjections ("mm", "right", a laugh) are under a
+ /// second; a turn at speaking is longer.
+ public static let dominance: Double = 1.3
+ /// The least time someone stays on the stage once there, whoever speaks
+ /// meanwhile: a stage that changes more often than this reads as a
+ /// flicker, not a conversation.
+ public static let minimumHold: Double = 3
+
+ /// One reading of one person.
+ public struct Voice: Equatable, Sendable {
+ public var peer: String
+ /// Speaking, per the speaking gate (on at -30 dBFS, off after 0.6 s
+ /// under -42).
+ public var speaking: Bool
+ /// The level, 0...1 of full scale.
+ public var level: Float
+ public init(peer: String, speaking: Bool, level: Float) {
+ self.peer = peer
+ self.speaking = speaking
+ self.level = level
+ }
+ }
+
+ /// Who is on the stage, or nil before anyone has spoken for long
+ /// enough (the stage then shows the first person in the viewer's order).
+ public private(set) var current: String?
+ /// When `current` took the stage.
+ public private(set) var since: Double = -.infinity
+ /// Who is loudest now, and since when, while they are not `current`.
+ public private(set) var candidate: String?
+ public private(set) var candidateSince: Double = 0
+
+ public init() {}
+
+ /// The loudest person speaking, you left out.
+ public static func dominant(_ voices: [Voice], me: String?) -> String? {
+ voices.filter { $0.speaking && $0.peer != me }.max { $0.level < $1.level }?.peer
+ }
+
+ /// Takes one reading. `present` is everyone else still in the call;
+ /// someone who has left stops being the speaker at once. Returns
+ /// whether `current` changed.
+ @discardableResult
+ public mutating func update(_ voices: [Voice], present: Set<String>, me: String?, now: Double) -> Bool {
+ let before = current
+ if let who = current, !present.contains(who) {
+ current = nil
+ since = -.infinity
+ }
+ let loudest = Self.dominant(voices.filter { present.contains($0.peer) }, me: me)
+ if loudest == nil || loudest == current {
+ // A pause, or the speaker speaking: nobody is taking over.
+ candidate = nil
+ } else if loudest != candidate {
+ candidate = loudest
+ candidateSince = now
+ }
+ if let next = candidate, now - candidateSince >= Self.dominance, now - since >= Self.minimumHold {
+ current = next
+ since = now
+ candidate = nil
+ }
+ return current != before
+ }
+
+ /// Forgets everything: a new call.
+ public mutating func reset() { self = ActiveSpeaker() }
+}
+
+// MARK: - The stage in Speaker view
+
+extension Stage {
+ /// What Speaker view puts on the stage. A pin stands while what is
+ /// pinned is still there. Otherwise a shared screen, the newest first,
+ /// as before 0.4.0: somebody who shares is asking to be looked at. Then
+ /// the active speaker, and before anyone has spoken, the first person
+ /// in the viewer's order who is not you. Alone, nothing (the stage says
+ /// who it is waiting for). You are on it only if you pinned yourself.
+ public static func speaker(
+ pinned: StageItem?, active: String?, others: [String], screens: [String], me: String? = nil
+ ) -> StageItem? {
+ if let pinned {
+ switch pinned {
+ case .camera(let peer) where others.contains(peer) || (peer == me && me != nil): return pinned
+ case .screen(let peer) where screens.contains(peer): return pinned
+ default: break
+ }
+ }
+ if let newest = screens.last { return .screen(newest) }
+ if let active, others.contains(active) { return .camera(active) }
+ return others.first.map(StageItem.camera)
+ }
+
+ /// The filmstrip under the stage: every screen not on the stage (newest
+ /// first), then every camera, yours included, in the viewer's `order`,
+ /// less whatever is on the stage.
+ public static func filmstrip(stage: StageItem?, order: [String], screens: [String]) -> [StageItem] {
+ let shared = screens.reversed().map(StageItem.screen).filter { $0 != stage }
+ return shared + order.map(StageItem.camera).filter { $0 != stage }
+ }
+
+ /// Whether you float over the stage as the self view rather than sit in
+ /// the filmstrip: only when the filmstrip would hold nobody but you —
+ /// a call of two, or a call of one — which keeps a two-person call
+ /// exactly as it has looked since 0.3.4 (the other person filling the
+ /// window, you in the corner). With anyone else to show, you are one of
+ /// the filmstrip, in your place in the order.
+ public static func selfFloats(filmstrip: [StageItem], me: String?) -> Bool {
+ guard let me else { return true }
+ return filmstrip.allSatisfy { $0 == .camera(me) }
+ }
+}
+
+/// What is said when the stage changes by the person's hand. Automatic
+/// switches are not announced: in a lively call they would talk over the
+/// talking. The stage's own label always says who is on it.
+public enum StageWords {
+ public static func onStage(_ name: String) -> String { "On stage: " + name }
+ public static func pinned(_ name: String) -> String { "Pinned " + name + " to the stage" }
+ public static let unpinned = "Unpinned. The stage follows whoever is speaking."
+ public static func pin(_ name: String) -> String { "Pin " + name }
+ public static func unpin(_ name: String) -> String { "Unpin " + name }
+ public static let moveEarlier = "Move earlier"
+ public static let moveLater = "Move later"
+ public static let moveToFirst = "Move to first"
+ public static func moved(_ name: String, to index: Int, of count: Int) -> String {
+ name + " moved to position \(index + 1) of \(count)"
+ }
+}
+
+/// A tile's accessibility label, said as the stage when it is on it.
+public func stageLabel(_ label: String, onStage: Bool, pinned: Bool) -> String {
+ guard onStage else { return label }
+ return StageWords.onStage(label) + (pinned ? ", pinned" : "")
+}
diff --git a/native/apple/Tests/CallsKitTests/ScreenShareTests.swift b/native/apple/Tests/CallsKitTests/ScreenShareTests.swift
index 78939ec..2af77cd 100644
--- a/native/apple/Tests/CallsKitTests/ScreenShareTests.swift
+++ b/native/apple/Tests/CallsKitTests/ScreenShareTests.swift
@@ -114,26 +114,30 @@ final class ScreenShareTests: XCTestCase {
// ---------------------------------------------------------------
func testTheStageIsTheFirstCameraWithNoScreens() {
- XCTAssertEqual(Stage.focus(chosen: nil, peers: ["a", "b"], screens: []), .camera("a"))
- XCTAssertNil(Stage.focus(chosen: nil, peers: [], screens: []))
+ XCTAssertEqual(Stage.speaker(pinned: nil, active: nil, others: ["a", "b"], screens: []), .camera("a"))
+ XCTAssertNil(Stage.speaker(pinned: nil, active: nil, others: [], screens: []))
}
func testTheNewestScreenTakesTheStage() {
- XCTAssertEqual(Stage.focus(chosen: nil, peers: ["a", "b"], screens: ["b"]), .screen("b"))
- XCTAssertEqual(Stage.focus(chosen: nil, peers: ["a", "b"], screens: ["b", "a"]), .screen("a"))
+ XCTAssertEqual(Stage.speaker(pinned: nil, active: "a", others: ["a", "b"], screens: ["b"]), .screen("b"))
+ XCTAssertEqual(
+ Stage.speaker(pinned: nil, active: nil, others: ["a", "b"], screens: ["b", "a"]), .screen("a"))
}
- func testAChoiceStandsWhileItIsThere() {
+ func testAPinStandsWhileItIsThere() {
XCTAssertEqual(
- Stage.focus(chosen: .camera("a"), peers: ["a", "b"], screens: ["b"]), .camera("a"))
+ Stage.speaker(pinned: .camera("a"), active: "b", others: ["a", "b"], screens: ["b"]), .camera("a"))
XCTAssertEqual(
- Stage.focus(chosen: .screen("a"), peers: ["a", "b"], screens: ["a", "b"]), .screen("a"))
+ Stage.speaker(pinned: .screen("a"), active: nil, others: ["a", "b"], screens: ["a", "b"]),
+ .screen("a"))
}
- func testAChoiceThatWentAwayFallsBack() {
+ func testAPinThatWentAwayFallsBack() {
// A stopped screen, and a person who left: neither leaves the stage
// empty while there is something else to show.
- XCTAssertEqual(Stage.focus(chosen: .screen("a"), peers: ["a", "b"], screens: []), .camera("a"))
- XCTAssertEqual(Stage.focus(chosen: .camera("c"), peers: ["a"], screens: ["a"]), .screen("a"))
+ XCTAssertEqual(
+ Stage.speaker(pinned: .screen("a"), active: nil, others: ["a", "b"], screens: []), .camera("a"))
+ XCTAssertEqual(
+ Stage.speaker(pinned: .camera("c"), active: nil, others: ["a"], screens: ["a"]), .screen("a"))
}
}
diff --git a/native/apple/Tests/CallsKitTests/SpeakerViewTests.swift b/native/apple/Tests/CallsKitTests/SpeakerViewTests.swift
new file mode 100644
index 0000000..732f62d
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/SpeakerViewTests.swift
@@ -0,0 +1,370 @@
+import XCTest
+
+@testable @_spi(Fixtures) import CallsKit
+
+/// 0.4.0: the viewer's order, the active speaker, Speaker view's stage and
+/// filmstrip, and Focus becoming Speaker.
+final class ParticipantOrderTests: XCTestCase {
+ func testNewArrivalsGoOnTheEndAndLeaversAreRemoved() {
+ var order = ParticipantOrder()
+ order.merge(present: ["self", "name:Ana"])
+ XCTAssertEqual(order.shown, ["self", "name:Ana"])
+ order.merge(present: ["self", "name:Ana", "name:Tom"])
+ XCTAssertEqual(order.shown, ["self", "name:Ana", "name:Tom"])
+ order.move("name:Tom", to: 0)
+ order.merge(present: ["name:Li", "self", "name:Tom", "name:Ana"])
+ XCTAssertEqual(order.shown, ["name:Tom", "self", "name:Ana", "name:Li"], "roster order does not undo the viewer's")
+ order.merge(present: ["self", "name:Tom", "name:Li"])
+ XCTAssertEqual(order.shown, ["name:Tom", "self", "name:Li"])
+ XCTAssertFalse(order.keys.contains("name:Ana"), "a leaver is gone, not remembered")
+ order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana"])
+ XCTAssertEqual(order.shown.last, "name:Ana", "and comes back at the end")
+ }
+
+ func testMovingTo() {
+ var order = ParticipantOrder()
+ order.merge(present: ["a", "b", "c", "d"])
+ XCTAssertTrue(order.move("d", to: 1))
+ XCTAssertEqual(order.shown, ["a", "d", "b", "c"])
+ XCTAssertTrue(order.move("a", to: 99))
+ XCTAssertEqual(order.shown, ["d", "b", "c", "a"], "past the end is last")
+ XCTAssertTrue(order.move("c", to: -3))
+ XCTAssertEqual(order.shown, ["c", "d", "b", "a"], "before the start is first")
+ XCTAssertFalse(order.move("c", to: 0), "already there")
+ XCTAssertFalse(order.move("zz", to: 0), "not here")
+ }
+
+ func testEarlierLaterFirst() {
+ var order = ParticipantOrder()
+ order.merge(present: ["a", "b", "c"])
+ XCTAssertFalse(order.moveEarlier("a"))
+ XCTAssertFalse(order.moveLater("c"))
+ XCTAssertTrue(order.moveLater("a"))
+ XCTAssertEqual(order.shown, ["b", "a", "c"])
+ XCTAssertTrue(order.moveEarlier("c"))
+ XCTAssertEqual(order.shown, ["b", "c", "a"])
+ XCTAssertTrue(order.moveToFirst("a"))
+ XCTAssertEqual(order.shown, ["a", "b", "c"])
+ XCTAssertFalse(order.moveToFirst("a"))
+ XCTAssertEqual(order.canMove("a").earlier, false)
+ XCTAssertEqual(order.canMove("a").later, true)
+ XCTAssertEqual(order.canMove("c").later, false)
+ }
+
+ /// Put back for a rejoin: the people remembered keep their places as
+ /// they arrive, one by one, and nobody is lost for not being in the
+ /// first roster.
+ func testARestoredOrderKeepsItsPlacesAsPeopleArrive() throws {
+ var kept = ParticipantOrder()
+ kept.merge(present: ["self", "name:Ana", "name:Tom", "name:Li"])
+ kept.move("name:Li", to: 0)
+ kept.move("self", to: 3)
+ let data = try JSONEncoder().encode(kept)
+ var order = try JSONDecoder().decode(ParticipantOrder.self, from: data)
+ XCTAssertEqual(order.shown, [], "nobody is here yet")
+ order.merge(present: ["self"])
+ XCTAssertEqual(order.shown, ["self"])
+ order.merge(present: ["self", "name:Tom"])
+ order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana"])
+ XCTAssertEqual(order.shown, ["name:Li", "name:Ana", "name:Tom", "self"])
+ order.merge(present: ["self", "name:Tom", "name:Li", "name:Ana", "name:Sam"])
+ XCTAssertEqual(order.shown.last, "name:Sam")
+ }
+
+ /// A move among the people here leaves remembered absentees where they
+ /// were.
+ func testMovingSkipsTheAbsent() {
+ var order = ParticipantOrder(keys: ["a", "gone", "b", "c"])
+ order.merge(present: ["a", "b", "c"])
+ // "gone" was never seen, so it is remembered, not a leaver.
+ XCTAssertEqual(order.keys, ["a", "gone", "b", "c"])
+ order.move("c", to: 0)
+ XCTAssertEqual(order.shown, ["c", "a", "b"])
+ XCTAssertEqual(order.keys, ["c", "gone", "a", "b"])
+ }
+
+ func testThePersistenceKey() {
+ XCTAssertEqual(
+ ParticipantOrder.storageKey(meeting: "https://calls.oficina.works/abc"),
+ "calls.order.https://calls.oficina.works/abc")
+ }
+
+ @MainActor func testOrdersAreKeptPerMeetingForTheSession() {
+ ParticipantOrders.forgetAll()
+ defer { ParticipantOrders.forgetAll() }
+ var order = ParticipantOrder()
+ order.merge(present: ["self", "name:Ana"])
+ order.move("name:Ana", to: 0)
+ ParticipantOrders.save(order, meeting: "s/one")
+ XCTAssertEqual(ParticipantOrders.load(meeting: "s/one").keys, ["name:Ana", "self"])
+ XCTAssertEqual(ParticipantOrders.load(meeting: "s/two").keys, [])
+ ParticipantOrders.save(order, meeting: "")
+ XCTAssertEqual(ParticipantOrders.load(meeting: "").keys, [], "a call with no meeting keeps nothing")
+ }
+
+ func testKeysAreNamesSoARejoinKeepsThem() {
+ let keys = ParticipantKeys.keys(
+ roster: [("p1", "Paul"), ("p2", "Ana"), ("p3", "Ana"), ("p4", "Tom")], me: "p1")
+ XCTAssertEqual(keys.map(\.key), ["self", "name:Ana", "name:Ana#2", "name:Tom"])
+ let again = ParticipantKeys.keys(roster: [("q9", "Ana"), ("q1", "Paul")], me: "q1")
+ XCTAssertEqual(again.map(\.key), ["name:Ana", "self"], "new peer ids, the same keys")
+ }
+}
+
+final class ActiveSpeakerTests: XCTestCase {
+ typealias Voice = ActiveSpeaker.Voice
+ let everyone: Set<String> = ["ana", "tom", "li"]
+
+ /// Feeds readings at 5 Hz, as the app takes them, from `from` to `to`
+ /// seconds, with `speaking` speaking at their levels.
+ private func run(
+ _ rule: inout ActiveSpeaker, from: Double, to: Double, _ speaking: [String: Float],
+ present: Set<String>? = nil, me: String? = "me"
+ ) {
+ var step = 0
+ while from + Double(step) * 0.2 < to - 1e-9 {
+ let t = from + Double(step) * 0.2
+ step += 1
+ let voices = (present ?? everyone).union(me.map { [$0] } ?? []).sorted().map { peer in
+ Voice(peer: peer, speaking: speaking[peer] != nil, level: speaking[peer] ?? 0.001)
+ }
+ rule.update(voices, present: present ?? everyone, me: me, now: t)
+ }
+ }
+
+ func testTheThresholdsAreInTheRangeAsked() {
+ XCTAssertGreaterThanOrEqual(ActiveSpeaker.dominance, 1.2)
+ XCTAssertLessThanOrEqual(ActiveSpeaker.dominance, 1.5)
+ XCTAssertGreaterThanOrEqual(ActiveSpeaker.minimumHold, 3)
+ }
+
+ func testTheStageGoesToSomeoneOnlyAfterTheDominanceDelay() {
+ var rule = ActiveSpeaker()
+ run(&rule, from: 0, to: 1.2, ["ana": 0.2])
+ XCTAssertNil(rule.current, "1.2 s is not yet a turn")
+ run(&rule, from: 1.2, to: 1.5, ["ana": 0.2])
+ XCTAssertEqual(rule.current, "ana")
+ XCTAssertEqual(rule.since, 1.4, accuracy: 0.001)
+ }
+
+ func testTheLoudestSpeakerIsTheDominantOne() {
+ var rule = ActiveSpeaker()
+ run(&rule, from: 0, to: 2, ["ana": 0.05, "tom": 0.3])
+ XCTAssertEqual(rule.current, "tom")
+ }
+
+ func testAnInterjectionDoesNotTakeTheStage() {
+ var rule = ActiveSpeaker()
+ run(&rule, from: 0, to: 5, ["ana": 0.2])
+ XCTAssertEqual(rule.current, "ana")
+ // Tom: "right, yes" for a second, louder than Ana.
+ run(&rule, from: 5, to: 6, ["ana": 0.1, "tom": 0.4])
+ run(&rule, from: 6, to: 10, ["ana": 0.2])
+ XCTAssertEqual(rule.current, "ana")
+ }
+
+ func testPausesHoldTheCurrentSpeaker() {
+ var rule = ActiveSpeaker()
+ run(&rule, from: 0, to: 2, ["tom": 0.2])
+ XCTAssertEqual(rule.current, "tom")
+ run(&rule, from: 2, to: 30, [:])
+ XCTAssertEqual(rule.current, "tom", "silence changes nothing")
+ }
+
+ func testTheMinimumHoldHoldsThroughATakeover() {
+ var rule = ActiveSpeaker()
+ run(&rule, from: 0, to: 1.5, ["ana": 0.2])
+ XCTAssertEqual(rule.current, "ana")
+ let took = rule.since
+ // Tom starts at once and keeps going: dominant long enough after
+ // 1.3 s, but Ana has not had her 3 s yet.
+ run(&rule, from: 1.6, to: took + 2.9, ["tom": 0.3])
+ XCTAssertEqual(rule.current, "ana")
+ run(&rule, from: took + 3, to: took + 3.1, ["tom": 0.3])
+ XCTAssertEqual(rule.current, "tom")
+ }
+
+ func testTwoPeopleTalkingOverEachOtherDoNotFlap() {
+ var rule = ActiveSpeaker()
+ var switches = 0
+ var last: String?
+ var t = 0.0
+ // Alternating every 0.8 s for a minute: each is loudest for less than
+ // the dominance delay.
+ while t < 60 {
+ let who = Int(t / 0.8) % 2 == 0 ? "ana" : "tom"
+ let other = who == "ana" ? "tom" : "ana"
+ rule.update(
+ [Voice(peer: who, speaking: true, level: 0.3), Voice(peer: other, speaking: true, level: 0.1)],
+ present: everyone, me: "me", now: t)
+ if rule.current != last {
+ switches += 1
+ last = rule.current
+ }
+ t += 0.2
+ }
+ XCTAssertEqual(switches, 0, "nobody held the floor for 1.3 s")
+ }
+
+ func testNeverYou() {
+ var rule = ActiveSpeaker()
+ let present: Set<String> = ["ana", "me"]
+ for step in 0..<50 {
+ rule.update(
+ [Voice(peer: "me", speaking: true, level: 0.9), Voice(peer: "ana", speaking: false, level: 0)],
+ present: present, me: "me", now: Double(step) * 0.2)
+ }
+ XCTAssertNil(rule.current, "your own voice never puts you on the stage")
+ }
+
+ func testALeaverLeavesTheStageAtOnce() {
+ var rule = ActiveSpeaker()
+ run(&rule, from: 0, to: 2, ["li": 0.2])
+ XCTAssertEqual(rule.current, "li")
+ XCTAssertTrue(rule.update([], present: ["ana", "tom"], me: "me", now: 2.2))
+ XCTAssertNil(rule.current)
+ // And the next speaker needs only the dominance delay, not a hold.
+ run(&rule, from: 2.4, to: 3.9, ["ana": 0.2], present: ["ana", "tom"])
+ XCTAssertEqual(rule.current, "ana")
+ }
+}
+
+final class SpeakerStageTests: XCTestCase {
+ func testPinBeatsScreenBeatsSpeakerBeatsOrder() {
+ let others = ["ana", "tom", "li"]
+ XCTAssertEqual(Stage.speaker(pinned: nil, active: nil, others: others, screens: []), .camera("ana"))
+ XCTAssertEqual(Stage.speaker(pinned: nil, active: "li", others: others, screens: []), .camera("li"))
+ XCTAssertEqual(Stage.speaker(pinned: nil, active: "li", others: others, screens: ["tom"]), .screen("tom"))
+ XCTAssertEqual(
+ Stage.speaker(pinned: .camera("ana"), active: "li", others: others, screens: ["tom"]), .camera("ana"),
+ "a new screen does not take a pinned stage")
+ XCTAssertEqual(
+ Stage.speaker(pinned: .camera("me"), active: "li", others: others, screens: [], me: "me"), .camera("me"),
+ "you only by your own pin")
+ }
+
+ func testTheSpeakerWhoLeftFallsBackToTheOrder() {
+ XCTAssertEqual(Stage.speaker(pinned: nil, active: "gone", others: ["tom", "ana"], screens: []), .camera("tom"))
+ }
+
+ func testTheFilmstripIsEveryoneElseInTheViewersOrder() {
+ let order = ["tom", "me", "ana", "li"]
+ XCTAssertEqual(
+ Stage.filmstrip(stage: .camera("ana"), order: order, screens: []),
+ [.camera("tom"), .camera("me"), .camera("li")])
+ // A screen on the stage: the sharer's camera is in the filmstrip.
+ XCTAssertEqual(
+ Stage.filmstrip(stage: .screen("li"), order: order, screens: ["li"]),
+ [.camera("tom"), .camera("me"), .camera("ana"), .camera("li")])
+ // Two screens: the other one leads the filmstrip.
+ XCTAssertEqual(
+ Stage.filmstrip(stage: .screen("li"), order: order, screens: ["ana", "li"]).first, .screen("ana"))
+ }
+
+ func testYouFloatOnlyWhenTheFilmstripWouldBeYouAlone() {
+ XCTAssertTrue(Stage.selfFloats(filmstrip: [.camera("me")], me: "me"), "a call of two")
+ XCTAssertTrue(Stage.selfFloats(filmstrip: [], me: "me"))
+ XCTAssertFalse(Stage.selfFloats(filmstrip: [.camera("me"), .camera("tom")], me: "me"))
+ XCTAssertFalse(Stage.selfFloats(filmstrip: [.camera("me"), .screen("tom")], me: "me"))
+ }
+
+ func testStageLabels() {
+ XCTAssertEqual(stageLabel("Tony", onStage: true, pinned: false), "On stage: Tony")
+ XCTAssertEqual(stageLabel("Tony, muted", onStage: true, pinned: true), "On stage: Tony, muted, pinned")
+ XCTAssertEqual(stageLabel("Tony", onStage: false, pinned: true), "Tony")
+ }
+}
+
+final class VideoLayoutTests: XCTestCase {
+ private var defaults: UserDefaults!
+ private let suite = "calls.tests.videoLayout"
+
+ override func setUp() {
+ defaults = UserDefaults(suiteName: suite)
+ defaults.removePersistentDomain(forName: suite)
+ }
+ override func tearDown() { defaults.removePersistentDomain(forName: suite) }
+
+ func testFocusBecomesSpeaker() {
+ XCTAssertEqual(VideoLayout.stored("focus"), .speaker)
+ XCTAssertEqual(VideoLayout.stored("grid"), .grid)
+ XCTAssertEqual(VideoLayout.stored("speaker"), .speaker)
+ XCTAssertEqual(VideoLayout.stored(nil), .speaker)
+ XCTAssertEqual(VideoLayout.stored("mosaic"), .speaker)
+ }
+
+ func testASavedFocusIsRewrittenOnce() {
+ defaults.set("focus", forKey: VideoLayout.key)
+ XCTAssertEqual(VideoLayout.load(defaults), .speaker)
+ XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "speaker")
+ defaults.set("grid", forKey: VideoLayout.key)
+ XCTAssertEqual(VideoLayout.load(defaults), .grid)
+ defaults.removeObject(forKey: VideoLayout.key)
+ XCTAssertEqual(VideoLayout.load(defaults), .speaker)
+ XCTAssertNil(defaults.string(forKey: VideoLayout.key), "a default is not written down")
+ }
+
+ @MainActor func testChoosingIsKeptAndPinningChoosesSpeaker() {
+ let arrangement = CallArrangement(defaults: defaults)
+ XCTAssertEqual(arrangement.layout, .speaker)
+ arrangement.choose(.grid)
+ XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "grid")
+ XCTAssertEqual(CallArrangement(defaults: defaults).layout, .grid, "the next call starts in Grid")
+ arrangement.pin(.camera("ana"))
+ XCTAssertEqual(arrangement.layout, .speaker)
+ arrangement.show(.grid)
+ XCTAssertEqual(defaults.string(forKey: VideoLayout.key), "speaker", "a fixture's layout is not kept")
+ }
+}
+
+/// The arrangement over a real roster: peer ids in, stable keys kept.
+@MainActor final class CallArrangementTests: XCTestCase {
+ private let suite = "calls.tests.arrangement"
+
+ private func roster(_ phase: String, me: String, _ people: [(String, String)]) -> RoomState {
+ let peers = people.map { #"{"peer":"\#($0.0)","name":"\#($0.1)","kind":"member"}"# }
+ let json = #"{"phase":"\#(phase)","peer":"\#(me)","boot":"t","peers":[\#(peers.joined(separator: ","))],"waiting":[]}"#
+ return try! JSONDecoder().decode(RoomState.self, from: Data(json.utf8))
+ }
+
+ func testARejoinPutsTheOrderBack() {
+ ParticipantOrders.forgetAll()
+ defer { ParticipantOrders.forgetAll() }
+ let defaults = UserDefaults(suiteName: suite)!
+ let arrangement = CallArrangement(defaults: defaults)
+ arrangement.begin(meeting: "https://x/m1")
+ arrangement.roster(roster("admitted", me: "p1", [("p1", "Paul"), ("p2", "Ana"), ("p3", "Tom")]))
+ XCTAssertEqual(arrangement.people, ["p1", "p2", "p3"])
+ XCTAssertTrue(arrangement.move("p3", to: 0))
+ XCTAssertTrue(arrangement.moveLater("p1"))
+ XCTAssertEqual(arrangement.people, ["p3", "p2", "p1"])
+ XCTAssertEqual(arrangement.others, ["p3", "p2"])
+ // Leaving: the roster goes, the order is kept.
+ arrangement.roster(nil)
+ // Rejoining: new peer ids, and the roster arriving in pieces.
+ let again = CallArrangement(defaults: defaults)
+ again.begin(meeting: "https://x/m1")
+ again.roster(roster("admitted", me: "q1", [("q1", "Paul")]))
+ again.roster(roster("admitted", me: "q1", [("q1", "Paul"), ("q7", "Ana"), ("q8", "Tom")]))
+ XCTAssertEqual(again.people, ["q8", "q7", "q1"])
+ // A roster while reconnecting says nothing about who left.
+ again.roster(roster("reconnecting", me: "q1", [("q1", "Paul")]))
+ XCTAssertEqual(again.people, ["q8", "q7", "q1"])
+ defaults.removePersistentDomain(forName: suite)
+ }
+
+ func testThePinAndTheStage() {
+ let defaults = UserDefaults(suiteName: suite)!
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let arrangement = CallArrangement(defaults: defaults)
+ arrangement.roster(roster("admitted", me: "p1", [("p1", "Paul"), ("p2", "Ana"), ("p3", "Tom")]))
+ XCTAssertEqual(arrangement.stage(active: "p3", screens: []), .camera("p3"))
+ arrangement.pin(.camera("p2"))
+ XCTAssertEqual(arrangement.stage(active: "p3", screens: ["p3"]), .camera("p2"))
+ arrangement.togglePin(.camera("p2"))
+ XCTAssertNil(arrangement.pinned)
+ XCTAssertEqual(arrangement.stage(active: "p3", screens: ["p3"]), .screen("p3"))
+ XCTAssertEqual(
+ arrangement.filmstrip(stage: .camera("p3"), screens: []), [.camera("p1"), .camera("p2")])
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/WindowMoveTests.swift b/native/apple/Tests/CallsKitTests/WindowMoveTests.swift
index 43ce68b..5bfe855 100644
--- a/native/apple/Tests/CallsKitTests/WindowMoveTests.swift
+++ b/native/apple/Tests/CallsKitTests/WindowMoveTests.swift
@@ -46,6 +46,7 @@
/// window behind another app's must move by its picture too.
@MainActor final class CallWindowMouseTests: XCTestCase {
private var window: NSWindow!
+ private var call: Call!
private let start = NSRect(x: 200, y: 200, width: 1000, height: 700)
override func setUp() async throws {
@@ -60,7 +61,7 @@
}
private func open(_ fixture: CallFixture = .focus, grid: Bool = false, controls: Bool = false) {
- let call = Call(playAudio: false)
+ call = Call(playAudio: false)
fixture.apply(to: call)
window = NSWindow(
contentRect: start, styleMask: [.titled, .closable, .miniaturizable, .resizable],
@@ -217,6 +218,34 @@
XCTAssertEqual(moved, CGVector(dx: 0, dy: 0))
}
+ /// 0.4.0: a click on a filmstrip tile pins that person to the stage,
+ /// and moves nothing. Ana is on the stage (first in the order, before
+ /// anyone has spoken); you, Tom and Li are the filmstrip along the
+ /// bottom, 117 pt tall and centred.
+ func testAClickOnTheFilmstripPins() {
+ open(.grid)
+ XCTAssertEqual(call.arrangement.layout, .speaker)
+ let before = window.frame.origin
+ NSApp.sendEvent(event(.leftMouseDown, at: NSPoint(x: 716, y: 66)))
+ pump(0.05)
+ NSApp.sendEvent(event(.leftMouseUp, at: NSPoint(x: 716, y: 66)))
+ // A single click is known only once a double click has not come.
+ pump(NSEvent.doubleClickInterval + 0.3)
+ XCTAssertEqual(window.frame.origin, before)
+ XCTAssertEqual(call.arrangement.pinned, .camera("li"))
+ XCTAssertEqual(call.arrangement.layout, .speaker)
+ }
+
+ /// 0.4.0: a drag on a Grid tile takes it to another's place, and the
+ /// window stays where it is.
+ func testDraggingAGridTileReordersAndDoesNotMoveTheWindow() {
+ open(.grid, grid: true)
+ XCTAssertEqual(call.arrangement.people, ["me", "ana", "tom", "li"])
+ let moved = drag(from: NSPoint(x: 750, y: 175), by: CGVector(dx: -500, dy: 350))
+ XCTAssertEqual(moved, CGVector(dx: 0, dy: 0), "a tile is not a handle")
+ XCTAssertEqual(call.arrangement.people, ["li", "me", "ana", "tom"])
+ }
+
func testTheBarIsNotAHandle() {
open(controls: true)
let moved = drag(from: NSPoint(x: bounds.midX, y: 40), by: CGVector(dx: 30, dy: 30))
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 9b1f839..43e0258 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -109,8 +109,11 @@ struct PhoneCall: View {
/// one sheet at a time.
@State var afterMore: MoreAction?
@State var readChatCount = 0
- @State var grid = false
- @State var chosen: StageItem?
+ @ObservedObject var arrangement: CallArrangement
+ @ObservedObject var speakerNow: SpeakerNow
+ /// A tile being dragged to a new place, and where every tile is.
+ @State var drag: TileDrag?
+ @State var tileFrames: [String: CGRect] = [:]
@State var dismissedNotices: Set<String> = []
@State var pipDrag: CGSize = .zero
@StateObject var draft = ChatDraft()
@@ -129,21 +132,78 @@ struct PhoneCall: View {
init(
call: Call, leave: @escaping () -> Void, style: CallLayoutStyle? = nil,
- controlsShown: Bool? = nil, showingMore: Bool = false, grid: Bool = false
+ controlsShown: Bool? = nil, showingMore: Bool = false, grid: Bool? = nil
) {
self.call = call
self.leave = leave
self.style = style
_showingMore = State(initialValue: showingMore)
- _grid = State(initialValue: grid)
+ if let grid { call.arrangement.show(grid ? .grid : .speaker) }
+ arrangement = call.arrangement
+ speakerNow = call.levels.speaker
_chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .touch, pinned: controlsShown))
}
var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle }
- var peers: [RosterPeer] { call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] }
- var focused: StageItem? {
- Stage.focus(chosen: chosen, peers: peers.map(\.peer), screens: call.screens)
+ var grid: Bool { arrangement.layout == .grid }
+ /// Everyone else, in this viewer's order.
+ var peers: [RosterPeer] {
+ let roster = call.state?.peers ?? []
+ return arrangement.others.compactMap { id in roster.first { $0.peer == id } }
+ }
+ /// What Speaker view's stage shows, and the filmstrip under it.
+ var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) }
+ var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) }
+ /// You float over the stage only when you would be all the filmstrip
+ /// holds (see Stage.selfFloats).
+ var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) }
+ var gridItems: [StageItem] {
+ call.screens.reversed().map(StageItem.screen) + arrangement.people.map(StageItem.camera)
+ }
+
+ /// A tile for anything the call can show, as it is seen now.
+ func phoneTile(
+ _ item: StageItem, cornerRadius: CGFloat = 18, compact: Bool = false,
+ labelInsets: EdgeInsets = EdgeInsets(), backdrop: Color = .black, onStage: Bool = false,
+ iconOnly: Bool = false
+ ) -> PhoneVideoTile {
+ let pinned = onStage && arrangement.pinned == item
+ switch item {
+ case .screen(let peer):
+ return PhoneVideoTile(
+ track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true,
+ cornerRadius: cornerRadius, compact: compact, labelInsets: labelInsets, backdrop: backdrop,
+ onStage: onStage, pinned: pinned, iconOnly: iconOnly)
+ case .camera(let peer) where peer == arrangement.me:
+ return PhoneVideoTile(
+ track: call.localTrack, label: "You", muted: call.muted, mirrored: true,
+ cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius,
+ compact: compact, labelInsets: labelInsets, backdrop: backdrop, audio: call.selfAudio,
+ onStage: onStage, pinned: pinned, iconOnly: iconOnly)
+ case .camera(let peer):
+ return PhoneVideoTile(
+ track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer),
+ cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact,
+ labelInsets: labelInsets, backdrop: backdrop, audio: call.tileAudio(peer),
+ onStage: onStage, pinned: pinned, iconOnly: iconOnly)
+ }
+ }
+
+ func itemTitle(_ item: StageItem) -> String {
+ switch item {
+ case .screen(let peer): screenCaption(call.name(of: peer))
+ case .camera(let peer): peer == arrangement.me ? "You" : call.name(of: peer)
+ }
+ }
+
+ /// A tile of Grid or the filmstrip: a long press picks it up to move
+ /// it, a tap pins it (the filmstrip), and VoiceOver has both as actions.
+ func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile {
+ ArrangeableTile(
+ item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag,
+ frames: tileFrames, tapToPin: tapToPin, touch: true, reduceMotion: reduceMotion,
+ fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 24, height: -30) : nil)
}
var body: some View {
@@ -170,10 +230,6 @@ struct PhoneCall: View {
screenShare.stop()
call.stopSharingScreen()
}
- .onChange(of: call.screens) { before, after in
- // A new screen takes the stage, as it does on the Mac.
- if after.contains(where: { !before.contains($0) }) { chosen = nil }
- }
.sheet(isPresented: $showingDevices) {
NavigationStack {
ScrollView { DevicePanel(call: call).frame(maxWidth: .infinity, alignment: .leading) }
@@ -214,29 +270,10 @@ struct PhoneCall: View {
}
Spacer()
Menu {
- Picker("Video layout", selection: $grid) {
- Label("Focus", systemImage: "pip").tag(false)
- Label("Grid", systemImage: "square.grid.2x2").tag(true)
- }
- if peers.count > 1 || !call.screens.isEmpty {
- Section("Focus on") {
- ForEach(call.screens, id: \.self) { peer in
- Button(screenCaption(call.name(of: peer))) {
- chosen = .screen(peer)
- grid = false
- }
- }
- ForEach(peers) { peer in
- Button(peer.name) {
- chosen = .camera(peer.peer)
- grid = false
- }
- }
- }
- }
+ LayoutChoices(call: call, arrangement: arrangement, title: itemTitle)
} label: {
Label(
- "Video layout", systemImage: grid ? "square.grid.2x2" : "pip"
+ "Video layout", systemImage: arrangement.layout.symbol
)
.labelStyle(.iconOnly).frame(width: 44, height: 44)
}.accessibilityIdentifier("videoLayout")
@@ -248,42 +285,21 @@ struct PhoneCall: View {
if grid {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
- ForEach(call.screens, id: \.self) { peer in
- PhoneVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true
- )
- .aspectRatio(4 / 3, contentMode: .fit)
- }
- PhoneVideoTile(
- track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff,
- placeholder: call.selfPlaceholder, audio: call.selfAudio
- )
- .aspectRatio(4 / 3, contentMode: .fit)
- ForEach(peers) { peer in
- PhoneVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer),
- audio: call.tileAudio(peer.peer)
- )
- .aspectRatio(4 / 3, contentMode: .fit)
+ ForEach(gridItems, id: \.self) { item in
+ phoneTile(item)
+ .aspectRatio(4 / 3, contentMode: .fit)
+ .modifier(arrangeable(item, tapToPin: false))
}
}
}
+ .coordinateSpace(name: arrangeSpace)
+ .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
} else {
GeometryReader { geometry in
ZStack(alignment: .bottomTrailing) {
- switch focused {
- case .screen(let peer):
- PhoneVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true)
- case .camera(let peer):
- PhoneVideoTile(
- track: call.remoteTracks[peer], label: call.name(of: peer),
- muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer),
- audio: call.tileAudio(peer))
- case nil:
+ if let focused {
+ phoneTile(focused, onStage: true)
+ } else {
ContentUnavailableView(
"Waiting for others", systemImage: "person.2",
description: Text("Others will appear here when they join.")
@@ -291,10 +307,7 @@ struct PhoneCall: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 18))
}
- PhoneVideoTile(
- track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff,
- placeholder: call.selfPlaceholder, audio: call.selfAudio
- )
+ phoneTile(.camera(arrangement.me ?? ""))
.frame(
width: min(160, geometry.size.width * 0.32),
height: min(120, geometry.size.height * 0.28)
@@ -400,6 +413,12 @@ struct PhoneVideoTile: View {
/// remote person's with "No sound from" and their notices. Nil for a
/// screen.
var audio: TileAudio? = nil
+ /// Speaker view's stage: VoiceOver hears "On stage: Ana", and whether
+ /// it was pinned there.
+ var onStage = false
+ var pinned = false
+ /// A filmstrip thumbnail: the symbol alone for a missing picture.
+ var iconOnly = false
var body: some View {
TileAudioReader(audio) { info in tile(info) }
}
@@ -415,7 +434,7 @@ struct PhoneVideoTile: View {
? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle"
)
.font(.largeTitle)
- if let placeholder, !cameraOff {
+ if let placeholder, !cameraOff, !iconOnly {
Text(placeholder).font(.caption).multilineTextAlignment(.center).padding(.horizontal, 6)
}
}.foregroundStyle(.white.opacity(0.8))
@@ -444,7 +463,7 @@ struct PhoneVideoTile: View {
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } }
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.accessibilityElement(children: .ignore)
- .accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info))
+ .accessibilityLabel(stageLabel(tileAccessibilityLabel(label, muted: muted, audio: info), onStage: onStage, pinned: pinned))
.accessibilityHint(info?.detail ?? "")
.accessibilityValue(
cameraOff
@@ -480,3 +499,40 @@ struct PhoneVideo: UIViewRepresentable {
}
}
}
+
+/// Speaker or Grid, and what is pinned to Speaker view's stage: the
+/// iPhone's layout menu, in the classic call and the controls sheet.
+struct LayoutChoices: View {
+ @ObservedObject var call: Call
+ @ObservedObject var arrangement: CallArrangement
+ let title: (StageItem) -> String
+
+ var body: some View {
+ Picker(
+ "Video layout", selection: Binding(get: { arrangement.layout }, set: { arrangement.choose($0) })
+ ) {
+ ForEach(VideoLayout.allCases) { layout in
+ Label(layout.title, systemImage: layout.symbol).tag(layout)
+ }
+ }
+ if arrangement.people.count > 2 || !call.screens.isEmpty {
+ Section("On the stage") {
+ Button {
+ arrangement.pin(nil)
+ } label: {
+ Label("Whoever is speaking", systemImage: arrangement.pinned == nil ? "checkmark" : "waveform")
+ }
+ ForEach(call.screens.reversed(), id: \.self) { peer in choice(.screen(peer)) }
+ ForEach(arrangement.others, id: \.self) { peer in choice(.camera(peer)) }
+ }
+ }
+ }
+
+ private func choice(_ item: StageItem) -> some View {
+ Button {
+ arrangement.pin(item)
+ } label: {
+ Label(title(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin")
+ }
+ }
+}
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift
index 05bef8a..16d9da5 100644
--- a/native/ios/Sources/PhoneCallFixture.swift
+++ b/native/ios/Sources/PhoneCallFixture.swift
@@ -12,7 +12,11 @@
/// muted|cameraOff|sharing` for your own state, `-CallsFixtureSpeaking
/// silent|low|mid|high` for everyone's speaking glow, and
/// `-CallsFixtureNoGlass YES` for the material older systems draw in
- /// place of Liquid Glass.
+ /// place of Liquid Glass. For Speaker view (0.4.0): `-CallsFixtureLayout
+ /// speaker|grid` (the grid fixture is Grid unless it says), and
+ /// `-CallsFixtureSpeaker tom`, `-CallsFixturePinned li`, `-CallsFixtureLift
+ /// li` (a tile half way through a drag) and `-CallsFixtureOrder
+ /// li,tom,me,ana`.
struct PhoneCallFixture: View {
static var requested: CallFixture? {
UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:))
@@ -30,7 +34,7 @@
call: call, leave: {},
style: defaults.bool(forKey: "CallsFixtureClassic") ? .classic : .fullWindow,
controlsShown: pinned, showingMore: defaults.bool(forKey: "CallsFixtureMore"),
- grid: fixture == .grid)
+ grid: defaults.string(forKey: "CallsFixtureLayout").map { $0 == "grid" } ?? (fixture == .grid))
}
.tint(CallsAppearance.accent)
.environment(
@@ -46,6 +50,11 @@
.flatMap(FixtureSelf.init(rawValue:))?.apply(to: call)
UserDefaults.standard.string(forKey: "CallsFixtureSpeaking")
.flatMap(FixtureSpeaking.init(rawValue:))?.apply(to: call)
+ call.levels.speaker.showFixture(defaults.string(forKey: "CallsFixtureSpeaker"))
+ call.arrangement.showPinned(defaults.string(forKey: "CallsFixturePinned").map(StageItem.camera))
+ call.arrangement.showLift(defaults.string(forKey: "CallsFixtureLift"))
+ call.arrangement.showOrder(
+ (defaults.string(forKey: "CallsFixtureOrder") ?? "").split(separator: ",").map(String.init))
}
}
}
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift
index 856c147..046fffe 100644
--- a/native/ios/Sources/PhoneFullScreenCall.swift
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -28,8 +28,7 @@ extension PhoneCall {
.contentShape(Rectangle())
.onTapGesture { chrome.tapped() }
.accessibilityAddTraits(.allowsDirectInteraction)
- .accessibilityIdentifier("callStage")
- if !grid { selfView(in: size, safe: safe) }
+ if !grid, selfFloats { selfView(in: size, safe: safe) }
topOverlay(safe: safe)
// Gone rather than transparent while hidden, so nothing invisible
// can be tapped or found; the fade is the transition.
@@ -66,7 +65,7 @@ extension PhoneCall {
}
) {
MoreControls(
- call: call, grid: $grid, chosen: $chosen, peers: peers, unread: unread,
+ call: call, arrangement: arrangement, title: itemTitle, unread: unread,
open: { action in
afterMore = action
showingMore = false
@@ -89,40 +88,100 @@ extension PhoneCall {
// MARK: Stage
@ViewBuilder private func stage(in size: CGSize, safe: EdgeInsets) -> some View {
+ Group {
+ if let fixtureBackdrop {
+ fixtureBackdrop.view.accessibilityHidden(true).accessibilityIdentifier("callStage")
+ } else if grid {
+ grid(in: size, safe: safe).accessibilityIdentifier("callStage")
+ } else {
+ speakerStage(in: size, safe: safe)
+ }
+ }
+ .coordinateSpace(name: arrangeSpace)
+ .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 }
+ }
+
+ /// Speaker view: the stage, and everyone else in a filmstrip along the
+ /// bottom that scrolls sideways, in this viewer's order. It sits just
+ /// above the home indicator and rises clear of the bar while the
+ /// controls show, as the self view does. In a call of two there is no
+ /// filmstrip: the other person fills the screen and you float.
+ private func speakerStage(in size: CGSize, safe: EdgeInsets) -> some View {
+ let strip = selfFloats ? [] : filmstrip
+ let thumb = Self.filmstripTile(in: size)
+ let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding
+ let raised = chrome.visible ? Self.barHeight : 0
let lift = EdgeInsets(
- top: 0, leading: safe.leading, bottom: safe.bottom + (chrome.visible ? Self.barHeight : 0),
+ top: 0, leading: safe.leading,
+ bottom: strip.isEmpty ? safe.bottom + raised : raised,
trailing: 0)
- if let fixtureBackdrop {
- fixtureBackdrop.view.accessibilityHidden(true)
- } else if grid {
- grid(in: size, safe: safe)
- } else {
- switch focused {
- case .screen(let peer):
- PhoneVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true, cornerRadius: 0, compact: true, labelInsets: lift)
- case .camera(let peer):
- PhoneVideoTile(
- track: call.remoteTracks[peer], label: call.name(of: peer),
- muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer),
- cornerRadius: 0, compact: true, labelInsets: lift, audio: call.tileAudio(peer))
- case nil:
- ContentUnavailableView(
- "Waiting for others", systemImage: "person.2",
- description: Text("Others will appear here when they join.")
- )
- .foregroundStyle(.white.opacity(0.85))
- .environment(\.colorScheme, .dark)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ return ZStack(alignment: .bottom) {
+ Group {
+ if let focused {
+ phoneTile(focused, cornerRadius: 0, compact: true, labelInsets: lift, onStage: true)
+ } else {
+ ContentUnavailableView(
+ "Waiting for others", systemImage: "person.2",
+ description: Text("Others will appear here when they join.")
+ )
+ .foregroundStyle(.white.opacity(0.85))
+ .environment(\.colorScheme, .dark)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ // The stage, and not the filmstrip, is what the UI tests tap to
+ // bring the controls back.
+ .accessibilityIdentifier("callStage")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.bottom, strip.isEmpty ? 0 : band + safe.bottom)
+ // The pin, over the stage's top corner below the title; outside
+ // the stage's own accessibility identifier, so it keeps its own.
+ if let focused, arrangement.pinned == focused {
+ PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) }
+ .padding(.top, max(safe.top, 12) + 60).padding(.trailing, safe.trailing + 12)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
+ }
+ if !strip.isEmpty {
+ ScrollView(.horizontal) {
+ HStack(spacing: 6) {
+ ForEach(strip, id: \.self) { item in
+ phoneTile(item, cornerRadius: 10, compact: true, backdrop: Self.tileBackdrop, iconOnly: true)
+ .frame(width: thumb.width, height: thumb.height)
+ .modifier(arrangeable(item, tapToPin: true))
+ }
+ }
+ .padding(.horizontal, 10).padding(.vertical, Self.filmstripPadding)
+ .frame(minWidth: size.width - safe.leading - safe.trailing)
+ }
+ .scrollIndicators(.never)
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Filmstrip")
+ .accessibilityIdentifier("filmstrip")
+ .padding(.leading, safe.leading).padding(.trailing, safe.trailing)
+ .padding(.bottom, safe.bottom + raised)
+ .animation(ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion), value: chrome.visible)
}
}
}
+ static let filmstripPadding: CGFloat = 6
+
+ /// A filmstrip tile: upright 3:4 in portrait, as a phone's camera sees
+ /// a face, 4:3 in landscape; about an eighth of the screen's height.
+ static func filmstripTile(in size: CGSize) -> CGSize {
+ if size.height >= size.width {
+ let height = min(132, max(96, (size.height / 7).rounded()))
+ return CGSize(width: (height * 3 / 4).rounded(), height: height)
+ }
+ let height = min(96, max(64, (size.height / 5).rounded()))
+ return CGSize(width: (height * 4 / 3).rounded(), height: height)
+ }
+
/// Every tile as large as fits inside the safe area, scrolling only
/// once they would be too small to see.
private func grid(in size: CGSize, safe: EdgeInsets) -> some View {
- let count = call.screens.count + 1 + peers.count
+ let items = gridItems
+ let count = items.count
let area = CGSize(
width: size.width - safe.leading - safe.trailing - 8,
height: size.height - safe.top - safe.bottom - 8)
@@ -136,27 +195,10 @@ extension PhoneCall {
columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across),
spacing: 4
) {
- ForEach(call.screens, id: \.self) { peer in
- PhoneVideoTile(
- track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
- screen: true, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop
- )
- .frame(width: tile.width, height: tile.height)
- }
- PhoneVideoTile(
- track: call.localTrack, label: "You", muted: call.muted, mirrored: true,
- cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 8,
- compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio
- )
- .frame(width: tile.width, height: tile.height)
- ForEach(peers) { peer in
- PhoneVideoTile(
- track: call.remoteTracks[peer.peer], label: peer.name,
- muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer),
- cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop,
- audio: call.tileAudio(peer.peer)
- )
- .frame(width: tile.width, height: tile.height)
+ ForEach(items, id: \.self) { item in
+ phoneTile(item, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop)
+ .frame(width: tile.width, height: tile.height)
+ .modifier(arrangeable(item, tapToPin: false))
}
}
.padding(4)
@@ -375,9 +417,8 @@ struct PhoneNoticeSurface: ViewModifier {
/// broadcast picker and a toggle, and show at a glance what is on.
struct MoreControls: View {
@ObservedObject var call: Call
- @Binding var grid: Bool
- @Binding var chosen: StageItem?
- let peers: [RosterPeer]
+ @ObservedObject var arrangement: CallArrangement
+ let title: (StageItem) -> String
let unread: Int
let open: (MoreAction) -> Void
let close: () -> Void
@@ -443,32 +484,11 @@ struct MoreControls: View {
.accessibilityIdentifier("micModeQuick")
.accessibilityHint(micModes.summary)
}
- tile(grid ? "Grid" : "Focus") {
+ tile(arrangement.layout.title) {
Menu {
- Picker("Video layout", selection: $grid) {
- Label("Focus", systemImage: "pip").tag(false)
- Label("Grid", systemImage: "square.grid.2x2").tag(true)
- }
- if peers.count > 1 || !call.screens.isEmpty {
- Section("Focus on") {
- ForEach(call.screens, id: \.self) { peer in
- Button(screenCaption(call.name(of: peer))) {
- chosen = .screen(peer)
- grid = false
- }
- }
- ForEach(peers) { peer in
- Button(peer.name) {
- chosen = .camera(peer.peer)
- grid = false
- }
- }
- }
- }
+ LayoutChoices(call: call, arrangement: arrangement, title: title)
} label: {
- Label(
- "Video layout",
- systemImage: grid ? "square.grid.2x2" : "pip")
+ Label("Video layout", systemImage: arrangement.layout.symbol)
}
.buttonStyle(CallActionStyle(color: .gray))
.accessibilityIdentifier("videoLayout")
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift
index e100302..e60eca2 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -301,6 +301,90 @@ final class CallsPhoneUITests: XCTestCase {
}
}
+ /// 0.4.0: Speaker view in a call of four. Tom has the floor and the
+ /// other three, you included, are in the filmstrip; a tap on Li pins Li
+ /// to the stage with a Pinned badge, and the badge unpins. The layout is
+ /// in the ⋯ sheet as Speaker and Grid.
+ @MainActor func testSpeakerViewPinsWithATap() throws {
+ XCUIDevice.shared.orientation = .portrait
+ let app = launchCall(
+ "grid",
+ ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "shown"])
+ let filmstrip = app.descendants(matching: .any)["filmstrip"]
+ XCTAssertTrue(filmstrip.waitForExistence(timeout: 15))
+ let stage = app.descendants(matching: .any).matching(
+ NSPredicate(format: "label BEGINSWITH %@", "On stage: ")).firstMatch
+ XCTAssertTrue(stage.waitForExistence(timeout: 5))
+ XCTAssertTrue(stage.label.hasPrefix("On stage: Tom Byrne"), stage.label)
+ for name in ["You", "Ana García", "Li Wei"] {
+ XCTAssertTrue(
+ filmstrip.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", name))
+ .firstMatch.exists, "\(name) is in the filmstrip")
+ }
+ XCTAssertFalse(app.descendants(matching: .any)["selfView"].exists, "you are in the filmstrip, not floating")
+ filmstrip.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "Li Wei"))
+ .firstMatch.tap()
+ let unpin = app.buttons["unpin"]
+ XCTAssertTrue(unpin.waitForExistence(timeout: 3), "a pinned stage shows its pin")
+ XCTAssertTrue(stage.label.hasPrefix("On stage: Li Wei"), stage.label)
+ XCTAssertTrue(stage.label.hasSuffix("pinned"), stage.label)
+ unpin.tap()
+ XCTAssertTrue(
+ app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "On stage: Tom Byrne"))
+ .firstMatch.waitForExistence(timeout: 3), "unpinned, the speaker has the stage again")
+ // The layout, in the controls sheet.
+ app.buttons["moreControls"].tap()
+ XCTAssertTrue(app.buttons["videoLayout"].waitForExistence(timeout: 5))
+ app.buttons["videoLayout"].tap()
+ XCTAssertTrue(app.buttons["Speaker"].waitForExistence(timeout: 3))
+ XCTAssertTrue(app.buttons["Grid"].exists)
+ XCTAssertFalse(app.buttons["Focus"].exists, "Focus is now Speaker")
+ }
+
+ /// A tile's accessibility actions move it without a drag: Move to first
+ /// on Li in Grid puts Li before you.
+ @MainActor func testTilesMoveWithoutADrag() throws {
+ XCUIDevice.shared.orientation = .portrait
+ let app = launchCall("grid", ["-CallsFixtureControls", "hidden"])
+ let li = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "Li Wei")).firstMatch
+ let me = app.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH %@", "You")).firstMatch
+ XCTAssertTrue(li.waitForExistence(timeout: 15))
+ XCTAssertGreaterThan(li.frame.minY + li.frame.minX / 1000, me.frame.minY + me.frame.minX / 1000, "Li starts after you")
+ let centre = CGVector(dx: 0.5, dy: 0.5)
+ li.coordinate(withNormalizedOffset: centre).press(
+ forDuration: 0.8, thenDragTo: me.coordinate(withNormalizedOffset: centre), withVelocity: .slow,
+ thenHoldForDuration: 0.5)
+ Thread.sleep(forTimeInterval: 1)
+ XCTAssertLessThanOrEqual(
+ li.frame.minY * 1000 + li.frame.minX, me.frame.minY * 1000 + me.frame.minX,
+ "a long press and drag onto your place puts Li there")
+ }
+
+ /// Snapshots of Speaker view and the order (0.4.0), portrait and
+ /// landscape: the filmstrip of three, a pin, a drag half way, and Grid
+ /// after a reorder.
+ @MainActor func testSpeakerViewSnapshots() throws {
+ defer { XCUIDevice.shared.orientation = .portrait }
+ for orientation in [UIDeviceOrientation.portrait, .landscapeLeft] {
+ XCUIDevice.shared.orientation = orientation
+ let turn = orientation == .portrait ? "portrait" : "landscape"
+ let cases: [(String, [String])] = [
+ ("speaker-filmstrip", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "hidden"]),
+ ("speaker-controls", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureControls", "shown"]),
+ ("speaker-pinned", ["-CallsFixtureLayout", "speaker", "-CallsFixturePinned", "li", "-CallsFixtureControls", "hidden"]),
+ ("speaker-drag", ["-CallsFixtureLayout", "speaker", "-CallsFixtureSpeaker", "tom", "-CallsFixtureLift", "li", "-CallsFixtureControls", "hidden"]),
+ ("grid-drag", ["-CallsFixtureLayout", "grid", "-CallsFixtureLift", "li", "-CallsFixtureControls", "hidden"]),
+ ("grid-reordered", ["-CallsFixtureLayout", "grid", "-CallsFixtureOrder", "li,tom,me,ana", "-CallsFixtureControls", "hidden"]),
+ ]
+ for (name, extra) in cases {
+ let app = launchCall("grid", extra, dark: true)
+ XCTAssertTrue(app.descendants(matching: .any)["callStage"].firstMatch.waitForExistence(timeout: 15))
+ Thread.sleep(forTimeInterval: 1)
+ try snapshot(app, "\(name)-\(turn)")
+ }
+ }
+ }
+
/// Landscape keeps both bar buttons on screen, clear of the Dynamic
/// Island's side.
@MainActor func testLandscapeKeepsTheBarOnScreen() throws {
diff --git a/native/ios/project.yml b/native/ios/project.yml
index 36fa266..4f62e54 100644
--- a/native/ios/project.yml
+++ b/native/ios/project.yml
@@ -10,7 +10,7 @@ settings:
base:
SWIFT_VERSION: '5.0'
CODE_SIGN_STYLE: Automatic
- MARKETING_VERSION: '0.3.9'
+ MARKETING_VERSION: '0.4.0'
CURRENT_PROJECT_VERSION: '1'
# The App Group the app and its broadcast extension share: the socket
# the iPhone's screen crosses lives in its container. One setting
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 66f8c47..6e9d448 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -412,6 +412,8 @@ function state(page) {
soundBanner: s.soundBanner,
noSoundFrom: s.noSoundFrom,
remoteMedia: s.remoteMedia,
+ // Speaker view: who has the floor, by peer id ('' before anyone).
+ speaker: s.speaker,
};
});
}
@@ -4358,6 +4360,187 @@ const run = {
}
},
+ // Speaker view and the viewer's order (0.4.0), in a call of four: two
+ // members and two guests. Everyone's microphone is silenced but one,
+ // which speaks a tone; the stage goes to that person only once they
+ // have held the floor for the dominance delay, and stays put through a
+ // short interjection. A pin holds the stage against the speaker; the
+ // tile buttons and a mouse drag change the tiles' order in the DOM,
+ // and the order survives a reload of the tab (sessionStorage).
+ // CALLS_SPEAKER_SNAPSHOTS=<dir> keeps pictures of each state.
+ async speaker() {
+ const out = process.env.CALLS_SPEAKER_SNAPSHOTS;
+ if (out) mkdirSync(out, { recursive: true });
+ const shot = async (page, name) => { if (out) await page.screenshot({ path: out + '/web-' + name + '.png' }); };
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const ctxG = await newContext('guest');
+ const ctxW = await newContext('waiter');
+ const pageA = await openRoom(ctxA);
+ await pageA.setViewportSize({ width: 1280, height: 900 });
+ await pageA.evaluate(() => { localStorage.removeItem('calls.videoLayout'); });
+ const pageB = await openRoom(ctxB);
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) return;
+ for (const [ctx, address, name] of [[ctxG, GUEST, 'guest'], [ctxW, WAITER, 'waiter']]) {
+ await signInAsGuest(ctx, address);
+ await openRoom(ctx);
+ check(await until(async () => (await pageA.locator('#knocks li').count()) >= 1 && await admitKnock(pageA, name), 20000),
+ 'A lets ' + name + ' in (' + JSON.stringify(await queueNames(pageA)) + ')');
+ }
+ const pageG = ctxG.pages().find((p) => p.url().endsWith('/m/' + sqid));
+ const pageW = ctxW.pages().find((p) => p.url().endsWith('/m/' + sqid));
+ const four = await until(async () => {
+ const s = await state(pageA);
+ return s.peers.length === 3 && Object.values(s.frames).filter((n) => n > 0).length >= 3 ? s : false;
+ }, 40000);
+ check(four, 'A is in a call of four, with video from all three (' + JSON.stringify(await state(pageA).catch(() => null)) + ')');
+ if (!four) return;
+ const b = pair.b.peer;
+ const g = (await state(pageG)).peer;
+ const w = (await state(pageW)).peer;
+ check(await pageA.locator('#layout-speaker').getAttribute('aria-pressed') === 'true', 'Speaker is the layout by default');
+
+ // Silence: every microphone's track disabled under the call, so the
+ // packets carry nothing and nobody is speaking.
+ const hush = (page) => page.evaluate(() => {
+ for (const pc of window.__pcs || []) {
+ for (const sn of pc.getSenders()) if (sn.track && sn.track.kind === 'audio') sn.track.enabled = false;
+ }
+ });
+ for (const page of [pageA, pageB, pageG, pageW]) await hush(page);
+ await sleep(2500);
+ const layoutOf = (page) => page.evaluate(() => ({
+ ...window.callsLayout.state(),
+ stage: (document.querySelector('#tiles > [data-stage]') || {}).dataset?.tile || '',
+ pinned: (document.querySelector('#tiles > [data-pinned]') || {}).dataset?.tile || '',
+ tiles: Array.from(document.querySelectorAll('#tiles > figure')).map((f) => f.dataset.tile),
+ strip: Array.from(document.querySelectorAll('#tiles > figure:not([data-stage])')).map((f) => f.dataset.tile),
+ }));
+ const quiet = await layoutOf(pageA);
+ check(quiet.strip.length === 3 && quiet.strip.includes('self'),
+ 'the filmstrip holds the three not on the stage, A included (' + JSON.stringify(quiet) + ')');
+
+ // Before anyone speaks the stage is the first other person in A's
+ // order. G, who is not, speaks: the stage goes to G, and not before
+ // G has held the floor for the dominance delay.
+ check(quiet.stage !== g, 'G is not on the stage before speaking (' + quiet.stage + ')');
+ await speakInto(pageG);
+ const started = Date.now();
+ const toG = await until(async () => ((await layoutOf(pageA)).stage === g ? Date.now() : false), 15000);
+ check(toG, 'the stage goes to G while G speaks (' + JSON.stringify(await layoutOf(pageA)) + ')');
+ check(toG && toG - started >= 1200, 'and only after G has held the floor for the delay (' + (toG - started) + ' ms)');
+ await shot(pageA, 'speaker-filmstrip');
+
+ // B interjects for about half a second while G carries on: the stage
+ // stays with G throughout.
+ await speakInto(pageB);
+ await sleep(500);
+ await stopSpeaking(pageB);
+ await hush(pageB);
+ const flapped = await stayed(pageA, 4000, (s) => s.speaker === g);
+ check(!flapped, 'an interjection does not take the stage (' + JSON.stringify(flapped && flapped.speaker) + ')');
+ // G stops; W speaks on. W takes it, and over the next seconds the
+ // stage changes exactly once.
+ await stopSpeaking(pageG);
+ await hush(pageG);
+ await speakInto(pageW);
+ const seen = [];
+ const deadline = Date.now() + 9000;
+ while (Date.now() < deadline) {
+ const s = await layoutOf(pageA);
+ if (seen[seen.length - 1] !== s.stage) seen.push(s.stage);
+ await sleep(150);
+ }
+ check(seen.join(',') === [g, w].join(','), 'the stage moves once, from G to W, and does not flap (' + seen.join(' -> ') + ')');
+
+ // A pin holds the stage against the speaker: B's tile, clicked in the
+ // filmstrip.
+ await pageA.locator('#tiles > figure[data-tile="' + b + '"]').click({ position: { x: 20, y: 40 } });
+ const pinned = await until(async () => {
+ const l = await layoutOf(pageA);
+ return l.stage === b && l.pinned === b ? l : false;
+ }, 3000);
+ check(pinned, 'clicking B in the filmstrip pins B to the stage (' + JSON.stringify(await layoutOf(pageA)) + ')');
+ check(/Pinned .* to the stage/.test(await pageA.locator('#layout-live').textContent()), 'and says so, politely');
+ check(await pageA.locator('#tiles > figure[data-tile="' + b + '"] .calls-tool-pin').isVisible(),
+ 'a pinned stage shows its pin');
+ await sleep(2500);
+ check((await layoutOf(pageA)).stage === b, 'W speaking does not take a pinned stage');
+ await shot(pageA, 'speaker-pinned');
+ await pageA.locator('#tiles > figure[data-tile="' + b + '"] .calls-tool-pin').click();
+ check(await until(async () => (await layoutOf(pageA)).stage === w, 3000), 'unpinning gives the stage back to the speaker');
+ await stopSpeaking(pageW);
+
+ // The viewer's order: "Move to first" on the last person changes the
+ // DOM order, and the keyboard stays on a move button.
+ await pageA.locator('#layout-grid').click();
+ const before = (await layoutOf(pageA)).tiles;
+ const last = before[before.length - 1];
+ const first = pageA.locator('#tiles > figure[data-tile="' + last + '"] [data-move="first"]');
+ await first.focus();
+ await pageA.keyboard.press('Enter');
+ const moved = await until(async () => {
+ const l = await layoutOf(pageA);
+ return l.tiles[0] === last ? l : false;
+ }, 3000);
+ check(moved, 'Move to first puts ' + last + ' first in the DOM (' + JSON.stringify(await layoutOf(pageA)) + ')');
+ check(/moved to position 1 of 4/.test(await pageA.locator('#layout-live').textContent()), 'and says where it went');
+ check(await pageA.evaluate(() => !!document.activeElement.closest('#tiles figure')), 'the keyboard stays with the tile');
+ // Move later, by keyboard: one place on.
+ const later = pageA.locator('#tiles > figure[data-tile="' + last + '"] [data-move="later"]');
+ await later.focus();
+ await pageA.keyboard.press('Enter');
+ check(await until(async () => (await layoutOf(pageA)).tiles[1] === last, 3000), 'Move later moves it one place');
+
+ // A mouse drag: the first tile dropped on the third's place.
+ const order1 = (await layoutOf(pageA)).tiles;
+ const from = await pageA.locator('#tiles > figure[data-tile="' + order1[0] + '"]').boundingBox();
+ const to = await pageA.locator('#tiles > figure[data-tile="' + order1[2] + '"]').boundingBox();
+ await pageA.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
+ await pageA.mouse.down();
+ for (let i = 1; i <= 10; i++) {
+ await pageA.mouse.move(from.x + from.width / 2 + (to.x - from.x) * i / 10, from.y + from.height / 2 + (to.y - from.y) * i / 10);
+ await sleep(30);
+ }
+ check(await pageA.locator('.calls-tile-lifted').count() === 1, 'the dragged tile is lifted');
+ await shot(pageA, 'grid-drag');
+ await pageA.mouse.up();
+ const dragged = await until(async () => {
+ const l = await layoutOf(pageA);
+ return l.tiles[2] === order1[0] ? l : false;
+ }, 3000);
+ check(dragged, 'dragging the first tile onto the third puts it third in the DOM (' +
+ JSON.stringify({ before: order1, after: (await layoutOf(pageA)).tiles }) + ')');
+ await shot(pageA, 'grid-reordered');
+ const kept = (await layoutOf(pageA)).tiles;
+ const stored = await pageA.evaluate((k) => sessionStorage.getItem(k), 'calls.order.' + sqid);
+ check(stored && JSON.parse(stored).length === 4, 'the order is kept in sessionStorage for the meeting (' + stored + ')');
+
+ // A reload of the tab puts the order back (the peer ids are new).
+ await pageA.reload({ waitUntil: 'domcontentloaded' });
+ await pageA.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
+ const back = await until(async () => {
+ const l = await pageA.evaluate(() => Array.from(document.querySelectorAll('#tiles > figure')).map((f) =>
+ f.classList.contains('calls-tile-self') ? 'self' : f.dataset.name));
+ return l.length === 4 ? l : false;
+ }, 40000);
+ check(back, 'A is back in the call of four after a reload');
+ // Compared with what was kept before the reload, not after it: a
+ // leave that wiped the order and a rejoin that rebuilt it in roster
+ // order would agree with themselves.
+ const want = JSON.parse(stored || '[]');
+ check(back && back.map((n) => n === 'self' ? 'self' : 'name:' + n).join(',') === want.join(','),
+ 'and the tiles come back in the kept order (' + JSON.stringify({ back, want, kept,
+ now: await pageA.evaluate(() => window.callsLayout && window.callsLayout.state()) }) + ')');
+
+ for (const label of ['member A', 'member B', 'guest', 'waiter']) {
+ check(errorsFor(label) === '', 'no console errors in ' + label + ' (' + errorsFor(label) + ')');
+ }
+ for (const page of [pageA, pageB, pageG, pageW]) await leaveRoom(page);
+ for (const ctx of [ctxA, ctxB, ctxG, ctxW]) await ctx.close();
+ },
+
// The floor: both pages load with JavaScript switched off, say so in
// plain words, and show nothing that pretends to be a call.
async layout() {
@@ -4402,7 +4585,7 @@ const run = {
await pageA.setViewportSize({width, height: 900});
for (const colorScheme of ['light', 'dark']) {
await pageA.emulateMedia({colorScheme, reducedMotion: 'reduce'});
- for (const mode of ['focus', 'grid']) {
+ for (const mode of ['speaker', 'grid']) {
await pageA.locator('#layout-' + mode).click();
check(await pageA.locator('#layout-' + mode).getAttribute('aria-pressed') === 'true', mode + ' selection is exposed');
check(await pageA.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
@@ -4414,11 +4597,11 @@ const run = {
}
}
}
- await pageA.locator('#layout-focus').click();
- await pageA.locator('[data-focused] .calls-tile-note').evaluate(note => { note.textContent = 'Connection recovery'; note.hidden = false; });
+ await pageA.locator('#layout-speaker').click();
+ await pageA.locator('[data-stage] .calls-tile-note').evaluate(note => { note.textContent = 'Connection recovery'; note.hidden = false; });
check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000),
'recovery captions disable the self overlay');
- await pageA.locator('[data-focused] .calls-tile-note').evaluate(note => { note.textContent = ''; note.hidden = true; });
+ await pageA.locator('[data-stage] .calls-tile-note').evaluate(note => { note.textContent = ''; note.hidden = true; });
await pageB.locator('#share').click();
await pageA.locator('.calls-tile-share').waitFor({state: 'visible'});
check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000),
diff --git a/test/browser/room-layout.test.mjs b/test/browser/room-layout.test.mjs
new file mode 100644
index 0000000..60a0a7f
--- /dev/null
+++ b/test/browser/room-layout.test.mjs
@@ -0,0 +1,168 @@
+// room-layout.test.mjs — Speaker view and the viewer's order (0.4.0),
+// gated without a browser.
+//
+// node --test test/browser/room-layout.test.mjs
+//
+// The same trick as room-glow.test.mjs: the functions are lifted out of
+// the shipped room.js and call-layout.js by name, so a retuned constant
+// or a renamed function fails here rather than passing against a copy.
+//
+// speakerStep who has the floor: the dominance delay, the minimum
+// hold, interjections, pauses, leavers
+// orderMerge arrivals on the end, leavers out, remembered keys back
+// orderMove a move among the people here
+// stageFor pin, then the newest screen, then the speaker
+// storedLayout Focus is Speaker
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const read = (rel) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8');
+const roomJS = read('../../internal/calls/static/room.js');
+const layoutJS = read('../../internal/calls/static/call-layout.js');
+const swift = read('../../native/apple/Sources/CallsKit/SpeakerView.swift');
+
+function lift(source, name) {
+ const start = source.indexOf('function ' + name + '(');
+ assert.notEqual(start, -1, name + ' is not there any more — this test is stale, not passing');
+ let depth = 0;
+ for (let i = source.indexOf('{', start); i < source.length; i++) {
+ if (source[i] === '{') depth += 1;
+ else if (source[i] === '}') {
+ depth -= 1;
+ if (depth === 0) return source.slice(start, i + 1);
+ }
+ }
+ assert.fail('unbalanced braces reading ' + name);
+}
+function liftConst(name) {
+ const m = roomJS.match(new RegExp('const ' + name + ' = (-?[0-9.]+);'));
+ assert.ok(m, name + ' is not a numeric const in room.js any more');
+ return 'const ' + name + ' = ' + m[1] + ';';
+}
+const CONSTS = ['SPEAKER_DOMINANCE', 'SPEAKER_HOLD'].map(liftConst).join('\n');
+// eslint-disable-next-line no-new-func
+const room = new Function(CONSTS + '\n' + lift(roomJS, 'speakerRecord') + '\n' + lift(roomJS, 'speakerStep') +
+ '; return { speakerRecord, speakerStep, SPEAKER_DOMINANCE, SPEAKER_HOLD };')();
+// eslint-disable-next-line no-new-func
+const layout = new Function(['storedLayout', 'orderMerge', 'orderShown', 'orderMove', 'stageFor']
+ .map((n) => lift(layoutJS, n)).join('\n') + '; return { storedLayout, orderMerge, orderShown, orderMove, stageFor };')();
+
+// Five polls a second, as pollLevels takes them, from `from` ms to `to`.
+function run(rec, from, to, speaking, present = ['ana', 'tom', 'li']) {
+ const here = new Set(present);
+ for (let t = from; t < to; t += 200) {
+ const voices = present.map((peer) => ({ peer, speaking: peer in speaking, level: speaking[peer] || 0.001 }));
+ room.speakerStep(rec, voices, here, t);
+ }
+}
+
+test('the thresholds are the native app’s', () => {
+ const dominance = Number(swift.match(/static let dominance: Double = ([0-9.]+)/)[1]);
+ const hold = Number(swift.match(/static let minimumHold: Double = ([0-9.]+)/)[1]);
+ assert.equal(room.SPEAKER_DOMINANCE, dominance * 1000);
+ assert.equal(room.SPEAKER_HOLD, hold * 1000);
+ assert.ok(room.SPEAKER_DOMINANCE >= 1200 && room.SPEAKER_DOMINANCE <= 1500);
+ assert.ok(room.SPEAKER_HOLD >= 3000);
+});
+
+test('the floor moves only after the dominance delay', () => {
+ const rec = room.speakerRecord();
+ run(rec, 0, 1300, { ana: 0.2 });
+ assert.equal(rec.current, '', '1.2 s is not yet a turn');
+ run(rec, 1400, 1500, { ana: 0.2 });
+ assert.equal(rec.current, 'ana');
+});
+
+test('the loudest speaker is the dominant one', () => {
+ const rec = room.speakerRecord();
+ run(rec, 0, 2000, { ana: 0.05, tom: 0.3 });
+ assert.equal(rec.current, 'tom');
+});
+
+test('an interjection does not take it, nor does a pause', () => {
+ const rec = room.speakerRecord();
+ run(rec, 0, 5000, { ana: 0.2 });
+ run(rec, 5000, 6000, { ana: 0.1, tom: 0.4 });
+ run(rec, 6000, 10000, { ana: 0.2 });
+ assert.equal(rec.current, 'ana');
+ run(rec, 10000, 40000, {});
+ assert.equal(rec.current, 'ana', 'silence changes nothing');
+});
+
+test('the minimum hold holds through a takeover', () => {
+ const rec = room.speakerRecord();
+ run(rec, 0, 1500, { ana: 0.2 });
+ assert.equal(rec.current, 'ana');
+ const took = rec.since;
+ run(rec, 1600, took + 2900, { tom: 0.3 });
+ assert.equal(rec.current, 'ana');
+ run(rec, took + 3000, took + 3100, { tom: 0.3 });
+ assert.equal(rec.current, 'tom');
+});
+
+test('two people alternating quickly never make it flap', () => {
+ const rec = room.speakerRecord();
+ let switches = 0;
+ for (let t = 0; t < 60000; t += 200) {
+ const who = Math.floor(t / 800) % 2 === 0 ? 'ana' : 'tom';
+ const other = who === 'ana' ? 'tom' : 'ana';
+ if (room.speakerStep(rec, [{ peer: who, speaking: true, level: 0.3 }, { peer: other, speaking: true, level: 0.1 }],
+ new Set(['ana', 'tom']), t)) switches += 1;
+ }
+ assert.equal(switches, 0);
+});
+
+test('a leaver loses the floor at once', () => {
+ const rec = room.speakerRecord();
+ run(rec, 0, 2000, { li: 0.2 });
+ assert.equal(rec.current, 'li');
+ assert.equal(room.speakerStep(rec, [], new Set(['ana', 'tom']), 2200), true);
+ assert.equal(rec.current, '');
+ run(rec, 2400, 3900, { ana: 0.2 }, ['ana', 'tom']);
+ assert.equal(rec.current, 'ana', 'and the next needs only the dominance delay');
+});
+
+test('storedLayout: Focus is Speaker', () => {
+ assert.equal(layout.storedLayout('focus'), 'speaker');
+ assert.equal(layout.storedLayout('speaker'), 'speaker');
+ assert.equal(layout.storedLayout('grid'), 'grid');
+ assert.equal(layout.storedLayout(null), 'speaker');
+ assert.equal(layout.storedLayout('mosaic'), 'speaker');
+});
+
+test('orderMerge and orderMove: arrivals on the end, leavers out, moves kept', () => {
+ let order = layout.orderMerge({ keys: [], present: [] }, ['self', 'name:Ana']);
+ order = layout.orderMerge(order, ['self', 'name:Ana', 'name:Tom']);
+ assert.deepEqual(layout.orderShown(order), ['self', 'name:Ana', 'name:Tom']);
+ order = layout.orderMove(order, 'name:Tom', 0);
+ assert.deepEqual(layout.orderShown(order), ['name:Tom', 'self', 'name:Ana']);
+ assert.equal(layout.orderMove(order, 'name:Tom', 0), null, 'already there');
+ order = layout.orderMerge(order, ['name:Li', 'self', 'name:Tom', 'name:Ana']);
+ assert.deepEqual(layout.orderShown(order), ['name:Tom', 'self', 'name:Ana', 'name:Li']);
+ order = layout.orderMerge(order, ['self', 'name:Tom', 'name:Li']);
+ assert.ok(!order.keys.includes('name:Ana'), 'a leaver is removed');
+ order = layout.orderMove(order, 'self', 99);
+ assert.deepEqual(layout.orderShown(order), ['name:Tom', 'name:Li', 'self']);
+});
+
+test('a kept order comes back as people arrive', () => {
+ let order = { keys: ['name:Li', 'name:Ana', 'self'], present: [] };
+ order = layout.orderMerge(order, ['self']);
+ assert.deepEqual(layout.orderShown(order), ['self']);
+ assert.deepEqual(order.keys, ['name:Li', 'name:Ana', 'self'], 'the absent are remembered, not removed');
+ order = layout.orderMerge(order, ['self', 'name:Ana', 'name:Li', 'name:Sam']);
+ assert.deepEqual(layout.orderShown(order), ['name:Li', 'name:Ana', 'self', 'name:Sam']);
+});
+
+test('stageFor: pin, then the newest screen, then the speaker, then the order', () => {
+ const others = ['tom', 'ana'];
+ assert.equal(layout.stageFor('', '', others, [], 'self'), 'tom');
+ assert.equal(layout.stageFor('', 'ana', others, [], 'self'), 'ana');
+ assert.equal(layout.stageFor('', 'ana', others, ['tom#share', 'ana#share'], 'self'), 'ana#share');
+ assert.equal(layout.stageFor('tom', 'ana', others, ['ana#share'], 'self'), 'tom', 'a pin beats a new screen');
+ assert.equal(layout.stageFor('gone', 'ana', others, [], 'self'), 'ana', 'a pin that left does not hold');
+ assert.equal(layout.stageFor('', '', [], [], 'self'), 'self', 'alone');
+ assert.equal(layout.stageFor('', 'self', others, [], 'self'), 'tom', 'never yourself unless pinned');
+});