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:
+8
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (v2 — full portability).
|
||||
static const int versionActual = 2;
|
||||
/// Current backup schema version (v3 — multi-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
|
||||
|
||||
Reference in New Issue
Block a user