Calls: the stage fills the window, and every placeholder is centred
Paul, 5 Oct: "Should the video view stay fullscreen even here? The black line is a bit weird." and "Vertical centering here too". Speaker view (Mac, iPhone, web). The stage stopped above the filmstrip, leaving a black band across the window behind the filmstrip and the bar, and the stage's name stranded at its foot. Now the stage fills the whole window, as FaceTime's does, and the filmstrip floats over its bottom, rising for the bar as before. The stage's name sits at the bottom left, just above the filmstrip (StageLabel.lift; calls.css's caption). A camera fills with a crop taken 35:65 from top and bottom, so heads are not cut (VideoFrame; object-position 50% 35%). A screen is fitted whole, and around it, rather than black, the same picture enlarged, soft and dimmed: ScreenBackdrop takes a frame twice a second, scaled by WebRTC to 64 x 36, converts and blurs it there, and lets the upscaling do the rest (5 ms a picture in a debug build); the web draws the video into a 64 x 36 canvas at 2 Hz under a CSS blur (slowest paint 1.4 ms). Grid unchanged. Placeholders. TilePlaceholder (Mac and iPhone: filmstrip, grid, stage, self view, mini window) and the web's camera-off cover were centred in the space above the name (0.4.1), which put them high. They are centred in the whole tile, with the name over its foot; only on a tile so short that they would reach the name do they rise, by no more than that (PlaceholderPlacement; on the web two flex spacers, the lower one held at the caption's clearance). Too short for the words: the symbol alone. Gates: TileLayoutTests (placement at every mini size, the stage label, the crop, the backdrop's picture and cost); CallsInterop --stage-snapshots; the browser gate's new stage scenario (fill, caption, cover centring at 1280/760/390, backdrop painted and its cost, grid), and layout's share check now expects you in the filmstrip at the foot of the screen. CALLS_SYNTHETIC_MEDIA=1 draws camera, screen and microphone in the page for a host whose Chromium cannot reach its fake devices (macOS camera and microphone access never granted to the launching process, so getUserMedia never settles).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+904
−78
internal/calls/static/calls.css+63 −15internal/calls/static/room.js+42 −0internal/callstest/browser_test.go+1 −0native/apple/Sources/CallsInterop/StageSnapshots.swift+3 −0native/apple/Sources/CallsKit/CallFixtures.swift+18 −10native/apple/Sources/CallsKit/CallsAppearance.swift+3 −0native/apple/Sources/CallsKit/MacFullWindowCall.swift+14 −10native/apple/Sources/CallsKit/MacVideoTile.swift+7 −8native/apple/Sources/CallsKit/MutedBadge.swift+62 −15native/apple/Sources/CallsKit/ScreenBackdrop.swift+239 −0native/apple/Sources/CallsKit/SpeakerView.swift+14 −0native/apple/Tests/CallsKitTests/TileLayoutTests.swift+155 −0native/ios/Sources/CallsPhone.swift+44 −10native/ios/Sources/PhoneFullScreenCall.swift+9 −7test/browser/call.mjs+230 −3
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css| index da75362..cdc429e 100644 |
| --- a/internal/calls/static/calls.css |
| +++ b/internal/calls/static/calls.css |
| @@ -622,6 +622,11 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| + /* A crop takes more from the bottom than the top (35:65): a face is |
| + above the middle of the picture, and a crop through the forehead |
| + reads worse than one through the chest. The native apps' VideoFrame |
| + has the same headroom. */ |
| + object-position: 50% 35%; |
| } |
| .calls-tile-self:not(.calls-tile-share) video, |
| .calls-preview { |
| @@ -632,15 +637,36 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| is usually at the edges, so the whole screen is fitted into the tile |
| rather than cropped to fill it. */ |
| .calls-tile-share video { |
| + position: relative; |
| object-fit: contain; |
| + object-position: 50% 50%; |
| background: #000; |
| } |
| +/* On the stage, what is beside or above the fitted screen is the same |
| + picture enlarged, soft and dimmed, rather than black bars, as the |
| + native apps' ScreenBackdrop: room.js draws a frame into this 64 × 36 |
| + canvas twice a second, and the browser's upscaling and a blur do the |
| + rest. It is under the video (which is positioned, and after it). */ |
| +.calls-tile-backdrop { display: none; } |
| +.calls-window #tiles > [data-stage].calls-tile-share > .calls-tile-backdrop { |
| + display: block; |
| + position: absolute; |
| + inset: -8%; |
| + inline-size: 116%; |
| + block-size: 116%; |
| + filter: blur(18px); |
| + opacity: 0.45; |
| + pointer-events: none; |
| +} |
| +.calls-window #tiles > [data-stage].calls-tile-share video { background: transparent; } |
| -/* Speaker view (0.4.0): the stage fills the window above the filmstrip, |
| - and the filmstrip runs along the bottom — tiles a sixth of the window |
| - high, 72 to 120px, centred while they fit and scrolling sideways once |
| - they do not — rising clear of the bar while the controls show, so |
| - with them hidden nothing lies over the stage. The stage is placed |
| +/* Speaker view (0.4.0): the stage fills the whole window, edge to edge, |
| + as FaceTime's does, and the filmstrip floats over its bottom — tiles a |
| + sixth of the window high, 72 to 120px, centred while they fit and |
| + scrolling sideways once they do not — rising clear of the bar while |
| + the controls show (2026-10-05: until then the stage stopped above the |
| + filmstrip, leaving a black band behind it and the bar). The stage's |
| + caption rides just above the filmstrip. The stage is placed |
| against .calls-stage rather than #tiles, so it neither scrolls with |
| the filmstrip nor is clipped by it; the tiles stay children of #tiles, |
| whose order is the reading order call-layout.js keeps. */ |
| @@ -673,22 +699,29 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| .calls-window #tiles[data-layout='speaker'] > figure:not([data-stage]) { cursor: pointer; z-index: 1; } |
| .calls-window #tiles[data-layout='speaker'] > [data-stage] { |
| position: absolute; |
| - inset: 0 0 var(--calls-band) 0; |
| + inset: 0; |
| block-size: auto; |
| inline-size: auto; |
| aspect-ratio: auto; |
| border-radius: 0; |
| background: #0b0b0c; |
| } |
| +/* The stage's caption: bottom left of the window, just above the |
| + filmstrip (StageLabel in the native apps), and rising with it, clear |
| + of the bar, while the controls show. In a call of two there is no |
| + filmstrip (--calls-band is 0): the other person fills the window, you |
| + float in a corner, and the caption clears the bar alone. */ |
| .calls-window #tiles[data-layout='speaker'] > [data-stage] .calls-tile-name { |
| - inset-block-end: 12px; |
| + inset-block-end: calc(12px + var(--calls-band)); |
| inset-inline-start: 12px; |
| } |
| -/* A call of two: no filmstrip, the other person fills the window and |
| - you float in a corner, clear of the bar while it shows. */ |
| -.calls-window[data-chrome='shown'] #tiles[data-layout='speaker']:not(:has(> figure:not([data-stage], .calls-tile-pip))) > [data-stage] .calls-tile-name { |
| +.calls-window[data-chrome='shown'] #tiles[data-layout='speaker'] > [data-stage] .calls-tile-name { |
| translate: 0 calc(-1 * var(--calls-lift)); |
| } |
| +/* What the stage's camera-off cover keeps clear of: that caption, lifted. */ |
| +.calls-window #tiles[data-layout='speaker'] > [data-stage] > .calls-tile-off { |
| + --calls-caption-clear: calc(12px + 2rem + var(--calls-band) + var(--calls-lift)); |
| +} |
| /* The self view, floating (Stage.selfFloats): about a fifth of the |
| window's width, 150 to 260px at 16:9, 12px in from the corner it was |
| @@ -804,21 +837,35 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| /* A camera that is off sends black frames, or holds the last one. The |
| cover says what that is instead of leaving a frozen face or a black |
| rectangle to be read as a fault. It lies over the video rather than |
| - hiding it: the element is also what plays the peer's audio. Its |
| - symbol and words are centred in the space above the caption. */ |
| + hiding it: the element is also what plays the peer's audio. |
| + Its symbol and words are centred in the whole tile, as FaceTime has |
| + them, with the caption over the bottom edge (2026-10-05; from 0.4.1 |
| + they were centred in the space above the caption, which left them |
| + high). Only on a tile so short that centred they would reach the |
| + caption do they rise, and by no more than that: the two spacers share |
| + the free space equally until the lower one would be shorter than the |
| + caption's clearance (--calls-caption-clear), when flexbox holds it at |
| + that and gives the rest to the upper one. The native apps' |
| + PlaceholderPlacement is the same rule. */ |
| .calls-tile-off { |
| + --calls-caption-clear: 2.75rem; |
| position: absolute; |
| inset: 0; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| - justify-content: center; |
| - gap: 8px; |
| - padding-block-end: 2.75rem; |
| background: #212121; |
| color: rgb(255 255 255 / 0.8); |
| font-size: var(--rst-fs-sm); |
| } |
| +.calls-tile-off::before, |
| +.calls-tile-off::after { |
| + content: ''; |
| + flex: 1 1 0; |
| +} |
| +.calls-tile-off::after { min-block-size: var(--calls-caption-clear); } |
| +.calls-tile-off > * { flex: none; } |
| +.calls-tile-off > * + * { margin-block-start: 8px; } |
| .calls-tile-off .icon { inline-size: 2rem; block-size: 2rem; } |
| .calls-tile-camera-off .calls-tile-name { z-index: 1; } |
| @@ -893,6 +940,7 @@ body:has(> .calls-window) { background: var(--rst-bg); } |
| font-size: var(--rst-fs-xs); |
| } |
| .calls-tile-name > span:first-child { flex: 0 1 auto; min-inline-size: 0; } |
| + .calls-tile-off { --calls-caption-clear: 2rem; } |
| .calls-tile-name > :not(:first-child) { flex: none; } |
| .calls-tile-muted > :not(svg) { |
| position: absolute; |
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index 1506083..d1bbf79 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -479,6 +479,7 @@ |
| let reconnectTimer = 0; |
| let reconnectWatchTimer = 0; |
| let statsTimer = 0; |
| + let backdropTimer = 0; |
| let fullTimer = 0; |
| let renewTimer = 0; // §5.3: the scheduled renewal, at `renewAt` |
| let iceRetryTimer = 0; // §5.3 step 1: the one-minute retry |
| @@ -5166,6 +5167,37 @@ |
| mediaLive.textContent = text; |
| } |
| + // paintBackdrops draws the shared screen on the stage, if there is one, |
| + // into its 64 × 36 backdrop canvas: what calls.css enlarges, blurs and |
| + // dims around the fitted screen instead of black bars (2026-10-05, as |
| + // FaceTime; the native apps' ScreenBackdrop). Twice a second, and only |
| + // for the screen on the stage in a visible tab: one small drawImage, |
| + // whose cost is kept on the canvas (data-cost-ms, the slowest so far) |
| + // for the browser gate to report. |
| + const BACKDROP_W = 64; |
| + const BACKDROP_H = 36; |
| + const BACKDROP_EVERY = 500; |
| + function paintBackdrops() { |
| + if (document.visibilityState !== 'visible') return; |
| + const fig = document.querySelector('#tiles > [data-stage].calls-tile-share'); |
| + if (!fig) return; |
| + const video = fig.querySelector('video'); |
| + const canvas = fig.querySelector('.calls-tile-backdrop'); |
| + if (!video || !canvas || video.readyState < 2 || !video.videoWidth) return; |
| + const ctx = canvas.getContext('2d', { alpha: false }); |
| + if (!ctx) return; |
| + const t0 = performance.now(); |
| + try { |
| + ctx.drawImage(video, 0, 0, BACKDROP_W, BACKDROP_H); |
| + } catch (err) { |
| + return; |
| + } |
| + const cost = performance.now() - t0; |
| + const worst = parseFloat(canvas.dataset.costMs || '0'); |
| + if (cost > worst) canvas.dataset.costMs = cost.toFixed(3); |
| + canvas.dataset.painted = String((parseInt(canvas.dataset.painted || '0', 10) || 0) + 1); |
| + } |
| + |
| // tileMarks builds one tile's pair of marks from the server's |
| // templates: the muted badge beside the name, and the camera-off cover |
| // over the video. The cover sits over the element rather than hiding |
| @@ -5885,6 +5917,7 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + clearInterval(backdropTimer); |
| clearInterval(reconnectWatchTimer); |
| clearInterval(levelTimer); |
| stopIceTimers(); |
| @@ -6082,6 +6115,13 @@ |
| // screen into the tile instead of cropping it to fill. |
| if (key.slice(-SHARE_SUFFIX.length) === SHARE_SUFFIX) { |
| figure.classList.add('calls-tile-share'); |
| + // What surrounds the fitted screen on the stage (paintBackdrops). |
| + const backdrop = document.createElement('canvas'); |
| + backdrop.className = 'calls-tile-backdrop'; |
| + backdrop.width = BACKDROP_W; |
| + backdrop.height = BACKDROP_H; |
| + backdrop.setAttribute('aria-hidden', 'true'); |
| + figure.append(backdrop); |
| } |
| const label = document.createElement('figcaption'); |
| label.className = 'calls-tile-name'; |
| @@ -6518,6 +6558,7 @@ |
| clearTimeout(reconnectTimer); |
| clearTimeout(fullTimer); |
| clearInterval(statsTimer); |
| + clearInterval(backdropTimer); |
| clearInterval(reconnectWatchTimer); |
| clearInterval(levelTimer); |
| stopIceTimers(); |
| @@ -7338,6 +7379,7 @@ |
| // camera that stopped encoding (Codex S4-5), so the walk runs |
| // either way and does whichever halves apply. |
| statsTimer = setInterval(pollFrames, 1000); |
| + backdropTimer = setInterval(paintBackdrops, BACKDROP_EVERY); |
| // §15.23: no stats, only what the tab already knows. |
| reconnectWatchTimer = setInterval(watchReconnect, RECONNECT_EVERY); |
| // §15.20's meters, five times a second and cheaper per turn: one |
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go| index e8ba0c4..1f9de34 100644 |
| --- a/internal/callstest/browser_test.go |
| +++ b/internal/callstest/browser_test.go |
| @@ -83,6 +83,7 @@ var scenarios = []string{ |
| "guestdirect", |
| "layout", |
| "glyphs", |
| + "stage", |
| "window", |
| "title", |
| "chrome", |
diff --git a/native/apple/Sources/CallsInterop/StageSnapshots.swift b/native/apple/Sources/CallsInterop/StageSnapshots.swift| index 9337f76..5b61f45 100644 |
| --- a/native/apple/Sources/CallsInterop/StageSnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/StageSnapshots.swift |
| @@ -36,6 +36,9 @@ import SwiftUI |
| try await shot( |
| "stage-camera-760x540-\(state)", shown: shown, size: CGSize(width: 760, height: 540), |
| speaker: "ana") |
| + try await shot( |
| + "stage-screen-900x800-\(state)", .screen, shown: shown, size: CGSize(width: 900, height: 800), |
| + speaker: "ana") |
| try await shot("grid-\(state)", grid: true, shown: shown, speaker: "ana") |
| } |
| for width in [MiniWindowRules.minimumWidth, MiniWindowRules.size.width, 480] { |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| index f409157..09e8543 100644 |
| --- a/native/apple/Sources/CallsKit/CallFixtures.swift |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -171,8 +171,10 @@ extension EnvironmentValues { |
| } |
| } |
| -/// A fixture's frame drawn as a tile draws video: a camera's 16:9 picture |
| -/// filling the tile, a screen's 16:10 fitted whole. |
| +/// A fixture's frame drawn as a tile draws video: placed by `VideoFrame` |
| +/// (a camera's 16:9 picture filling the tile with headroom, a screen's |
| +/// 16:10 fitted whole) and, around a screen, the same frame soft and |
| +/// dimmed as `ScreenBackdrop` draws it. |
| @_spi(Fixtures) public struct FixtureVideo: View { |
| let picture: FixtureBackdrop |
| let screen: Bool |
| @@ -184,14 +186,20 @@ extension EnvironmentValues { |
| GeometryReader { geometry in |
| let bounds = geometry.size |
| let video = screen ? CGSize(width: 16, height: 10) : CGSize(width: 16, height: 9) |
| - let scale = |
| - screen |
| - ? min(bounds.width / video.width, bounds.height / video.height) |
| - : max(bounds.width / video.width, bounds.height / video.height) |
| - let size = CGSize(width: video.width * scale, height: video.height * scale) |
| - picture.view |
| - .frame(width: size.width, height: size.height) |
| - .position(x: bounds.width / 2, y: bounds.height / 2) |
| + let placed = VideoFrame.place(video: video, in: bounds, fit: screen) |
| + ZStack(alignment: .topLeading) { |
| + if screen { |
| + CallsAppearance.stageSurface |
| + picture.view |
| + .frame(width: bounds.width, height: bounds.height) |
| + .scaleEffect(1.15) |
| + .blur(radius: 24) |
| + .opacity(BackdropSampler.dimmed) |
| + } |
| + picture.view |
| + .frame(width: placed.width, height: placed.height) |
| + .offset(x: placed.minX, y: placed.minY) |
| + } |
| } |
| .clipped() |
| } |
diff --git a/native/apple/Sources/CallsKit/CallsAppearance.swift b/native/apple/Sources/CallsKit/CallsAppearance.swift| index e72f292..cee6e96 100644 |
| --- a/native/apple/Sources/CallsKit/CallsAppearance.swift |
| +++ b/native/apple/Sources/CallsKit/CallsAppearance.swift |
| @@ -8,4 +8,7 @@ public enum CallsAppearance { |
| /// the accent itself is a deep teal that would read as a shadow there. |
| public static let accentLit = Color(red: 0.18, green: 0.80, blue: 0.86) |
| public static let background = Color.primary.opacity(0.035) |
| + /// The stage under a picture that does not cover it: the web call |
| + /// window's #0b0b0c, a shade off black. |
| + public static let stageSurface = Color(red: 11 / 255, green: 11 / 255, blue: 12 / 255) |
| } |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| index d859891..d5f38e3 100644 |
| --- a/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -139,27 +139,31 @@ |
| .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. |
| + /// Speaker view: the stage fills the whole window, edge to edge, as |
| + /// FaceTime's does, and everyone else is a filmstrip floating over its |
| + /// bottom, in this viewer's order (2026-10-05: until then the stage |
| + /// stopped above the filmstrip, leaving a black band across the |
| + /// window behind it and the bar). The filmstrip rises clear of the bar |
| + /// while the controls show, as the self view does, and settles to the |
| + /// window's edge when they go. The stage's name sits at the window's |
| + /// bottom left, just above the filmstrip (`StageLabel`). 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 |
| + let stageLift = StageLabel.lift(bar: lift, band: band) |
| return ZStack(alignment: .bottom) { |
| Group { |
| if let focused { |
| - macTile(focused, cornerRadius: 0, compact: true, labelLift: lift, onStage: true) |
| + macTile(focused, cornerRadius: 0, compact: true, labelLift: stageLift, onStage: true) |
| .overlay(alignment: .bottomTrailing) { |
| if arrangement.pinned == focused { |
| PinBadge(title: itemTitle(focused)) { |
| withAnimation(panelAnimation) { arrangement.pin(nil) } |
| } |
| - .padding(8).padding(.bottom, lift) |
| + .padding(8).padding(.bottom, stageLift) |
| } |
| } |
| } else { |
| @@ -173,7 +177,7 @@ |
| } |
| } |
| .frame(maxWidth: .infinity, maxHeight: .infinity) |
| - .padding(.bottom, band) |
| + .animation(fade, value: chrome.visible) |
| if !strip.isEmpty { |
| filmstripView(strip, tile: thumb, width: size.width) |
| .padding(.bottom, chrome.visible ? Self.barReserve - 8 : 0) |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index 1d3744e..2ba3e22 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -41,7 +41,7 @@ |
| var iconOnly = false |
| /// The height of the name label and what is over it, lift included, |
| - /// which the placeholder stays above. |
| + /// which the placeholder keeps clear of. |
| @State private var labelBlock: CGFloat = 0 |
| @Environment(\.fixtureStagePicture) private var fixturePicture |
| @@ -55,6 +55,9 @@ |
| if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil { |
| FixtureVideo(picture: fixturePicture, screen: screen) |
| } else if let track, !cameraOff, placeholder == nil { |
| + // A screen on the stage is fitted whole; around it, the same |
| + // picture soft and dimmed rather than black bars. |
| + if screen, onStage { ScreenBackdrop(track: track) } |
| MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| TilePlaceholder( |
| @@ -148,14 +151,10 @@ |
| override func layout() { |
| super.layout() |
| - let across = bounds.width / videoSize.width |
| - let down = bounds.height / videoSize.height |
| - let scale = fit ? min(across, down) : max(across, down) |
| - let size = NSSize(width: videoSize.width * scale, height: videoSize.height * scale) |
| + // Bottom-left origin: VideoFrame works top down. |
| + let placed = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit) |
| renderer.frame = NSRect( |
| - x: (bounds.width - size.width) / 2, |
| - y: (bounds.height - size.height) / 2, |
| - width: size.width, height: size.height) |
| + x: placed.minX, y: bounds.height - placed.maxY, width: placed.width, height: placed.height) |
| } |
| } |
| #endif |
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift| index cddb9ea..47af48a 100644 |
| --- a/native/apple/Sources/CallsKit/MutedBadge.swift |
| +++ b/native/apple/Sources/CallsKit/MutedBadge.swift |
| @@ -76,10 +76,15 @@ public struct TileNameLabel: View { |
| } |
| /// What a tile shows where its picture is not: a symbol, and the words |
| -/// for it when there is room (0.4.1). Centred in the space the tile has |
| -/// above its name label (`reserve`), never under it, and on a tile too |
| -/// short for the words the symbol stands alone, then smaller. The tile's |
| -/// accessibility value carries the words either way. |
| +/// for it when there is room. Centred in the whole tile, as FaceTime has |
| +/// it, with the name label over the tile's bottom edge (2026-10-05, Paul: |
| +/// "Vertical centering here too"; until then, from 0.4.1, it was centred |
| +/// in the space above the label, which left it high). Only if the tile is |
| +/// so short that the centred placeholder would reach the label is it |
| +/// moved up, by no more than that (`PlaceholderPlacement`). On a tile too |
| +/// short for the words between the label and the top, the symbol stands |
| +/// alone, then smaller. The tile's accessibility value carries the words |
| +/// either way. |
| public struct TilePlaceholder: View { |
| let symbol: String |
| let words: String? |
| @@ -101,25 +106,67 @@ public struct TilePlaceholder: View { |
| } |
| public var body: some View { |
| - ViewThatFits(in: .vertical) { |
| - if let words { |
| - VStack(spacing: spacing) { |
| - Image(systemName: symbol).font(iconFont) |
| - Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2) |
| - .padding(.horizontal, 12) |
| + PlaceholderLayout(reserve: reserve) { |
| + ViewThatFits(in: .vertical) { |
| + if let words { |
| + VStack(spacing: spacing) { |
| + Image(systemName: symbol).font(iconFont) |
| + Text(words).font(wordsFont).multilineTextAlignment(.center).lineLimit(2) |
| + .padding(.horizontal, 12) |
| + } |
| } |
| + Image(systemName: symbol).font(iconFont) |
| + Image(systemName: symbol).font(.body) |
| } |
| - Image(systemName: symbol).font(iconFont) |
| - Image(systemName: symbol).font(.body) |
| } |
| .foregroundStyle(.white.opacity(0.8)) |
| - .frame(maxWidth: .infinity, maxHeight: .infinity) |
| - // A little air between the placeholder and the label as well. |
| - .padding(.bottom, reserve > 0 ? reserve + 4 : 0) |
| .accessibilityHidden(true) |
| } |
| } |
| +/// Where a tile's placeholder goes, apart from any view so tests hold it: |
| +/// centred in the tile, unless that would bring it within `gap` of the |
| +/// label block at the bottom, when it rises just clear of it, and never |
| +/// above the tile's top. |
| +public enum PlaceholderPlacement { |
| + /// Air kept between the placeholder and the top of the label block. |
| + public static let gap: CGFloat = 4 |
| + |
| + /// The height the placeholder may take and still clear the label: what |
| + /// chooses between the words, the symbol, and the smaller symbol. |
| + public static func room(tile: CGFloat, reserve: CGFloat) -> CGFloat { |
| + max(0, tile - clearance(reserve)) |
| + } |
| + |
| + /// The placeholder's top, from the tile's top, for content `height` tall. |
| + public static func top(tile: CGFloat, content height: CGFloat, reserve: CGFloat) -> CGFloat { |
| + let centred = (tile - height) / 2 |
| + let clear = tile - clearance(reserve) - height |
| + return max(0, min(centred, clear)) |
| + } |
| + |
| + static func clearance(_ reserve: CGFloat) -> CGFloat { reserve > 0 ? reserve + gap : 0 } |
| +} |
| + |
| +/// Fills the tile and puts its one subview where `PlaceholderPlacement` |
| +/// says, offered the height it may take so `ViewThatFits` chooses what |
| +/// fits there. |
| +struct PlaceholderLayout: Layout { |
| + let reserve: CGFloat |
| + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { |
| + proposal.replacingUnspecifiedDimensions() |
| + } |
| + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { |
| + guard let content = subviews.first else { return } |
| + let room = PlaceholderPlacement.room(tile: bounds.height, reserve: reserve) |
| + let size = content.sizeThatFits(ProposedViewSize(width: bounds.width, height: room)) |
| + let top = PlaceholderPlacement.top(tile: bounds.height, content: size.height, reserve: reserve) |
| + content.place( |
| + at: CGPoint(x: bounds.midX, y: bounds.minY + top), anchor: .top, |
| + proposal: ProposedViewSize(width: bounds.width, height: size.height)) |
| + } |
| +} |
| + |
| /// A tile's name label block, measured, so its placeholder can keep |
| /// clear of it (see `TilePlaceholder`). |
| extension View { |
diff --git a/native/apple/Sources/CallsKit/ScreenBackdrop.swift b/native/apple/Sources/CallsKit/ScreenBackdrop.swift| new file mode 100644 |
| index 0000000..aefb084 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/ScreenBackdrop.swift |
| @@ -0,0 +1,239 @@ |
| +import CoreGraphics |
| +import Foundation |
| +import LiveKitWebRTC |
| +import SwiftUI |
| + |
| +/// What surrounds a shared screen on the stage (2026-10-05, Paul: "Should |
| +/// the video view stay fullscreen even here?"). A screen is fitted whole, |
| +/// never cropped, so a window of another shape leaves bars beside or |
| +/// above it. Rather than black, they show the same picture enlarged, soft |
| +/// and dimmed, as FaceTime does. |
| +/// |
| +/// Cheap by construction: twice a second, WebRTC scales one frame down to |
| +/// `width` × `height` (libyuv, from whatever buffer the decoder gave), it |
| +/// is converted and blurred at that size, and the picture is drawn |
| +/// enlarged, its smooth upscaling doing the rest of the blur. Nothing runs |
| +/// at the frame rate and nothing is blurred at the window's size. |
| +public struct ScreenBackdrop: View { |
| + let track: RTCVideoTrack |
| + @StateObject private var sampler = BackdropSampler() |
| + |
| + public init(track: RTCVideoTrack) { self.track = track } |
| + |
| + public var body: some View { |
| + ZStack { |
| + CallsAppearance.stageSurface |
| + if let image = sampler.image { |
| + Image(decorative: image, scale: 1) |
| + .resizable() |
| + .interpolation(.high) |
| + .aspectRatio(contentMode: .fill) |
| + // Past the edges a little: the blur's own edge is darker. |
| + .scaleEffect(1.15) |
| + .opacity(BackdropSampler.dimmed) |
| + .transition(.opacity) |
| + } |
| + } |
| + .clipped() |
| + .animation(.easeInOut(duration: 0.4), value: sampler.image != nil) |
| + .onAppear { sampler.attach(track) } |
| + .onChange(of: ObjectIdentifier(track)) { sampler.attach(track) } |
| + .onDisappear { sampler.detach() } |
| + .accessibilityHidden(true) |
| + } |
| +} |
| + |
| +/// Takes frames from a track at `interval` and keeps the latest as a small |
| +/// blurred picture. |
| +@MainActor final class BackdropSampler: ObservableObject { |
| + /// The picture's size: 16:9, enough for the colours and their places. |
| + nonisolated static let width = 64 |
| + nonisolated static let height = 36 |
| + /// How often a frame is taken. |
| + nonisolated static let interval: TimeInterval = 0.5 |
| + /// How much of the picture shows over the dark surface behind it. |
| + nonisolated static let dimmed: Double = 0.45 |
| + |
| + @Published private(set) var image: CGImage? |
| + private var track: RTCVideoTrack? |
| + private var renderer: Renderer? |
| + |
| + func attach(_ next: RTCVideoTrack) { |
| + guard track !== next else { return } |
| + detach() |
| + let renderer = Renderer { [weak self] image in |
| + Task { @MainActor in self?.image = image } |
| + } |
| + next.add(renderer) |
| + track = next |
| + self.renderer = renderer |
| + } |
| + |
| + func detach() { |
| + if let renderer { track?.remove(renderer) } |
| + track = nil |
| + renderer = nil |
| + image = nil |
| + } |
| + |
| + /// WebRTC's side: called on its decoding thread for every frame, it |
| + /// keeps one in `interval` and lets the rest go at once. |
| + final class Renderer: NSObject, RTCVideoRenderer, @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var last: TimeInterval = 0 |
| + private let deliver: (CGImage) -> Void |
| + init(deliver: @escaping (CGImage) -> Void) { self.deliver = deliver } |
| + |
| + func setSize(_ size: CGSize) {} |
| + |
| + func renderFrame(_ frame: RTCVideoFrame?) { |
| + guard let frame else { return } |
| + let now = ProcessInfo.processInfo.systemUptime |
| + lock.lock() |
| + let due = now - last >= BackdropSampler.interval |
| + if due { last = now } |
| + lock.unlock() |
| + guard due, let image = BackdropSampler.picture(of: frame) else { return } |
| + deliver(image) |
| + } |
| + } |
| + |
| + /// One frame as the backdrop's picture: scaled down by WebRTC, then |
| + /// converted and blurred here. |
| + nonisolated static func picture(of frame: RTCVideoFrame) -> CGImage? { |
| + let source = frame.buffer |
| + guard source.width > 0, source.height > 0 else { return nil } |
| + let small = |
| + source.cropAndScale?( |
| + with: 0, offsetY: 0, cropWidth: source.width, cropHeight: source.height, |
| + scaleWidth: Int32(width), scaleHeight: Int32(height)) ?? source |
| + let planes = small.toI420() |
| + var rgba = rgbaFromI420( |
| + width: Int(planes.width), height: Int(planes.height), |
| + y: planes.dataY, strideY: Int(planes.strideY), |
| + u: planes.dataU, strideU: Int(planes.strideU), |
| + v: planes.dataV, strideV: Int(planes.strideV), |
| + outWidth: width, outHeight: height) |
| + boxBlur(&rgba, width: width, height: height, radius: 3, passes: 2) |
| + return image(rgba, width: width, height: height) |
| + } |
| + |
| + /// I420 to RGBA (BT.601, video range) at `outWidth` × `outHeight`, |
| + /// sampling the nearest source pixel when the sizes differ. Integer |
| + /// arithmetic (coefficients × 256) over raw buffers. |
| + nonisolated static func rgbaFromI420( |
| + width: Int, height: Int, y: UnsafePointer<UInt8>, strideY: Int, |
| + u: UnsafePointer<UInt8>, strideU: Int, v: UnsafePointer<UInt8>, strideV: Int, |
| + outWidth: Int, outHeight: Int |
| + ) -> [UInt8] { |
| + var out = [UInt8](repeating: 255, count: outWidth * outHeight * 4) |
| + guard width > 0, height > 0 else { return out } |
| + out.withUnsafeMutableBufferPointer { buffer in |
| + let o = buffer.baseAddress! |
| + var i = 0 |
| + for row in 0..<outHeight { |
| + let sy = min(height - 1, row * height / outHeight) |
| + let yRow = y + sy * strideY |
| + let uRow = u + (sy >> 1) * strideU |
| + let vRow = v + (sy >> 1) * strideV |
| + for col in 0..<outWidth { |
| + let sx = min(width - 1, col * width / outWidth) |
| + let c = 298 * (Int(yRow[sx]) - 16) + 128 |
| + let d = Int(uRow[sx >> 1]) - 128 |
| + let e = Int(vRow[sx >> 1]) - 128 |
| + o[i] = clamp8((c + 409 * e) >> 8) |
| + o[i + 1] = clamp8((c - 100 * d - 208 * e) >> 8) |
| + o[i + 2] = clamp8((c + 516 * d) >> 8) |
| + i += 4 |
| + } |
| + } |
| + } |
| + return out |
| + } |
| + |
| + @inline(__always) nonisolated private static func clamp8(_ value: Int) -> UInt8 { |
| + UInt8(truncatingIfNeeded: value < 0 ? 0 : value > 255 ? 255 : value) |
| + } |
| + |
| + /// A separable box blur, `passes` times (two is close to Gaussian), |
| + /// with running sums, so each pass is linear in the pixels. |
| + nonisolated static func boxBlur( |
| + _ pixels: inout [UInt8], width: Int, height: Int, radius: Int, passes: Int |
| + ) { |
| + guard radius > 0, width > 0, height > 0 else { return } |
| + var scratch = pixels |
| + pixels.withUnsafeMutableBufferPointer { a in |
| + scratch.withUnsafeMutableBufferPointer { b in |
| + for _ in 0..<passes { |
| + blurLines(a.baseAddress!, b.baseAddress!, count: height, length: width, step: 4, lineStep: width * 4, radius: radius) |
| + blurLines(b.baseAddress!, a.baseAddress!, count: width, length: height, step: width * 4, lineStep: 4, radius: radius) |
| + } |
| + } |
| + } |
| + } |
| + |
| + nonisolated private static func blurLines( |
| + _ source: UnsafeMutablePointer<UInt8>, _ out: UnsafeMutablePointer<UInt8>, count: Int, |
| + length: Int, step: Int, lineStep: Int, radius: Int |
| + ) { |
| + for line in 0..<count { |
| + let base = line * lineStep |
| + for channel in 0..<3 { |
| + var sum = 0 |
| + var n = 0 |
| + // The window [at - radius, at + radius], clipped to the line. |
| + for k in 0...min(length - 1, radius) { |
| + sum += Int(source[base + k * step + channel]) |
| + n += 1 |
| + } |
| + for at in 0..<length { |
| + out[base + at * step + channel] = UInt8(truncatingIfNeeded: sum / n) |
| + let leaving = at - radius |
| + let entering = at + radius + 1 |
| + if leaving >= 0 { |
| + sum -= Int(source[base + leaving * step + channel]) |
| + n -= 1 |
| + } |
| + if entering < length { |
| + sum += Int(source[base + entering * step + channel]) |
| + n += 1 |
| + } |
| + } |
| + } |
| + for at in 0..<length { out[base + at * step + 3] = 255 } |
| + } |
| + } |
| + |
| + nonisolated static func image(_ rgba: [UInt8], width: Int, height: Int) -> CGImage? { |
| + guard let provider = CGDataProvider(data: Data(rgba) as CFData) else { return nil } |
| + return CGImage( |
| + width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: width * 4, |
| + space: CGColorSpaceCreateDeviceRGB(), |
| + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipLast.rawValue), |
| + provider: provider, decode: nil, shouldInterpolate: true, intent: .defaultIntent) |
| + } |
| +} |
| + |
| +/// Where a picture goes in its tile, top-down: a screen fitted whole and |
| +/// centred; a camera filling the tile, cropped evenly from the sides but, |
| +/// from top and bottom, more from the bottom (`headroom`), since a face |
| +/// is above the middle of the picture and a crop through the forehead |
| +/// reads worse than one through the chest. The web's stage and tiles do |
| +/// the same with object-position (calls.css). |
| +public enum VideoFrame { |
| + /// The share of a vertical crop taken from the top: 0.5 would be even. |
| + public static let headroom: CGFloat = 0.35 |
| + |
| + public static func place(video: CGSize, in bounds: CGSize, fit: Bool) -> CGRect { |
| + guard video.width > 0, video.height > 0 else { return CGRect(origin: .zero, size: bounds) } |
| + let across = bounds.width / video.width |
| + let down = bounds.height / video.height |
| + let scale = fit ? min(across, down) : max(across, down) |
| + let size = CGSize(width: video.width * scale, height: video.height * scale) |
| + let spare = bounds.height - size.height |
| + // Spare room (a fitted screen) is shared evenly; a crop (negative) |
| + // takes headroom of it from the top. |
| + let top = spare >= 0 || fit ? spare / 2 : spare * headroom |
| + return CGRect(x: (bounds.width - size.width) / 2, y: top, width: size.width, height: size.height) |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/SpeakerView.swift b/native/apple/Sources/CallsKit/SpeakerView.swift| index 8222e73..9f79050 100644 |
| --- a/native/apple/Sources/CallsKit/SpeakerView.swift |
| +++ b/native/apple/Sources/CallsKit/SpeakerView.swift |
| @@ -347,3 +347,17 @@ public func stageLabel(_ label: String, onStage: Bool, pinned: Bool) -> String { |
| guard onStage else { return label } |
| return StageWords.onStage(label) + (pinned ? ", pinned" : "") |
| } |
| + |
| +/// Where the stage's name sits in Speaker view, now that the stage fills |
| +/// the window under the filmstrip: at the bottom left, lifted by the |
| +/// filmstrip's band and, while the controls show, by as much as the |
| +/// filmstrip rises for the bar, so it rides just above the filmstrip and |
| +/// is never stranded mid-height. Without a filmstrip it clears the bar |
| +/// alone, as it always has. The same rule is calls.css's stage caption and |
| +/// the iPhone's stage label. |
| +public enum StageLabel { |
| + /// `bar` is how far the filmstrip (or a name) rises for the bar now: 0 |
| + /// while the controls are hidden. `band` is the filmstrip's height with |
| + /// its padding, 0 when there is none. |
| + public static func lift(bar: CGFloat, band: CGFloat) -> CGFloat { bar + band } |
| +} |
diff --git a/native/apple/Tests/CallsKitTests/TileLayoutTests.swift b/native/apple/Tests/CallsKitTests/TileLayoutTests.swift| new file mode 100644 |
| index 0000000..2e6ba2a |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/TileLayoutTests.swift |
| @@ -0,0 +1,155 @@ |
| +import CoreGraphics |
| +import Foundation |
| +import XCTest |
| + |
| +@testable import CallsKit |
| + |
| +/// 2026-10-05: a tile's placeholder centred in the whole tile, the stage |
| +/// filling the window under the filmstrip, a camera cropped with headroom, |
| +/// and the screen's backdrop. |
| +final class TileLayoutTests: XCTestCase { |
| + // MARK: Placeholder |
| + |
| + func testCentredInTheWholeTileWhenThereIsRoom() { |
| + // A stage 600 tall, a 34 pt label block: centred, not above the label. |
| + let top = PlaceholderPlacement.top(tile: 600, content: 80, reserve: 34) |
| + XCTAssertEqual(top, 260) |
| + XCTAssertEqual(top + 40, 300, "its middle is the tile's middle") |
| + } |
| + |
| + func testRisesOnlyAsFarAsTheLabelNeedsOnAShortTile() { |
| + // The mini window at its smallest, 200 × 112.5, label block 44: a |
| + // 34 pt symbol centred would end at 73.25, past the label's top less |
| + // the gap (112.5 - 48 = 64.5), so it rises by 8.75 and no more. |
| + let top = PlaceholderPlacement.top(tile: 112.5, content: 34, reserve: 44) |
| + XCTAssertEqual(top + 34, 112.5 - 44 - PlaceholderPlacement.gap, accuracy: 0.001) |
| + XCTAssertLessThan(top, (112.5 - 34) / 2) |
| + XCTAssertEqual((112.5 - 34) / 2 - top, 8.75, accuracy: 0.001) |
| + } |
| + |
| + func testNeverAboveTheTop() { |
| + XCTAssertEqual(PlaceholderPlacement.top(tile: 60, content: 50, reserve: 40), 0) |
| + } |
| + |
| + func testNoLabelIsPlainCentring() { |
| + XCTAssertEqual(PlaceholderPlacement.top(tile: 100, content: 20, reserve: 0), 40) |
| + XCTAssertEqual(PlaceholderPlacement.room(tile: 100, reserve: 0), 100) |
| + } |
| + |
| + func testRoomIsWhatChoosesTheWords() { |
| + // At 200 wide the words and symbol (about 66 pt) do not fit above a |
| + // 44 pt label in 112.5: the symbol stands alone. |
| + XCTAssertLessThan(PlaceholderPlacement.room(tile: 112.5, reserve: 44), 66) |
| + // At 320 (180 tall) they do. |
| + XCTAssertGreaterThan(PlaceholderPlacement.room(tile: 180, reserve: 44), 66) |
| + } |
| + |
| + func testEveryMiniSizeKeepsTheSymbolClearOfTheLabelAndCentredWhereItCan() { |
| + for width in [200.0, 320, 480] { |
| + let tile = width * 9 / 16 |
| + let reserve: CGFloat = 44 |
| + let content: CGFloat = PlaceholderPlacement.room(tile: tile, reserve: reserve) >= 66 ? 66 : 34 |
| + let top = PlaceholderPlacement.top(tile: tile, content: content, reserve: reserve) |
| + XCTAssertLessThanOrEqual(top + content, tile - reserve - PlaceholderPlacement.gap + 0.001, "\(width)") |
| + let centred = (tile - content) / 2 |
| + if centred + content <= tile - reserve - PlaceholderPlacement.gap { |
| + XCTAssertEqual(top, centred, accuracy: 0.001, "\(width): centred when it clears") |
| + } |
| + } |
| + } |
| + |
| + // MARK: The stage |
| + |
| + func testStageLabelRidesAboveTheFilmstrip() { |
| + // Controls showing: the filmstrip rises 72 for the bar; band 102. |
| + XCTAssertEqual(StageLabel.lift(bar: 72, band: 102), 174) |
| + // Hidden: just the band. |
| + XCTAssertEqual(StageLabel.lift(bar: 0, band: 102), 102) |
| + // A call of two: no filmstrip, the bar alone. |
| + XCTAssertEqual(StageLabel.lift(bar: 72, band: 0), 72) |
| + } |
| + |
| + func testACameraFillsWithMoreCropFromTheBottom() { |
| + // 16:9 into a 4:3 window: fills, crops sides evenly. |
| + let wide = VideoFrame.place(video: CGSize(width: 1280, height: 720), in: CGSize(width: 800, height: 600), fit: false) |
| + XCTAssertEqual(wide.height, 600, accuracy: 0.001) |
| + XCTAssertEqual(wide.minX, -(wide.width - 800) / 2, accuracy: 0.001) |
| + XCTAssertEqual(wide.minY, 0, accuracy: 0.001) |
| + // 16:9 into a tall window: crops top and bottom, 35% from the top. |
| + let tall = VideoFrame.place(video: CGSize(width: 1280, height: 720), in: CGSize(width: 1600, height: 600), fit: false) |
| + let overflow = tall.height - 600 |
| + XCTAssertGreaterThan(overflow, 0) |
| + XCTAssertEqual(-tall.minY, overflow * VideoFrame.headroom, accuracy: 0.001) |
| + XCTAssertEqual(tall.maxY - 600, overflow * (1 - VideoFrame.headroom), accuracy: 0.001) |
| + } |
| + |
| + func testAScreenIsFittedWholeAndCentred() { |
| + let r = VideoFrame.place(video: CGSize(width: 1920, height: 1200), in: CGSize(width: 1000, height: 1000), fit: true) |
| + XCTAssertEqual(r.width, 1000, accuracy: 0.001) |
| + XCTAssertEqual(r.minY, (1000 - r.height) / 2, accuracy: 0.001) |
| + XCTAssertGreaterThanOrEqual(r.minY, 0) |
| + } |
| + |
| + // MARK: The screen's backdrop |
| + |
| + func testBackdropPictureIsTheFramesColoursBlurred() throws { |
| + // A 64 × 36 I420 frame: left half pure red, right half pure blue. |
| + let (w, h) = (BackdropSampler.width, BackdropSampler.height) |
| + var y = [UInt8](repeating: 0, count: w * h) |
| + var u = [UInt8](repeating: 0, count: (w / 2) * (h / 2)) |
| + var v = [UInt8](repeating: 0, count: (w / 2) * (h / 2)) |
| + for row in 0..<h { |
| + for col in 0..<w { y[row * w + col] = col < w / 2 ? 81 : 41 } |
| + } |
| + for row in 0..<(h / 2) { |
| + for col in 0..<(w / 2) { |
| + u[row * (w / 2) + col] = col < w / 4 ? 90 : 240 |
| + v[row * (w / 2) + col] = col < w / 4 ? 240 : 110 |
| + } |
| + } |
| + var rgba = y.withUnsafeBufferPointer { yp in |
| + u.withUnsafeBufferPointer { up in |
| + v.withUnsafeBufferPointer { vp in |
| + BackdropSampler.rgbaFromI420( |
| + width: w, height: h, y: yp.baseAddress!, strideY: w, u: up.baseAddress!, strideU: w / 2, |
| + v: vp.baseAddress!, strideV: w / 2, outWidth: w, outHeight: h) |
| + } |
| + } |
| + } |
| + XCTAssertGreaterThan(rgba[0], 200, "red on the left") |
| + XCTAssertGreaterThan(rgba[(w - 1) * 4 + 2], 200, "blue on the right") |
| + let edge = (w / 2) * 4 |
| + let before = rgba[edge] |
| + BackdropSampler.boxBlur(&rgba, width: w, height: h, radius: 3, passes: 2) |
| + XCTAssertLessThan(rgba[edge], before == 0 ? 255 : 255, "blurred") |
| + XCTAssertGreaterThan(rgba[edge + 2], 40, "blue has bled across the edge") |
| + XCTAssertGreaterThan(rgba[edge], 40, "and red") |
| + XCTAssertNotNil(BackdropSampler.image(rgba, width: w, height: h)) |
| + _ = try XCTUnwrap(rgba.last) |
| + } |
| + |
| + func testBackdropWorkIsCheap() { |
| + // What happens twice a second for a screen on the stage, after |
| + // WebRTC's own scale to 64 × 36: conversion, blur and the CGImage. |
| + let (w, h) = (BackdropSampler.width, BackdropSampler.height) |
| + let y = [UInt8](repeating: 120, count: w * h) |
| + let c = [UInt8](repeating: 128, count: (w / 2) * (h / 2)) |
| + let runs = 50 |
| + let start = Date() |
| + for _ in 0..<runs { |
| + var rgba = y.withUnsafeBufferPointer { yp in |
| + c.withUnsafeBufferPointer { cp in |
| + BackdropSampler.rgbaFromI420( |
| + width: w, height: h, y: yp.baseAddress!, strideY: w, u: cp.baseAddress!, strideU: w / 2, |
| + v: cp.baseAddress!, strideV: w / 2, outWidth: w, outHeight: h) |
| + } |
| + } |
| + BackdropSampler.boxBlur(&rgba, width: w, height: h, radius: 3, passes: 2) |
| + _ = BackdropSampler.image(rgba, width: w, height: h) |
| + } |
| + let each = Date().timeIntervalSince(start) / Double(runs) * 1000 |
| + print("backdrop: \(String(format: "%.3f", each)) ms a picture, twice a second") |
| + // A debug build; release is several times quicker. Well under a frame. |
| + XCTAssertLessThan(each, 8) |
| + } |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index c212fa0..3ef7262 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -423,7 +423,7 @@ struct PhoneVideoTile: View { |
| /// A filmstrip thumbnail: the symbol alone for a missing picture. |
| var iconOnly = false |
| /// The height of the name label and what is over it, which the |
| - /// placeholder stays above. |
| + /// placeholder keeps clear of. |
| @State private var labelBlock: CGFloat = 0 |
| @Environment(\.fixtureStagePicture) private var fixturePicture |
| var body: some View { |
| @@ -435,6 +435,9 @@ struct PhoneVideoTile: View { |
| if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil { |
| FixtureVideo(picture: fixturePicture, screen: screen) |
| } else if let track, !cameraOff, placeholder == nil { |
| + // A screen on the stage is fitted whole; around it, the same |
| + // picture soft and dimmed rather than black bars. |
| + if screen, onStage { ScreenBackdrop(track: track) } |
| PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| TilePlaceholder( |
| @@ -473,18 +476,18 @@ struct PhoneVideo: UIViewRepresentable { |
| let track: RTCVideoTrack |
| var fit = false |
| func makeCoordinator() -> Coordinator { Coordinator() } |
| - func makeUIView(context: Context) -> RTCMTLVideoView { |
| - let view = RTCMTLVideoView(frame: .zero) |
| - view.videoContentMode = fit ? .scaleAspectFit : .scaleAspectFill |
| - context.coordinator.attach(track, to: view) |
| + func makeUIView(context: Context) -> PlacedVideoView { |
| + let view = PlacedVideoView() |
| + view.fit = fit |
| + context.coordinator.attach(track, to: view.renderer) |
| return view |
| } |
| - func updateUIView(_ view: RTCMTLVideoView, context: Context) { |
| - view.videoContentMode = fit ? .scaleAspectFit : .scaleAspectFill |
| - context.coordinator.attach(track, to: view) |
| + func updateUIView(_ view: PlacedVideoView, context: Context) { |
| + view.fit = fit |
| + context.coordinator.attach(track, to: view.renderer) |
| } |
| - static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) { |
| - coordinator.track?.remove(view) |
| + static func dismantleUIView(_ view: PlacedVideoView, coordinator: Coordinator) { |
| + coordinator.track?.remove(view.renderer) |
| } |
| final class Coordinator { |
| var track: RTCVideoTrack? |
| @@ -498,6 +501,37 @@ struct PhoneVideo: UIViewRepresentable { |
| } |
| } |
| +/// The renderer at the size and place `VideoFrame` gives it: a camera |
| +/// filling the tile with more of a crop taken from the bottom than the |
| +/// top, so heads are not cut; a screen fitted whole. The container clips. |
| +final class PlacedVideoView: UIView, RTCVideoViewDelegate { |
| + let renderer = RTCMTLVideoView(frame: .zero) |
| + private var videoSize = CGSize(width: 16, height: 9) |
| + var fit = false { |
| + didSet { if fit != oldValue { setNeedsLayout() } } |
| + } |
| + override init(frame: CGRect) { |
| + super.init(frame: frame) |
| + clipsToBounds = true |
| + isUserInteractionEnabled = false |
| + renderer.videoContentMode = .scaleToFill |
| + renderer.delegate = self |
| + addSubview(renderer) |
| + } |
| + required init?(coder: NSCoder) { nil } |
| + nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { |
| + DispatchQueue.main.async { [weak self] in |
| + guard let self, size.width > 0, size.height > 0 else { return } |
| + self.videoSize = size |
| + self.setNeedsLayout() |
| + } |
| + } |
| + override func layoutSubviews() { |
| + super.layoutSubviews() |
| + renderer.frame = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit) |
| + } |
| +} |
| + |
| /// 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 { |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| index b4b0129..294350e 100644 |
| --- a/native/ios/Sources/PhoneFullScreenCall.swift |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -101,11 +101,14 @@ extension PhoneCall { |
| .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. |
| + /// Speaker view: the stage fills the whole screen, edge to edge, and |
| + /// everyone else is a filmstrip floating over its bottom that scrolls |
| + /// sideways, in this viewer's order (2026-10-05: until then the stage |
| + /// stopped above the filmstrip, a black band behind it). The filmstrip |
| + /// sits just above the home indicator and rises clear of the bar while |
| + /// the controls show, as the self view does; the stage's name rides just |
| + /// above it (StageLabel). 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) |
| @@ -113,7 +116,7 @@ extension PhoneCall { |
| let raised = chrome.visible ? Self.barHeight : 0 |
| let lift = EdgeInsets( |
| top: 0, leading: safe.leading, |
| - bottom: strip.isEmpty ? safe.bottom + raised : raised, |
| + bottom: safe.bottom + StageLabel.lift(bar: raised, band: band), |
| trailing: 0) |
| return ZStack(alignment: .bottom) { |
| Group { |
| @@ -133,7 +136,6 @@ extension PhoneCall { |
| // 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 { |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 250627b..f19ca8b 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -347,9 +347,78 @@ async function newContext(label, { cookie = null, javaScriptEnabled = true, via |
| // constructed — no page behaviour changes, and nothing reads it |
| // except diagnose() and the renewal scenario's own ufrag walk. |
| if (javaScriptEnabled) await ctx.addInitScript(pcTap); |
| + if (javaScriptEnabled && process.env.CALLS_SYNTHETIC_MEDIA) await ctx.addInitScript(syntheticMedia); |
| return ctx; |
| } |
| +// syntheticMedia is an init script, used only when CALLS_SYNTHETIC_MEDIA |
| +// is set: for a machine where Chromium's fake devices cannot be used. |
| +// Chromium asks macOS for camera and microphone access even for them, on |
| +// behalf of whatever launched it, and when that process has never been |
| +// granted it (TCC "not determined") nobody can answer and getUserMedia |
| +// never settles — every scenario then stops at "Connecting to the call." |
| +// (2026-10-05, from an agent's shell). This draws the camera and the |
| +// screen on canvases instead (a head and shoulders on a gradient, moving; |
| +// a 16:10 document) and makes the microphone a quiet tone. Scenarios that |
| +// assert on Chromium's own fake device names do not hold under it: it is |
| +// a stand-in, said so in the run, never the gate's normal way. |
| +const syntheticMedia = () => { |
| + const md = navigator.mediaDevices; |
| + if (!md) return; |
| + const drawn = (w, h, draw) => { |
| + const c = document.createElement('canvas'); |
| + c.width = w; |
| + c.height = h; |
| + const g = c.getContext('2d'); |
| + let t = 0; |
| + draw(g, w, h, 0); |
| + setInterval(() => draw(g, w, h, ++t), 50); |
| + return c.captureStream(20).getVideoTracks()[0]; |
| + }; |
| + const camera = () => drawn(640, 360, (g, w, h, t) => { |
| + const sky = g.createLinearGradient(0, 0, w, h); |
| + sky.addColorStop(0, '#6d8fb3'); |
| + sky.addColorStop(1, '#2f3d2a'); |
| + g.fillStyle = sky; |
| + g.fillRect(0, 0, w, h); |
| + g.fillStyle = '#c99a78'; |
| + g.beginPath(); g.ellipse(w / 2, h * 0.36, w * 0.09, h * 0.2, 0, 0, Math.PI * 2); g.fill(); |
| + g.fillStyle = '#7a3b2e'; |
| + g.beginPath(); g.ellipse(w / 2, h * 1.02, w * 0.24, h * 0.42, 0, 0, Math.PI * 2); g.fill(); |
| + g.fillStyle = '#ffffff'; |
| + g.fillRect((t * 7) % w, h - 8, 40, 4); |
| + }); |
| + const screen = () => drawn(1280, 800, (g, w, h, t) => { |
| + g.fillStyle = '#f4f1ea'; |
| + g.fillRect(0, 0, w, h); |
| + g.fillStyle = '#1f6f8b'; |
| + g.fillRect(0, 0, w, 48); |
| + g.fillStyle = '#c0392b'; |
| + g.fillRect(w - 160, h - 120, 120, 80); |
| + g.fillStyle = '#333'; |
| + for (let y = 90; y < h - 40; y += 28) g.fillRect(60, y, w - 120 - ((y * 37) % 300), 10); |
| + g.fillStyle = '#f39c12'; |
| + g.fillRect((t * 9) % w, 56, 30, 30); |
| + }); |
| + const tone = () => { |
| + const ac = new AudioContext(); |
| + const o = ac.createOscillator(); |
| + const gain = ac.createGain(); |
| + gain.gain.value = 0.02; |
| + const out = ac.createMediaStreamDestination(); |
| + o.connect(gain).connect(out); |
| + o.start(); |
| + return out.stream.getAudioTracks()[0]; |
| + }; |
| + md.getUserMedia = async (c = {}) => { |
| + const tracks = []; |
| + if (c.audio) tracks.push(tone()); |
| + if (c.video) tracks.push(camera()); |
| + return new MediaStream(tracks); |
| + }; |
| + md.getDisplayMedia = async () => new MediaStream([screen()]); |
| +}; |
| + |
| function watch(page, label) { |
| const errs = consoleErrors.get(label); |
| const all = consoleLines.get(label); |
| @@ -5023,13 +5092,18 @@ const run = { |
| await pageA.locator('.calls-tile-share').waitFor({state: 'visible'}); |
| check(await until(async () => !await pageA.locator('#tiles').evaluate(el => el.hasAttribute('data-pip')), 2000), |
| 'screen share never gets a self-preview overlay'); |
| - // With the controls hidden: while they show, the filmstrip rises |
| - // clear of the bar and over the stage's lower edge, as the Mac's does. |
| + // With the controls hidden. Since 2026-10-05 the stage fills the |
| + // window and the filmstrip floats over its foot, as FaceTime's does: |
| + // you are a filmstrip tile there, never a floating self view, and the |
| + // filmstrip is in the bottom of the window, over the screen's soft |
| + // surround or its lowest edge (the stage scenario holds the rest). |
| await pageA.evaluate(() => window.callsChrome.pin(false)); |
| await sleep(400); |
| const shareBounds = await pageA.locator('.calls-tile-share').boundingBox(); |
| const selfBounds = await pageA.locator('.calls-tile-self').boundingBox(); |
| - check(selfBounds.y >= shareBounds.y + shareBounds.height - 1, 'self preview is outside shared screen content'); |
| + check(!await pageA.locator('.calls-tile-self').evaluate((f) => f.classList.contains('calls-tile-pip')) && |
| + selfBounds.y + selfBounds.height >= shareBounds.y + shareBounds.height - 24, |
| + 'self preview is a filmstrip tile at the foot of the shared screen, not floating over its content'); |
| await pageA.evaluate(() => window.callsChrome.pin(null)); |
| if (out) await pageA.screenshot({path: out + '/share-320.png', fullPage: true}); |
| await pageB.locator('#share').click(); |
| @@ -5152,6 +5226,159 @@ const run = { |
| await ctxB.close(); |
| }, |
| + // The stage (2026-10-05). Paul: "Should the video view stay fullscreen |
| + // even here? The black line is a bit weird." Speaker view's stage fills |
| + // the whole window, the filmstrip over its bottom, with no band behind |
| + // it and the bar; its caption sits just above the filmstrip, at the |
| + // window's left; a screen on the stage is fitted with its own picture |
| + // soft and dimmed around it (the backdrop canvas, painted, and what a |
| + // paint costs); Grid keeps its tiles inside the window. And "Vertical |
| + // centering here too": every camera-off cover is centred in its whole |
| + // tile, or, on a tile too short for that, only as high as clears the |
| + // caption. Controls shown and hidden throughout. |
| + // CALLS_STAGE_SNAPSHOTS=<dir> keeps pictures and the measurements. |
| + async stage() { |
| + const out = process.env.CALLS_STAGE_SNAPSHOTS; |
| + if (out) mkdirSync(out, { recursive: true }); |
| + const report = []; |
| + const ctxA = await newContext('stage A', { cookie: cookieA }); |
| + const ctxB = await newContext('stage B', { cookie: cookieB }); |
| + const pageA = await openRoom(ctxA); |
| + const pageB = await openRoom(ctxB); |
| + const pair = await assertPair(pageA, pageB, 'stage A', 'stage B'); |
| + if (!pair) throw new Error('stage call did not connect'); |
| + const b = pair.b.peer; |
| + await pageA.emulateMedia({ reducedMotion: 'reduce' }); |
| + await pageA.setViewportSize({ width: 1280, height: 800 }); |
| + await chooseLayout(pageA, 'speaker'); |
| + const geo = () => pageA.evaluate(() => { |
| + const r = (el) => { |
| + if (!el || !el.getClientRects().length) return null; |
| + const x = el.getBoundingClientRect(); |
| + return { top: x.top, bottom: x.bottom, left: x.left, right: x.right, width: x.width, height: x.height }; |
| + }; |
| + const ink = (fig) => { |
| + const off = fig && fig.querySelector('.calls-tile-off'); |
| + if (!off || off.hidden) return null; |
| + // What can be seen: not the words a narrow tile keeps only for |
| + // assistive technology (1 px, clipped). |
| + const parts = Array.from(off.children).filter((c) => getComputedStyle(c).display !== 'none' && c.getClientRects().length).map(r) |
| + .filter((q) => q.width > 1 && q.height > 1); |
| + if (!parts.length) return null; |
| + return { top: Math.min(...parts.map((q) => q.top)), bottom: Math.max(...parts.map((q) => q.bottom)) }; |
| + }; |
| + const stage = document.querySelector('#tiles > [data-stage]'); |
| + const strip = Array.from(document.querySelectorAll('#tiles > figure:not([data-stage], .calls-tile-pip)')).filter((f) => f.getClientRects().length); |
| + const backdrop = stage && stage.querySelector('.calls-tile-backdrop'); |
| + return { |
| + window: r(document.querySelector('.calls-stage')), |
| + stage: r(stage), stageKey: stage ? stage.dataset.tile : '', |
| + caption: r(stage && stage.querySelector('figcaption')), |
| + cover: ink(stage), |
| + strip: strip.map((f) => ({ key: f.dataset.tile, tile: r(f), caption: r(f.querySelector('figcaption')), cover: ink(f) })), |
| + backdrop: backdrop ? { shown: getComputedStyle(backdrop).display !== 'none', painted: +(backdrop.dataset.painted || 0), costMs: +(backdrop.dataset.costMs || 0) } : null, |
| + grid: Array.from(document.querySelectorAll('#tiles[data-layout="grid"] > figure')).map(r), |
| + }; |
| + }); |
| + // Centred in the tile, or risen only as far as clears its caption. |
| + const placed = (tile, cover, caption, what) => { |
| + if (!cover || !caption) return check(false, what + ': has a cover and a caption'); |
| + const mid = (cover.top + cover.bottom) / 2; |
| + const centre = (tile.top + tile.bottom) / 2; |
| + check(cover.bottom <= caption.top + 0.5, what + ': the cover is clear of the caption (' + Math.round(cover.bottom) + ' vs ' + Math.round(caption.top) + ')'); |
| + check(Math.abs(mid - centre) <= 1.5 || cover.bottom >= caption.top - 8, |
| + what + ': the cover is centred in the whole tile (' + mid.toFixed(1) + ' vs ' + centre.toFixed(1) + '), or only as high as clears the caption'); |
| + }; |
| + const both = async (name, fn) => { |
| + for (const shown of [true, false]) { |
| + await pageA.evaluate((v) => window.callsChrome.pin(v), shown); |
| + await sleep(500); |
| + const g = await geo(); |
| + report.push({ name, shown, ...g }); |
| + await fn(g, name + (shown ? ' (controls shown)' : ' (controls hidden)'), shown); |
| + if (out) await pageA.screenshot({ path: out + '/web-' + name + '-' + (shown ? 'controls' : 'hidden') + '.png' }); |
| + } |
| + await pageA.evaluate(() => window.callsChrome.pin(null)); |
| + }; |
| + const fills = (g, what, shown) => { |
| + check(g.stage && g.window && Math.abs(g.stage.top - g.window.top) <= 1 && Math.abs(g.stage.bottom - g.window.bottom) <= 1 && |
| + Math.abs(g.stage.left - g.window.left) <= 1 && Math.abs(g.stage.right - g.window.right) <= 1, |
| + what + ': the stage fills the window, no band (' + JSON.stringify(g.stage) + ' in ' + JSON.stringify(g.window) + ')'); |
| + // With a filmstrip, the caption rides just above it; without one |
| + // (a call of two, you floating), it is at the window's foot, above |
| + // the bar while that shows. |
| + const top = g.strip.length ? Math.min(...g.strip.map((t) => t.tile.top)) : g.window.bottom - (shown ? 72 : 0); |
| + check(g.caption && g.caption.bottom <= top - 4 && g.caption.bottom >= top - 24, |
| + what + ': the stage caption sits just above the ' + (g.strip.length ? 'filmstrip' : 'window\'s foot') + |
| + ' (' + (g.caption && Math.round(g.caption.bottom)) + ' vs ' + Math.round(top) + ')'); |
| + check(g.caption && g.caption.left - g.window.left <= 16, what + ': and at the window\'s left'); |
| + }; |
| + |
| + // A pins herself: her camera on the stage, B in the filmstrip. |
| + await openTileMenu(pageA, 'self'); |
| + await pageA.locator('#tile-menu [data-act="pin"]').click(); |
| + check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === 'self', 3000), |
| + 'A pinned to the stage, B in the filmstrip'); |
| + await both('stage-camera', async (g, what, shown) => { |
| + fills(g, what, shown); |
| + const fit = await pageA.locator('#tiles > [data-stage] video').evaluate((v) => [getComputedStyle(v).objectFit, getComputedStyle(v).objectPosition]); |
| + check(fit[0] === 'cover' && fit[1] === '50% 35%', what + ': the camera fills, cropped with headroom (' + fit.join(' ') + ')'); |
| + }); |
| + |
| + // B's camera off: B's filmstrip tile's cover, centred. |
| + await pageB.locator('#camera').click(); |
| + const tileB = pageA.locator('#tiles > figure[data-tile="' + b + '"]'); |
| + check(await until(async () => tileB.locator('.calls-tile-off').isVisible(), 10000), 'B shows camera off on A'); |
| + for (const width of [1280, 760, 390]) { |
| + await pageA.setViewportSize({ width, height: width < 500 ? 844 : 800 }); |
| + await both('filmstrip-camera-off-' + width, async (g, what) => { |
| + const t = g.strip.find((x) => x.key === b); |
| + placed(t.tile, t.cover, t.caption, what + ' B\'s filmstrip tile'); |
| + }); |
| + } |
| + await pageA.setViewportSize({ width: 1280, height: 800 }); |
| + |
| + // B, camera off, pinned to the stage. |
| + await openTileMenu(pageA, b); |
| + await pageA.locator('#tile-menu [data-act="pin"]').click(); |
| + check(await until(async () => (await pageA.locator('#tiles > [data-stage]').getAttribute('data-tile')) === b, 3000), 'B pinned to the stage'); |
| + await both('stage-camera-off', async (g, what, shown) => { |
| + fills(g, what, shown); |
| + placed(g.stage, g.cover, g.caption, what + ' the stage'); |
| + }); |
| + await pageB.locator('#camera').click(); |
| + |
| + // B shares: the screen takes the stage, fitted, its backdrop painted. |
| + await openTileMenu(pageA, b); |
| + await pageA.locator('#tile-menu [data-act="pin"]').click(); |
| + await pageB.locator('#share').click(); |
| + check(await until(async () => (await pageA.locator('#tiles > [data-stage].calls-tile-share').count()) === 1, 15000), 'B\'s screen is on the stage'); |
| + check(await until(async () => ((await geo()).backdrop || {}).painted > 1, 8000), 'the screen\'s backdrop is painted'); |
| + await both('stage-screen', async (g, what, shown) => { |
| + fills(g, what, shown); |
| + const fit = await pageA.locator('#tiles > [data-stage] video').evaluate((v) => getComputedStyle(v).objectFit); |
| + check(fit === 'contain', what + ': the screen is fitted whole (' + fit + ')'); |
| + check(g.backdrop && g.backdrop.shown, what + ': its soft picture shows around it'); |
| + }); |
| + const cost = ((await geo()).backdrop || { costMs: NaN }).costMs; |
| + report.push({ backdropWorstPaintMs: cost }); |
| + line('stage: the backdrop\'s slowest paint was ' + cost + ' ms, twice a second'); |
| + check(cost < 8, 'a backdrop paint is cheap (' + cost + ' ms)'); |
| + |
| + // Grid: tiles as before, inside the window. |
| + await chooseLayout(pageA, 'grid'); |
| + await both('grid', async (g, what) => { |
| + check(g.grid.length >= 2 && g.grid.every((t) => t.top >= g.window.top - 0.5 && t.bottom <= g.window.bottom + 0.5), |
| + what + ': every tile inside the window'); |
| + }); |
| + await pageB.locator('#share').click(); |
| + if (out) writeFileSync(out + '/web-stage-measurements.json', JSON.stringify(report, null, 2)); |
| + await leaveRoom(pageA); |
| + await leaveRoom(pageB); |
| + await ctxA.close(); |
| + await ctxB.close(); |
| + }, |
| + |
| // The call window (2026-09-28). Paul asked for the web call to be |
| // more like the Mac's: the whole call in a rounded window inset from |
| // the viewport. So: the window is inset with rounded corners on a |