oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import 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
103extension 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.
117private 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
148private struct FixtureBackdropKey: EnvironmentKey {
149 static let defaultValue: FixtureBackdrop? = nil
150}
151
152extension 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
160private struct FixtureStagePictureKey: EnvironmentKey {
161 static let defaultValue: FixtureBackdrop? = nil
162}
163
164extension 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