| 1 | import Combine |
| 2 | import 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. |
| 205 | public 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. |
| 219 | public 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. |
| 272 | public 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. |
| 305 | public 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. |
| 334 | public 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. |
| 347 | public 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. |
| 371 | public 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. |
| 406 | struct 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. |
| 447 | struct 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. |
| 458 | public 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. |
| 466 | public 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 | |