fix(auto): restore the equalizer toggle and list the user's own presets
Two Android Auto regressions reported from the car. 1. The on/off equalizer action disappeared from the playback screen. That was self-inflicted: commitcacd3ecremoved it on the theory that a custom action in `controls` aborts `AudioService.setState` and kills the media notification. Reading the plugin source refutes it. setState (AudioService.java:513-520) SPLITS the list -- a control carrying a customAction goes to `customActions` (PlaybackStateCompat, i.e. the car), everything else becomes a NotificationCompat.Action in `nativeActions` (the phone notification). The two never mix. And the throw the theory depended on cannot happen here: ic_auto_eq_on/ic_auto_eq_off both exist under res/drawable, and the labels are non-empty in all 13 locales. The notification outage was already fixed byabc6b47(transient idle on a source change, which setState turns into a full stop() at :557). The action is back, with both state-aware icons. The real invariant -- a custom action's icon must resolve and its label must be non-empty -- is now a test that reads res/drawable and fails on a missing file, instead of a comment claiming custom actions are forbidden outright. 2. The Ecualizador folder never listed the user's saved presets. itemsEcualizadorAuto iterated PresetEcualizador.presets, so only the six factory presets appeared -- the user's own were unreachable from the car, the surface where a preset picker matters most. They now arrive through a registered read function (same seam as stations and local music, re-read per browse so a preset saved on the phone shows up without an app restart). presetsEcualizadorAuto is the single source of truth for the ordered universe, used to BUILD the items and to RESOLVE a tap, so the folder cannot show an item that resolution then refuses -- which is what the factory-only default in seleccionarPresetEqPorMediaId would have caused. A custom preset whose name collides with a factory one is dropped: the media id is the raw name, so it could only ever resolve to the factory entry, and an item that applies a preset other than the one it names is worse than an absent one. Tests: 1108 -> 1120.
This commit is contained in:
@@ -11,6 +11,7 @@ import 'servicios/musica_local_auto.dart';
|
||||
import 'servicios/navegacion_auto.dart';
|
||||
import 'servicios/servicio_audio.dart';
|
||||
import 'servicios/servicio_audio_session.dart';
|
||||
import 'servicios/servicio_presets_personalizados.dart';
|
||||
import 'tema/pluriwave_tokens.dart';
|
||||
|
||||
const _anchoMinimoLandscape = 600.0;
|
||||
@@ -55,6 +56,14 @@ Future<void> main() async {
|
||||
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
|
||||
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
|
||||
|
||||
// User-saved EQ presets for the car's Ecualizador folder, same
|
||||
// injectable-prefs DI convention and same pre-init placement as the two
|
||||
// registrations above (neither depends on the AudioHandler). Passed as a
|
||||
// read function, not the service, so the folder re-reads on every browse:
|
||||
// a preset saved on the phone appears in the car without an app restart.
|
||||
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
|
||||
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
|
||||
|
||||
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
|
||||
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
|
||||
// drops what it is given — so every exception `audio_service` catches
|
||||
|
||||
@@ -954,11 +954,18 @@ Future<void> reproducirPorMediaId(
|
||||
/// A stale/unresolvable id, or any id that doesn't match
|
||||
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
|
||||
/// runs and no exception propagates.
|
||||
///
|
||||
/// [presets] is the universe the id is resolved against, and it MUST be the
|
||||
/// same list the folder was rendered from (`presetsEcualizadorAuto` in
|
||||
/// `servicio_audio.dart` — factory presets plus the user's saved ones).
|
||||
/// Defaulting to the factory six alone is what made a tapped custom preset a
|
||||
/// silent no-op: the item was listed, but nothing here could resolve it.
|
||||
Future<void> seleccionarPresetEqPorMediaId(
|
||||
String id, {
|
||||
required bool activo,
|
||||
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
||||
required Future<void> Function(bool) activarEcualizador,
|
||||
List<PresetEcualizador>? presets,
|
||||
}) async {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
if (!constructor.esPresetEqMediaId(id)) return;
|
||||
@@ -968,7 +975,7 @@ Future<void> seleccionarPresetEqPorMediaId(
|
||||
return;
|
||||
}
|
||||
|
||||
final preset = constructor.resolverPresetEq(id);
|
||||
final preset = constructor.resolverPresetEq(id, presets: presets);
|
||||
if (preset == null) return;
|
||||
|
||||
await aplicarPreset(preset);
|
||||
|
||||
@@ -60,6 +60,70 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
||||
_fuenteMusicaLocalGlobal = fuente;
|
||||
}
|
||||
|
||||
/// User-saved EQ presets browse source — registered from main.dart, mirrors
|
||||
/// the two registrations above.
|
||||
///
|
||||
/// On-device feedback: the car's `Ecualizador` folder only ever listed the
|
||||
/// six FACTORY presets, so a driver who had carefully saved their own could
|
||||
/// not reach it from the car at all — the one place a preset picker is most
|
||||
/// useful. The handler owns no persistence, so the list arrives through this
|
||||
/// seam exactly like stations and local music do.
|
||||
///
|
||||
/// A function rather than the service object: the folder needs a fresh read
|
||||
/// on every browse (a preset saved on the phone must appear in the car
|
||||
/// without an app restart), and this keeps `servicio_audio.dart` from
|
||||
/// importing the persistence layer. `null` until registered (headless cold
|
||||
/// bind) — consumers fall back to factory presets only, never throw.
|
||||
Future<List<PresetEcualizador>> Function()? _fuentePresetsPersonalizadosGlobal;
|
||||
|
||||
void registrarFuentePresetsPersonalizados(
|
||||
Future<List<PresetEcualizador>> Function() fuente,
|
||||
) {
|
||||
_fuentePresetsPersonalizadosGlobal = fuente;
|
||||
}
|
||||
|
||||
/// Reads the registered custom-preset source, tolerating both "never
|
||||
/// registered" and "the read blew up" as the same empty result: a
|
||||
/// diagnostics-grade failure must degrade the folder to its factory presets,
|
||||
/// never make browsing fail.
|
||||
Future<List<PresetEcualizador>> _leerPresetsPersonalizados() async {
|
||||
final fuente = _fuentePresetsPersonalizadosGlobal;
|
||||
if (fuente == null) return const [];
|
||||
try {
|
||||
return await fuente();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The full ordered preset universe the car's `Ecualizador` folder offers:
|
||||
/// the six factory presets first, then [personalizados] in save order.
|
||||
///
|
||||
/// A custom preset whose `nombre` matches a factory preset is DROPPED, not
|
||||
/// appended. Identity here is the raw name — [ConstructorArbolAuto.idPresetEq]
|
||||
/// builds `eq_preset:<nombre>` from it and
|
||||
/// [ConstructorArbolAuto.resolverPresetEq] resolves by first name match — so
|
||||
/// two entries sharing a name would produce one media id that can only ever
|
||||
/// reach the first of them. Rendering an item that silently applies a
|
||||
/// different preset than the one whose name it shows is worse than not
|
||||
/// rendering it, and the factory entry is the one the id is guaranteed to
|
||||
/// resolve to.
|
||||
///
|
||||
/// Single source of truth on purpose: [itemsEcualizadorAuto] builds the items
|
||||
/// from this list and the tap dispatch resolves against this same list, so
|
||||
/// the folder can never show an item that resolution then refuses.
|
||||
List<PresetEcualizador> presetsEcualizadorAuto({
|
||||
required List<PresetEcualizador> personalizados,
|
||||
List<PresetEcualizador>? deFabrica,
|
||||
}) {
|
||||
final fabrica = deFabrica ?? PresetEcualizador.presets;
|
||||
final nombresFabrica = fabrica.map((p) => p.nombre).toSet();
|
||||
return [
|
||||
...fabrica,
|
||||
...personalizados.where((p) => !nombresFabrica.contains(p.nombre)),
|
||||
];
|
||||
}
|
||||
|
||||
/// Teardown hook for whatever `main.dart` wired around the handler and must
|
||||
/// be undone when the handler itself dies — today only the
|
||||
/// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered
|
||||
@@ -313,10 +377,17 @@ String _marcarActivoEq(String titulo, {required bool activo}) =>
|
||||
/// since [presetActual] always originates from [PresetEcualizador.presets]
|
||||
/// or a "Personalizado" tweak that would just leave every item unmarked
|
||||
/// rather than mis-marking one).
|
||||
///
|
||||
/// [presetsPersonalizados] are the user's own saved presets, appended after
|
||||
/// the factory six by [presetsEcualizadorAuto] (which also settles name
|
||||
/// collisions). They render through the same [nombrePresetVisible] call as
|
||||
/// everything else: that helper passes an unrecognized name through
|
||||
/// verbatim, which is exactly right for a name the user typed themselves.
|
||||
List<MediaItem> itemsEcualizadorAuto({
|
||||
required bool activo,
|
||||
required PresetEcualizador presetActual,
|
||||
required AppLocalizations l10n,
|
||||
List<PresetEcualizador> presetsPersonalizados = const [],
|
||||
}) {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
final items = <MediaItem>[
|
||||
@@ -327,7 +398,9 @@ List<MediaItem> itemsEcualizadorAuto({
|
||||
extras: _contentStyleListaEq,
|
||||
),
|
||||
];
|
||||
for (final preset in PresetEcualizador.presets) {
|
||||
for (final preset in presetsEcualizadorAuto(
|
||||
personalizados: presetsPersonalizados,
|
||||
)) {
|
||||
items.add(
|
||||
MediaItem(
|
||||
id: constructor.idPresetEq(preset.nombre),
|
||||
@@ -637,29 +710,28 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
|
||||
/// at their existing indices 0-3, so `androidCompactActionIndices`
|
||||
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
|
||||
/// NOTHING custom goes in this list. `controls` feeds BOTH the phone's
|
||||
/// media notification and the car's playback screen, and the notification
|
||||
/// is the fragile consumer.
|
||||
///
|
||||
/// `AudioService.setState` (AudioService.java:513-520) walks every control
|
||||
/// through `createCustomAction` BEFORE it reaches
|
||||
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559),
|
||||
/// which is the ONLY place the notification is ever posted.
|
||||
/// `createCustomAction` resolves the icon by name via
|
||||
/// `getResources().getIdentifier(...)` (:415-420) — which returns 0 on a
|
||||
/// miss — and hands it to `PlaybackStateCompat.CustomAction.Builder`, which
|
||||
/// throws on a 0 icon or an empty label. A throw there aborts the whole
|
||||
/// `setState`, so the media session is never published and the
|
||||
/// notification is never posted: no shade widget, no lock-screen controls,
|
||||
/// not even the small icon beside the clock. Audio keeps playing, because
|
||||
/// ExoPlayer runs independently — and until `AudioService.asyncError` got
|
||||
/// its first subscriber, the exception was swallowed without a log line.
|
||||
/// A custom action here reaches the CAR ONLY, never the phone notification.
|
||||
/// `AudioService.setState` (AudioService.java:513-520) splits the list in
|
||||
/// two: `createCustomAction` returns non-null for a control carrying a
|
||||
/// `customAction`, and that control goes into `customActions` — which feeds
|
||||
/// `PlaybackStateCompat` and therefore the car's playback screen. Every
|
||||
/// other control falls to the `else` branch and becomes a
|
||||
/// `NotificationCompat.Action` in `nativeActions`, the list the media
|
||||
/// notification is built from. The two never mix, so the equalizer toggle
|
||||
/// cannot displace a transport button and cannot shift the indices
|
||||
/// `androidCompactActionIndices` points at.
|
||||
///
|
||||
/// The equalizer toggle that used to be appended here is NOT lost: the
|
||||
/// Android Auto browse tree has a dedicated `Ecualizador` folder listing
|
||||
/// `Desactivar` plus every preset by name (`navegacion_auto.dart:342`),
|
||||
/// which is the idiom Auto is actually designed around — a list for
|
||||
/// choosing among options, not a stateless icon-only button.
|
||||
/// THE ONE RULE for anything added here with a `customAction`: its
|
||||
/// `androidIcon` must name a drawable that really exists, and its `label`
|
||||
/// must be non-empty in EVERY locale. `getResourceId` (:415-420) resolves
|
||||
/// the icon by name through `getIdentifier` and yields 0 when it misses,
|
||||
/// and `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an
|
||||
/// empty label — a throw at :515 aborts `setState` before
|
||||
/// `mediaSession.setPlaybackState` (:552), taking the whole media session
|
||||
/// down with it. `servicio_audio_controles_notificacion_test.dart` holds
|
||||
/// that line: it reads `android/app/src/main/res/drawable/` and fails if an
|
||||
/// icon named here has no file behind it.
|
||||
List<MediaControl> _controlesTransporte({
|
||||
required bool colaActiva,
|
||||
required bool playing,
|
||||
@@ -668,16 +740,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
if (colaActiva) MediaControl.skipToNext,
|
||||
..._controlesEqPersonalizados(),
|
||||
];
|
||||
|
||||
/// Re-pushes `playbackState` with a freshly built controls list.
|
||||
///
|
||||
/// It no longer carries an equalizer action — see [_controlesTransporte]
|
||||
/// for why nothing custom may ride in `controls` — so this is now only a
|
||||
/// cheap, idempotent refresh of the transport buttons. Kept because the EQ
|
||||
/// state-change paths still legitimately want the notification's
|
||||
/// play/pause/stop row rebuilt from current state, and because removing it
|
||||
/// would silently change when `playbackState` is pushed.
|
||||
List<MediaControl> _controlesEqPersonalizados() =>
|
||||
controlesEcualizadorPersonalizados(
|
||||
disponible: _eqDisponible,
|
||||
activo: _ecualizadorActivo,
|
||||
l10n: _textos,
|
||||
);
|
||||
|
||||
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
|
||||
/// called whenever EQ availability/enabled state changes outside a
|
||||
/// player-state transition (a custom-action tap, or a phone-side toggle),
|
||||
/// so the equalizer action's icon and label stay in sync on the car's
|
||||
/// now-playing screen without waiting for an unrelated player event.
|
||||
/// Idempotent and cheap (no native calls) — safe to call from any EQ
|
||||
/// state-changing path.
|
||||
void _actualizarControlesEq() {
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
@@ -1408,6 +1487,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
activo: _ecualizadorActivo,
|
||||
presetActual: _presetActual,
|
||||
l10n: _textos,
|
||||
// Read per browse, not cached: a preset saved on the phone must
|
||||
// show up in the car on the next open, with no app restart.
|
||||
presetsPersonalizados: await _leerPresetsPersonalizados(),
|
||||
);
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
@@ -1495,6 +1577,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
activo: _ecualizadorActivo,
|
||||
aplicarPreset: aplicarPreset,
|
||||
activarEcualizador: setEcualizadorActivo,
|
||||
// The SAME list `itemsEcualizadorAuto` rendered from, so a tapped
|
||||
// custom preset resolves instead of silently doing nothing.
|
||||
presets: presetsEcualizadorAuto(
|
||||
personalizados: await _leerPresetsPersonalizados(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user