From 10520fef4831daabb5abac0adde9d8cd64829173 Mon Sep 17 00:00:00 2001 From: freetlab Date: Wed, 20 May 2026 23:44:14 +0200 Subject: [PATCH] fix(ui): unify scroll and improve playback switching --- lib/pantallas/pantalla_buscar.dart | 34 ++++-- lib/pantallas/pantalla_favoritos.dart | 32 ++--- lib/pantallas/pantalla_inicio.dart | 59 --------- lib/servicios/servicio_audio.dart | 38 +++--- lib/widgets/visualizador_audio.dart | 166 ++++++++++++-------------- 5 files changed, 138 insertions(+), 191 deletions(-) diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index 7a8220a..e061763 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -67,7 +67,8 @@ class _PantallaBuscarState extends State { final estado = context.watch(); final theme = Theme.of(context); - return Column( + return ListView( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 124), children: [ PluriScreenHeader( title: 'Buscar senal', @@ -125,7 +126,7 @@ class _PantallaBuscarState extends State { _buscar(); }, ), - Expanded(child: _resultados(estado, theme)), + _resultados(estado, theme), ], ); } @@ -144,7 +145,7 @@ class _PantallaBuscarState extends State { children: [ Expanded( child: Text( - pais == null ? 'Emisoras cercanas' : 'Emisoras cercanas ? $pais', + pais == null ? 'Emisoras cercanas' : 'Emisoras cercanas - $pais', style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w900, ), @@ -247,7 +248,10 @@ class _PantallaBuscarState extends State { Widget _resultados(EstadoRadio estado, ThemeData theme) { if (estado.cargandoBusqueda) { - return const Center(child: CircularProgressIndicator()); + return const SizedBox( + height: 220, + child: Center(child: CircularProgressIndicator()), + ); } final resultados = estado.resultadosBusqueda; @@ -257,18 +261,24 @@ class _PantallaBuscarState extends State { _controller.text.isEmpty && _paisSeleccionado == null && _idiomaSeleccionado == null; - return PluriEmptyState( - glyph: PluriIconGlyph.search, - title: sinFiltros ? 'Busca una emisora' : 'Sin resultados', - subtitle: sinFiltros - ? 'Usa la barra superior o los chips para descubrir senales de todo el mundo.' - : 'Proba quitar filtros o escribir otro nombre para encontrar una senal activa.', + return SizedBox( + height: 260, + child: PluriEmptyState( + glyph: PluriIconGlyph.search, + title: sinFiltros ? 'Busca una emisora' : 'Sin resultados', + subtitle: sinFiltros + ? 'Usa la barra superior o los chips para descubrir senales de todo el mundo.' + : 'Proba quitar filtros o escribir otro nombre para encontrar una senal activa.', + ), ); } + final total = resultados.length + (estado.hayMasBusqueda ? 1 : 0); return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), - itemCount: resultados.length + (estado.hayMasBusqueda ? 1 : 0), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + itemCount: total, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, i) { if (i >= resultados.length) { diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index ef78b43..7c605e9 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -18,7 +18,8 @@ class PantallaFavoritos extends StatelessWidget { final favoritos = estado.listaFavoritos; if (favoritos.isEmpty) { - return const Column( + return ListView( + padding: EdgeInsets.fromLTRB(0, 0, 0, 124), children: [ PluriScreenHeader( title: 'Favoritos', @@ -29,7 +30,8 @@ class PantallaFavoritos extends StatelessWidget { label: 'Coleccion', ), ), - Expanded( + SizedBox( + height: 320, child: PluriEmptyState( glyph: PluriIconGlyph.favorites, title: 'Sin favoritos aun', @@ -40,20 +42,22 @@ class PantallaFavoritos extends StatelessWidget { ); } - return Column( - children: [ - PluriScreenHeader( - title: 'Favoritos', - subtitle: 'Reordena tu coleccion y deja arriba las radios que mas importan.', - glyph: PluriIconGlyph.favorites, - trailing: PluriStatusPill( - icon: Icons.library_music_rounded, - label: '${favoritos.length} guardadas', + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: PluriScreenHeader( + title: 'Favoritos', + subtitle: 'Reordena tu coleccion y deja arriba las radios que mas importan.', + glyph: PluriIconGlyph.favorites, + trailing: PluriStatusPill( + icon: Icons.library_music_rounded, + label: '${favoritos.length} guardadas', + ), ), ), - Expanded( - child: ReorderableListView.builder( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 122), + SliverPadding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 124), + sliver: SliverReorderableList( proxyDecorator: (child, index, animation) => ScaleTransition( scale: Tween(begin: 1, end: 1.03).animate(animation), child: child, diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index f04fe48..1b738a2 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -48,7 +48,6 @@ class _PantallaInicioState extends State { child: CustomScrollView( slivers: [ SliverToBoxAdapter(child: _heroHeader(context, estado)), - const SliverToBoxAdapter(child: _AuroraWaveBanner()), SliverToBoxAdapter(child: _seccionCercanas(estado, theme)), SliverToBoxAdapter(child: _seccionTendencias(estado, theme)), SliverToBoxAdapter(child: _chipGeneros(context, theme)), @@ -312,64 +311,6 @@ class _PantallaInicioState extends State { } -class _AuroraWaveBanner extends StatelessWidget { - const _AuroraWaveBanner(); - - @override - Widget build(BuildContext context) { - final t = context.pluriTokens; - return Padding( - padding: EdgeInsets.fromLTRB(t.spacingMd, 4, t.spacingMd, 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(t.radiusLg), - child: Stack( - alignment: Alignment.centerLeft, - children: [ - Image.asset( - 'assets/images/aurora_wave_banner.png', - height: 108, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - Container( - height: 108, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black.withValues(alpha: 0.58), - Colors.transparent, - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Live spectrum', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - const SizedBox(height: 4), - Text( - 'Ondas aurora en tiempo real', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - class _ChipShimmer extends StatelessWidget { final ThemeData theme; const _ChipShimmer({required this.theme}); diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 56a80b0..fc003a3 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -106,7 +106,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; - Future _colaReproduccion = Future.value(); + int _playRequestId = 0; PluriWaveAudioHandler() { _setupStreams(); @@ -214,14 +214,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { } @override - Future playMediaItem(MediaItem mediaItem) { - _colaReproduccion = _colaReproduccion - .catchError((_) {}) - .then((_) => _playMediaItemSerializado(mediaItem)); - return _colaReproduccion; - } - - Future _playMediaItemSerializado(MediaItem mediaItem) async { + Future playMediaItem(MediaItem mediaItem) async { + final requestId = ++_playRequestId; this.mediaItem.add(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem); playbackState.add(playbackState.value.copyWith( @@ -231,11 +225,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { )); try { await _player.stop(); + if (requestId != _playRequestId) return; await _player.setUrl(mediaItem.id); + if (requestId != _playRequestId) return; await _player.play(); - await _activarEcualizador(); + if (requestId == _playRequestId) { + await _activarEcualizador(); + } } on PlayerException catch (e) { - _gestionarErrorReproduccion(e); + if (requestId == _playRequestId) { + _gestionarErrorReproduccion(e); + } throw Exception(_mensajeAmigable(e)); } on Exception catch (e) { developer.log( @@ -243,13 +243,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { name: 'ServicioAudio', level: 900, ); - playbackState.add(playbackState.value.copyWith( - processingState: AudioProcessingState.error, - playing: false, - errorMessage: 'Error inesperado al reproducir', - )); - emisoraActual = null; - this.mediaItem.add(null); + if (requestId == _playRequestId) { + playbackState.add(playbackState.value.copyWith( + processingState: AudioProcessingState.error, + playing: false, + errorMessage: 'Error inesperado al reproducir', + )); + emisoraActual = null; + this.mediaItem.add(null); + } rethrow; } } diff --git a/lib/widgets/visualizador_audio.dart b/lib/widgets/visualizador_audio.dart index 556e61d..b360bbb 100644 --- a/lib/widgets/visualizador_audio.dart +++ b/lib/widgets/visualizador_audio.dart @@ -45,31 +45,20 @@ class VisualizadorAudio extends StatefulWidget { } class _VisualizadorAudioState extends State - with TickerProviderStateMixin { + with SingleTickerProviderStateMixin { late AnimationController _controller; - late List<_BarraState> _barras; - final _random = Random(); bool _activo = false; StreamSubscription? _estadoSubscription; @override void initState() { super.initState(); - _barras = List.generate( - widget.barras, - (i) => _BarraState( - fase: _random.nextDouble() * pi * 2, - velocidad: 0.8 + _random.nextDouble() * 1.4, - amplitud: 0.4 + _random.nextDouble() * 0.6, - offset: _random.nextDouble() * 0.3, - ), - ); - _controller = AnimationController( vsync: this, - duration: const Duration(seconds: 1), - )..addListener(_actualizar); - + duration: const Duration(seconds: 2), + )..addListener(() { + if (mounted) setState(() {}); + }); _estadoSubscription = widget.estadoStream.listen(_onEstado); } @@ -79,17 +68,7 @@ class _VisualizadorAudioState extends State if (nuevoActivo == _activo) return; if (!mounted) return; setState(() => _activo = nuevoActivo); - if (nuevoActivo) { - _controller.repeat(); - } else { - _controller.forward(from: _controller.value).whenComplete(() { - if (!_activo && mounted) _controller.stop(); - }); - } - } - - void _actualizar() { - if (mounted) setState(() {}); + nuevoActivo ? _controller.repeat() : _controller.stop(); } @override @@ -103,75 +82,86 @@ class _VisualizadorAudioState extends State Widget build(BuildContext context) { final color = widget.color ?? Theme.of(context).colorScheme.primary; final t = _controller.value * pi * 2; - return SizedBox( height: widget.altura, - child: LayoutBuilder( - builder: (context, constraints) { - final totalAncho = constraints.maxWidth == double.infinity - ? 300.0 - : constraints.maxWidth; - final espaciado = totalAncho / widget.barras; - final anchoBar = (espaciado * 0.55).clamp(2.0, 8.0); - - return RepaintBoundary( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate(widget.barras, (i) { - final b = _barras[i]; - final double altura; - - if (_activo) { - // Movimiento orgánico: combinación de senos con diferentes fases - final onda1 = sin(t * b.velocidad + b.fase); - final onda2 = sin(t * b.velocidad * 0.7 + b.fase * 1.3) * 0.5; - final valor = ((onda1 + onda2 + 1.5) / 3.0).clamp(0.0, 1.0); - altura = (b.offset + valor * b.amplitud) * widget.altura; - } else { - // Decaer suavemente a altura mínima - final progreso = _controller.value; - final alturaActual = b.alturaActual; - b.alturaActual = alturaActual * (1 - progreso * 0.1); - altura = b.alturaActual.clamp(2.0, widget.altura * 0.05); - } - - return Padding( - padding: EdgeInsets.symmetric(horizontal: (espaciado - anchoBar) / 2), - child: AnimatedContainer( - duration: const Duration(milliseconds: 80), - width: anchoBar, - height: altura.clamp(2.0, widget.altura), - decoration: BoxDecoration( - color: color.withValues( - alpha: _activo ? 0.7 + (altura / widget.altura) * 0.3 : 0.3, - ), - borderRadius: BorderRadius.circular(anchoBar / 2), - ), - ), - ); - }), - ), - ); - }, + child: RepaintBoundary( + child: CustomPaint( + painter: _WaveFlowPainter( + color: color, + phase: t, + active: _activo, + ), + child: const SizedBox.expand(), + ), ), ); } } -class _BarraState { - final double fase; - final double velocidad; - final double amplitud; - final double offset; - double alturaActual; +class _WaveFlowPainter extends CustomPainter { + const _WaveFlowPainter({ + required this.color, + required this.phase, + required this.active, + }); - _BarraState({ - required this.fase, - required this.velocidad, - required this.amplitud, - required this.offset, - }) : alturaActual = offset * 20; + final Color color; + final double phase; + final bool active; + + @override + void paint(Canvas canvas, Size size) { + final center = size.height / 2; + final amp = active ? size.height * 0.24 : size.height * 0.06; + final glowPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = active ? 8 : 4 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: active ? 0.18 : 0.08); + final linePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..shader = LinearGradient( + colors: [ + color.withValues(alpha: 0.08), + color.withValues(alpha: active ? 0.95 : 0.35), + const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20), + color.withValues(alpha: 0.08), + ], + ).createShader(Offset.zero & size); + + Path pathFor(double shift, double amplitude) { + final path = Path()..moveTo(0, center); + for (double x = 0; x <= size.width; x += 8) { + final p = x / size.width; + final y = center + + sin((p * pi * 2.4) + phase + shift) * amplitude + + sin((p * pi * 5.2) - phase * 0.7 + shift) * amplitude * 0.32; + path.lineTo(x, y); + } + return path; + } + + canvas.drawPath(pathFor(0, amp), glowPaint); + canvas.drawPath(pathFor(0.6, amp * 0.62), glowPaint..color = color.withValues(alpha: active ? 0.10 : 0.05)); + canvas.drawPath(pathFor(0, amp), linePaint); + canvas.drawPath( + pathFor(0.9, amp * 0.58), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..strokeCap = StrokeCap.round + ..color = Colors.white.withValues(alpha: active ? 0.35 : 0.12), + ); + } + + @override + bool shouldRepaint(covariant _WaveFlowPainter oldDelegate) { + return oldDelegate.phase != phase || + oldDelegate.active != active || + oldDelegate.color != color; + } } /// Versión compacta del visualizador — 5 barras, para uso en MiniReproductor