refactor(auto): drop the in-car equalizer, keep EQ on the phone
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s

The car tree carried a whole equalizer feature: an `Ecualizador` root folder
with the six factory presets, a browsable `Personalizado` folder, five band
folders and thirteen gain leaves each, plus the media-id namespaces, routing
predicates, persistence-targeting and children-changed plumbing that fed
them. Equalization is a phone task; the driver should not be tuning 5 bands
from a car screen.

Removed: the `eq_preset:`/`eq_banda:`/`eq_gain:` namespaces and their
predicates and parsers, the `ecualizador`/`eq_custom` folder ids and their
getChildren branches, itemPresetEq/presetsEq/itemEqPersonalizado/bandasEq/
gananciasBandaEq, resolverPresetEq, presetPersonalizadoEfectivo,
dispositivoDestinoEq, debeAplicarPrincipalAhora/debeAplicarSeleccionAhora,
aplicarPresetPorMediaId, aplicarGananciaPorMediaId, and in the handler the
playFromMediaId branches, _presetPersonalizadoAuto, _dispositivoActivoAuto,
_dispositivoDestinoEqAuto and the subscribeToChildren/_hijosSubjects
notification machinery that existed only to refresh band titles after a gain
tap.

Deliberately KEPT: automatic per-device EQ. Reaching the car still applies
that device's preset, because that lives in EstadoEcualizador and the
output-device detection, not in this tree — it works with Android Auto or
without it. Configuring is what moves to the phone; applying stays automatic.

Also kept: the `eq_preset_*_v1` SharedPreferences keys in
ServicioEcualizador, which share a name with the deleted media-id prefix by
coincidence only and hold the phone's own presets.

