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:
@@ -12,5 +12,6 @@ import UIKit
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
AudioDevicesPlugin.register(with: engineBridge.pluginRegistry.registrar(forPlugin: "AudioDevicesPlugin")!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user