oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import SwiftUI
3
4// Spec §15.20 on the tiles: every voice measured where it can be — what
5// you send on your own tile, what arrives on everyone else's — shown as
6// the speaking glow behind the name (0.3.7; see SpeakingGlow), and a
7// sentence in its place when the sound has gone flat.
8//
9// The levels live in small observable objects of their own, one per tile,
10// rather than on `Call`: a level that changes a few times a second must
11// not re-render the whole call view each time. Each object publishes only
12// when what it shows changes — a step, a line appearing — not per
13// reading. The glow has an object of its own (GlowLevel), so only the
14// glow redraws as it breathes.
15
16/// Your own microphone, for your tile.
17@MainActor public final class MicAudio: ObservableObject {
18 @Published public private(set) var step = 0
19 @Published public private(set) var muted = false
20 @Published public private(set) var speaking = false
21 /// "Your microphone isn't picking anything up".
22 @Published public private(set) var silent = false
23 /// What the ⓘ says is being sent from.
24 @Published public private(set) var source = ""
25 /// The microphone has more than one input, so the ⓘ points at the
26 /// Input picker.
27 @Published public private(set) var channels = false
28 /// The glow behind your name.
29 public let glow = GlowLevel()
30
31 public init() {}
32
33 func show(step: Int, muted: Bool, speaking: Bool, silent: Bool, source: String, channels: Bool) {
34 if self.step != step { self.step = step }
35 if self.muted != muted { self.muted = muted }
36 if self.speaking != speaking { self.speaking = speaking }
37 if self.silent != silent { self.silent = silent }
38 if self.source != source { self.source = source }
39 if self.channels != channels { self.channels = channels }
40 }
41}
42
43/// One remote person's sound, for their tile.
44@MainActor public final class PeerAudio: ObservableObject {
45 @Published public private(set) var state: PeerAudioState = .meter(0)
46 @Published public private(set) var speaking = false
47 /// Their report about you: blocked, or not hearing you.
48 @Published public private(set) var notices: [String] = []
49 /// "<name> is connecting and can't hear you yet" (spec §15.24), drawn
50 /// with a moving ellipsis; never beside `notices`.
51 @Published public private(set) var connecting: String?
52 /// The glow behind their name.
53 public let glow = GlowLevel()
54
55 public init() {}
56
57 func show(state: PeerAudioState, speaking: Bool, notices: [String], connecting: String? = nil) {
58 if self.state != state { self.state = state }
59 if self.speaking != speaking { self.speaking = speaking }
60 if self.notices != notices { self.notices = notices }
61 if self.connecting != connecting { self.connecting = connecting }
62 }
63}
64
65/// One name's speaking glow, observed alone: only the glow redraws as it
66/// follows a voice. Publishes when the glow's target or lit state moves by
67/// something visible (SpeakingGlow.update says when), a few times a
68/// second at most while someone speaks and not at all in silence; between
69/// those the view works the brightness out for itself.
70@MainActor public final class GlowLevel: ObservableObject {
71 @Published public private(set) var glow = SpeakingGlow()
72 private var rule = SpeakingGlow()
73
74 public init() {}
75
76 func update(level: Float, speaking: Bool, now: Double) {
77 if rule.update(level: level, speaking: speaking, now: now) { glow = rule }
78 }
79
80 /// Takes another glow's state whole: the mini window's tab showing the
81 /// loudest person speaking.
82 func follow(_ other: SpeakingGlow) {
83 rule = other
84 if glow != other { glow = other }
85 }
86
87 func reset() { follow(SpeakingGlow()) }
88
89 /// Holds the glow at a level, for the snapshots.
90 @_spi(Fixtures) public func showFixture(level: Float) { follow(.fixture(level: level)) }
91}
92
93/// Every meter in a call, fed by `Call` (see `Call.watchLevels`).
94@MainActor public final class AudioLevels {
95 public let mic = MicAudio()
96 private var peers: [String: PeerAudio] = [:]
97 private var watches: [String: PeerAudioWatch] = [:]
98 private var micWatch = MicWatch()
99 /// Anyone other than you speaking, for the mini window's tab.
100 public let anyoneSpeaking = SpeakingNow()
101 /// The glow of the loudest of them, for the same tab.
102 public let othersGlow = GlowLevel()
103 /// Who has the floor, for Speaker view's stage (see ActiveSpeaker).
104 public let speaker = SpeakerNow()
105 private var lastLevel: [String: Float] = [:]
106
107 public init() {}
108
109 /// The model a remote person's tile reads. Made on first asking, so a
110 /// tile can be drawn before its first reading.
111 public func peer(_ id: String) -> PeerAudio {
112 if let existing = peers[id] { return existing }
113 let made = PeerAudio()
114 peers[id] = made
115 return made
116 }
117
118 func updateMic(_ sample: MicSample, now: Double, source: String, channels: Bool) {
119 let wasSilent = mic.silent
120 micWatch.update(sample, now: now)
121 mic.show(
122 step: micWatch.step, muted: sample.muted, speaking: micWatch.speaking,
123 silent: micWatch.silent, source: source, channels: channels)
124 mic.glow.update(level: sample.sentPeak, speaking: micWatch.speaking, now: now)
125 if micWatch.silent, !wasSilent { AudioAnnouncer.say(AudioWords.micSilent) }
126 }
127
128 /// Returns the state the rule arrived at.
129 @discardableResult
130 func updatePeer(
131 _ id: String, name: String, sample: PeerAudioSample, notices: [String], connecting: String? = nil,
132 now: Double
133 ) -> PeerAudioState {
134 var watch = watches[id] ?? PeerAudioWatch()
135 let before = watch.state
136 let state = watch.update(sample, now: now)
137 watches[id] = watch
138 lastLevel[id] = sample.level
139 let model = peer(id)
140 var fresh = notices.filter { !model.notices.contains($0) }
141 if let connecting, model.connecting != connecting { fresh.append(connecting) }
142 model.show(state: state, speaking: watch.speaking, notices: notices, connecting: connecting)
143 model.glow.update(level: sample.level, speaking: watch.speaking, now: now)
144 if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) }
145 for line in fresh { AudioAnnouncer.say(line) }
146 return state
147 }
148
149 /// Forgets everyone not in `present`, and says whether anyone left is
150 /// speaking, and how brightly the loudest of them glows.
151 func settle(present: Set<String>, now: Double = ProcessInfo.processInfo.systemUptime) {
152 for id in Array(watches.keys) where !present.contains(id) {
153 watches.removeValue(forKey: id)
154 lastLevel.removeValue(forKey: id)
155 peers[id]?.show(state: .meter(0), speaking: false, notices: [])
156 peers[id]?.glow.reset()
157 }
158 anyoneSpeaking.set(watches.values.contains { $0.speaking })
159 let loudest = watches.keys.compactMap { peers[$0]?.glow.glow }
160 .filter(\.lit).max { $0.target < $1.target }
161 othersGlow.follow(loudest ?? SpeakingGlow())
162 speaker.update(
163 watches.map { ActiveSpeaker.Voice(peer: $0.key, speaking: $0.value.speaking, level: lastLevel[$0.key] ?? 0) },
164 present: present, now: now)
165 }
166
167 /// The people whose sound has gone flat, in a stable order: what this
168 /// app reports as `noSoundFrom`.
169 var noSoundFrom: [String] {
170 watches.filter { $0.value.state == .noSound }.keys.sorted()
171 }
172
173 func reset() {
174 micWatch = MicWatch()
175 mic.show(step: 0, muted: false, speaking: false, silent: false, source: "", channels: false)
176 mic.glow.reset()
177 for id in Array(watches.keys) {
178 peers[id]?.show(state: .meter(0), speaking: false, notices: [])
179 peers[id]?.glow.reset()
180 }
181 watches = [:]
182 lastLevel = [:]
183 anyoneSpeaking.set(false)
184 othersGlow.reset()
185 speaker.reset()
186 }
187}
188
189/// A single flag, observed alone (the mini window's tab).
190@MainActor public final class SpeakingNow: ObservableObject {
191 @Published public private(set) var value = false
192 func set(_ on: Bool) { if value != on { value = on } }
193}
194
195/// Lines a person needs to hear when they appear, not only see.
196@MainActor enum AudioAnnouncer {
197 static func say(_ text: String) {
198 AccessibilityNotification.Announcement(text).post()
199 }
200}
201
202// MARK: - Views
203
204/// What a tile is told about its sound.
205public enum TileAudio {
206 case you(MicAudio)
207 case peer(PeerAudio, name: String)
208
209 /// The glow behind the tile's name.
210 @MainActor public var glow: GlowLevel {
211 switch self {
212 case .you(let mic): mic.glow
213 case .peer(let peer, _): peer.glow
214 }
215 }
216}
217
218/// A tile's sound, read out of its model as plain values.
219public struct TileAudioInfo: Equatable {
220 public enum Status: Equatable {
221 /// Muted: the name label says so, and there is nothing to glow.
222 case none
223 /// Sound is live: the glow behind the name carries it.
224 case live
225 /// "No sound from Bea", in place of the glow.
226 case noSound(String)
227 }
228 public var status: Status
229 public var speaking: Bool
230 /// Lines above the name: their notices about you, or your microphone
231 /// warning.
232 public var notices: [String]
233 /// "<name> is connecting and can't hear you yet" (spec §15.24), with its
234 /// moving ellipsis; nil when not said.
235 public var connecting: String? = nil
236 /// The ⓘ's text, when a line has one.
237 public var detail: String?
238
239 /// What the tile's accessibility label adds, after the name.
240 public var spoken: [String] {
241 var out: [String] = []
242 if case .noSound(let name) = status { out.append(AudioWords.noSound(from: name)) }
243 out.append(contentsOf: notices)
244 if let connecting { out.append(connecting) }
245 return out
246 }
247
248 @MainActor static func you(_ mic: MicAudio) -> TileAudioInfo {
249 TileAudioInfo(
250 status: mic.muted ? .none : .live, speaking: mic.speaking,
251 notices: mic.silent ? [AudioWords.micSilent] : [],
252 detail: mic.silent
253 ? AudioWords.micSilentDetail(source: mic.source, channels: mic.channels) : nil)
254 }
255
256 @MainActor static func peer(_ audio: PeerAudio, name: String) -> TileAudioInfo {
257 let status: Status
258 switch audio.state {
259 case .muted: status = .none
260 case .noSound: status = .noSound(name)
261 case .meter: status = .live
262 }
263 return TileAudioInfo(
264 status: status, speaking: audio.speaking, notices: audio.notices, connecting: audio.connecting,
265 detail: audio.state == .noSound ? AudioWords.noSoundDetail : nil)
266 }
267}
268
269/// Reads a tile's model and hands the tile plain values, so the tile
270/// itself need not observe anything and a tile without sound (a screen)
271/// observes nothing at all.
272public struct TileAudioReader<Content: View>: View {
273 let audio: TileAudio?
274 let content: (TileAudioInfo?) -> Content
275 public init(_ audio: TileAudio?, @ViewBuilder content: @escaping (TileAudioInfo?) -> Content) {
276 self.audio = audio
277 self.content = content
278 }
279 public var body: some View {
280 switch audio {
281 case .you(let mic)?: MicReader(mic: mic, content: content)
282 case .peer(let peer, let name)?: PeerReader(peer: peer, name: name, content: content)
283 case nil: content(nil)
284 }
285 }
286 private struct MicReader: View {
287 @ObservedObject var mic: MicAudio
288 let content: (TileAudioInfo?) -> Content
289 var body: some View { content(TileAudioInfo.you(mic)) }
290 }
291 private struct PeerReader: View {
292 @ObservedObject var peer: PeerAudio
293 let name: String
294 let content: (TileAudioInfo?) -> Content
295 var body: some View { content(TileAudioInfo.peer(peer, name: name)) }
296 }
297}
298
299/// A small level meter: bars that light from the left, in the Calls teal.
300/// Steps, not a continuous level, so it never animates frame by frame,
301/// and with Reduce Motion it does not animate at all. The device panel's
302/// "Microphone level" row, for setting up; the tiles have the glow.
303/// Hidden from assistive technology: the words beside it carry anything
304/// worth saying.
305public struct AudioLevelBars: View {
306 let step: Int
307 var height: CGFloat = 12
308 @Environment(\.accessibilityReduceMotion) private var reduceMotion
309 @Environment(\.colorScheme) private var scheme
310 public init(step: Int, height: CGFloat = 12) {
311 self.step = step
312 self.height = height
313 }
314 public var body: some View {
315 // The accent lit up on a dark panel; the deeper accent on a light
316 // sheet, where the lit one would be too pale to tell from the unlit.
317 let lit = scheme == .dark ? CallsAppearance.accentLit : CallsAppearance.accent
318 HStack(alignment: .bottom, spacing: 2) {
319 ForEach(0..<MeterScale.steps, id: \.self) { bar in
320 Capsule()
321 .fill(bar < step ? lit : Color.secondary.opacity(0.35))
322 .frame(width: 3, height: height * CGFloat(bar + 2) / CGFloat(MeterScale.steps + 1))
323 }
324 }
325 .frame(height: height, alignment: .bottom)
326 .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: step)
327 .accessibilityHidden(true)
328 }
329}
330
331/// What a tile says beside its name about its sound: "No sound from Bea"
332/// with its ⓘ, or nothing — live sound is the glow behind the name, and
333/// muted is the name label's own mark.
334public struct TileAudioStatus: View {
335 let info: TileAudioInfo?
336 public init(_ info: TileAudioInfo?) { self.info = info }
337 public var body: some View {
338 if case .noSound(let name)? = info?.status {
339 AudioLine(
340 text: AudioWords.noSound(from: name), systemImage: "speaker.slash.fill",
341 detail: info?.detail)
342 }
343 }
344}
345
346/// The lines above a tile's name.
347public struct TileAudioNotices: View {
348 let info: TileAudioInfo?
349 public init(_ info: TileAudioInfo?) { self.info = info }
350 public var body: some View {
351 if let info, !info.notices.isEmpty || info.connecting != nil {
352 VStack(alignment: .leading, spacing: 4) {
353 ForEach(info.notices, id: \.self) { line in
354 AudioLine(
355 text: line, systemImage: "exclamationmark.triangle.fill",
356 detail: line == AudioWords.micSilent ? info.detail : nil)
357 }
358 if let connecting = info.connecting {
359 AudioLine(text: connecting, systemImage: "ear", ellipsis: true)
360 .accessibilityIdentifier("connectingNotice")
361 }
362 }
363 }
364 }
365}
366
367/// "<name> is connecting and can't hear you yet" and three dots after it
368/// (spec §15.24) that brighten in turn — waiting, not wrong — or one steady
369/// ellipsis under Reduce Motion. One run of text, so a long name wraps the
370/// dots with the words. VoiceOver hears the sentence, never the dots.
371public struct ConnectingText: View {
372 let words: String
373 @Environment(\.accessibilityReduceMotion) private var reduceMotion
374 public init(_ words: String) { self.words = words }
375 public var body: some View {
376 Group {
377 if reduceMotion {
378 Text(words + "…")
379 } else {
380 TimelineView(.periodic(from: .now, by: 0.1)) { context in
381 let t = context.date.timeIntervalSinceReferenceDate
382 (0..<3).reduce(Text(words)) { line, dot in
383 line + Text(".").foregroundColor(.white.opacity(Self.opacity(at: t, dot: dot)))
384 }
385 }
386 }
387 }
388 .accessibilityLabel(words)
389 }
390
391 /// Each dot's brightness at `t`: dim, lit at its turn in a 1.4 s cycle,
392 /// a fifth of a second after the one before — the browser's
393 /// `calls-ellipsis` keyframes.
394 static func opacity(at t: Double, dot: Int) -> Double {
395 let cycle = 1.4
396 let phase = (t - Double(dot) * 0.2).truncatingRemainder(dividingBy: cycle)
397 let p = (phase < 0 ? phase + cycle : phase) / cycle
398 let lift = p <= 0.4 ? p / 0.4 : p <= 0.8 ? (0.8 - p) / 0.4 : 0
399 return 0.35 + 0.65 * lift
400 }
401}
402
403/// One line over the picture: light glyph and text on the dark overlay
404/// surface, legible over any video, with an optional ⓘ that opens the
405/// explanation.
406struct AudioLine: View {
407 let text: String
408 let systemImage: String
409 var detail: String?
410 /// The connecting notice's moving dots after the words (spec §15.24).
411 var ellipsis = false
412 @State private var showing = false
413 var body: some View {
414 HStack(spacing: 5) {
415 Image(systemName: systemImage)
416 Group {
417 if ellipsis { ConnectingText(text) } else { Text(text) }
418 }
419 .lineLimit(2).multilineTextAlignment(.leading)
420 .fixedSize(horizontal: false, vertical: true)
421 if let detail {
422 Button {
423 showing.toggle()
424 } label: {
425 Image(systemName: "info.circle")
426 }
427 .buttonStyle(.plain)
428 .help(detail)
429 .accessibilityLabel("About this")
430 .popover(isPresented: $showing) {
431 Text(detail).font(.callout).padding(14)
432 .frame(idealWidth: 280, maxWidth: 320)
433 .fixedSize(horizontal: false, vertical: true)
434 #if os(iOS)
435 .presentationCompactAdaptation(.popover)
436 #endif
437 }
438 }
439 }
440 .font(.caption.weight(.semibold)).foregroundStyle(.white)
441 .padding(.horizontal, 8).padding(.vertical, 4)
442 .callOverlaySurface(RoundedRectangle(cornerRadius: 8, style: .continuous))
443 }
444}
445
446/// Every thirtieth of a second from now until `until`, then nothing.
447struct GlowSchedule: TimelineSchedule {
448 let until: Date
449 func entries(from start: Date, mode: TimelineScheduleMode) -> [Date] {
450 guard until > start else { return [start] }
451 let every = mode == .lowFrequency ? 0.25 : 1.0 / 30
452 let count = min(Int(until.timeIntervalSince(start) / every) + 2, 300)
453 return (0..<count).map { start.addingTimeInterval(Double($0) * every) }
454 }
455}
456
457/// A tile's accessibility label with its sound said after the name.
458public func tileAccessibilityLabel(_ label: String, muted: Bool, audio: TileAudioInfo?) -> String {
459 ([tileAccessibilityLabel(label, muted: muted)] + (audio?.spoken ?? [])).joined(separator: ", ")
460}
461
462/// A waveform while someone is speaking, and nothing otherwise: the mini
463/// window's tab, with the same warm rim round it as a name has. Hidden from
464/// assistive technology, which would hear it flicker on and off with
465/// every sentence.
466public struct SpeakingGlyph: View {
467 @ObservedObject var speaking: SpeakingNow
468 let glow: GlowLevel?
469 public init(speaking: SpeakingNow, glow: GlowLevel? = nil) {
470 self.speaking = speaking
471 self.glow = glow
472 }
473 public var body: some View {
474 Image(systemName: "waveform")
475 .font(.caption.bold()).foregroundStyle(.white)
476 .frame(width: 22, height: 22)
477 .speakingRim(glow, in: Circle())
478 .opacity(speaking.value ? 1 : 0)
479 .help("Someone is speaking")
480 .accessibilityHidden(true)
481 }
482}
483