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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user