fix(player): stabilize first playback and refresh design
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m20s

This commit is contained in:
2026-05-20 22:50:39 +02:00
parent 22e19d1cb0
commit b9cf42b91c
27 changed files with 131 additions and 31 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,5 @@
# PluriWave Night Ocean asset sheet prompt
Generated with built-in image_gen for the Night Ocean Broadcast redesign.
Contents: app mark, station fallback artworks, aurora/waveform banner, and navigation glyph assets using teal/amber/cream over navy with no purple/magenta dominance.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 487 KiB

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,5 @@
# PluriWave Night Ocean mockup prompt
Generated with built-in image_gen after user feedback rejecting purple-heavy futuristic UI.
Direction: Night Ocean Broadcast ? midnight navy and petrol teal base, mint action states, warm amber live/accent, cream text/surfaces, practical radio streaming UX with immediate Now Playing flow.
+15 -3
View File
@@ -61,6 +61,7 @@ class EstadoRadio extends ChangeNotifier {
bool _cargandoPopulares = false; bool _cargandoPopulares = false;
bool _cargandoBusqueda = false; bool _cargandoBusqueda = false;
EstadoReproduccion _estadoReproduccion = EstadoReproduccion.detenido;
String? _errorCarga; String? _errorCarga;
List<Emisora> get populares => _populares; List<Emisora> get populares => _populares;
@@ -118,12 +119,11 @@ class EstadoRadio extends ChangeNotifier {
/// Escucha el stream de estado del audio y gestiona errores de reproducción. /// Escucha el stream de estado del audio y gestiona errores de reproducción.
void _escucharErroresReproduccion() { void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) { _suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.error) { _estadoReproduccion = estado;
if (timer.activo) { if (estado == EstadoReproduccion.error && timer.activo) {
unawaited(timer.cancelar()); unawaited(timer.cancelar());
} }
notifyListeners(); notifyListeners();
}
}); });
} }
@@ -192,7 +192,19 @@ class EstadoRadio extends ChangeNotifier {
} }
Future<void> reproducir(Emisora emisora) async { Future<void> reproducir(Emisora emisora) async {
final actual = audio.emisoraActual;
final mismaEmisoraActiva = actual?.uuid == emisora.uuid;
final yaEstaConectandoOSonando =
_estadoReproduccion == EstadoReproduccion.cargando ||
_estadoReproduccion == EstadoReproduccion.reproduciendo ||
audio.estaSonando;
if (mismaEmisoraActiva && yaEstaConectandoOSonando) {
notifyListeners();
return;
}
try { try {
notifyListeners();
await audio.reproducir(emisora); await audio.reproducir(emisora);
unawaited(radio.registrarClick(emisora.uuid)); unawaited(radio.registrarClick(emisora.uuid));
await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid)); await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid));
+3 -1
View File
@@ -8,6 +8,8 @@ import '../widgets/pluri_icon.dart';
import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'reproducir_y_abrir.dart';
const _paises = [ const _paises = [
('Espana', 'ES'), ('Espana', 'ES'),
('USA', 'US'), ('USA', 'US'),
@@ -200,7 +202,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
itemBuilder: (context, i) => TarjetaEmisora( itemBuilder: (context, i) => TarjetaEmisora(
emisora: resultados[i], emisora: resultados[i],
esCompacta: true, esCompacta: true,
onTap: () => context.read<EstadoRadio>().reproducir(resultados[i]), onTap: () => reproducirYAbrir(context, resultados[i]),
).animate().fadeIn(delay: (i * 20).ms).slideY(begin: 0.08), ).animate().fadeIn(delay: (i * 20).ms).slideY(begin: 0.08),
); );
} }
+3 -1
View File
@@ -7,6 +7,8 @@ import '../widgets/pluri_icon.dart';
import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'reproducir_y_abrir.dart';
class PantallaFavoritos extends StatelessWidget { class PantallaFavoritos extends StatelessWidget {
const PantallaFavoritos({super.key}); const PantallaFavoritos({super.key});
@@ -85,7 +87,7 @@ class PantallaFavoritos extends StatelessWidget {
key: Key(emisora.uuid), key: Key(emisora.uuid),
emisora: emisora, emisora: emisora,
esCompacta: true, esCompacta: true,
onTap: () => estado.reproducir(emisora), onTap: () => reproducirYAbrir(context, emisora),
), ),
), ),
IconButton.filledTonal( IconButton.filledTonal(
+4 -2
View File
@@ -10,6 +10,8 @@ import '../widgets/pluri_icon.dart';
import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'reproducir_y_abrir.dart';
/// Pantalla principal: emisoras populares y por género. /// Pantalla principal: emisoras populares y por género.
class PantallaInicio extends StatefulWidget { class PantallaInicio extends StatefulWidget {
const PantallaInicio({super.key}); const PantallaInicio({super.key});
@@ -118,7 +120,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
), ),
label: Text(e.nombre, maxLines: 1), label: Text(e.nombre, maxLines: 1),
onPressed: onPressed:
() => context.read<EstadoRadio>().reproducir(e), () => reproducirYAbrir(context, e),
).animate().fadeIn(delay: (i * 50).ms); ).animate().fadeIn(delay: (i * 50).ms);
}, },
), ),
@@ -225,7 +227,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, i) => TarjetaEmisora( (context, i) => TarjetaEmisora(
emisora: emisoras[i], emisora: emisoras[i],
onTap: () => context.read<EstadoRadio>().reproducir(emisoras[i]), onTap: () => reproducirYAbrir(context, emisoras[i]),
).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1), ).animate().fadeIn(delay: (i * 30).ms).slideY(begin: 0.1),
childCount: emisoras.length, childCount: emisoras.length,
), ),
+15
View File
@@ -0,0 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../modelos/emisora.dart';
import 'pantalla_reproductor.dart';
Future<void> reproducirYAbrir(BuildContext context, Emisora emisora) async {
final estado = context.read<EstadoRadio>();
unawaited(estado.reproducir(emisora));
if (!context.mounted) return;
await PantallaReproductor.abrir(context, emisora);
}
+5 -1
View File
@@ -215,11 +215,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
@override @override
Future<void> playMediaItem(MediaItem mediaItem) async { Future<void> playMediaItem(MediaItem mediaItem) async {
this.mediaItem.add(mediaItem); this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
playbackState.add(playbackState.value.copyWith(
processingState: AudioProcessingState.loading,
playing: false,
));
try { try {
await _player.stop(); await _player.stop();
await _player.setUrl(mediaItem.id); await _player.setUrl(mediaItem.id);
await _player.play(); await _player.play();
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
await _activarEcualizador(); await _activarEcualizador();
} on PlayerException catch (e) { } on PlayerException catch (e) {
_gestionarErrorReproduccion(e); _gestionarErrorReproduccion(e);
+6 -6
View File
@@ -9,12 +9,12 @@ abstract final class PluriWaveTheme {
const tokens = PluriWaveTokens.dark; const tokens = PluriWaveTokens.dark;
final colorScheme = const ColorScheme.dark().copyWith( final colorScheme = const ColorScheme.dark().copyWith(
primary: tokens.electricMagenta, primary: tokens.electricMagenta,
secondary: const Color(0xFF20E6FF), secondary: const Color(0xFF7EE4C2),
tertiary: tokens.warmCoral, tertiary: tokens.warmCoral,
surface: const Color(0xFF0B1024), surface: const Color(0xFF0D1B24),
surfaceContainerLow: const Color(0xFF111831), surfaceContainerLow: const Color(0xFF102532),
surfaceContainerHighest: const Color(0xFF202946), surfaceContainerHighest: const Color(0xFF1B3942),
onSurface: const Color(0xFFF7F2FF), onSurface: const Color(0xFFF2F7FA),
onPrimary: Colors.white, onPrimary: Colors.white,
); );
@@ -30,7 +30,7 @@ abstract final class PluriWaveTheme {
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
centerTitle: false, centerTitle: false,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Color(0xFFF7F2FF), foregroundColor: Color(0xFFF2F7FA),
elevation: 0, elevation: 0,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
), ),
+6 -6
View File
@@ -37,12 +37,12 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
final double spacingLg; final double spacingLg;
static const dark = PluriWaveTokens( static const dark = PluriWaveTokens(
deepViolet: Color(0xFF070A18), deepViolet: Color(0xFF07121A),
electricMagenta: Color(0xFFFF3DF2), electricMagenta: Color(0xFF21D4D9),
warmCoral: Color(0xFFFFB86B), warmCoral: Color(0xFFF4B860),
glassSurface: Color(0x24FFFFFF), glassSurface: Color(0x1FFFFFFF),
glassBorder: Color(0x52FFFFFF), glassBorder: Color(0x33FFFFFF),
glowColor: Color(0x88FF3DF2), glowColor: Color(0x6621D4D9),
radiusSm: 14, radiusSm: 14,
radiusMd: 22, radiusMd: 22,
radiusLg: 30, radiusLg: 30,
+7 -7
View File
@@ -31,10 +31,10 @@ class PluriWaveScaffold extends StatelessWidget {
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
const Color(0xFF070A18), const Color(0xFF07121A),
t.deepViolet, const Color(0xFF0D1B24),
const Color(0xFF151033), const Color(0xFF0E4A4F),
const Color(0xFF070A18), const Color(0xFF07121A),
], ],
stops: const [0, 0.34, 0.68, 1], stops: const [0, 0.34, 0.68, 1],
), ),
@@ -45,17 +45,17 @@ class PluriWaveScaffold extends StatelessWidget {
Positioned( Positioned(
left: -120, left: -120,
top: -120, top: -120,
child: _AuroraOrb(size: 300, color: const Color(0xFF20E6FF).withValues(alpha: 0.32)), child: _AuroraOrb(size: 300, color: const Color(0xFF21D4D9).withValues(alpha: 0.18)),
), ),
Positioned( Positioned(
right: -150, right: -150,
top: 160, top: 160,
child: _AuroraOrb(size: 340, color: t.electricMagenta.withValues(alpha: 0.26)), child: _AuroraOrb(size: 340, color: const Color(0xFF7EE4C2).withValues(alpha: 0.12)),
), ),
Positioned( Positioned(
left: -90, left: -90,
bottom: 80, bottom: 80,
child: _AuroraOrb(size: 260, color: t.warmCoral.withValues(alpha: 0.16)), child: _AuroraOrb(size: 260, color: t.warmCoral.withValues(alpha: 0.10)),
), ),
body, body,
], ],
+44
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/fakes.dart'; import '../helpers/fakes.dart';
@@ -172,6 +173,49 @@ void main() {
expect(audio.presetsAplicados.last, principal); expect(audio.presetsAplicados.last, principal);
}); });
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++);
await estado.inicializar();
final antes = notificaciones;
audio.emitirEstado(EstadoReproduccion.cargando);
await Future<void>.delayed(Duration.zero);
expect(notificaciones, greaterThan(antes));
});
test('reproducir la misma emisora mientras suena no reinicia el 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.inicializar();
await estado.reproducir(emisora);
await estado.reproducir(emisora);
expect(audio.emisorasReproducidas, hasLength(1));
});
test('reordenar favoritos reindexa de forma determinística', () async { test('reordenar favoritos reindexa de forma determinística', () async {
final favoritos = FakeServicioFavoritos(); final favoritos = FakeServicioFavoritos();
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A')); await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A'));
+11 -2
View File
@@ -17,6 +17,7 @@ class FakeServicioAudio extends ServicioAudio {
final List<PresetEcualizador> presetsAplicados = []; final List<PresetEcualizador> presetsAplicados = [];
final List<Emisora> emisorasReproducidas = []; final List<Emisora> emisorasReproducidas = [];
Emisora? _emisoraActual; Emisora? _emisoraActual;
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
@override @override
Emisora? get emisoraActual => _emisoraActual; Emisora? get emisoraActual => _emisoraActual;
@@ -27,17 +28,25 @@ class FakeServicioAudio extends ServicioAudio {
@override @override
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream; Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
@override
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
@override @override
Future<void> reproducir(Emisora emisora) async { Future<void> reproducir(Emisora emisora) async {
_emisoraActual = emisora; _emisoraActual = emisora;
emisorasReproducidas.add(emisora); emisorasReproducidas.add(emisora);
_estadoController.add(EstadoReproduccion.reproduciendo); emitirEstado(EstadoReproduccion.reproduciendo);
} }
@override @override
Future<void> detener() async { Future<void> detener() async {
_emisoraActual = null; _emisoraActual = null;
_estadoController.add(EstadoReproduccion.detenido); emitirEstado(EstadoReproduccion.detenido);
}
void emitirEstado(EstadoReproduccion estado) {
_estadoActual = estado;
_estadoController.add(estado);
} }
@override @override