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
@@ -6,6 +6,9 @@ import android.content.ClipData
import android.content.Intent
import android.content.ActivityNotFoundException
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri
import android.media.audiofx.Visualizer
import android.app.AlarmManager
@@ -31,6 +34,7 @@ class MainActivity : AudioServiceActivity() {
private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions"
private val audioDevicesChannel = "pluriwave/audio_devices"
private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822
private var visualizer: Visualizer? = null
@@ -39,8 +43,16 @@ class MainActivity : AudioServiceActivity() {
private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper())
// Audio devices channel state
private var audioDevicesSink: EventChannel.EventSink? = null
private var audioDeviceCallback: AudioDeviceCallback? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// --- Audio Devices Channel ---
setupAudioDevicesChannel(flutterEngine)
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
visualizerChannel
@@ -601,10 +613,149 @@ class MainActivity : AudioServiceActivity() {
}
}
// -------------------------------------------------------------------------
// Audio Devices Channel
// -------------------------------------------------------------------------
private fun setupAudioDevicesChannel(flutterEngine: FlutterEngine) {
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, audioDevicesChannel).setMethodCallHandler { call, result ->
when (call.method) {
"getActiveDevice" -> {
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.getActiveDevice -> $device")
result.success(device)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, audioDevicesChannel).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
audioDevicesSink = events
registerAudioDeviceCallback()
}
override fun onCancel(arguments: Any?) {
unregisterAudioDeviceCallback()
audioDevicesSink = null
}
}
)
}
private fun registerAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val callback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
// Emit the current active output device when something connects.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onDevicesAdded active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
// Emit the new active device after something disconnects.
val device = getActiveAudioDevice()
Log.d(tag, "audio_devices.onDevicesRemoved active=$device")
mainHandler.post { audioDevicesSink?.success(device) }
}
}
audioDeviceCallback = callback
audioManager.registerAudioDeviceCallback(callback, mainHandler)
}
private fun unregisterAudioDeviceCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) }
audioDeviceCallback = null
}
/**
* Returns a map describing the current active audio output device.
*
* Device ID format (matches spec):
* - "builtin_speaker" — TYPE_BUILTIN_SPEAKER (2)
* - "wired_headset" — TYPE_WIRED_HEADSET (3) or TYPE_WIRED_HEADPHONES (4)
* - "bt_a2dp:<MAC>" — TYPE_BLUETOOTH_A2DP (8); MAC from AudioDeviceInfo.address
* - "usb_headset:<address>" — TYPE_USB_HEADSET (14)
* - "builtin_speaker" — fallback when API < 23
*
* Type int values sent to Dart match the AudioDeviceInfo.TYPE_* constants.
*/
private fun getActiveAudioDevice(): Map<String, Any> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
// Priority: BT A2DP > USB headset > wired headset > built-in speaker > unknown
val priorityOrder = listOf(
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER,
)
val best = outputs
.filter { it.isSink }
.sortedBy { device ->
val idx = priorityOrder.indexOf(device.type)
if (idx == -1) Int.MAX_VALUE else idx
}
.firstOrNull()
return deviceToMap(best)
}
private fun deviceToMap(device: AudioDeviceInfo?): Map<String, Any> {
if (device == null) {
return mapOf("id" to "builtin_speaker", "type" to 2, "name" to "Speaker")
}
return when (device.type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER ->
mapOf("id" to "builtin_speaker", "type" to 2, "name" to (device.productName?.toString() ?: "Speaker"))
AudioDeviceInfo.TYPE_WIRED_HEADSET ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headset"))
AudioDeviceInfo.TYPE_WIRED_HEADPHONES ->
mapOf("id" to "wired_headset", "type" to 3, "name" to (device.productName?.toString() ?: "Wired Headphones"))
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> {
val mac = device.address?.takeIf { it.isNotBlank() } ?: "00:00:00:00:00:00"
mapOf(
"id" to "bt_a2dp:$mac",
"type" to 8,
"name" to (device.productName?.toString() ?: "Bluetooth"),
)
}
AudioDeviceInfo.TYPE_USB_HEADSET -> {
val addr = device.address?.takeIf { it.isNotBlank() } ?: device.id.toString()
mapOf(
"id" to "usb_headset:$addr",
"type" to 14,
"name" to (device.productName?.toString() ?: "USB Headset"),
)
}
else ->
mapOf(
"id" to "builtin_speaker",
"type" to device.type,
"name" to (device.productName?.toString() ?: "Unknown"),
)
}
}
// -------------------------------------------------------------------------
override fun onDestroy() {
if (activeInstance === this) {
activeInstance = null
}
unregisterAudioDeviceCallback()
stopVisualizer()
super.onDestroy()
}