feat(radio): add nearby discovery and paged search
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="PluriWave"
|
android:label="PluriWave"
|
||||||
|
|||||||
@@ -66,5 +66,7 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>PluriWave usa tu ubicacion aproximada para sugerirte emisoras cercanas.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
+113
-13
@@ -3,6 +3,8 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:geocoding/geocoding.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
@@ -51,6 +53,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
List<Emisora> _populares = [];
|
List<Emisora> _populares = [];
|
||||||
List<Emisora> _tendencias = [];
|
List<Emisora> _tendencias = [];
|
||||||
List<Emisora> _resultadosBusqueda = [];
|
List<Emisora> _resultadosBusqueda = [];
|
||||||
|
List<Emisora> _emisorasCercanas = [];
|
||||||
List<Emisora> _listaFavoritos = [];
|
List<Emisora> _listaFavoritos = [];
|
||||||
List<Emisora> _emisorasCustom = [];
|
List<Emisora> _emisorasCustom = [];
|
||||||
|
|
||||||
@@ -61,16 +64,31 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
|
|
||||||
bool _cargandoPopulares = false;
|
bool _cargandoPopulares = false;
|
||||||
bool _cargandoBusqueda = false;
|
bool _cargandoBusqueda = false;
|
||||||
EstadoReproduccion _estadoReproduccion = EstadoReproduccion.detenido;
|
bool _cargandoMasBusqueda = false;
|
||||||
|
bool _hayMasBusqueda = true;
|
||||||
|
bool _cargandoCercanas = false;
|
||||||
|
String? _paisCercanoDetectado;
|
||||||
|
String? _errorCercanas;
|
||||||
|
int _offsetBusqueda = 0;
|
||||||
|
String? _ultimoNombreBusqueda;
|
||||||
|
String? _ultimoPaisBusqueda;
|
||||||
|
String? _ultimoIdiomaBusqueda;
|
||||||
|
String? _ultimoTagBusqueda;
|
||||||
String? _errorCarga;
|
String? _errorCarga;
|
||||||
|
|
||||||
List<Emisora> get populares => _populares;
|
List<Emisora> get populares => _populares;
|
||||||
List<Emisora> get tendencias => _tendencias;
|
List<Emisora> get tendencias => _tendencias;
|
||||||
List<Emisora> get resultadosBusqueda => _resultadosBusqueda;
|
List<Emisora> get resultadosBusqueda => _resultadosBusqueda;
|
||||||
|
List<Emisora> get emisorasCercanas => _emisorasCercanas;
|
||||||
List<Emisora> get listaFavoritos => _listaFavoritos;
|
List<Emisora> get listaFavoritos => _listaFavoritos;
|
||||||
List<Emisora> get emisorasCustom => _emisorasCustom;
|
List<Emisora> get emisorasCustom => _emisorasCustom;
|
||||||
bool get cargandoPopulares => _cargandoPopulares;
|
bool get cargandoPopulares => _cargandoPopulares;
|
||||||
bool get cargandoBusqueda => _cargandoBusqueda;
|
bool get cargandoBusqueda => _cargandoBusqueda;
|
||||||
|
bool get cargandoMasBusqueda => _cargandoMasBusqueda;
|
||||||
|
bool get hayMasBusqueda => _hayMasBusqueda;
|
||||||
|
bool get cargandoCercanas => _cargandoCercanas;
|
||||||
|
String? get paisCercanoDetectado => _paisCercanoDetectado;
|
||||||
|
String? get errorCercanas => _errorCercanas;
|
||||||
String? get error => _errorCarga;
|
String? get error => _errorCarga;
|
||||||
Emisora? get emisoraActual => audio.emisoraActual;
|
Emisora? get emisoraActual => audio.emisoraActual;
|
||||||
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
||||||
@@ -119,7 +137,6 @@ 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) {
|
||||||
_estadoReproduccion = estado;
|
|
||||||
if (estado == EstadoReproduccion.error && timer.activo) {
|
if (estado == EstadoReproduccion.error && timer.activo) {
|
||||||
unawaited(timer.cancelar());
|
unawaited(timer.cancelar());
|
||||||
}
|
}
|
||||||
@@ -167,42 +184,125 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const int _tamanoPaginaBusqueda = 30;
|
||||||
|
static const int _maxResultadosBusquedaEnMemoria = 180;
|
||||||
|
|
||||||
Future<void> buscar({
|
Future<void> buscar({
|
||||||
String? nombre,
|
String? nombre,
|
||||||
String? pais,
|
String? pais,
|
||||||
String? idioma,
|
String? idioma,
|
||||||
String? tag,
|
String? tag,
|
||||||
}) async {
|
}) async {
|
||||||
|
_ultimoNombreBusqueda = nombre;
|
||||||
|
_ultimoPaisBusqueda = pais;
|
||||||
|
_ultimoIdiomaBusqueda = idioma;
|
||||||
|
_ultimoTagBusqueda = tag;
|
||||||
|
_offsetBusqueda = 0;
|
||||||
|
_hayMasBusqueda = true;
|
||||||
_cargandoBusqueda = true;
|
_cargandoBusqueda = true;
|
||||||
_resultadosBusqueda = [];
|
_resultadosBusqueda = [];
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
_resultadosBusqueda = await radio.buscar(
|
final pagina = await radio.buscar(
|
||||||
nombre: nombre,
|
nombre: nombre,
|
||||||
pais: pais,
|
pais: pais,
|
||||||
idioma: idioma,
|
idioma: idioma,
|
||||||
tag: tag,
|
tag: tag,
|
||||||
|
limit: _tamanoPaginaBusqueda,
|
||||||
|
offset: _offsetBusqueda,
|
||||||
);
|
);
|
||||||
|
_resultadosBusqueda = pagina;
|
||||||
|
_offsetBusqueda = pagina.length;
|
||||||
|
_hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
_errorController.add('Error en la búsqueda. Comprueba tu conexión.');
|
_errorController.add('Error en la busqueda. Comprueba tu conexion.');
|
||||||
} finally {
|
} finally {
|
||||||
_cargandoBusqueda = false;
|
_cargandoBusqueda = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reproducir(Emisora emisora) async {
|
Future<void> cargarMasBusqueda() async {
|
||||||
final actual = audio.emisoraActual;
|
if (_cargandoBusqueda || _cargandoMasBusqueda || !_hayMasBusqueda) return;
|
||||||
final mismaEmisoraActiva = actual?.uuid == emisora.uuid;
|
_cargandoMasBusqueda = true;
|
||||||
final yaEstaConectandoOSonando =
|
|
||||||
_estadoReproduccion == EstadoReproduccion.cargando ||
|
|
||||||
_estadoReproduccion == EstadoReproduccion.reproduciendo ||
|
|
||||||
audio.estaSonando;
|
|
||||||
if (mismaEmisoraActiva && yaEstaConectandoOSonando) {
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
try {
|
||||||
|
final pagina = await radio.buscar(
|
||||||
|
nombre: _ultimoNombreBusqueda,
|
||||||
|
pais: _ultimoPaisBusqueda,
|
||||||
|
idioma: _ultimoIdiomaBusqueda,
|
||||||
|
tag: _ultimoTagBusqueda,
|
||||||
|
limit: _tamanoPaginaBusqueda,
|
||||||
|
offset: _offsetBusqueda,
|
||||||
|
);
|
||||||
|
final porUuid = <String, Emisora>{
|
||||||
|
for (final emisora in _resultadosBusqueda) emisora.uuid: emisora,
|
||||||
|
};
|
||||||
|
for (final emisora in pagina) {
|
||||||
|
porUuid[emisora.uuid] = emisora;
|
||||||
|
}
|
||||||
|
var nuevaLista = porUuid.values.toList();
|
||||||
|
if (nuevaLista.length > _maxResultadosBusquedaEnMemoria) {
|
||||||
|
nuevaLista = nuevaLista.sublist(
|
||||||
|
nuevaLista.length - _maxResultadosBusquedaEnMemoria,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_resultadosBusqueda = nuevaLista;
|
||||||
|
_offsetBusqueda += pagina.length;
|
||||||
|
_hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda;
|
||||||
|
} catch (_) {
|
||||||
|
_errorController.add('No se pudieron cargar mas emisoras.');
|
||||||
|
} finally {
|
||||||
|
_cargandoMasBusqueda = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> cargarEmisorasCercanas() async {
|
||||||
|
_cargandoCercanas = true;
|
||||||
|
_errorCercanas = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
var pais = PlatformDispatcher.instance.locale.countryCode;
|
||||||
|
final servicioActivo = await Geolocator.isLocationServiceEnabled();
|
||||||
|
if (servicioActivo) {
|
||||||
|
var permiso = await Geolocator.checkPermission();
|
||||||
|
if (permiso == LocationPermission.denied) {
|
||||||
|
permiso = await Geolocator.requestPermission();
|
||||||
|
}
|
||||||
|
if (permiso == LocationPermission.always ||
|
||||||
|
permiso == LocationPermission.whileInUse) {
|
||||||
|
final posicion = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.low,
|
||||||
|
timeLimit: Duration(seconds: 8),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final marcas = await placemarkFromCoordinates(
|
||||||
|
posicion.latitude,
|
||||||
|
posicion.longitude,
|
||||||
|
);
|
||||||
|
if (marcas.isNotEmpty) {
|
||||||
|
pais = marcas.first.isoCountryCode ?? pais;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pais == null || pais.isEmpty) {
|
||||||
|
throw Exception('No se pudo detectar tu region');
|
||||||
|
}
|
||||||
|
_paisCercanoDetectado = pais;
|
||||||
|
_emisorasCercanas = await radio.buscar(pais: pais, limit: 30);
|
||||||
|
} catch (_) {
|
||||||
|
_errorCercanas = 'No pudimos detectar emisoras cercanas. Usa filtros por pais.';
|
||||||
|
_emisorasCercanas = [];
|
||||||
|
} finally {
|
||||||
|
_cargandoCercanas = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reproducir(Emisora emisora) async {
|
||||||
try {
|
try {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await audio.reproducir(emisora);
|
await audio.reproducir(emisora);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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';
|
import 'reproducir_minimizado.dart';
|
||||||
|
|
||||||
const _paises = [
|
const _paises = [
|
||||||
('Espana', 'ES'),
|
('Espana', 'ES'),
|
||||||
@@ -106,6 +106,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_seccionCercanas(estado),
|
||||||
_seccionFiltro(
|
_seccionFiltro(
|
||||||
'Pais',
|
'Pais',
|
||||||
_paises.map((p) => (p.$1, p.$2)).toList(),
|
_paises.map((p) => (p.$1, p.$2)).toList(),
|
||||||
@@ -129,6 +130,76 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _seccionCercanas(EstadoRadio estado) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final pais = estado.paisCercanoDetectado;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: PluriGlassSurface(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
pais == null ? 'Emisoras cercanas' : 'Emisoras cercanas ? $pais',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: estado.cargandoCercanas
|
||||||
|
? null
|
||||||
|
: estado.cargarEmisorasCercanas,
|
||||||
|
icon: estado.cargandoCercanas
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.my_location_rounded, size: 18),
|
||||||
|
label: const Text('Buscar cerca'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (estado.errorCercanas != null)
|
||||||
|
Text(
|
||||||
|
estado.errorCercanas!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (estado.emisorasCercanas.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 76,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: estado.emisorasCercanas.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final emisora = estado.emisorasCercanas[i];
|
||||||
|
return SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: TarjetaEmisora(
|
||||||
|
emisora: emisora,
|
||||||
|
esCompacta: true,
|
||||||
|
onTap: () => reproducirMinimizado(context, emisora),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _seccionFiltro(
|
Widget _seccionFiltro(
|
||||||
String titulo,
|
String titulo,
|
||||||
List<(String, String)> opciones,
|
List<(String, String)> opciones,
|
||||||
@@ -197,13 +268,27 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
|
|||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
||||||
itemCount: resultados.length,
|
itemCount: resultados.length + (estado.hayMasBusqueda ? 1 : 0),
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
itemBuilder: (context, i) => TarjetaEmisora(
|
itemBuilder: (context, i) {
|
||||||
|
if (i >= resultados.length) {
|
||||||
|
if (!estado.cargandoMasBusqueda) {
|
||||||
|
Future<void>.microtask(estado.cargarMasBusqueda);
|
||||||
|
}
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(18),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (i >= resultados.length - 5 && estado.hayMasBusqueda) {
|
||||||
|
Future<void>.microtask(estado.cargarMasBusqueda);
|
||||||
|
}
|
||||||
|
return TarjetaEmisora(
|
||||||
emisora: resultados[i],
|
emisora: resultados[i],
|
||||||
esCompacta: true,
|
esCompacta: true,
|
||||||
onTap: () => reproducirYAbrir(context, resultados[i]),
|
onTap: () => reproducirMinimizado(context, resultados[i]),
|
||||||
).animate().fadeIn(delay: (i * 20).ms).slideY(begin: 0.08),
|
).animate().fadeIn(delay: (i.clamp(0, 12) * 20).ms).slideY(begin: 0.08);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ 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';
|
import 'reproducir_minimizado.dart';
|
||||||
|
|
||||||
class PantallaFavoritos extends StatelessWidget {
|
class PantallaFavoritos extends StatelessWidget {
|
||||||
const PantallaFavoritos({super.key});
|
const PantallaFavoritos({super.key});
|
||||||
@@ -87,7 +87,7 @@ class PantallaFavoritos extends StatelessWidget {
|
|||||||
key: Key(emisora.uuid),
|
key: Key(emisora.uuid),
|
||||||
emisora: emisora,
|
emisora: emisora,
|
||||||
esCompacta: true,
|
esCompacta: true,
|
||||||
onTap: () => reproducirYAbrir(context, emisora),
|
onTap: () => reproducirMinimizado(context, emisora),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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';
|
import 'reproducir_minimizado.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 {
|
||||||
@@ -49,6 +49,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(child: _heroHeader(context, estado)),
|
SliverToBoxAdapter(child: _heroHeader(context, estado)),
|
||||||
const SliverToBoxAdapter(child: _AuroraWaveBanner()),
|
const SliverToBoxAdapter(child: _AuroraWaveBanner()),
|
||||||
|
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)),
|
||||||
if (estado.error != null)
|
if (estado.error != null)
|
||||||
@@ -87,6 +88,75 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _seccionCercanas(EstadoRadio estado, ThemeData theme) {
|
||||||
|
final pais = estado.paisCercanoDetectado;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
child: PluriGlassSurface(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
pais == null ? 'Cerca de vos' : 'Cerca de vos ? $pais',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: estado.cargandoCercanas
|
||||||
|
? null
|
||||||
|
: estado.cargarEmisorasCercanas,
|
||||||
|
icon: estado.cargandoCercanas
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.my_location_rounded, size: 18),
|
||||||
|
label: const Text('Detectar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (estado.errorCercanas != null)
|
||||||
|
Text(
|
||||||
|
estado.errorCercanas!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (estado.emisorasCercanas.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 76,
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: estado.emisorasCercanas.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final emisora = estado.emisorasCercanas[i];
|
||||||
|
return SizedBox(
|
||||||
|
width: 260,
|
||||||
|
child: TarjetaEmisora(
|
||||||
|
emisora: emisora,
|
||||||
|
esCompacta: true,
|
||||||
|
onTap: () => reproducirMinimizado(context, emisora),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _seccionTendencias(EstadoRadio estado, ThemeData theme) {
|
Widget _seccionTendencias(EstadoRadio estado, ThemeData theme) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
@@ -120,7 +190,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
),
|
),
|
||||||
label: Text(e.nombre, maxLines: 1),
|
label: Text(e.nombre, maxLines: 1),
|
||||||
onPressed:
|
onPressed:
|
||||||
() => reproducirYAbrir(context, e),
|
() => reproducirMinimizado(context, e),
|
||||||
).animate().fadeIn(delay: (i * 50).ms);
|
).animate().fadeIn(delay: (i * 50).ms);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -227,7 +297,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
|
|||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, i) => TarjetaEmisora(
|
(context, i) => TarjetaEmisora(
|
||||||
emisora: emisoras[i],
|
emisora: emisoras[i],
|
||||||
onTap: () => reproducirYAbrir(context, emisoras[i]),
|
onTap: () => reproducirMinimizado(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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../estado/estado_radio.dart';
|
import '../estado/estado_radio.dart';
|
||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import 'pantalla_reproductor.dart';
|
|
||||||
|
|
||||||
Future<void> reproducirYAbrir(BuildContext context, Emisora emisora) async {
|
void reproducirMinimizado(BuildContext context, Emisora emisora) {
|
||||||
final estado = context.read<EstadoRadio>();
|
final estado = context.read<EstadoRadio>();
|
||||||
unawaited(estado.reproducir(emisora));
|
unawaited(estado.reproducir(emisora));
|
||||||
if (!context.mounted) return;
|
|
||||||
await PantallaReproductor.abrir(context, emisora);
|
|
||||||
}
|
}
|
||||||
@@ -106,6 +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();
|
||||||
|
|
||||||
PluriWaveAudioHandler() {
|
PluriWaveAudioHandler() {
|
||||||
_setupStreams();
|
_setupStreams();
|
||||||
@@ -213,12 +214,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> playMediaItem(MediaItem mediaItem) async {
|
Future<void> playMediaItem(MediaItem mediaItem) {
|
||||||
|
_colaReproduccion = _colaReproduccion
|
||||||
|
.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(
|
||||||
processingState: AudioProcessingState.loading,
|
processingState: AudioProcessingState.loading,
|
||||||
playing: false,
|
playing: false,
|
||||||
|
errorMessage: null,
|
||||||
));
|
));
|
||||||
try {
|
try {
|
||||||
await _player.stop();
|
await _player.stop();
|
||||||
|
|||||||
@@ -105,8 +105,13 @@ class ServicioRadio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Emisoras más votadas globalmente.
|
/// Emisoras más votadas globalmente.
|
||||||
Future<List<Emisora>> obtenerPopulares({int limit = 30}) async {
|
Future<List<Emisora>> obtenerPopulares({int limit = 30, int offset = 0}) async {
|
||||||
return _get('/json/stations/topvote/$limit', {});
|
return _get('/json/stations/search', {
|
||||||
|
'limit': limit.toString(),
|
||||||
|
'offset': offset.toString(),
|
||||||
|
'order': 'votes',
|
||||||
|
'reverse': 'true',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emisoras más escuchadas (por clicks) globalmente.
|
/// Emisoras más escuchadas (por clicks) globalmente.
|
||||||
@@ -115,37 +120,41 @@ class ServicioRadio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Buscar por nombre de emisora.
|
/// Buscar por nombre de emisora.
|
||||||
Future<List<Emisora>> buscarPorNombre(String query, {int limit = 30}) async {
|
Future<List<Emisora>> buscarPorNombre(String query, {int limit = 30, int offset = 0}) async {
|
||||||
return _get('/json/stations/search', {
|
return _get('/json/stations/search', {
|
||||||
'name': query,
|
'name': query,
|
||||||
'limit': limit.toString(),
|
'limit': limit.toString(),
|
||||||
|
'offset': offset.toString(),
|
||||||
'order': 'votes',
|
'order': 'votes',
|
||||||
'reverse': 'true',
|
'reverse': 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US').
|
/// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US').
|
||||||
Future<List<Emisora>> buscarPorPais(String codigoPais, {int limit = 50}) async {
|
Future<List<Emisora>> buscarPorPais(String codigoPais, {int limit = 50, int offset = 0}) async {
|
||||||
return _get('/json/stations/bycountrycodeexact/$codigoPais', {
|
return _get('/json/stations/bycountrycodeexact/$codigoPais', {
|
||||||
'limit': limit.toString(),
|
'limit': limit.toString(),
|
||||||
|
'offset': offset.toString(),
|
||||||
'order': 'votes',
|
'order': 'votes',
|
||||||
'reverse': 'true',
|
'reverse': 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buscar por idioma (e.g. 'spanish', 'english').
|
/// Buscar por idioma (e.g. 'spanish', 'english').
|
||||||
Future<List<Emisora>> buscarPorIdioma(String idioma, {int limit = 30}) async {
|
Future<List<Emisora>> buscarPorIdioma(String idioma, {int limit = 30, int offset = 0}) async {
|
||||||
return _get('/json/stations/bylanguageexact/$idioma', {
|
return _get('/json/stations/bylanguageexact/$idioma', {
|
||||||
'limit': limit.toString(),
|
'limit': limit.toString(),
|
||||||
|
'offset': offset.toString(),
|
||||||
'order': 'votes',
|
'order': 'votes',
|
||||||
'reverse': 'true',
|
'reverse': 'true',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop').
|
/// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop').
|
||||||
Future<List<Emisora>> buscarPorTag(String tag, {int limit = 30}) async {
|
Future<List<Emisora>> buscarPorTag(String tag, {int limit = 30, int offset = 0}) async {
|
||||||
return _get('/json/stations/bytagexact/$tag', {
|
return _get('/json/stations/bytagexact/$tag', {
|
||||||
'limit': limit.toString(),
|
'limit': limit.toString(),
|
||||||
|
'offset': offset.toString(),
|
||||||
'order': 'votes',
|
'order': 'votes',
|
||||||
'reverse': 'true',
|
'reverse': 'true',
|
||||||
});
|
});
|
||||||
@@ -158,6 +167,7 @@ class ServicioRadio {
|
|||||||
String? idioma,
|
String? idioma,
|
||||||
String? tag,
|
String? tag,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
|
int offset = 0,
|
||||||
}) async {
|
}) async {
|
||||||
return _get('/json/stations/search', {
|
return _get('/json/stations/search', {
|
||||||
if (nombre != null && nombre.isNotEmpty) 'name': nombre,
|
if (nombre != null && nombre.isNotEmpty) 'name': nombre,
|
||||||
@@ -165,6 +175,7 @@ class ServicioRadio {
|
|||||||
if (idioma != null && idioma.isNotEmpty) 'language': idioma,
|
if (idioma != null && idioma.isNotEmpty) 'language': idioma,
|
||||||
if (tag != null && tag.isNotEmpty) 'tag': tag,
|
if (tag != null && tag.isNotEmpty) 'tag': tag,
|
||||||
'limit': limit.toString(),
|
'limit': limit.toString(),
|
||||||
|
'offset': offset.toString(),
|
||||||
'order': 'votes',
|
'order': 'votes',
|
||||||
'reverse': 'true',
|
'reverse': 'true',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -229,6 +229,86 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
geocoding:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: geocoding
|
||||||
|
sha256: d580c801cba9386b4fac5047c4c785a4e19554f46be42f4f5e5b7deacd088a66
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
geocoding_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geocoding_android
|
||||||
|
sha256: "1b13eca79b11c497c434678fed109c2be020b158cec7512c848c102bc7232603"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.3.1"
|
||||||
|
geocoding_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geocoding_ios
|
||||||
|
sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
|
geocoding_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geocoding_platform_interface
|
||||||
|
sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.0"
|
||||||
|
geolocator:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: geolocator
|
||||||
|
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.0.4"
|
||||||
|
geolocator_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_android
|
||||||
|
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.2"
|
||||||
|
geolocator_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_apple
|
||||||
|
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.13"
|
||||||
|
geolocator_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_platform_interface
|
||||||
|
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.6"
|
||||||
|
geolocator_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_web
|
||||||
|
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.3"
|
||||||
|
geolocator_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_windows
|
||||||
|
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.5"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ dependencies:
|
|||||||
file_picker: ^8.1.7
|
file_picker: ^8.1.7
|
||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
|
geolocator: ^13.0.4
|
||||||
|
geocoding: ^3.0.0
|
||||||
|
|
||||||
# Ads (activar cuando tengamos Ad Unit IDs)
|
# Ads (activar cuando tengamos Ad Unit IDs)
|
||||||
# google_mobile_ads: ^5.3.0
|
# google_mobile_ads: ^5.3.0
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ void main() {
|
|||||||
expect(notificaciones, greaterThan(antes));
|
expect(notificaciones, greaterThan(antes));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reproducir la misma emisora mientras suena no reinicia el stream', () async {
|
test('reproducir la misma emisora mientras suena fuerza recarga del stream', () async {
|
||||||
final audio = FakeServicioAudio();
|
final audio = FakeServicioAudio();
|
||||||
final emisora = emisoraDemo(uuid: 'same-1', nombre: 'Misma');
|
final emisora = emisoraDemo(uuid: 'same-1', nombre: 'Misma');
|
||||||
final estado = EstadoRadio(
|
final estado = EstadoRadio(
|
||||||
@@ -213,7 +213,7 @@ void main() {
|
|||||||
await estado.reproducir(emisora);
|
await estado.reproducir(emisora);
|
||||||
await estado.reproducir(emisora);
|
await estado.reproducir(emisora);
|
||||||
|
|
||||||
expect(audio.emisorasReproducidas, hasLength(1));
|
expect(audio.emisorasReproducidas, hasLength(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reordenar favoritos reindexa de forma determinística', () async {
|
test('reordenar favoritos reindexa de forma determinística', () async {
|
||||||
@@ -229,6 +229,35 @@ void main() {
|
|||||||
expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2]));
|
expect(lista.map((e) => e.orden).toList(), equals([0, 1, 2]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test('cargarMasBusqueda pagina resultados y acota memoria', () async {
|
||||||
|
final emisoras = List.generate(
|
||||||
|
70,
|
||||||
|
(i) => emisoraDemo(uuid: 'page-$i', nombre: 'Page $i'),
|
||||||
|
);
|
||||||
|
final estado = EstadoRadio(
|
||||||
|
audio: FakeServicioAudio(),
|
||||||
|
favoritos: FakeServicioFavoritos(),
|
||||||
|
radio: FakeServicioRadio(busqueda: emisoras),
|
||||||
|
servicioEcualizador: FakeServicioEcualizador(),
|
||||||
|
resolverArchivoCustom: _archivoCustomVacio,
|
||||||
|
iniciarAutomaticamente: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await estado.inicializar();
|
||||||
|
await estado.buscar(nombre: 'page');
|
||||||
|
expect(estado.resultadosBusqueda, hasLength(30));
|
||||||
|
expect(estado.hayMasBusqueda, isTrue);
|
||||||
|
|
||||||
|
await estado.cargarMasBusqueda();
|
||||||
|
expect(estado.resultadosBusqueda, hasLength(60));
|
||||||
|
|
||||||
|
await estado.cargarMasBusqueda();
|
||||||
|
expect(estado.resultadosBusqueda, hasLength(70));
|
||||||
|
expect(estado.hayMasBusqueda, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
test('toggleFavorito refresca lista global y evita estado stale', () async {
|
test('toggleFavorito refresca lista global y evita estado stale', () async {
|
||||||
final favoritos = FakeServicioFavoritos();
|
final favoritos = FakeServicioFavoritos();
|
||||||
final emisora = emisoraDemo(uuid: 'fav-sync', nombre: 'Sync');
|
final emisora = emisoraDemo(uuid: 'fav-sync', nombre: 'Sync');
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ class FakeServicioRadio extends ServicioRadio {
|
|||||||
error is Exception ? error : Exception(error.toString());
|
error is Exception ? error : Exception(error.toString());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Emisora>> obtenerPopulares({int limit = 30}) async {
|
Future<List<Emisora>> obtenerPopulares({int limit = 30, int offset = 0}) async {
|
||||||
final llamada = obtenerPopularesCalls++;
|
final llamada = obtenerPopularesCalls++;
|
||||||
if (llamada < _erroresPopularesPorLlamada.length) {
|
if (llamada < _erroresPopularesPorLlamada.length) {
|
||||||
throw _normalizarError(_erroresPopularesPorLlamada[llamada]);
|
throw _normalizarError(_erroresPopularesPorLlamada[llamada]);
|
||||||
@@ -177,8 +177,9 @@ class FakeServicioRadio extends ServicioRadio {
|
|||||||
String? idioma,
|
String? idioma,
|
||||||
String? tag,
|
String? tag,
|
||||||
int limit = 30,
|
int limit = 30,
|
||||||
|
int offset = 0,
|
||||||
}) async {
|
}) async {
|
||||||
return _busqueda.take(limit).toList();
|
return _busqueda.skip(offset).take(limit).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user