feat(eq): add per-device equalizer with 4-level preset resolution

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
This commit is contained in:
2026-06-27 11:33:53 +02:00
parent 8f42e67b48
commit 4632d53eb8
36 changed files with 2850 additions and 64 deletions
+1
View File
@@ -12,5 +12,6 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!)
}
}
+166
View File
@@ -0,0 +1,166 @@
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)"
}
}