oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import 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.
388enum 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