The root folder set goes from five entries to four (three without local
music); its test now asserts no equalizer folder is offered at all, so a
reintroduction has to be deliberate.
This commit is contained in:
2026-07-27 15:50:24 +02:00
parent d12dd49afe
commit 2403da3c2e
3 changed files with 65 additions and 1439 deletions
+8 -171
View File
@@ -4,13 +4,10 @@ import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/services.dart' show MethodChannel;
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject, ValueStream;
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/dispositivo_audio.dart';
import '../modelos/emisora.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
@@ -19,8 +16,6 @@ import 'controlador_reconexion.dart';
import 'musica_local_auto.dart';
import 'navegacion_auto.dart';
import 'servicio_audio_session.dart';
import 'servicio_dispositivo_audio.dart';
import 'servicio_ecualizador.dart';
/// Estado de reproducción expuesto al UI.
enum EstadoReproduccion {
@@ -494,7 +489,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
}
/// Public entry point for EVERY external play (phone `reproducir`, car
/// `emisora:`/`grupo:`/`pista:`/`eq_preset:` non-path). ALWAYS clears the
/// `emisora:`/`grupo:`/`pista:` non-path). ALWAYS clears the
/// local queue FIRST (Design ADR-2, the single load-bearing invariant:
/// "external play = leave queue mode") so a stale auto-advance can never
/// fire after an external source switch, then delegates to the private
@@ -913,10 +908,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _androidAudioSessionIdSub?.cancel();
await _player.dispose();
await _androidAudioSessionIdController.close();
for (final subject in _hijosSubjects.values) {
await subject.close();
}
_hijosSubjects.clear();
}
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
@@ -937,88 +928,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// method channel `ServicioDispositivoAudioReal` talks to, but method
/// calls only — opening a second EventChannel subscription here would
/// steal the phone-side service's Dart stream handler.
static const _canalDispositivos = MethodChannel('pluriwave/audio_devices');
/// Short timeout for the device query: on a headless Auto bind no
/// Activity (and thus no channel handler) exists, and a car tap must fall
/// back to global persistence instead of hanging.
static const _timeoutConsultaDispositivo = Duration(seconds: 2);
/// Fresh active-output-device query for the car EQ paths. Returns `null`
/// on ANY failure (missing handler while headless, timeout, malformed
/// map) so callers degrade to global persistence — never a crash.
Future<DispositivoAudio?> _dispositivoActivoAuto() async {
try {
final raw = await _canalDispositivos
.invokeMethod<Map<dynamic, dynamic>>('getActiveDevice')
.timeout(_timeoutConsultaDispositivo);
if (raw == null) return null;
return ServicioDispositivoAudioReal.dispositivoDesdeMapa(
Map<String, dynamic>.from(raw),
);
} catch (_) {
return null;
}
}
/// Resolves the persistence target for a car EQ action (feature
/// auto-custom-eq): a deviceId for a DEVICE-level write, `null` for the
/// global principal (toggle off, built-in speaker, placeholder id, or the
/// headless error/timeout fallback).
Future<String?> _dispositivoDestinoEqAuto(
ServicioEcualizador servicio,
) async {
try {
final config = await servicio.cargar();
if (!config.eqMultiDeviceEnabled) return null;
return dispositivoDestinoEq(
multiDeviceEnabled: config.eqMultiDeviceEnabled,
dispositivo: await _dispositivoActivoAuto(),
);
} catch (_) {
return null;
}
}
/// The custom preset the Auto tree shows and edits right now (feature
/// auto-custom-eq): the device-level entry for the current output device
/// when multi-device is on, the global principal otherwise — resolved
/// from persistence so a headless bind (no `EstadoEcualizador`) still
/// reports honest gains.
Future<PresetEcualizador> _presetPersonalizadoAuto() async {
final servicio = ServicioEcualizador();
final config = await servicio.cargar();
final destino =
config.eqMultiDeviceEnabled
? dispositivoDestinoEq(
multiDeviceEnabled: true,
dispositivo: await _dispositivoActivoAuto(),
)
: null;
return presetPersonalizadoEfectivo(config: config, deviceId: destino);
}
/// Per-parent children-changed subjects (feature auto-custom-eq):
/// audio_service subscribes to [subscribeToChildren]'s stream the first
/// time the platform loads a parent's children and translates every later
/// emission into a native `notifyChildrenChanged`, making the car
/// re-request `getChildren` so band titles and the selection mark refresh
/// right after a gain tap.
final Map<String, BehaviorSubject<Map<String, dynamic>>> _hijosSubjects = {};
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
_hijosSubjects.putIfAbsent(
parentMediaId,
() => BehaviorSubject.seeded(<String, dynamic>{}),
);
/// Emits a children-changed notification for [parentMediaId] — a no-op
/// until the platform has browsed that parent at least once.
void _notificarHijosCambiados(String parentMediaId) {
_hijosSubjects[parentMediaId]?.add(<String, dynamic>{});
}
@override
Future<List<MediaItem>> getChildren(
String parentMediaId, [
@@ -1032,23 +941,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal);
}
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return [
...constructor.presetsEq(PresetEcualizador.presets),
constructor.itemEqPersonalizado(),
];
}
if (parentMediaId == ConstructorArbolAuto.idEqPersonalizado) {
return constructor.bandasEq(await _presetPersonalizadoAuto());
}
if (esBandaEqMediaId(parentMediaId)) {
final indice = indiceBandaEqDesde(parentMediaId);
if (indice == null) return const [];
return constructor.gananciasBandaEq(
indice,
await _presetPersonalizadoAuto(),
);
}
final musicaLocal = await hijosMusicaLocal(
parentMediaId,
fuente: fuenteLocal,
@@ -1097,65 +989,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Map<String, dynamic>? extras,
]) async {
try {
// EQ preset selection (Design ADR-3, Spec "EQ Preset Selection Applies
// Without Disturbing Playback"): FIRST branch, unconditional `return`,
// so an `eq_preset:` id can never fall through to the playback routing
// below. `aplicarPresetPorMediaId`'s seams are EQ-only (persist +
// apply) — there is no playback parameter to inject here.
if (esPresetMediaId(mediaId)) {
final servicio = ServicioEcualizador();
// Persistence targeting (feature auto-custom-eq): with multi-device
// EQ on and a non-builtin output device active, the tap persists a
// DEVICE-level entry so the selection sticks for the car's device
// instead of being shadowed by the hierarchy; otherwise (or on any
// headless query failure) it persists the global principal as
// before.
await aplicarPresetPorMediaId(
mediaId,
presets: PresetEcualizador.presets,
uuidActual: emisoraActual?.uuid,
clavesPorEmisora: () async =>
(await servicio.cargar()).porEmisora.keys.toSet(),
clavesMatriz: () async =>
(await servicio.cargar()).presetsMatriz.keys.toSet(),
dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio),
persistirDispositivo: servicio.guardarPresetDispositivo,
persistirPrincipal: servicio.guardarPrincipal,
aplicar: aplicarPreset,
);
return;
}
// Custom-EQ gain selection (feature auto-custom-eq): same
// unconditional-return shape as the eq_preset branch above — an
// `eq_gain:` id can never fall through to playback routing.
if (esGananciaEqMediaId(mediaId)) {
final servicio = ServicioEcualizador();
await aplicarGananciaPorMediaId(
mediaId,
cargarConfig: servicio.cargar,
dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio),
persistirDispositivo: servicio.guardarPresetDispositivo,
persistirPrincipal: servicio.guardarPrincipal,
aplicarBanda: setBanda,
uuidActual: emisoraActual?.uuid,
clavesPorEmisora: () async =>
(await servicio.cargar()).porEmisora.keys.toSet(),
clavesMatriz: () async =>
(await servicio.cargar()).presetsMatriz.keys.toSet(),
);
// Refresh the affected browse nodes so the band title under
// `Personalizado` and the `● ` selection mark reflect the new gain.
final ganancia = gananciaEqDesde(mediaId);
if (ganancia != null) {
_notificarHijosCambiados(ConstructorArbolAuto.idEqPersonalizado);
_notificarHijosCambiados(idBandaEq(ganancia.$1));
}
return;
}
// Local-track playback (Design "Local Track Playback Reuses Existing
// Pipeline", Spec "User selects a local track"): SECOND branch,
// unconditional `return`, mirroring the eq_preset branch above — a
// `pista:` id never falls through to the station routing below.
// Pipeline", Spec "User selects a local track"): FIRST branch,
// unconditional `return` — a `pista:` id never falls through to the
// station routing below.
if (esPistaMediaId(mediaId)) {
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (fuenteLocal == null) return;
@@ -1166,10 +1003,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
);
return;
}
// Folder-play actions (Design ADR-5, Phase 3 task 4.3): THIRD
// branch, after eq_preset/pista, before the station fallthrough —
// mirrors both branches above's unconditional-return shape so
// neither new action id can fall through to station routing.
// Folder-play actions (Design ADR-5, Phase 3 task 4.3): SECOND
// branch, after `pista:`, before the station fallthrough — mirrors the
// branch above's unconditional-return shape so neither new action id
// can fall through to station routing.
final constructorArbol = ConstructorArbolAuto();
final esAccionCarpeta =
constructorArbol.esCarpetaLocalReproducirMediaId(mediaId) ||