feat(player): add radio recording and real waveform
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m27s

This commit is contained in:
2026-05-21 21:17:51 +02:00
parent 6aa9a59d7b
commit a6a91af402
12 changed files with 1518 additions and 286 deletions
+66 -34
View File
@@ -22,7 +22,10 @@ void registrarHandler(PluriWaveAudioHandler handler) {
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
assert(_handlerGlobal != null, 'registrarHandler() no fue llamado en main.dart');
assert(
_handlerGlobal != null,
'registrarHandler() no fue llamado en main.dart',
);
return _handlerGlobal!;
}
@@ -50,9 +53,10 @@ class ServicioAudio {
title: emisora.nombre,
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri: emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
artUri:
emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
extras: {'uuid': emisora.uuid},
);
await _handler.playMediaItem(item);
@@ -73,6 +77,8 @@ class ServicioAudio {
Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
double get volumen => _handler.volumen;
bool get estaSonando => _handler.playbackState.value.playing;
Stream<int?> get androidAudioSessionIdStream =>
_handler.androidAudioSessionIdStream;
Future<void> dispose() async {}
// ── Ecualizador ───────────────────────────────────────────────────────────
@@ -83,8 +89,7 @@ class ServicioAudio {
Future<void> aplicarPreset(PresetEcualizador preset) =>
_handler.aplicarPreset(preset);
Future<void> setBanda(int index, double db) =>
_handler.setBanda(index, db);
Future<void> setBanda(int index, double db) => _handler.setBanda(index, db);
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -99,6 +104,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub;
StreamSubscription<PlaybackEvent>? _eventosSub;
StreamSubscription<int?>? _androidAudioSessionIdSub;
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
Future<void> _colaCambioFuente = Future<void>.value();
int _revisionFuente = 0;
@@ -112,6 +119,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
Stream<int?> get androidAudioSessionIdStream =>
_androidAudioSessionIdController.stream;
PluriWaveAudioHandler() {
_conectarStreamsPlayer();
@@ -127,18 +136,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_estadoPlayerSub = _player.playerStateStream.listen((state) {
final playing = state.playing;
final proc = state.processingState;
playbackState.add(playbackState.value.copyWith(
controls: [
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
],
systemActions: const {MediaAction.seek, MediaAction.stop},
androidCompactActionIndices: const [0],
processingState: _mapProcState(proc),
playing: playing,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
));
playbackState.add(
playbackState.value.copyWith(
controls: [
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
],
systemActions: const {MediaAction.seek, MediaAction.stop},
androidCompactActionIndices: const [0],
processingState: _mapProcState(proc),
playing: playing,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
});
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
@@ -151,6 +162,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_gestionarErrorReproduccion(error);
},
);
_androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen((
sessionId,
) {
if (!_androidAudioSessionIdController.isClosed) {
_androidAudioSessionIdController.add(sessionId);
}
});
}
/// Gestiona cualquier error de reproducción de ExoPlayer.
@@ -172,11 +191,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
level: 900,
);
playbackState.add(playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorMessage: mensaje,
));
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorMessage: mensaje,
),
);
emisoraActual = null;
mediaItem.add(null);
@@ -236,11 +257,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async {
this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
playbackState.add(playbackState.value.copyWith(
processingState: AudioProcessingState.loading,
playing: false,
errorMessage: null,
));
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.loading,
playing: false,
errorMessage: null,
),
);
try {
await _recrearPlayer();
if (revision != _revisionFuente) return;
@@ -263,11 +286,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
stackTrace: stackTrace,
);
if (revision == _revisionFuente) {
playbackState.add(playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorMessage: 'Error inesperado al reproducir',
));
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorMessage: 'Error inesperado al reproducir',
),
);
emisoraActual = null;
this.mediaItem.add(null);
}
@@ -279,6 +304,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
final anterior = _player;
try {
@@ -330,7 +356,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
if (!_eqDisponible) return;
try {
final params = await _eq.parameters;
for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) {
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(preset.bandas[i]);
}
} catch (_) {}
@@ -381,7 +411,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
await _player.dispose();
await _androidAudioSessionIdController.close();
}
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
+313
View File
@@ -0,0 +1,313 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/emisora.dart';
enum EstadoGrabacionRadioTipo {
inactiva,
preparando,
grabando,
deteniendo,
error,
}
class EstadoGrabacionRadio {
const EstadoGrabacionRadio({
required this.tipo,
this.emisora,
this.archivo,
this.bytes = 0,
this.inicio,
this.duracionObjetivo,
this.error,
});
const EstadoGrabacionRadio.inactiva()
: tipo = EstadoGrabacionRadioTipo.inactiva,
emisora = null,
archivo = null,
bytes = 0,
inicio = null,
duracionObjetivo = null,
error = null;
final EstadoGrabacionRadioTipo tipo;
final Emisora? emisora;
final File? archivo;
final int bytes;
final DateTime? inicio;
final Duration? duracionObjetivo;
final String? error;
bool get activa =>
tipo == EstadoGrabacionRadioTipo.preparando ||
tipo == EstadoGrabacionRadioTipo.grabando ||
tipo == EstadoGrabacionRadioTipo.deteniendo;
Duration get transcurrido {
final inicioLocal = inicio;
if (inicioLocal == null) return Duration.zero;
return DateTime.now().difference(inicioLocal);
}
EstadoGrabacionRadio copyWith({
EstadoGrabacionRadioTipo? tipo,
Emisora? emisora,
File? archivo,
int? bytes,
DateTime? inicio,
Duration? duracionObjetivo,
String? error,
}) {
return EstadoGrabacionRadio(
tipo: tipo ?? this.tipo,
emisora: emisora ?? this.emisora,
archivo: archivo ?? this.archivo,
bytes: bytes ?? this.bytes,
inicio: inicio ?? this.inicio,
duracionObjetivo: duracionObjetivo ?? this.duracionObjetivo,
error: error,
);
}
}
class ServicioGrabacionRadio {
ServicioGrabacionRadio({
http.Client? cliente,
Future<Directory> Function()? resolverDirectorioBase,
DateTime Function()? reloj,
}) : _clienteExterno = cliente,
_resolverDirectorioBase = resolverDirectorioBase,
_reloj = reloj ?? DateTime.now;
static const _claveDirectorio = 'grabacion_radio_directorio';
final http.Client? _clienteExterno;
final Future<Directory> Function()? _resolverDirectorioBase;
final DateTime Function() _reloj;
final _estadoController = StreamController<EstadoGrabacionRadio>.broadcast();
EstadoGrabacionRadio _estado = const EstadoGrabacionRadio.inactiva();
StreamSubscription<List<int>>? _subscripcionStream;
IOSink? _sink;
http.Client? _clienteActivo;
Timer? _timerAutoStop;
String? _directorioConfigurado;
EstadoGrabacionRadio get estado => _estado;
Stream<EstadoGrabacionRadio> get estadoStream => _estadoController.stream;
String? get directorioConfigurado => _directorioConfigurado;
Future<void> inicializar() async {
try {
final prefs = await SharedPreferences.getInstance();
_directorioConfigurado = prefs.getString(_claveDirectorio);
} catch (_) {
_directorioConfigurado = null;
}
}
Future<String> directorioEfectivo() async {
final configurado = _directorioConfigurado;
if (configurado != null && configurado.trim().isNotEmpty) {
return configurado;
}
final base =
_resolverDirectorioBase != null
? await _resolverDirectorioBase()
: await getApplicationDocumentsDirectory();
return '${base.path}${Platform.pathSeparator}grabaciones';
}
Future<void> guardarDirectorio(String path) async {
final normalizado = path.trim();
if (normalizado.isEmpty) {
throw ArgumentError('La ruta de grabación no puede estar vacía');
}
_directorioConfigurado = normalizado;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_claveDirectorio, normalizado);
} catch (_) {}
_emitir(_estado);
}
Future<void> limpiarDirectorioConfigurado() async {
_directorioConfigurado = null;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_claveDirectorio);
} catch (_) {}
_emitir(_estado);
}
Future<void> iniciar(
Emisora emisora, {
Duration? duracion,
String? directorio,
}) async {
if (_estado.activa) {
throw StateError('Ya hay una grabación en curso');
}
final inicio = _reloj();
_emitir(
EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.preparando,
emisora: emisora,
inicio: inicio,
duracionObjetivo: duracion,
),
);
try {
final carpeta = Directory(directorio ?? await directorioEfectivo());
await carpeta.create(recursive: true);
final cliente = _clienteExterno ?? http.Client();
_clienteActivo = cliente;
final request = http.Request('GET', Uri.parse(emisora.url))
..headers['User-Agent'] = 'PluriWave/0.1.0 (radio recorder)';
final response = await cliente.send(request);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException('HTTP ${response.statusCode}', uri: request.url);
}
final archivo = File(
'${carpeta.path}${Platform.pathSeparator}'
'${_nombreArchivo(emisora, inicio, response.headers)}',
);
_sink = archivo.openWrite();
_emitir(
EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.grabando,
emisora: emisora,
archivo: archivo,
inicio: inicio,
duracionObjetivo: duracion,
),
);
if (duracion != null && duracion > Duration.zero) {
_timerAutoStop = Timer(duracion, () => unawaited(detener()));
}
_subscripcionStream = response.stream.listen(
(chunk) {
_sink?.add(chunk);
_emitir(_estado.copyWith(bytes: _estado.bytes + chunk.length));
},
onDone: () => unawaited(_finalizar()),
onError: (Object error) => unawaited(_fallar(error)),
cancelOnError: true,
);
} catch (error) {
await _fallar(error);
rethrow;
}
}
Future<void> detener() async {
if (!_estado.activa) return;
_emitir(_estado.copyWith(tipo: EstadoGrabacionRadioTipo.deteniendo));
_timerAutoStop?.cancel();
_timerAutoStop = null;
_clienteActivo?.close();
await _subscripcionStream?.cancel();
await _finalizar();
}
Future<void> _finalizar() async {
_timerAutoStop?.cancel();
_timerAutoStop = null;
await _subscripcionStream?.cancel();
_subscripcionStream = null;
await _sink?.flush();
await _sink?.close();
_sink = null;
if (_clienteExterno == null) {
_clienteActivo?.close();
}
_clienteActivo = null;
_emitir(const EstadoGrabacionRadio.inactiva());
}
Future<void> _fallar(Object error) async {
_timerAutoStop?.cancel();
_timerAutoStop = null;
await _subscripcionStream?.cancel();
_subscripcionStream = null;
try {
await _sink?.flush();
await _sink?.close();
} catch (_) {}
_sink = null;
if (_clienteExterno == null) {
_clienteActivo?.close();
}
_clienteActivo = null;
_emitir(
EstadoGrabacionRadio(
tipo: EstadoGrabacionRadioTipo.error,
error: error.toString(),
),
);
}
void _emitir(EstadoGrabacionRadio estado) {
_estado = estado;
if (!_estadoController.isClosed) {
_estadoController.add(estado);
}
}
String _nombreArchivo(
Emisora emisora,
DateTime inicio,
Map<String, String> headers,
) {
final fecha = inicio
.toIso8601String()
.replaceAll(':', '-')
.replaceAll('.', '-');
final nombre = _slug(emisora.nombre);
final extension = _extension(emisora.codec, headers['content-type']);
return '$fecha-$nombre.$extension';
}
String _extension(String? codec, String? contentType) {
final c = codec?.toLowerCase().trim();
if (c == 'mp3' || c == 'mpeg') return 'mp3';
if (c == 'aac' || c == 'aac+' || c == 'heaac') return 'aac';
if (c == 'ogg' || c == 'opus') return 'ogg';
if (c == 'flac') return 'flac';
final type = contentType?.toLowerCase() ?? '';
if (type.contains('mpeg') || type.contains('mp3')) return 'mp3';
if (type.contains('aac')) return 'aac';
if (type.contains('ogg') || type.contains('opus')) return 'ogg';
if (type.contains('flac')) return 'flac';
return 'mp3';
}
String _slug(String value) {
final slug = value
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9áéíóúüñ]+', unicode: true), '-')
.replaceAll(RegExp('-+'), '-')
.replaceAll(RegExp(r'^-|-$'), '');
return slug.isEmpty ? 'radio' : slug;
}
Future<void> dispose() async {
_timerAutoStop?.cancel();
_clienteActivo?.close();
await _subscripcionStream?.cancel();
await _sink?.close();
await _estadoController.close();
}
}
+79 -39
View File
@@ -26,13 +26,14 @@ class ServicioRadio {
int maxIntentos = _maxIntentosPorDefecto,
Duration retryDelay = _retryDelayPorDefecto,
Duration timeout = _timeoutPorDefecto,
}) : _cliente = cliente ?? http.Client(),
_servidores = (servidores == null || servidores.isEmpty)
? List<String>.from(_servidoresFallback)
: List<String>.from(servidores),
_maxIntentos = maxIntentos < 1 ? 1 : maxIntentos,
_retryDelay = retryDelay,
_timeout = timeout;
}) : _cliente = cliente ?? http.Client(),
_servidores =
(servidores == null || servidores.isEmpty)
? List<String>.from(_servidoresFallback)
: List<String>.from(servidores),
_maxIntentos = maxIntentos < 1 ? 1 : maxIntentos,
_retryDelay = retryDelay,
_timeout = timeout;
final http.Client _cliente;
final List<String> _servidores;
@@ -56,10 +57,7 @@ class ServicioRadio {
}
Uri _uri(String servidor, String path, Map<String, String> params) {
return Uri.https(servidor, path, {
'hidebroken': 'true',
...params,
});
return Uri.https(servidor, path, {'hidebroken': 'true', ...params});
}
Future<List<Emisora>> _get(String path, Map<String, String> params) async {
@@ -69,15 +67,17 @@ class ServicioRadio {
for (int intento = 0; intento < totalIntentos; intento++) {
final servidor = _servidorPorIntento(indiceBase, intento);
final uri = _uri(servidor, path, {
'lastcheckok': '1',
...params,
});
final uri = _uri(servidor, path, {'lastcheckok': '1', ...params});
try {
final resp = await _cliente.get(uri, headers: {
'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
}).timeout(_timeout);
final resp = await _cliente
.get(
uri,
headers: {
'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
},
)
.timeout(_timeout);
if (resp.statusCode != 200) {
throw Exception('API error ${resp.statusCode}');
@@ -85,11 +85,14 @@ class ServicioRadio {
final lista = json.decode(resp.body) as List<dynamic>;
_servidorActual = servidor;
return lista
.cast<Map<String, dynamic>>()
.map(Emisora.fromApi)
.where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty)
.toList();
final emisoras =
lista
.cast<Map<String, dynamic>>()
.map(Emisora.fromApi)
.where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty)
.toList();
emisoras.sort(_compararCalidad);
return emisoras;
} on Exception catch (e) {
ultimoError = e;
_servidorActual = null;
@@ -105,57 +108,78 @@ class ServicioRadio {
}
/// Emisoras más votadas globalmente.
Future<List<Emisora>> obtenerPopulares({int limit = 30, int offset = 0}) async {
Future<List<Emisora>> obtenerPopulares({
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/search', {
'limit': limit.toString(),
'offset': offset.toString(),
'order': 'votes',
'order': 'bitrate',
'reverse': 'true',
});
}
/// Emisoras más escuchadas (por clicks) globalmente.
Future<List<Emisora>> obtenerTendencias({int limit = 20}) async {
return _get('/json/stations/topclick/$limit', {});
final emisoras = await _get('/json/stations/topclick/$limit', {});
emisoras.sort(_compararCalidad);
return emisoras;
}
/// Buscar por nombre de emisora.
Future<List<Emisora>> buscarPorNombre(String query, {int limit = 30, int offset = 0}) async {
Future<List<Emisora>> buscarPorNombre(
String query, {
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/search', {
'name': query,
'limit': limit.toString(),
'offset': offset.toString(),
'order': 'votes',
'order': 'bitrate',
'reverse': 'true',
});
}
/// 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, int offset = 0}) async {
Future<List<Emisora>> buscarPorPais(
String codigoPais, {
int limit = 50,
int offset = 0,
}) async {
return _get('/json/stations/bycountrycodeexact/$codigoPais', {
'limit': limit.toString(),
'offset': offset.toString(),
'order': 'votes',
'order': 'bitrate',
'reverse': 'true',
});
}
/// Buscar por idioma (e.g. 'spanish', 'english').
Future<List<Emisora>> buscarPorIdioma(String idioma, {int limit = 30, int offset = 0}) async {
Future<List<Emisora>> buscarPorIdioma(
String idioma, {
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/bylanguageexact/$idioma', {
'limit': limit.toString(),
'offset': offset.toString(),
'order': 'votes',
'order': 'bitrate',
'reverse': 'true',
});
}
/// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop').
Future<List<Emisora>> buscarPorTag(String tag, {int limit = 30, int offset = 0}) async {
Future<List<Emisora>> buscarPorTag(
String tag, {
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/bytagexact/$tag', {
'limit': limit.toString(),
'offset': offset.toString(),
'order': 'votes',
'order': 'bitrate',
'reverse': 'true',
});
}
@@ -176,20 +200,36 @@ class ServicioRadio {
if (tag != null && tag.isNotEmpty) 'tag': tag,
'limit': limit.toString(),
'offset': offset.toString(),
'order': 'votes',
'order': 'bitrate',
'reverse': 'true',
});
}
int _compararCalidad(Emisora a, Emisora b) {
final bitrateA = a.bitrate ?? 0;
final bitrateB = b.bitrate ?? 0;
final porBitrate = bitrateB.compareTo(bitrateA);
if (porBitrate != 0) return porBitrate;
final porClicks = b.clickcount.compareTo(a.clickcount);
if (porClicks != 0) return porClicks;
return b.votes.compareTo(a.votes);
}
/// Registrar un click en la API (best effort).
Future<void> registrarClick(String uuid) async {
try {
final servidor =
_servidorActual ?? _servidorPorIntento(_indiceServidorInicial(), 0);
await _cliente.get(
Uri.https(servidor, '/json/url/$uuid'),
headers: {'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)'},
).timeout(_timeout);
await _cliente
.get(
Uri.https(servidor, '/json/url/$uuid'),
headers: {
'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
},
)
.timeout(_timeout);
} catch (_) {
// No crítico, ignorar.
}