| 1 | import Foundation |
| 2 | import SwiftUI |
| 3 | |
| 4 | /// Described calls for the offline snapshots of the call views (Mac, from |
| 5 | /// CallsInterop) and the iPhone's UI test of its controls (a debug build |
| 6 | /// launched with `-CallsCallFixture <name>`). No server, no media: the |
| 7 | /// tiles show their placeholders, which is what an off-screen capture |
| 8 | /// would show of video anyway. |
| 9 | @_spi(Fixtures) public enum CallFixture: String, CaseIterable, Sendable { |
| 10 | /// One other person, muted, on the stage. |
| 11 | case focus |
| 12 | /// Four people in the call. |
| 13 | case grid |
| 14 | /// Somebody sharing a screen, with two cameras beside it, and you |
| 15 | /// sharing too. |
| 16 | case screen |
| 17 | /// Somebody waiting to be let in, and the connection coming back. |
| 18 | case notices |
| 19 | /// Two people who have just joined (spec §15.24): one still connecting |
| 20 | /// and unable to hear you yet, the other's browser blocking sound. |
| 21 | case connecting |
| 22 | /// The focus call in a meeting someone named: its title is shown, |
| 23 | /// centred where a window's title would be (CallTitle). |
| 24 | case named |
| 25 | /// Nobody else here yet: "Waiting for others", and you in the corner |
| 26 | /// (Paul's 0.4.6 screenshot, 2026-10-03). |
| 27 | case alone |
| 28 | |
| 29 | public static let title = "Call, 24 Sep 10:21" |
| 30 | public static let namedTitle = "Board meeting" |
| 31 | |
| 32 | @MainActor public func apply(to call: Call) { |
| 33 | let me = #"{"peer":"me","name":"Paul Campbell","kind":"member"}"# |
| 34 | let ana = #"{"peer":"ana","name":"Ana García","kind":"member","media":{"muted":true,"cameraOff":false}}"# |
| 35 | let tom = #"{"peer":"tom","name":"Tom Byrne","kind":"guest","media":{"muted":false,"cameraOff":true}}"# |
| 36 | let li = #"{"peer":"li","name":"Li Wei","kind":"member"}"# |
| 37 | func roster(_ phase: String, _ peers: [String], waiting: [String] = []) -> String { |
| 38 | #"{"phase":"\#(phase)","peer":"me","boot":"fixture","peers":[\#(peers.joined(separator: ","))],"waiting":[\#(waiting.joined(separator: ","))]}"# |
| 39 | } |
| 40 | switch self { |
| 41 | case .focus: |
| 42 | call.showFixture(title: Self.title, state: roster("admitted", [me, ana])) |
| 43 | case .grid: |
| 44 | call.showFixture(title: Self.title, state: roster("admitted", [me, ana, tom, li])) |
| 45 | case .screen: |
| 46 | call.showFixture( |
| 47 | title: Self.title, state: roster("admitted", [me, ana, tom, li]), screens: ["li"], |
| 48 | sharingScreen: true) |
| 49 | case .notices: |
| 50 | let knock = #"{"peer":"sam","name":"Sam Kelly","kind":"guest"}"# |
| 51 | call.showFixture( |
| 52 | title: Self.title, status: "Reconnecting…", |
| 53 | state: roster("reconnecting", [me, ana], waiting: [knock]), muted: true, floor: true) |
| 54 | case .connecting: |
| 55 | let sam = #"{"peer":"sam","name":"Samuel Okafor","kind":"member","media":{"muted":false,"cameraOff":false,"hearing":[]}}"# |
| 56 | let bea = #"{"peer":"bea","name":"Bea Ruiz","kind":"guest","media":{"muted":false,"cameraOff":true,"audioBlocked":true,"hearing":[]}}"# |
| 57 | call.showFixture(title: Self.title, state: roster("admitted", [me, ana, sam, bea])) |
| 58 | call.showFixtureConnecting(["sam", "bea"]) |
| 59 | case .named: |
| 60 | call.showFixture(title: Self.namedTitle, state: roster("admitted", [me, ana]), named: true) |
| 61 | case .alone: |
| 62 | call.showFixture(title: Self.title, state: roster("admitted", [me])) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// How loudly everyone is speaking, for the snapshots of the glow behind |
| 68 | /// the names: silent, and low, middling and loud speech. |
| 69 | @_spi(Fixtures) public enum FixtureSpeaking: String, CaseIterable, Sendable { |
| 70 | case silent, low, mid, high |
| 71 | |
| 72 | public var decibels: Float { |
| 73 | switch self { |
| 74 | case .silent: -60 |
| 75 | case .low: -29 |
| 76 | case .mid: -21 |
| 77 | case .high: -12 |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | @MainActor public func apply(to call: Call) { |
| 82 | call.showFixtureSpeaking(level: pow(10, decibels / 20)) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /// What the snapshots put behind the call's overlay in place of video, to |
| 87 | /// show that the controls read over anything: black, white, and a busy |
| 88 | /// frame with light and dark and colour and fine detail in it. |
| 89 | @_spi(Fixtures) public enum FixtureBackdrop: String, CaseIterable, Sendable { |
| 90 | case black, white, busy |
| 91 | } |
| 92 | |
| 93 | /// Your own state, for the snapshots of the controls in each. |
| 94 | @_spi(Fixtures) public enum FixtureSelf: String, CaseIterable, Sendable { |
| 95 | case normal, muted, cameraOff, sharing |
| 96 | |
| 97 | @MainActor public func apply(to call: Call) { |
| 98 | call.showFixtureSelf( |
| 99 | muted: self == .muted, cameraOff: self == .cameraOff, sharingScreen: self == .sharing) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | extension FixtureBackdrop { |
| 104 | /// The frame itself, filling whatever it is given. |
| 105 | @_spi(Fixtures) @MainActor @ViewBuilder public var view: some View { |
| 106 | switch self { |
| 107 | case .black: Color.black |
| 108 | case .white: Color.white |
| 109 | case .busy: BusyFrame() |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | /// A stand-in for a busy picture: a bright window and a dark room, a |
| 115 | /// saturated poster, and stripes as fine as a patterned shirt, across the |
| 116 | /// whole frame so the bar and the pills each land on some of everything. |
| 117 | private struct BusyFrame: View { |
| 118 | var body: some View { |
| 119 | GeometryReader { geometry in |
| 120 | let size = geometry.size |
| 121 | ZStack { |
| 122 | LinearGradient( |
| 123 | colors: [Color(white: 0.95), Color(red: 0.55, green: 0.42, blue: 0.3), Color(white: 0.08)], |
| 124 | startPoint: .topLeading, endPoint: .bottomTrailing) |
| 125 | Rectangle().fill(Color(red: 0.98, green: 0.85, blue: 0.2)) |
| 126 | .frame(width: size.width * 0.28, height: size.height * 0.5) |
| 127 | .position(x: size.width * 0.2, y: size.height * 0.62) |
| 128 | Rectangle().fill(Color(red: 0.1, green: 0.35, blue: 0.85)) |
| 129 | .frame(width: size.width * 0.22, height: size.height * 0.7) |
| 130 | .position(x: size.width * 0.72, y: size.height * 0.55) |
| 131 | HStack(spacing: 6) { |
| 132 | ForEach(0..<60, id: \.self) { index in |
| 133 | Rectangle().fill(index.isMultiple(of: 2) ? Color.white : Color.black) |
| 134 | .frame(width: 6) |
| 135 | } |
| 136 | } |
| 137 | .frame(width: size.width * 0.45, height: size.height * 0.35) |
| 138 | .position(x: size.width * 0.45, y: size.height * 0.88) |
| 139 | Circle().fill(Color(red: 0.9, green: 0.2, blue: 0.3)) |
| 140 | .frame(width: size.height * 0.3) |
| 141 | .position(x: size.width * 0.9, y: size.height * 0.12) |
| 142 | } |
| 143 | .clipped() |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | private struct FixtureBackdropKey: EnvironmentKey { |
| 149 | static let defaultValue: FixtureBackdrop? = nil |
| 150 | } |
| 151 | |
| 152 | extension EnvironmentValues { |
| 153 | /// A fixture's frame in place of the call's stage; nil in a real call. |
| 154 | @_spi(Fixtures) public var fixtureBackdrop: FixtureBackdrop? { |
| 155 | get { self[FixtureBackdropKey.self] } |
| 156 | set { self[FixtureBackdropKey.self] = newValue } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | private struct FixtureStagePictureKey: EnvironmentKey { |
| 161 | static let defaultValue: FixtureBackdrop? = nil |
| 162 | } |
| 163 | |
| 164 | extension EnvironmentValues { |
| 165 | /// A fixture's frame as the picture on the stage's tile, so snapshots of |
| 166 | /// Speaker view show how a picture sits under the filmstrip and the bar, |
| 167 | /// which a tile's placeholder cannot; nil in a real call. |
| 168 | @_spi(Fixtures) public var fixtureStagePicture: FixtureBackdrop? { |
| 169 | get { self[FixtureStagePictureKey.self] } |
| 170 | set { self[FixtureStagePictureKey.self] = newValue } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | /// A fixture's frame drawn as a tile draws video: placed by `VideoFrame` |
| 175 | /// (a camera's 16:9 picture filling the tile with headroom, a screen's |
| 176 | /// 16:10 fitted whole) and, around a screen, the same frame soft and |
| 177 | /// dimmed as `ScreenBackdrop` draws it. |
| 178 | @_spi(Fixtures) public struct FixtureVideo: View { |
| 179 | let picture: FixtureBackdrop |
| 180 | let screen: Bool |
| 181 | public init(picture: FixtureBackdrop, screen: Bool) { |
| 182 | self.picture = picture |
| 183 | self.screen = screen |
| 184 | } |
| 185 | public var body: some View { |
| 186 | GeometryReader { geometry in |
| 187 | let bounds = geometry.size |
| 188 | let video = screen ? CGSize(width: 16, height: 10) : CGSize(width: 16, height: 9) |
| 189 | let placed = VideoFrame.place(video: video, in: bounds, fit: screen) |
| 190 | ZStack(alignment: .topLeading) { |
| 191 | if screen { |
| 192 | CallsAppearance.stageSurface |
| 193 | picture.view |
| 194 | .frame(width: bounds.width, height: bounds.height) |
| 195 | .scaleEffect(1.15) |
| 196 | .blur(radius: 24) |
| 197 | .opacity(BackdropSampler.dimmed) |
| 198 | } |
| 199 | picture.view |
| 200 | .frame(width: placed.width, height: placed.height) |
| 201 | .offset(x: placed.minX, y: placed.minY) |
| 202 | } |
| 203 | } |
| 204 | .clipped() |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | /// The warm rim on a tile's name at one level, over each kind of picture |
| 209 | /// it has to read on (0.4.3): the rim mock-up's own dark and bright tiles, |
| 210 | /// for comparing with it, then white and the busy frame. The name is the |
| 211 | /// tiles' own `TileSpeakingName`, full size and compact. |
| 212 | @_spi(Fixtures) public struct WarmRimGallery: View { |
| 213 | let level: FixtureSpeaking |
| 214 | @State private var glow = GlowLevel() |
| 215 | public init(level: FixtureSpeaking) { self.level = level } |
| 216 | |
| 217 | public enum Picture: String, CaseIterable, Sendable { case mockDark, mockBright, white, busy } |
| 218 | |
| 219 | public var body: some View { |
| 220 | VStack(spacing: 10) { |
| 221 | ForEach(Picture.allCases, id: \.self) { picture in |
| 222 | ZStack(alignment: .bottomLeading) { |
| 223 | Self.view(picture) |
| 224 | HStack(spacing: 12) { |
| 225 | TileSpeakingName(label: "Vicky Walsh", muted: false, compact: false, glow: glow) |
| 226 | TileSpeakingName(label: "Tony Keane", muted: false, compact: true, glow: glow) |
| 227 | } |
| 228 | .padding(14) |
| 229 | } |
| 230 | .frame(width: 360, height: 150) |
| 231 | .clipShape(RoundedRectangle(cornerRadius: 16)) |
| 232 | } |
| 233 | } |
| 234 | .padding(10) |
| 235 | .onAppear { glow.showFixture(level: pow(10, level.decibels / 20)) } |
| 236 | } |
| 237 | |
| 238 | @MainActor @ViewBuilder static func view(_ picture: Picture) -> some View { |
| 239 | switch picture { |
| 240 | case .mockDark: |
| 241 | // speaking-indicator-options/index.html's .tile, as near as SwiftUI |
| 242 | // draws its gradients. |
| 243 | ZStack { |
| 244 | LinearGradient( |
| 245 | stops: [ |
| 246 | .init(color: Color(red: 0x3b / 255, green: 0x36 / 255, blue: 0x32 / 255), location: 0), |
| 247 | .init(color: Color(red: 0x1d / 255, green: 0x1b / 255, blue: 0x1a / 255), location: 0.6), |
| 248 | .init(color: Color(red: 0x12 / 255, green: 0x12 / 255, blue: 0x12 / 255), location: 1), |
| 249 | ], startPoint: .topLeading, endPoint: .bottomTrailing) |
| 250 | EllipticalGradient( |
| 251 | colors: [Color(red: 1, green: 214 / 255, blue: 170 / 255).opacity(0.35), .clear], |
| 252 | center: UnitPoint(x: 0.55, y: 0.4), startRadiusFraction: 0, endRadiusFraction: 0.5) |
| 253 | EllipticalGradient( |
| 254 | colors: [Color(red: 90 / 255, green: 110 / 255, blue: 140 / 255).opacity(0.45), .clear], |
| 255 | center: UnitPoint(x: 0.3, y: 0.8), startRadiusFraction: 0, endRadiusFraction: 0.35) |
| 256 | } |
| 257 | case .mockBright: |
| 258 | ZStack { |
| 259 | LinearGradient( |
| 260 | stops: [ |
| 261 | .init(color: Color(red: 0xe9 / 255, green: 0xe4 / 255, blue: 0xdc / 255), location: 0), |
| 262 | .init(color: Color(red: 0xc9 / 255, green: 0xc2 / 255, blue: 0xb7 / 255), location: 0.6), |
| 263 | .init(color: Color(red: 0xa9 / 255, green: 0xa1 / 255, blue: 0x96 / 255), location: 1), |
| 264 | ], startPoint: .topLeading, endPoint: .bottomTrailing) |
| 265 | EllipticalGradient( |
| 266 | colors: [Color.white.opacity(0.9), .clear], |
| 267 | center: UnitPoint(x: 0.55, y: 0.4), startRadiusFraction: 0, endRadiusFraction: 0.5) |
| 268 | } |
| 269 | case .white: FixtureBackdrop.white.view |
| 270 | case .busy: FixtureBackdrop.busy.view |
| 271 | } |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | #if os(macOS) |
| 276 | /// Where the full-window call's control bar last laid out, in its |
| 277 | /// window's coordinates (top-left origin), for the bar layout gate |
| 278 | /// (`CallsInterop --bar-layout`): the glass pane, and the row of |
| 279 | /// buttons on it, which overflow the pane if it is too narrow for them. |
| 280 | @_spi(Fixtures) @MainActor public enum CallBarProbe { |
| 281 | public static var pane: CGRect = .zero |
| 282 | /// The first button on the bar and the last (Leave). |
| 283 | public static var first: CGRect = .zero |
| 284 | public static var last: CGRect = .zero |
| 285 | public static var row: CGRect { first.union(last) } |
| 286 | } |
| 287 | |
| 288 | /// Records a bar button's frame for CallBarProbe; nothing else reads it. |
| 289 | struct CallBarEndProbe: ViewModifier { |
| 290 | let last: Bool |
| 291 | func body(content: Content) -> some View { |
| 292 | content.background( |
| 293 | GeometryReader { button in |
| 294 | Color.clear |
| 295 | .onAppear { record(button.frame(in: .global)) } |
| 296 | .onChange(of: button.frame(in: .global)) { _, now in record(now) } |
| 297 | }) |
| 298 | } |
| 299 | @MainActor private func record(_ frame: CGRect) { |
| 300 | if last { CallBarProbe.last = frame } else { CallBarProbe.first = frame } |
| 301 | } |
| 302 | } |
| 303 | #endif |
| 304 | |