Calls fixtures: a picture on the stage's tile, and stage snapshots
A tile's placeholder is all an offline snapshot of Speaker view could show on the stage, black on a black window, so the band left under the filmstrip and the bar did not show. fixtureStagePicture (Mac environment; iPhone -CallsFixtureStagePicture busy) draws a fixture frame as the stage tile's video, as a camera fills a tile and a screen is fitted. CallsInterop --stage-snapshots draws Speaker view with a camera, a camera that is off and a shared screen on the stage, controls shown and hidden, at 1000 x 640 and 760 x 540; Grid; and the mini window at 200, 320 and 480 and its tab. Off screen: this Mac's screencapture is refused, so renderLive cannot run here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+123
−3
native/apple/Sources/CallsInterop/Interop.swift+6 −0native/apple/Sources/CallsInterop/StageSnapshots.swift+63 −0native/apple/Sources/CallsKit/CallFixtures.swift+40 −0native/apple/Sources/CallsKit/MacVideoTile.swift+4 −1native/ios/Sources/CallsPhone.swift+4 −1native/ios/Sources/PhoneCallFixture.swift+6 −1
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index 603c858..6abf335 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -52,6 +52,12 @@ struct Input: Decodable { |
| } catch { exit(1) } |
| return |
| } |
| + if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--stage-snapshots" { |
| + do { |
| + try await StageSnapshots.save(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| + } catch { exit(1) } |
| + return |
| + } |
| if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--bar-layout" { |
| exit(await BarLayoutHarness.run(to: URL(fileURLWithPath: CommandLine.arguments[2])) ? 0 : 1) |
| } |
diff --git a/native/apple/Sources/CallsInterop/StageSnapshots.swift b/native/apple/Sources/CallsInterop/StageSnapshots.swift| new file mode 100644 |
| index 0000000..9337f76 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/StageSnapshots.swift |
| @@ -0,0 +1,63 @@ |
| +import AppKit |
| +@_spi(Fixtures) import CallsKit |
| +import SwiftUI |
| + |
| +/// The stage and every tile's placeholder (2026-10-05), drawn off screen |
| +/// (no glass and no Metal video, as the other offline fixtures), with a busy frame as the picture on the stage's |
| +/// tile so the filmstrip, the names and the bar show over a picture: |
| +/// Speaker view with a camera on the stage, with a camera that is off on |
| +/// it, and with a shared screen on it, controls shown and hidden, at the |
| +/// default window and the smallest; Grid; and the mini window at 200, 320 |
| +/// and 480 with someone whose camera is off, and its tab. |
| +@MainActor enum StageSnapshots { |
| + static func save(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, |
| + size: CGSize = CGSize(width: 1000, height: 640), speaker: String? |
| + ) async throws { |
| + let call = Call(playAudio: false) |
| + fixture.apply(to: call) |
| + call.levels.speaker.showFixture(speaker) |
| + try await draw( |
| + MacCallView( |
| + call: call, grid: grid, style: .fullWindow, controlsShown: shown, leave: {}, rejoin: {} |
| + ) |
| + .tint(CallsAppearance.accent) |
| + .environment(\.fixtureStagePicture, .busy), |
| + size: size, to: folder.appendingPathComponent("mac-\(name).png")) |
| + } |
| + for shown in [true, false] { |
| + let state = shown ? "controls" : "hidden" |
| + try await shot("stage-camera-\(state)", shown: shown, speaker: "ana") |
| + try await shot("stage-camera-off-\(state)", shown: shown, speaker: "tom") |
| + try await shot("stage-screen-\(state)", .screen, shown: shown, speaker: "ana") |
| + try await shot( |
| + "stage-camera-760x540-\(state)", shown: shown, size: CGSize(width: 760, height: 540), |
| + speaker: "ana") |
| + try await shot("grid-\(state)", grid: true, shown: shown, speaker: "ana") |
| + } |
| + for width in [MiniWindowRules.minimumWidth, MiniWindowRules.size.width, 480] { |
| + for (who, label) in [("tom", "camera-off"), (nil, "self")] as [(String?, String)] { |
| + let call = Call(playAudio: false) |
| + CallFixture.grid.apply(to: call) |
| + call.levels.speaker.showFixture(who) |
| + if who == nil { call.showFixtureSelf(muted: false, cameraOff: true, sharingScreen: false) } |
| + try await draw( |
| + MiniWindowSnapshot.window(call: call, hovering: false), |
| + size: CGSize(width: width, height: width / MiniWindowRules.aspect), |
| + to: folder.appendingPathComponent("mini-\(Int(width))-\(label).png")) |
| + } |
| + } |
| + let call = Call(playAudio: false) |
| + CallFixture.grid.apply(to: call) |
| + try await draw( |
| + MiniWindowSnapshot.tab(call: call), size: MiniWindowTab.size, |
| + to: folder.appendingPathComponent("mini-tab.png")) |
| + } |
| + |
| + private static func draw(_ content: some View, size: CGSize, to file: URL) async throws { |
| + try await LobbySnapshots.render(content, scheme: .dark, size: size, to: file) |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| index 89f70b0..f409157 100644 |
| --- a/native/apple/Sources/CallsKit/CallFixtures.swift |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -157,6 +157,46 @@ extension EnvironmentValues { |
| } |
| } |
| +private struct FixtureStagePictureKey: EnvironmentKey { |
| + static let defaultValue: FixtureBackdrop? = nil |
| +} |
| + |
| +extension EnvironmentValues { |
| + /// A fixture's frame as the picture on the stage's tile, so snapshots of |
| + /// Speaker view show how a picture sits under the filmstrip and the bar, |
| + /// which a tile's placeholder cannot; nil in a real call. |
| + @_spi(Fixtures) public var fixtureStagePicture: FixtureBackdrop? { |
| + get { self[FixtureStagePictureKey.self] } |
| + set { self[FixtureStagePictureKey.self] = newValue } |
| + } |
| +} |
| + |
| +/// 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. |
| +@_spi(Fixtures) public struct FixtureVideo: View { |
| + let picture: FixtureBackdrop |
| + let screen: Bool |
| + public init(picture: FixtureBackdrop, screen: Bool) { |
| + self.picture = picture |
| + self.screen = screen |
| + } |
| + public var body: some View { |
| + 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) |
| + } |
| + .clipped() |
| + } |
| +} |
| + |
| /// The warm rim on a tile's name at one level, over each kind of picture |
| /// it has to read on (0.4.3): the rim mock-up's own dark and bright tiles, |
| /// for comparing with it, then white and the busy frame. The name is the |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index 5d2bd88..1d3744e 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -43,6 +43,7 @@ |
| /// The height of the name label and what is over it, lift included, |
| /// which the placeholder stays above. |
| @State private var labelBlock: CGFloat = 0 |
| + @Environment(\.fixtureStagePicture) private var fixturePicture |
| var body: some View { |
| TileAudioReader(audio) { info in tile(info) } |
| @@ -51,7 +52,9 @@ |
| private func tile(_ info: TileAudioInfo?) -> some View { |
| ZStack { |
| backdrop |
| - if let track, !cameraOff, placeholder == nil { |
| + if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil { |
| + FixtureVideo(picture: fixturePicture, screen: screen) |
| + } else if let track, !cameraOff, placeholder == nil { |
| MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| TilePlaceholder( |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 926362b..c212fa0 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -425,13 +425,16 @@ struct PhoneVideoTile: View { |
| /// The height of the name label and what is over it, which the |
| /// placeholder stays above. |
| @State private var labelBlock: CGFloat = 0 |
| + @Environment(\.fixtureStagePicture) private var fixturePicture |
| var body: some View { |
| TileAudioReader(audio) { info in tile(info) } |
| } |
| private func tile(_ info: TileAudioInfo?) -> some View { |
| ZStack { |
| backdrop |
| - if let track, !cameraOff, placeholder == nil { |
| + if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil { |
| + FixtureVideo(picture: fixturePicture, screen: screen) |
| + } else if let track, !cameraOff, placeholder == nil { |
| PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| TilePlaceholder( |
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift| index 714f657..f3e4284 100644 |
| --- a/native/ios/Sources/PhoneCallFixture.swift |
| +++ b/native/ios/Sources/PhoneCallFixture.swift |
| @@ -8,7 +8,9 @@ |
| /// optionally `-CallsFixtureControls shown|hidden` to pin the controls, |
| /// `-CallsFixtureMore YES` to open the controls menu, `-CallsFixtureDark |
| /// YES`, `-CallsFixtureClassic YES`, `-CallsFixtureBackdrop black|white|busy` |
| - /// for a frame in place of the stage, `-CallsFixtureSelf |
| + /// for a frame in place of the stage, `-CallsFixtureStagePicture |
| + /// black|white|busy` for a frame as the picture on the stage's tile (the |
| + /// filmstrip and labels over it), `-CallsFixtureSelf |
| /// 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 |
| @@ -45,6 +47,9 @@ |
| .environment( |
| \.fixtureBackdrop, |
| defaults.string(forKey: "CallsFixtureBackdrop").flatMap(FixtureBackdrop.init(rawValue:))) |
| + .environment( |
| + \.fixtureStagePicture, |
| + defaults.string(forKey: "CallsFixtureStagePicture").flatMap(FixtureBackdrop.init(rawValue:))) |
| .environment(\.callOverlayGlass, !defaults.bool(forKey: "CallsFixtureNoGlass")) |
| .preferredColorScheme(defaults.bool(forKey: "CallsFixtureDark") ? .dark : .light) |
| .onAppear { |