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.
This commit is contained in:
@@ -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
|
||||
@@ -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<out AudioDeviceInfo>) {
|
||||
// Emit the current active output device when something connects.
|
||||
|
||||
+19
-1
@@ -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<String>? _errorSubscription;
|
||||
StreamSubscription<EventoAlarmaAndroid>? _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<EstadoEcualizador>().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();
|
||||
|
||||
@@ -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<DispositivoAudio>? _deviceSub;
|
||||
Future<void>? _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<void> refrescarDispositivoActual() {
|
||||
final enCurso = _refrescoEnCurso;
|
||||
if (enCurso != null) return enCurso;
|
||||
final refresco = _refrescarDispositivoActual().whenComplete(() {
|
||||
_refrescoEnCurso = null;
|
||||
});
|
||||
_refrescoEnCurso = refresco;
|
||||
return refresco;
|
||||
}
|
||||
|
||||
Future<void> _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 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => 'مجلد محدد';
|
||||
}
|
||||
|
||||
@@ -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 => 'নির্বাচিত ফোল্ডার';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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é';
|
||||
}
|
||||
|
||||
@@ -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 => 'चयनित फ़ोल्डर';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '選択したフォルダー';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => 'Выбранная папка';
|
||||
}
|
||||
|
||||
@@ -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 => '已选文件夹';
|
||||
}
|
||||
|
||||
@@ -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<EstadoEcualizador>().refrescarDispositivoActual());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
@@ -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:<indice>`. 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:<indice>` 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:<indice>:<db>`. 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<MediaItem> presetsEq(List<PresetEcualizador> 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:<indice>`, title
|
||||
/// `<frecuencia> · <ganancia actual>` (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<MediaItem> 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:<indice>:<db>`. 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<MediaItem> 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<PresetEcualizador> 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:<indice>` [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:<indice>:<db>` [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<String> 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<String> clavesPorEmisora,
|
||||
required Set<String> 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:<nombre>` 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<void> aplicarPresetPorMediaId(
|
||||
String id, {
|
||||
@@ -1250,9 +1439,34 @@ Future<void> aplicarPresetPorMediaId(
|
||||
required Future<Set<String>> Function() clavesPorEmisora,
|
||||
required Future<void> Function(PresetEcualizador) persistirPrincipal,
|
||||
required Future<void> Function(PresetEcualizador) aplicar,
|
||||
Future<String?> Function()? dispositivoDestino,
|
||||
Future<void> Function(String deviceId, PresetEcualizador preset)?
|
||||
persistirDispositivo,
|
||||
Future<Set<String>> 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 <String>{} : await clavesMatriz(),
|
||||
deviceIdDestino: destino,
|
||||
)) {
|
||||
await aplicar(preset);
|
||||
}
|
||||
await persistirDispositivo!(destino, preset);
|
||||
return;
|
||||
}
|
||||
|
||||
await persistirPrincipal(preset);
|
||||
if (debeAplicarPrincipalAhora(
|
||||
uuidActual: uuidActual,
|
||||
@@ -1262,6 +1476,75 @@ Future<void> aplicarPresetPorMediaId(
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates an `eq_gain:<indice>:<db>` 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<void> aplicarGananciaPorMediaId(
|
||||
String id, {
|
||||
required Future<ConfiguracionEcualizador> Function() cargarConfig,
|
||||
required Future<String?> Function() dispositivoDestino,
|
||||
required Future<void> Function(String deviceId, PresetEcualizador preset)
|
||||
persistirDispositivo,
|
||||
required Future<void> Function(PresetEcualizador preset) persistirPrincipal,
|
||||
required Future<void> Function(int indice, double db) aplicarBanda,
|
||||
String? uuidActual,
|
||||
Future<Set<String>> Function()? clavesPorEmisora,
|
||||
Future<Set<String>> 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<double>.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 <String>{} : await clavesPorEmisora(),
|
||||
clavesMatriz:
|
||||
clavesMatriz == null ? const <String>{} : 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
|
||||
|
||||
@@ -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<DispositivoAudio?> _dispositivoActivoAuto() async {
|
||||
try {
|
||||
final raw = await _canalDispositivos
|
||||
.invokeMethod<Map<dynamic, dynamic>>('getActiveDevice')
|
||||
.timeout(_timeoutConsultaDispositivo);
|
||||
if (raw == null) return null;
|
||||
return ServicioDispositivoAudioReal.dispositivoDesdeMapa(
|
||||
Map<String, dynamic>.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<String?> _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<PresetEcualizador> _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<String, BehaviorSubject<Map<String, dynamic>>> _hijosSubjects = {};
|
||||
|
||||
@override
|
||||
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
||||
_hijosSubjects.putIfAbsent(
|
||||
parentMediaId,
|
||||
() => BehaviorSubject.seeded(<String, dynamic>{}),
|
||||
);
|
||||
|
||||
/// 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(<String, dynamic>{});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> 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
|
||||
|
||||
@@ -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<DispositivoAudio> 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<void> 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<void> resubscribir() async {
|
||||
await _eventSub?.cancel();
|
||||
_subscribeToEvents();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoBluetooth() async {
|
||||
final granted = await _methodChannel.invokeMethod<bool>(
|
||||
@@ -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<String, dynamic> map) =>
|
||||
_mapToDispositivo(map);
|
||||
|
||||
static DispositivoAudio _mapToDispositivo(Map<String, dynamic> map) {
|
||||
final id = map['id'] as String? ?? 'builtin_speaker';
|
||||
final type = map['type'] as int? ?? 2;
|
||||
|
||||
+5
-5
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
void completarResubscribir() => _resubscribir.complete();
|
||||
|
||||
@override
|
||||
Future<void> resubscribir() async {
|
||||
resubscribirLlamadas++;
|
||||
await _resubscribir.future;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,11 +433,20 @@ class FakeServicioDispositivoAudioThrows extends ServicioDispositivoAudio {
|
||||
@override
|
||||
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
|
||||
|
||||
/// Number of times [resubscribir] was called.
|
||||
int resubscribirLlamadas = 0;
|
||||
|
||||
@override
|
||||
Future<DispositivoAudio> obtenerDispositivoActual() async {
|
||||
throw Exception('Platform channel error: device unavailable');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resubscribir() async {
|
||||
resubscribirLlamadas++;
|
||||
throw Exception('Platform channel error: resubscribe unavailable');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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<DispositivoAudio>.broadcast();
|
||||
DispositivoAudio? _dispositivoActual;
|
||||
|
||||
@@ -481,6 +493,11 @@ class FakeServicioDispositivoAudio extends ServicioDispositivoAudio {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resubscribir() async {
|
||||
resubscribirLlamadas++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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<void> dispose() async {
|
||||
await _controller.close();
|
||||
|
||||
@@ -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:<i> 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:<i>:<g> 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 => <String>{},
|
||||
clavesMatriz: () async => <String>{},
|
||||
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 => <String>{},
|
||||
clavesMatriz: () async => <String>{},
|
||||
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 => <String>{},
|
||||
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<String, PresetEcualizador>? 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 => <String>{},
|
||||
);
|
||||
|
||||
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 => <String>{},
|
||||
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 => <String>{},
|
||||
clavesMatriz: () async => <String>{},
|
||||
);
|
||||
|
||||
expect(bandasAplicadas, [(1, 4.0)]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fake `metadatosDe` that always resolves to an empty map — used by every
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ class NullServicioDispositivoAudio extends ServicioDispositivoAudio {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> resubscribir() async {}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoBluetooth() async => true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user