Introduce multi-device EQ support allowing each audio output device (built-in speaker, wired, USB, individual Bluetooth by MAC) to have its own equalizer preset, combined with existing per-station presets for a full station×device matrix. - Add DispositivoAudio model and ServicioDispositivoAudio interface - Add Android platform channel (AudioDeviceCallback) for device detection - Add iOS AudioDevicesPlugin (AVAudioSession route tracking) - Extend ServicioEcualizador with device and matrix persistence keys - Implement 4-level resolution: matrix > station > device > global - Add advanced EQ settings section with feature toggle (off by default) - Extend export/import to v3 with backward compatibility - 184 tests passing, zero analyzer issues
167 lines
5.9 KiB
Swift
167 lines
5.9 KiB
Swift
import AVFoundation
|
|
import Flutter
|
|
|
|
/// Platform channel plugin that detects audio output devices on iOS via
|
|
/// AVAudioSession route change notifications.
|
|
///
|
|
/// Channel: "pluriwave/audio_devices"
|
|
/// - MethodChannel `getActiveDevice` → Dictionary with `id`, `type`, `name`
|
|
/// - EventChannel stream → same Dictionary on route change
|
|
///
|
|
/// Device ID format (matches spec):
|
|
/// "builtin_speaker" — AVAudioSession.Port.builtInSpeaker
|
|
/// "wired_headset" — AVAudioSession.Port.headphones / .headsetMic
|
|
/// "bt_a2dp:<uid|portName>" — AVAudioSession.Port.bluetoothA2DP
|
|
/// "usb_headset:<uid|name>" — AVAudioSession.Port.usbAudio
|
|
///
|
|
/// iOS uid fallback (spec scenario "iOS uid fallback on uid instability"):
|
|
/// Use portType+uid as the primary key; fall back to portType+portName
|
|
/// if uid is empty or nil to guarantee a non-empty, non-null key.
|
|
class AudioDevicesPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
|
|
|
private static let channelName = "pluriwave/audio_devices"
|
|
|
|
// Type int constants matching the Dart-side protocol (same as Android).
|
|
private static let typeBuiltinSpeaker = 2
|
|
private static let typeWiredHeadset = 3
|
|
private static let typeBluetoothA2dp = 8
|
|
private static let typeUsbHeadset = 14
|
|
private static let typeUnknown = 0
|
|
|
|
private var eventSink: FlutterEventSink?
|
|
|
|
// MARK: - Plugin registration
|
|
|
|
static func register(with registrar: FlutterPluginRegistrar) {
|
|
let messenger = registrar.messenger()
|
|
|
|
let instance = AudioDevicesPlugin()
|
|
|
|
let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: messenger)
|
|
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
|
|
|
let eventChannel = FlutterEventChannel(name: channelName, binaryMessenger: messenger)
|
|
eventChannel.setStreamHandler(instance)
|
|
|
|
registrar.addApplicationDelegate(instance)
|
|
}
|
|
|
|
// MARK: - MethodChannel
|
|
|
|
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
switch call.method {
|
|
case "getActiveDevice":
|
|
result(currentDeviceMap())
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
|
|
// MARK: - EventChannel (FlutterStreamHandler)
|
|
|
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
|
self.eventSink = events
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(routeChanged(_:)),
|
|
name: AVAudioSession.routeChangeNotification,
|
|
object: nil
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
|
NotificationCenter.default.removeObserver(
|
|
self,
|
|
name: AVAudioSession.routeChangeNotification,
|
|
object: nil
|
|
)
|
|
self.eventSink = nil
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Route change notification
|
|
|
|
@objc private func routeChanged(_ notification: Notification) {
|
|
guard let sink = eventSink else { return }
|
|
sink(currentDeviceMap())
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Returns a dictionary representing the current active audio output route.
|
|
private func currentDeviceMap() -> [String: Any] {
|
|
let session = AVAudioSession.sharedInstance()
|
|
let outputs = session.currentRoute.outputs
|
|
|
|
// Priority order: BT A2DP > USB > wired > built-in speaker > unknown
|
|
let priority: [AVAudioSession.Port] = [
|
|
.bluetoothA2DP,
|
|
.usbAudio,
|
|
.headphones,
|
|
.headsetMic,
|
|
.builtInSpeaker,
|
|
]
|
|
|
|
let best = outputs.min { a, b in
|
|
let ia = priority.firstIndex(of: a.portType) ?? Int.max
|
|
let ib = priority.firstIndex(of: b.portType) ?? Int.max
|
|
return ia < ib
|
|
}
|
|
|
|
return portToMap(best)
|
|
}
|
|
|
|
/// Converts an AVAudioSessionPortDescription to the channel map format.
|
|
private func portToMap(_ port: AVAudioSessionPortDescription?) -> [String: Any] {
|
|
guard let port = port else {
|
|
return ["id": "builtin_speaker", "type": AudioDevicesPlugin.typeBuiltinSpeaker, "name": "Speaker"]
|
|
}
|
|
|
|
switch port.portType {
|
|
case .builtInSpeaker:
|
|
return ["id": "builtin_speaker",
|
|
"type": AudioDevicesPlugin.typeBuiltinSpeaker,
|
|
"name": port.portName]
|
|
|
|
case .headphones, .headsetMic:
|
|
return ["id": "wired_headset",
|
|
"type": AudioDevicesPlugin.typeWiredHeadset,
|
|
"name": port.portName]
|
|
|
|
case .bluetoothA2DP:
|
|
let key = stableKey(prefix: "bt_a2dp", port: port)
|
|
return ["id": key,
|
|
"type": AudioDevicesPlugin.typeBluetoothA2dp,
|
|
"name": port.portName]
|
|
|
|
case .usbAudio:
|
|
let key = stableKey(prefix: "usb_headset", port: port)
|
|
return ["id": key,
|
|
"type": AudioDevicesPlugin.typeUsbHeadset,
|
|
"name": port.portName]
|
|
|
|
default:
|
|
// Unknown type — use portType string as part of id to avoid empty key.
|
|
let id = "unknown:\(port.portType.rawValue)"
|
|
return ["id": id,
|
|
"type": AudioDevicesPlugin.typeUnknown,
|
|
"name": port.portName]
|
|
}
|
|
}
|
|
|
|
/// Derives a stable device key using uid (preferred) or portName as fallback.
|
|
///
|
|
/// Per spec scenario "iOS uid fallback on uid instability": uid may differ
|
|
/// across sessions on some BT devices. In that case, use portType+portName.
|
|
private func stableKey(prefix: String, port: AVAudioSessionPortDescription) -> String {
|
|
let uid = port.uid
|
|
if !uid.isEmpty {
|
|
return "\(prefix):\(uid)"
|
|
}
|
|
// uid is empty — fall back to portType+portName (must not be empty per spec).
|
|
let name = port.portName.isEmpty ? "unknown" : port.portName
|
|
return "\(prefix):\(name)"
|
|
}
|
|
}
|