fix(ui): unify scroll and improve playback switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s

This commit is contained in:
2026-05-20 23:44:14 +02:00
parent 34022e0814
commit 10520fef48
5 changed files with 138 additions and 191 deletions
+22 -12
View File
@@ -67,7 +67,8 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
final estado = context.watch<EstadoRadio>(); final estado = context.watch<EstadoRadio>();
final theme = Theme.of(context); final theme = Theme.of(context);
return Column( return ListView(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
children: [ children: [
PluriScreenHeader( PluriScreenHeader(
title: 'Buscar senal', title: 'Buscar senal',
@@ -125,7 +126,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
_buscar(); _buscar();
}, },
), ),
Expanded(child: _resultados(estado, theme)), _resultados(estado, theme),
], ],
); );
} }
@@ -144,7 +145,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
pais == null ? 'Emisoras cercanas' : 'Emisoras cercanas ? $pais', pais == null ? 'Emisoras cercanas' : 'Emisoras cercanas - $pais',
style: theme.textTheme.labelLarge?.copyWith( style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
), ),
@@ -247,7 +248,10 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
Widget _resultados(EstadoRadio estado, ThemeData theme) { Widget _resultados(EstadoRadio estado, ThemeData theme) {
if (estado.cargandoBusqueda) { if (estado.cargandoBusqueda) {
return const Center(child: CircularProgressIndicator()); return const SizedBox(
height: 220,
child: Center(child: CircularProgressIndicator()),
);
} }
final resultados = estado.resultadosBusqueda; final resultados = estado.resultadosBusqueda;
@@ -257,18 +261,24 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
_controller.text.isEmpty && _controller.text.isEmpty &&
_paisSeleccionado == null && _paisSeleccionado == null &&
_idiomaSeleccionado == null; _idiomaSeleccionado == null;
return PluriEmptyState( return SizedBox(
glyph: PluriIconGlyph.search, height: 260,
title: sinFiltros ? 'Busca una emisora' : 'Sin resultados', child: PluriEmptyState(
subtitle: sinFiltros glyph: PluriIconGlyph.search,
? 'Usa la barra superior o los chips para descubrir senales de todo el mundo.' title: sinFiltros ? 'Busca una emisora' : 'Sin resultados',
: 'Proba quitar filtros o escribir otro nombre para encontrar una senal activa.', 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( return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), shrinkWrap: true,
itemCount: resultados.length + (estado.hayMasBusqueda ? 1 : 0), physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
itemCount: total,
separatorBuilder: (_, __) => const SizedBox(height: 10), separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i >= resultados.length) { if (i >= resultados.length) {
+18 -14
View File
@@ -18,7 +18,8 @@ class PantallaFavoritos extends StatelessWidget {
final favoritos = estado.listaFavoritos; final favoritos = estado.listaFavoritos;
if (favoritos.isEmpty) { if (favoritos.isEmpty) {
return const Column( return ListView(
padding: EdgeInsets.fromLTRB(0, 0, 0, 124),
children: [ children: [
PluriScreenHeader( PluriScreenHeader(
title: 'Favoritos', title: 'Favoritos',
@@ -29,7 +30,8 @@ class PantallaFavoritos extends StatelessWidget {
label: 'Coleccion', label: 'Coleccion',
), ),
), ),
Expanded( SizedBox(
height: 320,
child: PluriEmptyState( child: PluriEmptyState(
glyph: PluriIconGlyph.favorites, glyph: PluriIconGlyph.favorites,
title: 'Sin favoritos aun', title: 'Sin favoritos aun',
@@ -40,20 +42,22 @@ class PantallaFavoritos extends StatelessWidget {
); );
} }
return Column( return CustomScrollView(
children: [ slivers: [
PluriScreenHeader( SliverToBoxAdapter(
title: 'Favoritos', child: PluriScreenHeader(
subtitle: 'Reordena tu coleccion y deja arriba las radios que mas importan.', title: 'Favoritos',
glyph: PluriIconGlyph.favorites, subtitle: 'Reordena tu coleccion y deja arriba las radios que mas importan.',
trailing: PluriStatusPill( glyph: PluriIconGlyph.favorites,
icon: Icons.library_music_rounded, trailing: PluriStatusPill(
label: '${favoritos.length} guardadas', icon: Icons.library_music_rounded,
label: '${favoritos.length} guardadas',
),
), ),
), ),
Expanded( SliverPadding(
child: ReorderableListView.builder( padding: const EdgeInsets.fromLTRB(12, 4, 12, 124),
padding: const EdgeInsets.fromLTRB(12, 4, 12, 122), sliver: SliverReorderableList(
proxyDecorator: (child, index, animation) => ScaleTransition( proxyDecorator: (child, index, animation) => ScaleTransition(
scale: Tween<double>(begin: 1, end: 1.03).animate(animation), scale: Tween<double>(begin: 1, end: 1.03).animate(animation),
child: child, child: child,
-59
View File
@@ -48,7 +48,6 @@ class _PantallaInicioState extends State<PantallaInicio> {
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter(child: _heroHeader(context, estado)), SliverToBoxAdapter(child: _heroHeader(context, estado)),
const SliverToBoxAdapter(child: _AuroraWaveBanner()),
SliverToBoxAdapter(child: _seccionCercanas(estado, theme)), SliverToBoxAdapter(child: _seccionCercanas(estado, theme)),
SliverToBoxAdapter(child: _seccionTendencias(estado, theme)), SliverToBoxAdapter(child: _seccionTendencias(estado, theme)),
SliverToBoxAdapter(child: _chipGeneros(context, theme)), SliverToBoxAdapter(child: _chipGeneros(context, theme)),
@@ -312,64 +311,6 @@ class _PantallaInicioState extends State<PantallaInicio> {
} }
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 { class _ChipShimmer extends StatelessWidget {
final ThemeData theme; final ThemeData theme;
const _ChipShimmer({required this.theme}); const _ChipShimmer({required this.theme});
+20 -18
View File
@@ -106,7 +106,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetActual => _presetActual;
Future<void> _colaReproduccion = Future<void>.value(); int _playRequestId = 0;
PluriWaveAudioHandler() { PluriWaveAudioHandler() {
_setupStreams(); _setupStreams();
@@ -214,14 +214,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
} }
@override @override
Future<void> playMediaItem(MediaItem mediaItem) { Future<void> playMediaItem(MediaItem mediaItem) async {
_colaReproduccion = _colaReproduccion final requestId = ++_playRequestId;
.catchError((_) {})
.then((_) => _playMediaItemSerializado(mediaItem));
return _colaReproduccion;
}
Future<void> _playMediaItemSerializado(MediaItem mediaItem) async {
this.mediaItem.add(mediaItem); this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem);
playbackState.add(playbackState.value.copyWith( playbackState.add(playbackState.value.copyWith(
@@ -231,11 +225,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
)); ));
try { try {
await _player.stop(); await _player.stop();
if (requestId != _playRequestId) return;
await _player.setUrl(mediaItem.id); await _player.setUrl(mediaItem.id);
if (requestId != _playRequestId) return;
await _player.play(); await _player.play();
await _activarEcualizador(); if (requestId == _playRequestId) {
await _activarEcualizador();
}
} on PlayerException catch (e) { } on PlayerException catch (e) {
_gestionarErrorReproduccion(e); if (requestId == _playRequestId) {
_gestionarErrorReproduccion(e);
}
throw Exception(_mensajeAmigable(e)); throw Exception(_mensajeAmigable(e));
} on Exception catch (e) { } on Exception catch (e) {
developer.log( developer.log(
@@ -243,13 +243,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
name: 'ServicioAudio', name: 'ServicioAudio',
level: 900, level: 900,
); );
playbackState.add(playbackState.value.copyWith( if (requestId == _playRequestId) {
processingState: AudioProcessingState.error, playbackState.add(playbackState.value.copyWith(
playing: false, processingState: AudioProcessingState.error,
errorMessage: 'Error inesperado al reproducir', playing: false,
)); errorMessage: 'Error inesperado al reproducir',
emisoraActual = null; ));
this.mediaItem.add(null); emisoraActual = null;
this.mediaItem.add(null);
}
rethrow; rethrow;
} }
} }
+78 -88
View File
@@ -45,31 +45,20 @@ class VisualizadorAudio extends StatefulWidget {
} }
class _VisualizadorAudioState extends State<VisualizadorAudio> class _VisualizadorAudioState extends State<VisualizadorAudio>
with TickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late List<_BarraState> _barras;
final _random = Random();
bool _activo = false; bool _activo = false;
StreamSubscription<EstadoReproduccion>? _estadoSubscription; StreamSubscription<EstadoReproduccion>? _estadoSubscription;
@override @override
void initState() { void initState() {
super.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( _controller = AnimationController(
vsync: this, vsync: this,
duration: const Duration(seconds: 1), duration: const Duration(seconds: 2),
)..addListener(_actualizar); )..addListener(() {
if (mounted) setState(() {});
});
_estadoSubscription = widget.estadoStream.listen(_onEstado); _estadoSubscription = widget.estadoStream.listen(_onEstado);
} }
@@ -79,17 +68,7 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
if (nuevoActivo == _activo) return; if (nuevoActivo == _activo) return;
if (!mounted) return; if (!mounted) return;
setState(() => _activo = nuevoActivo); setState(() => _activo = nuevoActivo);
if (nuevoActivo) { nuevoActivo ? _controller.repeat() : _controller.stop();
_controller.repeat();
} else {
_controller.forward(from: _controller.value).whenComplete(() {
if (!_activo && mounted) _controller.stop();
});
}
}
void _actualizar() {
if (mounted) setState(() {});
} }
@override @override
@@ -103,75 +82,86 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = widget.color ?? Theme.of(context).colorScheme.primary; final color = widget.color ?? Theme.of(context).colorScheme.primary;
final t = _controller.value * pi * 2; final t = _controller.value * pi * 2;
return SizedBox( return SizedBox(
height: widget.altura, height: widget.altura,
child: LayoutBuilder( child: RepaintBoundary(
builder: (context, constraints) { child: CustomPaint(
final totalAncho = constraints.maxWidth == double.infinity painter: _WaveFlowPainter(
? 300.0 color: color,
: constraints.maxWidth; phase: t,
final espaciado = totalAncho / widget.barras; active: _activo,
final anchoBar = (espaciado * 0.55).clamp(2.0, 8.0); ),
child: const SizedBox.expand(),
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),
),
),
);
}),
),
);
},
), ),
); );
} }
} }
class _BarraState { class _WaveFlowPainter extends CustomPainter {
final double fase; const _WaveFlowPainter({
final double velocidad; required this.color,
final double amplitud; required this.phase,
final double offset; required this.active,
double alturaActual; });
_BarraState({ final Color color;
required this.fase, final double phase;
required this.velocidad, final bool active;
required this.amplitud,
required this.offset, @override
}) : alturaActual = offset * 20; 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 /// Versión compacta del visualizador — 5 barras, para uso en MiniReproductor