import SwiftUI /// The camera, microphone and speaker a call is on, and a choice of each /// where there is more than one. /// /// One view for both apps, because the thing a person wants here is the /// same on a Mac and an iPhone. On iPhone the microphone is an audio /// session route and the speaker follows it; on Mac both are devices on /// the call's own audio device module. /// /// The audio readout at the bottom is the reason this panel opens at /// all, most of the time: someone has noticed the call sounds wrong. It /// says which half of "wrong" the counters are seeing, in a sentence, /// rather than making them guess between a bad network and a bad device. @MainActor public struct DevicePanel: View { @ObservedObject private var call: Call @ObservedObject private var audio: AudioRoutes @State private var camera: String? @State private var cameraRequest = 0 @State private var showingAudioDetails = false @Environment(\.dismiss) private var dismiss /// How long a popover, or the sheet holding the panel, takes to go /// before another sheet can open in its place. static let handOver: TimeInterval = 0.45 #if os(iOS) @StateObject private var micModes = MicrophoneModes() #endif public init(call: Call) { self.call = call self.audio = call.audio } public var body: some View { VStack(alignment: .leading, spacing: 16) { cameraRow #if os(iOS) if audio.inputs.count > 1 { microphonePicker } else if let only = audio.inputs.first { row("Microphone", only.name) } micMode if !audio.outputName.isEmpty { row("Speaker", audio.outputName) } #else macAudio #endif MicLevelRow(mic: call.levels.mic) if !call.deviceNote.isEmpty { Label(call.deviceNote, systemImage: "exclamationmark.circle.fill") .font(.callout).foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) .accessibilityLabel("Device problem: \(call.deviceNote)") } noiseToggle AudioSelfTestButton(call: call).buttonStyle(.bordered) screenSection audioFoot } .padding(16) .onAppear { call.refreshDevices() camera = call.currentCamera #if os(iOS) micModes.refresh() #endif } // The picker follows the camera, not the intention: a switch that was // refused leaves the capture where it was, and the control has to go // back rather than keep showing a device the call is not on. .onChange(of: call.currentCamera) { _, now in camera = now } // Devices come and go while the panel is open — a phone offered as a // Continuity Camera, a headset plugged in — so it keeps looking. .task { while !Task.isCancelled { call.refreshDevices() try? await Task.sleep(for: .seconds(1)) } } } /// A camera the person picked, remembered and switched to. Driven by the /// picker's own setter, which only a person's choice calls — so choosing /// the camera the call is already on (there by fallback, say) counts, as /// it did not in 0.3.3, and the picker following the capture never does. private func pickCamera(_ id: String) { camera = id cameraRequest += 1 let request = cameraRequest Task { await call.useCamera(id) // Only the latest pick puts the picker back where the camera is. if request == cameraRequest { camera = call.currentCamera } } } /// Always there in a call with a real camera — a camera that has gone /// is exactly when a person needs this row, and 0.3.1 hid it then. A /// picker even for one camera: a Continuity or external camera can /// arrive while the panel is open, and the list follows it. @ViewBuilder private var cameraRow: some View { let state = CameraRowState( cameras: call.cameras.count, offers: call.offersCameras, status: call.cameraStatus) if state != .hidden { VStack(alignment: .leading, spacing: 6) { if state == .noCamera { row("Camera", "No camera found") } else { picker( "Camera", selection: Binding( get: { camera }, set: { id in if let id { pickCamera(id) } }), options: call.cameras.map { ($0.id, $0.name) }, identifier: "cameraPicker") } if case .picker(let line, _) = state, let line { Text(line).font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("cameraStatus") } if !call.cameraNote.isEmpty { Label(call.cameraNote, systemImage: "info.circle") .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("cameraNote") } if let keep = call.cameraToKeep { Button("Use \(keep.name) from now on", systemImage: "checkmark.circle") { pickCamera(keep.id) } .buttonStyle(.bordered) .accessibilityIdentifier("cameraKeep") .accessibilityHint("Remembers this camera for your next calls.") } if state.offersRetry { Button("Try again", systemImage: "arrow.clockwise") { Task { await call.retryCamera() } } .buttonStyle(.bordered) .accessibilityIdentifier("cameraRetry") .accessibilityHint("Looks for cameras again and restarts the camera.") } } } } #if os(iOS) private var microphonePicker: some View { VStack(alignment: .leading, spacing: 6) { picker( "Microphone", selection: Binding( get: { audio.currentInput }, set: { id in guard let id else { return } do { try audio.use(input: id) } catch { call.noteDevice(error.localizedDescription) } }), options: audio.inputs.map { ($0.id, $0.name) }, identifier: "microphonePicker") // Said before the choice rather than after it: the cost of a // Bluetooth microphone is that the whole call drops to the // hands-free profile, which is narrowband both ways. Someone who // picks it should be picking it knowingly. Text( "A Bluetooth microphone puts the call on the hands-free profile, which is lower quality in both directions." ) .font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } /// Apple's Mic Mode, named and one tap from its picker. Calls cannot /// set it, only open the place where the person can; see /// MicrophoneModes for why it is worth the row. private var micMode: some View { VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 2) { Text("Mic Mode").font(.headline) Text(micModes.summary).font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("micMode") }.accessibilityElement(children: .combine) Button("Change Mic Mode", systemImage: "waveform") { micModes.showPicker() } .buttonStyle(.bordered) .accessibilityIdentifier("changeMicMode") .accessibilityHint("Opens the system Mic Mode picker.") Text("Voice Isolation filters out wind, typing and room echo. The choice is kept by iOS, not Calls.") .font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } #endif #if os(macOS) /// Microphone and speaker on the call's own audio device module, so a /// choice here moves this call and leaves the Mac's system setting /// alone. "System default" is always the first entry, and follows macOS. private var macAudio: some View { VStack(alignment: .leading, spacing: 12) { if !audio.inputs.isEmpty { picker( "Microphone", selection: Binding( get: { audio.currentInput }, set: { id in guard let id, id != audio.currentInput else { return } do { try audio.use(input: id) call.noteDevice("") } catch { call.noteDevice(error.localizedDescription) } }), options: audio.inputs.map { ($0.id, $0.name) }, identifier: "microphonePicker") } InputChannelPicker(audio: audio) if !audio.outputs.isEmpty { picker( "Speaker", selection: Binding( get: { audio.currentOutput }, set: { id in guard let id, id != audio.currentOutput else { return } do { try audio.use(output: id) call.noteDevice("") } catch { call.noteDevice(error.localizedDescription) } }), options: audio.outputs.map { ($0.id, $0.name) }, identifier: "speakerPicker") } if !audio.inputs.isEmpty || !audio.outputs.isEmpty { Text("Calls remembers these for next time.") .font(.footnote).foregroundStyle(.secondary) } if !audio.routeNote.isEmpty { Label(audio.routeNote, systemImage: "info.circle") .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("routeNote") } } } #endif /// Said as what it does for the far side, since that is who hears it. private var noiseToggle: some View { VStack(alignment: .leading, spacing: 4) { Toggle("Reduce background noise", isOn: $call.reduceNoise) .accessibilityIdentifier("reduceNoise") Text("Takes out typing, wind and room noise before others hear you.") .font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } /// "Optimise for", and while this app is sharing, one line saying how /// the screen is actually leaving — the answer to "it isn't keeping up". private var screenSection: some View { VStack(alignment: .leading, spacing: 4) { Picker("Optimise shared screen for", selection: $call.screenMode) { ForEach(ScreenShareMode.allCases) { mode in Text(mode.title).tag(mode) } } .accessibilityIdentifier("screenMode") #if os(macOS) .pickerStyle(.menu) #endif Text(call.screenMode.explanation) .font(.footnote).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } /// The panel's foot: a warning line only when something is wrong, and /// the ⓘ that opens the full readout. The readout used to fill the /// bottom of the panel all the time; most of the time it said all is /// well, at length. private var audioFoot: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { if let warning = AudioWarning.line( health: call.audioHealth, processing: call.audioProcessing, reduceNoise: call.reduceNoise) { Label(warning, systemImage: "exclamationmark.triangle.fill") .font(.callout).foregroundStyle(.orange) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("audioWarning") } Spacer(minLength: 0) Button("Audio details", systemImage: "info.circle") { showingAudioDetails.toggle() } .labelStyle(.iconOnly) .buttonStyle(.borderless) .help("Audio details") .accessibilityIdentifier("audioDetails") .popover(isPresented: $showingAudioDetails) { ScrollView { audioReadout.padding(16) } .frame(minWidth: 280, idealWidth: 320, maxWidth: 360, maxHeight: 420) .fixedSize(horizontal: false, vertical: true) #if os(iOS) .presentationCompactAdaptation(.popover) #endif } } } private var audioReadout: some View { VStack(alignment: .leading, spacing: 4) { Text("Audio health").font(.headline) if let health = call.audioHealth { Text(health.summary) .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("audioHealth") } else { Text("Measuring…").font(.callout).foregroundStyle(.secondary) .accessibilityIdentifier("audioHealth") } if let processing = call.audioProcessing { Text(processing.summary) .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("audioProcessing") } CopyAudioDiagnosticsButton(title: "Copy call diagnostics") { call.audioDiagnosticsText() } .padding(.top, 6) // Beside the copy, the same diagnostics sent rather than pasted // (spec §15.26). The popover goes first — and on iPhone the Devices // sheet with it — so the report's sheet opens over the call. ReportProblemButton { showingAudioDetails = false #if os(iOS) dismiss() #endif let call = self.call DispatchQueue.main.asyncAfter(deadline: .now() + Self.handOver) { call.reportProblem() } } .buttonStyle(.borderless) .font(.footnote) .disabled(!call.canReportProblem) if call.sharingScreen { Text("Screen sharing").font(.headline).padding(.top, 6) Text(call.screenReport?.summary ?? "Screen: starting…") .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("screenReport") if !call.screenDetail.isEmpty { Text(call.screenDetail) .font(.callout).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) .accessibilityIdentifier("screenDetail") } } } .textSelection(.enabled) } private func row(_ label: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text(label).font(.headline) Text(value).font(.callout).foregroundStyle(.secondary).textSelection(.enabled) }.accessibilityElement(children: .combine) } private func picker( _ label: String, selection: Binding, options: [(String, String)], identifier: String ) -> some View { Picker(label, selection: selection) { // While a camera is switching there is no current one; say so // rather than show a device the call is not on. if selection.wrappedValue == nil { Text("Choose a device").tag(Optional.none) } ForEach(options, id: \.0) { option in Text(option.1).tag(Optional(option.0)) } } .accessibilityIdentifier(identifier) #if os(macOS) .pickerStyle(.menu) #endif } } /// What the panel's Camera row shows, as a rule the tests can hold. enum CameraRowState: Equatable { /// A capture with nothing to choose (the interop gate's synthetic one). case hidden /// A real camera was expected and none is connected. case noCamera /// The picker, with a plain-words line when the camera is not working, /// and "Try again" when trying again could help. case picker(line: String?, retry: Bool) init(cameras: Int, offers: Bool, status: CameraStatus?) { if cameras == 0 { self = offers ? .noCamera : .hidden return } switch status { case .noCamera?, .live?, nil: self = .picker(line: nil, retry: false) case .starting?: self = .picker(line: status?.panelText, retry: false) case .noSignal?, .disconnected?, .failed?: self = .picker(line: status?.panelText, retry: true) } } var offersRetry: Bool { switch self { case .hidden: return false case .noCamera: return true case .picker(_, let retry): return retry } } }