feat(auto): add an Ecualizador browsable folder with preset selection
On-device feedback showed the equalizer's preset-cycling custom action
looked dead: many head units render custom actions icon-first, and a
monochrome icon cannot legibly encode "which of six presets" the way a
browsable list's text rows can.
This adds an "Ecualizador" folder to the car's browse tree, listing
"Desactivar" first, then the six factory presets by name, with the
currently-active one marked. Selecting a preset routes through the same
playFromMediaId seam every other browse-tree leaf already uses; picking
a preset while the equalizer is off turns it on and applies that preset.
Supersedes the earlier "no equalizer folder" rule (commit 2403da3),
which predated this feedback -- see decision auto/ecualizador-diseno.
The preset-cycling custom action still coexists with the folder in this
commit; it is removed in the next one.
This commit is contained in:
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "تعطيل",
|
||||||
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة"
|
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "বন্ধ করুন",
|
||||||
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত"
|
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Deaktivieren",
|
||||||
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert"
|
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Disable",
|
||||||
"alarmCardVacationPausedBadge": "Paused for vacation"
|
"alarmCardVacationPausedBadge": "Paused for vacation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -800,5 +800,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Desactivar",
|
||||||
"alarmCardVacationPausedBadge": "Pausada por vacaciones"
|
"alarmCardVacationPausedBadge": "Pausada por vacaciones"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Désactiver",
|
||||||
"alarmCardVacationPausedBadge": "En pause pour les vacances"
|
"alarmCardVacationPausedBadge": "En pause pour les vacances"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "बंद करें",
|
||||||
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया"
|
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Nonaktifkan",
|
||||||
"alarmCardVacationPausedBadge": "Dijeda karena liburan"
|
"alarmCardVacationPausedBadge": "Dijeda karena liburan"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Disattiva",
|
||||||
"alarmCardVacationPausedBadge": "In pausa per le vacanze"
|
"alarmCardVacationPausedBadge": "In pausa per le vacanze"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "無効化",
|
||||||
"alarmCardVacationPausedBadge": "休暇のため一時停止中"
|
"alarmCardVacationPausedBadge": "休暇のため一時停止中"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Desativar",
|
||||||
"alarmCardVacationPausedBadge": "Pausada por férias"
|
"alarmCardVacationPausedBadge": "Pausada por férias"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "Отключить",
|
||||||
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска"
|
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,5 +841,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"autoEqDisableOption": "关闭",
|
||||||
"alarmCardVacationPausedBadge": "因假期已暂停"
|
"alarmCardVacationPausedBadge": "因假期已暂停"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3068,6 +3068,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Preset: {preset}'**
|
/// **'Preset: {preset}'**
|
||||||
String eqCustomActionPresetLabel(String preset);
|
String eqCustomActionPresetLabel(String preset);
|
||||||
|
|
||||||
|
/// No description provided for @autoEqDisableOption.
|
||||||
|
///
|
||||||
|
/// In es, this message translates to:
|
||||||
|
/// **'Desactivar'**
|
||||||
|
String get autoEqDisableOption;
|
||||||
|
|
||||||
/// No description provided for @alarmCardVacationPausedBadge.
|
/// No description provided for @alarmCardVacationPausedBadge.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -1688,6 +1688,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
return 'الإعداد المسبق: $preset';
|
return 'الإعداد المسبق: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'تعطيل';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
|
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1697,6 +1697,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
return 'প্রিসেট: $preset';
|
return 'প্রিসেট: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'বন্ধ করুন';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
|
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1710,6 +1710,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Voreinstellung: $preset';
|
return 'Voreinstellung: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Deaktivieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
|
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1690,6 +1690,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Preset: $preset';
|
return 'Preset: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Disable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'Paused for vacation';
|
String get alarmCardVacationPausedBadge => 'Paused for vacation';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1704,6 +1704,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Preset: $preset';
|
return 'Preset: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Desactivar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
|
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1713,6 +1713,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Préréglage : $preset';
|
return 'Préréglage : $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Désactiver';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
|
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1692,6 +1692,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
return 'प्रीसेट: $preset';
|
return 'प्रीसेट: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'बंद करें';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
|
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1700,6 +1700,9 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
return 'Prasetel: $preset';
|
return 'Prasetel: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Nonaktifkan';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
|
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1712,6 +1712,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'Preset attivo: $preset';
|
return 'Preset attivo: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Disattiva';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
|
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1643,6 +1643,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
return 'プリセット: $preset';
|
return 'プリセット: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => '無効化';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
|
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1700,6 +1700,9 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
return 'Predefinição: $preset';
|
return 'Predefinição: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Desativar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'Pausada por férias';
|
String get alarmCardVacationPausedBadge => 'Pausada por férias';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1704,6 +1704,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
return 'Пресет: $preset';
|
return 'Пресет: $preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => 'Отключить';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
|
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1635,6 +1635,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
return '预设:$preset';
|
return '预设:$preset';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoEqDisableOption => '关闭';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get alarmCardVacationPausedBadge => '因假期已暂停';
|
String get alarmCardVacationPausedBadge => '因假期已暂停';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../estado/orden_emisoras.dart';
|
|||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/grupo_favoritos.dart';
|
import '../modelos/grupo_favoritos.dart';
|
||||||
import '../modelos/pista_local.dart';
|
import '../modelos/pista_local.dart';
|
||||||
|
import '../modelos/preset_ecualizador.dart';
|
||||||
import 'musica_local_auto.dart';
|
import 'musica_local_auto.dart';
|
||||||
import 'persistencia_tolerante.dart';
|
import 'persistencia_tolerante.dart';
|
||||||
import 'servicio_favoritos.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
|
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
|
||||||
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
|
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
|
||||||
/// list's range returns `[]`, never throws.
|
/// list's range returns `[]`, never throws.
|
||||||
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
|
List<T> paginaDe<T>(
|
||||||
items.skip(pagina * tamano).take(tamano).toList();
|
List<T> 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
|
/// Whether a page after [pagina] exists for a list of [total] elements
|
||||||
/// (Design ADR-6): `true` iff at least one element remains beyond the
|
/// (Design ADR-6): `true` iff at least one element remains beyond the
|
||||||
/// current page's slice. The exact-boundary case
|
/// current page's slice. The exact-boundary case
|
||||||
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
|
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
|
||||||
/// reveal.
|
/// reveal.
|
||||||
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
|
bool hayPaginaSiguiente(
|
||||||
total > (pagina + 1) * tamano;
|
int total, {
|
||||||
|
required int pagina,
|
||||||
|
required int tamano,
|
||||||
|
}) => total > (pagina + 1) * tamano;
|
||||||
|
|
||||||
/// Browse-tree ordering comparator for a local-music folder's children
|
/// Browse-tree ordering comparator for a local-music folder's children
|
||||||
/// (Design "Directories before files", item 1): directories sort before
|
/// (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
|
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
|
||||||
// `true`) — check `host.isNotEmpty` explicitly to actually require a
|
// `true`) — check `host.isNotEmpty` explicitly to actually require a
|
||||||
// non-empty authority host.
|
// non-empty authority host.
|
||||||
return (uri.scheme == 'http' || uri.scheme == 'https') &&
|
return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
|
||||||
uri.host.isNotEmpty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic rotation index over the 4 on-brand fallback arts, same
|
/// 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
|
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
|
||||||
/// on-brand art the phone UI would pick for this station (per-station
|
/// on-brand art the phone UI would pick for this station (per-station
|
||||||
/// parity), never a launcher-icon lookalike.
|
/// parity), never a launcher-icon lookalike.
|
||||||
String artUriPara(Emisora e) => faviconUsable(e.favicon)
|
String artUriPara(Emisora e) =>
|
||||||
? e.favicon!
|
faviconUsable(e.favicon)
|
||||||
: 'android.resource://es.freetimelab.pluriwave/drawable/'
|
? e.favicon!
|
||||||
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
|
: 'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||||
|
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
|
||||||
|
|
||||||
/// Formats a human-readable audio-quality hint for the browse row's
|
/// Formats a human-readable audio-quality hint for the browse row's
|
||||||
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
|
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
|
||||||
@@ -212,6 +219,17 @@ class ConstructorArbolAuto {
|
|||||||
/// (Design "Local root hidden until a folder is configured").
|
/// (Design "Local root hidden until a folder is configured").
|
||||||
static const idMusicaLocal = 'musica_local';
|
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 _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
||||||
static const _maxItemsPorCarpeta = 50;
|
static const _maxItemsPorCarpeta = 50;
|
||||||
|
|
||||||
@@ -295,12 +313,23 @@ class ConstructorArbolAuto {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
/// 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
|
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
||||||
/// only. The car still gets the right sound, because the per-device preset
|
/// folder" rule that used to live in this doc comment (commit `2403da3`,
|
||||||
/// is applied automatically when the output device changes — that lives in
|
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
|
||||||
/// `EstadoEcualizador`, not in this tree.
|
/// 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
|
/// `Música Local` is OMITTED entirely (not just empty) unless
|
||||||
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
||||||
@@ -311,6 +340,7 @@ class ConstructorArbolAuto {
|
|||||||
_carpeta(idTodas, 'Todas las emisoras'),
|
_carpeta(idTodas, 'Todas las emisoras'),
|
||||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||||
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
||||||
|
_carpeta(idEcualizador, 'Ecualizador'),
|
||||||
];
|
];
|
||||||
|
|
||||||
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
||||||
@@ -457,10 +487,11 @@ class ConstructorArbolAuto {
|
|||||||
final construir = construirItem ?? _itemLocal;
|
final construir = construirItem ?? _itemLocal;
|
||||||
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
||||||
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
||||||
final docIds = paginaActual
|
final docIds =
|
||||||
.where((n) => !n.esDirectorio)
|
paginaActual
|
||||||
.map((n) => n.documentId)
|
.where((n) => !n.esDirectorio)
|
||||||
.toList();
|
.map((n) => n.documentId)
|
||||||
|
.toList();
|
||||||
final metadatos = await metadatosDe(docIds);
|
final metadatos = await metadatosDe(docIds);
|
||||||
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
||||||
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
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
|
// (direct or nested), absent for a folder that is genuinely empty
|
||||||
// even recursively (Spec "Folder has no tracks").
|
// even recursively (Spec "Folder has no tracks").
|
||||||
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
|
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
|
||||||
if (hayContenidoReproducible)
|
if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
|
||||||
_itemReproducirAleatorio(documentIdPadre),
|
|
||||||
if (ofreceOrdenCalidad(totalPistas))
|
if (ofreceOrdenCalidad(totalPistas))
|
||||||
_itemModoOrdenCalidad(documentIdPadre),
|
_itemModoOrdenCalidad(documentIdPadre),
|
||||||
if (ofreceBuckets(totalPistas))
|
if (ofreceBuckets(totalPistas))
|
||||||
@@ -725,10 +755,11 @@ class ConstructorArbolAuto {
|
|||||||
final ordenados = [...buckets[idxBucket].nodos]
|
final ordenados = [...buckets[idxBucket].nodos]
|
||||||
..sort((a, b) => a.nombre.compareTo(b.nombre));
|
..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||||
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
||||||
final docIds = paginaActual
|
final docIds =
|
||||||
.where((n) => !n.esDirectorio)
|
paginaActual
|
||||||
.map((n) => n.documentId)
|
.where((n) => !n.esDirectorio)
|
||||||
.toList();
|
.map((n) => n.documentId)
|
||||||
|
.toList();
|
||||||
final metadatos = await metadatosDe(docIds);
|
final metadatos = await metadatosDe(docIds);
|
||||||
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
||||||
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
||||||
@@ -743,20 +774,21 @@ class ConstructorArbolAuto {
|
|||||||
}
|
}
|
||||||
final meta = metadatos[nodo.documentId];
|
final meta = metadatos[nodo.documentId];
|
||||||
final tituloMeta = meta?.titulo?.trim();
|
final tituloMeta = meta?.titulo?.trim();
|
||||||
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
|
final titulo =
|
||||||
? tituloMeta
|
(tituloMeta != null && tituloMeta.isNotEmpty)
|
||||||
: _tituloDesdeNombre(nodo.nombre);
|
? tituloMeta
|
||||||
|
: _tituloDesdeNombre(nodo.nombre);
|
||||||
final artUriMeta = meta?.artUri?.trim();
|
final artUriMeta = meta?.artUri?.trim();
|
||||||
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
|
final artUri =
|
||||||
? artUriMeta
|
(artUriMeta != null && artUriMeta.isNotEmpty)
|
||||||
: artUriLocal(nodo.documentId);
|
? artUriMeta
|
||||||
|
: artUriLocal(nodo.documentId);
|
||||||
final artistaMeta = meta?.artista?.trim();
|
final artistaMeta = meta?.artista?.trim();
|
||||||
return MediaItem(
|
return MediaItem(
|
||||||
id: '$_prefijoPista${nodo.documentId}',
|
id: '$_prefijoPista${nodo.documentId}',
|
||||||
title: titulo,
|
title: titulo,
|
||||||
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
|
artist:
|
||||||
? artistaMeta
|
(artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
|
||||||
: null,
|
|
||||||
playable: true,
|
playable: true,
|
||||||
artUri: Uri.parse(artUri),
|
artUri: Uri.parse(artUri),
|
||||||
displaySubtitle: subtituloCalidadLocal(meta),
|
displaySubtitle: subtituloCalidadLocal(meta),
|
||||||
@@ -777,15 +809,17 @@ class ConstructorArbolAuto {
|
|||||||
required List<GrupoFavoritos> grupos,
|
required List<GrupoFavoritos> grupos,
|
||||||
required List<Emisora> favoritos,
|
required List<Emisora> favoritos,
|
||||||
}) {
|
}) {
|
||||||
final carpetas = grupos
|
final carpetas =
|
||||||
.where((g) => !g.esSinAsignar)
|
grupos
|
||||||
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
|
.where((g) => !g.esSinAsignar)
|
||||||
.take(_maxGruposPorFavoritos)
|
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
|
||||||
.map(itemGrupo)
|
.take(_maxGruposPorFavoritos)
|
||||||
.toList();
|
.map(itemGrupo)
|
||||||
final sinAsignar = favoritos
|
.toList();
|
||||||
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
|
final sinAsignar =
|
||||||
.toList();
|
favoritos
|
||||||
|
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
|
||||||
|
.toList();
|
||||||
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
|
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,13 +835,62 @@ class ConstructorArbolAuto {
|
|||||||
if (!esCarpetaGrupo(grupoMediaId)) return const [];
|
if (!esCarpetaGrupo(grupoMediaId)) return const [];
|
||||||
final id = grupoMediaId.substring(_prefijoGrupo.length);
|
final id = grupoMediaId.substring(_prefijoGrupo.length);
|
||||||
if (id.isEmpty) return const [];
|
if (id.isEmpty) return const [];
|
||||||
final miembros = favoritos
|
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
|
||||||
.where((e) => e.grupoFavoritosId == id)
|
|
||||||
.toList();
|
|
||||||
if (miembros.isEmpty) return const [];
|
if (miembros.isEmpty) return const [];
|
||||||
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
|
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
|
||||||
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Equalizer preset-selection media-id prefix (decision
|
||||||
|
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` 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:<nombre>` [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<PresetEcualizador>? 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:<uuid>` media id and the
|
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
|
||||||
@@ -847,6 +930,43 @@ Future<void> reproducirPorMediaId(
|
|||||||
await reproducir(item);
|
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<void> seleccionarPresetEqPorMediaId(
|
||||||
|
String id, {
|
||||||
|
required bool activo,
|
||||||
|
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
||||||
|
required Future<void> 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
|
/// Fallback title (Design "Title = filename minus extension") for a blank
|
||||||
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
||||||
/// matching every other car-tree label in this file (`'Favoritos'`,
|
/// matching every other car-tree label in this file (`'Favoritos'`,
|
||||||
@@ -904,10 +1024,8 @@ List<NodoLocal> ordenarPorCalidadLocal(
|
|||||||
) {
|
) {
|
||||||
final ordenados = List<NodoLocal>.from(nodos);
|
final ordenados = List<NodoLocal>.from(nodos);
|
||||||
ordenados.sort(
|
ordenados.sort(
|
||||||
(a, b) => compararCalidadLocal(
|
(a, b) =>
|
||||||
metadatos[a.documentId],
|
compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
|
||||||
metadatos[b.documentId],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return ordenados;
|
return ordenados;
|
||||||
}
|
}
|
||||||
@@ -948,12 +1066,13 @@ List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
|
|||||||
final pistas = nodos.where((n) => !n.esDirectorio).toList();
|
final pistas = nodos.where((n) => !n.esDirectorio).toList();
|
||||||
return _rangosBucket.map((rango) {
|
return _rangosBucket.map((rango) {
|
||||||
final (etiqueta, desde, hasta) = rango;
|
final (etiqueta, desde, hasta) = rango;
|
||||||
final coincidencias = pistas.where((n) {
|
final coincidencias =
|
||||||
final recortado = n.nombre.trim();
|
pistas.where((n) {
|
||||||
if (recortado.isEmpty) return false;
|
final recortado = n.nombre.trim();
|
||||||
final letra = recortado[0].toLowerCase();
|
if (recortado.isEmpty) return false;
|
||||||
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
|
final letra = recortado[0].toLowerCase();
|
||||||
}).toList();
|
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
|
||||||
|
}).toList();
|
||||||
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
|
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
@@ -1119,9 +1238,10 @@ Future<void> reproducirCarpetaLocal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
|
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
|
||||||
final pistas = aleatorio
|
final pistas =
|
||||||
? mezclarFisherYates(recolectadas, rng ?? Random())
|
aleatorio
|
||||||
: recolectadas;
|
? mezclarFisherYates(recolectadas, rng ?? Random())
|
||||||
|
: recolectadas;
|
||||||
if (pistas.isEmpty) return;
|
if (pistas.isEmpty) return;
|
||||||
|
|
||||||
await iniciarCola(pistas);
|
await iniciarCola(pistas);
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
|||||||
/// the browse tree and the car-tap path already show, instead of a blank
|
/// the browse tree and the car-tap path already show, instead of a blank
|
||||||
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
|
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
|
||||||
/// dependency — so it is unit-testable without instantiating the handler.
|
/// 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(
|
return MediaItem(
|
||||||
id: emisora.url,
|
id: emisora.url,
|
||||||
title: localizedStationName(l10n, emisora.nombre),
|
title: localizedStationName(l10n, emisora.nombre),
|
||||||
@@ -180,12 +183,12 @@ List<MediaControl> controlesEcualizadorPersonalizados({
|
|||||||
if (!disponible) return const [];
|
if (!disponible) return const [];
|
||||||
return [
|
return [
|
||||||
MediaControl.custom(
|
MediaControl.custom(
|
||||||
androidIcon: activo
|
androidIcon:
|
||||||
? 'drawable/ic_auto_eq_on'
|
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
|
||||||
: 'drawable/ic_auto_eq_off',
|
label:
|
||||||
label: activo
|
activo
|
||||||
? l10n.eqCustomActionDisableLabel
|
? l10n.eqCustomActionDisableLabel
|
||||||
: l10n.eqCustomActionEnableLabel,
|
: l10n.eqCustomActionEnableLabel,
|
||||||
name: accionEqToggle,
|
name: accionEqToggle,
|
||||||
),
|
),
|
||||||
MediaControl.custom(
|
MediaControl.custom(
|
||||||
@@ -198,6 +201,85 @@ List<MediaControl> controlesEcualizadorPersonalizados({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<MediaItem> itemsEcualizadorAuto({
|
||||||
|
required bool activo,
|
||||||
|
required PresetEcualizador presetActual,
|
||||||
|
required AppLocalizations l10n,
|
||||||
|
}) {
|
||||||
|
final constructor = ConstructorArbolAuto();
|
||||||
|
final items = <MediaItem>[
|
||||||
|
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.
|
/// Wrapper de alto nivel para el UI.
|
||||||
class ServicioAudio {
|
class ServicioAudio {
|
||||||
PluriWaveAudioHandler get _handler {
|
PluriWaveAudioHandler get _handler {
|
||||||
@@ -1158,6 +1240,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
fuente: fuenteLocal,
|
fuente: fuenteLocal,
|
||||||
);
|
);
|
||||||
if (musicaLocal != null) return musicaLocal;
|
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;
|
final fuente = _fuenteNavegacionGlobal;
|
||||||
if (fuente == null) return const [];
|
if (fuente == null) return const [];
|
||||||
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
||||||
@@ -1228,14 +1321,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
if (fuenteLocal == null) return;
|
if (fuenteLocal == null) return;
|
||||||
await reproducirCarpetaLocal(
|
await reproducirCarpetaLocal(
|
||||||
mediaId,
|
mediaId,
|
||||||
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(
|
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId),
|
||||||
mediaId,
|
|
||||||
),
|
|
||||||
fuente: fuenteLocal,
|
fuente: fuenteLocal,
|
||||||
iniciarCola: _iniciarColaLocal,
|
iniciarCola: _iniciarColaLocal,
|
||||||
);
|
);
|
||||||
return;
|
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;
|
final fuente = _fuenteNavegacionGlobal;
|
||||||
if (fuente == null) return;
|
if (fuente == null) return;
|
||||||
await reproducirPorMediaId(
|
await reproducirPorMediaId(
|
||||||
|
|||||||
+2213
-2154
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
|||||||
|
import 'dart:ui' show Locale;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||||
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||||
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
|
|
||||||
|
/// Decision `auto/ecualizador-diseno` -- the "Ecualizador" browsable
|
||||||
|
/// folder's item-building (`itemsEcualizadorAuto`), the pure,
|
||||||
|
/// `AppLocalizations`-dependent half of the folder feature. Lives in a
|
||||||
|
/// dedicated file, separate from `servicio_audio_eq_custom_actions_test.dart`
|
||||||
|
/// (which covers the now-playing screen's on/off toggle) because this is a
|
||||||
|
/// different car UI surface: a browsable folder, not a custom action.
|
||||||
|
void main() {
|
||||||
|
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||||
|
|
||||||
|
group('itemsEcualizadorAuto (decision `auto/ecualizador-diseno`)', () {
|
||||||
|
test('devuelve exactamente 7 items: Desactivar primero, luego los 6 '
|
||||||
|
'presets de fábrica, todos playable', () {
|
||||||
|
final items = itemsEcualizadorAuto(
|
||||||
|
activo: true,
|
||||||
|
presetActual: PresetEcualizador.flat,
|
||||||
|
l10n: l10n,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(items, hasLength(7));
|
||||||
|
expect(items.first.id, ConstructorArbolAuto.idDesactivarEq);
|
||||||
|
for (final item in items) {
|
||||||
|
expect(item.playable, isTrue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('los 6 presets aparecen en el mismo orden que '
|
||||||
|
'PresetEcualizador.presets, cada uno con su id eq_preset:<nombre>', () {
|
||||||
|
final items = itemsEcualizadorAuto(
|
||||||
|
activo: true,
|
||||||
|
presetActual: PresetEcualizador.flat,
|
||||||
|
l10n: l10n,
|
||||||
|
);
|
||||||
|
final builder = ConstructorArbolAuto();
|
||||||
|
|
||||||
|
final idsPresets = items.skip(1).map((i) => i.id).toList();
|
||||||
|
final idsEsperados =
|
||||||
|
PresetEcualizador.presets
|
||||||
|
.map((p) => builder.idPresetEq(p.nombre))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
expect(idsPresets, idsEsperados);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('los nombres de preset están localizados vía nombrePresetVisible, '
|
||||||
|
'no crudos', () {
|
||||||
|
final items = itemsEcualizadorAuto(
|
||||||
|
activo: true,
|
||||||
|
presetActual: PresetEcualizador.flat,
|
||||||
|
l10n: l10n,
|
||||||
|
);
|
||||||
|
final builder = ConstructorArbolAuto();
|
||||||
|
|
||||||
|
final rock = items.firstWhere(
|
||||||
|
(i) => i.id == builder.idPresetEq(PresetEcualizador.rock.nombre),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rock.title, contains(nombrePresetVisible(l10n, 'Rock')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('con el ecualizador ACTIVO, el preset actual queda marcado y '
|
||||||
|
'Desactivar NO', () {
|
||||||
|
final items = itemsEcualizadorAuto(
|
||||||
|
activo: true,
|
||||||
|
presetActual: PresetEcualizador.jazz,
|
||||||
|
l10n: l10n,
|
||||||
|
);
|
||||||
|
final builder = ConstructorArbolAuto();
|
||||||
|
|
||||||
|
final desactivar = items.firstWhere(
|
||||||
|
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
|
||||||
|
);
|
||||||
|
final jazz = items.firstWhere(
|
||||||
|
(i) => i.id == builder.idPresetEq(PresetEcualizador.jazz.nombre),
|
||||||
|
);
|
||||||
|
final marcados = items.where((i) => i.title.contains('✓')).toList();
|
||||||
|
|
||||||
|
expect(desactivar.title, isNot(contains('✓')));
|
||||||
|
expect(jazz.title, contains('✓'));
|
||||||
|
expect(marcados, hasLength(1));
|
||||||
|
expect(marcados.single.id, jazz.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('con el ecualizador DESACTIVADO, Desactivar queda marcado y NINGÚN '
|
||||||
|
'preset lo está', () {
|
||||||
|
final items = itemsEcualizadorAuto(
|
||||||
|
activo: false,
|
||||||
|
presetActual: PresetEcualizador.rock,
|
||||||
|
l10n: l10n,
|
||||||
|
);
|
||||||
|
|
||||||
|
final desactivar = items.firstWhere(
|
||||||
|
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
|
||||||
|
);
|
||||||
|
final marcados = items.where((i) => i.title.contains('✓')).toList();
|
||||||
|
|
||||||
|
expect(desactivar.title, contains('✓'));
|
||||||
|
expect(marcados, hasLength(1));
|
||||||
|
expect(marcados.single.id, ConstructorArbolAuto.idDesactivarEq);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('el label de Desactivar usa l10n.autoEqDisableOption', () {
|
||||||
|
final items = itemsEcualizadorAuto(
|
||||||
|
activo: false,
|
||||||
|
presetActual: PresetEcualizador.flat,
|
||||||
|
l10n: l10n,
|
||||||
|
);
|
||||||
|
|
||||||
|
final desactivar = items.firstWhere(
|
||||||
|
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(desactivar.title, contains(l10n.autoEqDisableOption));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user