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 _presetsEmisoraMap = {}; /// Per-device presets: deviceId → PresetEcualizador. final Map _presetsDispositivo = {}; /// Matrix presets: "stationUuid:deviceId" → PresetEcualizador. final Map _presetsMatriz = {}; /// Custom display names for devices: deviceId → custom name. final Map _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 _nombresPlataforma = {}; PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; bool _activo = true; bool _eqMultiDeviceEnabled = false; String? _dispositivoActualId; StreamSubscription? _deviceSub; Future? _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 get nombresDispositivos => Map.unmodifiable(_nombresDispositivos); Map get presetsPorEmisora => Map.unmodifiable(_presetsEmisoraMap); Map get presetsDispositivo => Map.unmodifiable(_presetsDispositivo); Map 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 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 _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 refrescarDispositivoActual() { final enCurso = _refrescoEnCurso; if (enCurso != null) return enCurso; final refresco = _refrescarDispositivoActual().whenComplete(() { _refrescoEnCurso = null; }); _refrescoEnCurso = refresco; return refresco; } Future _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 _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 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 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 solicitarPermisoBluetooth() async { final svc = _dispositivoAudio; if (svc == null) return false; return svc.solicitarPermisoBluetooth(); } Future 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 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 habilitarPresetPorEmisora( String uuid, { PresetEcualizador? base, bool notificar = true, }) async { final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal; await guardarPresetPorEmisora(uuid, presetBase, notificar: notificar); } Future deshabilitarPresetPorEmisora( String uuid, { bool notificar = true, }) async { _presetsEmisoraMap.remove(uuid); await servicio.eliminarPorEmisora(uuid); if (_emisoraActualUuid() == uuid) { await aplicarPresetActivo(_presetPrincipal); } if (notificar) notifyListeners(); } Future 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 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 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 cambiarActivo(bool activo) async { _activo = activo; await servicio.guardarActivo(activo); await audio.setEcualizadorActivo(activo); if (activo) { await audio.aplicarPreset(_presetActual); } notifyListeners(); } Future 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 cambiarBanda(int index, double db) async { final bandas = List.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 importarConfiguracion({ required PresetEcualizador principal, required Map porEmisora, Map? presetsDispositivo, Map? 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(); } }