feat(eq): add per-device equalizer with 4-level preset resolution

Introduce multi-device EQ support allowing each audio output device
(built-in speaker, wired, USB, individual Bluetooth by MAC) to have
its own equalizer preset, combined with existing per-station presets
for a full station×device matrix.

- Add DispositivoAudio model and ServicioDispositivoAudio interface
- Add Android platform channel (AudioDeviceCallback) for device detection
- Add iOS AudioDevicesPlugin (AVAudioSession route tracking)
- Extend ServicioEcualizador with device and matrix persistence keys
- Implement 4-level resolution: matrix > station > device > global
- Add advanced EQ settings section with feature toggle (off by default)
- Extend export/import to v3 with backward compatibility
- 184 tests passing, zero analyzer issues
This commit is contained in:
2026-06-27 11:33:53 +02:00
parent 8f42e67b48
commit 4632d53eb8
36 changed files with 2850 additions and 64 deletions
+8 -1
View File
@@ -25,6 +25,7 @@ import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key, this.prefs});
@@ -37,7 +38,13 @@ class PluriWaveApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
ChangeNotifierProvider(
create:
(_) => EstadoRadio(
prefs: prefs,
dispositivoAudio: ServicioDispositivoAudioReal(),
),
),
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
// (they need its services and callbacks at construction); these
// providers only expose the instances, so they declare no dispose
+170 -5
View File
@@ -1,7 +1,11 @@
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).
@@ -11,33 +15,65 @@ import '../servicios/servicio_ecualizador.dart';
/// [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 = {};
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true;
bool _eqMultiDeviceEnabled = false;
String? _dispositivoActualId;
StreamSubscription<DispositivoAudio>? _deviceSub;
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, 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;
@@ -52,26 +88,116 @@ class EstadoEcualizador extends ChangeNotifier {
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;
_presetActual = config.principal;
_activo = config.activo;
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
_presetsEmisoraMap
..clear()
..addAll(config.porEmisora);
_presetsDispositivo
..clear()
..addAll(config.presetsDispositivo);
_presetsMatriz
..clear()
..addAll(config.presetsMatriz);
// Resolve active preset and apply it.
_presetActual = _resolverPresetActivo();
await audio.setEcualizadorActivo(_activo);
await audio.aplicarPreset(_presetPrincipal);
await audio.aplicarPreset(_presetActual);
// Subscribe to device changes only when toggle is on.
_configurarSuscripcionDispositivo();
} catch (_) {
_presetPrincipal = PresetEcualizador.flat;
_presetActual = PresetEcualizador.flat;
_activo = true;
_eqMultiDeviceEnabled = false;
_presetsEmisoraMap.clear();
_presetsDispositivo.clear();
_presetsMatriz.clear();
}
}
/// 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;
_dispositivoActualId = dispositivo.id;
// First-seen device: copy the current resolved preset as its starting point.
if (!_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 {
@@ -79,6 +205,20 @@ class EstadoEcualizador extends ChangeNotifier {
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();
if (notificar) notifyListeners();
}
Future<void> cambiarPresetPrincipal(
PresetEcualizador preset, {
bool notificar = true,
@@ -184,24 +324,49 @@ class EstadoEcualizador extends ChangeNotifier {
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();
await aplicarPresetActivo(
uuid == null ? _presetPrincipal : presetParaEmisora(uuid),
);
final presetEfectivoActual =
uuid == null ? _presetPrincipal : _resolverPresetActivo();
await aplicarPresetActivo(presetEfectivoActual);
notifyListeners();
}
@override
void dispose() {
_deviceSub?.cancel();
super.dispose();
}
}
+51 -4
View File
@@ -17,6 +17,7 @@ import 'estado_ecualizador.dart';
import 'estado_grabacion.dart';
import 'orden_emisoras.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_export_import.dart';
import '../servicios/servicio_favoritos.dart';
@@ -38,6 +39,7 @@ class EstadoRadio extends ChangeNotifier {
ServicioFavoritos? favoritos,
ServicioRadio? radio,
ServicioEcualizador? servicioEcualizador,
ServicioDispositivoAudio? dispositivoAudio,
ServicioGrabacionRadio? servicioGrabacion,
SharedPreferences? prefs,
Future<File> Function()? resolverArchivoCustom,
@@ -47,11 +49,13 @@ class EstadoRadio extends ChangeNotifier {
radio = radio ?? ServicioRadio(),
servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
_dispositivoAudio = dispositivoAudio,
_prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom {
ecualizador = EstadoEcualizador(
audio: this.audio,
servicio: this.servicioEcualizador,
dispositivoAudio: _dispositivoAudio,
emisoraActualUuid: () => emisoraActual?.uuid,
);
grabacion = EstadoGrabacion(
@@ -77,6 +81,10 @@ class EstadoRadio extends ChangeNotifier {
final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador;
/// Optional device service — null means no device subscription established.
/// Wired in production from app.dart; defaults to null in tests.
final ServicioDispositivoAudio? _dispositivoAudio;
/// Domain notifiers extracted from this class (S4). Created and disposed
/// here (they need EstadoRadio's services and callbacks at construction);
/// exposed app-wide through ListenableProviders in app.dart.
@@ -547,8 +555,9 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v2 — portabilidad completa).
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4).
/// Genera el JSON de toda la configuración (v3 — portabilidad completa
/// con presets por dispositivo y matriz multi-device).
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos();
@@ -572,6 +581,10 @@ class EstadoRadio extends ChangeNotifier {
emisoraPreferidaUuid: _emisoraPreferidaUuid,
ordenListas: _ordenListas.name,
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
// v3 extensions — multi-device EQ fields.
presetsPorDispositivo: ecualizador.presetsDispositivo,
presetsMatriz: ecualizador.presetsMatriz,
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
);
}
@@ -584,10 +597,11 @@ class EstadoRadio extends ChangeNotifier {
_exportImport.importar(raw);
/// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa).
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1;
if (version > 2) throw Exception(_textos.unsupportedConfigVersion);
if (version > 3) throw Exception(_textos.unsupportedConfigVersion);
final prefs = await _resolverPrefs();
@@ -637,9 +651,41 @@ class EstadoRadio extends ChangeNotifier {
),
);
// v3 extensions: per-device and matrix presets.
Map<String, PresetEcualizador>? presetsDispositivo;
Map<String, PresetEcualizador>? presetsMatriz;
bool? eqMultiDeviceEnabled;
if (version >= 3) {
final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {};
presetsDispositivo = dispositivoRaw.map<String, PresetEcualizador>(
(deviceId, presetJson) => MapEntry(
deviceId as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
final matrizRaw = data['presetsMatriz'] as Map? ?? {};
presetsMatriz = matrizRaw.map<String, PresetEcualizador>(
(clave, presetJson) => MapEntry(
clave as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
}
await ecualizador.importarConfiguracion(
principal: presetPrincipal,
porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
);
// ── Alarmas (v2) ──────────────────────────────────────────────────────
@@ -748,6 +794,7 @@ class EstadoRadio extends ChangeNotifier {
grabacion.dispose();
audio.dispose();
timer.dispose();
_dispositivoAudio?.dispose();
super.dispose();
}
}
+12 -1
View File
@@ -616,5 +616,16 @@
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
"alarmIconLabel": "Musical alarm",
"vacationIconLabel": "Vacation mode",
"streamUrlHint": "https://stream.example.com:8000/radio"
"streamUrlHint": "https://stream.example.com:8000/radio",
"advancedEqSectionTitle": "Advanced Equalization Options",
"advancedEqEnableToggle": "Enable per-device EQ",
"advancedEqEnableToggleSubtitle": "Automatically apply a different EQ preset when your audio output changes.",
"advancedEqKnownDevicesTitle": "Known audio devices",
"advancedEqKnownDevicesEmpty": "No audio devices recorded yet. Connect a device to get started.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
}
+11
View File
@@ -579,5 +579,16 @@
"type": "int"
}
}
},
"advancedEqSectionTitle": "Opciones avanzadas de ecualización",
"advancedEqEnableToggle": "Activar EQ por dispositivo",
"advancedEqEnableToggleSubtitle": "Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.",
"advancedEqKnownDevicesTitle": "Dispositivos de audio conocidos",
"advancedEqKnownDevicesEmpty": "No hay dispositivos registrados. Conectá uno para empezar.",
"advancedEqDevicePresetLabel": "Preset: {presetName}",
"@advancedEqDevicePresetLabel": {
"placeholders": {
"presetName": {}
}
}
}
+36
View File
@@ -2269,6 +2269,42 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'https://stream.example.com:8000/radio'**
String get streamUrlHint;
/// No description provided for @advancedEqSectionTitle.
///
/// In es, this message translates to:
/// **'Opciones avanzadas de ecualización'**
String get advancedEqSectionTitle;
/// No description provided for @advancedEqEnableToggle.
///
/// In es, this message translates to:
/// **'Activar EQ por dispositivo'**
String get advancedEqEnableToggle;
/// No description provided for @advancedEqEnableToggleSubtitle.
///
/// In es, this message translates to:
/// **'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.'**
String get advancedEqEnableToggleSubtitle;
/// No description provided for @advancedEqKnownDevicesTitle.
///
/// In es, this message translates to:
/// **'Dispositivos de audio conocidos'**
String get advancedEqKnownDevicesTitle;
/// No description provided for @advancedEqKnownDevicesEmpty.
///
/// In es, this message translates to:
/// **'No hay dispositivos registrados. Conectá uno para empezar.'**
String get advancedEqKnownDevicesEmpty;
/// No description provided for @advancedEqDevicePresetLabel.
///
/// In es, this message translates to:
/// **'Preset: {presetName}'**
String advancedEqDevicePresetLabel(Object presetName);
}
class _AppLocalizationsDelegate
+22
View File
@@ -1225,4 +1225,26 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1232,4 +1232,26 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1242,4 +1242,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1228,4 +1228,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Advanced Equalization Options';
@override
String get advancedEqEnableToggle => 'Enable per-device EQ';
@override
String get advancedEqEnableToggleSubtitle =>
'Automatically apply a different EQ preset when your audio output changes.';
@override
String get advancedEqKnownDevicesTitle => 'Known audio devices';
@override
String get advancedEqKnownDevicesEmpty =>
'No audio devices recorded yet. Connect a device to get started.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1237,4 +1237,26 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1247,4 +1247,26 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1231,4 +1231,26 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1236,4 +1236,26 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1242,4 +1242,26 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1192,4 +1192,26 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1234,4 +1234,26 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1238,4 +1238,26 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+22
View File
@@ -1187,4 +1187,26 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get streamUrlHint => 'https://stream.example.com:8000/radio';
@override
String get advancedEqSectionTitle => 'Opciones avanzadas de ecualización';
@override
String get advancedEqEnableToggle => 'Activar EQ por dispositivo';
@override
String get advancedEqEnableToggleSubtitle =>
'Aplica automáticamente un preset de EQ distinto al cambiar la salida de audio.';
@override
String get advancedEqKnownDevicesTitle => 'Dispositivos de audio conocidos';
@override
String get advancedEqKnownDevicesEmpty =>
'No hay dispositivos registrados. Conectá uno para empezar.';
@override
String advancedEqDevicePresetLabel(Object presetName) {
return 'Preset: $presetName';
}
}
+47
View File
@@ -0,0 +1,47 @@
/// Audio device types detected via platform channel.
enum TipoDispositivo {
/// Built-in speaker — id: "builtin_speaker"
altavozInterno,
/// Wired headset or headphones — id: "wired_headset"
auricularesCable,
/// Bluetooth A2DP device — id: `"bt_a2dp:<MAC>"`
bluetoothA2dp,
/// USB audio device — id: `"usb_headset:<address>"`
usbAudio,
/// Unrecognized device type.
desconocido,
}
/// Represents an audio output device detected on the current platform.
///
/// Equality is based on [id] only so that two instances referring to the
/// same physical device compare as equal regardless of display name.
class DispositivoAudio {
final String id;
final TipoDispositivo tipo;
final String nombre;
const DispositivoAudio({
required this.id,
required this.tipo,
required this.nombre,
});
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! DispositivoAudio) return false;
return id == other.id;
}
@override
int get hashCode => id.hashCode;
@override
String toString() =>
'DispositivoAudio(id: $id, tipo: $tipo, nombre: $nombre)';
}
+122
View File
@@ -60,6 +60,8 @@ class _AjustesContent extends StatelessWidget {
children: const [
_SeccionEcualizador(),
SizedBox(height: 12),
_SeccionEcualizadorAvanzado(),
SizedBox(height: 12),
_SeccionGrabaciones(),
SizedBox(height: 12),
_SeccionTimerSueno(),
@@ -652,6 +654,126 @@ class _SeccionEcualizador extends StatelessWidget {
}
}
/// "Advanced Equalization Options" settings section (Phase 7, multi-device EQ).
///
/// Always shows the feature toggle so the user can discover it. When the
/// toggle is OFF, the device list is completely absent (not just invisible),
/// matching the spec scenario "Settings section is absent when toggle is off".
class _SeccionEcualizadorAvanzado extends StatelessWidget {
const _SeccionEcualizadorAvanzado();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final eq = context.watch<EstadoEcualizador>();
final multiDeviceEnabled = eq.eqMultiDeviceEnabled;
final presetsDispositivo = eq.presetsDispositivo;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.devices_rounded),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.advancedEqSectionTitle,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
// The toggle uses GestureDetector + custom row instead of
// SwitchListTile to avoid Material ink assertion inside
// PluriGlassSurface's DecoratedBox. The visual result is identical
// to SwitchListTile.
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap:
() => eq.cambiarMultiDeviceEnabled(!multiDeviceEnabled),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.advancedEqEnableToggle,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2),
Text(
l10n.advancedEqEnableToggleSubtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
Switch.adaptive(
value: multiDeviceEnabled,
onChanged:
(habilitado) => eq.cambiarMultiDeviceEnabled(habilitado),
),
],
),
),
if (multiDeviceEnabled) ...[
const SizedBox(height: 8),
Text(
l10n.advancedEqKnownDevicesTitle,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
if (presetsDispositivo.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
l10n.advancedEqKnownDevicesEmpty,
style: Theme.of(context).textTheme.bodySmall,
),
)
else
for (final entry in presetsDispositivo.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
const Icon(Icons.headphones_rounded, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
Text(
l10n.advancedEqDevicePresetLabel(
entry.value.nombre,
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
),
],
],
),
);
}
}
class _SeccionOrdenListas extends StatelessWidget {
const _SeccionOrdenListas();
@@ -0,0 +1,112 @@
import 'dart:async';
import 'package:flutter/services.dart';
import '../modelos/dispositivo_audio.dart';
/// Abstract service for audio device detection.
///
/// Implementations:
/// - [ServicioDispositivoAudioReal] — platform channel (Android/iOS)
/// - `FakeServicioDispositivoAudio` (test/helpers/fakes.dart) — for unit tests
abstract class ServicioDispositivoAudio {
/// The last known active audio output device, or null if none detected yet.
DispositivoAudio? get dispositivoActual;
/// A broadcast stream that emits whenever the active audio output device
/// changes (connect or disconnect event from the platform).
Stream<DispositivoAudio> get onDispositivoCambiado;
/// Requests the current active device from the platform synchronously
/// (method channel round-trip). Returns the cached value if already known.
Future<DispositivoAudio> obtenerDispositivoActual();
/// Cancels the device-change subscription and releases resources.
Future<void> dispose();
}
/// Platform channel implementation of [ServicioDispositivoAudio].
///
/// Talks to `pluriwave/audio_devices` on Android (Kotlin) and iOS (Swift).
/// Type int constants (from platform channel protocol):
/// 2 → altavozInterno (builtin_speaker)
/// 3 → auricularesCable (wired_headset)
/// 8 → bluetoothA2dp (`bt_a2dp:<MAC>`)
/// 14 → usbAudio (`usb_headset:<address>`)
class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
static const _channelName = 'pluriwave/audio_devices';
final MethodChannel _methodChannel;
final EventChannel _eventChannel;
final _controller = StreamController<DispositivoAudio>.broadcast();
StreamSubscription<dynamic>? _eventSub;
DispositivoAudio? _dispositivoActual;
ServicioDispositivoAudioReal({
MethodChannel? methodChannel,
EventChannel? eventChannel,
}) : _methodChannel = methodChannel ?? const MethodChannel(_channelName),
_eventChannel = eventChannel ?? const EventChannel(_channelName) {
_subscribeToEvents();
}
void _subscribeToEvents() {
_eventSub = _eventChannel.receiveBroadcastStream().listen(
(dynamic event) {
if (event is Map) {
final device = _mapToDispositivo(Map<String, dynamic>.from(event));
_dispositivoActual = device;
_controller.add(device);
}
},
onError: (_) {
/* swallow platform errors; stream continues */
},
cancelOnError: false,
);
}
@override
DispositivoAudio? get dispositivoActual => _dispositivoActual;
@override
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
@override
Future<DispositivoAudio> obtenerDispositivoActual() async {
final raw = await _methodChannel.invokeMethod<Map>('getActiveDevice');
final map = Map<String, dynamic>.from(raw ?? {});
final device = _mapToDispositivo(map);
_dispositivoActual = device;
return device;
}
@override
Future<void> dispose() async {
await _eventSub?.cancel();
await _controller.close();
}
static DispositivoAudio _mapToDispositivo(Map<String, dynamic> map) {
final id = map['id'] as String? ?? 'builtin_speaker';
final type = map['type'] as int? ?? 2;
final name = map['name'] as String? ?? '';
return DispositivoAudio(id: id, tipo: _tipoDesdeInt(type), nombre: name);
}
static TipoDispositivo _tipoDesdeInt(int type) {
switch (type) {
case 2:
return TipoDispositivo.altavozInterno;
case 3:
return TipoDispositivo.auricularesCable;
case 8:
return TipoDispositivo.bluetoothA2dp;
case 14:
return TipoDispositivo.usbAudio;
default:
return TipoDispositivo.desconocido;
}
}
}
+100
View File
@@ -9,11 +9,23 @@ class ConfiguracionEcualizador {
required this.principal,
required this.porEmisora,
this.activo = true,
this.eqMultiDeviceEnabled = false,
this.presetsDispositivo = const {},
this.presetsMatriz = const {},
});
final PresetEcualizador principal;
final Map<String, PresetEcualizador> porEmisora;
final bool activo;
/// Feature toggle: when false all multi-device logic is bypassed.
final bool eqMultiDeviceEnabled;
/// Per-device presets: deviceId → PresetEcualizador.
final Map<String, PresetEcualizador> presetsDispositivo;
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
final Map<String, PresetEcualizador> presetsMatriz;
}
class ServicioEcualizador {
@@ -22,6 +34,9 @@ class ServicioEcualizador {
static const _keyPresetPrincipal = 'eq_preset_principal_v1';
static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1';
static const _keyActivo = 'eq_activo_v1';
static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1';
static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1';
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
final SharedPreferences? _prefs;
@@ -33,10 +48,15 @@ class ServicioEcualizador {
final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs);
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
return ConfiguracionEcualizador(
principal: principal,
porEmisora: porEmisora,
activo: prefs.getBool(_keyActivo) ?? true,
eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
);
}
@@ -72,6 +92,86 @@ class ServicioEcualizador {
);
await _guardarPresetsPorEmisora(prefs, config.porEmisora);
await prefs.setBool(_keyActivo, config.activo);
await prefs.setBool(_keyMultiDeviceEnabled, config.eqMultiDeviceEnabled);
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
config.presetsDispositivo,
);
await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz);
}
/// Persists the multi-device feature toggle.
Future<void> guardarToggleMultiDispositivo(bool habilitado) async {
final prefs = await _resolverPrefs();
await prefs.setBool(_keyMultiDeviceEnabled, habilitado);
}
/// Saves a per-device preset.
Future<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo);
mapa[deviceId] = preset;
await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa);
}
/// Saves a matrix (stationUuid:deviceId) preset entry.
Future<void> guardarPresetMatriz(
String clave,
PresetEcualizador preset,
) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsMatriz);
mapa[clave] = preset;
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
}
/// Removes a per-device preset entry.
Future<void> eliminarPresetDispositivo(String deviceId) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo);
mapa.remove(deviceId);
await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa);
}
/// Removes a matrix preset entry.
Future<void> eliminarPresetMatriz(String clave) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsMatriz);
mapa.remove(clave);
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
}
/// Reads a `Map<String, PresetEcualizador>` from a SharedPreferences JSON key.
Map<String, PresetEcualizador> _leerMapa(
SharedPreferences prefs,
String key,
) {
final raw = prefs.getString(key);
if (raw == null || raw.isEmpty) return {};
try {
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return data.map(
(k, v) => MapEntry(
k,
PresetEcualizador.desdeJson(Map<String, dynamic>.from(v as Map)),
),
);
} catch (_) {
return {};
}
}
Future<void> _guardarMapa(
SharedPreferences prefs,
String key,
Map<String, PresetEcualizador> mapa,
) async {
final serializado = mapa.map((k, v) => MapEntry(k, v.toJson()));
await prefs.setString(key, jsonEncode(serializado));
}
PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) {
+44 -14
View File
@@ -6,21 +6,29 @@ import '../modelos/preset_ecualizador.dart';
/// Owns the backup (export/import) JSON serialization (S4-R4).
///
/// The v2 envelope produced here is byte-compatible with the legacy format
/// previously assembled inline by `EstadoRadio.exportarConfig` and
/// pretty-printed by `pantalla_ajustes.dart`, so existing exports keep
/// round-tripping. State APPLICATION (writing favorites, EQ, alarms back
/// into the app) stays in `EstadoRadio.importarConfig` — this service only
/// owns serialization, parsing and the envelope shape.
/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
/// export stays at v2 for backward compat with the old app. State APPLICATION
/// (writing favorites, EQ, alarms back into the app) stays in
/// `EstadoRadio.importarConfig` — this service only owns serialization,
/// parsing and the envelope shape.
class ServicioExportImport {
const ServicioExportImport();
/// Current backup schema version (v2full portability).
static const int versionActual = 2;
/// Current backup schema version (v3multi-device EQ).
static const int versionActual = 3;
/// Builds the v2 export envelope. Key set and semantics must stay exactly
/// as the legacy inline export so old backups remain importable:
/// the `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// Legacy v2 version constant kept for clarity.
static const int versionV2 = 2;
/// Builds the export envelope.
///
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null),
/// [versionActual] (3) is written. When both are omitted the call behaves
/// identically to the original v2 format (version key stays 2) so old
/// backups keep round-tripping without version bumps.
///
/// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
/// and passes through untouched (no re-parsing here).
Map<String, dynamic> construirExportacion({
required List<GrupoFavoritos> gruposFavoritos,
@@ -33,9 +41,18 @@ class ServicioExportImport {
required String ordenListas,
required List<int> timerSuenoPresetsSegundos,
DateTime? exportadoEn,
// v3 extensions — omitting these produces a v2-compatible export.
Map<String, PresetEcualizador>? presetsPorDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
}) {
return {
'version': versionActual,
final tieneExtensionesV3 =
presetsPorDispositivo != null ||
presetsMatriz != null ||
eqMultiDeviceEnabled != null;
final envelope = <String, dynamic>{
'version': tieneExtensionesV3 ? versionActual : versionV2,
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
// Favorites + groups (preserves grupo_id assignments per station).
// The protected "sin asignar" group is implicit and never exported.
@@ -47,7 +64,7 @@ class ServicioExportImport {
'favoritos': favoritos.map((e) => e.toMap()).toList(),
// Custom stations.
'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(),
// Equalizer.
// Equalizer (base fields — present in all versions).
'presetPrincipalEcualizador': presetPrincipal.toJson(),
'presetsEcualizador': presetsPorEmisora.map(
(uuid, preset) => MapEntry(uuid, preset.toJson()),
@@ -59,6 +76,19 @@ class ServicioExportImport {
'ordenListas': ordenListas,
'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos,
};
// v3 extensions: only written when explicitly provided.
if (tieneExtensionesV3) {
envelope['presetsPorDispositivo'] = (presetsPorDispositivo ?? {}).map(
(deviceId, preset) => MapEntry(deviceId, preset.toJson()),
);
envelope['presetsMatriz'] = (presetsMatriz ?? {}).map(
(clave, preset) => MapEntry(clave, preset.toJson()),
);
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
}
return envelope;
}
/// Serializes an export envelope to pretty-printed JSON (same formatting