feat(radio): add nearby discovery and paged search
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m34s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s

This commit is contained in:
2026-05-20 23:22:15 +02:00
parent f888153aa9
commit 7fcd0f544e
13 changed files with 428 additions and 40 deletions
+2
View File
@@ -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"
+2
View File
@@ -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
View File
@@ -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 = notifyListeners();
_estadoReproduccion == EstadoReproduccion.cargando || try {
_estadoReproduccion == EstadoReproduccion.reproduciendo || final pagina = await radio.buscar(
audio.estaSonando; nombre: _ultimoNombreBusqueda,
if (mismaEmisoraActiva && yaEstaConectandoOSonando) { 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(); notifyListeners();
return;
} }
}
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);
+92 -7
View File
@@ -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) {
emisora: resultados[i], if (i >= resultados.length) {
esCompacta: true, if (!estado.cargandoMasBusqueda) {
onTap: () => reproducirYAbrir(context, resultados[i]), Future<void>.microtask(estado.cargarMasBusqueda);
).animate().fadeIn(delay: (i * 20).ms).slideY(begin: 0.08), }
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],
esCompacta: true,
onTap: () => reproducirMinimizado(context, resultados[i]),
).animate().fadeIn(delay: (i.clamp(0, 12) * 20).ms).slideY(begin: 0.08);
},
); );
} }
} }
+2 -2
View File
@@ -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(
+73 -3
View File
@@ -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);
} }
+10 -1
View File
@@ -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();
+17 -6
View File
@@ -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',
}); });
+80
View File
@@ -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:
+2
View File
@@ -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
+31 -2
View File
@@ -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');
+3 -2
View File
@@ -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