feat(ios): audio presets + advanced settings disclosure
Replaces the flat list of audio settings with a preset picker that shows context-appropriate options based on whether a Bluetooth device is connected. Presets: - Default (Phone Speaker): built-in mic + speaker, standard, mono - Bluetooth Headset (HFP): BT mic + BT output, standard, mono — only shown when a BT device is connected - BT Headphones + Phone Mic: A2DP stereo output + built-in mic, standard, mono — only shown when BT connected - BT Headphones + Stereo Mic: A2DP stereo output + built-in mic stereo (front+back capsules), standard, stereo — only shown when BT connected - Custom: shown when advanced settings don't match any preset When no Bluetooth device is connected, only 'Default' and 'Custom' appear, with a hint to connect Bluetooth headphones for more options. All granular controls (input port, orientation, polar pattern, mic mode, channels, bluetooth mode, output route, AirPlay) are now under an 'Advanced Audio' disclosure group, collapsed by default. IOSAudioRouter gains: - AudioPreset enum with bluetoothMode/captureChannels/micMode/usesBuiltInMic - hasBluetoothDevice detection (checks currentRoute + availableInputs for bluetoothA2DP/bluetoothHFP port types) - availablePresets (filtered by BT connection state) - activePreset (computed from current settings) - applyPreset() (sets all individual settings + finds built-in mic port UID)
This commit is contained in:
@@ -6,142 +6,162 @@ 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 Input
|
||||
Section("Audio Input") {
|
||||
// Input port picker (AVAudioSession.availableInputs)
|
||||
Picker("Input Port", selection: Binding(
|
||||
get: { router.selectedInputPortId ?? "" },
|
||||
set: { id in
|
||||
if !id.isEmpty { router.selectInputPort(id) }
|
||||
}
|
||||
// MARK: - Audio Preset
|
||||
Section("Audio") {
|
||||
Picker("Preset", selection: Binding(
|
||||
get: { router.activePreset },
|
||||
set: { preset in router.applyPreset(preset) }
|
||||
)) {
|
||||
Text("Default").tag("")
|
||||
ForEach(router.inputPorts) { port in
|
||||
Text(port.name).tag(port.id)
|
||||
ForEach(router.availablePresets) { preset in
|
||||
Text(preset.rawValue).tag(preset)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Audio input port selection")
|
||||
.accessibilityLabel("Audio preset")
|
||||
|
||||
// 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 ?? "" },
|
||||
if !router.hasBluetoothDevice {
|
||||
Text("Connect Bluetooth headphones to see Bluetooth presets.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("No Bluetooth 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.selectDataSource(id) }
|
||||
if !id.isEmpty { router.selectInputPort(id) }
|
||||
}
|
||||
)) {
|
||||
Text("Default").tag("")
|
||||
ForEach(dataSources) { ds in
|
||||
Text(ds.name).tag(ds.id)
|
||||
ForEach(router.inputPorts) { port in
|
||||
Text(port.name).tag(port.id)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Microphone orientation")
|
||||
.accessibilityLabel("Audio input port selection")
|
||||
|
||||
// Polar pattern sub-picker (only if the data source supports patterns)
|
||||
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) }
|
||||
// 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(patterns, id: \.self) { pattern in
|
||||
Text(polarPatternLabel(pattern)).tag(pattern)
|
||||
ForEach(dataSources) { ds in
|
||||
Text(ds.name).tag(ds.id)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Microphone polar pattern")
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Microphone orientation")
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// MARK: - Audio Output
|
||||
Section("Audio Output") {
|
||||
// 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)
|
||||
// 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")
|
||||
}
|
||||
.accessibilityLabel("Current output: \(route.name)")
|
||||
}
|
||||
} else {
|
||||
Text("No output route")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// AirPlay button
|
||||
HStack {
|
||||
Text("AirPlay")
|
||||
Spacer()
|
||||
RoutePickerButton()
|
||||
// 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")
|
||||
}
|
||||
.accessibilityLabel("AirPlay output selector")
|
||||
}
|
||||
|
||||
// MARK: - Voice
|
||||
|
||||
Reference in New Issue
Block a user