| 1 | import SwiftUI |
| 2 | |
| 3 | /// The camera, microphone and speaker a call is on, and a choice of each |
| 4 | /// where there is more than one. |
| 5 | /// |
| 6 | /// One view for both apps, because the thing a person wants here is the |
| 7 | /// same on a Mac and an iPhone. On iPhone the microphone is an audio |
| 8 | /// session route and the speaker follows it; on Mac both are devices on |
| 9 | /// the call's own audio device module. |
| 10 | /// |
| 11 | /// The audio readout at the bottom is the reason this panel opens at |
| 12 | /// all, most of the time: someone has noticed the call sounds wrong. It |
| 13 | /// says which half of "wrong" the counters are seeing, in a sentence, |
| 14 | /// rather than making them guess between a bad network and a bad device. |
| 15 | @MainActor public struct DevicePanel: View { |
| 16 | @ObservedObject private var call: Call |
| 17 | @ObservedObject private var audio: AudioRoutes |
| 18 | @State private var camera: String? |
| 19 | @State private var cameraRequest = 0 |
| 20 | @State private var showingAudioDetails = false |
| 21 | @Environment(\.dismiss) private var dismiss |
| 22 | /// How long a popover, or the sheet holding the panel, takes to go |
| 23 | /// before another sheet can open in its place. |
| 24 | static let handOver: TimeInterval = 0.45 |
| 25 | #if os(iOS) |
| 26 | @StateObject private var micModes = MicrophoneModes() |
| 27 | #endif |
| 28 | |
| 29 | public init(call: Call) { |
| 30 | self.call = call |
| 31 | self.audio = call.audio |
| 32 | } |
| 33 | |
| 34 | public var body: some View { |
| 35 | VStack(alignment: .leading, spacing: 16) { |
| 36 | cameraRow |
| 37 | |
| 38 | #if os(iOS) |
| 39 | if audio.inputs.count > 1 { |
| 40 | microphonePicker |
| 41 | } else if let only = audio.inputs.first { |
| 42 | row("Microphone", only.name) |
| 43 | } |
| 44 | micMode |
| 45 | if !audio.outputName.isEmpty { row("Speaker", audio.outputName) } |
| 46 | #else |
| 47 | macAudio |
| 48 | #endif |
| 49 | |
| 50 | MicLevelRow(mic: call.levels.mic) |
| 51 | |
| 52 | if !call.deviceNote.isEmpty { |
| 53 | Label(call.deviceNote, systemImage: "exclamationmark.circle.fill") |
| 54 | .font(.callout).foregroundStyle(.red) |
| 55 | .fixedSize(horizontal: false, vertical: true) |
| 56 | .accessibilityLabel("Device problem: \(call.deviceNote)") |
| 57 | } |
| 58 | |
| 59 | noiseToggle |
| 60 | |
| 61 | AudioSelfTestButton(call: call).buttonStyle(.bordered) |
| 62 | |
| 63 | screenSection |
| 64 | |
| 65 | audioFoot |
| 66 | } |
| 67 | .padding(16) |
| 68 | .onAppear { |
| 69 | call.refreshDevices() |
| 70 | camera = call.currentCamera |
| 71 | #if os(iOS) |
| 72 | micModes.refresh() |
| 73 | #endif |
| 74 | } |
| 75 | // The picker follows the camera, not the intention: a switch that was |
| 76 | // refused leaves the capture where it was, and the control has to go |
| 77 | // back rather than keep showing a device the call is not on. |
| 78 | .onChange(of: call.currentCamera) { _, now in camera = now } |
| 79 | // Devices come and go while the panel is open — a phone offered as a |
| 80 | // Continuity Camera, a headset plugged in — so it keeps looking. |
| 81 | .task { |
| 82 | while !Task.isCancelled { |
| 83 | call.refreshDevices() |
| 84 | try? await Task.sleep(for: .seconds(1)) |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /// A camera the person picked, remembered and switched to. Driven by the |
| 90 | /// picker's own setter, which only a person's choice calls — so choosing |
| 91 | /// the camera the call is already on (there by fallback, say) counts, as |
| 92 | /// it did not in 0.3.3, and the picker following the capture never does. |
| 93 | private func pickCamera(_ id: String) { |
| 94 | camera = id |
| 95 | cameraRequest += 1 |
| 96 | let request = cameraRequest |
| 97 | Task { |
| 98 | await call.useCamera(id) |
| 99 | // Only the latest pick puts the picker back where the camera is. |
| 100 | if request == cameraRequest { camera = call.currentCamera } |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Always there in a call with a real camera — a camera that has gone |
| 105 | /// is exactly when a person needs this row, and 0.3.1 hid it then. A |
| 106 | /// picker even for one camera: a Continuity or external camera can |
| 107 | /// arrive while the panel is open, and the list follows it. |
| 108 | @ViewBuilder private var cameraRow: some View { |
| 109 | let state = CameraRowState( |
| 110 | cameras: call.cameras.count, offers: call.offersCameras, status: call.cameraStatus) |
| 111 | if state != .hidden { |
| 112 | VStack(alignment: .leading, spacing: 6) { |
| 113 | if state == .noCamera { |
| 114 | row("Camera", "No camera found") |
| 115 | } else { |
| 116 | picker( |
| 117 | "Camera", |
| 118 | selection: Binding( |
| 119 | get: { camera }, |
| 120 | set: { id in if let id { pickCamera(id) } }), |
| 121 | options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker") |
| 122 | } |
| 123 | if case .picker(let line, _) = state, let line { |
| 124 | Text(line).font(.callout).foregroundStyle(.secondary) |
| 125 | .fixedSize(horizontal: false, vertical: true) |
| 126 | .accessibilityIdentifier("cameraStatus") |
| 127 | } |
| 128 | if !call.cameraNote.isEmpty { |
| 129 | Label(call.cameraNote, systemImage: "info.circle") |
| 130 | .font(.callout).foregroundStyle(.secondary) |
| 131 | .fixedSize(horizontal: false, vertical: true) |
| 132 | .accessibilityIdentifier("cameraNote") |
| 133 | } |
| 134 | if let keep = call.cameraToKeep { |
| 135 | Button("Use \(keep.name) from now on", systemImage: "checkmark.circle") { |
| 136 | pickCamera(keep.id) |
| 137 | } |
| 138 | .buttonStyle(.bordered) |
| 139 | .accessibilityIdentifier("cameraKeep") |
| 140 | .accessibilityHint("Remembers this camera for your next calls.") |
| 141 | } |
| 142 | if state.offersRetry { |
| 143 | Button("Try again", systemImage: "arrow.clockwise") { |
| 144 | Task { await call.retryCamera() } |
| 145 | } |
| 146 | .buttonStyle(.bordered) |
| 147 | .accessibilityIdentifier("cameraRetry") |
| 148 | .accessibilityHint("Looks for cameras again and restarts the camera.") |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | #if os(iOS) |
| 155 | private var microphonePicker: some View { |
| 156 | VStack(alignment: .leading, spacing: 6) { |
| 157 | picker( |
| 158 | "Microphone", selection: Binding( |
| 159 | get: { audio.currentInput }, |
| 160 | set: { id in |
| 161 | guard let id else { return } |
| 162 | do { try audio.use(input: id) } catch { call.noteDevice(error.localizedDescription) } |
| 163 | }), |
| 164 | options: audio.inputs.map { ($0.id, $0.name) }, identifier: "microphonePicker") |
| 165 | // Said before the choice rather than after it: the cost of a |
| 166 | // Bluetooth microphone is that the whole call drops to the |
| 167 | // hands-free profile, which is narrowband both ways. Someone who |
| 168 | // picks it should be picking it knowingly. |
| 169 | Text( |
| 170 | "A Bluetooth microphone puts the call on the hands-free profile, which is lower quality in both directions." |
| 171 | ) |
| 172 | .font(.footnote).foregroundStyle(.secondary) |
| 173 | .fixedSize(horizontal: false, vertical: true) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | /// Apple's Mic Mode, named and one tap from its picker. Calls cannot |
| 178 | /// set it, only open the place where the person can; see |
| 179 | /// MicrophoneModes for why it is worth the row. |
| 180 | private var micMode: some View { |
| 181 | VStack(alignment: .leading, spacing: 6) { |
| 182 | VStack(alignment: .leading, spacing: 2) { |
| 183 | Text("Mic Mode").font(.headline) |
| 184 | Text(micModes.summary).font(.callout).foregroundStyle(.secondary) |
| 185 | .fixedSize(horizontal: false, vertical: true) |
| 186 | .accessibilityIdentifier("micMode") |
| 187 | }.accessibilityElement(children: .combine) |
| 188 | Button("Change Mic Mode", systemImage: "waveform") { micModes.showPicker() } |
| 189 | .buttonStyle(.bordered) |
| 190 | .accessibilityIdentifier("changeMicMode") |
| 191 | .accessibilityHint("Opens the system Mic Mode picker.") |
| 192 | Text("Voice Isolation filters out wind, typing and room echo. The choice is kept by iOS, not Calls.") |
| 193 | .font(.footnote).foregroundStyle(.secondary) |
| 194 | .fixedSize(horizontal: false, vertical: true) |
| 195 | } |
| 196 | } |
| 197 | #endif |
| 198 | |
| 199 | #if os(macOS) |
| 200 | /// Microphone and speaker on the call's own audio device module, so a |
| 201 | /// choice here moves this call and leaves the Mac's system setting |
| 202 | /// alone. "System default" is always the first entry, and follows macOS. |
| 203 | private var macAudio: some View { |
| 204 | VStack(alignment: .leading, spacing: 12) { |
| 205 | if !audio.inputs.isEmpty { |
| 206 | picker( |
| 207 | "Microphone", |
| 208 | selection: Binding( |
| 209 | get: { audio.currentInput }, |
| 210 | set: { id in |
| 211 | guard let id, id != audio.currentInput else { return } |
| 212 | do { |
| 213 | try audio.use(input: id) |
| 214 | call.noteDevice("") |
| 215 | } catch { call.noteDevice(error.localizedDescription) } |
| 216 | }), |
| 217 | options: audio.inputs.map { ($0.id, $0.name) }, identifier: "microphonePicker") |
| 218 | } |
| 219 | InputChannelPicker(audio: audio) |
| 220 | if !audio.outputs.isEmpty { |
| 221 | picker( |
| 222 | "Speaker", |
| 223 | selection: Binding( |
| 224 | get: { audio.currentOutput }, |
| 225 | set: { id in |
| 226 | guard let id, id != audio.currentOutput else { return } |
| 227 | do { |
| 228 | try audio.use(output: id) |
| 229 | call.noteDevice("") |
| 230 | } catch { call.noteDevice(error.localizedDescription) } |
| 231 | }), |
| 232 | options: audio.outputs.map { ($0.id, $0.name) }, identifier: "speakerPicker") |
| 233 | } |
| 234 | if !audio.inputs.isEmpty || !audio.outputs.isEmpty { |
| 235 | Text("Calls remembers these for next time.") |
| 236 | .font(.footnote).foregroundStyle(.secondary) |
| 237 | } |
| 238 | if !audio.routeNote.isEmpty { |
| 239 | Label(audio.routeNote, systemImage: "info.circle") |
| 240 | .font(.callout).foregroundStyle(.secondary) |
| 241 | .fixedSize(horizontal: false, vertical: true) |
| 242 | .accessibilityIdentifier("routeNote") |
| 243 | } |
| 244 | } |
| 245 | } |
| 246 | #endif |
| 247 | |
| 248 | /// Said as what it does for the far side, since that is who hears it. |
| 249 | private var noiseToggle: some View { |
| 250 | VStack(alignment: .leading, spacing: 4) { |
| 251 | Toggle("Reduce background noise", isOn: $call.reduceNoise) |
| 252 | .accessibilityIdentifier("reduceNoise") |
| 253 | Text("Takes out typing, wind and room noise before others hear you.") |
| 254 | .font(.footnote).foregroundStyle(.secondary) |
| 255 | .fixedSize(horizontal: false, vertical: true) |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | /// "Optimise for", and while this app is sharing, one line saying how |
| 260 | /// the screen is actually leaving — the answer to "it isn't keeping up". |
| 261 | private var screenSection: some View { |
| 262 | VStack(alignment: .leading, spacing: 4) { |
| 263 | Picker("Optimise shared screen for", selection: $call.screenMode) { |
| 264 | ForEach(ScreenShareMode.allCases) { mode in |
| 265 | Text(mode.title).tag(mode) |
| 266 | } |
| 267 | } |
| 268 | .accessibilityIdentifier("screenMode") |
| 269 | #if os(macOS) |
| 270 | .pickerStyle(.menu) |
| 271 | #endif |
| 272 | Text(call.screenMode.explanation) |
| 273 | .font(.footnote).foregroundStyle(.secondary) |
| 274 | .fixedSize(horizontal: false, vertical: true) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | /// The panel's foot: a warning line only when something is wrong, and |
| 279 | /// the ⓘ that opens the full readout. The readout used to fill the |
| 280 | /// bottom of the panel all the time; most of the time it said all is |
| 281 | /// well, at length. |
| 282 | private var audioFoot: some View { |
| 283 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 284 | if let warning = AudioWarning.line( |
| 285 | health: call.audioHealth, processing: call.audioProcessing, reduceNoise: call.reduceNoise) |
| 286 | { |
| 287 | Label(warning, systemImage: "exclamationmark.triangle.fill") |
| 288 | .font(.callout).foregroundStyle(.orange) |
| 289 | .fixedSize(horizontal: false, vertical: true) |
| 290 | .accessibilityIdentifier("audioWarning") |
| 291 | } |
| 292 | Spacer(minLength: 0) |
| 293 | Button("Audio details", systemImage: "info.circle") { showingAudioDetails.toggle() } |
| 294 | .labelStyle(.iconOnly) |
| 295 | .buttonStyle(.borderless) |
| 296 | .help("Audio details") |
| 297 | .accessibilityIdentifier("audioDetails") |
| 298 | .popover(isPresented: $showingAudioDetails) { |
| 299 | ScrollView { audioReadout.padding(16) } |
| 300 | .frame(minWidth: 280, idealWidth: 320, maxWidth: 360, maxHeight: 420) |
| 301 | .fixedSize(horizontal: false, vertical: true) |
| 302 | #if os(iOS) |
| 303 | .presentationCompactAdaptation(.popover) |
| 304 | #endif |
| 305 | } |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | private var audioReadout: some View { |
| 310 | VStack(alignment: .leading, spacing: 4) { |
| 311 | Text("Audio health").font(.headline) |
| 312 | if let health = call.audioHealth { |
| 313 | Text(health.summary) |
| 314 | .font(.callout).foregroundStyle(.secondary) |
| 315 | .fixedSize(horizontal: false, vertical: true) |
| 316 | .accessibilityIdentifier("audioHealth") |
| 317 | } else { |
| 318 | Text("Measuring…").font(.callout).foregroundStyle(.secondary) |
| 319 | .accessibilityIdentifier("audioHealth") |
| 320 | } |
| 321 | if let processing = call.audioProcessing { |
| 322 | Text(processing.summary) |
| 323 | .font(.callout).foregroundStyle(.secondary) |
| 324 | .fixedSize(horizontal: false, vertical: true) |
| 325 | .accessibilityIdentifier("audioProcessing") |
| 326 | } |
| 327 | CopyAudioDiagnosticsButton(title: "Copy call diagnostics") { call.audioDiagnosticsText() } |
| 328 | .padding(.top, 6) |
| 329 | // Beside the copy, the same diagnostics sent rather than pasted |
| 330 | // (spec §15.26). The popover goes first — and on iPhone the Devices |
| 331 | // sheet with it — so the report's sheet opens over the call. |
| 332 | ReportProblemButton { |
| 333 | showingAudioDetails = false |
| 334 | #if os(iOS) |
| 335 | dismiss() |
| 336 | #endif |
| 337 | let call = self.call |
| 338 | DispatchQueue.main.asyncAfter(deadline: .now() + Self.handOver) { call.reportProblem() } |
| 339 | } |
| 340 | .buttonStyle(.borderless) |
| 341 | .font(.footnote) |
| 342 | .disabled(!call.canReportProblem) |
| 343 | if call.sharingScreen { |
| 344 | Text("Screen sharing").font(.headline).padding(.top, 6) |
| 345 | Text(call.screenReport?.summary ?? "Screen: starting…") |
| 346 | .font(.callout).foregroundStyle(.secondary) |
| 347 | .fixedSize(horizontal: false, vertical: true) |
| 348 | .accessibilityIdentifier("screenReport") |
| 349 | if !call.screenDetail.isEmpty { |
| 350 | Text(call.screenDetail) |
| 351 | .font(.callout).foregroundStyle(.secondary) |
| 352 | .fixedSize(horizontal: false, vertical: true) |
| 353 | .accessibilityIdentifier("screenDetail") |
| 354 | } |
| 355 | } |
| 356 | } |
| 357 | .textSelection(.enabled) |
| 358 | } |
| 359 | |
| 360 | private func row(_ label: String, _ value: String) -> some View { |
| 361 | VStack(alignment: .leading, spacing: 2) { |
| 362 | Text(label).font(.headline) |
| 363 | Text(value).font(.callout).foregroundStyle(.secondary).textSelection(.enabled) |
| 364 | }.accessibilityElement(children: .combine) |
| 365 | } |
| 366 | |
| 367 | private func picker( |
| 368 | _ label: String, selection: Binding<String?>, options: [(String, String)], identifier: String |
| 369 | ) -> some View { |
| 370 | Picker(label, selection: selection) { |
| 371 | // While a camera is switching there is no current one; say so |
| 372 | // rather than show a device the call is not on. |
| 373 | if selection.wrappedValue == nil { |
| 374 | Text("Choose a device").tag(Optional<String>.none) |
| 375 | } |
| 376 | ForEach(options, id: \.0) { option in |
| 377 | Text(option.1).tag(Optional(option.0)) |
| 378 | } |
| 379 | } |
| 380 | .accessibilityIdentifier(identifier) |
| 381 | #if os(macOS) |
| 382 | .pickerStyle(.menu) |
| 383 | #endif |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | /// What the panel's Camera row shows, as a rule the tests can hold. |
| 388 | enum CameraRowState: Equatable { |
| 389 | /// A capture with nothing to choose (the interop gate's synthetic one). |
| 390 | case hidden |
| 391 | /// A real camera was expected and none is connected. |
| 392 | case noCamera |
| 393 | /// The picker, with a plain-words line when the camera is not working, |
| 394 | /// and "Try again" when trying again could help. |
| 395 | case picker(line: String?, retry: Bool) |
| 396 | |
| 397 | init(cameras: Int, offers: Bool, status: CameraStatus?) { |
| 398 | if cameras == 0 { |
| 399 | self = offers ? .noCamera : .hidden |
| 400 | return |
| 401 | } |
| 402 | switch status { |
| 403 | case .noCamera?, .live?, nil: |
| 404 | self = .picker(line: nil, retry: false) |
| 405 | case .starting?: |
| 406 | self = .picker(line: status?.panelText, retry: false) |
| 407 | case .noSignal?, .disconnected?, .failed?: |
| 408 | self = .picker(line: status?.panelText, retry: true) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | var offersRetry: Bool { |
| 413 | switch self { |
| 414 | case .hidden: return false |
| 415 | case .noCamera: return true |
| 416 | case .picker(_, let retry): return retry |
| 417 | } |
| 418 | } |
| 419 | } |
| 420 | |