diff --git a/android/app/src/main/res/drawable/ic_auto_eq_off.xml b/android/app/src/main/res/drawable/ic_auto_eq_off.xml
new file mode 100644
index 0000000..34416fa
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_auto_eq_off.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_auto_eq_on.xml b/android/app/src/main/res/drawable/ic_auto_eq_on.xml
new file mode 100644
index 0000000..1584ea4
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_auto_eq_on.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb
index 8d77b73..b649158 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "إصلاح",
"alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.",
- "alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك."
+ "alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.",
+ "autoEqDisableOption": "تعطيل"
}
diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb
index 7c170d0..11615c1 100644
--- a/lib/l10n/app_bn.arb
+++ b/lib/l10n/app_bn.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "সমাধান করুন",
"alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।",
- "alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।"
+ "alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।",
+ "autoEqDisableOption": "বন্ধ করুন"
}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 2c4bfd3..d972c03 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Beheben",
"alarmDiagnosticsIntentUnavailable": "Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.",
- "alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen."
+ "alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen.",
+ "autoEqDisableOption": "Deaktivieren"
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index ccedce8..097fcca 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Fix this",
"alarmDiagnosticsIntentUnavailable": "Couldn't open that settings screen on this phone. Try looking for it manually in Settings.",
- "alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet."
+ "alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet.",
+ "autoEqDisableOption": "Disable"
}
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index cf54457..2e0f8c7 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -833,5 +833,6 @@
},
"alarmDiagnosticsFixAction": "Solucionar",
"alarmDiagnosticsIntentUnavailable": "No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.",
- "alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma."
+ "alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma.",
+ "autoEqDisableOption": "Desactivar"
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 3e286a2..9267ffb 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Corriger",
"alarmDiagnosticsIntentUnavailable": "Impossible d'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.",
- "alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme."
+ "alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme.",
+ "autoEqDisableOption": "Désactiver"
}
diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb
index 9de8d9d..d08ec56 100644
--- a/lib/l10n/app_hi.arb
+++ b/lib/l10n/app_hi.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "ठीक करें",
"alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।",
- "alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।"
+ "alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।",
+ "autoEqDisableOption": "बंद करें"
}
diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb
index 1fff03d..dcb8c90 100644
--- a/lib/l10n/app_id.arb
+++ b/lib/l10n/app_id.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Perbaiki",
"alarmDiagnosticsIntentUnavailable": "Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.",
- "alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu."
+ "alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu.",
+ "autoEqDisableOption": "Nonaktifkan"
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 21d2cc9..aeb9ee9 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Risolvi",
"alarmDiagnosticsIntentUnavailable": "Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.",
- "alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia."
+ "alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia.",
+ "autoEqDisableOption": "Disattiva"
}
diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb
index 1031fc0..d0151af 100644
--- a/lib/l10n/app_ja.arb
+++ b/lib/l10n/app_ja.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "修正する",
"alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。",
- "alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。"
+ "alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。",
+ "autoEqDisableOption": "無効化"
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 155136c..b234893 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Resolver",
"alarmDiagnosticsIntentUnavailable": "Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.",
- "alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme."
+ "alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme.",
+ "autoEqDisableOption": "Desativar"
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 3be5bf3..32e3f7b 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "Исправить",
"alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.",
- "alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника."
+ "alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.",
+ "autoEqDisableOption": "Отключить"
}
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index 47af98e..0a5d154 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -874,5 +874,6 @@
},
"alarmDiagnosticsFixAction": "解决",
"alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。",
- "alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。"
+ "alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。",
+ "autoEqDisableOption": "关闭"
}
diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart
index 1e97997..22bef5f 100644
--- a/lib/l10n/gen/app_localizations.dart
+++ b/lib/l10n/gen/app_localizations.dart
@@ -3187,6 +3187,12 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Todavía no pudimos revisar tus ajustes de alarma.'**
String get alarmDiagnosticsUnavailableHint;
+
+ /// No description provided for @autoEqDisableOption.
+ ///
+ /// In es, this message translates to:
+ /// **'Desactivar'**
+ String get autoEqDisableOption;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart
index bdc6149..40605e7 100644
--- a/lib/l10n/gen/app_localizations_ar.dart
+++ b/lib/l10n/gen/app_localizations_ar.dart
@@ -1760,4 +1760,7 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.';
+
+ @override
+ String get autoEqDisableOption => 'تعطيل';
}
diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart
index 54abf51..ae3f5aa 100644
--- a/lib/l10n/gen/app_localizations_bn.dart
+++ b/lib/l10n/gen/app_localizations_bn.dart
@@ -1768,4 +1768,7 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।';
+
+ @override
+ String get autoEqDisableOption => 'বন্ধ করুন';
}
diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart
index 5da415c..3be5612 100644
--- a/lib/l10n/gen/app_localizations_de.dart
+++ b/lib/l10n/gen/app_localizations_de.dart
@@ -1782,4 +1782,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Wir konnten deine Alarmeinstellungen noch nicht prüfen.';
+
+ @override
+ String get autoEqDisableOption => 'Deaktivieren';
}
diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart
index 9390c61..19433cc 100644
--- a/lib/l10n/gen/app_localizations_en.dart
+++ b/lib/l10n/gen/app_localizations_en.dart
@@ -1762,4 +1762,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'We couldn\'t check your alarm settings yet.';
+
+ @override
+ String get autoEqDisableOption => 'Disable';
}
diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart
index 4b1a268..1a201ea 100644
--- a/lib/l10n/gen/app_localizations_es.dart
+++ b/lib/l10n/gen/app_localizations_es.dart
@@ -1778,4 +1778,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Todavía no pudimos revisar tus ajustes de alarma.';
+
+ @override
+ String get autoEqDisableOption => 'Desactivar';
}
diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart
index 9e7eea0..2c935cf 100644
--- a/lib/l10n/gen/app_localizations_fr.dart
+++ b/lib/l10n/gen/app_localizations_fr.dart
@@ -1788,4 +1788,7 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Nous n\'avons pas encore pu vérifier vos paramètres d\'alarme.';
+
+ @override
+ String get autoEqDisableOption => 'Désactiver';
}
diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart
index edae3ef..b8fd3de 100644
--- a/lib/l10n/gen/app_localizations_hi.dart
+++ b/lib/l10n/gen/app_localizations_hi.dart
@@ -1763,4 +1763,7 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।';
+
+ @override
+ String get autoEqDisableOption => 'बंद करें';
}
diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart
index d7594bf..d8b2eca 100644
--- a/lib/l10n/gen/app_localizations_id.dart
+++ b/lib/l10n/gen/app_localizations_id.dart
@@ -1772,4 +1772,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Kami belum bisa memeriksa pengaturan alarmmu.';
+
+ @override
+ String get autoEqDisableOption => 'Nonaktifkan';
}
diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart
index 7ca9c9b..4c8e8f8 100644
--- a/lib/l10n/gen/app_localizations_it.dart
+++ b/lib/l10n/gen/app_localizations_it.dart
@@ -1786,4 +1786,7 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Non abbiamo ancora potuto controllare le impostazioni della sveglia.';
+
+ @override
+ String get autoEqDisableOption => 'Disattiva';
}
diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart
index 079753d..76b4d03 100644
--- a/lib/l10n/gen/app_localizations_ja.dart
+++ b/lib/l10n/gen/app_localizations_ja.dart
@@ -1711,4 +1711,7 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint => 'アラームの設定をまだ確認できていません。';
+
+ @override
+ String get autoEqDisableOption => '無効化';
}
diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart
index b2d5df9..9067f87 100644
--- a/lib/l10n/gen/app_localizations_pt.dart
+++ b/lib/l10n/gen/app_localizations_pt.dart
@@ -1773,4 +1773,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Ainda não conseguimos verificar as configurações do seu alarme.';
+
+ @override
+ String get autoEqDisableOption => 'Desativar';
}
diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart
index ea9c019..9559e89 100644
--- a/lib/l10n/gen/app_localizations_ru.dart
+++ b/lib/l10n/gen/app_localizations_ru.dart
@@ -1778,4 +1778,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint =>
'Мы пока не смогли проверить настройки вашего будильника.';
+
+ @override
+ String get autoEqDisableOption => 'Отключить';
}
diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart
index 1a0c678..71638d3 100644
--- a/lib/l10n/gen/app_localizations_zh.dart
+++ b/lib/l10n/gen/app_localizations_zh.dart
@@ -1699,4 +1699,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get alarmDiagnosticsUnavailableHint => '我们还无法检查你的闹钟设置。';
+
+ @override
+ String get autoEqDisableOption => '关闭';
}
diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart
index 46f53b7..b2ad3d8 100644
--- a/lib/servicios/navegacion_auto.dart
+++ b/lib/servicios/navegacion_auto.dart
@@ -10,6 +10,7 @@ import '../estado/orden_emisoras.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_favoritos.dart';
@@ -19,16 +20,22 @@ import 'servicio_favoritos.dart';
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
/// list's range returns `[]`, never throws.
-List paginaDe(List items, {required int pagina, required int tamano}) =>
- items.skip(pagina * tamano).take(tamano).toList();
+List paginaDe(
+ List items, {
+ required int pagina,
+ required int tamano,
+}) => items.skip(pagina * tamano).take(tamano).toList();
/// Whether a page after [pagina] exists for a list of [total] elements
/// (Design ADR-6): `true` iff at least one element remains beyond the
/// current page's slice. The exact-boundary case
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
/// reveal.
-bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
- total > (pagina + 1) * tamano;
+bool hayPaginaSiguiente(
+ int total, {
+ required int pagina,
+ required int tamano,
+}) => total > (pagina + 1) * tamano;
/// Browse-tree ordering comparator for a local-music folder's children
/// (Design "Directories before files", item 1): directories sort before
@@ -83,8 +90,7 @@ bool faviconUsable(String? favicon) {
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
// `true`) — check `host.isNotEmpty` explicitly to actually require a
// non-empty authority host.
- return (uri.scheme == 'http' || uri.scheme == 'https') &&
- uri.host.isNotEmpty;
+ return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
}
/// Deterministic rotation index over the 4 on-brand fallback arts, same
@@ -98,10 +104,11 @@ int indiceArtePara(String seed) =>
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
/// on-brand art the phone UI would pick for this station (per-station
/// parity), never a launcher-icon lookalike.
-String artUriPara(Emisora e) => faviconUsable(e.favicon)
- ? e.favicon!
- : 'android.resource://es.freetimelab.pluriwave/drawable/'
- 'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
+String artUriPara(Emisora e) =>
+ faviconUsable(e.favicon)
+ ? e.favicon!
+ : 'android.resource://es.freetimelab.pluriwave/drawable/'
+ 'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
/// Formats a human-readable audio-quality hint for the browse row's
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
@@ -212,6 +219,17 @@ class ConstructorArbolAuto {
/// (Design "Local root hidden until a folder is configured").
static const idMusicaLocal = 'musica_local';
+ /// Root folder id for the "Ecualizador" browsable folder (decision
+ /// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
+ /// presets, the currently-active one marked. Deliberately NOT added to
+ /// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
+ /// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
+ /// (which needs `AppLocalizations` -- this pure builder class does not
+ /// depend on it), not the generic station-list [hijos] path. Unlike
+ /// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
+ /// hidden.
+ static const idEcualizador = 'ecualizador';
+
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -295,12 +313,23 @@ class ConstructorArbolAuto {
};
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
- /// optionally Música Local), all non-playable.
+ /// optionally Música Local, Ecualizador), all non-playable.
///
- /// There is deliberately no equalizer folder: EQ is configured on the phone
- /// only. The car still gets the right sound, because the per-device preset
- /// is applied automatically when the output device changes — that lives in
- /// `EstadoEcualizador`, not in this tree.
+ /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
+ /// folder" rule that used to live in this doc comment (commit `2403da3`,
+ /// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
+ /// line 40). That rule was sound when written, but predated on-device
+ /// feedback showing that Android Auto custom actions don't surface
+ /// enough state for choosing among six presets: a monochrome icon cannot
+ /// legibly encode "which preset", and many head units render a custom
+ /// action icon-first, hiding its label. `Ecualizador` is a real
+ /// browsable folder again: "Desactivar" first, then the six factory
+ /// presets, the active one marked (children built by
+ /// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays
+ /// free of any `AppLocalizations` dependency, unlike that builder).
+ /// Always present, and LAST in the list (after Música Local, when
+ /// included) -- unlike [idMusicaLocal] it is never conditionally hidden.
+ /// Do not "restore" the no-folder rule without re-reading that decision.
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
@@ -311,6 +340,7 @@ class ConstructorArbolAuto {
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
+ _carpeta(idEcualizador, 'Ecualizador'),
];
MediaItem _carpeta(String id, String titulo) => MediaItem(
@@ -457,10 +487,11 @@ class ConstructorArbolAuto {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
- final docIds = paginaActual
- .where((n) => !n.esDirectorio)
- .map((n) => n.documentId)
- .toList();
+ final docIds =
+ paginaActual
+ .where((n) => !n.esDirectorio)
+ .map((n) => n.documentId)
+ .toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -486,8 +517,7 @@ class ConstructorArbolAuto {
// (direct or nested), absent for a folder that is genuinely empty
// even recursively (Spec "Folder has no tracks").
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
- if (hayContenidoReproducible)
- _itemReproducirAleatorio(documentIdPadre),
+ if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
@@ -725,10 +755,11 @@ class ConstructorArbolAuto {
final ordenados = [...buckets[idxBucket].nodos]
..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
- final docIds = paginaActual
- .where((n) => !n.esDirectorio)
- .map((n) => n.documentId)
- .toList();
+ final docIds =
+ paginaActual
+ .where((n) => !n.esDirectorio)
+ .map((n) => n.documentId)
+ .toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -743,20 +774,21 @@ class ConstructorArbolAuto {
}
final meta = metadatos[nodo.documentId];
final tituloMeta = meta?.titulo?.trim();
- final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
- ? tituloMeta
- : _tituloDesdeNombre(nodo.nombre);
+ final titulo =
+ (tituloMeta != null && tituloMeta.isNotEmpty)
+ ? tituloMeta
+ : _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
- final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
- ? artUriMeta
- : artUriLocal(nodo.documentId);
+ final artUri =
+ (artUriMeta != null && artUriMeta.isNotEmpty)
+ ? artUriMeta
+ : artUriLocal(nodo.documentId);
final artistaMeta = meta?.artista?.trim();
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: titulo,
- artist: (artistaMeta != null && artistaMeta.isNotEmpty)
- ? artistaMeta
- : null,
+ artist:
+ (artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
playable: true,
artUri: Uri.parse(artUri),
displaySubtitle: subtituloCalidadLocal(meta),
@@ -777,15 +809,17 @@ class ConstructorArbolAuto {
required List grupos,
required List favoritos,
}) {
- final carpetas = grupos
- .where((g) => !g.esSinAsignar)
- .where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
- .take(_maxGruposPorFavoritos)
- .map(itemGrupo)
- .toList();
- final sinAsignar = favoritos
- .where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
- .toList();
+ final carpetas =
+ grupos
+ .where((g) => !g.esSinAsignar)
+ .where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
+ .take(_maxGruposPorFavoritos)
+ .map(itemGrupo)
+ .toList();
+ final sinAsignar =
+ favoritos
+ .where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
+ .toList();
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
}
@@ -801,13 +835,62 @@ class ConstructorArbolAuto {
if (!esCarpetaGrupo(grupoMediaId)) return const [];
final id = grupoMediaId.substring(_prefijoGrupo.length);
if (id.isEmpty) return const [];
- final miembros = favoritos
- .where((e) => e.grupoFavoritosId == id)
- .toList();
+ final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
+
+ /// Equalizer preset-selection media-id prefix (decision
+ /// `auto/ecualizador-diseno`): `eq_preset:` for the six
+ /// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
+ /// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
+ /// other prefix/bare id in this class -- diverges from every sibling
+ /// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
+ /// bare folder id (none of which starts with "eq_preset:").
+ static const _prefijoPresetEq = 'eq_preset:';
+
+ /// Reserved sentinel raw value for the "Desactivar" item under
+ /// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
+ /// collides with a real [PresetEcualizador.nombre]; none of the six
+ /// factory presets is named this.
+ static const _valorDesactivarEq = '_off_';
+
+ /// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
+ /// sentinel under [_prefijoPresetEq].
+ static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
+
+ /// Whether [id] identifies an item under the Ecualizador folder (a
+ /// factory preset OR "Desactivar").
+ bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
+
+ /// Whether [id] is specifically the "Desactivar" item (not a factory
+ /// preset). Only meaningful alongside [esPresetEqMediaId].
+ bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
+
+ /// Builds a factory preset's selection media id, matched by raw
+ /// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
+ /// [PresetEcualizador.presets] already uses for equality, so a locale
+ /// change never breaks resolution.
+ String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
+
+ /// Resolves an `eq_preset:` [id] to the matching factory
+ /// [PresetEcualizador] from [presets] (defaults to
+ /// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
+ /// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
+ /// any id that doesn't match [esPresetEqMediaId] -- never throws.
+ PresetEcualizador? resolverPresetEq(
+ String id, {
+ List? presets,
+ }) {
+ if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
+ final nombre = id.substring(_prefijoPresetEq.length);
+ final lista = presets ?? PresetEcualizador.presets;
+ for (final preset in lista) {
+ if (preset.nombre == nombre) return preset;
+ }
+ return null;
+ }
}
/// Routing seam between a car-tapped `emisora:` media id and the
@@ -847,6 +930,43 @@ Future reproducirPorMediaId(
await reproducir(item);
}
+/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
+/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
+/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
+/// resolved factory preset to [aplicarPreset] -- turning the equalizer
+/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
+/// is currently `false`, so tapping a preset while the equalizer is off
+/// both re-enables it AND applies the tapped preset's gains (Spec
+/// "selecting a preset while disabled enables it and applies it"), never
+/// silently just remembering the preset for later. [aplicarPreset] runs
+/// BEFORE the enable check so the native engine only ever pushes gains
+/// once, for the NEW preset -- never once for whatever was active before,
+/// then again for the new one.
+///
+/// A stale/unresolvable id, or any id that doesn't match
+/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
+/// runs and no exception propagates.
+Future seleccionarPresetEqPorMediaId(
+ String id, {
+ required bool activo,
+ required Future Function(PresetEcualizador) aplicarPreset,
+ required Future Function(bool) activarEcualizador,
+}) async {
+ final constructor = ConstructorArbolAuto();
+ if (!constructor.esPresetEqMediaId(id)) return;
+
+ if (constructor.esDesactivarEqMediaId(id)) {
+ await activarEcualizador(false);
+ return;
+ }
+
+ final preset = constructor.resolverPresetEq(id);
+ if (preset == null) return;
+
+ await aplicarPreset(preset);
+ if (!activo) await activarEcualizador(true);
+}
+
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
@@ -904,10 +1024,8 @@ List ordenarPorCalidadLocal(
) {
final ordenados = List.from(nodos);
ordenados.sort(
- (a, b) => compararCalidadLocal(
- metadatos[a.documentId],
- metadatos[b.documentId],
- ),
+ (a, b) =>
+ compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
);
return ordenados;
}
@@ -948,12 +1066,13 @@ List bucketsDe(List nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
return _rangosBucket.map((rango) {
final (etiqueta, desde, hasta) = rango;
- final coincidencias = pistas.where((n) {
- final recortado = n.nombre.trim();
- if (recortado.isEmpty) return false;
- final letra = recortado[0].toLowerCase();
- return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
- }).toList();
+ final coincidencias =
+ pistas.where((n) {
+ final recortado = n.nombre.trim();
+ if (recortado.isEmpty) return false;
+ final letra = recortado[0].toLowerCase();
+ return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
+ }).toList();
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
}).toList();
}
@@ -1119,9 +1238,10 @@ Future reproducirCarpetaLocal(
}
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
- final pistas = aleatorio
- ? mezclarFisherYates(recolectadas, rng ?? Random())
- : recolectadas;
+ final pistas =
+ aleatorio
+ ? mezclarFisherYates(recolectadas, rng ?? Random())
+ : recolectadas;
if (pistas.isEmpty) return;
await iniciarCola(pistas);
diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart
index 7f53582..e9ef72b 100644
--- a/lib/servicios/servicio_audio.dart
+++ b/lib/servicios/servicio_audio.dart
@@ -66,7 +66,10 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
/// the browse tree and the car-tap path already show, instead of a blank
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
/// dependency — so it is unit-testable without instantiating the handler.
-MediaItem mediaItemParaEmisora(Emisora emisora, {required AppLocalizations l10n}) {
+MediaItem mediaItemParaEmisora(
+ Emisora emisora, {
+ required AppLocalizations l10n,
+}) {
return MediaItem(
id: emisora.url,
title: localizedStationName(l10n, emisora.nombre),
@@ -108,7 +111,6 @@ Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
/// `MediaControl`/`customAction` namespace, never compared against a
/// media id).
const accionEqToggle = 'eq_toggle';
-const accionEqPresetSiguiente = 'eq_preset_siguiente';
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
@@ -151,38 +153,126 @@ String nombrePresetVisible(AppLocalizations l10n, String nombre) {
}
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
-/// screen (Design "EQ custom actions", item 4) — deliberately just 2: an
-/// on/off toggle plus a cycling-preset action, NOT one action per preset,
-/// since Android Auto only surfaces a limited number of custom actions.
+/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
+/// toggle. The previous design paired this with a SECOND action that cycled
+/// through the six factory presets; that action is REMOVED. On-device
+/// feedback: many head units render custom actions icon-first, so two
+/// static, non-parametrized glyphs sitting side by side looked identical/
+/// dead even though the toggle's own icon DID change and the cycle action
+/// DID work — a monochrome icon simply cannot legibly encode "which of six
+/// presets" the way a browsable list's text rows can. Preset selection now
+/// lives in the "Ecualizador" browsable folder instead (see
+/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
+/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
+/// instead.
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
/// without the native Equalizer effect gets no EQ actions at all, not
-/// broken ones. Reuses the SAME bundled `ic_stat_pluriwave` drawable the
-/// notification's own status-bar icon already uses (an equalizer-bars
-/// glyph) — zero new native assets. Pure, no handler dependency.
+/// broken ones.
+///
+/// On-device feedback follow-up: this action used to reuse the SAME
+/// `ic_stat_pluriwave` drawable as everything else and was visually
+/// indistinguishable on a car head unit, which foregrounds the icon over
+/// the label. It now gets its own dedicated drawables
+/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
+/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
+/// handler dependency.
List controlesEcualizadorPersonalizados({
required bool disponible,
required bool activo,
- required PresetEcualizador presetActual,
required AppLocalizations l10n,
}) {
if (!disponible) return const [];
return [
MediaControl.custom(
- androidIcon: 'drawable/ic_stat_pluriwave',
- label: activo
- ? l10n.eqCustomActionDisableLabel
- : l10n.eqCustomActionEnableLabel,
+ androidIcon:
+ activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
+ label:
+ activo
+ ? l10n.eqCustomActionDisableLabel
+ : l10n.eqCustomActionEnableLabel,
name: accionEqToggle,
),
- MediaControl.custom(
- androidIcon: 'drawable/ic_stat_pluriwave',
- label: l10n.eqCustomActionPresetLabel(
- nombrePresetVisible(l10n, presetActual.nombre),
- ),
- name: accionEqPresetSiguiente,
+ ];
+}
+
+/// Content-style extras for the Ecualizador folder's items (decision
+/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
+/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
+/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
+/// are deliberately duplicated across this handler/service layer and the
+/// pure browse-tree builder layer rather than cross-layer-shared). List
+/// style, not grid: these items are short text options with no artwork of
+/// their own, unlike a station or local-track tile.
+const _contentStyleListaEq = {
+ 'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
+ 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
+};
+
+/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
+/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
+/// preset must be visibly marked").
+///
+/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
+/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
+/// and REJECTED as the marking mechanism: this project's `audio_service`
+/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
+/// 's list/grid hints are exposed — and the raw platform key itself is
+/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
+/// was already listened to"), not item SELECTION; repurposing it here could
+/// render as "already played" on some head units, which would be actively
+/// misleading for a preset picker, and there is no way to verify its actual
+/// rendering on a real head unit from this environment. A plain-text
+/// marker renders identically and unambiguously on every head unit, which
+/// an unverifiable, semantically-mismatched extras key cannot guarantee.
+String _marcarActivoEq(String titulo, {required bool activo}) =>
+ activo ? '✓ $titulo' : titulo;
+
+/// Builds the "Ecualizador" folder's children for the Android Auto browse
+/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
+/// six factory presets in [PresetEcualizador.presets] order, each localized
+/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
+/// label already uses, so a preset's name reads identically whether the
+/// driver sees it in the now-playing screen's tooltip or in this folder.
+/// All items are playable: tapping one is dispatched through
+/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
+/// dart`), the same seam every other browse-tree leaf already uses; this
+/// folder has no sub-browsing. Exactly one item is marked active via
+/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
+/// whichever preset equals [presetActual] — never both at once, and never
+/// zero once this function is reached (an unresolvable [presetActual] with
+/// [activo] `true` simply marks nothing, which cannot happen in practice
+/// since [presetActual] always originates from [PresetEcualizador.presets]
+/// or a "Personalizado" tweak that would just leave every item unmarked
+/// rather than mis-marking one).
+List itemsEcualizadorAuto({
+ required bool activo,
+ required PresetEcualizador presetActual,
+ required AppLocalizations l10n,
+}) {
+ final constructor = ConstructorArbolAuto();
+ final items = [
+ MediaItem(
+ id: ConstructorArbolAuto.idDesactivarEq,
+ title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
+ playable: true,
+ extras: _contentStyleListaEq,
),
];
+ for (final preset in PresetEcualizador.presets) {
+ items.add(
+ MediaItem(
+ id: constructor.idPresetEq(preset.nombre),
+ title: _marcarActivoEq(
+ nombrePresetVisible(l10n, preset.nombre),
+ activo: activo && preset == presetActual,
+ ),
+ playable: true,
+ extras: _contentStyleListaEq,
+ ),
+ );
+ }
+ return items;
}
/// Wrapper de alto nivel para el UI.
@@ -473,7 +563,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
- presetActual: _presetActual,
l10n: _textos,
);
@@ -1081,14 +1170,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _reproducirEntradaCola(anterior.actual);
}
- /// Dispatches the equalizer's 2 custom actions (item 4, Design "EQ custom
- /// actions"): `accionEqToggle` flips on/off, `accionEqPresetSiguiente`
- /// cycles to the next factory preset. Both delegate to the existing
- /// [setEcualizadorActivo]/[aplicarPreset] — the SAME entry points the
+ /// Dispatches the equalizer's only custom action (decision
+ /// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
+ /// to the existing [setEcualizadorActivo] — the SAME entry point the
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
- /// phone tap have identical effects and both refresh the custom action's
- /// label via `_actualizarControlesEq()` (already wired into those two
- /// methods). Any other [name] is a no-op — never throws.
+ /// phone tap have identical effects and both refresh the action's label
+ /// via `_actualizarControlesEq()` (already wired into that method). The
+ /// preset-cycling action that used to live here is REMOVED — preset
+ /// selection now goes through the "Ecualizador" browsable folder (see
+ /// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched
+ /// from [playFromMediaId] below). Any other [name] is a no-op — never
+ /// throws.
@override
Future customAction(
String name, [
@@ -1097,8 +1189,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
switch (name) {
case accionEqToggle:
await setEcualizadorActivo(!_ecualizadorActivo);
- case accionEqPresetSiguiente:
- await aplicarPreset(presetSiguiente(_presetActual));
}
}
@@ -1145,6 +1235,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler
fuente: fuenteLocal,
);
if (musicaLocal != null) return musicaLocal;
+ // Ecualizador folder (decision `auto/ecualizador-diseno`): needs no
+ // external data source, unlike every branch below it -- checked
+ // before the `_fuenteNavegacionGlobal` gate, mirroring how the
+ // local-music branch above is also resolved before that gate.
+ if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
+ return itemsEcualizadorAuto(
+ activo: _ecualizadorActivo,
+ presetActual: _presetActual,
+ l10n: _textos,
+ );
+ }
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return const [];
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
@@ -1215,14 +1316,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (fuenteLocal == null) return;
await reproducirCarpetaLocal(
mediaId,
- aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(
- mediaId,
- ),
+ aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId),
fuente: fuenteLocal,
iniciarCola: _iniciarColaLocal,
);
return;
}
+ // Equalizer preset selection (decision `auto/ecualizador-diseno`):
+ // THIRD branch, same unconditional-return shape as the two above --
+ // an `eq_preset:` id never falls through to station routing.
+ if (constructorArbol.esPresetEqMediaId(mediaId)) {
+ await seleccionarPresetEqPorMediaId(
+ mediaId,
+ activo: _ecualizadorActivo,
+ aplicarPreset: aplicarPreset,
+ activarEcualizador: setEcualizadorActivo,
+ );
+ return;
+ }
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return;
await reproducirPorMediaId(
diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart
index 4f19239..674452a 100644
--- a/test/servicios/navegacion_auto_test.dart
+++ b/test/servicios/navegacion_auto_test.dart
@@ -5,33 +5,38 @@ import 'package:flutter_test/flutter_test.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';
void main() {
group('faviconUsable', () {
- test('rechaza favicons malformados o no http(s), acepta http(s) válidos', () {
- final casos = {
- null: false,
- '': false,
- ' ': false,
- 'ftp://cdn.example.com/logo.png': false,
- 'cdn.example.com/logo.png': false,
- 'http://': false,
- '/relative/path/logo.png': false,
- 'not a url at all \$\$\$ ///': false,
- 'http://cdn.example.com/logo.png': true,
- 'https://cdn.example.com/logo.png': true,
- };
+ test(
+ 'rechaza favicons malformados o no http(s), acepta http(s) válidos',
+ () {
+ final casos = {
+ null: false,
+ '': false,
+ ' ': false,
+ 'ftp://cdn.example.com/logo.png': false,
+ 'cdn.example.com/logo.png': false,
+ 'http://': false,
+ '/relative/path/logo.png': false,
+ 'not a url at all \$\$\$ ///': false,
+ 'http://cdn.example.com/logo.png': true,
+ 'https://cdn.example.com/logo.png': true,
+ };
- casos.forEach((entrada, esperado) {
- expect(
- faviconUsable(entrada),
- esperado,
- reason: 'faviconUsable(${entrada.toString()}) debería ser $esperado',
- );
- });
- });
+ casos.forEach((entrada, esperado) {
+ expect(
+ faviconUsable(entrada),
+ esperado,
+ reason:
+ 'faviconUsable(${entrada.toString()}) debería ser $esperado',
+ );
+ });
+ },
+ );
});
group('indiceArtePara', () {
@@ -39,8 +44,7 @@ void main() {
const seeds = ['', 'a', 'uuid-1234-abcd-real-looking'];
for (final seed in seeds) {
- final esperado =
- seed.codeUnits.fold(0, (a, b) => a + b) % 4;
+ final esperado = seed.codeUnits.fold(0, (a, b) => a + b) % 4;
expect(indiceArtePara(seed), esperado);
}
});
@@ -143,27 +147,39 @@ void main() {
group('subtituloCalidad', () {
test('matriz de codec/bitrate conocidos y desconocidos', () {
expect(
- subtituloCalidad(_emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128)),
+ subtituloCalidad(
+ _emisora(uuid: 'u1', nombre: 'N', codec: 'mp3', bitrate: 128),
+ ),
'128 kbps · MP3',
);
expect(
- subtituloCalidad(_emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128)),
+ subtituloCalidad(
+ _emisora(uuid: 'u2', nombre: 'N', codec: null, bitrate: 128),
+ ),
'128 kbps',
);
expect(
- subtituloCalidad(_emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null)),
+ subtituloCalidad(
+ _emisora(uuid: 'u3', nombre: 'N', codec: 'mp3', bitrate: null),
+ ),
'MP3',
);
expect(
- subtituloCalidad(_emisora(uuid: 'u4', nombre: 'N', codec: 'mp3', bitrate: 0)),
+ subtituloCalidad(
+ _emisora(uuid: 'u4', nombre: 'N', codec: 'mp3', bitrate: 0),
+ ),
'MP3',
);
expect(
- subtituloCalidad(_emisora(uuid: 'u5', nombre: 'N', codec: null, bitrate: null)),
+ subtituloCalidad(
+ _emisora(uuid: 'u5', nombre: 'N', codec: null, bitrate: null),
+ ),
isNull,
);
expect(
- subtituloCalidad(_emisora(uuid: 'u6', nombre: 'N', codec: null, bitrate: 0)),
+ subtituloCalidad(
+ _emisora(uuid: 'u6', nombre: 'N', codec: null, bitrate: 0),
+ ),
isNull,
);
expect(
@@ -222,253 +238,395 @@ void main() {
});
group('ConstructorArbolAuto.raiz', () {
- test(
- 'con incluirMusicaLocal: true devuelve exactamente 4 carpetas no '
- 'reproducibles con los ids esperados, terminando en Música Local',
- () {
- final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true);
+ test('con incluirMusicaLocal: true devuelve exactamente 5 carpetas no '
+ 'reproducibles con los ids esperados, terminando en Ecualizador', () {
+ final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: true);
- expect(raiz, hasLength(4));
- final ids = raiz.map((item) => item.id).toSet();
- expect(
- ids,
- equals({
- ConstructorArbolAuto.idFavoritos,
- ConstructorArbolAuto.idTodas,
- ConstructorArbolAuto.idMisEmisoras,
- ConstructorArbolAuto.idMusicaLocal,
- }),
- );
- for (final item in raiz) {
- expect(item.playable, isFalse);
- expect(item.title, isNotEmpty);
- }
- expect(raiz.last.id, ConstructorArbolAuto.idMusicaLocal);
- },
- );
-
- test(
- 'con incluirMusicaLocal: false devuelve exactamente 3 carpetas — '
- 'Música Local queda OCULTA, no vacía',
- () {
- final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
-
- expect(raiz, hasLength(3));
- final ids = raiz.map((item) => item.id).toSet();
- expect(
- ids,
- equals({
- ConstructorArbolAuto.idFavoritos,
- ConstructorArbolAuto.idTodas,
- ConstructorArbolAuto.idMisEmisoras,
- }),
- );
- expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal)));
- },
- );
-
- test('la raíz no ofrece ninguna carpeta de ecualizador', () {
- // EQ is phone-only: the car never browses or edits it. The right preset
- // still reaches the car, applied automatically by EstadoEcualizador when
- // the output device changes.
- final ids = ConstructorArbolAuto()
- .raiz(incluirMusicaLocal: true)
- .map((item) => item.id)
- .toList();
-
- expect(ids, isNot(contains('ecualizador')));
- expect(ids, isNot(contains('eq_custom')));
- });
- });
-
- group('esCarpetaLocalMediaId / esPistaMediaId', () {
- test(
- 'reconocen sus prefijos, rechazan el resto y no colisionan entre sí '
- 'ni con emisora:/grupo:/los ids de carpeta fijos',
- () {
- final builder = ConstructorArbolAuto();
-
- expect(builder.esCarpetaLocalMediaId('carpeta_local:doc1'), isTrue);
- expect(builder.esCarpetaLocalMediaId('carpeta_local:'), isTrue);
- expect(esPistaMediaId('pista:doc1'), isTrue);
- expect(esPistaMediaId('pista:'), isTrue);
-
- final noLocales = [
- 'emisora:x',
- 'grupo:g1',
+ expect(raiz, hasLength(5));
+ final ids = raiz.map((item) => item.id).toSet();
+ expect(
+ ids,
+ equals({
ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idMusicaLocal,
- '',
- ];
- for (final id in noLocales) {
- expect(
- builder.esCarpetaLocalMediaId(id),
- isFalse,
- reason: 'esCarpetaLocalMediaId($id) debería ser false',
- );
- expect(
- esPistaMediaId(id),
- isFalse,
- reason: 'esPistaMediaId($id) debería ser false',
- );
- }
+ ConstructorArbolAuto.idEcualizador,
+ }),
+ );
+ for (final item in raiz) {
+ expect(item.playable, isFalse);
+ expect(item.title, isNotEmpty);
+ }
+ // Decision `auto/ecualizador-diseno`: Ecualizador is always LAST,
+ // after Música Local when it is present.
+ expect(raiz[raiz.length - 2].id, ConstructorArbolAuto.idMusicaLocal);
+ expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
+ });
- // carpeta_local: y pista: nunca colisionan entre sí.
- expect(builder.esCarpetaLocalMediaId('pista:doc1'), isFalse);
- expect(esPistaMediaId('carpeta_local:doc1'), isFalse);
- },
- );
+ test('con incluirMusicaLocal: false devuelve exactamente 4 carpetas — '
+ 'Música Local queda OCULTA, no vacía, y Ecualizador sigue presente y '
+ 'al final', () {
+ final raiz = ConstructorArbolAuto().raiz(incluirMusicaLocal: false);
+
+ expect(raiz, hasLength(4));
+ final ids = raiz.map((item) => item.id).toSet();
+ expect(
+ ids,
+ equals({
+ ConstructorArbolAuto.idFavoritos,
+ ConstructorArbolAuto.idTodas,
+ ConstructorArbolAuto.idMisEmisoras,
+ ConstructorArbolAuto.idEcualizador,
+ }),
+ );
+ expect(ids, isNot(contains(ConstructorArbolAuto.idMusicaLocal)));
+ expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
+ });
+
+ test('la raíz SÍ ofrece la carpeta de ecualizador -- decisión '
+ '`auto/ecualizador-diseno` SUPERSEDE la regla anterior de "sin '
+ 'carpeta de ecualizador" (commit `2403da3`); este test REEMPLAZA '
+ 'deliberadamente al test homónimo previo que afirmaba lo contrario, '
+ 'no es una regresión', () {
+ final ids =
+ ConstructorArbolAuto()
+ .raiz(incluirMusicaLocal: true)
+ .map((item) => item.id)
+ .toList();
+
+ expect(ids, contains(ConstructorArbolAuto.idEcualizador));
+ expect(ConstructorArbolAuto.idEcualizador, 'ecualizador');
+ });
+ });
+
+ group('ConstructorArbolAuto EQ preset media-ids (decision '
+ '`auto/ecualizador-diseno`)', () {
+ test('esPresetEqMediaId reconoce el prefijo eq_preset: (presets y '
+ 'Desactivar), no colisiona con emisora:/grupo:/carpeta_local:/'
+ 'pista:/los ids de carpeta fijos', () {
+ final builder = ConstructorArbolAuto();
+
+ expect(builder.esPresetEqMediaId('eq_preset:Rock'), isTrue);
+ expect(
+ builder.esPresetEqMediaId(ConstructorArbolAuto.idDesactivarEq),
+ isTrue,
+ );
+
+ final otros = [
+ 'emisora:x',
+ 'grupo:g1',
+ 'carpeta_local:doc1',
+ 'carpeta_local_pag:0:doc1',
+ 'pista:doc1',
+ ConstructorArbolAuto.idFavoritos,
+ ConstructorArbolAuto.idTodas,
+ ConstructorArbolAuto.idMisEmisoras,
+ ConstructorArbolAuto.idMusicaLocal,
+ ConstructorArbolAuto.idEcualizador,
+ '',
+ ];
+ for (final id in otros) {
+ expect(
+ builder.esPresetEqMediaId(id),
+ isFalse,
+ reason: 'esPresetEqMediaId($id) debería ser false',
+ );
+ }
+ });
+
+ test('esDesactivarEqMediaId SOLO reconoce el sentinel de Desactivar, '
+ 'nunca un preset real', () {
+ final builder = ConstructorArbolAuto();
+
+ expect(
+ builder.esDesactivarEqMediaId(ConstructorArbolAuto.idDesactivarEq),
+ isTrue,
+ );
+ for (final preset in PresetEcualizador.presets) {
+ expect(
+ builder.esDesactivarEqMediaId(builder.idPresetEq(preset.nombre)),
+ isFalse,
+ reason:
+ 'esDesactivarEqMediaId no debería reconocer un preset '
+ 'real (${preset.nombre})',
+ );
+ }
+ });
test(
- 'idCarpetaLocalDesde recorta por longitud, preservando un documentId '
- 'que contiene ":" (SAF documentIds reales, p.ej. '
- '"primary:Music/Local")',
+ 'idPresetEq construye eq_preset: y resolverPresetEq '
+ 'resuelve cada uno de los 6 presets de fábrica por su nombre crudo',
() {
final builder = ConstructorArbolAuto();
- const docId = 'primary:Music/Local';
- expect(builder.idCarpetaLocalDesde('carpeta_local:$docId'), docId);
+ for (final preset in PresetEcualizador.presets) {
+ final id = builder.idPresetEq(preset.nombre);
+ expect(id, 'eq_preset:${preset.nombre}');
+ expect(builder.resolverPresetEq(id), preset);
+ }
},
);
+
+ test('resolverPresetEq devuelve null para el sentinel de Desactivar, un '
+ 'nombre desconocido, o un id que no matchea esPresetEqMediaId -- '
+ 'nunca lanza', () {
+ final builder = ConstructorArbolAuto();
+
+ expect(
+ builder.resolverPresetEq(ConstructorArbolAuto.idDesactivarEq),
+ isNull,
+ );
+ expect(builder.resolverPresetEq('eq_preset:Preset Inexistente'), isNull);
+ expect(builder.resolverPresetEq('emisora:x'), isNull);
+ expect(builder.resolverPresetEq(''), isNull);
+ });
+
+ test('idDesactivarEq es estable y vive bajo el mismo prefijo '
+ 'eq_preset:', () {
+ expect(ConstructorArbolAuto.idDesactivarEq, startsWith('eq_preset:'));
+ });
});
group(
- 'esCarpetaLocalReproducirMediaId / esCarpetaLocalAleatorioMediaId '
- '(Design ADR-5, Phase 3)',
+ 'seleccionarPresetEqPorMediaId (decision `auto/ecualizador-diseno`)',
() {
test(
- 'reconocen sus prefijos, no colisionan entre sí ni con NINGUNO de '
- 'los 8 prefijos/ids existentes (Spec "New Action Media-IDs Are '
- 'Collision-Free")',
- () {
- final builder = ConstructorArbolAuto();
+ 'Desactivar llama activarEcualizador(false), nunca aplicarPreset',
+ () async {
+ final activarLlamadoCon = [];
+ var aplicarLlamado = false;
- expect(
- builder.esCarpetaLocalReproducirMediaId(
- 'carpeta_local_reproducir:doc1',
- ),
- isTrue,
- );
- expect(
- builder.esCarpetaLocalAleatorioMediaId(
- 'carpeta_local_aleatorio:doc1',
- ),
- isTrue,
+ await seleccionarPresetEqPorMediaId(
+ ConstructorArbolAuto.idDesactivarEq,
+ activo: true,
+ aplicarPreset: (_) async => aplicarLlamado = true,
+ activarEcualizador: (v) async => activarLlamadoCon.add(v),
);
- final existentes = [
- 'emisora:x',
- 'grupo:g1',
- 'carpeta_local:doc1',
- 'carpeta_local_pag:0:doc1',
- 'carpeta_local_ord:calidad:0:doc1',
- 'carpeta_local_bucket:0:0:doc1',
- 'pista:doc1',
- ];
- for (final id in existentes) {
- expect(
- builder.esCarpetaLocalReproducirMediaId(id),
- isFalse,
- reason: 'esCarpetaLocalReproducirMediaId($id) debería ser '
- 'false',
- );
- expect(
- builder.esCarpetaLocalAleatorioMediaId(id),
- isFalse,
- reason: 'esCarpetaLocalAleatorioMediaId($id) debería ser '
- 'false',
+ expect(activarLlamadoCon, [false]);
+ expect(aplicarLlamado, isFalse);
+ },
+ );
+
+ test('seleccionar un preset mientras el ecualizador está DESACTIVADO lo '
+ 'activa Y aplica ese preset (spec "selecting a preset while '
+ 'disabled enables it and applies it")', () async {
+ PresetEcualizador? presetAplicado;
+ final activarLlamadoCon = [];
+ final builder = ConstructorArbolAuto();
+
+ await seleccionarPresetEqPorMediaId(
+ builder.idPresetEq(PresetEcualizador.rock.nombre),
+ activo: false,
+ aplicarPreset: (p) async => presetAplicado = p,
+ activarEcualizador: (v) async => activarLlamadoCon.add(v),
+ );
+
+ expect(presetAplicado, PresetEcualizador.rock);
+ expect(activarLlamadoCon, [true]);
+ });
+
+ test('seleccionar un preset mientras el ecualizador YA está activo solo '
+ 'aplica el preset, sin volver a tocar activarEcualizador', () async {
+ PresetEcualizador? presetAplicado;
+ final activarLlamadoCon = [];
+ final builder = ConstructorArbolAuto();
+
+ await seleccionarPresetEqPorMediaId(
+ builder.idPresetEq(PresetEcualizador.jazz.nombre),
+ activo: true,
+ aplicarPreset: (p) async => presetAplicado = p,
+ activarEcualizador: (v) async => activarLlamadoCon.add(v),
+ );
+
+ expect(presetAplicado, PresetEcualizador.jazz);
+ expect(activarLlamadoCon, isEmpty);
+ });
+
+ test(
+ 'un id desconocido o que no matchea el prefijo eq_preset: es un '
+ 'no-op -- ninguno de los dos callbacks se invoca, sin lanzar',
+ () async {
+ var llamadas = 0;
+
+ for (final id in ['emisora:x', 'eq_preset:Preset Inexistente', '']) {
+ await seleccionarPresetEqPorMediaId(
+ id,
+ activo: true,
+ aplicarPreset: (_) async => llamadas++,
+ activarEcualizador: (_) async => llamadas++,
);
}
- // Y en la dirección inversa: ninguno de los 8 predicados
- // existentes reconoce las 2 nuevas acciones.
- const reproducir = 'carpeta_local_reproducir:doc1';
- const aleatorio = 'carpeta_local_aleatorio:doc1';
- expect(builder.esCarpetaLocalMediaId(reproducir), isFalse);
- expect(builder.esCarpetaLocalMediaId(aleatorio), isFalse);
- expect(
- builder.esCarpetaLocalPaginadaMediaId(reproducir),
- isFalse,
- );
- expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse);
- expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse);
- expect(esPistaMediaId(reproducir), isFalse);
-
- // Divergencia en el índice 14 (justo después de "carpeta_local_"):
- // 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) /
- // 'b' (_bucket) — prueba de colisión de Design ADR-5.
- expect(reproducir[14], 'r');
- expect(aleatorio[14], 'a');
- expect('carpeta_local_pag:0:doc1'[14], 'p');
- expect('carpeta_local_ord:calidad:0:doc1'[14], 'o');
- expect('carpeta_local_bucket:0:0:doc1'[14], 'b');
- // 'carpeta_local:' diverge en el índice 13 (':' vs '_').
- expect('carpeta_local:doc1'[13], ':');
- expect(reproducir[13], '_');
- },
- );
-
- test(
- 'idCarpetaLocalReproducirDesde / idCarpetaLocalAleatorioDesde '
- 'recortan por longitud, preservando un documentId con ":" y "/" '
- 'verbatim (Spec "Raw documentIds with :/ survive round-trip")',
- () {
- final builder = ConstructorArbolAuto();
- const docId = 'primary:Music/Local Folder';
-
- expect(
- builder.idCarpetaLocalReproducirDesde(
- 'carpeta_local_reproducir:$docId',
- ),
- docId,
- );
- expect(
- builder.idCarpetaLocalAleatorioDesde(
- 'carpeta_local_aleatorio:$docId',
- ),
- docId,
- );
- },
- );
-
- test(
- 'tail vacío (empty tail) hace round-trip a la raíz local',
- () {
- final builder = ConstructorArbolAuto();
-
- expect(
- builder.idCarpetaLocalReproducirDesde('carpeta_local_reproducir:'),
- '',
- );
- expect(
- builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:'),
- '',
- );
+ expect(llamadas, 0);
},
);
},
);
- group('artUriLocal', () {
- test(
- 'reproduce la misma rotación que artUriPara/indiceArtePara, sembrada '
- 'por documentId en vez de por uuid',
- () {
- const documentIds = ['doc-a', 'doc-b', 'doc-c', 'doc-d'];
- const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova'];
+ group('esCarpetaLocalMediaId / esPistaMediaId', () {
+ test('reconocen sus prefijos, rechazan el resto y no colisionan entre sí '
+ 'ni con emisora:/grupo:/los ids de carpeta fijos', () {
+ final builder = ConstructorArbolAuto();
- for (final docId in documentIds) {
- final indice = indiceArtePara(docId);
- expect(
- artUriLocal(docId),
- 'android.resource://es.freetimelab.pluriwave/drawable/'
- 'station_art_${nombresPorIndice[indice]}',
- );
- }
- },
- );
+ expect(builder.esCarpetaLocalMediaId('carpeta_local:doc1'), isTrue);
+ expect(builder.esCarpetaLocalMediaId('carpeta_local:'), isTrue);
+ expect(esPistaMediaId('pista:doc1'), isTrue);
+ expect(esPistaMediaId('pista:'), isTrue);
+
+ final noLocales = [
+ 'emisora:x',
+ 'grupo:g1',
+ ConstructorArbolAuto.idFavoritos,
+ ConstructorArbolAuto.idTodas,
+ ConstructorArbolAuto.idMisEmisoras,
+ ConstructorArbolAuto.idMusicaLocal,
+ '',
+ ];
+ for (final id in noLocales) {
+ expect(
+ builder.esCarpetaLocalMediaId(id),
+ isFalse,
+ reason: 'esCarpetaLocalMediaId($id) debería ser false',
+ );
+ expect(
+ esPistaMediaId(id),
+ isFalse,
+ reason: 'esPistaMediaId($id) debería ser false',
+ );
+ }
+
+ // carpeta_local: y pista: nunca colisionan entre sí.
+ expect(builder.esCarpetaLocalMediaId('pista:doc1'), isFalse);
+ expect(esPistaMediaId('carpeta_local:doc1'), isFalse);
+ });
+
+ test('idCarpetaLocalDesde recorta por longitud, preservando un documentId '
+ 'que contiene ":" (SAF documentIds reales, p.ej. '
+ '"primary:Music/Local")', () {
+ final builder = ConstructorArbolAuto();
+ const docId = 'primary:Music/Local';
+
+ expect(builder.idCarpetaLocalDesde('carpeta_local:$docId'), docId);
+ });
+ });
+
+ group('esCarpetaLocalReproducirMediaId / esCarpetaLocalAleatorioMediaId '
+ '(Design ADR-5, Phase 3)', () {
+ test('reconocen sus prefijos, no colisionan entre sí ni con NINGUNO de '
+ 'los 8 prefijos/ids existentes (Spec "New Action Media-IDs Are '
+ 'Collision-Free")', () {
+ final builder = ConstructorArbolAuto();
+
+ expect(
+ builder.esCarpetaLocalReproducirMediaId(
+ 'carpeta_local_reproducir:doc1',
+ ),
+ isTrue,
+ );
+ expect(
+ builder.esCarpetaLocalAleatorioMediaId('carpeta_local_aleatorio:doc1'),
+ isTrue,
+ );
+
+ final existentes = [
+ 'emisora:x',
+ 'grupo:g1',
+ 'carpeta_local:doc1',
+ 'carpeta_local_pag:0:doc1',
+ 'carpeta_local_ord:calidad:0:doc1',
+ 'carpeta_local_bucket:0:0:doc1',
+ 'pista:doc1',
+ ];
+ for (final id in existentes) {
+ expect(
+ builder.esCarpetaLocalReproducirMediaId(id),
+ isFalse,
+ reason:
+ 'esCarpetaLocalReproducirMediaId($id) debería ser '
+ 'false',
+ );
+ expect(
+ builder.esCarpetaLocalAleatorioMediaId(id),
+ isFalse,
+ reason:
+ 'esCarpetaLocalAleatorioMediaId($id) debería ser '
+ 'false',
+ );
+ }
+
+ // Y en la dirección inversa: ninguno de los 8 predicados
+ // existentes reconoce las 2 nuevas acciones.
+ const reproducir = 'carpeta_local_reproducir:doc1';
+ const aleatorio = 'carpeta_local_aleatorio:doc1';
+ expect(builder.esCarpetaLocalMediaId(reproducir), isFalse);
+ expect(builder.esCarpetaLocalMediaId(aleatorio), isFalse);
+ expect(builder.esCarpetaLocalPaginadaMediaId(reproducir), isFalse);
+ expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse);
+ expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse);
+ expect(esPistaMediaId(reproducir), isFalse);
+
+ // Divergencia en el índice 14 (justo después de "carpeta_local_"):
+ // 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) /
+ // 'b' (_bucket) — prueba de colisión de Design ADR-5.
+ expect(reproducir[14], 'r');
+ expect(aleatorio[14], 'a');
+ expect('carpeta_local_pag:0:doc1'[14], 'p');
+ expect('carpeta_local_ord:calidad:0:doc1'[14], 'o');
+ expect('carpeta_local_bucket:0:0:doc1'[14], 'b');
+ // 'carpeta_local:' diverge en el índice 13 (':' vs '_').
+ expect('carpeta_local:doc1'[13], ':');
+ expect(reproducir[13], '_');
+ });
+
+ test('idCarpetaLocalReproducirDesde / idCarpetaLocalAleatorioDesde '
+ 'recortan por longitud, preservando un documentId con ":" y "/" '
+ 'verbatim (Spec "Raw documentIds with :/ survive round-trip")', () {
+ final builder = ConstructorArbolAuto();
+ const docId = 'primary:Music/Local Folder';
+
+ expect(
+ builder.idCarpetaLocalReproducirDesde(
+ 'carpeta_local_reproducir:$docId',
+ ),
+ docId,
+ );
+ expect(
+ builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:$docId'),
+ docId,
+ );
+ });
+
+ test('tail vacío (empty tail) hace round-trip a la raíz local', () {
+ final builder = ConstructorArbolAuto();
+
+ expect(
+ builder.idCarpetaLocalReproducirDesde('carpeta_local_reproducir:'),
+ '',
+ );
+ expect(
+ builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:'),
+ '',
+ );
+ });
+ });
+
+ group('artUriLocal', () {
+ test('reproduce la misma rotación que artUriPara/indiceArtePara, sembrada '
+ 'por documentId en vez de por uuid', () {
+ const documentIds = ['doc-a', 'doc-b', 'doc-c', 'doc-d'];
+ const nombresPorIndice = ['aurora', 'cosmic', 'pulse', 'nova'];
+
+ for (final docId in documentIds) {
+ final indice = indiceArtePara(docId);
+ expect(
+ artUriLocal(docId),
+ 'android.resource://es.freetimelab.pluriwave/drawable/'
+ 'station_art_${nombresPorIndice[indice]}',
+ );
+ }
+ });
});
group('paginaDe', () {
@@ -507,410 +665,367 @@ void main() {
);
});
- group(
- 'esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde',
- () {
- test(
- 'reconoce el prefijo carpeta_local_pag: y no colisiona con '
+ group('esCarpetaLocalPaginadaMediaId / paginaCarpetaLocalDesde', () {
+ test('reconoce el prefijo carpeta_local_pag: y no colisiona con '
'emisora:/grupo:/carpeta_local:/pista: ni con los ids de '
- 'carpeta fijos',
- () {
- final builder = ConstructorArbolAuto();
+ 'carpeta fijos', () {
+ final builder = ConstructorArbolAuto();
- expect(
- builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:doc1'),
- isTrue,
- );
- expect(
- builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:'),
- isTrue,
- );
-
- final otros = [
- 'emisora:x',
- 'grupo:g1',
- 'carpeta_local:doc1',
- 'pista:doc1',
- ConstructorArbolAuto.idFavoritos,
- ConstructorArbolAuto.idTodas,
- ConstructorArbolAuto.idMisEmisoras,
- ConstructorArbolAuto.idMusicaLocal,
- '',
- ];
- for (final id in otros) {
- expect(
- builder.esCarpetaLocalPaginadaMediaId(id),
- isFalse,
- reason: 'esCarpetaLocalPaginadaMediaId($id) debería ser false',
- );
- }
-
- // Simétricamente, un carpeta_local: plano nunca matchea el prefijo
- // paginado, y viceversa.
- expect(
- builder.esCarpetaLocalMediaId('carpeta_local_pag:1:doc1'),
- isFalse,
- );
- },
+ expect(
+ builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:doc1'),
+ isTrue,
+ );
+ expect(
+ builder.esCarpetaLocalPaginadaMediaId('carpeta_local_pag:1:'),
+ isTrue,
);
- test(
- 'parsea (documentId, pagina) recortando el prefijo y separando en '
+ final otros = [
+ 'emisora:x',
+ 'grupo:g1',
+ 'carpeta_local:doc1',
+ 'pista:doc1',
+ ConstructorArbolAuto.idFavoritos,
+ ConstructorArbolAuto.idTodas,
+ ConstructorArbolAuto.idMisEmisoras,
+ ConstructorArbolAuto.idMusicaLocal,
+ '',
+ ];
+ for (final id in otros) {
+ expect(
+ builder.esCarpetaLocalPaginadaMediaId(id),
+ isFalse,
+ reason: 'esCarpetaLocalPaginadaMediaId($id) debería ser false',
+ );
+ }
+
+ // Simétricamente, un carpeta_local: plano nunca matchea el prefijo
+ // paginado, y viceversa.
+ expect(
+ builder.esCarpetaLocalMediaId('carpeta_local_pag:1:doc1'),
+ isFalse,
+ );
+ });
+
+ test('parsea (documentId, pagina) recortando el prefijo y separando en '
'el PRIMER ":" — el documentId sobrevive intacto aunque contenga '
- '":" o "/"',
- () {
- final builder = ConstructorArbolAuto();
+ '":" o "/"', () {
+ final builder = ConstructorArbolAuto();
- expect(
- builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:doc1'),
- ('doc1', 1),
- );
- expect(
- builder.paginaCarpetaLocalDesde(
- 'carpeta_local_pag:3:primary:Music/Local',
- ),
- ('primary:Music/Local', 3),
- );
- },
+ expect(builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:doc1'), (
+ 'doc1',
+ 1,
+ ));
+ expect(
+ builder.paginaCarpetaLocalDesde(
+ 'carpeta_local_pag:3:primary:Music/Local',
+ ),
+ ('primary:Music/Local', 3),
);
+ });
- test(
- 'página raíz con tail vacío ("carpeta_local_pag:1:") parsea a '
- 'documentId vacío',
- () {
- final builder = ConstructorArbolAuto();
+ test('página raíz con tail vacío ("carpeta_local_pag:1:") parsea a '
+ 'documentId vacío', () {
+ final builder = ConstructorArbolAuto();
- expect(
- builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:'),
- ('', 1),
- );
- },
- );
- },
- );
+ expect(builder.paginaCarpetaLocalDesde('carpeta_local_pag:1:'), ('', 1));
+ });
+ });
group('ConstructorArbolAuto.itemsLocales', () {
- test(
- 'invariante de conteo de llamadas (ADR-3): con 200 nodos, cada página '
- 'invoca construirItem exactamente min(50, restantes) veces — nunca '
- 'una vez por el total de la carpeta',
- () async {
- final nodos = List.generate(
- 200,
- (i) => NodoLocal(
- documentId: 'doc-$i',
- nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3',
- esDirectorio: false,
- ),
- );
- final builder = ConstructorArbolAuto();
+ test('invariante de conteo de llamadas (ADR-3): con 200 nodos, cada página '
+ 'invoca construirItem exactamente min(50, restantes) veces — nunca '
+ 'una vez por el total de la carpeta', () async {
+ final nodos = List.generate(
+ 200,
+ (i) => NodoLocal(
+ documentId: 'doc-$i',
+ nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3',
+ esDirectorio: false,
+ ),
+ );
+ final builder = ConstructorArbolAuto();
- var llamadas = 0;
- MediaItem espia(NodoLocal nodo, Map _) {
- llamadas++;
- return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre);
- }
+ var llamadas = 0;
+ MediaItem espia(NodoLocal nodo, Map _) {
+ llamadas++;
+ return MediaItem(id: 'pista:${nodo.documentId}', title: nodo.nombre);
+ }
- llamadas = 0;
- await builder.itemsLocales(
- nodos,
- documentIdPadre: 'x',
- pagina: 0,
- metadatosDe: _metadatosVacio,
- construirItem: espia,
- );
- expect(llamadas, 50);
+ llamadas = 0;
+ await builder.itemsLocales(
+ nodos,
+ documentIdPadre: 'x',
+ pagina: 0,
+ metadatosDe: _metadatosVacio,
+ construirItem: espia,
+ );
+ expect(llamadas, 50);
- llamadas = 0;
- await builder.itemsLocales(
- nodos,
- documentIdPadre: 'x',
- pagina: 1,
- metadatosDe: _metadatosVacio,
- construirItem: espia,
- );
- expect(llamadas, 50);
+ llamadas = 0;
+ await builder.itemsLocales(
+ nodos,
+ documentIdPadre: 'x',
+ pagina: 1,
+ metadatosDe: _metadatosVacio,
+ construirItem: espia,
+ );
+ expect(llamadas, 50);
- llamadas = 0;
- await builder.itemsLocales(
- nodos,
- documentIdPadre: 'x',
- pagina: 3,
- metadatosDe: _metadatosVacio,
- construirItem: espia,
- );
- expect(llamadas, 50); // 200 - 150 = 50 restantes
+ llamadas = 0;
+ await builder.itemsLocales(
+ nodos,
+ documentIdPadre: 'x',
+ pagina: 3,
+ metadatosDe: _metadatosVacio,
+ construirItem: espia,
+ );
+ expect(llamadas, 50); // 200 - 150 = 50 restantes
- llamadas = 0;
- await builder.itemsLocales(
- nodos,
- documentIdPadre: 'x',
- pagina: 4,
- metadatosDe: _metadatosVacio,
- construirItem: espia,
- );
- expect(llamadas, 0); // más allá del rango, no queda nada
- },
- );
+ llamadas = 0;
+ await builder.itemsLocales(
+ nodos,
+ documentIdPadre: 'x',
+ pagina: 4,
+ metadatosDe: _metadatosVacio,
+ construirItem: espia,
+ );
+ expect(llamadas, 0); // más allá del rango, no queda nada
+ });
- test(
- 'invariante de resolución de metadata (THE load-bearing test, mirror '
- 'del spy de conteo de arriba): con 200 nodos, cada página invoca '
- 'metadatosDe con EXACTAMENTE los documentIds de esa página — página 0 '
- 'recibe los 50 primeros, página 3 recibe los 50 finales, nunca los '
- '200 completos',
- () async {
- final nodos = List.generate(
- 200,
- (i) => NodoLocal(
- documentId: 'doc-$i',
- nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3',
- esDirectorio: false,
- ),
- );
- final builder = ConstructorArbolAuto();
- List? idsRecibidos;
+ test('invariante de resolución de metadata (THE load-bearing test, mirror '
+ 'del spy de conteo de arriba): con 200 nodos, cada página invoca '
+ 'metadatosDe con EXACTAMENTE los documentIds de esa página — página 0 '
+ 'recibe los 50 primeros, página 3 recibe los 50 finales, nunca los '
+ '200 completos', () async {
+ final nodos = List.generate(
+ 200,
+ (i) => NodoLocal(
+ documentId: 'doc-$i',
+ nombre: 'cancion_${i.toString().padLeft(3, '0')}.mp3',
+ esDirectorio: false,
+ ),
+ );
+ final builder = ConstructorArbolAuto();
+ List? idsRecibidos;
- Future