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/// "Test audio": one button, on the home screen and in the device panel,
4/// opening the test in a sheet. The test runs while the sheet is open and
5/// stops, recording and all, when it closes.
6@MainActor public struct AudioSelfTestButton: View {
7 private let call: Call?
8 @State private var testing = false
9
10 /// `call` in a call, where the test reads that call's microphone and
11 /// plays nothing; nil on the home screen.
12 public init(call: Call? = nil) {
13 self.call = call
14 }
15
16 public var body: some View {
17 Button("Test audio", systemImage: "waveform") { testing = true }
18 .accessibilityIdentifier("testAudio")
19 .accessibilityHint("Checks your microphone and speaker.")
20 .sheet(isPresented: $testing) {
21 AudioSelfTestSheet(call: call) { testing = false }
22 }
23 }
24}
25
26@MainActor private struct AudioSelfTestSheet: View {
27 @StateObject private var test: AudioSelfTest
28 let done: () -> Void
29
30 init(call: Call?, done: @escaping () -> Void) {
31 _test = StateObject(wrappedValue: call.map { AudioSelfTest.during($0) } ?? AudioSelfTest.standalone())
32 self.done = done
33 }
34
35 var body: some View {
36 #if os(iOS)
37 NavigationStack {
38 ScrollView { AudioSelfTestView(test: test).padding(20) }
39 .navigationTitle("Test audio")
40 .navigationBarTitleDisplayMode(.inline)
41 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done", action: done) } }
42 }
43 .task { await test.open() }
44 .onDisappear { test.close() }
45 #else
46 VStack(alignment: .leading, spacing: 0) {
47 Text("Test audio").font(.title2.bold()).padding([.horizontal, .top], 20)
48 ScrollView { AudioSelfTestView(test: test).padding(20) }
49 HStack {
50 Spacer()
51 Button("Done", action: done).keyboardShortcut(.defaultAction)
52 }.padding(16)
53 }
54 .frame(minWidth: 420, idealWidth: 460, minHeight: 480, idealHeight: 560)
55 .task { await test.open() }
56 .onDisappear { test.close() }
57 #endif
58 }
59}
60
61/// The test itself: microphone, speaker, and hear yourself, each with a
62/// sentence saying what was found. Every meter has its words beside it.
63@MainActor public struct AudioSelfTestView: View {
64 @ObservedObject private var test: AudioSelfTest
65
66 public init(test: AudioSelfTest) {
67 self.test = test
68 }
69
70 public var body: some View {
71 VStack(alignment: .leading, spacing: 24) {
72 microphone
73 Divider()
74 speaker
75 Divider()
76 hearYourself
77 Divider()
78 CopyAudioDiagnosticsButton { test.diagnosticsText() }
79 }
80 .frame(maxWidth: .infinity, alignment: .leading)
81 }
82
83 // MARK: Microphone
84
85 private var microphone: some View {
86 VStack(alignment: .leading, spacing: 10) {
87 Text("Microphone").font(.headline)
88 #if os(macOS)
89 if let routes = test.routes {
90 DevicePicker(
91 label: "Microphone", routes: routes, input: true, identifier: "testMicrophonePicker",
92 changed: test.microphoneChanged)
93 } else if !test.microphoneName.isEmpty {
94 Text(test.microphoneName).foregroundStyle(.secondary)
95 }
96 #else
97 if !test.microphoneName.isEmpty { Text(test.microphoneName).foregroundStyle(.secondary) }
98 #endif
99 if !test.microphoneDetail.isEmpty {
100 Text("Using \(test.microphoneDetail)").font(.footnote).foregroundStyle(.secondary)
101 .fixedSize(horizontal: false, vertical: true)
102 .accessibilityIdentifier("testMicrophoneDevice")
103 }
104 LevelMeter(step: test.micStep, label: "Microphone level")
105 .opacity(test.startingSentence == nil ? 1 : 0.4)
106 if let starting = test.startingSentence {
107 // The window is up at once; the microphone takes a moment, and a
108 // Bluetooth headset a second or two more. Said, not frozen.
109 HStack(spacing: 8) {
110 ProgressView().controlSize(.small)
111 Text(starting).font(.callout).fixedSize(horizontal: false, vertical: true)
112 }
113 .accessibilityElement(children: .combine)
114 .accessibilityIdentifier("testMicrophoneResult")
115 } else {
116 Label(test.micSentence, systemImage: micSymbol)
117 .font(.callout)
118 .fixedSize(horizontal: false, vertical: true)
119 .accessibilityIdentifier("testMicrophoneResult")
120 }
121 if let advice = test.micAdvice {
122 Text(advice).font(.footnote).foregroundStyle(.secondary)
123 .fixedSize(horizontal: false, vertical: true)
124 }
125 Text(
126 test.reduceNoise
127 ? "Measured after background noise reduction, as others hear you."
128 : "Background noise reduction is off, so this is your microphone as it is."
129 )
130 .font(.footnote).foregroundStyle(.secondary)
131 .fixedSize(horizontal: false, vertical: true)
132 }
133 }
134
135 private var micSymbol: String {
136 if test.micProblem != nil { return "exclamationmark.triangle" }
137 switch test.micVerdict {
138 case .working: return "checkmark.circle"
139 case .silent, .noAudio: return "exclamationmark.triangle"
140 case .listening, .hearing, .notStarted: return "ear"
141 }
142 }
143
144 // MARK: Speaker
145
146 private var speaker: some View {
147 VStack(alignment: .leading, spacing: 10) {
148 Text("Speaker").font(.headline)
149 if test.inCall {
150 Text(AudioSelfTest.inCallPlayback).font(.callout).foregroundStyle(.secondary)
151 .fixedSize(horizontal: false, vertical: true)
152 } else {
153 #if os(macOS)
154 if let routes = test.routes {
155 DevicePicker(
156 label: "Speaker", routes: routes, input: false, identifier: "testSpeakerPicker", changed: {})
157 }
158 #else
159 if !test.speakerName.isEmpty { Text(test.speakerName).foregroundStyle(.secondary) }
160 #endif
161 Button("Play a test sound", systemImage: "speaker.wave.2") {
162 Task { await test.playTestSound() }
163 }
164 .buttonStyle(.bordered)
165 .disabled(busy)
166 .accessibilityIdentifier("testSpeaker")
167 if test.speaker == .playing {
168 LevelMeter(step: test.outputStep, label: "Speaker level")
169 }
170 if let line = test.speakerSentence {
171 Text(line).font(.callout).fixedSize(horizontal: false, vertical: true)
172 .accessibilityIdentifier("testSpeakerResult")
173 }
174 }
175 }
176 }
177
178 // MARK: Hear yourself
179
180 private var hearYourself: some View {
181 VStack(alignment: .leading, spacing: 10) {
182 Text("Hear yourself as others will").font(.headline)
183 if test.inCall {
184 Text(AudioSelfTest.inCallPlayback).font(.callout).foregroundStyle(.secondary)
185 .fixedSize(horizontal: false, vertical: true)
186 } else {
187 Text(
188 "Records \(Int(test.recordSeconds)) seconds through the same sound processing a call uses, then plays it back. The recording stays in memory on this device and is deleted after it plays."
189 )
190 .font(.footnote).foregroundStyle(.secondary)
191 .fixedSize(horizontal: false, vertical: true)
192 Button("Record and play back", systemImage: "record.circle") {
193 Task { await test.recordAndPlayBack() }
194 }
195 .buttonStyle(.bordered)
196 .disabled(busy || test.micProblem != nil || test.startingSentence != nil)
197 .accessibilityIdentifier("testRecord")
198 if test.recording == .recording {
199 ProgressView(value: test.recordingProgress)
200 .accessibilityLabel("Recording")
201 .accessibilityValue("\(Int(test.recordingProgress * 100)) percent")
202 }
203 if test.recording == .playing {
204 LevelMeter(step: test.outputStep, label: "Playback level")
205 }
206 if let line = test.recordingSentence {
207 Text(line).font(.callout).fixedSize(horizontal: false, vertical: true)
208 .accessibilityIdentifier("testRecordResult")
209 }
210 }
211 }
212 }
213
214 private var busy: Bool {
215 test.speaker == .playing || test.recording == .recording || test.recording == .playing
216 }
217}
218
219#if os(macOS)
220 /// The test's own device choice before a call. The same remembered
221 /// choice the device panel makes, so what is chosen here is what the
222 /// next call uses.
223 @MainActor private struct DevicePicker: View {
224 let label: String
225 @ObservedObject var routes: AudioRoutes
226 let input: Bool
227 let identifier: String
228 let changed: () -> Void
229 @State private var problem = ""
230
231 var body: some View {
232 let list = input ? routes.inputs : routes.outputs
233 let current = input ? routes.currentInput : routes.currentOutput
234 VStack(alignment: .leading, spacing: 4) {
235 if list.isEmpty {
236 Text("No \(label.lowercased()) found").foregroundStyle(.secondary)
237 } else {
238 Picker(
239 label,
240 selection: Binding(
241 get: { current },
242 set: { id in
243 guard let id, id != current else { return }
244 do {
245 try input ? routes.use(input: id) : routes.use(output: id)
246 problem = ""
247 changed()
248 } catch { problem = error.localizedDescription }
249 })
250 ) {
251 if current == nil { Text("Choose a device").tag(Optional<String>.none) }
252 ForEach(list) { Text($0.name).tag(Optional($0.id)) }
253 }
254 .pickerStyle(.menu)
255 .labelsHidden()
256 .accessibilityLabel(label)
257 .accessibilityIdentifier(identifier)
258 }
259 if !problem.isEmpty {
260 Text(problem).font(.callout).foregroundStyle(.red)
261 }
262 }
263 }
264 }
265#endif
266
267/// "Copy audio diagnostics": what the app knows about its audio — the
268/// devices chosen and resolved, formats, the engine's state, the last 50
269/// audio log lines and the recent levels — as text on the clipboard, to
270/// paste into a message. Never any audio.
271@MainActor struct CopyAudioDiagnosticsButton: View {
272 /// "Copy audio diagnostics" in Test audio; in a call, where the screen's
273 /// account goes in too, "Copy call diagnostics".
274 var title = "Copy audio diagnostics"
275 let text: () -> String
276 @State private var copied = false
277
278 var body: some View {
279 HStack(spacing: 8) {
280 Button(title, systemImage: "doc.on.clipboard") {
281 AudioDiagnosticsReport.copy(text())
282 copied = true
283 }
284 .buttonStyle(.borderless)
285 .font(.footnote)
286 .accessibilityIdentifier("copyAudioDiagnostics")
287 .accessibilityHint("Copies device names, formats, levels and screen sharing's account, never audio or pictures, to paste into a message.")
288 if copied {
289 Text("Copied").font(.footnote).foregroundStyle(.secondary)
290 .accessibilityIdentifier("copyAudioDiagnosticsDone")
291 }
292 }
293 }
294}
295
296/// A level as a row of bars: the lit count is the level, in steps
297/// (MeterSteps), so it only redraws when a bar changes. With Reduce
298/// Motion the bars switch without animating. VoiceOver hears the level in
299/// words.
300@MainActor struct LevelMeter: View {
301 let step: Int
302 let label: String
303 @Environment(\.accessibilityReduceMotion) private var reduceMotion
304
305 var body: some View {
306 HStack(spacing: 3) {
307 ForEach(0..<MeterSteps.count, id: \.self) { i in
308 RoundedRectangle(cornerRadius: 2)
309 .fill(i < step ? color(i) : Color.secondary.opacity(0.2))
310 .frame(height: 10)
311 }
312 }
313 .frame(maxWidth: 320)
314 .animation(reduceMotion ? nil : .easeOut(duration: 0.08), value: step)
315 .accessibilityElement(children: .ignore)
316 .accessibilityLabel(label)
317 .accessibilityValue(MeterSteps.words(step))
318 }
319
320 private func color(_ i: Int) -> Color {
321 i >= MeterSteps.count - 1 ? .orange : CallsAppearance.accent
322 }
323}
324