Files
pluriwave/lib/servicios/servicio_audio.dart
T
FreeTLab 3398d02a43 fix(auto): keep the service alive through interruptions, restore local music
Four car reports, two root causes.

1. Local music vanished from the Android Auto menu. Self-inflicted, by
c1afe72 yesterday.

That commit moved registrarFuenteNavegacion above every await to keep a
headless engine from dying before it ran -- but left
registrarFuenteMusicaLocal below `await SharedPreferences.getInstance()`.
The root menu decides whether to offer "Música Local" with
`fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`, so
the car could now get a root response in the window between the two
registrations, find a null source, and be told there is no local music.
Android Auto caches the browse root, so it stayed missing for the whole
session. Before the reorder both registrations sat together after the
await and the window did not exist.

FuenteMusicaLocalAutoImpl never needed prefs to be CONSTRUCTED -- it
resolves them lazily per call, the same convention ServicioAlarmas uses
-- so it now registers beside the station source, above every await, and
the window is gone rather than narrowed.

2. PluriWave disappeared from the Auto pane mid-drive, the playback
screen sat frozen, and the equalizer was lost on every navigation
prompt. One cause for all three.

androidWillPauseWhenDucked: true made audio_session translate Android's
AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a car that
fires constantly: every navigation instruction, every speed-camera
warning, every voice assistant. And a pause publishes playing:false,
which AudioService.setState turns into exitPlayingState() and, with
androidStopForegroundOnPause: true, into stopForeground(...). The
plugin's own doc for that flag says what follows: "while in this lower
priority state, the operating system will also be able to kill your
service at any time to reclaim resources". A killed service is a media
session that vanishes from the car pane -- and another media app takes
the slot.

Now the app ducks instead of pausing, so playing stays true and session,
notification and pane all survive an interruption; and the service stays
foreground even on a real pause, so a genuine one is not a death
sentence either. androidNotificationOngoing goes to false because the
plugin asserts it implies stopForegroundOnPause, and nothing is lost: a
foreground service already forces the notification to be ongoing.

A real, non-duckable focus loss (a phone call) still pauses and still
auto-resumes -- asserted, so the duck change cannot silently turn a call
into a station playing over it.

3. Previous/next on the car playback screen, for stations too.

skipToPrevious/skipToNext are now advertised unconditionally, since
Android Auto only draws those buttons when the app declares support.
They are no longer inert without a local queue: they walk the narrowest
list the current station belongs to -- favourites, then my stations,
then the catalogue -- wrapping at both ends, because a button that goes
dead at the end of a list reads as broken on a screen with no visible
list position. Matching is by uuid so a refreshed snapshot still
resolves, and a station in no list leaves playback untouched.

The equalizer toggle still fits alongside them: prev/next take their two
reserved slots and the equalizer claims the remaining custom-action room
because construirControlesTransporte places it before MediaControl.stop.

The phone notification is deliberately untouched: `controls` still gates
skip on an active queue, so nativeActions and
androidCompactActionIndices are byte-identical. Only systemActions
changed, and only the car reads those.

Tests: 1146 -> 1158.
2026-08-06 19:49:57 +02:00

