feat(eq): android auto custom equalizer and robust device detection
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s

- MainActivity: onListen re-emits the current active device and registers
  the audio device callback idempotently, so recreated activities resync
  instead of freezing the active-device id on a disconnected device.
- servicio_dispositivo_audio: resubscribir() re-opens the event channel;
  estado_ecualizador exposes refrescarDispositivoActual() with an
  in-flight guard, invoked on app resume and when opening advanced EQ
  options, clearing stale green-dot device selections.
- navegacion_auto/servicio_audio: new 'Personalizado' browse tree in
  Android Auto (5 band folders, 13 gain steps each) applied live via
  setBanda; preset and gain taps persist at device level when
  multi-device EQ is active and respect station/matrix overrides,
  with apply-before-persist ordering and children-changed notifications.
- l10n: regenerate stale generated localizations; add rxdart as direct
  dependency for the subscribeToChildren override.
This commit is contained in:
Javier Bautista Fernández
2026-07-22 10:26:02 +02:00
parent 3473034b58
commit 163ff69f7a
29 changed files with 1883 additions and 42 deletions
+286 -3
View File
@@ -7,12 +7,15 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart';
import '../modelos/dispositivo_audio.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_dispositivo_audio.dart';
import 'servicio_ecualizador.dart';
import 'servicio_favoritos.dart';
/// Generic page slice over [items] (Design ADR-6): returns at most [tamano]
@@ -43,6 +46,38 @@ const _prefijoPresetEq = 'eq_preset:';
/// predicate.
bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq);
/// Custom-EQ band folder media-id prefix (feature auto-custom-eq):
/// `eq_banda:<indice>`. Collision-free against [_prefijoPresetEq] and
/// [_prefijoGananciaEq] — the three `eq_` prefixes diverge at index 3
/// (`b` vs `p` vs `g`), so no `startsWith` check ever matches a sibling's id.
const _prefijoBandaEq = 'eq_banda:';
/// Whether [id] identifies a custom-EQ band folder (feature auto-custom-eq).
bool esBandaEqMediaId(String id) => id.startsWith(_prefijoBandaEq);
/// Builds the `eq_banda:<indice>` media id for band [indice] (feature
/// auto-custom-eq) — single authority for the id shape, shared by the tree
/// builder and the handler's children-changed notification.
String idBandaEq(int indice) => '$_prefijoBandaEq$indice';
/// Custom-EQ gain leaf media-id prefix (feature auto-custom-eq):
/// `eq_gain:<indice>:<db>`. Collision-free against [_prefijoBandaEq] and
/// [_prefijoPresetEq] (see [_prefijoBandaEq]'s divergence note).
const _prefijoGananciaEq = 'eq_gain:';
/// Whether [id] identifies a custom-EQ gain leaf item (feature
/// auto-custom-eq).
bool esGananciaEqMediaId(String id) => id.startsWith(_prefijoGananciaEq);
/// Number of fixed EQ bands (`PresetEcualizador` asserts exactly 5).
const _numBandasEq = 5;
/// Frequency labels for the 5 EQ bands, in the same order as
/// `PresetEcualizador.bandas` (60Hz, 250Hz, 1kHz, 4kHz, 16kHz — the model's
/// documented band layout, same frequencies the phone's
/// `EcualizadorWidget._etiquetas` renders).
const etiquetasBandasEq = ['60 Hz', '250 Hz', '1 kHz', '4 kHz', '16 kHz'];
/// Local-track media-id prefix (Design "media-id scheme"), collision-free
/// against [_prefijoEmisora], [_prefijoPresetEq], `grupo:` and the bare
/// folder id constants. Top-level (not a [ConstructorArbolAuto] member),
@@ -208,6 +243,11 @@ class ConstructorArbolAuto {
/// generic station-list `hijos()` path.
static const idEcualizador = 'ecualizador';
/// Browsable "Personalizado" folder id under [idEcualizador] (feature
/// auto-custom-eq). Deliberately NOT added to [_idsCarpetas] — routed by
/// its own dedicated branch in `getChildren`, like [idEcualizador] itself.
static const idEqPersonalizado = 'eq_custom';
/// Root folder id for the local-music browsable root (Design "media-id
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
@@ -736,6 +776,52 @@ class ConstructorArbolAuto {
List<MediaItem> presetsEq(List<PresetEcualizador> presets) =>
presets.map(itemPresetEq).toList();
/// The browsable "Personalizado" folder appended after the fixed preset
/// leaves under `Ecualizador` (feature auto-custom-eq). Hardcoded Spanish
/// label, matching every other car-tree label in this file — never routed
/// through `AppLocalizations` (see [_tituloMasLocal]'s precedent).
MediaItem itemEqPersonalizado() =>
_carpeta(idEqPersonalizado, 'Personalizado');
/// The 5 per-band browsable folders under `Personalizado` (feature
/// auto-custom-eq): id `eq_banda:<indice>`, title
/// `<frecuencia> · <ganancia actual>` (e.g. `'60 Hz · +3 dB'`) so the
/// driver sees the effective custom gains at a glance. [actual] is the
/// persisted/effective custom preset resolved by the caller
/// ([presetPersonalizadoEfectivo]).
List<MediaItem> bandasEq(PresetEcualizador actual) => [
for (
var i = 0;
i < actual.bandas.length && i < etiquetasBandasEq.length;
i++
)
_carpeta(
idBandaEq(i),
'${etiquetasBandasEq[i]} · ${formatearGananciaEq(actual.bandas[i])}',
),
];
/// The 13 playable gain leaves for band [indice] (feature auto-custom-eq):
/// -12..+12 dB in steps of 2, id `eq_gain:<indice>:<db>`. The currently
/// selected gain — when it falls on the 2 dB grid — is marked with a
/// leading `● ` so the active value is visible while browsing. An
/// out-of-range [indice] returns `[]`, never throws.
List<MediaItem> gananciasBandaEq(int indice, PresetEcualizador actual) {
if (indice < 0 || indice >= actual.bandas.length) return const [];
final gananciaActual = actual.bandas[indice];
return [
for (var db = -12; db <= 12; db += 2)
MediaItem(
id: '$_prefijoGananciaEq$indice:$db',
title:
'${db.toDouble() == gananciaActual ? '' : ''}'
'${formatearGananciaEq(db.toDouble())}',
playable: true,
extras: _contentStyleGrid,
),
];
}
/// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
/// direct leaves at the Favoritos root"): non-empty custom-group folders
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by
@@ -1221,6 +1307,79 @@ PresetEcualizador? resolverPresetEq(String id, List<PresetEcualizador> presets)
return null;
}
/// Formats a gain in dB for the Auto tree (feature auto-custom-eq):
/// explicit `+` for boosts, plain `0 dB` for neutral, ASCII `-` for cuts.
/// Non-integer factory-preset gains keep one decimal (`'+1.5 dB'`) so band
/// titles never lie about the effective value.
String formatearGananciaEq(double db) {
final esEntera = db == db.roundToDouble();
final valor = esEntera ? db.round().toString() : db.toStringAsFixed(1);
return db > 0 ? '+$valor dB' : '$valor dB';
}
/// Parses an `eq_banda:<indice>` [id] into its band index (feature
/// auto-custom-eq). Any other shape (no prefix, non-integer, outside the
/// fixed [_numBandasEq]-band range) returns `null` instead of throwing.
int? indiceBandaEqDesde(String id) {
if (!esBandaEqMediaId(id)) return null;
final indice = int.tryParse(id.substring(_prefijoBandaEq.length));
if (indice == null || indice < 0 || indice >= _numBandasEq) return null;
return indice;
}
/// Parses an `eq_gain:<indice>:<db>` [id] into its `(indice, db)` pair
/// (feature auto-custom-eq): the prefix is stripped by length, the remainder
/// split on its single `:`. Any other shape (no prefix, missing/non-integer
/// fields, index outside the band range, gain outside -12..+12) returns
/// `null` instead of throwing.
(int indice, double db)? gananciaEqDesde(String id) {
if (!esGananciaEqMediaId(id)) return null;
final resto = id.substring(_prefijoGananciaEq.length);
final separador = resto.indexOf(':');
if (separador <= 0) return null;
final indice = int.tryParse(resto.substring(0, separador));
final db = int.tryParse(resto.substring(separador + 1));
if (indice == null || db == null) return null;
if (indice < 0 || indice >= _numBandasEq) return null;
if (db < -12 || db > 12) return null;
return (indice, db.toDouble());
}
/// Persistence-targeting decision for a car EQ action — preset tap or band
/// change (feature auto-custom-eq): returns the deviceId to persist a
/// DEVICE-level entry for, or `null` to fall back to the global principal.
/// `null` cases mirror the phone hierarchy's own exclusions: multi-device
/// toggle off, unknown device (query failed/timed out — [dispositivo] is
/// `null`), built-in speaker (must keep falling through to L4 global), and
/// composite-placeholder BT ids (ADR-6). Pure — testable without platform
/// channels.
String? dispositivoDestinoEq({
required bool multiDeviceEnabled,
required DispositivoAudio? dispositivo,
}) {
if (!multiDeviceEnabled || dispositivo == null) return null;
if (dispositivo.tipo == TipoDispositivo.altavozInterno) return null;
if (dispositivo.id.startsWith(prefijoPlaceholderBtName)) return null;
return dispositivo.id;
}
/// Resolves the custom-EQ preset the Auto tree displays and edits (feature
/// auto-custom-eq): the device-level entry for [deviceId] when multi-device
/// is enabled and one exists, the global principal otherwise. Station/matrix
/// overrides are deliberately NOT consulted — the `Personalizado` tree
/// displays and edits exactly the level a gain tap persists to
/// ([dispositivoDestinoEq]), so what the driver sees is what a tap changes.
PresetEcualizador presetPersonalizadoEfectivo({
required ConfiguracionEcualizador config,
required String? deviceId,
}) {
if (config.eqMultiDeviceEnabled && deviceId != null) {
final porDispositivo = config.presetsDispositivo[deviceId];
if (porDispositivo != null) return porDispositivo;
}
return config.principal;
}
/// Pure per-station apply gate (Design ADR-5), mirroring
/// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic
/// (`estado_ecualizador.dart:302-304`): the new principal preset is applied
@@ -1232,16 +1391,46 @@ bool debeAplicarPrincipalAhora({
required Set<String> clavesPorEmisora,
}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual);
/// Apply-live gate for a DEVICE-targeted car selection (feature
/// auto-custom-eq), extending [debeAplicarPrincipalAhora] with the matrix
/// level of the phone hierarchy: the freshly persisted device-level preset
/// is audible now unless the current station carries a per-station override
/// or a `estación:dispositivoDestino` matrix entry shadows it.
bool debeAplicarSeleccionAhora({
required String? uuidActual,
required Set<String> clavesPorEmisora,
required Set<String> clavesMatriz,
required String? deviceIdDestino,
}) {
if (uuidActual == null) return true;
if (clavesPorEmisora.contains(uuidActual)) return false;
if (deviceIdDestino != null &&
clavesMatriz.contains('$uuidActual:$deviceIdDestino')) {
return false;
}
return true;
}
/// Orchestrates an `eq_preset:<nombre>` selection from the car (Design
/// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq],
/// persists it as principal via [persistirPrincipal], and conditionally
/// applies it live via [aplicar] when [debeAplicarPrincipalAhora] allows it.
/// persists it, and conditionally applies it live via [aplicar].
///
/// Persistence targeting (feature auto-custom-eq): when the optional
/// [dispositivoDestino]/[persistirDispositivo] seams are provided and the
/// destination resolves to a deviceId ([dispositivoDestinoEq]'s decision),
/// the preset is persisted as a DEVICE-level entry — so the car selection
/// sticks for the car's output device instead of being shadowed by the
/// hierarchy's L3 lookup — and the live application is gated by
/// [debeAplicarSeleccionAhora]. Otherwise (seams omitted, or destination
/// `null`: toggle off, built-in, placeholder, query error/timeout) the
/// original global path runs unchanged: [persistirPrincipal] +
/// [debeAplicarPrincipalAhora].
///
/// This function's signature exposes ONLY the EQ persist/apply seams — it
/// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so
/// there is no code path from a preset tap to playback (Design ADR-3,
/// non-playback invariant enforced structurally, not by discipline). An
/// unknown/stale [id] is a no-op: neither seam is invoked and no exception
/// unknown/stale [id] is a no-op: no seam is invoked and no exception
/// propagates (Spec "Unknown or stale preset id").
Future<void> aplicarPresetPorMediaId(
String id, {
@@ -1250,9 +1439,34 @@ Future<void> aplicarPresetPorMediaId(
required Future<Set<String>> Function() clavesPorEmisora,
required Future<void> Function(PresetEcualizador) persistirPrincipal,
required Future<void> Function(PresetEcualizador) aplicar,
Future<String?> Function()? dispositivoDestino,
Future<void> Function(String deviceId, PresetEcualizador preset)?
persistirDispositivo,
Future<Set<String>> Function()? clavesMatriz,
}) async {
final preset = resolverPresetEq(id, presets);
if (preset == null) return;
final destino =
(dispositivoDestino == null || persistirDispositivo == null)
? null
: await dispositivoDestino();
if (destino != null) {
// Apply-first ordering: if the live application throws, nothing has
// been persisted yet, so audible and persisted state cannot diverge.
if (debeAplicarSeleccionAhora(
uuidActual: uuidActual,
clavesPorEmisora: await clavesPorEmisora(),
clavesMatriz:
clavesMatriz == null ? const <String>{} : await clavesMatriz(),
deviceIdDestino: destino,
)) {
await aplicar(preset);
}
await persistirDispositivo!(destino, preset);
return;
}
await persistirPrincipal(preset);
if (debeAplicarPrincipalAhora(
uuidActual: uuidActual,
@@ -1262,6 +1476,75 @@ Future<void> aplicarPresetPorMediaId(
}
}
/// Orchestrates an `eq_gain:<indice>:<db>` selection from the car (feature
/// auto-custom-eq): parses [id] via [gananciaEqDesde], resolves the custom
/// base preset for the persistence target ([presetPersonalizadoEfectivo]
/// over [cargarConfig]'s snapshot), replaces the single band (the result is
/// always named `Personalizado` via `copyWithBandas`), persists it at DEVICE
/// level when [dispositivoDestino] yields a deviceId — global principal
/// otherwise, including the headless error/timeout fallback — and applies
/// the band live via [aplicarBanda] (the handler's `setBanda`, itself a
/// no-op while the EQ engine is unavailable).
///
/// Live application is gated by [debeAplicarSeleccionAhora] over the same
/// optional [uuidActual]/[clavesPorEmisora]/[clavesMatriz] seams as
/// [aplicarPresetPorMediaId] (omitted seams keep the legacy always-apply
/// behavior), and runs BEFORE persistence so a throwing apply cannot leave
/// persisted and audible state divergent.
///
/// Same non-playback structural invariant as [aplicarPresetPorMediaId]: no
/// playback seam exists in this signature. A malformed/out-of-range [id] or
/// a failing [cargarConfig] degrades to a no-op — no seam is invoked and no
/// exception propagates.
Future<void> aplicarGananciaPorMediaId(
String id, {
required Future<ConfiguracionEcualizador> Function() cargarConfig,
required Future<String?> Function() dispositivoDestino,
required Future<void> Function(String deviceId, PresetEcualizador preset)
persistirDispositivo,
required Future<void> Function(PresetEcualizador preset) persistirPrincipal,
required Future<void> Function(int indice, double db) aplicarBanda,
String? uuidActual,
Future<Set<String>> Function()? clavesPorEmisora,
Future<Set<String>> Function()? clavesMatriz,
}) async {
final ganancia = gananciaEqDesde(id);
if (ganancia == null) return;
final (indice, db) = ganancia;
final ConfiguracionEcualizador config;
try {
config = await cargarConfig();
} catch (_) {
return;
}
final destino = await dispositivoDestino();
final base = presetPersonalizadoEfectivo(config: config, deviceId: destino);
final bandas = List<double>.from(base.bandas);
bandas[indice] = db;
final modificado = base.copyWithBandas(bandas);
// Apply-first ordering (same rationale as [aplicarPresetPorMediaId]'s
// device branch): a throwing apply must not leave persisted state ahead
// of the audible one.
if (debeAplicarSeleccionAhora(
uuidActual: uuidActual,
clavesPorEmisora:
clavesPorEmisora == null ? const <String>{} : await clavesPorEmisora(),
clavesMatriz:
clavesMatriz == null ? const <String>{} : await clavesMatriz(),
deviceIdDestino: destino,
)) {
await aplicarBanda(indice, db);
}
if (destino != null) {
await persistirDispositivo(destino, modificado);
} else {
await persistirPrincipal(modificado);
}
}
/// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design
/// "getChildren data source"). Reads favourites from SQLite and custom
/// stations from the tolerant JSON file directly — both loadable without