Speaker view, pinning, and your own order of the people in a call
Paul asked whether Grid could be rearranged, and for a Speaker view with the speaker in the main window and everyone else underneath. - Layouts are Speaker and Grid on Mac, iPhone/iPad and web. Speaker replaces Focus (Focus was Speaker with one person pinned); the choice is now kept, and a kept "focus" reads as Speaker. - The stage: a pin, else the newest screen, else whoever has the floor (ActiveSpeaker / speakerStep: 1.3 s dominance, 3 s minimum hold, never you), else the first other person in your order. Everyone else is in a filmstrip under it, you included; in a call of two you still float. - Click or tap a filmstrip tile, or Pin in its menu or accessibility actions, to pin; a Pinned badge unpins. - Drag tiles to reorder (long press on iPhone/iPad), in Grid and the filmstrip; Move earlier/later/to first as menus, accessibility actions and, in the browser, tile buttons. The order is local, kept per meeting for the session (sessionStorage in the browser) under keys that survive a rejoin. - The mini window follows Speaker view's stage. Tests: ParticipantOrder, ActiveSpeaker (fake clock), stage/filmstrip, the Focus migration, Mac mouse tests for pin and drag, iPhone UI tests, the node room-layout test and a four-person browser scenario.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 files changed,
+2810
−387
Makefile+1 −0internal/calls/icons/icons.go+8 −2internal/calls/static/call-layout.js+391 −15internal/calls/static/calls.css+68 −7internal/calls/static/room.js+65 −0internal/calls/templates/room.html+10 −1internal/callstest/browser_test.go+1 −0native/DESIGN.md+81 −2native/README.md+5 −4native/apple/Sources/CallsInterop/CallSnapshots.swift+38 −0native/apple/Sources/CallsInterop/Interop.swift+6 −0native/apple/Sources/CallsKit/ArrangeTiles.swift+249 −0native/apple/Sources/CallsKit/AudioMeter.swift+11 −1native/apple/Sources/CallsKit/Call.swift+6 −1native/apple/Sources/CallsKit/CallArrangement.swift+183 −0native/apple/Sources/CallsKit/MacCallView.swift+143 −98native/apple/Sources/CallsKit/MacFullWindowCall.swift+82 −62native/apple/Sources/CallsKit/MacVideoTile.swift+13 −3native/apple/Sources/CallsKit/MiniWindow.swift+14 −9native/apple/Sources/CallsKit/ScreenShare.swift+2 −21native/apple/Sources/CallsKit/SpeakerView.swift+349 −0native/apple/Tests/CallsKitTests/ScreenShareTests.swift+14 −10native/apple/Tests/CallsKitTests/SpeakerViewTests.swift+370 −0native/apple/Tests/CallsKitTests/WindowMoveTests.swift+30 −1native/ios/Sources/CallsPhone.swift+124 −68native/ios/Sources/PhoneCallFixture.swift+11 −2native/ios/Sources/PhoneFullScreenCall.swift+96 −76native/ios/UITests/CallsPhoneUITests.swift+84 −0test/browser/call.mjs+187 −4test/browser/room-layout.test.mjs+168 −0
diff --git a/Makefile b/Makefile| index eb52588..42fd71c 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 a6cd8bc..c1c1f01 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -556,10 +556,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 8e341c6..9af8b53 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-selftest" { |
| exit(await AudioSelfTestHarness.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 6484edb..3b4c6fb 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" |
| @@ -187,6 +191,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/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'); |
| +}); |