Files
pluriwave/lib/servicios/servicio_audio.dart
T
FreeTLab a9202c6eb3
Build & Deploy Pluriwave / Análisis de código (push) Successful in 13s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m4s
fix(settings): show real version and map equalizer gains
2026-05-21 22:16:18 +02:00

475 lines
15 KiB
Dart

import 'dart:async';
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;
Stream<int?> get androidAudioSessionIdStream async* {
yield _handler.androidAudioSessionId;
yield* _handler.androidAudioSessionIdStream;
}
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> setEcualizadorActivo(bool activo) =>
_handler.setEcualizadorActivo(activo);
Future<void> setBanda(int index, double db) => _handler.setBanda(index, db);
}
// ─────────────────────────────────────────────────────────────────────────────
// AudioHandler
// ─────────────────────────────────────────────────────────────────────────────
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
static const _timeoutCambioFuente = Duration(seconds: 12);
static const _timeoutCierrePlayer = Duration(seconds: 3);
AndroidEqualizer _eq = AndroidEqualizer();
late AudioPlayer _player = _crearPlayer();
StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub;
StreamSubscription<PlaybackEvent>? _eventosSub;
StreamSubscription<int?>? _androidAudioSessionIdSub;
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
int? _androidAudioSessionId;
Future<void> _colaCambioFuente = Future<void>.value();
int _revisionFuente = 0;
Emisora? emisoraActual;
double _volumen = 1.0;
double get volumen => _volumen;
AndroidEqualizer? get ecualizador => _eq;
bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible;
bool _ecualizadorActivo = true;
bool get ecualizadorActivo => _ecualizadorActivo;
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
int? get androidAudioSessionId => _androidAudioSessionId;
Stream<int?> get androidAudioSessionIdStream =>
_androidAudioSessionIdController.stream;
PluriWaveAudioHandler() {
_conectarStreamsPlayer();
}
AudioPlayer _crearPlayer() {
return AudioPlayer(
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
);
}
void _conectarStreamsPlayer() {
_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,
),
);
});
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos));
});
_eventosSub = _player.playbackEventStream.listen(
(_) {},
onError: (Object error, StackTrace stackTrace) {
_gestionarErrorReproduccion(error);
},
);
_androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen((
sessionId,
) {
_androidAudioSessionId = sessionId;
if (!_androidAudioSessionIdController.isClosed) {
_androidAudioSessionIdController.add(sessionId);
}
});
}
/// 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 {
final revision = ++_revisionFuente;
_colaCambioFuente = _colaCambioFuente
.catchError((_) {})
.then((_) => _cambiarFuente(mediaItem, revision));
return _colaCambioFuente;
}
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,
),
);
try {
await _recrearPlayer();
if (revision != _revisionFuente) return;
await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente);
if (revision != _revisionFuente) return;
_iniciarPlaySinBloquear(mediaItem, revision);
unawaited(_activarEcualizador());
} on PlayerException catch (e) {
if (revision == _revisionFuente) {
_gestionarErrorReproduccion(e);
}
throw Exception(_mensajeAmigable(e));
} on Exception catch (e, stackTrace) {
developer.log(
'[PluriWave] Error inesperado en playMediaItem: $e',
name: 'ServicioAudio',
level: 900,
stackTrace: stackTrace,
);
if (revision == _revisionFuente) {
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorMessage: 'Error inesperado al reproducir',
),
);
emisoraActual = null;
this.mediaItem.add(null);
}
rethrow;
}
}
Future<void> _recrearPlayer() async {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
final anterior = _player;
try {
await anterior.stop().timeout(_timeoutCierrePlayer);
} catch (_) {}
try {
await anterior.dispose().timeout(_timeoutCierrePlayer);
} catch (_) {}
_eq = AndroidEqualizer();
_eqDisponible = false;
_androidAudioSessionId = null;
_player = _crearPlayer();
await _player.setVolume(_volumen);
_conectarStreamsPlayer();
}
void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) {
unawaited(
_player.play().catchError((Object error, StackTrace stackTrace) {
developer.log(
'[PluriWave] Error al iniciar ${mediaItem.title}: $error',
name: 'ServicioAudio',
level: 900,
stackTrace: stackTrace,
);
if (revision == _revisionFuente) {
_gestionarErrorReproduccion(error);
}
}),
);
}
Future<void> _activarEcualizador() async {
try {
final params = await _eq.parameters;
_eqDisponible = params.bands.isNotEmpty;
if (_eqDisponible) {
await _eq.setEnabled(_ecualizadorActivo);
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 {
await _eq.setEnabled(_ecualizadorActivo);
if (!_ecualizadorActivo) return;
final params = await _eq.parameters;
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(
_mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
} catch (_) {}
}
/// Ajusta una banda individual.
Future<void> setBanda(int index, double db) async {
final bandas = List<double>.from(_presetActual.bandas);
if (index >= 0 && index < bandas.length) {
bandas[index] = db;
_presetActual = _presetActual.copyWithBandas(bandas);
}
if (!_eqDisponible || !_ecualizadorActivo) return;
try {
final params = await _eq.parameters;
if (index < params.bands.length) {
await params.bands[index].setGain(
_mapearGananciaNativa(
db,
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
} catch (_) {}
}
double _mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final normalizado = ((db.clamp(-12.0, 12.0) + 12.0) / 24.0).clamp(0.0, 1.0);
return minDecibels + (normalizado * (maxDecibels - minDecibels));
}
Future<void> setEcualizadorActivo(bool activo) async {
_ecualizadorActivo = activo;
if (!_eqDisponible) return;
try {
await _eq.setEnabled(activo);
if (activo) {
await aplicarPreset(_presetActual);
}
} 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 {
_revisionFuente++;
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 _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
await _player.dispose();
await _androidAudioSessionIdController.close();
}
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(),
);
}
}