Files
pluriwave/lib/servicios/servicio_audio.dart
T
FreeTLab cacd3ece57 fix(audio): keep custom actions out of the media notification controls
The equalizer toggle appended to the transport controls was aborting the
whole notification. controls feeds BOTH the phone notification and the
car playback screen, and AudioService.setState walks every control
through createCustomAction (AudioService.java:513-520) BEFORE reaching
mediaSession.setPlaybackState (:552) and enterPlayingState (:559) -- the
only place the notification is ever posted.

createCustomAction resolves the icon by name via getIdentifier (:415-420),
which returns 0 on a miss, and passes it to
PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an
empty label. That throw aborts setState, so the media session is never
published: no shade widget, no lock-screen controls, not even the small
status-bar icon. ExoPlayer runs independently so audio keeps playing, and
until asyncError got a subscriber the exception was dropped silently.

Nothing is lost in the car: the Ecualizador browse folder already lists
Desactivar plus every preset by name, which is Auto's own idiom for
choosing among options.
2026-08-01 20:33:34 +02:00

1536 lines
62 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:just_audio/just_audio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.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';
/// 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;
}
/// Teardown hook for whatever `main.dart` wired around the handler and must
/// be undone when the handler itself dies — today only the
/// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered
/// from `main.dart`, mirroring [registrarHandler] and the two browse-source
/// registrations above; run exactly once from
/// [PluriWaveAudioHandler.onTaskRemoved].
///
/// The direction of the dependency matters: the bootstrap layer injects its
/// cleanup INTO the service layer, so `servicio_audio.dart` never has to
/// import `arranque_audio.dart` (nor the plugin's static error stream) just to
/// be able to close it.
Future<void> Function()? _limpiezaArranqueGlobal;
void registrarLimpiezaArranque(Future<void> Function() limpieza) {
_limpiezaArranqueGlobal = limpieza;
}
/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android
/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a
/// station with no usable favicon gets the SAME on-brand rotating fallback
/// the browse tree and the car-tap path already show, instead of a blank
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
/// dependency — so it is unit-testable without instantiating the handler.
MediaItem mediaItemParaEmisora(
Emisora emisora, {
required AppLocalizations l10n,
}) {
return MediaItem(
id: emisora.url,
title: localizedStationName(l10n, emisora.nombre),
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri: Uri.parse(artUriPara(emisora)),
extras: {'uuid': emisora.uuid},
);
}
/// Reconstructs the phone-side [Emisora] from the handler's current
/// [MediaItem] (item 3): gates `favicon` through [faviconUsable]
/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand
/// FALLBACK `artUri` (an `android.resource://` drawable, never a real
/// favicon) is never misread as a genuine station favicon — the phone UI's
/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty`
/// (not on `faviconUsable`'s scheme check), so without this guard they would
/// attempt a doomed network fetch of the fallback's non-http URI before
/// falling back to [PluriStationArtFallback] themselves. A genuine http(s)
/// favicon still round-trips exactly as before. Pure — no handler
/// dependency — unit-testable directly.
Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
final artUriTexto = mediaItem.artUri?.toString();
return Emisora(
uuid: uuid,
nombre: mediaItem.title,
url: mediaItem.id,
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
favicon: faviconUsable(artUriTexto) ? artUriTexto : null,
);
}
/// Maps a `just_audio` [ProcessingState] to the `audio_service`
/// [AudioProcessingState] pushed into `playbackState`. Identical to the
/// previous private `_mapProcState` in every case EXCEPT one:
/// [ProcessingState.idle] maps to [AudioProcessingState.loading] while
/// [cambiandoFuente] is `true`.
///
/// Why that single exception exists — this is the media-notification
/// regression, not a cosmetic tweak:
///
/// `audio_service`'s `_observePlaybackState` (`audio_service.dart:1131-1136`)
/// calls `AudioService._stop()` — which reaches `stopService()` and cancels
/// the notification through `deactivateMediaSession()` — on ANY transition
/// into `idle` from a non-idle state. The notification is posted at exactly
/// one place, `internalStartForeground()`, reachable only from the
/// `!wasPlaying && playing` edge, and its FIRST statement is
/// `ContextCompat.startForegroundService(...)`, which throws
/// `ForegroundServiceStartNotAllowedException` on API 31+ whenever the
/// process is not in a foreground state.
///
/// Every station change walked straight into that: `_cambiarFuente` pushes
/// `loading`, then `_recrearPlayer` disposes the old [AudioPlayer] and builds
/// a FRESH one, and a fresh player's first `playerStateStream` event is
/// always `idle`. Forwarded verbatim, that is a `loading -> idle` transition,
/// so the foreground service was torn down mid-source-change and the app then
/// depended on the following `playing: true` edge to restart it. With the
/// screen off, on the lock screen, or on an Android Auto / Bluetooth-initiated
/// start, that restart is exactly the case the platform refuses — audio keeps
/// playing, the notification never comes back. Self-inflicted, on every API
/// level, no plugin patch needed: just stop emitting the transient `idle`.
///
/// A genuine user stop is unaffected: `stop()` clears the flag BEFORE
/// `_player.stop()`, so its `idle` still reaches `playbackState` as a real
/// `idle` and still tears the service down. Pure — no handler dependency — so
/// the full [ProcessingState] x [cambiandoFuente] matrix is unit-testable
/// directly.
AudioProcessingState mapearEstadoProceso(
ProcessingState proc, {
required bool cambiandoFuente,
}) {
if (cambiandoFuente && proc == ProcessingState.idle) {
return AudioProcessingState.loading;
}
return switch (proc) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
};
}
/// Custom-action names for the equalizer's `PlaybackStateCompat` custom
/// actions on the now-playing screen (Design "EQ custom actions", item 4).
/// Public consts so tests and this file's own `customAction` dispatch share
/// the exact same literals; distinct from every browse-tree media-id prefix
/// in `navegacion_auto.dart` (they live in a completely different
/// `MediaControl`/`customAction` namespace, never compared against a
/// media id).
const accionEqToggle = 'eq_toggle';
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
/// after the last one. When [actual] is not found in [presets] (e.g. a
/// user-tweaked "Personalizado" preset from `EstadoEcualizador.cambiarBanda`),
/// starts from the FIRST preset rather than throwing — cycling from an
/// unknown state always lands somewhere sane. Pure, no I/O.
///
/// [presets] defaults to [PresetEcualizador.presets] — not a literal default
/// value, since that field is `static final` (not `const`) and Dart default
/// parameter values must be compile-time constants.
PresetEcualizador presetSiguiente(
PresetEcualizador actual, {
List<PresetEcualizador>? presets,
}) {
final lista = presets ?? PresetEcualizador.presets;
final indice = lista.indexWhere((p) => p == actual);
if (indice == -1) return lista.first;
return lista[(indice + 1) % lista.length];
}
/// Localizes a preset's raw `nombre` for the equalizer custom action's
/// label (Design "EQ custom actions", item 4) — mirrors
/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated
/// rather than shared: that file is UI-widget layer, this one is the
/// service/handler layer, and the mapping is a single small switch, not
/// worth a cross-layer import for). An unrecognized name (e.g. a future
/// user-named custom preset) falls through to the raw name verbatim.
String nombrePresetVisible(AppLocalizations l10n, String nombre) {
return switch (nombre) {
'Flat' => l10n.equalizerPresetFlat,
'Rock' => l10n.equalizerPresetRock,
'Pop' => l10n.equalizerPresetPop,
'Bass Boost' => l10n.equalizerPresetBassBoost,
'Jazz' => l10n.equalizerPresetJazz,
'Voz' => l10n.equalizerPresetVoice,
'Personalizado' => l10n.equalizerPresetCustom,
_ => nombre,
};
}
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
/// toggle. The previous design paired this with a SECOND action that cycled
/// through the six factory presets; that action is REMOVED. On-device
/// feedback: many head units render custom actions icon-first, so two
/// static, non-parametrized glyphs sitting side by side looked identical/
/// dead even though the toggle's own icon DID change and the cycle action
/// DID work — a monochrome icon simply cannot legibly encode "which of six
/// presets" the way a browsable list's text rows can. Preset selection now
/// lives in the "Ecualizador" browsable folder instead (see
/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
/// instead.
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
/// without the native Equalizer effect gets no EQ actions at all, not
/// broken ones.
///
/// On-device feedback follow-up: this action used to reuse the SAME
/// `ic_stat_pluriwave` drawable as everything else and was visually
/// indistinguishable on a car head unit, which foregrounds the icon over
/// the label. It now gets its own dedicated drawables
/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
/// handler dependency.
List<MediaControl> controlesEcualizadorPersonalizados({
required bool disponible,
required bool activo,
required AppLocalizations l10n,
}) {
if (!disponible) return const [];
return [
MediaControl.custom(
androidIcon:
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
label:
activo
? l10n.eqCustomActionDisableLabel
: l10n.eqCustomActionEnableLabel,
name: accionEqToggle,
),
];
}
/// Content-style extras for the Ecualizador folder's items (decision
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
/// are deliberately duplicated across this handler/service layer and the
/// pure browse-tree builder layer rather than cross-layer-shared). List
/// style, not grid: these items are short text options with no artwork of
/// their own, unlike a station or local-track tile.
const _contentStyleListaEq = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
};
/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
/// preset must be visibly marked").
///
/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
/// and REJECTED as the marking mechanism: this project's `audio_service`
/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
/// 's list/grid hints are exposed — and the raw platform key itself is
/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
/// was already listened to"), not item SELECTION; repurposing it here could
/// render as "already played" on some head units, which would be actively
/// misleading for a preset picker, and there is no way to verify its actual
/// rendering on a real head unit from this environment. A plain-text
/// marker renders identically and unambiguously on every head unit, which
/// an unverifiable, semantically-mismatched extras key cannot guarantee.
String _marcarActivoEq(String titulo, {required bool activo}) =>
activo ? '✓ $titulo' : titulo;
/// Builds the "Ecualizador" folder's children for the Android Auto browse
/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
/// six factory presets in [PresetEcualizador.presets] order, each localized
/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
/// label already uses, so a preset's name reads identically whether the
/// driver sees it in the now-playing screen's tooltip or in this folder.
/// All items are playable: tapping one is dispatched through
/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
/// dart`), the same seam every other browse-tree leaf already uses; this
/// folder has no sub-browsing. Exactly one item is marked active via
/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
/// whichever preset equals [presetActual] — never both at once, and never
/// zero once this function is reached (an unresolvable [presetActual] with
/// [activo] `true` simply marks nothing, which cannot happen in practice
/// since [presetActual] always originates from [PresetEcualizador.presets]
/// or a "Personalizado" tweak that would just leave every item unmarked
/// rather than mis-marking one).
List<MediaItem> itemsEcualizadorAuto({
required bool activo,
required PresetEcualizador presetActual,
required AppLocalizations l10n,
}) {
final constructor = ConstructorArbolAuto();
final items = <MediaItem>[
MediaItem(
id: ConstructorArbolAuto.idDesactivarEq,
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
playable: true,
extras: _contentStyleListaEq,
),
];
for (final preset in PresetEcualizador.presets) {
items.add(
MediaItem(
id: constructor.idPresetEq(preset.nombre),
title: _marcarActivoEq(
nombrePresetVisible(l10n, preset.nombre),
activo: activo && preset == presetActual,
),
playable: true,
extras: _contentStyleListaEq,
),
);
}
return items;
}
/// 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 = mediaItemParaEmisora(
emisora,
l10n: lookupAppLocalizations(const Locale('es')),
);
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;
/// `true` only for the window inside [_cambiarFuente] where the OLD player
/// has been disposed and the FRESH one has not loaded its URL yet — the
/// window in which `playerStateStream` unavoidably emits a transient
/// `idle` that is NOT a stop. [mapearEstadoProceso] masks that one `idle`
/// as `loading` so `audio_service` does not tear the foreground service
/// (and with it the media notification) down mid-source-change; see that
/// function's doc for the full mechanism.
///
/// A value stuck at `true` is the ONLY risk this flag introduces: a real
/// user stop would then be masked away from `idle` and the service would
/// never stop, leaving an unkillable notification. It is therefore cleared
/// by a `finally` in [_cambiarFuente] (which covers normal completion,
/// both revision-mismatch `return`s, every `rethrow`, and any non-`Exception`
/// `Error` that no catch clause matches), AND eagerly at the top of every
/// catch clause, AND at the start of [stop] and [_gestionarErrorReproduccion]
/// — i.e. before every single `_player.stop()` call in this class.
bool _cambiandoFuente = false;
/// 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: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
),
systemActions: {
MediaAction.seek,
MediaAction.stop,
if (colaActiva) MediaAction.skipToPrevious,
if (colaActiva) MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso(
proc,
cambiandoFuente: _cambiandoFuente,
),
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());
}
});
}
/// The full transport `controls` list for a `playbackState` push (item 4):
/// the existing skip/play-pause/stop set, plus the equalizer's custom
/// actions appended at the end. Appending (rather than interleaving) keeps
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
/// at their existing indices 0-3, so `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
/// NOTHING custom goes in this list. `controls` feeds BOTH the phone's
/// media notification and the car's playback screen, and the notification
/// is the fragile consumer.
///
/// `AudioService.setState` (AudioService.java:513-520) walks every control
/// through `createCustomAction` BEFORE it reaches
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559),
/// which is the ONLY place the notification is ever posted.
/// `createCustomAction` resolves the icon by name via
/// `getResources().getIdentifier(...)` (:415-420) — which returns 0 on a
/// miss — and hands it to `PlaybackStateCompat.CustomAction.Builder`, which
/// throws on a 0 icon or an empty label. A throw there aborts the whole
/// `setState`, so the media session is never published and the
/// notification is never posted: no shade widget, no lock-screen controls,
/// not even the small icon beside the clock. Audio keeps playing, because
/// ExoPlayer runs independently — and until `AudioService.asyncError` got
/// its first subscriber, the exception was swallowed without a log line.
///
/// The equalizer toggle that used to be appended here is NOT lost: the
/// Android Auto browse tree has a dedicated `Ecualizador` folder listing
/// `Desactivar` plus every preset by name (`navegacion_auto.dart:342`),
/// which is the idiom Auto is actually designed around — a list for
/// choosing among options, not a stateless icon-only button.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
];
/// Re-pushes `playbackState` with a freshly built controls list.
///
/// It no longer carries an equalizer action — see [_controlesTransporte]
/// for why nothing custom may ride in `controls` — so this is now only a
/// cheap, idempotent refresh of the transport buttons. Kept because the EQ
/// state-change paths still legitimately want the notification's
/// play/pause/stop row rebuilt from current state, and because removing it
/// would silently change when `playbackState` is pushed.
void _actualizarControlesEq() {
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: _colaLocal != null,
playing: playbackState.value.playing,
),
),
);
}
/// 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) {
// Terminal-error path also ends in `_player.stop()` below, and it is
// reachable from `_eventosSub`'s `onError` WHILE a source change is still
// in flight. Dropping the mask here keeps the invariant total: the flag
// is `false` before every `_player.stop()` call in this class.
_cambiandoFuente = false;
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;
}
/// Public entry point for EVERY external play (phone `reproducir`, car
/// `emisora:`/`grupo:`/`pista:` 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,
),
);
// Opens the masking window BEFORE `_recrearPlayer`, which is what
// disposes the old player and constructs the fresh one whose first
// `playerStateStream` event is the transient `idle` we must not forward
// (see [mapearEstadoProceso]).
_cambiandoFuente = true;
try {
await _recrearPlayer();
if (revision != _revisionFuente) return;
await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente);
// Source swap complete: the fresh player's transient `idle` is behind
// us, so stop masking immediately — before anything below can await —
// and let a real `idle` through again from here on.
_cambiandoFuente = false;
if (revision != _revisionFuente) return;
_iniciarPlaySinBloquear(mediaItem, revision);
unawaited(_activarEcualizador());
} on PlayerException catch (e) {
// Cleared BEFORE `_gestionarErrorReproduccion`, not just by the
// `finally`: that method calls `_player.stop()` without awaiting it, so
// the resulting `idle` could otherwise land while the mask was still
// up and be rewritten to `loading`. Same reason in the two clauses
// below.
_cambiandoFuente = false;
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) {
_cambiandoFuente = false;
// 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) {
_cambiandoFuente = false;
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;
} finally {
// Leak-proof backstop. Dart runs `finally` on EVERY exit from the
// block above: normal completion, both `revision != _revisionFuente`
// early returns, every `throw`/`rethrow` out of a catch clause, and —
// crucially — any `Error` (as opposed to `Exception`) that none of the
// three clauses catches. The flag must never depend on a single
// hand-audited exit path, because a `_cambiandoFuente` stuck at `true`
// would mask a REAL stop's `idle` and leave the notification unkillable.
_cambiandoFuente = false;
}
}
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;
}
// Item 4: an availability flip (e.g. a station switch that lands on a
// device without the native Equalizer effect) must show/hide the EQ
// custom actions immediately, not wait for a coincidental later
// player-state event.
_actualizarControlesEq();
}
/// 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) {
try {
await _eq.setEnabled(_ecualizadorActivo);
if (_ecualizadorActivo) {
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 (_) {}
}
// Item 4: keeps the EQ custom action's preset-cycle label in sync
// regardless of WHO changed the preset (a car customAction tap or the
// phone settings screen via EstadoEcualizador) — single chokepoint.
_actualizarControlesEq();
}
/// 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) {
try {
await _eq.setEnabled(activo);
if (activo) {
await aplicarPreset(_presetActual);
}
} catch (_) {}
}
// Item 4: keeps the EQ custom action's on/off label in sync regardless
// of WHO toggled it (a car customAction tap or the phone settings
// screen via EstadoEcualizador).
_actualizarControlesEq();
}
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);
}
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
/// existing [_activarEcualizador] (already does the correct idempotent
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
/// [_ecualizadorActivo] rather than forcing it on). Called by
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
/// interface member's doc for why the existing session-id-change trigger
/// misses this case.
@override
Future<void> reaplicarEcualizador() => _activarEcualizador();
@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();
// Genuine user stop: drop the source-change mask BEFORE `_player.stop()`,
// so its `idle` reaches `playbackState` as a REAL `idle` and
// `audio_service` tears the foreground service down as it always has.
// `stop()` never pushes `idle` itself — `BaseAudioHandler.stop()` is
// empty and the teardown is driven entirely by the player's emission —
// so a stop landing while a station change is still in flight would
// otherwise be masked to `loading` and the notification would become
// unkillable. Paired with `_revisionFuente++` below, which invalidates
// that in-flight change; its `finally` only re-clears the flag, and
// nothing re-arms it (the single `= true` assignment already ran).
_cambiandoFuente = false;
_revisionFuente++;
await _player.stop();
// Publish `idle` OURSELVES rather than trusting the player to emit it.
// `just_audio`'s `playerStateStream` is `.distinct()` over a value-equal
// `PlayerState`, so a stop landing on an already-idle player (a station
// change stopped before its native init finished pushing `loading`)
// emits NOTHING. Combined with the source-change mask above — which
// WRITES `loading` into `playbackState` rather than filtering at read
// time — that would leave the state stuck at `loading` forever:
// `audio_service` only tears the service down on a non-idle -> idle
// transition (`audio_service.dart:1131-1136`), so the notification would
// survive as an unkillable "cargando" with a dead Stop button. Strictly
// worse than the bug this branch fixes. Additive and idempotent: when
// the player DOES emit its own `idle`, this simply lands first and the
// duplicate is a no-op transition.
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.idle,
playing: false,
errorMessage: null,
),
);
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);
}
/// Dispatches the equalizer's only custom action (decision
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
/// to the existing [setEcualizadorActivo] — the SAME entry point the
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
/// phone tap have identical effects and both refresh the action's label
/// via `_actualizarControlesEq()` (already wired into that method). The
/// preset-cycling action that used to live here is REMOVED — preset
/// selection now goes through the "Ecualizador" browsable folder (see
/// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched
/// from [playFromMediaId] below). Any other [name] is a no-op — never
/// throws.
@override
Future<dynamic> customAction(
String name, [
Map<String, dynamic>? extras,
]) async {
switch (name) {
case accionEqToggle:
await setEcualizadorActivo(!_ecualizadorActivo);
}
}
@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();
// Handler teardown: release the bootstrap-owned `AudioService.asyncError`
// subscription too, so it cannot outlive the handler it was instrumenting.
// Never throws out of teardown — a failing cleanup hook must not prevent
// the rest of `onTaskRemoved` from having completed above.
try {
await _limpiezaArranqueGlobal?.call();
} catch (_) {}
}
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
// Item 3: delegates to the top-level, unit-testable function so the
// `faviconUsable` guard (never reflect the on-brand fallback artUri
// back as a real favicon) is covered without instantiating the handler.
return emisoraDesdeMediaItem(mediaItem);
}
// ── 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.
@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);
}
final musicaLocal = await hijosMusicaLocal(
parentMediaId,
fuente: fuenteLocal,
);
if (musicaLocal != null) return musicaLocal;
// Ecualizador folder (decision `auto/ecualizador-diseno`): needs no
// external data source, unlike every branch below it -- checked
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
// local-music branch above is also resolved before that gate.
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return itemsEcualizadorAuto(
activo: _ecualizadorActivo,
presetActual: _presetActual,
l10n: _textos,
);
}
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 {
// Local-track playback (Design "Local Track Playback Reuses Existing
// Pipeline", Spec "User selects a local track"): FIRST branch,
// unconditional `return` — 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): SECOND
// branch, after `pista:`, before the station fallthrough — mirrors the
// branch 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;
}
// Equalizer preset selection (decision `auto/ecualizador-diseno`):
// THIRD branch, same unconditional-return shape as the two above --
// an `eq_preset:` id never falls through to station routing.
if (constructorArbol.esPresetEqMediaId(mediaId)) {
await seleccionarPresetEqPorMediaId(
mediaId,
activo: _ecualizadorActivo,
aplicarPreset: aplicarPreset,
activarEcualizador: setEcualizadorActivo,
);
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();
}
}