1822 lines
75 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;
}
/// User-saved EQ presets browse source — registered from main.dart, mirrors
/// the two registrations above.
///
/// On-device feedback: the car's `Ecualizador` folder only ever listed the
/// six FACTORY presets, so a driver who had carefully saved their own could
/// not reach it from the car at all — the one place a preset picker is most
/// useful. The handler owns no persistence, so the list arrives through this
/// seam exactly like stations and local music do.
///
/// A function rather than the service object: the folder needs a fresh read
/// on every browse (a preset saved on the phone must appear in the car
/// without an app restart), and this keeps `servicio_audio.dart` from
/// importing the persistence layer. `null` until registered (headless cold
/// bind) — consumers fall back to factory presets only, never throw.
Future<List<PresetEcualizador>> Function()? _fuentePresetsPersonalizadosGlobal;
void registrarFuentePresetsPersonalizados(
Future<List<PresetEcualizador>> Function() fuente,
) {
_fuentePresetsPersonalizadosGlobal = fuente;
}
/// Reads the registered custom-preset source, tolerating both "never
/// registered" and "the read blew up" as the same empty result: a
/// diagnostics-grade failure must degrade the folder to its factory presets,
/// never make browsing fail.
Future<List<PresetEcualizador>> _leerPresetsPersonalizados() async {
final fuente = _fuentePresetsPersonalizadosGlobal;
if (fuente == null) return const [];
try {
return await fuente();
} catch (_) {
return const [];
}
}
/// The full ordered preset universe the car's `Ecualizador` folder offers:
/// the six factory presets first, then [personalizados] in save order.
///
/// A custom preset whose `nombre` matches a factory preset is DROPPED, not
/// appended. Identity here is the raw name — [ConstructorArbolAuto.idPresetEq]
/// builds `eq_preset:<nombre>` from it and
/// [ConstructorArbolAuto.resolverPresetEq] resolves by first name match — so
/// two entries sharing a name would produce one media id that can only ever
/// reach the first of them. Rendering an item that silently applies a
/// different preset than the one whose name it shows is worse than not
/// rendering it, and the factory entry is the one the id is guaranteed to
/// resolve to.
///
/// Single source of truth on purpose: [itemsEcualizadorAuto] builds the items
/// from this list and the tap dispatch resolves against this same list, so
/// the folder can never show an item that resolution then refuses.
List<PresetEcualizador> presetsEcualizadorAuto({
required List<PresetEcualizador> personalizados,
List<PresetEcualizador>? deFabrica,
}) {
final fabrica = deFabrica ?? PresetEcualizador.presets;
final nombresFabrica = fabrica.map((p) => p.nombre).toSet();
return [
...fabrica,
...personalizados.where((p) => !nombresFabrica.contains(p.nombre)),
];
}
/// 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,
),
];
}
/// The handler's full transport `controls` list for a `playbackState` push.
///
/// Top-level and public so tests exercise THIS function rather than a copy of
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
/// re-declare the list inline, which meant it stayed green while asserting a
/// shape `lib/` no longer produced — a guard that cannot see the thing it
/// guards. `PluriWaveAudioHandler` itself cannot be instantiated in a unit
/// test (a real `just_audio.AudioPlayer` needs platform MethodChannels), so
/// pulling the pure part out is the only way to test the real thing.
///
/// ORDER MATTERS, and only for the car.
///
/// On Android 13+ `createCustomAction` (AudioService.java:466-469) turns
/// [MediaControl.stop] into a `CUSTOM_ACTION_STOP` custom action too. So on a
/// modern phone the car receives TWO custom actions, in list order, and a head
/// unit that exposes a single custom-action slot shows only the first and
/// buries the rest in an overflow menu — which is why the equalizer toggle
/// stayed invisible on the playback screen even once it was back in this list
/// (reported on v1.2.14+136, which does contain it).
///
/// The equalizer therefore goes BEFORE `stop`, and wins that slot on purpose:
/// the car already has its own path to stop playback and Auto's template
/// renders play/pause itself, while the equalizer is reachable no other way
/// from this screen.
///
/// The phone notification is untouched by that ordering, on every API level.
/// `setState` (AudioService.java:513-521) splits this list by whether a
/// control carries a `customAction`: on 13+ `stop` goes to `customActions`
/// (never the notification) and the equalizer was never in `nativeActions`
/// anyway; below 13 the equalizer is the only custom action and `stop` stays
/// native. Either way `nativeActions` comes out as
/// `[prev?, play/pause, stop, next?]`, and `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) still lands on play/pause.
List<MediaControl> construirControlesTransporte({
required bool colaActiva,
required bool playing,
required bool eqDisponible,
required bool eqActivo,
required AppLocalizations l10n,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
...controlesEcualizadorPersonalizados(
disponible: eqDisponible,
activo: eqActivo,
l10n: l10n,
),
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
];
/// 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).
///
/// [presetsPersonalizados] are the user's own saved presets, appended after
/// the factory six by [presetsEcualizadorAuto] (which also settles name
/// collisions). They render through the same [nombrePresetVisible] call as
/// everything else: that helper passes an unrecognized name through
/// verbatim, which is exactly right for a name the user typed themselves.
List<MediaItem> itemsEcualizadorAuto({
required bool activo,
required PresetEcualizador presetActual,
required AppLocalizations l10n,
List<PresetEcualizador> presetsPersonalizados = const [],
}) {
final constructor = ConstructorArbolAuto();
final items = <MediaItem>[
MediaItem(
id: ConstructorArbolAuto.idDesactivarEq,
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
playable: true,
extras: _contentStyleListaEq,
),
];
for (final preset in presetsEcualizadorAuto(
personalizados: presetsPersonalizados,
)) {
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<Duration?>? _duracionSub;
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,
),
// Android for Cars, "Enable playback control": «Android Auto and
// AAOS display playback controls based on the actions that are
// enabled in the PlaybackStateCompat object. By default, your app
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
//
// This set had carried only `seek` + `stop` since the very first
// commit, so the required transport actions were never advertised.
// The car got away with it for a long time — but Android Auto is a
// separate app that updates itself, so a tolerance it used to have
// can disappear without a single line changing on our side. That
// matches the report exactly: "it used to work, and in the latest
// versions it doesn't", with no audio commit in between that could
// explain it.
//
// The phone notification never depended on any of this: it builds
// its play/pause button from `controls`, which is why the symptom
// is car-only.
systemActions: {
MediaAction.play,
MediaAction.pause,
MediaAction.playPause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
MediaAction.seek,
// Previous/next are advertised ALWAYS now, not only for a local
// queue. Android Auto reserves those two slots and only hands the
// space to custom actions when the app declares no support, so
// this is what puts prev/next on the car's transport row -- and
// `skipToNext`/`skipToPrevious` fall back to station-to-station
// skipping when there is no queue, so neither button is inert.
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso(
proc,
cambiandoFuente: _cambiandoFuente,
),
playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
_trazarEstadoPublicado();
});
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(
playbackState.value.copyWith(
bufferedPosition: pos,
// Must ride along: `copyWith` stamps a fresh `updateTime` but keeps
// the old `updatePosition`, so a push without it actively tells the
// client the PREVIOUS position is current NOW — freezing the bar
// between player-state events. This stream ticks ~2/s, which is
// what keeps the car's bar smooth.
updatePosition: _player.position,
),
);
});
// Duration arrives asynchronously once the source is parsed, and Android
// Auto draws no progress bar for a MediaItem without one. Radio streams
// report null (correct: live audio has no length) and are left alone.
_duracionSub = _player.durationStream.listen((duracion) {
final actual = mediaItem.value;
if (duracion == null || actual == null) return;
if (actual.duration == duracion) return;
mediaItem.add(actual.copyWith(duration: duracion));
});
_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());
}
});
}
String? _ultimaTrazaEstado;
/// Logs the state actually handed to `AudioService.setState`, once per real
/// change (this fires on every player event, so unconditional logging would
/// bury the signal).
///
/// Exists for one open question that static reading could not settle: the
/// Android Auto playback screen shows PLAY while a station is audibly
/// playing. The car does NOT take that icon from `controls` — it takes it
/// from `PlaybackStateCompat.getState()` (AudioService.java:601-611), where
/// `ready` + `playing` is the only combination that yields `STATE_PLAYING`;
/// `idle` gives `STATE_NONE`, which is what a freshly created session
/// carries (:319) and what a car would render as a play button. Every
/// `playbackState.add` in this file was audited and none publishes
/// `playing: false` while audio runs, so the failing input is unknown and
/// any fix would be guesswork.
///
/// `eqDisponible` rides along because the equalizer custom action is gated
/// on it and the flag is otherwise unobservable — one car session answers
/// both questions at once:
/// adb logcat -s ServicioAudio
void _trazarEstadoPublicado() {
final s = playbackState.value;
final traza =
'proc=${s.processingState.name} playing=${s.playing} '
'eqDisponible=$_eqDisponible eqActivo=$_ecualizadorActivo '
'custom=${s.controls.where((c) => c.customAction != null).length} '
'controles=${s.controls.length}';
if (traza == _ultimaTrazaEstado) return;
_ultimaTrazaEstado = traza;
developer.log(traza, name: 'ServicioAudio');
}
/// Binds [construirControlesTransporte] — which holds the whole contract,
/// including why the equalizer must precede `stop` — to this handler's live
/// equalizer state.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
}) => construirControlesTransporte(
colaActiva: colaActiva,
playing: playing,
eqDisponible: _eqDisponible,
eqActivo: _ecualizadorActivo,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled state changes outside a
/// player-state transition (a custom-action tap, or a phone-side toggle),
/// so the equalizer action's icon and label stay in sync on the car's
/// now-playing screen without waiting for an unrelated player event.
/// Idempotent and cheap (no native calls) — safe to call from any EQ
/// state-changing path.
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 _duracionSub?.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). Past the last track, clears the queue and stops — mirroring
/// auto-advance's end-of-queue behavior (no wraparound).
///
/// With NO local queue this now moves to the next STATION instead of doing
/// nothing: the car's transport row offers previous/next for radio too,
/// and a button that is present but inert is worse than no button.
@override
Future<void> skipToNext() async {
final cola = _colaLocal;
if (cola == null) {
await _saltarEmisora(haciaAtras: false);
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).
/// Clamps at the first track (restarts it) instead of wrapping to the last
/// one. With no local queue, moves to the previous STATION — see
/// [skipToNext].
@override
Future<void> skipToPrevious() async {
final cola = _colaLocal;
if (cola == null) {
await _saltarEmisora(haciaAtras: true);
return;
}
final anterior = cola.conAnterior();
_colaLocal = anterior;
await _reproducirEntradaCola(anterior.actual);
}
/// Station-to-station skipping for the car's transport row.
///
/// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest
/// list the current station actually belongs to, favourites first. Anything
/// unresolvable — no source, no current station, a station that is in no
/// list, a single-entry list — leaves playback untouched. Never throws;
/// this runs from a hardware/steering-wheel button and an exception here
/// would surface as the app going silent mid-drive.
Future<void> _saltarEmisora({required bool haciaAtras}) async {
try {
final fuente = _fuenteNavegacionGlobal;
final actual = emisoraActual;
if (fuente == null || actual == null) return;
final lista = listaParaSaltoEmisora(
actual: actual,
favoritos: await fuente.favoritos(),
misEmisoras: await fuente.misEmisoras(),
todas: await fuente.todas(),
);
final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras);
if (destino == null) return;
await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos));
} catch (e) {
developer.log(
'[PluriWave] Error saltando de emisora: $e',
name: 'ServicioAudio',
level: 900,
);
}
}
/// 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 _duracionSub?.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.
// The Ecualizador folder is no longer offered by `raiz()` (owner
// decision: the car keeps only the on/off toggle on the playback
// screen). This branch stays as a TRANSITIONAL courtesy: Android Auto
// caches browse trees on the head unit, so a stale "Ecualizador" entry
// can survive the update for a session or two. Answering it keeps that
// leftover working instead of opening an empty dead folder. Delete
// once no head unit can still be holding the old tree.
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return itemsEcualizadorAuto(
activo: _ecualizadorActivo,
presetActual: _presetActual,
l10n: _textos,
// Read per browse, not cached: a preset saved on the phone must
// show up in the car on the next open, with no app restart.
presetsPersonalizados: await _leerPresetsPersonalizados(),
);
}
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;
}
}
/// Voice search from the car ("pon Radio Clásica").
///
/// `ACTION_PLAY_FROM_SEARCH` is one of the actions Android for Cars
/// documents as required, and it is now advertised in `systemActions` — so
/// it has to actually do something. Advertising it unimplemented would be
/// worse than omitting it: the assistant would accept the command and
/// nothing would play, with no error to explain it.
///
/// Favourites first, then my stations, then the full list, so a station the
/// driver already cares about wins a name tie. Never throws and never plays
/// something arbitrary on a miss — see [emisoraParaBusqueda].
@override
Future<void> playFromSearch(
String query, [
Map<String, dynamic>? extras,
]) async {
try {
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return;
final candidatas = <Emisora>[
...await fuente.favoritos(),
...await fuente.misEmisoras(),
...await fuente.todas(),
];
final emisora = emisoraParaBusqueda(query, candidatas);
if (emisora == null) return;
await playMediaItem(mediaItemParaEmisora(emisora, l10n: _textos));
} catch (e) {
developer.log(
'[PluriWave] Error en playFromSearch($query): $e',
name: 'ServicioAudio',
level: 900,
);
}
}
@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,
// The SAME list `itemsEcualizadorAuto` rendered from, so a tapped
// custom preset resolves instead of silently doing nothing.
presets: presetsEcualizadorAuto(
personalizados: await _leerPresetsPersonalizados(),
),
);
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();
}
}