- Added state management for startup retry and custom station handling in `EstadoRadio`. - Created tasks for implementing strict TDD with RED tests for HTTP failure retries and EQ persistence. - Developed verification report to ensure compliance with TDD practices. - Introduced fake services for testing, including `FakeServicioAudio`, `FakeServicioFavoritos`, and `FakeServicioRadio`. - Implemented widget tests for `PantallaInicio` and `PantallaFavoritos` to validate UI behavior with custom stations. - Enhanced `ServicioRadio` to support host rotation and retry logic for API calls. - Established a new configuration file to enforce project constraints and testing rules.
324 lines
10 KiB
Dart
324 lines
10 KiB
Dart
import 'dart:developer' as developer;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/preset_ecualizador.dart';
|
|
|
|
/// Estado de reproducción expuesto al UI.
|
|
enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error }
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Handler global — inicializado en main.dart con AudioService.init
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
PluriWaveAudioHandler? _handlerGlobal;
|
|
|
|
void registrarHandler(PluriWaveAudioHandler handler) {
|
|
_handlerGlobal = handler;
|
|
}
|
|
|
|
/// Wrapper de alto nivel para el UI.
|
|
class ServicioAudio {
|
|
PluriWaveAudioHandler get _handler {
|
|
assert(_handlerGlobal != null, 'registrarHandler() no fue llamado en main.dart');
|
|
return _handlerGlobal!;
|
|
}
|
|
|
|
Emisora? get emisoraActual => _handler.emisoraActual;
|
|
|
|
Stream<EstadoReproduccion> get estadoStream =>
|
|
_handler.playbackState.map((s) {
|
|
if (s.processingState == AudioProcessingState.error) {
|
|
return EstadoReproduccion.error;
|
|
}
|
|
if (s.processingState == AudioProcessingState.loading ||
|
|
s.processingState == AudioProcessingState.buffering) {
|
|
return EstadoReproduccion.cargando;
|
|
}
|
|
if (s.playing) return EstadoReproduccion.reproduciendo;
|
|
if (s.processingState == AudioProcessingState.idle) {
|
|
return EstadoReproduccion.detenido;
|
|
}
|
|
return EstadoReproduccion.pausado;
|
|
});
|
|
|
|
Future<void> reproducir(Emisora emisora) async {
|
|
final item = MediaItem(
|
|
id: emisora.url,
|
|
title: emisora.nombre,
|
|
artist: emisora.pais ?? '',
|
|
album: 'PluriWave',
|
|
artUri: emisora.favicon != null && emisora.favicon!.isNotEmpty
|
|
? Uri.tryParse(emisora.favicon!)
|
|
: null,
|
|
extras: {'uuid': emisora.uuid},
|
|
);
|
|
await _handler.playMediaItem(item);
|
|
}
|
|
|
|
Future<void> pausar() => _handler.pause();
|
|
Future<void> reanudar() => _handler.play();
|
|
|
|
Future<void> togglePlay() async {
|
|
if (_handler.playbackState.value.playing) {
|
|
await pausar();
|
|
} else {
|
|
await reanudar();
|
|
}
|
|
}
|
|
|
|
Future<void> detener() => _handler.stop();
|
|
Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
|
|
double get volumen => _handler.volumen;
|
|
bool get estaSonando => _handler.playbackState.value.playing;
|
|
Future<void> dispose() async {}
|
|
|
|
// ── Ecualizador ───────────────────────────────────────────────────────────
|
|
AndroidEqualizer? get ecualizador => _handler.ecualizador;
|
|
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
|
|
PresetEcualizador get presetActual => _handler.presetActual;
|
|
|
|
Future<void> aplicarPreset(PresetEcualizador preset) =>
|
|
_handler.aplicarPreset(preset);
|
|
|
|
Future<void> setBanda(int index, double db) =>
|
|
_handler.setBanda(index, db);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AudioHandler
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
|
final AndroidEqualizer _eq = AndroidEqualizer();
|
|
|
|
late final AudioPlayer _player = AudioPlayer(
|
|
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
|
|
);
|
|
|
|
Emisora? emisoraActual;
|
|
double _volumen = 1.0;
|
|
double get volumen => _volumen;
|
|
|
|
AndroidEqualizer? get ecualizador => _eq;
|
|
bool _eqDisponible = false;
|
|
bool get ecualizadorDisponible => _eqDisponible;
|
|
|
|
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
|
PresetEcualizador get presetActual => _presetActual;
|
|
|
|
PluriWaveAudioHandler() {
|
|
_setupStreams();
|
|
}
|
|
|
|
void _setupStreams() {
|
|
_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,
|
|
));
|
|
});
|
|
|
|
_player.bufferedPositionStream.listen((pos) {
|
|
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos));
|
|
});
|
|
|
|
_player.playbackEventStream.listen(
|
|
(_) {},
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
_gestionarErrorReproduccion(error);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Gestiona cualquier error de reproducción de ExoPlayer.
|
|
void _gestionarErrorReproduccion(Object error) {
|
|
String mensaje;
|
|
String codigoLog;
|
|
|
|
if (error is PlayerException) {
|
|
codigoLog = 'PlayerException(code=${error.code}): ${error.message}';
|
|
mensaje = _mensajeAmigable(error);
|
|
} else {
|
|
codigoLog = 'Error desconocido: $error';
|
|
mensaje = 'Error de reproducción';
|
|
}
|
|
|
|
developer.log(
|
|
'[PluriWave] Error reproducción: $codigoLog',
|
|
name: 'ServicioAudio',
|
|
level: 900,
|
|
);
|
|
|
|
playbackState.add(playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.error,
|
|
playing: false,
|
|
errorMessage: mensaje,
|
|
));
|
|
emisoraActual = null;
|
|
mediaItem.add(null);
|
|
|
|
_player.stop().catchError((_) {});
|
|
}
|
|
|
|
/// Traduce códigos de error de ExoPlayer a mensajes para el usuario.
|
|
String _mensajeAmigable(PlayerException e) {
|
|
final code = e.code;
|
|
|
|
if (code >= 2000 && code < 3000) {
|
|
if (code == 2001) return 'Sin conexión a internet';
|
|
if (code == 2002) return 'La URL de la radio no es válida';
|
|
if (code == 2003) return 'La radio no está disponible (error 404)';
|
|
if (code == 2004) return 'Tiempo de espera agotado al conectar';
|
|
return 'No se puede conectar a la radio';
|
|
}
|
|
|
|
if (code >= 3000 && code < 4000) {
|
|
return 'Formato de stream no compatible';
|
|
}
|
|
|
|
if (code >= 4000 && code < 5000) {
|
|
return 'Error al decodificar el stream de audio';
|
|
}
|
|
|
|
final msg = e.message ?? '';
|
|
if (msg.contains('Cleartext') || msg.contains('cleartext')) {
|
|
return 'Esta radio usa HTTP sin cifrar (no permitido)';
|
|
}
|
|
if (msg.contains('CERTIFICATE') || msg.contains('HandshakeException')) {
|
|
return 'Certificado SSL inválido en la radio';
|
|
}
|
|
|
|
return 'No se puede reproducir esta radio';
|
|
}
|
|
|
|
AudioProcessingState _mapProcState(ProcessingState state) {
|
|
return switch (state) {
|
|
ProcessingState.idle => AudioProcessingState.idle,
|
|
ProcessingState.loading => AudioProcessingState.loading,
|
|
ProcessingState.buffering => AudioProcessingState.buffering,
|
|
ProcessingState.ready => AudioProcessingState.ready,
|
|
ProcessingState.completed => AudioProcessingState.completed,
|
|
};
|
|
}
|
|
|
|
@override
|
|
Future<void> playMediaItem(MediaItem mediaItem) async {
|
|
this.mediaItem.add(mediaItem);
|
|
try {
|
|
await _player.stop();
|
|
await _player.setUrl(mediaItem.id);
|
|
await _player.play();
|
|
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
|
|
await _activarEcualizador();
|
|
} on PlayerException catch (e) {
|
|
_gestionarErrorReproduccion(e);
|
|
throw Exception(_mensajeAmigable(e));
|
|
} on Exception catch (e) {
|
|
developer.log(
|
|
'[PluriWave] Error inesperado en playMediaItem: $e',
|
|
name: 'ServicioAudio',
|
|
level: 900,
|
|
);
|
|
playbackState.add(playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.error,
|
|
playing: false,
|
|
errorMessage: 'Error inesperado al reproducir',
|
|
));
|
|
emisoraActual = null;
|
|
this.mediaItem.add(null);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> _activarEcualizador() async {
|
|
try {
|
|
final params = await _eq.parameters;
|
|
_eqDisponible = params.bands.isNotEmpty;
|
|
if (_eqDisponible) {
|
|
await _eq.setEnabled(true);
|
|
await aplicarPreset(_presetActual);
|
|
}
|
|
} catch (_) {
|
|
_eqDisponible = false;
|
|
}
|
|
}
|
|
|
|
/// Aplica un preset al ecualizador nativo Android.
|
|
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
|
_presetActual = preset;
|
|
if (!_eqDisponible) return;
|
|
try {
|
|
final params = await _eq.parameters;
|
|
for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) {
|
|
await params.bands[i].setGain(preset.bandas[i]);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
/// Ajusta una banda individual.
|
|
Future<void> setBanda(int index, double db) async {
|
|
if (!_eqDisponible) return;
|
|
final bandas = List<double>.from(_presetActual.bandas);
|
|
if (index >= 0 && index < bandas.length) {
|
|
bandas[index] = db;
|
|
_presetActual = _presetActual.copyWithBandas(bandas);
|
|
}
|
|
try {
|
|
final params = await _eq.parameters;
|
|
if (index < params.bands.length) {
|
|
await params.bands[index].setGain(db);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> setVolumen(double vol) async {
|
|
_volumen = vol.clamp(0.0, 1.0);
|
|
await _player.setVolume(_volumen);
|
|
}
|
|
|
|
@override
|
|
Future<void> play() => _player.play();
|
|
|
|
@override
|
|
Future<void> pause() => _player.pause();
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
await _player.stop();
|
|
emisoraActual = null;
|
|
mediaItem.add(null);
|
|
await super.stop();
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration position) => _player.seek(position);
|
|
|
|
@override
|
|
Future<void> onTaskRemoved() async {
|
|
await stop();
|
|
await _player.dispose();
|
|
}
|
|
|
|
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
|
|
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
|
|
return Emisora(
|
|
uuid: uuid,
|
|
nombre: mediaItem.title,
|
|
url: mediaItem.id,
|
|
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
|
|
favicon: mediaItem.artUri?.toString(),
|
|
);
|
|
}
|
|
}
|