import Combine import SwiftUI // Spec §15.20 on the tiles: every voice measured where it can be — what // you send on your own tile, what arrives on everyone else's — shown as // the speaking glow behind the name (0.3.7; see SpeakingGlow), and a // sentence in its place when the sound has gone flat. // // The levels live in small observable objects of their own, one per tile, // rather than on `Call`: a level that changes a few times a second must // not re-render the whole call view each time. Each object publishes only // when what it shows changes — a step, a line appearing — not per // reading. The glow has an object of its own (GlowLevel), so only the // glow redraws as it breathes. /// Your own microphone, for your tile. @MainActor public final class MicAudio: ObservableObject { @Published public private(set) var step = 0 @Published public private(set) var muted = false @Published public private(set) var speaking = false /// "Your microphone isn't picking anything up". @Published public private(set) var silent = false /// What the ⓘ says is being sent from. @Published public private(set) var source = "" /// The microphone has more than one input, so the ⓘ points at the /// Input picker. @Published public private(set) var channels = false /// The glow behind your name. public let glow = GlowLevel() public init() {} func show(step: Int, muted: Bool, speaking: Bool, silent: Bool, source: String, channels: Bool) { if self.step != step { self.step = step } if self.muted != muted { self.muted = muted } if self.speaking != speaking { self.speaking = speaking } if self.silent != silent { self.silent = silent } if self.source != source { self.source = source } if self.channels != channels { self.channels = channels } } } /// One remote person's sound, for their tile. @MainActor public final class PeerAudio: ObservableObject { @Published public private(set) var state: PeerAudioState = .meter(0) @Published public private(set) var speaking = false /// Their report about you: blocked, or not hearing you. @Published public private(set) var notices: [String] = [] /// " is connecting and can't hear you yet" (spec §15.24), drawn /// with a moving ellipsis; never beside `notices`. @Published public private(set) var connecting: String? /// The glow behind their name. public let glow = GlowLevel() public init() {} func show(state: PeerAudioState, speaking: Bool, notices: [String], connecting: String? = nil) { if self.state != state { self.state = state } if self.speaking != speaking { self.speaking = speaking } if self.notices != notices { self.notices = notices } if self.connecting != connecting { self.connecting = connecting } } } /// One name's speaking glow, observed alone: only the glow redraws as it /// follows a voice. Publishes when the glow's target or lit state moves by /// something visible (SpeakingGlow.update says when), a few times a /// second at most while someone speaks and not at all in silence; between /// those the view works the brightness out for itself. @MainActor public final class GlowLevel: ObservableObject { @Published public private(set) var glow = SpeakingGlow() private var rule = SpeakingGlow() public init() {} func update(level: Float, speaking: Bool, now: Double) { if rule.update(level: level, speaking: speaking, now: now) { glow = rule } } /// Takes another glow's state whole: the mini window's tab showing the /// loudest person speaking. func follow(_ other: SpeakingGlow) { rule = other if glow != other { glow = other } } func reset() { follow(SpeakingGlow()) } /// Holds the glow at a level, for the snapshots. @_spi(Fixtures) public func showFixture(level: Float) { follow(.fixture(level: level)) } } /// Every meter in a call, fed by `Call` (see `Call.watchLevels`). @MainActor public final class AudioLevels { public let mic = MicAudio() private var peers: [String: PeerAudio] = [:] private var watches: [String: PeerAudioWatch] = [:] private var micWatch = MicWatch() /// Anyone other than you speaking, for the mini window's tab. public let anyoneSpeaking = SpeakingNow() /// The glow of the loudest of them, for the same tab. public let othersGlow = GlowLevel() /// Who has the floor, for Speaker view's stage (see ActiveSpeaker). public let speaker = SpeakerNow() private var lastLevel: [String: Float] = [:] public init() {} /// The model a remote person's tile reads. Made on first asking, so a /// tile can be drawn before its first reading. public func peer(_ id: String) -> PeerAudio { if let existing = peers[id] { return existing } let made = PeerAudio() peers[id] = made return made } func updateMic(_ sample: MicSample, now: Double, source: String, channels: Bool) { let wasSilent = mic.silent micWatch.update(sample, now: now) mic.show( step: micWatch.step, muted: sample.muted, speaking: micWatch.speaking, silent: micWatch.silent, source: source, channels: channels) mic.glow.update(level: sample.sentPeak, speaking: micWatch.speaking, now: now) if micWatch.silent, !wasSilent { AudioAnnouncer.say(AudioWords.micSilent) } } /// Returns the state the rule arrived at. @discardableResult func updatePeer( _ id: String, name: String, sample: PeerAudioSample, notices: [String], connecting: String? = nil, now: Double ) -> PeerAudioState { var watch = watches[id] ?? PeerAudioWatch() let before = watch.state let state = watch.update(sample, now: now) watches[id] = watch lastLevel[id] = sample.level let model = peer(id) var fresh = notices.filter { !model.notices.contains($0) } if let connecting, model.connecting != connecting { fresh.append(connecting) } model.show(state: state, speaking: watch.speaking, notices: notices, connecting: connecting) model.glow.update(level: sample.level, speaking: watch.speaking, now: now) if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) } for line in fresh { AudioAnnouncer.say(line) } return state } /// Forgets everyone not in `present`, and says whether anyone left is /// speaking, and how brightly the loudest of them glows. func settle(present: Set, now: Double = ProcessInfo.processInfo.systemUptime) { for id in Array(watches.keys) where !present.contains(id) { watches.removeValue(forKey: id) lastLevel.removeValue(forKey: id) peers[id]?.show(state: .meter(0), speaking: false, notices: []) peers[id]?.glow.reset() } anyoneSpeaking.set(watches.values.contains { $0.speaking }) let loudest = watches.keys.compactMap { peers[$0]?.glow.glow } .filter(\.lit).max { $0.target < $1.target } othersGlow.follow(loudest ?? SpeakingGlow()) speaker.update( watches.map { ActiveSpeaker.Voice(peer: $0.key, speaking: $0.value.speaking, level: lastLevel[$0.key] ?? 0) }, present: present, now: now) } /// The people whose sound has gone flat, in a stable order: what this /// app reports as `noSoundFrom`. var noSoundFrom: [String] { watches.filter { $0.value.state == .noSound }.keys.sorted() } func reset() { micWatch = MicWatch() mic.show(step: 0, muted: false, speaking: false, silent: false, source: "", channels: false) mic.glow.reset() for id in Array(watches.keys) { peers[id]?.show(state: .meter(0), speaking: false, notices: []) peers[id]?.glow.reset() } watches = [:] lastLevel = [:] anyoneSpeaking.set(false) othersGlow.reset() speaker.reset() } } /// A single flag, observed alone (the mini window's tab). @MainActor public final class SpeakingNow: ObservableObject { @Published public private(set) var value = false func set(_ on: Bool) { if value != on { value = on } } } /// Lines a person needs to hear when they appear, not only see. @MainActor enum AudioAnnouncer { static func say(_ text: String) { AccessibilityNotification.Announcement(text).post() } } // MARK: - Views /// What a tile is told about its sound. public enum TileAudio { case you(MicAudio) case peer(PeerAudio, name: String) /// The glow behind the tile's name. @MainActor public var glow: GlowLevel { switch self { case .you(let mic): mic.glow case .peer(let peer, _): peer.glow } } } /// A tile's sound, read out of its model as plain values. public struct TileAudioInfo: Equatable { public enum Status: Equatable { /// Muted: the name label says so, and there is nothing to glow. case none /// Sound is live: the glow behind the name carries it. case live /// "No sound from Bea", in place of the glow. case noSound(String) } public var status: Status public var speaking: Bool /// Lines above the name: their notices about you, or your microphone /// warning. public var notices: [String] /// " is connecting and can't hear you yet" (spec §15.24), with its /// moving ellipsis; nil when not said. public var connecting: String? = nil /// The ⓘ's text, when a line has one. public var detail: String? /// What the tile's accessibility label adds, after the name. public var spoken: [String] { var out: [String] = [] if case .noSound(let name) = status { out.append(AudioWords.noSound(from: name)) } out.append(contentsOf: notices) if let connecting { out.append(connecting) } return out } @MainActor static func you(_ mic: MicAudio) -> TileAudioInfo { TileAudioInfo( status: mic.muted ? .none : .live, speaking: mic.speaking, notices: mic.silent ? [AudioWords.micSilent] : [], detail: mic.silent ? AudioWords.micSilentDetail(source: mic.source, channels: mic.channels) : nil) } @MainActor static func peer(_ audio: PeerAudio, name: String) -> TileAudioInfo { let status: Status switch audio.state { case .muted: status = .none case .noSound: status = .noSound(name) case .meter: status = .live } return TileAudioInfo( status: status, speaking: audio.speaking, notices: audio.notices, connecting: audio.connecting, detail: audio.state == .noSound ? AudioWords.noSoundDetail : nil) } } /// Reads a tile's model and hands the tile plain values, so the tile /// itself need not observe anything and a tile without sound (a screen) /// observes nothing at all. public struct TileAudioReader: View { let audio: TileAudio? let content: (TileAudioInfo?) -> Content public init(_ audio: TileAudio?, @ViewBuilder content: @escaping (TileAudioInfo?) -> Content) { self.audio = audio self.content = content } public var body: some View { switch audio { case .you(let mic)?: MicReader(mic: mic, content: content) case .peer(let peer, let name)?: PeerReader(peer: peer, name: name, content: content) case nil: content(nil) } } private struct MicReader: View { @ObservedObject var mic: MicAudio let content: (TileAudioInfo?) -> Content var body: some View { content(TileAudioInfo.you(mic)) } } private struct PeerReader: View { @ObservedObject var peer: PeerAudio let name: String let content: (TileAudioInfo?) -> Content var body: some View { content(TileAudioInfo.peer(peer, name: name)) } } } /// A small level meter: bars that light from the left, in the Calls teal. /// Steps, not a continuous level, so it never animates frame by frame, /// and with Reduce Motion it does not animate at all. The device panel's /// "Microphone level" row, for setting up; the tiles have the glow. /// Hidden from assistive technology: the words beside it carry anything /// worth saying. public struct AudioLevelBars: View { let step: Int var height: CGFloat = 12 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.colorScheme) private var scheme public init(step: Int, height: CGFloat = 12) { self.step = step self.height = height } public var body: some View { // The accent lit up on a dark panel; the deeper accent on a light // sheet, where the lit one would be too pale to tell from the unlit. let lit = scheme == .dark ? CallsAppearance.accentLit : CallsAppearance.accent HStack(alignment: .bottom, spacing: 2) { ForEach(0.. is connecting and can't hear you yet" and three dots after it /// (spec §15.24) that brighten in turn — waiting, not wrong — or one steady /// ellipsis under Reduce Motion. One run of text, so a long name wraps the /// dots with the words. VoiceOver hears the sentence, never the dots. public struct ConnectingText: View { let words: String @Environment(\.accessibilityReduceMotion) private var reduceMotion public init(_ words: String) { self.words = words } public var body: some View { Group { if reduceMotion { Text(words + "…") } else { TimelineView(.periodic(from: .now, by: 0.1)) { context in let t = context.date.timeIntervalSinceReferenceDate (0..<3).reduce(Text(words)) { line, dot in line + Text(".").foregroundColor(.white.opacity(Self.opacity(at: t, dot: dot))) } } } } .accessibilityLabel(words) } /// Each dot's brightness at `t`: dim, lit at its turn in a 1.4 s cycle, /// a fifth of a second after the one before — the browser's /// `calls-ellipsis` keyframes. static func opacity(at t: Double, dot: Int) -> Double { let cycle = 1.4 let phase = (t - Double(dot) * 0.2).truncatingRemainder(dividingBy: cycle) let p = (phase < 0 ? phase + cycle : phase) / cycle let lift = p <= 0.4 ? p / 0.4 : p <= 0.8 ? (0.8 - p) / 0.4 : 0 return 0.35 + 0.65 * lift } } /// One line over the picture: light glyph and text on the dark overlay /// surface, legible over any video, with an optional ⓘ that opens the /// explanation. struct AudioLine: View { let text: String let systemImage: String var detail: String? /// The connecting notice's moving dots after the words (spec §15.24). var ellipsis = false @State private var showing = false var body: some View { HStack(spacing: 5) { Image(systemName: systemImage) Group { if ellipsis { ConnectingText(text) } else { Text(text) } } .lineLimit(2).multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) if let detail { Button { showing.toggle() } label: { Image(systemName: "info.circle") } .buttonStyle(.plain) .help(detail) .accessibilityLabel("About this") .popover(isPresented: $showing) { Text(detail).font(.callout).padding(14) .frame(idealWidth: 280, maxWidth: 320) .fixedSize(horizontal: false, vertical: true) #if os(iOS) .presentationCompactAdaptation(.popover) #endif } } } .font(.caption.weight(.semibold)).foregroundStyle(.white) .padding(.horizontal, 8).padding(.vertical, 4) .callOverlaySurface(RoundedRectangle(cornerRadius: 8, style: .continuous)) } } /// Every thirtieth of a second from now until `until`, then nothing. struct GlowSchedule: TimelineSchedule { let until: Date func entries(from start: Date, mode: TimelineScheduleMode) -> [Date] { guard until > start else { return [start] } let every = mode == .lowFrequency ? 0.25 : 1.0 / 30 let count = min(Int(until.timeIntervalSince(start) / every) + 2, 300) return (0.. String { ([tileAccessibilityLabel(label, muted: muted)] + (audio?.spoken ?? [])).joined(separator: ", ") } /// A waveform while someone is speaking, and nothing otherwise: the mini /// window's tab, with the same warm rim round it as a name has. Hidden from /// assistive technology, which would hear it flicker on and off with /// every sentence. public struct SpeakingGlyph: View { @ObservedObject var speaking: SpeakingNow let glow: GlowLevel? public init(speaking: SpeakingNow, glow: GlowLevel? = nil) { self.speaking = speaking self.glow = glow } public var body: some View { Image(systemName: "waveform") .font(.caption.bold()).foregroundStyle(.white) .frame(width: 22, height: 22) .speakingRim(glow, in: Circle()) .opacity(speaking.value ? 1 : 0) .help("Someone is speaking") .accessibilityHidden(true) } }