fix(ui): unify scroll and improve playback switching
This commit is contained in:
@@ -67,7 +67,8 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
final estado = context.watch<EstadoRadio>();
|
||||
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<PantallaBuscar> {
|
||||
_buscar();
|
||||
},
|
||||
),
|
||||
Expanded(child: _resultados(estado, theme)),
|
||||
_resultados(estado, theme),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -144,7 +145,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
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<PantallaBuscar> {
|
||||
|
||||
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<PantallaBuscar> {
|
||||
_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) {
|
||||
|
||||
@@ -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<double>(begin: 1, end: 1.03).animate(animation),
|
||||
child: child,
|
||||
|
||||
@@ -48,7 +48,6 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
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<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 {
|
||||
final ThemeData theme;
|
||||
const _ChipShimmer({required this.theme});
|
||||
|
||||
@@ -106,7 +106,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
|
||||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
||||
PresetEcualizador get presetActual => _presetActual;
|
||||
Future<void> _colaReproduccion = Future<void>.value();
|
||||
int _playRequestId = 0;
|
||||
|
||||
PluriWaveAudioHandler() {
|
||||
_setupStreams();
|
||||
@@ -214,14 +214,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playMediaItem(MediaItem mediaItem) {
|
||||
_colaReproduccion = _colaReproduccion
|
||||
.catchError((_) {})
|
||||
.then((_) => _playMediaItemSerializado(mediaItem));
|
||||
return _colaReproduccion;
|
||||
}
|
||||
|
||||
Future<void> _playMediaItemSerializado(MediaItem mediaItem) async {
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,31 +45,20 @@ class VisualizadorAudio extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _VisualizadorAudioState extends State<VisualizadorAudio>
|
||||
with TickerProviderStateMixin {
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late List<_BarraState> _barras;
|
||||
final _random = Random();
|
||||
bool _activo = false;
|
||||
StreamSubscription<EstadoReproduccion>? _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<VisualizadorAudio>
|
||||
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<VisualizadorAudio>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user