Files
pluriwave/lib/estado/estado_ecualizador.dart
T
Javier Bautista Fernández 163ff69f7a
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s
feat(eq): android auto custom equalizer and robust device detection
- 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.
2026-07-22 10:26:02 +02:00

535 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../modelos/dispositivo_audio.dart';
import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart';
/// Equalizer state extracted from `EstadoRadio` (S4-R1).
///
/// Owns the main preset, the per-station preset map, the current (applied)
/// preset and the enabled flag, plus their persistence through
/// [ServicioEcualizador] and their application through [ServicioAudio].
/// Notifies ONLY its own listeners — EQ changes must not rebuild
/// `EstadoRadio` consumers (S4-R1-A, S4-R5).
///
/// Multi-device EQ extension (Phase 5): when [eqMultiDeviceEnabled] is true,
/// resolves the active preset through a 4-level hierarchy:
/// 1. presetsMatriz["stationUuid:deviceId"]
/// 2. presetsEmisoraMap[stationUuid]
/// 3. presetsDispositivo[deviceId]
/// 4. presetPrincipal
///
/// When the toggle is false, resolution falls back to the original 2-level
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
class EstadoEcualizador extends ChangeNotifier {
EstadoEcualizador({
required this.audio,
ServicioEcualizador? servicio,
ServicioDispositivoAudio? dispositivoAudio,
String? Function()? emisoraActualUuid,
}) : servicio = servicio ?? ServicioEcualizador(),
_dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
final ServicioAudio audio;
final ServicioEcualizador servicio;
final ServicioDispositivoAudio? _dispositivoAudio;
/// Callback into the owner (EstadoRadio) for the currently playing station;
/// keeps this notifier free of any station-list coupling.
final String? Function() _emisoraActualUuid;
final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
/// Per-device presets: deviceId → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsDispositivo = {};
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
final Map<String, PresetEcualizador> _presetsMatriz = {};
/// Custom display names for devices: deviceId → custom name.
final Map<String, String> _nombresDispositivos = {};
/// Last-seen platform (Bluetooth/productName) name per deviceId.
///
/// In-memory only (bt-device-identity ADR-4) — NOT persisted. Devices
/// re-report their name on every enumeration, so this cache self-heals
/// every session without needing a SharedPreferences key or migration.
final Map<String, String> _nombresPlataforma = {};
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true;
bool _eqMultiDeviceEnabled = false;
String? _dispositivoActualId;
StreamSubscription<DispositivoAudio>? _deviceSub;
Future<void>? _refrescoEnCurso;
PresetEcualizador get presetActual => _presetActual;
PresetEcualizador get presetPrincipal => _presetPrincipal;
bool get activo => _activo;
bool get disponible => audio.ecualizadorDisponible;
bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled;
String? get dispositivoActualId => _dispositivoActualId;
Map<String, String> get nombresDispositivos =>
Map.unmodifiable(_nombresDispositivos);
Map<String, PresetEcualizador> get presetsPorEmisora =>
Map.unmodifiable(_presetsEmisoraMap);
Map<String, PresetEcualizador> get presetsDispositivo =>
Map.unmodifiable(_presetsDispositivo);
Map<String, PresetEcualizador> get presetsMatriz =>
Map.unmodifiable(_presetsMatriz);
bool get emisoraActualTienePresetPropio {
final uuid = _emisoraActualUuid();
if (uuid == null) return false;
return tienePresetPorEmisora(uuid);
}
bool tienePresetPorEmisora(String uuid) =>
_presetsEmisoraMap.containsKey(uuid);
PresetEcualizador? presetPorEmisora(String uuid) => _presetsEmisoraMap[uuid];
PresetEcualizador presetParaEmisora(String uuid) =>
_presetsEmisoraMap[uuid] ?? _presetPrincipal;
/// 4-level resolution hierarchy (ADR-4, spec requirement).
///
/// When [eqMultiDeviceEnabled] is false, falls back to 2-level (station → global).
PresetEcualizador presetEfectivo({
required String stationUuid,
required String deviceId,
}) {
if (!_eqMultiDeviceEnabled) {
// Original 2-level behavior: station → global.
return _presetsEmisoraMap[stationUuid] ?? _presetPrincipal;
}
// Level 1: station × device matrix.
final matrizKey = '$stationUuid:$deviceId';
if (_presetsMatriz.containsKey(matrizKey)) {
return _presetsMatriz[matrizKey]!;
}
// Level 2: station-only.
if (_presetsEmisoraMap.containsKey(stationUuid)) {
return _presetsEmisoraMap[stationUuid]!;
}
// Level 3: device-only.
if (_presetsDispositivo.containsKey(deviceId)) {
return _presetsDispositivo[deviceId]!;
}
// Level 4: global fallback.
return _presetPrincipal;
}
/// Resolves the effective preset for the current station and device.
PresetEcualizador _resolverPresetActivo() {
final uuid = _emisoraActualUuid();
if (!_eqMultiDeviceEnabled) {
return uuid != null
? (_presetsEmisoraMap[uuid] ?? _presetPrincipal)
: _presetPrincipal;
}
final deviceId = _dispositivoActualId;
if (uuid == null || deviceId == null) {
return _presetPrincipal;
}
return presetEfectivo(stationUuid: uuid, deviceId: deviceId);
}
/// Loads the persisted EQ configuration and applies it to the audio engine.
Future<void> cargarPersistido() async {
try {
final config = await servicio.cargar();
_presetPrincipal = config.principal;
_activo = config.activo;
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
_presetsEmisoraMap
..clear()
..addAll(config.porEmisora);
_presetsDispositivo
..clear()
..addAll(config.presetsDispositivo);
_presetsMatriz
..clear()
..addAll(config.presetsMatriz);
_nombresDispositivos
..clear()
..addAll(config.nombresDispositivos);
// Resolve active preset and apply it.
_presetActual = _resolverPresetActivo();
await audio.setEcualizadorActivo(_activo);
await audio.aplicarPreset(_presetActual);
// Subscribe to device changes only when toggle is on.
_configurarSuscripcionDispositivo();
// Seed current device immediately so presets resolve before first event.
if (_eqMultiDeviceEnabled) {
await _sembrarDispositivoActual();
}
} catch (_) {
_presetPrincipal = PresetEcualizador.flat;
_presetActual = PresetEcualizador.flat;
_activo = true;
_eqMultiDeviceEnabled = false;
_presetsEmisoraMap.clear();
_presetsDispositivo.clear();
_presetsMatriz.clear();
_nombresDispositivos.clear();
}
}
/// Queries the current device and seeds [_dispositivoActualId] without
/// waiting for a stream event. Falls back to `'builtin_speaker'` on error.
Future<void> _sembrarDispositivoActual() async {
final svc = _dispositivoAudio;
if (svc == null) return;
try {
final dispositivo = await svc.obtenerDispositivoActual();
await _onDispositivoCambiado(dispositivo);
} catch (_) {
_dispositivoActualId = 'builtin_speaker';
}
}
/// Re-syncs the active device after a possibly-missed native resync
/// (activity recreation over the cached engine, return to foreground,
/// opening the settings section): re-subscribes the platform event channel
/// via [ServicioDispositivoAudio.resubscribir] — re-registering the native
/// callback on the CURRENT activity — and re-seeds [_dispositivoActualId]
/// with a fresh query. No-op when the multi-device toggle is off or no
/// device service is injected; safe to call repeatedly. Concurrent calls
/// (app-resume observer + settings initState) share the same in-flight
/// refresh instead of racing [ServicioDispositivoAudio.resubscribir],
/// which would leak a native AudioDeviceCallback.
Future<void> refrescarDispositivoActual() {
final enCurso = _refrescoEnCurso;
if (enCurso != null) return enCurso;
final refresco = _refrescarDispositivoActual().whenComplete(() {
_refrescoEnCurso = null;
});
_refrescoEnCurso = refresco;
return refresco;
}
Future<void> _refrescarDispositivoActual() async {
if (!_eqMultiDeviceEnabled) return;
final svc = _dispositivoAudio;
if (svc == null) return;
try {
await svc.resubscribir();
} catch (_) {
// A failed resubscribe must never block the fresh-query re-seed below.
}
await _sembrarDispositivoActual();
}
/// Subscribes to the device change stream if multi-device is enabled.
void _configurarSuscripcionDispositivo() {
_deviceSub?.cancel();
_deviceSub = null;
final svc = _dispositivoAudio;
if (!_eqMultiDeviceEnabled || svc == null) return;
_deviceSub = svc.onDispositivoCambiado.listen(_onDispositivoCambiado);
}
/// Called when a device change event arrives.
Future<void> _onDispositivoCambiado(DispositivoAudio dispositivo) async {
if (!_eqMultiDeviceEnabled) return;
// Cache updates on every event regardless of whether a preset entry
// gets created below (bt-device-identity ADR-4).
_nombresPlataforma[dispositivo.id] = dispositivo.nombre;
_dispositivoActualId = dispositivo.id;
// First-seen device: copy the current resolved preset as its starting
// point. builtin_speaker is excluded: it must always fall through to
// the hierarchy (L4 global) instead of being pinned by a forced L3
// device-level copy, otherwise a later global-preset change would be
// masked by this stale entry for the base device. Composite-placeholder
// ids are also excluded (bt-device-identity ADR-6): they are transient
// fallback ids for a device whose real MAC is not yet known, so
// persisting a preset entry for them would create dead noise that never
// resolves to the eventual real-MAC id.
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
final esPlaceholderCompuesto = dispositivo.id.startsWith(
prefijoPlaceholderBtName,
);
if (!esBase &&
!esPlaceholderCompuesto &&
!_presetsDispositivo.containsKey(dispositivo.id)) {
final presetBase = _resolverPresetActivo();
_presetsDispositivo[dispositivo.id] = presetBase;
await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
}
// Re-resolve and apply the preset for the new device.
final resuelto = _resolverPresetActivo();
_presetActual = resuelto;
await audio.aplicarPreset(resuelto);
notifyListeners();
}
/// Applies [preset] to the audio engine and tracks it as current
/// WITHOUT persisting it (used when switching stations).
Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
_presetActual = preset;
await audio.aplicarPreset(preset);
}
/// Enables or disables the multi-device EQ feature toggle.
///
/// When disabled, the device stream subscription is cancelled and resolution
/// immediately collapses to 2-level (station → global).
Future<void> cambiarMultiDeviceEnabled(
bool habilitado, {
bool notificar = true,
}) async {
_eqMultiDeviceEnabled = habilitado;
await servicio.guardarToggleMultiDispositivo(habilitado);
_configurarSuscripcionDispositivo();
// Re-seed the active device from a fresh query instead of leaving a
// stale _dispositivoActualId from before the toggle flip (it would
// otherwise only self-correct on the next native device-change event).
if (_eqMultiDeviceEnabled) {
await _sembrarDispositivoActual();
}
if (notificar) notifyListeners();
}
/// Requests `BLUETOOTH_CONNECT` (API 31+) at the point the
/// device-management UI is opened (bt-device-identity ADR-1).
///
/// Thin passthrough to the injected device service so callers don't need
/// [ServicioDispositivoAudio] wired as its own top-level `Provider` (Task
/// 4.8 — `ServicioDispositivoAudio` is only ever constructed inside
/// `EstadoRadio` today, not exposed via the provider tree; routing through
/// here avoids adding wiring the tree doesn't already have). Returns false
/// when no device service is injected.
Future<bool> solicitarPermisoBluetooth() async {
final svc = _dispositivoAudio;
if (svc == null) return false;
return svc.solicitarPermisoBluetooth();
}
Future<void> cambiarPresetPrincipal(
PresetEcualizador preset, {
bool notificar = true,
}) async {
_presetPrincipal = preset;
await servicio.guardarPrincipal(preset);
final uuid = _emisoraActualUuid();
final puedeAplicarAhora =
uuid == null || !_presetsEmisoraMap.containsKey(uuid);
if (puedeAplicarAhora) {
await aplicarPresetActivo(preset);
}
if (notificar) notifyListeners();
}
Future<void> guardarPresetPorEmisora(
String uuid,
PresetEcualizador preset, {
bool notificar = true,
}) async {
_presetsEmisoraMap[uuid] = preset;
await servicio.guardarPorEmisora(uuid, preset);
if (_emisoraActualUuid() == uuid) {
await aplicarPresetActivo(preset);
}
if (notificar) notifyListeners();
}
Future<void> habilitarPresetPorEmisora(
String uuid, {
PresetEcualizador? base,
bool notificar = true,
}) async {
final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal;
await guardarPresetPorEmisora(uuid, presetBase, notificar: notificar);
}
Future<void> deshabilitarPresetPorEmisora(
String uuid, {
bool notificar = true,
}) async {
_presetsEmisoraMap.remove(uuid);
await servicio.eliminarPorEmisora(uuid);
if (_emisoraActualUuid() == uuid) {
await aplicarPresetActivo(_presetPrincipal);
}
if (notificar) notifyListeners();
}
Future<void> cambiarModoEmisoraActual({required bool usarPropio}) async {
final uuid = _emisoraActualUuid();
if (uuid == null) return;
if (usarPropio) {
await habilitarPresetPorEmisora(uuid);
} else {
await deshabilitarPresetPorEmisora(uuid);
}
}
/// Persists a custom display name for [deviceId].
///
/// Empty names are silently ignored so the existing name is preserved.
/// No-op when the multi-device toggle is off.
Future<void> renombrarDispositivo(String deviceId, String nombre) async {
if (!_eqMultiDeviceEnabled) return;
final nombreTrimmed = nombre.trim();
if (nombreTrimmed.isEmpty) return;
_nombresDispositivos[deviceId] = nombreTrimmed;
await servicio.guardarNombresDispositivos(
Map.unmodifiable(_nombresDispositivos),
);
notifyListeners();
}
/// Persists a per-device EQ preset for [deviceId].
///
/// Guards on [_eqMultiDeviceEnabled]: no-op when toggle is off.
/// If [deviceId] is the currently active device, re-resolves and
/// applies the effective preset immediately.
Future<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
if (!_eqMultiDeviceEnabled) return;
_presetsDispositivo[deviceId] = preset;
await servicio.guardarPresetDispositivo(deviceId, preset);
if (_dispositivoActualId == deviceId) {
// Re-resolve using the full hierarchy (station/matrix may override).
final resuelto = _resolverPresetActivo();
_presetActual = resuelto;
await audio.aplicarPreset(resuelto);
}
notifyListeners();
}
/// Returns the stored custom name for [deviceId], or an empty string if none.
String obtenerNombreDispositivo(String deviceId) =>
_nombresDispositivos[deviceId] ?? '';
/// Returns the last-seen platform name for [deviceId], or an empty string
/// if none has been observed yet (bt-device-identity ADR-4).
String nombrePlataforma(String deviceId) =>
_nombresPlataforma[deviceId] ?? '';
/// Resolves the display name for [deviceId] using the fallback chain:
/// custom name → [platformName] → raw [deviceId].
String nombreVisible(String deviceId, String platformName) {
final custom = _nombresDispositivos[deviceId];
if (custom != null && custom.isNotEmpty) return custom;
if (platformName.isNotEmpty) return platformName;
return deviceId;
}
Future<void> cambiarActivo(bool activo) async {
_activo = activo;
await servicio.guardarActivo(activo);
await audio.setEcualizadorActivo(activo);
if (activo) {
await audio.aplicarPreset(_presetActual);
}
notifyListeners();
}
Future<void> cambiarPreset(
PresetEcualizador preset, {
bool guardarPorEmisora = true,
}) async {
final uuid = _emisoraActualUuid();
final usarPresetPropio =
guardarPorEmisora &&
uuid != null &&
_presetsEmisoraMap.containsKey(uuid);
if (usarPresetPropio) {
await guardarPresetPorEmisora(uuid, preset);
return;
}
await cambiarPresetPrincipal(preset);
}
Future<void> cambiarBanda(int index, double db) async {
final bandas = List<double>.from(_presetActual.bandas);
if (index < 0 || index >= bandas.length) return;
bandas[index] = db;
final modificado = PresetEcualizador(
nombre: 'Personalizado',
bandas: bandas,
);
await cambiarPreset(modificado);
}
/// Replaces the whole EQ configuration (backup import path): persists it,
/// re-applies the preset effective for the current station and notifies.
Future<void> importarConfiguracion({
required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
}) async {
_presetPrincipal = principal;
_presetsEmisoraMap
..clear()
..addAll(porEmisora);
if (presetsDispositivo != null) {
_presetsDispositivo
..clear()
..addAll(presetsDispositivo);
}
if (presetsMatriz != null) {
_presetsMatriz
..clear()
..addAll(presetsMatriz);
}
if (eqMultiDeviceEnabled != null) {
_eqMultiDeviceEnabled = eqMultiDeviceEnabled;
}
await servicio.guardarConfiguracion(
ConfiguracionEcualizador(
principal: _presetPrincipal,
porEmisora: _presetsEmisoraMap,
activo: _activo,
eqMultiDeviceEnabled: _eqMultiDeviceEnabled,
presetsDispositivo: _presetsDispositivo,
presetsMatriz: _presetsMatriz,
),
);
final uuid = _emisoraActualUuid();
final presetEfectivoActual =
uuid == null ? _presetPrincipal : _resolverPresetActivo();
await aplicarPresetActivo(presetEfectivoActual);
notifyListeners();
}
@override
void dispose() {
_deviceSub?.cancel();
super.dispose();
}
}