- MainActivity: onListen re-emits the current active device and registers the audio device callback idempotently, so recreated activities resync instead of freezing the active-device id on a disconnected device. - servicio_dispositivo_audio: resubscribir() re-opens the event channel; estado_ecualizador exposes refrescarDispositivoActual() with an in-flight guard, invoked on app resume and when opening advanced EQ options, clearing stale green-dot device selections. - navegacion_auto/servicio_audio: new 'Personalizado' browse tree in Android Auto (5 band folders, 13 gain steps each) applied live via setBanda; preset and gain taps persist at device level when multi-device EQ is active and respect station/matrix overrides, with apply-before-persist ordering and children-changed notifications. - l10n: regenerate stale generated localizations; add rxdart as direct dependency for the subscribeToChildren override.
1225 lines
45 KiB
Dart
1225 lines
45 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer' as developer;
|
|
import 'dart:ui' show Locale;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:flutter/services.dart' show MethodChannel;
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:rxdart/rxdart.dart' show BehaviorSubject, ValueStream;
|
|
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/dispositivo_audio.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/pista_local.dart';
|
|
import '../modelos/preset_ecualizador.dart';
|
|
import 'cola_local.dart';
|
|
import 'controlador_reconexion.dart';
|
|
import 'musica_local_auto.dart';
|
|
import 'navegacion_auto.dart';
|
|
import 'servicio_audio_session.dart';
|
|
import 'servicio_dispositivo_audio.dart';
|
|
import 'servicio_ecualizador.dart';
|
|
|
|
/// Estado de reproducción expuesto al UI.
|
|
enum EstadoReproduccion {
|
|
detenido,
|
|
cargando,
|
|
reproduciendo,
|
|
pausado,
|
|
|
|
/// Transient network stall: the handler is retrying with backoff (S7-R2).
|
|
/// UI surfaces it as a loading indicator, never as an error dialog (S7-R3).
|
|
reconectando,
|
|
error,
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Handler global — inicializado en main.dart con AudioService.init
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
PluriWaveAudioHandler? _handlerGlobal;
|
|
|
|
void registrarHandler(PluriWaveAudioHandler handler) {
|
|
_handlerGlobal = handler;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Android Auto browse source — registered from main.dart, mirrors
|
|
// registrarHandler above (Design "getChildren data source registration").
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
FuenteEmisorasAuto? _fuenteNavegacionGlobal;
|
|
|
|
void registrarFuenteNavegacion(FuenteEmisorasAuto fuente) {
|
|
_fuenteNavegacionGlobal = fuente;
|
|
}
|
|
|
|
/// Local-music browse source — registered from main.dart, mirrors
|
|
/// [registrarFuenteNavegacion] above (Design "getChildren data source
|
|
/// registration"). `null` until registered (headless cold bind before
|
|
/// main.dart's registration line runs) — every consumer below treats a
|
|
/// `null` fuente as "not configured" rather than throwing.
|
|
FuenteMusicaLocalAuto? _fuenteMusicaLocalGlobal;
|
|
|
|
void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
|
_fuenteMusicaLocalGlobal = fuente;
|
|
}
|
|
|
|
/// 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;
|
|
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
_handler.configurarLocalizaciones(l10n);
|
|
}
|
|
|
|
Stream<EstadoReproduccion> get estadoStream =>
|
|
_handler.playbackState.map((s) {
|
|
if (s.processingState == AudioProcessingState.error) {
|
|
return EstadoReproduccion.error;
|
|
}
|
|
if (_handler.reconectando) return EstadoReproduccion.reconectando;
|
|
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: localizedStationName(
|
|
lookupAppLocalizations(const Locale('es')),
|
|
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
|
|
implements ObjetivoAudioInterrumpible {
|
|
static const _timeoutCambioFuente = Duration(seconds: 12);
|
|
static const _timeoutCierrePlayer = Duration(seconds: 3);
|
|
static const _factorAtenuacion = 0.3;
|
|
|
|
// ── Live-stream buffer (Design 7.1, S7-R1) ────────────────────────────────
|
|
// Forward jitter cushion for live radio: there is no rewind history, so the
|
|
// buffer only absorbs short drops (up to roughly what was buffered when the
|
|
// drop hit); on reconnect we rejoin the live edge.
|
|
static const bufferMinimo = Duration(seconds: 15);
|
|
static const bufferMaximo = Duration(seconds: 50);
|
|
static const bufferParaIniciar = Duration(milliseconds: 2500);
|
|
static const bufferTrasRebuffer = Duration(seconds: 5);
|
|
|
|
/// Buffer configuration applied at [AudioPlayer] construction. Exposed so
|
|
/// tests can assert the values without touching platform channels (S7-R1).
|
|
static const configuracionCargaAndroid = AudioLoadConfiguration(
|
|
androidLoadControl: AndroidLoadControl(
|
|
minBufferDuration: bufferMinimo,
|
|
maxBufferDuration: bufferMaximo,
|
|
bufferForPlaybackDuration: bufferParaIniciar,
|
|
bufferForPlaybackAfterRebufferDuration: bufferTrasRebuffer,
|
|
prioritizeTimeOverSizeThresholds: true,
|
|
),
|
|
);
|
|
|
|
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;
|
|
|
|
/// Last session id processed for EQ re-apply purposes (Design "Change-guard
|
|
/// field separate from broadcast field"). Kept apart from
|
|
/// [_androidAudioSessionId] so external broadcast semantics on
|
|
/// [androidAudioSessionIdStream] stay untouched by the EQ re-apply guard.
|
|
int? _ultimaSessionIdEq;
|
|
Future<void> _colaCambioFuente = Future<void>.value();
|
|
int _revisionFuente = 0;
|
|
|
|
/// Active local-music queue (Design "single load-bearing invariant"):
|
|
/// `null` means "not local-queue playback" — the ONLY gate the
|
|
/// auto-advance/skip/isolation logic reads. Radio never sets this field.
|
|
ColaLocal? _colaLocal;
|
|
|
|
/// Re-entry latch (Design ADR-3): armed synchronously the instant an
|
|
/// auto-advance is triggered, cleared when the next track reaches
|
|
/// `playing && ready`, or on deactivate/stop/external play. Guards
|
|
/// against a double-advance from repeated `completed` emissions during
|
|
/// the async URI-resolve gap.
|
|
bool _avanzandoCola = false;
|
|
|
|
Emisora? emisoraActual;
|
|
double _volumen = 1.0;
|
|
double get volumen => _volumen;
|
|
AppLocalizations? _l10n;
|
|
|
|
/// Intent-to-play flag (Designs 3.1/7.2): reflects the LAST explicit
|
|
/// intent (play/pause/stop, including audio-session interruptions, which
|
|
/// pause through [pausar]). The S7 reconnect state machine reads it to
|
|
/// distinguish a network stall from an intentional pause.
|
|
bool _intencionReproducir = false;
|
|
|
|
/// Ducked state requested by the audio session (transient focus loss).
|
|
bool _atenuado = false;
|
|
|
|
/// Reconnect-on-stall state machine (Design 7.2, S7-R2).
|
|
final ControladorReconexion _reconexion = ControladorReconexion();
|
|
|
|
/// True while the handler is inside the reconnect window. [ServicioAudio]
|
|
/// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading
|
|
/// indicator instead of an error during retries (S7-R3).
|
|
bool _reconectando = false;
|
|
bool get reconectando => _reconectando;
|
|
|
|
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();
|
|
}
|
|
|
|
AppLocalizations get _textos {
|
|
final actual = _l10n;
|
|
if (actual != null) return actual;
|
|
return lookupAppLocalizations(const Locale('es'));
|
|
}
|
|
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
_l10n = l10n;
|
|
}
|
|
|
|
AudioPlayer _crearPlayer() {
|
|
return AudioPlayer(
|
|
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
|
|
audioLoadConfiguration: configuracionCargaAndroid,
|
|
);
|
|
}
|
|
|
|
void _conectarStreamsPlayer() {
|
|
_estadoPlayerSub = _player.playerStateStream.listen((state) {
|
|
final playing = state.playing;
|
|
final proc = state.processingState;
|
|
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
|
|
// double-gated on `completed` + an active local queue, so this is a
|
|
// no-op for radio (which never emits `completed`) and for
|
|
// single-track local playback (which never sets `_colaLocal`).
|
|
_manejarFinPista(proc);
|
|
if (playing && proc == ProcessingState.ready) {
|
|
// Successful (re)connection: reset the backoff so the next stall
|
|
// starts over, and leave the reconnect window (S7-R7).
|
|
_reconexion.restablecer();
|
|
_reconectando = false;
|
|
// Local queue (Design ADR-3): the next queued track reached a
|
|
// stable playing state — clear the re-entry latch so a LATER
|
|
// completion can advance again. A no-op for radio, which never
|
|
// sets `_avanzandoCola`.
|
|
_avanzandoCola = false;
|
|
}
|
|
// Local queue transport (Design "Transport wiring"): skip controls
|
|
// are only offered while a queue is active — when `_colaLocal` is
|
|
// `null` this list/set/index is byte-identical to the pre-change
|
|
// radio behavior (regression guard).
|
|
final colaActiva = _colaLocal != null;
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
controls: [
|
|
if (colaActiva) MediaControl.skipToPrevious,
|
|
if (playing) MediaControl.pause else MediaControl.play,
|
|
MediaControl.stop,
|
|
if (colaActiva) MediaControl.skipToNext,
|
|
],
|
|
systemActions: {
|
|
MediaAction.seek,
|
|
MediaAction.stop,
|
|
if (colaActiva) MediaAction.skipToPrevious,
|
|
if (colaActiva) MediaAction.skipToNext,
|
|
},
|
|
androidCompactActionIndices: [colaActiva ? 1 : 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);
|
|
}
|
|
if (debeReaplicarEcualizador(
|
|
sessionId: sessionId,
|
|
ultimaSessionIdEq: _ultimaSessionIdEq,
|
|
eqDisponible: _eqDisponible,
|
|
)) {
|
|
_ultimaSessionIdEq = sessionId;
|
|
unawaited(_activarEcualizador());
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Gestiona cualquier error de reproducción de ExoPlayer.
|
|
///
|
|
/// Network-class failures while the user still intends to play enter the
|
|
/// reconnect state machine (S7-R2) instead of surfacing a terminal error;
|
|
/// only retry exhaustion (or non-network errors) falls through to the
|
|
/// existing error path, so the user sees a single error — no spam per retry.
|
|
void _gestionarErrorReproduccion(Object error) {
|
|
if (_intentarReconexion(error)) return;
|
|
|
|
String mensaje;
|
|
String codigoLog;
|
|
|
|
if (error is PlayerException) {
|
|
codigoLog = 'PlayerException(code=${error.code}): ${error.message}';
|
|
mensaje = _mensajeAmigable(error);
|
|
} else if (error is TimeoutException) {
|
|
codigoLog = 'TimeoutException: $error';
|
|
mensaje = _textos.audioErrorTimeout;
|
|
} else {
|
|
codigoLog = 'Error desconocido: $error';
|
|
mensaje = _textos.audioErrorGeneric;
|
|
}
|
|
|
|
developer.log(
|
|
'[PluriWave] Error reproducción: $codigoLog',
|
|
name: 'ServicioAudio',
|
|
level: 900,
|
|
);
|
|
|
|
_detenerReconexion();
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.error,
|
|
playing: false,
|
|
errorMessage: mensaje,
|
|
),
|
|
);
|
|
emisoraActual = null;
|
|
mediaItem.add(null);
|
|
|
|
_player.stop().catchError((_) {});
|
|
}
|
|
|
|
/// Network-class failures: ExoPlayer 2xxx source errors (no internet, bad
|
|
/// URL/host, timeout) and our own source-change timeout guard.
|
|
bool _esErrorDeRed(Object error) =>
|
|
(error is PlayerException && error.code >= 2000 && error.code < 3000) ||
|
|
error is TimeoutException;
|
|
|
|
/// Attempts to enter (or stay in) the reconnect window. Returns true when a
|
|
/// retry was scheduled and the terminal error path must be skipped.
|
|
bool _intentarReconexion(Object error) {
|
|
if (!_esErrorDeRed(error)) return false;
|
|
final item = mediaItem.value;
|
|
if (item == null) return false;
|
|
|
|
final decision = _reconexion.registrarFallo(
|
|
intencionReproducir: _intencionReproducir,
|
|
alReintentar: () => _reintentarFuente(item),
|
|
);
|
|
if (decision != DecisionReconexion.reintentar) {
|
|
// ignorar (user pause/stop or interruption) keeps the player quiet;
|
|
// agotado falls through to the single terminal error (S7-R2-C).
|
|
if (decision == DecisionReconexion.ignorar) {
|
|
_reconectando = false;
|
|
}
|
|
return decision == DecisionReconexion.ignorar;
|
|
}
|
|
|
|
_reconectando = true;
|
|
developer.log(
|
|
'[PluriWave] Stall de red, reintento ${_reconexion.intentos}/'
|
|
'${_reconexion.maxReintentos} en '
|
|
'${_reconexion.retrasoParaIntento(_reconexion.intentos).inSeconds}s',
|
|
name: 'ServicioAudio',
|
|
level: 800,
|
|
);
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
processingState: AudioProcessingState.buffering,
|
|
playing: false,
|
|
errorMessage: null,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/// Re-issues the live source through the revision-guarded source-change
|
|
/// queue, so a user source switch or stop during the retry cancels it.
|
|
void _reintentarFuente(MediaItem item) {
|
|
if (!_intencionReproducir) {
|
|
_detenerReconexion();
|
|
return;
|
|
}
|
|
final revision = ++_revisionFuente;
|
|
_colaCambioFuente = _colaCambioFuente
|
|
.catchError((_) {})
|
|
.then((_) => _cambiarFuente(item, revision))
|
|
// Failures already routed through _gestionarErrorReproduccion, which
|
|
// schedules the next backoff retry or surfaces the terminal error.
|
|
.catchError((_) {});
|
|
}
|
|
|
|
void _detenerReconexion() {
|
|
_reconexion.cancelar();
|
|
_reconectando = false;
|
|
}
|
|
|
|
/// 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 _textos.audioErrorNoInternet;
|
|
if (code == 2002) return _textos.audioErrorInvalidUrl;
|
|
if (code == 2003) return _textos.audioErrorNotFound;
|
|
if (code == 2004) return _textos.audioErrorTimeout;
|
|
return _textos.audioErrorCannotConnect;
|
|
}
|
|
|
|
if (code >= 3000 && code < 4000) {
|
|
return _textos.audioErrorUnsupportedFormat;
|
|
}
|
|
|
|
if (code >= 4000 && code < 5000) {
|
|
return _textos.audioErrorDecode;
|
|
}
|
|
|
|
final msg = e.message ?? '';
|
|
if (msg.contains('Cleartext') || msg.contains('cleartext')) {
|
|
return _textos.audioErrorCleartext;
|
|
}
|
|
if (msg.contains('CERTIFICATE') || msg.contains('HandshakeException')) {
|
|
return _textos.audioErrorSsl;
|
|
}
|
|
|
|
return _textos.audioErrorCannotPlay;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// Public entry point for EVERY external play (phone `reproducir`, car
|
|
/// `emisora:`/`grupo:`/`pista:`/`eq_preset:` non-path). ALWAYS clears the
|
|
/// local queue FIRST (Design ADR-2, the single load-bearing invariant:
|
|
/// "external play = leave queue mode") so a stale auto-advance can never
|
|
/// fire after an external source switch, then delegates to the private
|
|
/// [_encolarCambioFuente] — the SAME source-change path queue playback
|
|
/// uses via [_reproducirEntradaCola].
|
|
@override
|
|
Future<void> playMediaItem(MediaItem mediaItem) async {
|
|
_colaLocal = null;
|
|
_avanzandoCola = false;
|
|
return _encolarCambioFuente(mediaItem);
|
|
}
|
|
|
|
/// The revision-guarded source-change queue (Design ADR-1/ADR-2), shared
|
|
/// by public [playMediaItem] (which clears `_colaLocal` first) and
|
|
/// [_reproducirEntradaCola] (queue play, which does NOT clear it) — the
|
|
/// body is exactly [playMediaItem]'s previous implementation, unchanged.
|
|
Future<void> _encolarCambioFuente(MediaItem mediaItem) async {
|
|
_intencionReproducir = true;
|
|
// Fresh user play/source switch: restart the backoff from scratch and
|
|
// leave any previous reconnect window (S7-R2).
|
|
_reconexion.restablecer();
|
|
_reconectando = false;
|
|
final revision = ++_revisionFuente;
|
|
_colaCambioFuente = _colaCambioFuente
|
|
.catchError((_) {})
|
|
.then((_) => _cambiarFuente(mediaItem, revision));
|
|
return _colaCambioFuente;
|
|
}
|
|
|
|
/// Plays a single local-queue track WITHOUT clearing `_colaLocal` (Design
|
|
/// ADR-2's "queue play" transition) — the ONLY other caller of
|
|
/// [_encolarCambioFuente] besides public [playMediaItem]. Callers are
|
|
/// responsible for setting/keeping `_colaLocal` themselves before calling
|
|
/// this (queue start, auto-advance, skip) — this method itself never
|
|
/// touches the field on the happy path.
|
|
///
|
|
/// [colaEsperada], when provided, is the mid-await race guard (Design
|
|
/// ADR-3's advance flow): after resolving [nodo]'s content URI (the
|
|
/// async gap where a user action could replace `_colaLocal`), playback
|
|
/// only proceeds when `_colaLocal` is still IDENTICAL to [colaEsperada].
|
|
/// Skip/queue-start callers omit it (no prior async gap to guard).
|
|
Future<void> _reproducirEntradaCola(
|
|
NodoLocal nodo, {
|
|
ColaLocal? colaEsperada,
|
|
}) async {
|
|
final fuente = _fuenteMusicaLocalGlobal;
|
|
if (fuente == null) {
|
|
if (colaEsperada != null) _avanzandoCola = false;
|
|
return;
|
|
}
|
|
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
|
|
if (colaEsperada != null && !avanceEsValido(_colaLocal, colaEsperada)) {
|
|
// Stale: a user action (external play, stop, another skip) replaced
|
|
// `_colaLocal` during the await — abort this advance without
|
|
// touching whatever the newer action already set.
|
|
return;
|
|
}
|
|
if (item == null) {
|
|
// The local track's content URI could not be resolved (revoked
|
|
// permission, moved/deleted file). Only an in-flight advance owns
|
|
// the latch here (a direct skip/queue-start never armed it) — end
|
|
// the queue cleanly instead of leaving `_avanzandoCola` stuck.
|
|
if (colaEsperada != null) {
|
|
_avanzandoCola = false;
|
|
_desactivarCola();
|
|
unawaited(stop());
|
|
}
|
|
return;
|
|
}
|
|
await _encolarCambioFuente(item);
|
|
}
|
|
|
|
/// Clears the local queue (Design ADR-2/ADR-4): shared by [stop], the
|
|
/// end-of-queue path, and a resolve failure mid-advance.
|
|
void _desactivarCola() {
|
|
_colaLocal = null;
|
|
_avanzandoCola = false;
|
|
}
|
|
|
|
/// Sets up a fresh local-music queue and plays its first track (Design
|
|
/// "Data Flow" — the `iniciarCola` seam [playFromMediaId] passes to
|
|
/// [reproducirCarpetaLocal]).
|
|
Future<void> _iniciarColaLocal(List<NodoLocal> pistas) async {
|
|
final cola = ColaLocal(pistas: pistas);
|
|
_colaLocal = cola;
|
|
await _reproducirEntradaCola(cola.actual);
|
|
}
|
|
|
|
/// First line of the `playerStateStream` listener (Design ADR-3, Phase 3
|
|
/// task 3.3): pure-decision-driven queue-advance handling. A no-op for
|
|
/// radio (never emits `completed`) and single-track local playback
|
|
/// (never sets `_colaLocal`) — see [decidirAvanceCola]'s doc comment for
|
|
/// the full gating contract.
|
|
void _manejarFinPista(ProcessingState proc) {
|
|
final decision = decidirAvanceCola(
|
|
colaLocal: _colaLocal,
|
|
avanzandoCola: _avanzandoCola,
|
|
trackCompletado: proc == ProcessingState.completed,
|
|
);
|
|
switch (decision) {
|
|
case DecisionAvanceCola.ninguna:
|
|
return;
|
|
case DecisionAvanceCola.desactivar:
|
|
_desactivarCola();
|
|
unawaited(stop());
|
|
return;
|
|
case DecisionAvanceCola.avanzar:
|
|
final siguiente = _colaLocal!.conSiguiente();
|
|
if (siguiente == null) {
|
|
// Structurally unreachable — decidirAvanceCola already proved
|
|
// conSiguiente() != null for `avanzar` — guarded defensively.
|
|
_desactivarCola();
|
|
unawaited(stop());
|
|
return;
|
|
}
|
|
// Set the re-entry latch synchronously, before any await, so a
|
|
// second rapid `completed` emission is caught by
|
|
// decidirAvanceCola's `avanzandoCola == true` gate (Design
|
|
// ADR-3).
|
|
_avanzandoCola = true;
|
|
_colaLocal = siguiente;
|
|
unawaited(
|
|
_reproducirEntradaCola(siguiente.actual, colaEsperada: siguiente),
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
// Reconnect engaged: complete normally so callers do not surface a
|
|
// snackbar/dialog while the handler keeps retrying (S7-R3).
|
|
if (_reconectando) return;
|
|
}
|
|
throw Exception(_mensajeAmigable(e));
|
|
} on TimeoutException catch (e) {
|
|
// A real network drop usually surfaces as our 12s source timeout:
|
|
// route it through the reconnect machine instead of a terminal error.
|
|
if (revision == _revisionFuente) {
|
|
_gestionarErrorReproduccion(e);
|
|
if (_reconectando) return;
|
|
}
|
|
rethrow;
|
|
} 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: _textos.audioErrorUnexpectedPlayback,
|
|
),
|
|
);
|
|
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;
|
|
_ultimaSessionIdEq = null;
|
|
_player = _crearPlayer();
|
|
await _player.setVolume(_volumenEfectivo);
|
|
_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;
|
|
}
|
|
}
|
|
|
|
/// Pure re-apply decision for a native session-id emission. No side effects.
|
|
///
|
|
/// Returns `true` when the native audio session id genuinely rotated
|
|
/// mid-playback (audio-focus ducking by another app) and the equalizer is
|
|
/// currently attached, meaning the caller should re-attach the EQ and
|
|
/// re-push the current preset's gains via [_activarEcualizador].
|
|
@visibleForTesting
|
|
static bool debeReaplicarEcualizador({
|
|
required int? sessionId,
|
|
required int? ultimaSessionIdEq,
|
|
required bool eqDisponible,
|
|
}) => sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
|
|
|
|
/// 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(_volumenEfectivo);
|
|
}
|
|
|
|
double get _volumenEfectivo =>
|
|
_atenuado ? _volumen * _factorAtenuacion : _volumen;
|
|
|
|
// ── ObjetivoAudioInterrumpible (audio-session seam, S3-R1) ───────────────
|
|
|
|
@override
|
|
bool get intencionReproducir => _intencionReproducir;
|
|
|
|
@override
|
|
bool get estaReproduciendo => playbackState.value.playing;
|
|
|
|
@override
|
|
Future<void> pausar() => pause();
|
|
|
|
@override
|
|
Future<void> reanudar() => play();
|
|
|
|
@override
|
|
Future<void> setAtenuado(bool atenuado) async {
|
|
if (_atenuado == atenuado) return;
|
|
_atenuado = atenuado;
|
|
await _player.setVolume(_volumenEfectivo);
|
|
}
|
|
|
|
@override
|
|
Future<void> play() {
|
|
_intencionReproducir = true;
|
|
return _player.play();
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() {
|
|
// User (or audio-session interruption) pause: disarm any pending retry —
|
|
// a stall must never fight an intentional pause (S7-R2-B, S7-R6).
|
|
_intencionReproducir = false;
|
|
_detenerReconexion();
|
|
return _player.pause();
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
// User stop (including the sleep-timer fade-out stop): cancel reconnect
|
|
// so retries never restart playback after a stop (S7-R6).
|
|
_intencionReproducir = false;
|
|
_detenerReconexion();
|
|
// Local queue (Design ADR-2): clears alongside the reconnect-cancel
|
|
// logic above — `onTaskRemoved` (which calls stop()) inherits this for
|
|
// free (Phase 3 task 3.5).
|
|
_desactivarCola();
|
|
_revisionFuente++;
|
|
await _player.stop();
|
|
emisoraActual = null;
|
|
mediaItem.add(null);
|
|
await super.stop();
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration position) => _player.seek(position);
|
|
|
|
/// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task
|
|
/// 3.6): a no-op when no local queue is active. Past the last track,
|
|
/// clears the queue and stops — mirroring auto-advance's end-of-queue
|
|
/// behavior (no wraparound).
|
|
@override
|
|
Future<void> skipToNext() async {
|
|
final cola = _colaLocal;
|
|
if (cola == null) return;
|
|
final siguiente = cola.conSiguiente();
|
|
if (siguiente == null) {
|
|
_desactivarCola();
|
|
await stop();
|
|
return;
|
|
}
|
|
_colaLocal = siguiente;
|
|
await _reproducirEntradaCola(siguiente.actual);
|
|
}
|
|
|
|
/// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6):
|
|
/// a no-op when no local queue is active. Clamps at the first track
|
|
/// (restarts it) instead of wrapping to the last one.
|
|
@override
|
|
Future<void> skipToPrevious() async {
|
|
final cola = _colaLocal;
|
|
if (cola == null) return;
|
|
final anterior = cola.conAnterior();
|
|
_colaLocal = anterior;
|
|
await _reproducirEntradaCola(anterior.actual);
|
|
}
|
|
|
|
@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();
|
|
for (final subject in _hijosSubjects.values) {
|
|
await subject.close();
|
|
}
|
|
_hijosSubjects.clear();
|
|
}
|
|
|
|
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(),
|
|
);
|
|
}
|
|
|
|
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's
|
|
// already-tested pure logic — Design "getChildren data source") ─────────
|
|
|
|
/// One-shot device-query channel (feature auto-custom-eq): the SAME
|
|
/// method channel `ServicioDispositivoAudioReal` talks to, but method
|
|
/// calls only — opening a second EventChannel subscription here would
|
|
/// steal the phone-side service's Dart stream handler.
|
|
static const _canalDispositivos = MethodChannel('pluriwave/audio_devices');
|
|
|
|
/// Short timeout for the device query: on a headless Auto bind no
|
|
/// Activity (and thus no channel handler) exists, and a car tap must fall
|
|
/// back to global persistence instead of hanging.
|
|
static const _timeoutConsultaDispositivo = Duration(seconds: 2);
|
|
|
|
/// Fresh active-output-device query for the car EQ paths. Returns `null`
|
|
/// on ANY failure (missing handler while headless, timeout, malformed
|
|
/// map) so callers degrade to global persistence — never a crash.
|
|
Future<DispositivoAudio?> _dispositivoActivoAuto() async {
|
|
try {
|
|
final raw = await _canalDispositivos
|
|
.invokeMethod<Map<dynamic, dynamic>>('getActiveDevice')
|
|
.timeout(_timeoutConsultaDispositivo);
|
|
if (raw == null) return null;
|
|
return ServicioDispositivoAudioReal.dispositivoDesdeMapa(
|
|
Map<String, dynamic>.from(raw),
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Resolves the persistence target for a car EQ action (feature
|
|
/// auto-custom-eq): a deviceId for a DEVICE-level write, `null` for the
|
|
/// global principal (toggle off, built-in speaker, placeholder id, or the
|
|
/// headless error/timeout fallback).
|
|
Future<String?> _dispositivoDestinoEqAuto(
|
|
ServicioEcualizador servicio,
|
|
) async {
|
|
try {
|
|
final config = await servicio.cargar();
|
|
if (!config.eqMultiDeviceEnabled) return null;
|
|
return dispositivoDestinoEq(
|
|
multiDeviceEnabled: config.eqMultiDeviceEnabled,
|
|
dispositivo: await _dispositivoActivoAuto(),
|
|
);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// The custom preset the Auto tree shows and edits right now (feature
|
|
/// auto-custom-eq): the device-level entry for the current output device
|
|
/// when multi-device is on, the global principal otherwise — resolved
|
|
/// from persistence so a headless bind (no `EstadoEcualizador`) still
|
|
/// reports honest gains.
|
|
Future<PresetEcualizador> _presetPersonalizadoAuto() async {
|
|
final servicio = ServicioEcualizador();
|
|
final config = await servicio.cargar();
|
|
final destino =
|
|
config.eqMultiDeviceEnabled
|
|
? dispositivoDestinoEq(
|
|
multiDeviceEnabled: true,
|
|
dispositivo: await _dispositivoActivoAuto(),
|
|
)
|
|
: null;
|
|
return presetPersonalizadoEfectivo(config: config, deviceId: destino);
|
|
}
|
|
|
|
/// Per-parent children-changed subjects (feature auto-custom-eq):
|
|
/// audio_service subscribes to [subscribeToChildren]'s stream the first
|
|
/// time the platform loads a parent's children and translates every later
|
|
/// emission into a native `notifyChildrenChanged`, making the car
|
|
/// re-request `getChildren` so band titles and the selection mark refresh
|
|
/// right after a gain tap.
|
|
final Map<String, BehaviorSubject<Map<String, dynamic>>> _hijosSubjects = {};
|
|
|
|
@override
|
|
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
|
_hijosSubjects.putIfAbsent(
|
|
parentMediaId,
|
|
() => BehaviorSubject.seeded(<String, dynamic>{}),
|
|
);
|
|
|
|
/// Emits a children-changed notification for [parentMediaId] — a no-op
|
|
/// until the platform has browsed that parent at least once.
|
|
void _notificarHijosCambiados(String parentMediaId) {
|
|
_hijosSubjects[parentMediaId]?.add(<String, dynamic>{});
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> getChildren(
|
|
String parentMediaId, [
|
|
Map<String, dynamic>? options,
|
|
]) async {
|
|
try {
|
|
final constructor = ConstructorArbolAuto();
|
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
|
if (parentMediaId == AudioService.browsableRootId) {
|
|
final incluirMusicaLocal =
|
|
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
|
|
return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal);
|
|
}
|
|
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
|
|
return [
|
|
...constructor.presetsEq(PresetEcualizador.presets),
|
|
constructor.itemEqPersonalizado(),
|
|
];
|
|
}
|
|
if (parentMediaId == ConstructorArbolAuto.idEqPersonalizado) {
|
|
return constructor.bandasEq(await _presetPersonalizadoAuto());
|
|
}
|
|
if (esBandaEqMediaId(parentMediaId)) {
|
|
final indice = indiceBandaEqDesde(parentMediaId);
|
|
if (indice == null) return const [];
|
|
return constructor.gananciasBandaEq(
|
|
indice,
|
|
await _presetPersonalizadoAuto(),
|
|
);
|
|
}
|
|
final musicaLocal = await hijosMusicaLocal(
|
|
parentMediaId,
|
|
fuente: fuenteLocal,
|
|
);
|
|
if (musicaLocal != null) return musicaLocal;
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
if (fuente == null) return const [];
|
|
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
|
return constructor.carpetasFavoritos(
|
|
grupos: await fuente.grupos(),
|
|
favoritos: await fuente.favoritos(),
|
|
);
|
|
}
|
|
if (constructor.esCarpetaGrupo(parentMediaId)) {
|
|
return constructor.hijosGrupo(
|
|
parentMediaId,
|
|
favoritos: await fuente.favoritos(),
|
|
);
|
|
}
|
|
final emisoras = await _listaParaCarpeta(fuente, parentMediaId);
|
|
return constructor.hijos(parentMediaId, emisoras: emisoras);
|
|
} catch (_) {
|
|
// Spec "Browse requested before app state is loaded": never throw out
|
|
// of a browse call, even on an unexpected failure.
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<MediaItem?> getMediaItem(String mediaId) async {
|
|
try {
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
if (fuente == null) return null;
|
|
final universo = await _universoCompleto(fuente);
|
|
final constructor = ConstructorArbolAuto();
|
|
final emisora = constructor.resolver(mediaId, universo);
|
|
return emisora == null ? null : constructor.itemEmisora(emisora);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> playFromMediaId(
|
|
String mediaId, [
|
|
Map<String, dynamic>? extras,
|
|
]) async {
|
|
try {
|
|
// EQ preset selection (Design ADR-3, Spec "EQ Preset Selection Applies
|
|
// Without Disturbing Playback"): FIRST branch, unconditional `return`,
|
|
// so an `eq_preset:` id can never fall through to the playback routing
|
|
// below. `aplicarPresetPorMediaId`'s seams are EQ-only (persist +
|
|
// apply) — there is no playback parameter to inject here.
|
|
if (esPresetMediaId(mediaId)) {
|
|
final servicio = ServicioEcualizador();
|
|
// Persistence targeting (feature auto-custom-eq): with multi-device
|
|
// EQ on and a non-builtin output device active, the tap persists a
|
|
// DEVICE-level entry so the selection sticks for the car's device
|
|
// instead of being shadowed by the hierarchy; otherwise (or on any
|
|
// headless query failure) it persists the global principal as
|
|
// before.
|
|
await aplicarPresetPorMediaId(
|
|
mediaId,
|
|
presets: PresetEcualizador.presets,
|
|
uuidActual: emisoraActual?.uuid,
|
|
clavesPorEmisora: () async =>
|
|
(await servicio.cargar()).porEmisora.keys.toSet(),
|
|
clavesMatriz: () async =>
|
|
(await servicio.cargar()).presetsMatriz.keys.toSet(),
|
|
dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio),
|
|
persistirDispositivo: servicio.guardarPresetDispositivo,
|
|
persistirPrincipal: servicio.guardarPrincipal,
|
|
aplicar: aplicarPreset,
|
|
);
|
|
return;
|
|
}
|
|
// Custom-EQ gain selection (feature auto-custom-eq): same
|
|
// unconditional-return shape as the eq_preset branch above — an
|
|
// `eq_gain:` id can never fall through to playback routing.
|
|
if (esGananciaEqMediaId(mediaId)) {
|
|
final servicio = ServicioEcualizador();
|
|
await aplicarGananciaPorMediaId(
|
|
mediaId,
|
|
cargarConfig: servicio.cargar,
|
|
dispositivoDestino: () => _dispositivoDestinoEqAuto(servicio),
|
|
persistirDispositivo: servicio.guardarPresetDispositivo,
|
|
persistirPrincipal: servicio.guardarPrincipal,
|
|
aplicarBanda: setBanda,
|
|
uuidActual: emisoraActual?.uuid,
|
|
clavesPorEmisora: () async =>
|
|
(await servicio.cargar()).porEmisora.keys.toSet(),
|
|
clavesMatriz: () async =>
|
|
(await servicio.cargar()).presetsMatriz.keys.toSet(),
|
|
);
|
|
// Refresh the affected browse nodes so the band title under
|
|
// `Personalizado` and the `● ` selection mark reflect the new gain.
|
|
final ganancia = gananciaEqDesde(mediaId);
|
|
if (ganancia != null) {
|
|
_notificarHijosCambiados(ConstructorArbolAuto.idEqPersonalizado);
|
|
_notificarHijosCambiados(idBandaEq(ganancia.$1));
|
|
}
|
|
return;
|
|
}
|
|
// Local-track playback (Design "Local Track Playback Reuses Existing
|
|
// Pipeline", Spec "User selects a local track"): SECOND branch,
|
|
// unconditional `return`, mirroring the eq_preset branch above — a
|
|
// `pista:` id never falls through to the station routing below.
|
|
if (esPistaMediaId(mediaId)) {
|
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
|
if (fuenteLocal == null) return;
|
|
await reproducirPistaLocal(
|
|
mediaId,
|
|
fuente: fuenteLocal,
|
|
reproducir: playMediaItem,
|
|
);
|
|
return;
|
|
}
|
|
// Folder-play actions (Design ADR-5, Phase 3 task 4.3): THIRD
|
|
// branch, after eq_preset/pista, before the station fallthrough —
|
|
// mirrors both branches above's unconditional-return shape so
|
|
// neither new action id can fall through to station routing.
|
|
final constructorArbol = ConstructorArbolAuto();
|
|
final esAccionCarpeta =
|
|
constructorArbol.esCarpetaLocalReproducirMediaId(mediaId) ||
|
|
constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId);
|
|
if (esAccionCarpeta) {
|
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
|
if (fuenteLocal == null) return;
|
|
await reproducirCarpetaLocal(
|
|
mediaId,
|
|
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(
|
|
mediaId,
|
|
),
|
|
fuente: fuenteLocal,
|
|
iniciarCola: _iniciarColaLocal,
|
|
);
|
|
return;
|
|
}
|
|
final fuente = _fuenteNavegacionGlobal;
|
|
if (fuente == null) return;
|
|
await reproducirPorMediaId(
|
|
mediaId,
|
|
fuente: fuente,
|
|
reproducir: playMediaItem,
|
|
);
|
|
} catch (e) {
|
|
// Spec "Unknown or stale media id": never propagate from the handler.
|
|
developer.log(
|
|
'[PluriWave] Error en playFromMediaId($mediaId): $e',
|
|
name: 'ServicioAudio',
|
|
level: 900,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<List<Emisora>> _listaParaCarpeta(
|
|
FuenteEmisorasAuto fuente,
|
|
String parentId,
|
|
) => switch (parentId) {
|
|
ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(),
|
|
ConstructorArbolAuto.idTodas => fuente.todas(),
|
|
_ => Future.value(const []),
|
|
};
|
|
|
|
Future<List<Emisora>> _universoCompleto(FuenteEmisorasAuto fuente) async {
|
|
final listas = await Future.wait([
|
|
fuente.favoritos(),
|
|
fuente.misEmisoras(),
|
|
fuente.todas(),
|
|
]);
|
|
return listas.expand((lista) => lista).toList();
|
|
}
|
|
}
|