fix(player): stabilize first playback and refresh design
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.5 MiB |
|
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.
|
||||
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 565 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 487 KiB After Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 351 KiB |
|
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.
|
||||
@@ -61,6 +61,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
bool _cargandoPopulares = false;
|
||||
bool _cargandoBusqueda = false;
|
||||
EstadoReproduccion _estadoReproduccion = EstadoReproduccion.detenido;
|
||||
String? _errorCarga;
|
||||
|
||||
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.
|
||||
void _escucharErroresReproduccion() {
|
||||
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
|
||||
if (estado == EstadoReproduccion.error) {
|
||||
if (timer.activo) {
|
||||
unawaited(timer.cancelar());
|
||||
}
|
||||
notifyListeners();
|
||||
_estadoReproduccion = estado;
|
||||
if (estado == EstadoReproduccion.error && timer.activo) {
|
||||
unawaited(timer.cancelar());
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,7 +192,19 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
|
||||
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 {
|
||||
notifyListeners();
|
||||
await audio.reproducir(emisora);
|
||||
unawaited(radio.registrarClick(emisora.uuid));
|
||||
await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid));
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
|
||||
import 'reproducir_y_abrir.dart';
|
||||
|
||||
const _paises = [
|
||||
('Espana', 'ES'),
|
||||
('USA', 'US'),
|
||||
@@ -200,7 +202,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
||||
itemBuilder: (context, i) => TarjetaEmisora(
|
||||
emisora: resultados[i],
|
||||
esCompacta: true,
|
||||
onTap: () => context.read<EstadoRadio>().reproducir(resultados[i]),
|
||||
onTap: () => reproducirYAbrir(context, resultados[i]),
|
||||
).animate().fadeIn(delay: (i * 20).ms).slideY(begin: 0.08),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
|
||||
import 'reproducir_y_abrir.dart';
|
||||
|
||||
class PantallaFavoritos extends StatelessWidget {
|
||||
const PantallaFavoritos({super.key});
|
||||
|
||||
@@ -85,7 +87,7 @@ class PantallaFavoritos extends StatelessWidget {
|
||||
key: Key(emisora.uuid),
|
||||
emisora: emisora,
|
||||
esCompacta: true,
|
||||
onTap: () => estado.reproducir(emisora),
|
||||
onTap: () => reproducirYAbrir(context, emisora),
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
|
||||
@@ -10,6 +10,8 @@ import '../widgets/pluri_icon.dart';
|
||||
import '../widgets/pluri_premium_widgets.dart';
|
||||
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
|
||||
|
||||
import 'reproducir_y_abrir.dart';
|
||||
|
||||
/// Pantalla principal: emisoras populares y por género.
|
||||
class PantallaInicio extends StatefulWidget {
|
||||
const PantallaInicio({super.key});
|
||||
@@ -118,7 +120,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
),
|
||||
label: Text(e.nombre, maxLines: 1),
|
||||
onPressed:
|
||||
() => context.read<EstadoRadio>().reproducir(e),
|
||||
() => reproducirYAbrir(context, e),
|
||||
).animate().fadeIn(delay: (i * 50).ms);
|
||||
},
|
||||
),
|
||||
@@ -225,7 +227,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) => TarjetaEmisora(
|
||||
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),
|
||||
childCount: emisoras.length,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -215,11 +215,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
@override
|
||||
Future<void> playMediaItem(MediaItem mediaItem) async {
|
||||
this.mediaItem.add(mediaItem);
|
||||
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
processingState: AudioProcessingState.loading,
|
||||
playing: false,
|
||||
));
|
||||
try {
|
||||
await _player.stop();
|
||||
await _player.setUrl(mediaItem.id);
|
||||
await _player.play();
|
||||
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
|
||||
await _activarEcualizador();
|
||||
} on PlayerException catch (e) {
|
||||
_gestionarErrorReproduccion(e);
|
||||
|
||||
@@ -9,12 +9,12 @@ abstract final class PluriWaveTheme {
|
||||
const tokens = PluriWaveTokens.dark;
|
||||
final colorScheme = const ColorScheme.dark().copyWith(
|
||||
primary: tokens.electricMagenta,
|
||||
secondary: const Color(0xFF20E6FF),
|
||||
secondary: const Color(0xFF7EE4C2),
|
||||
tertiary: tokens.warmCoral,
|
||||
surface: const Color(0xFF0B1024),
|
||||
surfaceContainerLow: const Color(0xFF111831),
|
||||
surfaceContainerHighest: const Color(0xFF202946),
|
||||
onSurface: const Color(0xFFF7F2FF),
|
||||
surface: const Color(0xFF0D1B24),
|
||||
surfaceContainerLow: const Color(0xFF102532),
|
||||
surfaceContainerHighest: const Color(0xFF1B3942),
|
||||
onSurface: const Color(0xFFF2F7FA),
|
||||
onPrimary: Colors.white,
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ abstract final class PluriWaveTheme {
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFFF7F2FF),
|
||||
foregroundColor: Color(0xFFF2F7FA),
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
),
|
||||
|
||||
@@ -37,12 +37,12 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
|
||||
final double spacingLg;
|
||||
|
||||
static const dark = PluriWaveTokens(
|
||||
deepViolet: Color(0xFF070A18),
|
||||
electricMagenta: Color(0xFFFF3DF2),
|
||||
warmCoral: Color(0xFFFFB86B),
|
||||
glassSurface: Color(0x24FFFFFF),
|
||||
glassBorder: Color(0x52FFFFFF),
|
||||
glowColor: Color(0x88FF3DF2),
|
||||
deepViolet: Color(0xFF07121A),
|
||||
electricMagenta: Color(0xFF21D4D9),
|
||||
warmCoral: Color(0xFFF4B860),
|
||||
glassSurface: Color(0x1FFFFFFF),
|
||||
glassBorder: Color(0x33FFFFFF),
|
||||
glowColor: Color(0x6621D4D9),
|
||||
radiusSm: 14,
|
||||
radiusMd: 22,
|
||||
radiusLg: 30,
|
||||
|
||||
@@ -31,10 +31,10 @@ class PluriWaveScaffold extends StatelessWidget {
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
const Color(0xFF070A18),
|
||||
t.deepViolet,
|
||||
const Color(0xFF151033),
|
||||
const Color(0xFF070A18),
|
||||
const Color(0xFF07121A),
|
||||
const Color(0xFF0D1B24),
|
||||
const Color(0xFF0E4A4F),
|
||||
const Color(0xFF07121A),
|
||||
],
|
||||
stops: const [0, 0.34, 0.68, 1],
|
||||
),
|
||||
@@ -45,17 +45,17 @@ class PluriWaveScaffold extends StatelessWidget {
|
||||
Positioned(
|
||||
left: -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(
|
||||
right: -150,
|
||||
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(
|
||||
left: -90,
|
||||
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,
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
@@ -172,6 +173,49 @@ void main() {
|
||||
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 {
|
||||
final favoritos = FakeServicioFavoritos();
|
||||
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A'));
|
||||
|
||||
@@ -17,6 +17,7 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
final List<PresetEcualizador> presetsAplicados = [];
|
||||
final List<Emisora> emisorasReproducidas = [];
|
||||
Emisora? _emisoraActual;
|
||||
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
|
||||
|
||||
@override
|
||||
Emisora? get emisoraActual => _emisoraActual;
|
||||
@@ -27,17 +28,25 @@ class FakeServicioAudio extends ServicioAudio {
|
||||
@override
|
||||
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
|
||||
|
||||
@override
|
||||
bool get estaSonando => _estadoActual == EstadoReproduccion.reproduciendo;
|
||||
|
||||
@override
|
||||
Future<void> reproducir(Emisora emisora) async {
|
||||
_emisoraActual = emisora;
|
||||
emisorasReproducidas.add(emisora);
|
||||
_estadoController.add(EstadoReproduccion.reproduciendo);
|
||||
emitirEstado(EstadoReproduccion.reproduciendo);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> detener() async {
|
||||
_emisoraActual = null;
|
||||
_estadoController.add(EstadoReproduccion.detenido);
|
||||
emitirEstado(EstadoReproduccion.detenido);
|
||||
}
|
||||
|
||||
void emitirEstado(EstadoReproduccion estado) {
|
||||
_estadoActual = estado;
|
||||
_estadoController.add(estado);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||