From 921e972183fc8a60604e9969afb978ef20a1ea54 Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 21 May 2026 21:56:25 +0200 Subject: [PATCH] fix(player): stabilize equalizer and visualizer --- lib/estado/estado_radio.dart | 16 + lib/pantallas/pantalla_ajustes.dart | 21 +- lib/pantallas/pantalla_reproductor.dart | 15 + lib/servicios/servicio_audio.dart | 32 +- lib/servicios/servicio_ecualizador.dart | 14 +- lib/widgets/visualizador_audio.dart | 67 +++- test/estado/estado_radio_test.dart | 430 +++++++++++++----------- test/helpers/fakes.dart | 60 +++- 8 files changed, 427 insertions(+), 228 deletions(-) diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index f46e9e9..68cbc2c 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -69,6 +69,7 @@ class EstadoRadio extends ChangeNotifier { final Map _presetsEmisoraMap = {}; PresetEcualizador _presetPrincipal = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat; + bool _ecualizadorActivo = true; bool _cargandoPopulares = false; bool _cargandoBusqueda = false; @@ -102,6 +103,7 @@ class EstadoRadio extends ChangeNotifier { Stream get estadoStream => audio.estadoStream; PresetEcualizador get presetEcualizador => _presetActual; PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal; + bool get ecualizadorActivo => _ecualizadorActivo; bool get ecualizadorDisponible => audio.ecualizadorDisponible; bool get emisoraActualEsFavorita { @@ -172,13 +174,16 @@ class EstadoRadio extends ChangeNotifier { final config = await servicioEcualizador.cargar(); _presetPrincipal = config.principal; _presetActual = config.principal; + _ecualizadorActivo = config.activo; _presetsEmisoraMap ..clear() ..addAll(config.porEmisora); + await audio.setEcualizadorActivo(_ecualizadorActivo); await audio.aplicarPreset(_presetPrincipal); } catch (_) { _presetPrincipal = PresetEcualizador.flat; _presetActual = PresetEcualizador.flat; + _ecualizadorActivo = true; _presetsEmisoraMap.clear(); } } @@ -483,6 +488,16 @@ class EstadoRadio extends ChangeNotifier { } } + Future cambiarEcualizadorActivo(bool activo) async { + _ecualizadorActivo = activo; + await servicioEcualizador.guardarActivo(activo); + await audio.setEcualizadorActivo(activo); + if (activo) { + await audio.aplicarPreset(_presetActual); + } + notifyListeners(); + } + Future cambiarPresetEcualizador( PresetEcualizador preset, { bool guardarPorEmisora = true, @@ -632,6 +647,7 @@ class EstadoRadio extends ChangeNotifier { ConfiguracionEcualizador( principal: _presetPrincipal, porEmisora: _presetsEmisoraMap, + activo: _ecualizadorActivo, ), ); diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 77397eb..9c9bb6f 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -181,13 +181,26 @@ class _SeccionEcualizador extends StatelessWidget { style: Theme.of(ctx).textTheme.titleMedium, ), const Spacer(), - if (!disponible) - const Chip( - label: Text('Se guarda aunque no esté activo'), - visualDensity: VisualDensity.compact, + Chip( + label: Text( + estado.ecualizadorActivo ? 'Activo' : 'Desactivado', ), + visualDensity: VisualDensity.compact, + ), ], ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Activar ecualizador'), + subtitle: Text( + disponible + ? 'Los cambios se aplican en tiempo real a la emisora actual.' + : 'Se guardan los cambios y se aplicarán cuando Android habilite el efecto.', + ), + value: estado.ecualizadorActivo, + onChanged: estado.cambiarEcualizadorActivo, + ), if (mostrarModoPorEmisora) ...[ const SizedBox(height: 8), SwitchListTile.adaptive( diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 682779b..76f0e37 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -90,6 +90,21 @@ class _PantallaReproductorState extends State onPressed: () => Navigator.pop(context), ), actions: [ + IconButton( + icon: Icon( + estado.ecualizadorActivo + ? Icons.equalizer_rounded + : Icons.equalizer_outlined, + color: estado.ecualizadorActivo ? tokens.warmCoral : null, + ), + tooltip: + estado.ecualizadorActivo + ? 'Desactivar ecualizador' + : 'Activar ecualizador', + onPressed: + () => + estado.cambiarEcualizadorActivo(!estado.ecualizadorActivo), + ), IconButton( icon: Icon( esFavorito diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index f4eb58e..405a8ad 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -77,8 +77,11 @@ class ServicioAudio { Future setVolumen(double vol) => _handler.setVolumen(vol); double get volumen => _handler.volumen; bool get estaSonando => _handler.playbackState.value.playing; - Stream get androidAudioSessionIdStream => - _handler.androidAudioSessionIdStream; + Stream get androidAudioSessionIdStream async* { + yield _handler.androidAudioSessionId; + yield* _handler.androidAudioSessionIdStream; + } + Future dispose() async {} // ── Ecualizador ─────────────────────────────────────────────────────────── @@ -88,6 +91,8 @@ class ServicioAudio { Future aplicarPreset(PresetEcualizador preset) => _handler.aplicarPreset(preset); + Future setEcualizadorActivo(bool activo) => + _handler.setEcualizadorActivo(activo); Future setBanda(int index, double db) => _handler.setBanda(index, db); } @@ -106,6 +111,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { StreamSubscription? _eventosSub; StreamSubscription? _androidAudioSessionIdSub; final _androidAudioSessionIdController = StreamController.broadcast(); + int? _androidAudioSessionId; Future _colaCambioFuente = Future.value(); int _revisionFuente = 0; @@ -116,9 +122,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { AndroidEqualizer? get ecualizador => _eq; bool _eqDisponible = false; bool get ecualizadorDisponible => _eqDisponible; + bool _ecualizadorActivo = true; + bool get ecualizadorActivo => _ecualizadorActivo; PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; + int? get androidAudioSessionId => _androidAudioSessionId; Stream get androidAudioSessionIdStream => _androidAudioSessionIdController.stream; @@ -166,6 +175,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { _androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen(( sessionId, ) { + _androidAudioSessionId = sessionId; if (!_androidAudioSessionIdController.isClosed) { _androidAudioSessionIdController.add(sessionId); } @@ -316,6 +326,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { _eq = AndroidEqualizer(); _eqDisponible = false; + _androidAudioSessionId = null; _player = _crearPlayer(); await _player.setVolume(_volumen); _conectarStreamsPlayer(); @@ -342,7 +353,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { final params = await _eq.parameters; _eqDisponible = params.bands.isNotEmpty; if (_eqDisponible) { - await _eq.setEnabled(true); + await _eq.setEnabled(_ecualizadorActivo); await aplicarPreset(_presetActual); } } catch (_) { @@ -355,6 +366,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { _presetActual = preset; if (!_eqDisponible) return; try { + await _eq.setEnabled(_ecualizadorActivo); + if (!_ecualizadorActivo) return; final params = await _eq.parameters; for ( int i = 0; @@ -368,12 +381,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { /// Ajusta una banda individual. Future setBanda(int index, double db) async { - if (!_eqDisponible) return; final bandas = List.from(_presetActual.bandas); if (index >= 0 && index < bandas.length) { bandas[index] = db; _presetActual = _presetActual.copyWithBandas(bandas); } + if (!_eqDisponible || !_ecualizadorActivo) return; try { final params = await _eq.parameters; if (index < params.bands.length) { @@ -382,6 +395,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { } catch (_) {} } + Future setEcualizadorActivo(bool activo) async { + _ecualizadorActivo = activo; + if (!_eqDisponible) return; + try { + await _eq.setEnabled(activo); + if (activo) { + await aplicarPreset(_presetActual); + } + } catch (_) {} + } + Future setVolumen(double vol) async { _volumen = vol.clamp(0.0, 1.0); await _player.setVolume(_volumen); diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index cd93bfa..f272d35 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -8,15 +8,18 @@ class ConfiguracionEcualizador { const ConfiguracionEcualizador({ required this.principal, required this.porEmisora, + this.activo = true, }); final PresetEcualizador principal; final Map porEmisora; + final bool activo; } class ServicioEcualizador { static const _keyPresetPrincipal = 'eq_preset_principal_v1'; static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1'; + static const _keyActivo = 'eq_activo_v1'; Future cargar() async { final prefs = await SharedPreferences.getInstance(); @@ -25,6 +28,7 @@ class ServicioEcualizador { return ConfiguracionEcualizador( principal: principal, porEmisora: porEmisora, + activo: prefs.getBool(_keyActivo) ?? true, ); } @@ -40,6 +44,11 @@ class ServicioEcualizador { await _guardarPresetsPorEmisora(prefs, mapa); } + Future guardarActivo(bool activo) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyActivo, activo); + } + Future eliminarPorEmisora(String uuid) async { final prefs = await SharedPreferences.getInstance(); final mapa = _leerPresetsPorEmisora(prefs); @@ -54,6 +63,7 @@ class ServicioEcualizador { jsonEncode(config.principal.toJson()), ); await _guardarPresetsPorEmisora(prefs, config.porEmisora); + await prefs.setBool(_keyActivo, config.activo); } PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) { @@ -82,9 +92,7 @@ class ServicioEcualizador { return data.map( (uuid, preset) => MapEntry( uuid, - PresetEcualizador.desdeJson( - Map.from(preset as Map), - ), + PresetEcualizador.desdeJson(Map.from(preset as Map)), ), ); } catch (_) { diff --git a/lib/widgets/visualizador_audio.dart b/lib/widgets/visualizador_audio.dart index f3b3943..d1888dd 100644 --- a/lib/widgets/visualizador_audio.dart +++ b/lib/widgets/visualizador_audio.dart @@ -40,7 +40,9 @@ class _VisualizadorAudioState extends State late final AnimationController _controller; bool _activo = false; int? _sessionId; - List _ondaReal = const []; + List _ondaObjetivo = const []; + List _ondaVisual = const []; + DateTime? _ultimaOndaReal; StreamSubscription? _estadoSubscription; StreamSubscription? _sessionSubscription; StreamSubscription? _ondaSubscription; @@ -52,7 +54,10 @@ class _VisualizadorAudioState extends State vsync: this, duration: const Duration(seconds: 2), )..addListener(() { - if (mounted) setState(() {}); + if (mounted) { + _actualizarOndaVisual(); + setState(() {}); + } }); _estadoSubscription = widget.estadoStream.listen(_onEstado); _sessionSubscription = widget.androidAudioSessionIdStream?.listen( @@ -87,9 +92,7 @@ class _VisualizadorAudioState extends State if (!puedeCapturar) { unawaited(_ondaSubscription?.cancel()); _ondaSubscription = null; - if (_ondaReal.isNotEmpty && mounted) { - setState(() => _ondaReal = const []); - } + _ultimaOndaReal = null; return; } @@ -107,18 +110,66 @@ class _VisualizadorAudioState extends State .map((v) => v.toDouble().clamp(0.0, 1.0)) .toList(growable: false); if (muestras.isNotEmpty) { - setState(() => _ondaReal = muestras); + _ultimaOndaReal = DateTime.now(); + _ondaObjetivo = _normalizar(muestras); } }, onError: (_) { unawaited(_ondaSubscription?.cancel()); _ondaSubscription = null; - if (mounted) setState(() => _ondaReal = const []); + _ultimaOndaReal = null; }, cancelOnError: false, ); } + void _actualizarOndaVisual() { + final objetivo = _objetivoActual(); + if (_ondaVisual.length != objetivo.length) { + _ondaVisual = List.from(objetivo); + return; + } + _ondaVisual = List.generate(objetivo.length, (i) { + final suavizado = _ondaVisual[i] + (objetivo[i] - _ondaVisual[i]) * 0.16; + return suavizado.clamp(0.0, 1.0); + }, growable: false); + } + + List _objetivoActual() { + final ahora = DateTime.now(); + final tieneReal = + _ultimaOndaReal != null && + ahora.difference(_ultimaOndaReal!) < + const Duration(milliseconds: 900) && + _ondaObjetivo.isNotEmpty; + if (tieneReal) return _ondaObjetivo; + return _ondaOrganica(); + } + + List _ondaOrganica() { + final count = widget.barras.clamp(8, 96); + final phase = _controller.value * pi * 2; + final intensidad = _activo ? 1.0 : 0.18; + return List.generate(count, (i) { + final p = count <= 1 ? 0.0 : i / (count - 1); + final envelope = sin(pi * p).clamp(0.10, 1.0); + final flow = + sin(phase + p * pi * 2.2) * 0.24 + + sin(phase * 0.63 - p * pi * 5.1) * 0.18 + + sin(phase * 1.37 + p * pi * 9.0) * 0.08; + final value = 0.5 + flow * envelope * intensidad; + return value.clamp(0.12, 0.88); + }, growable: false); + } + + List _normalizar(List muestras) { + final maximo = muestras.fold(0, (max, v) => v > max ? v : max); + if (maximo <= 0.001) return muestras; + return muestras + .map((v) => (0.08 + (v / maximo) * 0.84).clamp(0.0, 1.0)) + .toList(growable: false); + } + @override void dispose() { _estadoSubscription?.cancel(); @@ -141,7 +192,7 @@ class _VisualizadorAudioState extends State color: color, phase: t, active: _activo, - waveform: _ondaReal, + waveform: _ondaVisual, ), child: const SizedBox.expand(), ), diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 827f545..ad81113 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -35,223 +35,269 @@ void main() { ); }); - test('carga EQ principal persistido antes de decidir EQ de reproducción', - () async { - final audio = FakeServicioAudio(); - final principal = PresetEcualizador.rock; - final emisora = emisoraDemo(uuid: 'api-1', nombre: 'API Uno'); - final estado = EstadoRadio( - audio: audio, - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(populares: [emisora]), - servicioEcualizador: FakeServicioEcualizador(principal: principal), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); + test( + 'carga EQ principal persistido antes de decidir EQ de reproducción', + () async { + final audio = FakeServicioAudio(); + final principal = PresetEcualizador.rock; + final emisora = emisoraDemo(uuid: 'api-1', nombre: 'API Uno'); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(principal: principal), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); - await estado.inicializar(); - await estado.reproducir(emisora); + await estado.inicializar(); + await estado.reproducir(emisora); - expect(estado.presetEcualizador, principal); - expect(audio.presetsAplicados.first, principal); - expect(audio.presetsAplicados.last, principal); - }); - - test('mantiene EQ persistido aunque el ecualizador nativo no esté disponible', - () async { - final principal = PresetEcualizador.jazz; - final porEmisora = {'fav-1': PresetEcualizador.rock}; - final estado = EstadoRadio( - audio: FakeServicioAudio(ecualizadorActivo: false), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador( - principal: principal, - porEmisora: porEmisora, - ), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); - - await estado.inicializar(); - - expect(estado.ecualizadorDisponible, isFalse); - expect(estado.presetEcualizador, principal); - expect(estado.presetPrincipalEcualizador, principal); - expect( - estado.presetEcualizadorPorEmisora('fav-1'), - PresetEcualizador.rock, - ); - }); + expect(estado.presetEcualizador, principal); + expect(audio.presetsAplicados.first, principal); + expect(audio.presetsAplicados.last, principal); + }, + ); test( - 'inicializar deja error tras fallo y cargarPopulares manual recupera estaciones', - () async { - final radio = FakeServicioRadio( - erroresPopularesPorLlamada: [Exception('sin red')], - popularesPorLlamada: [ - const [], - [emisoraDemo(uuid: 'api-ok', nombre: 'API Recuperada')], - ], - tendenciasPorLlamada: [ - const [], - [emisoraDemo(uuid: 'trend-ok', nombre: 'Trend Recuperada')], - ], - ); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: radio, - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); + 'mantiene EQ persistido aunque el ecualizador nativo no esté disponible', + () async { + final principal = PresetEcualizador.jazz; + final porEmisora = {'fav-1': PresetEcualizador.rock}; + final estado = EstadoRadio( + audio: FakeServicioAudio(ecualizadorActivo: false), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador( + principal: principal, + porEmisora: porEmisora, + ), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); - await estado.inicializar(); - expect(estado.error, 'Sin conexión a la API de radio'); + await estado.inicializar(); - await estado.cargarPopulares(); + expect(estado.ecualizadorDisponible, isFalse); + expect(estado.presetEcualizador, principal); + expect(estado.presetPrincipalEcualizador, principal); + expect( + estado.presetEcualizadorPorEmisora('fav-1'), + PresetEcualizador.rock, + ); + }, + ); - expect(estado.error, isNull); - expect(estado.populares.map((e) => e.uuid), contains('api-ok')); - expect(estado.tendencias.map((e) => e.uuid), contains('trend-ok')); - expect(radio.obtenerPopularesCalls, 2); - }); + test( + 'inicializar deja error tras fallo y cargarPopulares manual recupera estaciones', + () async { + final radio = FakeServicioRadio( + erroresPopularesPorLlamada: [Exception('sin red')], + popularesPorLlamada: [ + const [], + [emisoraDemo(uuid: 'api-ok', nombre: 'API Recuperada')], + ], + tendenciasPorLlamada: [ + const [], + [emisoraDemo(uuid: 'trend-ok', nombre: 'Trend Recuperada')], + ], + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: radio, + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); - test('EQ propio por emisora pisa al principal y puede volver a fallback', - () async { - final audio = FakeServicioAudio(); - final favoritos = FakeServicioFavoritos(); - final emisora = emisoraDemo(uuid: 'fav-1', nombre: 'Favorita'); - final principal = PresetEcualizador.pop; - final propio = PresetEcualizador.jazz; - await favoritos.agregar(emisora); + await estado.inicializar(); + expect(estado.error, 'Sin conexión a la API de radio'); - final estado = EstadoRadio( - audio: audio, - favoritos: favoritos, - radio: FakeServicioRadio(populares: [emisora]), - servicioEcualizador: FakeServicioEcualizador(principal: principal), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); + await estado.cargarPopulares(); - await estado.inicializar(); - await estado.cargarFavoritos(); - await estado.guardarPresetEcualizadorPorEmisora(emisora.uuid, propio); + expect(estado.error, isNull); + expect(estado.populares.map((e) => e.uuid), contains('api-ok')); + expect(estado.tendencias.map((e) => e.uuid), contains('trend-ok')); + expect(radio.obtenerPopularesCalls, 2); + }, + ); - await estado.reproducir(emisora); - expect(estado.presetEcualizador, propio); - expect(audio.presetsAplicados.last, propio); + test( + 'EQ propio por emisora pisa al principal y puede volver a fallback', + () async { + final audio = FakeServicioAudio(); + final favoritos = FakeServicioFavoritos(); + final emisora = emisoraDemo(uuid: 'fav-1', nombre: 'Favorita'); + final principal = PresetEcualizador.pop; + final propio = PresetEcualizador.jazz; + await favoritos.agregar(emisora); - await estado.deshabilitarPresetEcualizadorPorEmisora(emisora.uuid); - await estado.reproducir(emisora); - expect(estado.presetEcualizador, principal); - expect(audio.presetsAplicados.last, principal); - }); + final estado = EstadoRadio( + audio: audio, + favoritos: favoritos, + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(principal: principal), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); - test('favorita sin EQ propio usa EQ principal desde el primer play', () async { - final audio = FakeServicioAudio(); - final favoritos = FakeServicioFavoritos(); - final emisora = emisoraDemo(uuid: 'fav-main', nombre: 'Favorita Main'); - final principal = PresetEcualizador.voz; - await favoritos.agregar(emisora); + await estado.inicializar(); + await estado.cargarFavoritos(); + await estado.guardarPresetEcualizadorPorEmisora(emisora.uuid, propio); - final estado = EstadoRadio( - audio: audio, - favoritos: favoritos, - radio: FakeServicioRadio(populares: [emisora]), - servicioEcualizador: FakeServicioEcualizador(principal: principal), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); + await estado.reproducir(emisora); + expect(estado.presetEcualizador, propio); + expect(audio.presetsAplicados.last, propio); - await estado.inicializar(); - await estado.cargarFavoritos(); - await estado.reproducir(emisora); + await estado.deshabilitarPresetEcualizadorPorEmisora(emisora.uuid); + await estado.reproducir(emisora); + expect(estado.presetEcualizador, principal); + expect(audio.presetsAplicados.last, principal); + }, + ); - expect(estado.tienePresetEcualizadorPorEmisora(emisora.uuid), isFalse); - expect(estado.presetEcualizador, principal); - expect(audio.presetsAplicados.last, principal); - }); + test( + 'favorita sin EQ propio usa EQ principal desde el primer play', + () async { + final audio = FakeServicioAudio(); + final favoritos = FakeServicioFavoritos(); + final emisora = emisoraDemo(uuid: 'fav-main', nombre: 'Favorita Main'); + final principal = PresetEcualizador.voz; + await favoritos.agregar(emisora); + final estado = EstadoRadio( + audio: audio, + favoritos: favoritos, + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(principal: principal), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + await estado.cargarFavoritos(); + await estado.reproducir(emisora); - test('notifica cambios de estado de audio para mostrar reproductor al primer play', () async { - final audio = FakeServicioAudio(); - final emisora = emisoraDemo(uuid: 'play-1', nombre: 'Primera'); - final estado = EstadoRadio( - audio: audio, - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(populares: [emisora]), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); - var notificaciones = 0; - estado.addListener(() => notificaciones++); + expect(estado.tienePresetEcualizadorPorEmisora(emisora.uuid), isFalse); + expect(estado.presetEcualizador, principal); + expect(audio.presetsAplicados.last, principal); + }, + ); - await estado.inicializar(); - final antes = notificaciones; - audio.emitirEstado(EstadoReproduccion.cargando); - await Future.delayed(Duration.zero); + test( + 'permite activar y desactivar el ecualizador de forma persistente', + () async { + final audio = FakeServicioAudio(); + final servicioEcualizador = FakeServicioEcualizador(); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: servicioEcualizador, + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); - expect(notificaciones, greaterThan(antes)); - }); + await estado.inicializar(); + expect(estado.ecualizadorActivo, isTrue); - test('reproducir la misma emisora mientras suena fuerza recarga del stream', () async { - final audio = FakeServicioAudio(); - final emisora = emisoraDemo(uuid: 'same-1', nombre: 'Misma'); - final estado = EstadoRadio( - audio: audio, - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(populares: [emisora]), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); + await estado.cambiarEcualizadorActivo(false); + expect(estado.ecualizadorActivo, isFalse); + expect(servicioEcualizador.config.activo, isFalse); + expect(audio.cambiosEcualizadorActivo.last, isFalse); - await estado.inicializar(); - await estado.reproducir(emisora); - await estado.reproducir(emisora); + await estado.cambiarEcualizadorActivo(true); + expect(estado.ecualizadorActivo, isTrue); + expect(servicioEcualizador.config.activo, isTrue); + expect(audio.cambiosEcualizadorActivo.last, isTrue); + }, + ); - expect(audio.emisorasReproducidas, hasLength(2)); - }); + test( + 'notifica cambios de estado de audio para mostrar reproductor al primer play', + () async { + final audio = FakeServicioAudio(); + final emisora = emisoraDemo(uuid: 'play-1', nombre: 'Primera'); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + var notificaciones = 0; + estado.addListener(() => notificaciones++); - test('ignora finalizaciones stale cuando se cambia de emisora rapido', () async { - final audio = _AudioControlado(); - final radio = FakeServicioRadio(); - final primera = emisoraDemo(uuid: 'slow-1', nombre: 'Lenta'); - final segunda = emisoraDemo(uuid: 'fast-2', nombre: 'Rapida'); - final estado = EstadoRadio( - audio: audio, - favoritos: FakeServicioFavoritos(), - radio: radio, - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); + await estado.inicializar(); + final antes = notificaciones; + audio.emitirEstado(EstadoReproduccion.cargando); + await Future.delayed(Duration.zero); - await estado.inicializar(); - await estado.guardarPresetEcualizadorPorEmisora( - primera.uuid, - PresetEcualizador.rock, - ); - await estado.guardarPresetEcualizadorPorEmisora( - segunda.uuid, - PresetEcualizador.jazz, - ); + expect(notificaciones, greaterThan(antes)); + }, + ); - final primeraFuture = estado.reproducir(primera); - final segundaFuture = estado.reproducir(segunda); - audio.completar(segunda.uuid); - await segundaFuture; - audio.completar(primera.uuid); - await primeraFuture; + test( + 'reproducir la misma emisora mientras suena fuerza recarga del stream', + () async { + final audio = FakeServicioAudio(); + final emisora = emisoraDemo(uuid: 'same-1', nombre: 'Misma'); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(populares: [emisora]), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); - expect(estado.presetEcualizador, PresetEcualizador.jazz); - expect(radio.ultimoUuidClick, segunda.uuid); - }); + await estado.inicializar(); + await estado.reproducir(emisora); + await estado.reproducir(emisora); + + expect(audio.emisorasReproducidas, hasLength(2)); + }, + ); + + test( + 'ignora finalizaciones stale cuando se cambia de emisora rapido', + () async { + final audio = _AudioControlado(); + final radio = FakeServicioRadio(); + final primera = emisoraDemo(uuid: 'slow-1', nombre: 'Lenta'); + final segunda = emisoraDemo(uuid: 'fast-2', nombre: 'Rapida'); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: radio, + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + await estado.guardarPresetEcualizadorPorEmisora( + primera.uuid, + PresetEcualizador.rock, + ); + await estado.guardarPresetEcualizadorPorEmisora( + segunda.uuid, + PresetEcualizador.jazz, + ); + + final primeraFuture = estado.reproducir(primera); + final segundaFuture = estado.reproducir(segunda); + audio.completar(segunda.uuid); + await segundaFuture; + audio.completar(primera.uuid); + await primeraFuture; + + expect(estado.presetEcualizador, PresetEcualizador.jazz); + expect(radio.ultimoUuidClick, segunda.uuid); + }, + ); test('reordenar favoritos reindexa de forma determinística', () async { final favoritos = FakeServicioFavoritos(); @@ -266,8 +312,6 @@ void main() { expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2])); }); - - test('cargarMasBusqueda pagina resultados y acota memoria', () async { final emisoras = List.generate( 70, @@ -358,6 +402,8 @@ Future _archivoCustomVacio() async => _crearArchivoCustom(const []); Future _crearArchivoCustom(List emisoras) async { final dir = await Directory.systemTemp.createTemp('pluriwave-test-'); final archivo = File('${dir.path}/emisoras_custom.json'); - await archivo.writeAsString(jsonEncode(emisoras.map((e) => e.toMap()).toList())); + await archivo.writeAsString( + jsonEncode(emisoras.map((e) => e.toMap()).toList()), + ); return archivo; } diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index bee3248..39260e3 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -16,6 +16,7 @@ class FakeServicioAudio extends ServicioAudio { final _estadoController = StreamController.broadcast(); final List presetsAplicados = []; final List emisorasReproducidas = []; + final List cambiosEcualizadorActivo = []; Emisora? _emisoraActual; EstadoReproduccion _estadoActual = EstadoReproduccion.detenido; @@ -57,6 +58,11 @@ class FakeServicioAudio extends ServicioAudio { @override Future setBanda(int index, double db) async {} + @override + Future setEcualizadorActivo(bool activo) async { + cambiosEcualizadorActivo.add(activo); + } + @override Future dispose() async { await _estadoController.close(); @@ -122,13 +128,13 @@ class FakeServicioRadio extends ServicioRadio { List>? tendenciasPorLlamada, List? erroresPopularesPorLlamada, List? erroresTendenciasPorLlamada, - }) : _populares = populares ?? [], - _tendencias = tendencias ?? [], - _busqueda = busqueda ?? [], - _popularesPorLlamada = popularesPorLlamada ?? const [], - _tendenciasPorLlamada = tendenciasPorLlamada ?? const [], - _erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [], - _erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const []; + }) : _populares = populares ?? [], + _tendencias = tendencias ?? [], + _busqueda = busqueda ?? [], + _popularesPorLlamada = popularesPorLlamada ?? const [], + _tendenciasPorLlamada = tendenciasPorLlamada ?? const [], + _erroresPopularesPorLlamada = erroresPopularesPorLlamada ?? const [], + _erroresTendenciasPorLlamada = erroresTendenciasPorLlamada ?? const []; final List _populares; final List _tendencias; @@ -147,14 +153,18 @@ class FakeServicioRadio extends ServicioRadio { error is Exception ? error : Exception(error.toString()); @override - Future> obtenerPopulares({int limit = 30, int offset = 0}) async { + Future> obtenerPopulares({ + int limit = 30, + int offset = 0, + }) async { final llamada = obtenerPopularesCalls++; if (llamada < _erroresPopularesPorLlamada.length) { throw _normalizarError(_erroresPopularesPorLlamada[llamada]); } - final data = llamada < _popularesPorLlamada.length - ? _popularesPorLlamada[llamada] - : _populares; + final data = + llamada < _popularesPorLlamada.length + ? _popularesPorLlamada[llamada] + : _populares; return data.take(limit).toList(); } @@ -164,9 +174,10 @@ class FakeServicioRadio extends ServicioRadio { if (llamada < _erroresTendenciasPorLlamada.length) { throw _normalizarError(_erroresTendenciasPorLlamada[llamada]); } - final data = llamada < _tendenciasPorLlamada.length - ? _tendenciasPorLlamada[llamada] - : _tendencias; + final data = + llamada < _tendenciasPorLlamada.length + ? _tendenciasPorLlamada[llamada] + : _tendencias; return data.take(limit).toList(); } @@ -193,12 +204,15 @@ class FakeServicioEcualizador extends ServicioEcualizador { FakeServicioEcualizador({ PresetEcualizador? principal, Map? porEmisora, + bool activo = true, }) : _config = ConfiguracionEcualizador( - principal: principal ?? PresetEcualizador.flat, - porEmisora: porEmisora ?? {}, - ); + principal: principal ?? PresetEcualizador.flat, + porEmisora: porEmisora ?? {}, + activo: activo, + ); ConfiguracionEcualizador _config; + ConfiguracionEcualizador get config => _config; @override Future cargar() async => _config; @@ -208,6 +222,16 @@ class FakeServicioEcualizador extends ServicioEcualizador { _config = ConfiguracionEcualizador( principal: preset, porEmisora: _config.porEmisora, + activo: _config.activo, + ); + } + + @override + Future guardarActivo(bool activo) async { + _config = ConfiguracionEcualizador( + principal: _config.principal, + porEmisora: _config.porEmisora, + activo: activo, ); } @@ -218,6 +242,7 @@ class FakeServicioEcualizador extends ServicioEcualizador { _config = ConfiguracionEcualizador( principal: _config.principal, porEmisora: mapa, + activo: _config.activo, ); } @@ -228,6 +253,7 @@ class FakeServicioEcualizador extends ServicioEcualizador { _config = ConfiguracionEcualizador( principal: _config.principal, porEmisora: mapa, + activo: _config.activo, ); } }