import SwiftUI import AVKit import VoiceCatCore struct SettingsView: View { @Environment(AppState.self) private var appState @Bindable var session: SessionState @StateObject private var router = IOSAudioRouter.shared @State private var showAdvanced = false var body: some View { NavigationStack { Form { // MARK: - Audio Preset Section("Audio") { Picker("Preset", selection: Binding( get: { router.activePreset }, set: { preset in router.applyPreset(preset) } )) { ForEach(router.availablePresets) { preset in Text(preset.rawValue).tag(preset) } } .accessibilityLabel("Audio preset") Toggle("Speaker output", isOn: Binding( get: { router.forceSpeaker }, set: { router.setForceSpeaker($0) } )) .accessibilityLabel("Speaker output") .accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.") // Surface the voice-processing state. On the AEC presets the native iOS // Voice-Processing unit (VPIO) does echo cancellation, noise suppression and // automatic gain control; the other presets (stereo/studio/A2DP) can't use it. if router.currentConfigUsesVoiceProcessing { Label("Echo cancellation & noise suppression on (iOS voice processing)", systemImage: "waveform.badge.mic") .font(.caption) .foregroundStyle(.secondary) .accessibilityLabel("Echo cancellation and noise suppression are on") } else { Label("No echo cancellation in this preset (stereo / studio / A2DP)", systemImage: "waveform.slash") .font(.caption) .foregroundStyle(.secondary) .accessibilityLabel("Echo cancellation is off in this preset") } if !router.hasBluetoothDevice && !router.hasWiredHeadset { Text("Connect Bluetooth headphones or a wired headset for more presets.") .font(.caption) .foregroundStyle(.secondary) .accessibilityLabel("No external audio device connected") } } // MARK: - Advanced Audio Section { DisclosureGroup("Advanced Audio", isExpanded: $showAdvanced) { // Input port picker (AVAudioSession.availableInputs) Picker("Input Port", selection: Binding( get: { router.selectedInputPortId ?? "" }, set: { id in if !id.isEmpty { router.selectInputPort(id) } } )) { Text("Default").tag("") ForEach(router.inputPorts) { port in Text(port.name).tag(port.id) } } .accessibilityLabel("Audio input port selection") // Built-in mic sub-options: orientation (data source) + polar pattern if router.selectedPortIsBuiltInMic, let dataSources = router.selectedPortDataSources, !dataSources.isEmpty { Picker("Mic Orientation", selection: Binding( get: { router.selectedDataSourceId ?? "" }, set: { id in if !id.isEmpty { router.selectDataSource(id) } } )) { Text("Default").tag("") ForEach(dataSources) { ds in Text(ds.name).tag(ds.id) } } .accessibilityLabel("Microphone orientation") // Polar pattern sub-picker if let selectedDs = dataSources.first(where: { $0.id == router.selectedDataSourceId }), let patterns = selectedDs.polarPatterns, !patterns.isEmpty { Picker("Polar Pattern", selection: Binding( get: { router.selectedPolarPattern ?? "" }, set: { pattern in if !pattern.isEmpty { router.selectPolarPattern(pattern) } } )) { Text("Default").tag("") ForEach(patterns, id: \.self) { pattern in Text(polarPatternLabel(pattern)).tag(pattern) } } .accessibilityLabel("Microphone polar pattern") } } // Mic processing mode: Standard vs Raw/Studio Picker("Mic Mode", selection: Binding( get: { router.micMode }, set: { router.selectMicMode($0) } )) { ForEach(IOSAudioRouter.MicMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .accessibilityLabel("Microphone processing mode") if router.showsRawModeSpeakerWarning { Label( "Raw mode on speaker — echo risk (no AEC)", systemImage: "exclamationmark.triangle.fill" ) .foregroundStyle(.orange) .font(.caption) .accessibilityLabel("Warning: Raw mode with speaker output may cause echo") } if router.showsA2dpNoAecWarning { Label( "A2DP mode — no echo cancellation (hardware AEC unavailable)", systemImage: "info.circle.fill" ) .foregroundStyle(.blue) .font(.caption) .accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation") } // Capture channels: Mono vs Stereo Picker("Channels", selection: Binding( get: { router.captureChannels }, set: { router.selectCaptureChannels($0) } )) { ForEach(IOSAudioRouter.CaptureChannels.allCases) { ch in Text(ch.rawValue).tag(ch) } } .accessibilityLabel("Capture channel count") // Bluetooth mode Picker("Bluetooth Mode", selection: Binding( get: { router.bluetoothMode }, set: { router.selectBluetoothMode($0) } )) { ForEach(IOSAudioRouter.BluetoothMode.allCases) { mode in Text(mode.rawValue).tag(mode) } } .accessibilityLabel("Bluetooth audio mode") // Current output route (read-only) if !router.outputRoutes.isEmpty { ForEach(router.outputRoutes) { route in HStack { Text(route.name) Spacer() Text(route.portType) .foregroundStyle(.secondary) .font(.caption) } .accessibilityLabel("Current output: \(route.name)") } } else { Text("No output route") .foregroundStyle(.secondary) } // AirPlay button HStack { Text("AirPlay") Spacer() RoutePickerButton() } .accessibilityLabel("AirPlay output selector") } } // MARK: - Voice Section("Voice") { Picker("Input Mode", selection: Binding( get: { session.voiceState.inputMode }, set: { session.setInputMode($0) } )) { Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation) Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk) Text("Always On").tag(VoiceCatInputMode.alwaysOn) } .accessibilityLabel("Voice input mode") if session.voiceState.inputMode == .voiceActivation { VStack(alignment: .leading, spacing: 4) { Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))") .font(.caption) Slider( value: Binding( get: { Double(session.voiceState.vadThreshold) }, set: { session.setVadThreshold(Float($0)) } ), in: 0.001...0.1, step: 0.001 ) .accessibilityLabel("Voice activation threshold") } } } // MARK: - Admin if session.permissions.canAdminAccounts || session.permissions.isAdmin { Section("Administration") { NavigationLink("Manage Accounts") { AccountsView(session: session) } .accessibilityLabel("Manage server accounts") } } // MARK: - Server Section("Server") { Button(role: .destructive) { session.stopMicStream() appState.disconnect() } label: { Label("Disconnect", systemImage: "phone.down") .foregroundStyle(.red) } .accessibilityLabel("Disconnect from server") } // MARK: - About Section("About") { Text(VoiceCatClient.versionString) .font(.caption) .foregroundStyle(.secondary) .accessibilityLabel("Version: \(VoiceCatClient.versionString)") } } .navigationTitle("Settings") .onAppear { router.refreshRoutes() } } } /// Human-readable label for AVAudioSession.PolarPattern raw values. private func polarPatternLabel(_ rawValue: String) -> String { switch rawValue { case AVAudioSession.PolarPattern.omnidirectional.rawValue: return "Omnidirectional" case AVAudioSession.PolarPattern.cardioid.rawValue: return "Cardioid" case AVAudioSession.PolarPattern.subcardioid.rawValue: return "Subcardioid" default: return rawValue } } } /// SwiftUI wrapper for AVRoutePickerView (AVKit's UIView for AirPlay route selection). private struct RoutePickerButton: UIViewRepresentable { func makeUIView(context: Context) -> AVRoutePickerView { let view = AVRoutePickerView() view.tintColor = .systemBlue return view } func updateUIView(_ uiView: AVRoutePickerView, context: Context) {} }