From 163ff69f7a4cf4cb16ac7648ce38b65eaffc323a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Bautista=20Fern=C3=A1ndez?= Date: Wed, 22 Jul 2026 10:26:02 +0200 Subject: [PATCH] feat(eq): android auto custom equalizer and robust device detection - MainActivity: onListen re-emits the current active device and registers the audio device callback idempotently, so recreated activities resync instead of freezing the active-device id on a disconnected device. - servicio_dispositivo_audio: resubscribir() re-opens the event channel; estado_ecualizador exposes refrescarDispositivoActual() with an in-flight guard, invoked on app resume and when opening advanced EQ options, clearing stale green-dot device selections. - navegacion_auto/servicio_audio: new 'Personalizado' browse tree in Android Auto (5 band folders, 13 gain steps each) applied live via setBanda; preset and gain taps persist at device level when multi-device EQ is active and respect station/matrix overrides, with apply-before-persist ordering and children-changed notifications. - l10n: regenerate stale generated localizations; add rxdart as direct dependency for the subscribeToChildren override. --- .codegraph/.gitignore | 5 + .../es/freetimelab/pluriwave/MainActivity.kt | 12 + lib/app.dart | 20 +- lib/estado/estado_ecualizador.dart | 41 +- lib/l10n/gen/app_localizations.dart | 54 ++ lib/l10n/gen/app_localizations_ar.dart | 38 +- lib/l10n/gen/app_localizations_bn.dart | 38 +- lib/l10n/gen/app_localizations_de.dart | 30 + lib/l10n/gen/app_localizations_en.dart | 30 + lib/l10n/gen/app_localizations_es.dart | 30 + lib/l10n/gen/app_localizations_fr.dart | 30 + lib/l10n/gen/app_localizations_hi.dart | 38 +- lib/l10n/gen/app_localizations_id.dart | 30 + lib/l10n/gen/app_localizations_it.dart | 30 + lib/l10n/gen/app_localizations_ja.dart | 38 +- lib/l10n/gen/app_localizations_pt.dart | 30 + lib/l10n/gen/app_localizations_ru.dart | 38 +- lib/l10n/gen/app_localizations_zh.dart | 37 +- lib/pantallas/pantalla_ajustes.dart | 19 +- lib/servicios/navegacion_auto.dart | 289 +++++++- lib/servicios/servicio_audio.dart | 147 ++++- lib/servicios/servicio_dispositivo_audio.dart | 33 + pubspec.lock | 10 +- pubspec.yaml | 3 + test/estado/estado_ecualizador_test.dart | 151 +++++ test/helpers/fakes.dart | 25 + test/servicios/navegacion_auto_test.dart | 620 ++++++++++++++++++ .../servicio_dispositivo_audio_real_test.dart | 56 ++ ...ervicio_dispositivo_audio_toggle_test.dart | 3 + 29 files changed, 1883 insertions(+), 42 deletions(-) create mode 100644 .codegraph/.gitignore diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index c42430a..7e35769 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -1014,6 +1014,15 @@ class MainActivity : AudioServiceActivity() { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { audioDevicesSink = events registerAudioDeviceCallback() + // Immediate resync: emit the current active device on every + // (re)subscription. The Flutter engine outlives the Activity + // (AudioServiceActivity), so a recreated Activity installs a + // fresh StreamHandler that never sees a "listen" until Dart + // resubscribes — without this emission the Dart side would + // keep a stale device until the next physical connect event. + val device = getActiveAudioDevice() + Log.d(tag, "audio_devices.onListen -> $device") + events?.success(device) } override fun onCancel(arguments: Any?) { @@ -1041,6 +1050,9 @@ class MainActivity : AudioServiceActivity() { private fun registerAudioDeviceCallback() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + // Idempotent: a re-listen without a prior cancel must not leak the + // previously registered callback. + audioDeviceCallback?.let { audioManager.unregisterAudioDeviceCallback(it) } val callback = object : AudioDeviceCallback() { override fun onAudioDevicesAdded(addedDevices: Array) { // Emit the current active output device when something connects. diff --git a/lib/app.dart b/lib/app.dart index 29099ff..1cc8a77 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -96,7 +96,8 @@ class _PaginaPrincipal extends StatefulWidget { State<_PaginaPrincipal> createState() => _PaginaPrincipalState(); } -class _PaginaPrincipalState extends State<_PaginaPrincipal> { +class _PaginaPrincipalState extends State<_PaginaPrincipal> + with WidgetsBindingObserver { int _indice = 0; StreamSubscription? _errorSubscription; StreamSubscription? _alarmaSubscription; @@ -124,6 +125,22 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings), ]; + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + // Fix "stale green dot": on return to foreground the Activity may have + // been recreated over the cached engine, leaving the device event channel + // without a live native sink. Re-subscribe and re-seed the active device + // (no-op when multi-device EQ is off). + unawaited(context.read().refrescarDispositivoActual()); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -179,6 +196,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _errorSubscription?.cancel(); _alarmaSubscription?.cancel(); _alarmaVencidaSubscription?.cancel(); diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index 2d9559d..a6fdaaa 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -26,12 +26,6 @@ import '../servicios/servicio_ecualizador.dart'; /// When the toggle is false, resolution falls back to the original 2-level /// hierarchy (station → global) — zero behavioral change vs. prior releases. class EstadoEcualizador extends ChangeNotifier { - /// Composite-placeholder id prefix (bt-device-identity ADR-6): marks a - /// device whose real Bluetooth MAC is not yet known (BLUETOOTH_CONNECT - /// denied or unresolved). Devices with this prefix still update the - /// platform-name cache but never auto-create a `presetsDispositivo` entry. - static const _prefijoPlaceholderCompuesto = 'bt_a2dp:name:'; - EstadoEcualizador({ required this.audio, ServicioEcualizador? servicio, @@ -73,6 +67,7 @@ class EstadoEcualizador extends ChangeNotifier { bool _eqMultiDeviceEnabled = false; String? _dispositivoActualId; StreamSubscription? _deviceSub; + Future? _refrescoEnCurso; PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetPrincipal => _presetPrincipal; @@ -208,6 +203,38 @@ class EstadoEcualizador extends ChangeNotifier { } } + /// Re-syncs the active device after a possibly-missed native resync + /// (activity recreation over the cached engine, return to foreground, + /// opening the settings section): re-subscribes the platform event channel + /// via [ServicioDispositivoAudio.resubscribir] — re-registering the native + /// callback on the CURRENT activity — and re-seeds [_dispositivoActualId] + /// with a fresh query. No-op when the multi-device toggle is off or no + /// device service is injected; safe to call repeatedly. Concurrent calls + /// (app-resume observer + settings initState) share the same in-flight + /// refresh instead of racing [ServicioDispositivoAudio.resubscribir], + /// which would leak a native AudioDeviceCallback. + Future refrescarDispositivoActual() { + final enCurso = _refrescoEnCurso; + if (enCurso != null) return enCurso; + final refresco = _refrescarDispositivoActual().whenComplete(() { + _refrescoEnCurso = null; + }); + _refrescoEnCurso = refresco; + return refresco; + } + + Future _refrescarDispositivoActual() async { + if (!_eqMultiDeviceEnabled) return; + final svc = _dispositivoAudio; + if (svc == null) return; + try { + await svc.resubscribir(); + } catch (_) { + // A failed resubscribe must never block the fresh-query re-seed below. + } + await _sembrarDispositivoActual(); + } + /// Subscribes to the device change stream if multi-device is enabled. void _configurarSuscripcionDispositivo() { _deviceSub?.cancel(); @@ -239,7 +266,7 @@ class EstadoEcualizador extends ChangeNotifier { // resolves to the eventual real-MAC id. final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno; final esPlaceholderCompuesto = dispositivo.id.startsWith( - _prefijoPlaceholderCompuesto, + prefijoPlaceholderBtName, ); if (!esBase && !esPlaceholderCompuesto && diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index eb07965..3f6910e 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2395,6 +2395,60 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Conectado'** String get eqDeviceConnected; + + /// No description provided for @localMusicSectionTitle. + /// + /// In es, this message translates to: + /// **'Música local (Android Auto)'** + String get localMusicSectionTitle; + + /// No description provided for @localMusicSectionDescription. + /// + /// In es, this message translates to: + /// **'Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.'** + String get localMusicSectionDescription; + + /// No description provided for @localMusicFolderNotConfigured. + /// + /// In es, this message translates to: + /// **'No hay carpeta seleccionada'** + String get localMusicFolderNotConfigured; + + /// No description provided for @localMusicFolderTitle. + /// + /// In es, this message translates to: + /// **'Carpeta de música local'** + String get localMusicFolderTitle; + + /// No description provided for @localMusicChoosePath. + /// + /// In es, this message translates to: + /// **'Elegir carpeta'** + String get localMusicChoosePath; + + /// No description provided for @localMusicChangePath. + /// + /// In es, this message translates to: + /// **'Cambiar carpeta'** + String get localMusicChangePath; + + /// No description provided for @localMusicFolderUpdated. + /// + /// In es, this message translates to: + /// **'Carpeta de música local actualizada'** + String get localMusicFolderUpdated; + + /// No description provided for @localMusicFolderSaveError. + /// + /// In es, this message translates to: + /// **'No se pudo guardar la carpeta: {error}'** + String localMusicFolderSaveError(Object error); + + /// No description provided for @localMusicFolderGenericName. + /// + /// In es, this message translates to: + /// **'Carpeta seleccionada'** + String get localMusicFolderGenericName; } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index c2ef6d4..c4978f4 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -63,22 +63,22 @@ class AppLocalizationsAr extends AppLocalizations { Object minutes, Object seconds, ) { - return '$hours ? $minutes ? $seconds ?'; + return '$hours س $minutes د $seconds ث'; } @override String durationMinutesSeconds(Object minutes, Object seconds) { - return '$minutes ? $seconds ?'; + return '$minutes د $seconds ث'; } @override String durationMinutesOnly(Object minutes) { - return '$minutes ?'; + return '$minutes د'; } @override String durationSecondsOnly(Object seconds) { - return '$seconds ?'; + return '$seconds ث'; } @override @@ -1297,4 +1297,34 @@ class AppLocalizationsAr extends AppLocalizations { @override String get eqDeviceConnected => 'متصل'; + + @override + String get localMusicSectionTitle => 'الموسيقى المحلية (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.'; + + @override + String get localMusicFolderNotConfigured => 'لم يتم تحديد مجلد'; + + @override + String get localMusicFolderTitle => 'مجلد الموسيقى المحلية'; + + @override + String get localMusicChoosePath => 'اختيار مجلد'; + + @override + String get localMusicChangePath => 'تغيير المجلد'; + + @override + String get localMusicFolderUpdated => 'تم تحديث مجلد الموسيقى المحلية'; + + @override + String localMusicFolderSaveError(Object error) { + return 'تعذّر حفظ المجلد: $error'; + } + + @override + String get localMusicFolderGenericName => 'مجلد محدد'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index aeac725..ecd6b3a 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -64,22 +64,22 @@ class AppLocalizationsBn extends AppLocalizations { Object minutes, Object seconds, ) { - return '$hours ? $minutes ?? $seconds ??'; + return '$hours ঘণ্টা $minutes মিনিট $seconds সেকেন্ড'; } @override String durationMinutesSeconds(Object minutes, Object seconds) { - return '$minutes ?? $seconds ??'; + return '$minutes মিনিট $seconds সেকেন্ড'; } @override String durationMinutesOnly(Object minutes) { - return '$minutes ??'; + return '$minutes মিনিট'; } @override String durationSecondsOnly(Object seconds) { - return '$seconds ??'; + return '$seconds সেকেন্ড'; } @override @@ -1305,4 +1305,34 @@ class AppLocalizationsBn extends AppLocalizations { @override String get eqDeviceConnected => 'সংযুক্ত'; + + @override + String get localMusicSectionTitle => 'স্থানীয় সঙ্গীত (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।'; + + @override + String get localMusicFolderNotConfigured => 'কোনো ফোল্ডার নির্বাচিত হয়নি'; + + @override + String get localMusicFolderTitle => 'স্থানীয় সঙ্গীত ফোল্ডার'; + + @override + String get localMusicChoosePath => 'ফোল্ডার বেছে নিন'; + + @override + String get localMusicChangePath => 'ফোল্ডার পরিবর্তন করুন'; + + @override + String get localMusicFolderUpdated => 'স্থানীয় সঙ্গীত ফোল্ডার আপডেট হয়েছে'; + + @override + String localMusicFolderSaveError(Object error) { + return 'ফোল্ডার সংরক্ষণ করা যায়নি: $error'; + } + + @override + String get localMusicFolderGenericName => 'নির্বাচিত ফোল্ডার'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index e380be1..0d88696 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1315,4 +1315,34 @@ class AppLocalizationsDe extends AppLocalizations { @override String get eqDeviceConnected => 'Verbunden'; + + @override + String get localMusicSectionTitle => 'Lokale Musik (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Wähle einen Ordner auf diesem Gerät aus, um dessen Audiodateien im Auto zu durchsuchen und abzuspielen.'; + + @override + String get localMusicFolderNotConfigured => 'Kein Ordner ausgewählt'; + + @override + String get localMusicFolderTitle => 'Ordner für lokale Musik'; + + @override + String get localMusicChoosePath => 'Ordner auswählen'; + + @override + String get localMusicChangePath => 'Ordner ändern'; + + @override + String get localMusicFolderUpdated => 'Ordner für lokale Musik aktualisiert'; + + @override + String localMusicFolderSaveError(Object error) { + return 'Ordner konnte nicht gespeichert werden: $error'; + } + + @override + String get localMusicFolderGenericName => 'Ausgewählter Ordner'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index b56f0e1..1953649 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1301,4 +1301,34 @@ class AppLocalizationsEn extends AppLocalizations { @override String get eqDeviceConnected => 'Connected'; + + @override + String get localMusicSectionTitle => 'Local music (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Pick a folder on this device to browse and play its audio files from the car.'; + + @override + String get localMusicFolderNotConfigured => 'No folder selected'; + + @override + String get localMusicFolderTitle => 'Local music folder'; + + @override + String get localMusicChoosePath => 'Choose folder'; + + @override + String get localMusicChangePath => 'Change folder'; + + @override + String get localMusicFolderUpdated => 'Local music folder updated'; + + @override + String localMusicFolderSaveError(Object error) { + return 'Could not save the folder: $error'; + } + + @override + String get localMusicFolderGenericName => 'Selected folder'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 8a700d8..8c9fdeb 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1310,4 +1310,34 @@ class AppLocalizationsEs extends AppLocalizations { @override String get eqDeviceConnected => 'Conectado'; + + @override + String get localMusicSectionTitle => 'Música local (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.'; + + @override + String get localMusicFolderNotConfigured => 'No hay carpeta seleccionada'; + + @override + String get localMusicFolderTitle => 'Carpeta de música local'; + + @override + String get localMusicChoosePath => 'Elegir carpeta'; + + @override + String get localMusicChangePath => 'Cambiar carpeta'; + + @override + String get localMusicFolderUpdated => 'Carpeta de música local actualizada'; + + @override + String localMusicFolderSaveError(Object error) { + return 'No se pudo guardar la carpeta: $error'; + } + + @override + String get localMusicFolderGenericName => 'Carpeta seleccionada'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index a94fdfa..7016594 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1320,4 +1320,34 @@ class AppLocalizationsFr extends AppLocalizations { @override String get eqDeviceConnected => 'Connecté'; + + @override + String get localMusicSectionTitle => 'Musique locale (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Choisissez un dossier sur cet appareil pour parcourir et lire ses fichiers audio depuis la voiture.'; + + @override + String get localMusicFolderNotConfigured => 'Aucun dossier sélectionné'; + + @override + String get localMusicFolderTitle => 'Dossier de musique locale'; + + @override + String get localMusicChoosePath => 'Choisir un dossier'; + + @override + String get localMusicChangePath => 'Changer de dossier'; + + @override + String get localMusicFolderUpdated => 'Dossier de musique locale mis à jour'; + + @override + String localMusicFolderSaveError(Object error) { + return 'Impossible d’enregistrer le dossier : $error'; + } + + @override + String get localMusicFolderGenericName => 'Dossier sélectionné'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 6c3146d..1b3ec2c 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -63,22 +63,22 @@ class AppLocalizationsHi extends AppLocalizations { Object minutes, Object seconds, ) { - return '$hours ?? $minutes ?? $seconds ??'; + return '$hours घंटे $minutes मिनट $seconds सेकंड'; } @override String durationMinutesSeconds(Object minutes, Object seconds) { - return '$minutes ?? $seconds ??'; + return '$minutes मिनट $seconds सेकंड'; } @override String durationMinutesOnly(Object minutes) { - return '$minutes ??'; + return '$minutes मिनट'; } @override String durationSecondsOnly(Object seconds) { - return '$seconds ??'; + return '$seconds सेकंड'; } @override @@ -1303,4 +1303,34 @@ class AppLocalizationsHi extends AppLocalizations { @override String get eqDeviceConnected => 'कनेक्टेड'; + + @override + String get localMusicSectionTitle => 'स्थानीय संगीत (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।'; + + @override + String get localMusicFolderNotConfigured => 'कोई फ़ोल्डर चुना नहीं गया'; + + @override + String get localMusicFolderTitle => 'स्थानीय संगीत फ़ोल्डर'; + + @override + String get localMusicChoosePath => 'फ़ोल्डर चुनें'; + + @override + String get localMusicChangePath => 'फ़ोल्डर बदलें'; + + @override + String get localMusicFolderUpdated => 'स्थानीय संगीत फ़ोल्डर अपडेट हुआ'; + + @override + String localMusicFolderSaveError(Object error) { + return 'फ़ोल्डर सहेजा नहीं जा सका: $error'; + } + + @override + String get localMusicFolderGenericName => 'चयनित फ़ोल्डर'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 06db23a..1e3919e 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1309,4 +1309,34 @@ class AppLocalizationsId extends AppLocalizations { @override String get eqDeviceConnected => 'Terhubung'; + + @override + String get localMusicSectionTitle => 'Musik lokal (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Pilih folder di perangkat ini untuk menjelajahi dan memutar file audio di dalamnya dari mobil.'; + + @override + String get localMusicFolderNotConfigured => 'Belum ada folder dipilih'; + + @override + String get localMusicFolderTitle => 'Folder musik lokal'; + + @override + String get localMusicChoosePath => 'Pilih folder'; + + @override + String get localMusicChangePath => 'Ubah folder'; + + @override + String get localMusicFolderUpdated => 'Folder musik lokal diperbarui'; + + @override + String localMusicFolderSaveError(Object error) { + return 'Tidak dapat menyimpan folder: $error'; + } + + @override + String get localMusicFolderGenericName => 'Folder terpilih'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 41e5b7f..37b9f2b 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1315,4 +1315,34 @@ class AppLocalizationsIt extends AppLocalizations { @override String get eqDeviceConnected => 'Connesso'; + + @override + String get localMusicSectionTitle => 'Musica locale (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Scegli una cartella su questo dispositivo per sfogliare e riprodurre i suoi file audio dall\'auto.'; + + @override + String get localMusicFolderNotConfigured => 'Nessuna cartella selezionata'; + + @override + String get localMusicFolderTitle => 'Cartella musica locale'; + + @override + String get localMusicChoosePath => 'Scegli cartella'; + + @override + String get localMusicChangePath => 'Cambia cartella'; + + @override + String get localMusicFolderUpdated => 'Cartella musica locale aggiornata'; + + @override + String localMusicFolderSaveError(Object error) { + return 'Impossibile salvare la cartella: $error'; + } + + @override + String get localMusicFolderGenericName => 'Cartella selezionata'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 7192358..37d4a6d 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -62,22 +62,22 @@ class AppLocalizationsJa extends AppLocalizations { Object minutes, Object seconds, ) { - return '$hours?? $minutes? $seconds?'; + return '$hours時間$minutes分$seconds秒'; } @override String durationMinutesSeconds(Object minutes, Object seconds) { - return '$minutes? $seconds?'; + return '$minutes分$seconds秒'; } @override String durationMinutesOnly(Object minutes) { - return '$minutes?'; + return '$minutes分'; } @override String durationSecondsOnly(Object seconds) { - return '$seconds?'; + return '$seconds秒'; } @override @@ -1263,4 +1263,34 @@ class AppLocalizationsJa extends AppLocalizations { @override String get eqDeviceConnected => '接続中'; + + @override + String get localMusicSectionTitle => 'ローカル音楽(Android Auto)'; + + @override + String get localMusicSectionDescription => + 'この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。'; + + @override + String get localMusicFolderNotConfigured => 'フォルダーが選択されていません'; + + @override + String get localMusicFolderTitle => 'ローカル音楽フォルダー'; + + @override + String get localMusicChoosePath => 'フォルダーを選択'; + + @override + String get localMusicChangePath => 'フォルダーを変更'; + + @override + String get localMusicFolderUpdated => 'ローカル音楽フォルダーを更新しました'; + + @override + String localMusicFolderSaveError(Object error) { + return 'フォルダーを保存できませんでした: $error'; + } + + @override + String get localMusicFolderGenericName => '選択したフォルダー'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 16e3606..b3bb647 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1307,4 +1307,34 @@ class AppLocalizationsPt extends AppLocalizations { @override String get eqDeviceConnected => 'Conectado'; + + @override + String get localMusicSectionTitle => 'Música local (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Escolha uma pasta neste dispositivo para navegar e reproduzir os arquivos de áudio dela no carro.'; + + @override + String get localMusicFolderNotConfigured => 'Nenhuma pasta selecionada'; + + @override + String get localMusicFolderTitle => 'Pasta de música local'; + + @override + String get localMusicChoosePath => 'Escolher pasta'; + + @override + String get localMusicChangePath => 'Alterar pasta'; + + @override + String get localMusicFolderUpdated => 'Pasta de música local atualizada'; + + @override + String localMusicFolderSaveError(Object error) { + return 'Não foi possível salvar a pasta: $error'; + } + + @override + String get localMusicFolderGenericName => 'Pasta selecionada'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 1158c50..766e64c 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -63,22 +63,22 @@ class AppLocalizationsRu extends AppLocalizations { Object minutes, Object seconds, ) { - return '$hours ? $minutes ??? $seconds ?'; + return '$hours ч $minutes мин $seconds сек'; } @override String durationMinutesSeconds(Object minutes, Object seconds) { - return '$minutes ??? $seconds ?'; + return '$minutes мин $seconds сек'; } @override String durationMinutesOnly(Object minutes) { - return '$minutes ???'; + return '$minutes мин'; } @override String durationSecondsOnly(Object seconds) { - return '$seconds ?'; + return '$seconds сек'; } @override @@ -1311,4 +1311,34 @@ class AppLocalizationsRu extends AppLocalizations { @override String get eqDeviceConnected => 'Подключено'; + + @override + String get localMusicSectionTitle => 'Локальная музыка (Android Auto)'; + + @override + String get localMusicSectionDescription => + 'Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.'; + + @override + String get localMusicFolderNotConfigured => 'Папка не выбрана'; + + @override + String get localMusicFolderTitle => 'Папка локальной музыки'; + + @override + String get localMusicChoosePath => 'Выбрать папку'; + + @override + String get localMusicChangePath => 'Изменить папку'; + + @override + String get localMusicFolderUpdated => 'Папка локальной музыки обновлена'; + + @override + String localMusicFolderSaveError(Object error) { + return 'Не удалось сохранить папку: $error'; + } + + @override + String get localMusicFolderGenericName => 'Выбранная папка'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index bafff6c..44628ea 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -62,22 +62,22 @@ class AppLocalizationsZh extends AppLocalizations { Object minutes, Object seconds, ) { - return '$hours ?? $minutes ? $seconds ?'; + return '$hours小时$minutes分钟$seconds秒'; } @override String durationMinutesSeconds(Object minutes, Object seconds) { - return '$minutes ? $seconds ?'; + return '$minutes分钟$seconds秒'; } @override String durationMinutesOnly(Object minutes) { - return '$minutes ?'; + return '$minutes分钟'; } @override String durationSecondsOnly(Object seconds) { - return '$seconds ?'; + return '$seconds秒'; } @override @@ -1256,4 +1256,33 @@ class AppLocalizationsZh extends AppLocalizations { @override String get eqDeviceConnected => '已连接'; + + @override + String get localMusicSectionTitle => '本地音乐(Android Auto)'; + + @override + String get localMusicSectionDescription => '选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。'; + + @override + String get localMusicFolderNotConfigured => '未选择文件夹'; + + @override + String get localMusicFolderTitle => '本地音乐文件夹'; + + @override + String get localMusicChoosePath => '选择文件夹'; + + @override + String get localMusicChangePath => '更改文件夹'; + + @override + String get localMusicFolderUpdated => '本地音乐文件夹已更新'; + + @override + String localMusicFolderSaveError(Object error) { + return '无法保存文件夹:$error'; + } + + @override + String get localMusicFolderGenericName => '已选文件夹'; } diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 4adca31..701f6cf 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -779,9 +779,26 @@ class _SeccionEcualizador extends StatelessWidget { /// Always shows the feature toggle so the user can discover it. When the /// toggle is OFF, the device list is completely absent (not just invisible), /// matching the spec scenario "Settings section is absent when toggle is off". -class _SeccionEcualizadorAvanzado extends StatelessWidget { +class _SeccionEcualizadorAvanzado extends StatefulWidget { const _SeccionEcualizadorAvanzado(); + @override + State<_SeccionEcualizadorAvanzado> createState() => + _SeccionEcualizadorAvanzadoState(); +} + +class _SeccionEcualizadorAvanzadoState + extends State<_SeccionEcualizadorAvanzado> { + @override + void initState() { + super.initState(); + // Fix "stale green dot": refresh the active-device indicator with a + // fresh native query the moment this section becomes visible, instead of + // trusting the last event that happened to arrive (no-op when the + // multi-device toggle is off). + unawaited(context.read().refrescarDispositivoActual()); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index b16737d..e4d0014 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -7,12 +7,15 @@ import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path_provider/path_provider.dart'; import '../estado/orden_emisoras.dart'; +import '../modelos/dispositivo_audio.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; import 'musica_local_auto.dart'; import 'persistencia_tolerante.dart'; +import 'servicio_dispositivo_audio.dart'; +import 'servicio_ecualizador.dart'; import 'servicio_favoritos.dart'; /// Generic page slice over [items] (Design ADR-6): returns at most [tamano] @@ -43,6 +46,38 @@ const _prefijoPresetEq = 'eq_preset:'; /// predicate. bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq); +/// Custom-EQ band folder media-id prefix (feature auto-custom-eq): +/// `eq_banda:`. Collision-free against [_prefijoPresetEq] and +/// [_prefijoGananciaEq] — the three `eq_` prefixes diverge at index 3 +/// (`b` vs `p` vs `g`), so no `startsWith` check ever matches a sibling's id. +const _prefijoBandaEq = 'eq_banda:'; + +/// Whether [id] identifies a custom-EQ band folder (feature auto-custom-eq). +bool esBandaEqMediaId(String id) => id.startsWith(_prefijoBandaEq); + +/// Builds the `eq_banda:` media id for band [indice] (feature +/// auto-custom-eq) — single authority for the id shape, shared by the tree +/// builder and the handler's children-changed notification. +String idBandaEq(int indice) => '$_prefijoBandaEq$indice'; + +/// Custom-EQ gain leaf media-id prefix (feature auto-custom-eq): +/// `eq_gain::`. Collision-free against [_prefijoBandaEq] and +/// [_prefijoPresetEq] (see [_prefijoBandaEq]'s divergence note). +const _prefijoGananciaEq = 'eq_gain:'; + +/// Whether [id] identifies a custom-EQ gain leaf item (feature +/// auto-custom-eq). +bool esGananciaEqMediaId(String id) => id.startsWith(_prefijoGananciaEq); + +/// Number of fixed EQ bands (`PresetEcualizador` asserts exactly 5). +const _numBandasEq = 5; + +/// Frequency labels for the 5 EQ bands, in the same order as +/// `PresetEcualizador.bandas` (60Hz, 250Hz, 1kHz, 4kHz, 16kHz — the model's +/// documented band layout, same frequencies the phone's +/// `EcualizadorWidget._etiquetas` renders). +const etiquetasBandasEq = ['60 Hz', '250 Hz', '1 kHz', '4 kHz', '16 kHz']; + /// Local-track media-id prefix (Design "media-id scheme"), collision-free /// against [_prefijoEmisora], [_prefijoPresetEq], `grupo:` and the bare /// folder id constants. Top-level (not a [ConstructorArbolAuto] member), @@ -208,6 +243,11 @@ class ConstructorArbolAuto { /// generic station-list `hijos()` path. static const idEcualizador = 'ecualizador'; + /// Browsable "Personalizado" folder id under [idEcualizador] (feature + /// auto-custom-eq). Deliberately NOT added to [_idsCarpetas] — routed by + /// its own dedicated branch in `getChildren`, like [idEcualizador] itself. + static const idEqPersonalizado = 'eq_custom'; + /// Root folder id for the local-music browsable root (Design "media-id /// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own /// dedicated branch (`hijosMusicaLocal`), not the generic station-list @@ -736,6 +776,52 @@ class ConstructorArbolAuto { List presetsEq(List presets) => presets.map(itemPresetEq).toList(); + /// The browsable "Personalizado" folder appended after the fixed preset + /// leaves under `Ecualizador` (feature auto-custom-eq). Hardcoded Spanish + /// label, matching every other car-tree label in this file — never routed + /// through `AppLocalizations` (see [_tituloMasLocal]'s precedent). + MediaItem itemEqPersonalizado() => + _carpeta(idEqPersonalizado, 'Personalizado'); + + /// The 5 per-band browsable folders under `Personalizado` (feature + /// auto-custom-eq): id `eq_banda:`, title + /// ` · ` (e.g. `'60 Hz · +3 dB'`) so the + /// driver sees the effective custom gains at a glance. [actual] is the + /// persisted/effective custom preset resolved by the caller + /// ([presetPersonalizadoEfectivo]). + List bandasEq(PresetEcualizador actual) => [ + for ( + var i = 0; + i < actual.bandas.length && i < etiquetasBandasEq.length; + i++ + ) + _carpeta( + idBandaEq(i), + '${etiquetasBandasEq[i]} · ${formatearGananciaEq(actual.bandas[i])}', + ), + ]; + + /// The 13 playable gain leaves for band [indice] (feature auto-custom-eq): + /// -12..+12 dB in steps of 2, id `eq_gain::`. The currently + /// selected gain — when it falls on the 2 dB grid — is marked with a + /// leading `● ` so the active value is visible while browsing. An + /// out-of-range [indice] returns `[]`, never throws. + List gananciasBandaEq(int indice, PresetEcualizador actual) { + if (indice < 0 || indice >= actual.bandas.length) return const []; + final gananciaActual = actual.bandas[indice]; + return [ + for (var db = -12; db <= 12; db += 2) + MediaItem( + id: '$_prefijoGananciaEq$indice:$db', + title: + '${db.toDouble() == gananciaActual ? '● ' : ''}' + '${formatearGananciaEq(db.toDouble())}', + playable: true, + extras: _contentStyleGrid, + ), + ]; + } + /// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as /// direct leaves at the Favoritos root"): non-empty custom-group folders /// (phone order, capped at [_maxGruposPorFavoritos]), followed by @@ -1221,6 +1307,79 @@ PresetEcualizador? resolverPresetEq(String id, List presets) return null; } +/// Formats a gain in dB for the Auto tree (feature auto-custom-eq): +/// explicit `+` for boosts, plain `0 dB` for neutral, ASCII `-` for cuts. +/// Non-integer factory-preset gains keep one decimal (`'+1.5 dB'`) so band +/// titles never lie about the effective value. +String formatearGananciaEq(double db) { + final esEntera = db == db.roundToDouble(); + final valor = esEntera ? db.round().toString() : db.toStringAsFixed(1); + return db > 0 ? '+$valor dB' : '$valor dB'; +} + +/// Parses an `eq_banda:` [id] into its band index (feature +/// auto-custom-eq). Any other shape (no prefix, non-integer, outside the +/// fixed [_numBandasEq]-band range) returns `null` instead of throwing. +int? indiceBandaEqDesde(String id) { + if (!esBandaEqMediaId(id)) return null; + final indice = int.tryParse(id.substring(_prefijoBandaEq.length)); + if (indice == null || indice < 0 || indice >= _numBandasEq) return null; + return indice; +} + +/// Parses an `eq_gain::` [id] into its `(indice, db)` pair +/// (feature auto-custom-eq): the prefix is stripped by length, the remainder +/// split on its single `:`. Any other shape (no prefix, missing/non-integer +/// fields, index outside the band range, gain outside -12..+12) returns +/// `null` instead of throwing. +(int indice, double db)? gananciaEqDesde(String id) { + if (!esGananciaEqMediaId(id)) return null; + final resto = id.substring(_prefijoGananciaEq.length); + final separador = resto.indexOf(':'); + if (separador <= 0) return null; + final indice = int.tryParse(resto.substring(0, separador)); + final db = int.tryParse(resto.substring(separador + 1)); + if (indice == null || db == null) return null; + if (indice < 0 || indice >= _numBandasEq) return null; + if (db < -12 || db > 12) return null; + return (indice, db.toDouble()); +} + +/// Persistence-targeting decision for a car EQ action — preset tap or band +/// change (feature auto-custom-eq): returns the deviceId to persist a +/// DEVICE-level entry for, or `null` to fall back to the global principal. +/// `null` cases mirror the phone hierarchy's own exclusions: multi-device +/// toggle off, unknown device (query failed/timed out — [dispositivo] is +/// `null`), built-in speaker (must keep falling through to L4 global), and +/// composite-placeholder BT ids (ADR-6). Pure — testable without platform +/// channels. +String? dispositivoDestinoEq({ + required bool multiDeviceEnabled, + required DispositivoAudio? dispositivo, +}) { + if (!multiDeviceEnabled || dispositivo == null) return null; + if (dispositivo.tipo == TipoDispositivo.altavozInterno) return null; + if (dispositivo.id.startsWith(prefijoPlaceholderBtName)) return null; + return dispositivo.id; +} + +/// Resolves the custom-EQ preset the Auto tree displays and edits (feature +/// auto-custom-eq): the device-level entry for [deviceId] when multi-device +/// is enabled and one exists, the global principal otherwise. Station/matrix +/// overrides are deliberately NOT consulted — the `Personalizado` tree +/// displays and edits exactly the level a gain tap persists to +/// ([dispositivoDestinoEq]), so what the driver sees is what a tap changes. +PresetEcualizador presetPersonalizadoEfectivo({ + required ConfiguracionEcualizador config, + required String? deviceId, +}) { + if (config.eqMultiDeviceEnabled && deviceId != null) { + final porDispositivo = config.presetsDispositivo[deviceId]; + if (porDispositivo != null) return porDispositivo; + } + return config.principal; +} + /// Pure per-station apply gate (Design ADR-5), mirroring /// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic /// (`estado_ecualizador.dart:302-304`): the new principal preset is applied @@ -1232,16 +1391,46 @@ bool debeAplicarPrincipalAhora({ required Set clavesPorEmisora, }) => uuidActual == null || !clavesPorEmisora.contains(uuidActual); +/// Apply-live gate for a DEVICE-targeted car selection (feature +/// auto-custom-eq), extending [debeAplicarPrincipalAhora] with the matrix +/// level of the phone hierarchy: the freshly persisted device-level preset +/// is audible now unless the current station carries a per-station override +/// or a `estación:dispositivoDestino` matrix entry shadows it. +bool debeAplicarSeleccionAhora({ + required String? uuidActual, + required Set clavesPorEmisora, + required Set clavesMatriz, + required String? deviceIdDestino, +}) { + if (uuidActual == null) return true; + if (clavesPorEmisora.contains(uuidActual)) return false; + if (deviceIdDestino != null && + clavesMatriz.contains('$uuidActual:$deviceIdDestino')) { + return false; + } + return true; +} + /// Orchestrates an `eq_preset:` selection from the car (Design /// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq], -/// persists it as principal via [persistirPrincipal], and conditionally -/// applies it live via [aplicar] when [debeAplicarPrincipalAhora] allows it. +/// persists it, and conditionally applies it live via [aplicar]. +/// +/// Persistence targeting (feature auto-custom-eq): when the optional +/// [dispositivoDestino]/[persistirDispositivo] seams are provided and the +/// destination resolves to a deviceId ([dispositivoDestinoEq]'s decision), +/// the preset is persisted as a DEVICE-level entry — so the car selection +/// sticks for the car's output device instead of being shadowed by the +/// hierarchy's L3 lookup — and the live application is gated by +/// [debeAplicarSeleccionAhora]. Otherwise (seams omitted, or destination +/// `null`: toggle off, built-in, placeholder, query error/timeout) the +/// original global path runs unchanged: [persistirPrincipal] + +/// [debeAplicarPrincipalAhora]. /// /// This function's signature exposes ONLY the EQ persist/apply seams — it /// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so /// there is no code path from a preset tap to playback (Design ADR-3, /// non-playback invariant enforced structurally, not by discipline). An -/// unknown/stale [id] is a no-op: neither seam is invoked and no exception +/// unknown/stale [id] is a no-op: no seam is invoked and no exception /// propagates (Spec "Unknown or stale preset id"). Future aplicarPresetPorMediaId( String id, { @@ -1250,9 +1439,34 @@ Future aplicarPresetPorMediaId( required Future> Function() clavesPorEmisora, required Future Function(PresetEcualizador) persistirPrincipal, required Future Function(PresetEcualizador) aplicar, + Future Function()? dispositivoDestino, + Future Function(String deviceId, PresetEcualizador preset)? + persistirDispositivo, + Future> Function()? clavesMatriz, }) async { final preset = resolverPresetEq(id, presets); if (preset == null) return; + + final destino = + (dispositivoDestino == null || persistirDispositivo == null) + ? null + : await dispositivoDestino(); + if (destino != null) { + // Apply-first ordering: if the live application throws, nothing has + // been persisted yet, so audible and persisted state cannot diverge. + if (debeAplicarSeleccionAhora( + uuidActual: uuidActual, + clavesPorEmisora: await clavesPorEmisora(), + clavesMatriz: + clavesMatriz == null ? const {} : await clavesMatriz(), + deviceIdDestino: destino, + )) { + await aplicar(preset); + } + await persistirDispositivo!(destino, preset); + return; + } + await persistirPrincipal(preset); if (debeAplicarPrincipalAhora( uuidActual: uuidActual, @@ -1262,6 +1476,75 @@ Future aplicarPresetPorMediaId( } } +/// Orchestrates an `eq_gain::` selection from the car (feature +/// auto-custom-eq): parses [id] via [gananciaEqDesde], resolves the custom +/// base preset for the persistence target ([presetPersonalizadoEfectivo] +/// over [cargarConfig]'s snapshot), replaces the single band (the result is +/// always named `Personalizado` via `copyWithBandas`), persists it at DEVICE +/// level when [dispositivoDestino] yields a deviceId — global principal +/// otherwise, including the headless error/timeout fallback — and applies +/// the band live via [aplicarBanda] (the handler's `setBanda`, itself a +/// no-op while the EQ engine is unavailable). +/// +/// Live application is gated by [debeAplicarSeleccionAhora] over the same +/// optional [uuidActual]/[clavesPorEmisora]/[clavesMatriz] seams as +/// [aplicarPresetPorMediaId] (omitted seams keep the legacy always-apply +/// behavior), and runs BEFORE persistence so a throwing apply cannot leave +/// persisted and audible state divergent. +/// +/// Same non-playback structural invariant as [aplicarPresetPorMediaId]: no +/// playback seam exists in this signature. A malformed/out-of-range [id] or +/// a failing [cargarConfig] degrades to a no-op — no seam is invoked and no +/// exception propagates. +Future aplicarGananciaPorMediaId( + String id, { + required Future Function() cargarConfig, + required Future Function() dispositivoDestino, + required Future Function(String deviceId, PresetEcualizador preset) + persistirDispositivo, + required Future Function(PresetEcualizador preset) persistirPrincipal, + required Future Function(int indice, double db) aplicarBanda, + String? uuidActual, + Future> Function()? clavesPorEmisora, + Future> Function()? clavesMatriz, +}) async { + final ganancia = gananciaEqDesde(id); + if (ganancia == null) return; + final (indice, db) = ganancia; + + final ConfiguracionEcualizador config; + try { + config = await cargarConfig(); + } catch (_) { + return; + } + + final destino = await dispositivoDestino(); + final base = presetPersonalizadoEfectivo(config: config, deviceId: destino); + final bandas = List.from(base.bandas); + bandas[indice] = db; + final modificado = base.copyWithBandas(bandas); + + // Apply-first ordering (same rationale as [aplicarPresetPorMediaId]'s + // device branch): a throwing apply must not leave persisted state ahead + // of the audible one. + if (debeAplicarSeleccionAhora( + uuidActual: uuidActual, + clavesPorEmisora: + clavesPorEmisora == null ? const {} : await clavesPorEmisora(), + clavesMatriz: + clavesMatriz == null ? const {} : await clavesMatriz(), + deviceIdDestino: destino, + )) { + await aplicarBanda(indice, db); + } + if (destino != null) { + await persistirDispositivo(destino, modificado); + } else { + await persistirPrincipal(modificado); + } +} + /// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design /// "getChildren data source"). Reads favourites from SQLite and custom /// stations from the tolerant JSON file directly — both loadable without diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index e5ead09..8890d09 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -4,10 +4,13 @@ import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/services.dart' show MethodChannel; import 'package:just_audio/just_audio.dart'; +import 'package:rxdart/rxdart.dart' show BehaviorSubject, ValueStream; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; +import '../modelos/dispositivo_audio.dart'; import '../modelos/emisora.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; @@ -16,6 +19,7 @@ import 'controlador_reconexion.dart'; import 'musica_local_auto.dart'; import 'navegacion_auto.dart'; import 'servicio_audio_session.dart'; +import 'servicio_dispositivo_audio.dart'; import 'servicio_ecualizador.dart'; /// Estado de reproducción expuesto al UI. @@ -909,6 +913,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _androidAudioSessionIdSub?.cancel(); await _player.dispose(); await _androidAudioSessionIdController.close(); + for (final subject in _hijosSubjects.values) { + await subject.close(); + } + _hijosSubjects.clear(); } Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { @@ -925,6 +933,92 @@ class PluriWaveAudioHandler extends BaseAudioHandler // ── Android Auto browsing (thin delegation to navegacion_auto.dart's // already-tested pure logic — Design "getChildren data source") ───────── + /// One-shot device-query channel (feature auto-custom-eq): the SAME + /// method channel `ServicioDispositivoAudioReal` talks to, but method + /// calls only — opening a second EventChannel subscription here would + /// steal the phone-side service's Dart stream handler. + static const _canalDispositivos = MethodChannel('pluriwave/audio_devices'); + + /// Short timeout for the device query: on a headless Auto bind no + /// Activity (and thus no channel handler) exists, and a car tap must fall + /// back to global persistence instead of hanging. + static const _timeoutConsultaDispositivo = Duration(seconds: 2); + + /// Fresh active-output-device query for the car EQ paths. Returns `null` + /// on ANY failure (missing handler while headless, timeout, malformed + /// map) so callers degrade to global persistence — never a crash. + Future _dispositivoActivoAuto() async { + try { + final raw = await _canalDispositivos + .invokeMethod>('getActiveDevice') + .timeout(_timeoutConsultaDispositivo); + if (raw == null) return null; + return ServicioDispositivoAudioReal.dispositivoDesdeMapa( + Map.from(raw), + ); + } catch (_) { + return null; + } + } + + /// Resolves the persistence target for a car EQ action (feature + /// auto-custom-eq): a deviceId for a DEVICE-level write, `null` for the + /// global principal (toggle off, built-in speaker, placeholder id, or the + /// headless error/timeout fallback). + Future _dispositivoDestinoEqAuto( + ServicioEcualizador servicio, + ) async { + try { + final config = await servicio.cargar(); + if (!config.eqMultiDeviceEnabled) return null; + return dispositivoDestinoEq( + multiDeviceEnabled: config.eqMultiDeviceEnabled, + dispositivo: await _dispositivoActivoAuto(), + ); + } catch (_) { + return null; + } + } + + /// The custom preset the Auto tree shows and edits right now (feature + /// auto-custom-eq): the device-level entry for the current output device + /// when multi-device is on, the global principal otherwise — resolved + /// from persistence so a headless bind (no `EstadoEcualizador`) still + /// reports honest gains. + Future _presetPersonalizadoAuto() async { + final servicio = ServicioEcualizador(); + final config = await servicio.cargar(); + final destino = + config.eqMultiDeviceEnabled + ? dispositivoDestinoEq( + multiDeviceEnabled: true, + dispositivo: await _dispositivoActivoAuto(), + ) + : null; + return presetPersonalizadoEfectivo(config: config, deviceId: destino); + } + + /// Per-parent children-changed subjects (feature auto-custom-eq): + /// audio_service subscribes to [subscribeToChildren]'s stream the first + /// time the platform loads a parent's children and translates every later + /// emission into a native `notifyChildrenChanged`, making the car + /// re-request `getChildren` so band titles and the selection mark refresh + /// right after a gain tap. + final Map>> _hijosSubjects = {}; + + @override + ValueStream> subscribeToChildren(String parentMediaId) => + _hijosSubjects.putIfAbsent( + parentMediaId, + () => BehaviorSubject.seeded({}), + ); + + /// Emits a children-changed notification for [parentMediaId] — a no-op + /// until the platform has browsed that parent at least once. + void _notificarHijosCambiados(String parentMediaId) { + _hijosSubjects[parentMediaId]?.add({}); + } + @override Future> getChildren( String parentMediaId, [ @@ -939,7 +1033,21 @@ class PluriWaveAudioHandler extends BaseAudioHandler return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal); } if (parentMediaId == ConstructorArbolAuto.idEcualizador) { - return constructor.presetsEq(PresetEcualizador.presets); + return [ + ...constructor.presetsEq(PresetEcualizador.presets), + constructor.itemEqPersonalizado(), + ]; + } + if (parentMediaId == ConstructorArbolAuto.idEqPersonalizado) { + return constructor.bandasEq(await _presetPersonalizadoAuto()); + } + if (esBandaEqMediaId(parentMediaId)) { + final indice = indiceBandaEqDesde(parentMediaId); + if (indice == null) return const []; + return constructor.gananciasBandaEq( + indice, + await _presetPersonalizadoAuto(), + ); } final musicaLocal = await hijosMusicaLocal( parentMediaId, @@ -996,17 +1104,54 @@ class PluriWaveAudioHandler extends BaseAudioHandler // apply) — there is no playback parameter to inject here. if (esPresetMediaId(mediaId)) { final servicio = ServicioEcualizador(); + // Persistence targeting (feature auto-custom-eq): with multi-device + // EQ on and a non-builtin output device active, the tap persists a + // DEVICE-level entry so the selection sticks for the car's device + // instead of being shadowed by the hierarchy; otherwise (or on any + // headless query failure) it persists the global principal as + // before. await aplicarPresetPorMediaId( mediaId, presets: PresetEcualizador.presets, uuidActual: emisoraActual?.uuid, clavesPorEmisora: () async => (await servicio.cargar()).porEmisora.keys.toSet(), + clavesMatriz: () async => + (await servicio.cargar()).presetsMatriz.keys.toSet(), + dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio), + persistirDispositivo: servicio.guardarPresetDispositivo, persistirPrincipal: servicio.guardarPrincipal, aplicar: aplicarPreset, ); return; } + // Custom-EQ gain selection (feature auto-custom-eq): same + // unconditional-return shape as the eq_preset branch above — an + // `eq_gain:` id can never fall through to playback routing. + if (esGananciaEqMediaId(mediaId)) { + final servicio = ServicioEcualizador(); + await aplicarGananciaPorMediaId( + mediaId, + cargarConfig: servicio.cargar, + dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio), + persistirDispositivo: servicio.guardarPresetDispositivo, + persistirPrincipal: servicio.guardarPrincipal, + aplicarBanda: setBanda, + uuidActual: emisoraActual?.uuid, + clavesPorEmisora: () async => + (await servicio.cargar()).porEmisora.keys.toSet(), + clavesMatriz: () async => + (await servicio.cargar()).presetsMatriz.keys.toSet(), + ); + // Refresh the affected browse nodes so the band title under + // `Personalizado` and the `● ` selection mark reflect the new gain. + final ganancia = gananciaEqDesde(mediaId); + if (ganancia != null) { + _notificarHijosCambiados(ConstructorArbolAuto.idEqPersonalizado); + _notificarHijosCambiados(idBandaEq(ganancia.$1)); + } + return; + } // Local-track playback (Design "Local Track Playback Reuses Existing // Pipeline", Spec "User selects a local track"): SECOND branch, // unconditional `return`, mirroring the eq_preset branch above — a diff --git a/lib/servicios/servicio_dispositivo_audio.dart b/lib/servicios/servicio_dispositivo_audio.dart index 093e0f1..60422c3 100644 --- a/lib/servicios/servicio_dispositivo_audio.dart +++ b/lib/servicios/servicio_dispositivo_audio.dart @@ -4,6 +4,14 @@ import 'package:flutter/services.dart'; import '../modelos/dispositivo_audio.dart'; +/// Composite-placeholder id prefix (bt-device-identity ADR-6): marks a BT +/// device whose real MAC is not yet known (BLUETOOTH_CONNECT denied or +/// unresolved). Single source of truth for the marker shared by +/// `EstadoEcualizador` and the Auto EQ persistence-targeting logic: ids with +/// this prefix are transient and must never receive a device-level preset +/// entry. +const prefijoPlaceholderBtName = 'bt_a2dp:name:'; + /// Abstract service for audio device detection. /// /// Implementations: @@ -21,6 +29,16 @@ abstract class ServicioDispositivoAudio { /// (method channel round-trip). Returns the cached value if already known. Future obtenerDispositivoActual(); + /// Cancels the current device-change subscription and subscribes again. + /// + /// Sends the platform `cancel`+`listen` control messages, which re-triggers + /// `onListen` on the CURRENT activity's stream handler and re-registers the + /// native `AudioDeviceCallback`. Needed because the Flutter engine outlives + /// the Activity (`AudioServiceActivity`): after an activity recreation the + /// new handler never saw a `listen`, so its event sink stays null and + /// device events stop reaching Dart until this resync runs. + Future resubscribir(); + /// Requests the `BLUETOOTH_CONNECT` runtime permission (API 31+) at the /// point the device-management UI is opened (bt-device-identity ADR-1). /// Returns true when granted or not required (SDK < 31, iOS); false when @@ -88,6 +106,12 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio { return device; } + @override + Future resubscribir() async { + await _eventSub?.cancel(); + _subscribeToEvents(); + } + @override Future solicitarPermisoBluetooth() async { final granted = await _methodChannel.invokeMethod( @@ -102,6 +126,15 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio { await _controller.close(); } + /// Builds a [DispositivoAudio] from the raw platform-channel map shape. + /// + /// Public so headless consumers (the Android Auto handler's one-shot + /// `getActiveDevice` query) reuse the exact same mapping without opening a + /// second event-channel subscription, which would steal this service's + /// stream handler on the Dart side. + static DispositivoAudio dispositivoDesdeMapa(Map map) => + _mapToDispositivo(map); + static DispositivoAudio _mapToDispositivo(Map map) { final id = map['id'] as String? ?? 'builtin_speaker'; final type = map['type'] as int? ?? 2; diff --git a/pubspec.lock b/pubspec.lock index 69b3da8..c177a94 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -449,10 +449,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -598,7 +598,7 @@ packages: source: hosted version: "2.2.0" rxdart: - dependency: transitive + dependency: "direct main" description: name: rxdart sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" @@ -798,10 +798,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 58cd5d3..25473c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,9 @@ dependencies: just_audio: ^0.9.42 audio_service: ^0.18.15 audio_session: ^0.1.21 + # Ya transitiva vía audio_service; directa para poder emitir + # notifyChildrenChanged (subscribeToChildren con BehaviorSubject). + rxdart: ^0.28.0 # UI cupertino_icons: ^1.0.8 diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 34afa09..6380072 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_ecualizador.dart'; import 'package:pluriwave/estado/estado_radio.dart'; @@ -1402,4 +1404,153 @@ void main() { }, ); }); + + // --------------------------------------------------------------------------- + // Fix: robust active-device detection (stale green dot) + // --------------------------------------------------------------------------- + + group('EstadoEcualizador.refrescarDispositivoActual', () { + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Speaker', + ); + + test( + 'resubscribes the event channel and re-seeds the id from a fresh ' + 'device query', + () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + expect(eq.dispositivoActualId, equals('builtin_speaker')); + + // The native side changed device but the stale subscription lost the + // event (activity recreation): only the fake's cached value moved. + fakeDispositivo.establecerDispositivoSinEvento(btDevice); + + await eq.refrescarDispositivoActual(); + + expect(fakeDispositivo.resubscribirLlamadas, equals(1)); + expect(eq.dispositivoActualId, equals(btDevice.id)); + eq.dispose(); + }, + ); + + test('is a no-op when the multi-device toggle is off', () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + + await eq.refrescarDispositivoActual(); + + expect(fakeDispositivo.resubscribirLlamadas, equals(0)); + expect(eq.dispositivoActualId, isNull); + eq.dispose(); + }); + + test('is a no-op when no device service is injected', () async { + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + + // Must not throw despite the missing service. + await eq.refrescarDispositivoActual(); + + expect(eq.dispositivoActualId, isNull); + eq.dispose(); + }); + + test('is safe to call repeatedly', () async { + final fakeDispositivo = FakeServicioDispositivoAudio(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + fakeDispositivo.establecerDispositivoSinEvento(btDevice); + + await eq.refrescarDispositivoActual(); + await eq.refrescarDispositivoActual(); + await eq.refrescarDispositivoActual(); + + expect(fakeDispositivo.resubscribirLlamadas, equals(3)); + expect(eq.dispositivoActualId, equals(btDevice.id)); + eq.dispose(); + }); + + test( + 'a failing resubscribe still falls back to builtin_speaker via the ' + 'seeding error path instead of throwing', + () async { + final fakeDispositivo = FakeServicioDispositivoAudioThrows(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + + await eq.refrescarDispositivoActual(); + + expect(fakeDispositivo.resubscribirLlamadas, equals(1)); + expect(eq.dispositivoActualId, equals('builtin_speaker')); + eq.dispose(); + }, + ); + + test( + 'overlapping calls share one in-flight refresh (single resubscribe)', + () async { + final fakeDispositivo = _FakeDispositivoAudioResubscribirLento(); + final eq = EstadoEcualizador( + audio: FakeServicioAudio(), + servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true), + dispositivoAudio: fakeDispositivo, + emisoraActualUuid: () => null, + ); + await eq.cargarPersistido(); + + // Second call lands while the first is still awaiting resubscribir. + final primero = eq.refrescarDispositivoActual(); + final segundo = eq.refrescarDispositivoActual(); + fakeDispositivo.completarResubscribir(); + await Future.wait([primero, segundo]); + + expect(fakeDispositivo.resubscribirLlamadas, equals(1)); + eq.dispose(); + }, + ); + }); +} + +/// Fake whose [resubscribir] stays pending until [completarResubscribir] +/// runs — creates the overlap window for the in-flight-guard test above. +class _FakeDispositivoAudioResubscribirLento + extends FakeServicioDispositivoAudio { + final _resubscribir = Completer(); + + void completarResubscribir() => _resubscribir.complete(); + + @override + Future resubscribir() async { + resubscribirLlamadas++; + await _resubscribir.future; + } } diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index 61f0515..8f58491 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -433,11 +433,20 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio { @override Stream get onDispositivoCambiado => _controller.stream; + /// Number of times [resubscribir] was called. + int resubscribirLlamadas = 0; + @override Future obtenerDispositivoActual() async { throw Exception('Platform channel error: device unavailable'); } + @override + Future resubscribir() async { + resubscribirLlamadas++; + throw Exception('Platform channel error: resubscribe unavailable'); + } + @override Future solicitarPermisoBluetooth() async => true; @@ -462,6 +471,9 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { /// Number of times [solicitarPermisoBluetooth] was called. int solicitarPermisoBluetoothCalls = 0; + /// Number of times [resubscribir] was called. + int resubscribirLlamadas = 0; + final _controller = StreamController.broadcast(); DispositivoAudio? _dispositivoActual; @@ -481,6 +493,11 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { ); } + @override + Future resubscribir() async { + resubscribirLlamadas++; + } + @override Future solicitarPermisoBluetooth() async { solicitarPermisoBluetoothCalls++; @@ -493,6 +510,14 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio { _controller.add(dispositivo); } + /// Helper for tests: change the cached device WITHOUT emitting a stream + /// event — simulates the stale-subscription bug (activity recreation), + /// where the native device changed but the event never reached Dart and + /// only a fresh query ([obtenerDispositivoActual]) sees the new value. + void establecerDispositivoSinEvento(DispositivoAudio dispositivo) { + _dispositivoActual = dispositivo; + } + @override Future dispose() async { await _controller.close(); diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 0acffe6..7691dcc 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2,12 +2,14 @@ import 'dart:math' show Random; import 'package:audio_service/audio_service.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/dispositivo_audio.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/pista_local.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/musica_local_auto.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; +import 'package:pluriwave/servicios/servicio_ecualizador.dart'; void main() { group('faviconUsable', () { @@ -3050,6 +3052,624 @@ void main() { }, ); }); + + // ─────────────────────────────────────────────────────────────────────────── + // EQ gráfico personalizado en Android Auto (feature auto-custom-eq) + // ─────────────────────────────────────────────────────────────────────────── + + group('ConstructorArbolAuto.itemEqPersonalizado', () { + test('carpeta no reproducible con id eq_custom y título Personalizado', () { + final item = ConstructorArbolAuto().itemEqPersonalizado(); + + expect(item.id, ConstructorArbolAuto.idEqPersonalizado); + expect(item.id, 'eq_custom'); + expect(item.title, 'Personalizado'); + expect(item.playable, isFalse); + }); + }); + + group('formatearGananciaEq', () { + test('positivos con signo +, cero sin signo, negativos con -', () { + expect(formatearGananciaEq(4), '+4 dB'); + expect(formatearGananciaEq(0), '0 dB'); + expect(formatearGananciaEq(-6), '-6 dB'); + expect(formatearGananciaEq(12), '+12 dB'); + expect(formatearGananciaEq(-12), '-12 dB'); + }); + + test('ganancias no enteras (presets de fábrica) muestran un decimal', () { + expect(formatearGananciaEq(1.5), '+1.5 dB'); + expect(formatearGananciaEq(-1.5), '-1.5 dB'); + }); + }); + + group('ConstructorArbolAuto.bandasEq', () { + test( + '5 carpetas eq_banda: con frecuencia y ganancia actual en el título', + () { + final preset = PresetEcualizador( + nombre: 'Personalizado', + bandas: [3.0, 0.0, -6.0, 1.5, 12.0], + ); + + final items = ConstructorArbolAuto().bandasEq(preset); + + expect(items, hasLength(5)); + expect(items.map((i) => i.id), [ + 'eq_banda:0', + 'eq_banda:1', + 'eq_banda:2', + 'eq_banda:3', + 'eq_banda:4', + ]); + expect(items[0].title, '60 Hz · +3 dB'); + expect(items[1].title, '250 Hz · 0 dB'); + expect(items[2].title, '1 kHz · -6 dB'); + expect(items[3].title, '4 kHz · +1.5 dB'); + expect(items[4].title, '16 kHz · +12 dB'); + expect(items.every((i) => i.playable == false), isTrue); + }, + ); + }); + + group('ConstructorArbolAuto.gananciasBandaEq', () { + test( + '13 items reproducibles eq_gain:: de -12 a +12 en pasos de 2', + () { + final items = ConstructorArbolAuto().gananciasBandaEq( + 1, + PresetEcualizador.flat, + ); + + expect(items, hasLength(13)); + expect(items.first.id, 'eq_gain:1:-12'); + expect(items.last.id, 'eq_gain:1:12'); + expect(items.every((i) => i.playable == true), isTrue); + expect(items.first.title, '-12 dB'); + expect(items.last.title, '+12 dB'); + }, + ); + + test('marca la ganancia actualmente seleccionada con "● "', () { + final preset = PresetEcualizador( + nombre: 'Personalizado', + bandas: [0.0, 4.0, 0.0, 0.0, 0.0], + ); + + final items = ConstructorArbolAuto().gananciasBandaEq(1, preset); + + final marcados = items.where((i) => i.title.startsWith('● ')).toList(); + expect(marcados, hasLength(1)); + expect(marcados.single.id, 'eq_gain:1:4'); + expect(marcados.single.title, '● +4 dB'); + }); + + test( + 'ganancia actual fuera de la retícula (impar) no marca ningún item', + () { + // Rock: bandas [2.0, 1.0, -1.0, 2.0, 3.0] — banda 1 vale 1.0. + final items = ConstructorArbolAuto().gananciasBandaEq( + 1, + PresetEcualizador.rock, + ); + + expect(items.where((i) => i.title.startsWith('● ')), isEmpty); + }, + ); + + test('índice fuera de rango devuelve lista vacía, nunca lanza', () { + final constructor = ConstructorArbolAuto(); + expect(constructor.gananciasBandaEq(-1, PresetEcualizador.flat), isEmpty); + expect(constructor.gananciasBandaEq(5, PresetEcualizador.flat), isEmpty); + }); + }); + + group('esBandaEqMediaId / indiceBandaEqDesde', () { + test('reconoce ids eq_banda: y extrae el índice', () { + expect(esBandaEqMediaId('eq_banda:0'), isTrue); + expect(esBandaEqMediaId('eq_banda:4'), isTrue); + expect(esBandaEqMediaId('eq_custom'), isFalse); + expect(esBandaEqMediaId('eq_gain:0:2'), isFalse); + + expect(indiceBandaEqDesde('eq_banda:0'), 0); + expect(indiceBandaEqDesde('eq_banda:4'), 4); + }); + + test('ids malformados o fuera de rango devuelven null, nunca lanzan', () { + expect(indiceBandaEqDesde('eq_banda:'), isNull); + expect(indiceBandaEqDesde('eq_banda:x'), isNull); + expect(indiceBandaEqDesde('eq_banda:5'), isNull); + expect(indiceBandaEqDesde('eq_banda:-1'), isNull); + expect(indiceBandaEqDesde('emisora:x'), isNull); + }); + }); + + group('esGananciaEqMediaId / gananciaEqDesde', () { + test('reconoce ids eq_gain: y extrae (índice, dB)', () { + expect(esGananciaEqMediaId('eq_gain:0:2'), isTrue); + expect(esGananciaEqMediaId('eq_banda:0'), isFalse); + + expect(gananciaEqDesde('eq_gain:0:2'), (0, 2.0)); + expect(gananciaEqDesde('eq_gain:4:-12'), (4, -12.0)); + expect(gananciaEqDesde('eq_gain:2:0'), (2, 0.0)); + }); + + test('ids malformados o fuera de rango devuelven null, nunca lanzan', () { + expect(gananciaEqDesde('eq_gain:'), isNull); + expect(gananciaEqDesde('eq_gain:0'), isNull); + expect(gananciaEqDesde('eq_gain:0:'), isNull); + expect(gananciaEqDesde('eq_gain:x:2'), isNull); + expect(gananciaEqDesde('eq_gain:0:x'), isNull); + expect(gananciaEqDesde('eq_gain:5:2'), isNull); + expect(gananciaEqDesde('eq_gain:0:14'), isNull); + expect(gananciaEqDesde('eq_gain:0:-14'), isNull); + expect(gananciaEqDesde('emisora:x'), isNull); + }); + }); + + group('dispositivoDestinoEq', () { + const btDevice = DispositivoAudio( + id: 'bt_a2dp:AA:BB:CC:DD:EE:FF', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'BT Speaker', + ); + const builtin = DispositivoAudio( + id: 'builtin_speaker', + tipo: TipoDispositivo.altavozInterno, + nombre: 'Speaker', + ); + const placeholder = DispositivoAudio( + id: 'bt_a2dp:name:My Speaker', + tipo: TipoDispositivo.bluetoothA2dp, + nombre: 'My Speaker', + ); + + test('dispositivo BT real con multi-device activo → su id', () { + expect( + dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: btDevice), + btDevice.id, + ); + }); + + test('multi-device apagado → null (persistencia global)', () { + expect( + dispositivoDestinoEq(multiDeviceEnabled: false, dispositivo: btDevice), + isNull, + ); + }); + + test('altavoz interno → null (nunca entrada por dispositivo)', () { + expect( + dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: builtin), + isNull, + ); + }); + + test('placeholder compuesto bt_a2dp:name: → null (ADR-6)', () { + expect( + dispositivoDestinoEq( + multiDeviceEnabled: true, + dispositivo: placeholder, + ), + isNull, + ); + }); + + test('dispositivo desconocido (consulta fallida) → null', () { + expect( + dispositivoDestinoEq(multiDeviceEnabled: true, dispositivo: null), + isNull, + ); + }); + }); + + group('presetPersonalizadoEfectivo', () { + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + + test('entrada por dispositivo gana cuando multi-device está activo', () { + final config = ConfiguracionEcualizador( + principal: PresetEcualizador.pop, + porEmisora: const {}, + eqMultiDeviceEnabled: true, + presetsDispositivo: {deviceId: PresetEcualizador.jazz}, + ); + + expect( + presetPersonalizadoEfectivo(config: config, deviceId: deviceId), + PresetEcualizador.jazz, + ); + }); + + test('sin entrada por dispositivo cae al principal', () { + final config = ConfiguracionEcualizador( + principal: PresetEcualizador.pop, + porEmisora: const {}, + eqMultiDeviceEnabled: true, + ); + + expect( + presetPersonalizadoEfectivo(config: config, deviceId: deviceId), + PresetEcualizador.pop, + ); + }); + + test('multi-device apagado ignora la entrada por dispositivo', () { + final config = ConfiguracionEcualizador( + principal: PresetEcualizador.pop, + porEmisora: const {}, + eqMultiDeviceEnabled: false, + presetsDispositivo: {deviceId: PresetEcualizador.jazz}, + ); + + expect( + presetPersonalizadoEfectivo(config: config, deviceId: deviceId), + PresetEcualizador.pop, + ); + }); + + test('deviceId null (destino global) cae al principal', () { + final config = ConfiguracionEcualizador( + principal: PresetEcualizador.pop, + porEmisora: const {}, + eqMultiDeviceEnabled: true, + presetsDispositivo: {deviceId: PresetEcualizador.jazz}, + ); + + expect( + presetPersonalizadoEfectivo(config: config, deviceId: null), + PresetEcualizador.pop, + ); + }); + }); + + group('debeAplicarSeleccionAhora', () { + const deviceId = 'bt_a2dp:AA:BB'; + + test('true sin estación actual', () { + expect( + debeAplicarSeleccionAhora( + uuidActual: null, + clavesPorEmisora: const {'uuid-otra'}, + clavesMatriz: const {}, + deviceIdDestino: deviceId, + ), + isTrue, + ); + }); + + test('false cuando la estación actual tiene override por-emisora', () { + expect( + debeAplicarSeleccionAhora( + uuidActual: 'uuid-con-override', + clavesPorEmisora: const {'uuid-con-override'}, + clavesMatriz: const {}, + deviceIdDestino: deviceId, + ), + isFalse, + ); + }); + + test( + 'false cuando existe entrada de matriz estación:dispositivo destino', + () { + expect( + debeAplicarSeleccionAhora( + uuidActual: 'uuid-1', + clavesPorEmisora: const {}, + clavesMatriz: const {'uuid-1:$deviceId'}, + deviceIdDestino: deviceId, + ), + isFalse, + ); + }, + ); + + test('true cuando la matriz solo cubre otro dispositivo', () { + expect( + debeAplicarSeleccionAhora( + uuidActual: 'uuid-1', + clavesPorEmisora: const {}, + clavesMatriz: const {'uuid-1:otro-dispositivo'}, + deviceIdDestino: deviceId, + ), + isTrue, + ); + }); + }); + + group('aplicarPresetPorMediaId — persistencia dirigida por dispositivo', () { + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + + test( + 'con destino de dispositivo persiste a nivel dispositivo, nunca al ' + 'principal, y aplica en vivo cuando no hay overrides', + () async { + final persistidosDispositivo = <(String, PresetEcualizador)>[]; + var persistirPrincipalLlamadas = 0; + PresetEcualizador? aplicado; + + await aplicarPresetPorMediaId( + 'eq_preset:${PresetEcualizador.rock.nombre}', + presets: PresetEcualizador.presets, + uuidActual: null, + clavesPorEmisora: () async => {}, + clavesMatriz: () async => {}, + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async { + persistidosDispositivo.add((id, preset)); + }, + persistirPrincipal: (preset) async { + persistirPrincipalLlamadas++; + }, + aplicar: (preset) async { + aplicado = preset; + }, + ); + + expect(persistidosDispositivo, [(deviceId, PresetEcualizador.rock)]); + expect(persistirPrincipalLlamadas, 0); + expect(aplicado, PresetEcualizador.rock); + }, + ); + + test( + 'con destino null (builtin/apagado/error) cae a la persistencia ' + 'principal como hasta ahora', + () async { + PresetEcualizador? persistidoPrincipal; + var persistirDispositivoLlamadas = 0; + + await aplicarPresetPorMediaId( + 'eq_preset:${PresetEcualizador.jazz.nombre}', + presets: PresetEcualizador.presets, + uuidActual: null, + clavesPorEmisora: () async => {}, + clavesMatriz: () async => {}, + dispositivoDestino: () async => null, + persistirDispositivo: (id, preset) async { + persistirDispositivoLlamadas++; + }, + persistirPrincipal: (preset) async { + persistidoPrincipal = preset; + }, + aplicar: (preset) async {}, + ); + + expect(persistidoPrincipal, PresetEcualizador.jazz); + expect(persistirDispositivoLlamadas, 0); + }, + ); + + test( + 'persiste a dispositivo pero NO aplica en vivo cuando la matriz ' + 'estación:dispositivo hace sombra', + () async { + var persistirDispositivoLlamadas = 0; + var aplicarLlamadas = 0; + + await aplicarPresetPorMediaId( + 'eq_preset:${PresetEcualizador.rock.nombre}', + presets: PresetEcualizador.presets, + uuidActual: 'uuid-1', + clavesPorEmisora: () async => {}, + clavesMatriz: () async => {'uuid-1:$deviceId'}, + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async { + persistirDispositivoLlamadas++; + }, + persistirPrincipal: (preset) async {}, + aplicar: (preset) async { + aplicarLlamadas++; + }, + ); + + expect(persistirDispositivoLlamadas, 1); + expect(aplicarLlamadas, 0); + }, + ); + }); + + group('aplicarGananciaPorMediaId', () { + const deviceId = 'bt_a2dp:AA:BB:CC:DD:EE:FF'; + + ConfiguracionEcualizador configCon({ + PresetEcualizador? principal, + Map? presetsDispositivo, + bool multiDevice = true, + }) => ConfiguracionEcualizador( + principal: principal ?? PresetEcualizador.flat, + porEmisora: const {}, + eqMultiDeviceEnabled: multiDevice, + presetsDispositivo: presetsDispositivo ?? const {}, + ); + + test( + 'con destino de dispositivo: modifica solo la banda, persiste como ' + 'Personalizado a nivel dispositivo y aplica la banda en vivo', + () async { + final base = PresetEcualizador.jazz; // [3, -1, -1.5, 2, 4] + final persistidosDispositivo = <(String, PresetEcualizador)>[]; + var persistirPrincipalLlamadas = 0; + final bandasAplicadas = <(int, double)>[]; + + await aplicarGananciaPorMediaId( + 'eq_gain:1:4', + cargarConfig: () async => + configCon(presetsDispositivo: {deviceId: base}), + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async { + persistidosDispositivo.add((id, preset)); + }, + persistirPrincipal: (preset) async { + persistirPrincipalLlamadas++; + }, + aplicarBanda: (indice, db) async { + bandasAplicadas.add((indice, db)); + }, + ); + + expect(persistirPrincipalLlamadas, 0); + expect(persistidosDispositivo, hasLength(1)); + final (idPersistido, presetPersistido) = persistidosDispositivo.single; + expect(idPersistido, deviceId); + expect(presetPersistido.nombre, 'Personalizado'); + expect(presetPersistido.bandas, [3.0, 4.0, -1.5, 2.0, 4.0]); + expect(bandasAplicadas, [(1, 4.0)]); + }, + ); + + test( + 'con destino null persiste al principal partiendo de sus bandas', + () async { + PresetEcualizador? persistidoPrincipal; + var persistirDispositivoLlamadas = 0; + + await aplicarGananciaPorMediaId( + 'eq_gain:0:-2', + cargarConfig: () async => + configCon(principal: PresetEcualizador.pop, multiDevice: false), + dispositivoDestino: () async => null, + persistirDispositivo: (id, preset) async { + persistirDispositivoLlamadas++; + }, + persistirPrincipal: (preset) async { + persistidoPrincipal = preset; + }, + aplicarBanda: (indice, db) async {}, + ); + + expect(persistirDispositivoLlamadas, 0); + expect(persistidoPrincipal, isNotNull); + expect(persistidoPrincipal!.nombre, 'Personalizado'); + // Pop: [1.0, 1.5, 0.5, 1.0, 1.5] con banda 0 → -2. + expect(persistidoPrincipal!.bandas, [-2.0, 1.5, 0.5, 1.0, 1.5]); + }, + ); + + test('id malformado: no persiste, no aplica, no lanza', () async { + var llamadas = 0; + + await aplicarGananciaPorMediaId( + 'eq_gain:9:99', + cargarConfig: () async => configCon(), + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async { + llamadas++; + }, + persistirPrincipal: (preset) async { + llamadas++; + }, + aplicarBanda: (indice, db) async { + llamadas++; + }, + ); + + expect(llamadas, 0); + }); + + test( + 'cargarConfig que lanza degrada a no-op sin propagar la excepción', + () async { + var llamadas = 0; + + await aplicarGananciaPorMediaId( + 'eq_gain:0:2', + cargarConfig: () async => throw Exception('prefs rotas'), + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async { + llamadas++; + }, + persistirPrincipal: (preset) async { + llamadas++; + }, + aplicarBanda: (indice, db) async { + llamadas++; + }, + ); + + expect(llamadas, 0); + }, + ); + + test( + 'con override por-emisora de la estación actual persiste pero NO ' + 'aplica la banda en vivo', + () async { + final persistidosDispositivo = <(String, PresetEcualizador)>[]; + final bandasAplicadas = <(int, double)>[]; + + await aplicarGananciaPorMediaId( + 'eq_gain:1:4', + cargarConfig: () async => configCon(), + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async { + persistidosDispositivo.add((id, preset)); + }, + persistirPrincipal: (preset) async {}, + aplicarBanda: (indice, db) async { + bandasAplicadas.add((indice, db)); + }, + uuidActual: 'uuid-1', + clavesPorEmisora: () async => {'uuid-1'}, + clavesMatriz: () async => {}, + ); + + expect(persistidosDispositivo, hasLength(1)); + expect(bandasAplicadas, isEmpty); + }, + ); + + test( + 'con entrada de matriz estación:dispositivo destino persiste pero NO ' + 'aplica la banda en vivo', + () async { + final persistidosDispositivo = <(String, PresetEcualizador)>[]; + final bandasAplicadas = <(int, double)>[]; + + await aplicarGananciaPorMediaId( + 'eq_gain:1:4', + cargarConfig: () async => configCon(), + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async { + persistidosDispositivo.add((id, preset)); + }, + persistirPrincipal: (preset) async {}, + aplicarBanda: (indice, db) async { + bandasAplicadas.add((indice, db)); + }, + uuidActual: 'uuid-1', + clavesPorEmisora: () async => {}, + clavesMatriz: () async => {'uuid-1:$deviceId'}, + ); + + expect(persistidosDispositivo, hasLength(1)); + expect(bandasAplicadas, isEmpty); + }, + ); + + test( + 'con estación actual sin overrides sí aplica la banda en vivo', + () async { + final bandasAplicadas = <(int, double)>[]; + + await aplicarGananciaPorMediaId( + 'eq_gain:1:4', + cargarConfig: () async => configCon(), + dispositivoDestino: () async => deviceId, + persistirDispositivo: (id, preset) async {}, + persistirPrincipal: (preset) async {}, + aplicarBanda: (indice, db) async { + bandasAplicadas.add((indice, db)); + }, + uuidActual: 'uuid-1', + clavesPorEmisora: () async => {}, + clavesMatriz: () async => {}, + ); + + expect(bandasAplicadas, [(1, 4.0)]); + }, + ); + }); } /// Fake `metadatosDe` that always resolves to an empty map — used by every diff --git a/test/servicios/servicio_dispositivo_audio_real_test.dart b/test/servicios/servicio_dispositivo_audio_real_test.dart index 92e69dd..0d8b3ea 100644 --- a/test/servicios/servicio_dispositivo_audio_real_test.dart +++ b/test/servicios/servicio_dispositivo_audio_real_test.dart @@ -146,5 +146,61 @@ void main() { expect(granted, isFalse); }, ); + + // ── Fix: robust active-device detection (stale green dot) ──────────────── + + test( + 'resubscribir sends cancel+listen to the event channel and forwards ' + 'the device the fresh onListen emits', + () async { + var listens = 0; + var cancels = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + const EventChannel(methodChannelName), + MockStreamHandler.inline( + onListen: (arguments, events) { + listens++; + // Mirrors the native onListen immediate resync: every + // (re)subscription receives the current active device. + events.success({ + 'id': 'bt_a2dp:AA:BB:CC:DD:EE:FF', + 'type': 8, + 'name': 'My BT Device', + }); + }, + onCancel: (arguments) => cancels++, + ), + ); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockStreamHandler( + const EventChannel(methodChannelName), + null, + ); + }); + + final servicioLocal = ServicioDispositivoAudioReal(); + addTearDown(servicioLocal.dispose); + + // The constructor's initial subscription delivers the first resync. + final primero = await servicioLocal.onDispositivoCambiado.first; + expect(primero.id, 'bt_a2dp:AA:BB:CC:DD:EE:FF'); + expect(listens, 1); + expect(cancels, 0); + + final segundoFuturo = servicioLocal.onDispositivoCambiado.first; + await servicioLocal.resubscribir(); + final segundo = await segundoFuturo; + + expect(cancels, 1); + expect(listens, 2); + expect(segundo.tipo, TipoDispositivo.bluetoothA2dp); + expect( + servicioLocal.dispositivoActual?.id, + 'bt_a2dp:AA:BB:CC:DD:EE:FF', + ); + }, + ); }); } diff --git a/test/servicios/servicio_dispositivo_audio_toggle_test.dart b/test/servicios/servicio_dispositivo_audio_toggle_test.dart index 7237187..48c3f2f 100644 --- a/test/servicios/servicio_dispositivo_audio_toggle_test.dart +++ b/test/servicios/servicio_dispositivo_audio_toggle_test.dart @@ -24,6 +24,9 @@ class NullServicioDispositivoAudio extends ServicioDispositivoAudio { ); } + @override + Future resubscribir() async {} + @override Future solicitarPermisoBluetooth() async => true;