import 'dart:async'; import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show debugPrint, 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> Function()? _fuentePresetsPersonalizadosGlobal; void registrarFuentePresetsPersonalizados( Future> 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> _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:` 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 presetsEcualizadorAuto({ required List personalizados, List? 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 Function()? _limpiezaArranqueGlobal; void registrarLimpiezaArranque(Future 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? 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 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 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 itemsEcualizadorAuto({ required bool activo, required PresetEcualizador presetActual, required AppLocalizations l10n, List presetsPersonalizados = const [], }) { final constructor = ConstructorArbolAuto(); final items = [ 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 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 reproducir(Emisora emisora) async { final item = mediaItemParaEmisora( emisora, l10n: lookupAppLocalizations(const Locale('es')), ); await _handler.playMediaItem(item); } Future pausar() => _handler.pause(); Future reanudar() => _handler.play(); Future togglePlay() async { if (_handler.playbackState.value.playing) { await pausar(); } else { await reanudar(); } } Future detener() => _handler.stop(); Future setVolumen(double vol) => _handler.setVolumen(vol); double get volumen => _handler.volumen; bool get estaSonando => _handler.playbackState.value.playing; Stream get androidAudioSessionIdStream async* { yield _handler.androidAudioSessionId; yield* _handler.androidAudioSessionIdStream; } Future dispose() async {} // ── Ecualizador ─────────────────────────────────────────────────────────── AndroidEqualizer? get ecualizador => _handler.ecualizador; bool get ecualizadorDisponible => _handler.ecualizadorDisponible; PresetEcualizador get presetActual => _handler.presetActual; Future aplicarPreset(PresetEcualizador preset) => _handler.aplicarPreset(preset); Future setEcualizadorActivo(bool activo) => _handler.setEcualizadorActivo(activo); Future 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? _estadoPlayerSub; StreamSubscription? _bufferedSub; StreamSubscription? _duracionSub; StreamSubscription? _eventosSub; StreamSubscription? _androidAudioSessionIdSub; final _androidAudioSessionIdController = StreamController.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 _colaCambioFuente = Future.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 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 | grep PluriWave /// /// It uses [debugPrint] and NOT `dart:developer`'s `log`, and that is not a /// style choice. `log()` writes to the VM service, which a RELEASE build /// does not have — so this trace, and every error line in this file, was /// invisible in the only build that ever runs in a car. Weeks of "no /// evidence" were this, not a quiet app. Do not convert these back. 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; debugPrint('[PluriWave][ServicioAudio] estado $traza'); } /// Binds [construirControlesTransporte] — which holds the whole contract, /// including why the equalizer must precede `stop` — to this handler's live /// equalizer state. List _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; } debugPrint('[PluriWave][ServicioAudio] Error reproducción: $codigoLog'); _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; debugPrint( '[PluriWave][ServicioAudio] Stall de red, reintento ${_reconexion.intentos}/' '${_reconexion.maxReintentos} en ' '${_reconexion.retrasoParaIntento(_reconexion.intentos).inSeconds}s', ); 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 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 _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 _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 _iniciarColaLocal(List 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 _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; debugPrint( '[PluriWave][ServicioAudio] Error inesperado en playMediaItem: $e', ); 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 _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) { debugPrint( '[PluriWave][ServicioAudio] Error al iniciar ${mediaItem.title}: $error', ); if (revision == _revisionFuente) { _gestionarErrorReproduccion(error); } }), ); } Future _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 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 setBanda(int index, double db) async { final bandas = List.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 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 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 pausar() => pause(); @override Future reanudar() => play(); @override Future 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 reaplicarEcualizador() => _activarEcualizador(); @override Future play() { _intencionReproducir = true; return _player.play(); } @override Future 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 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 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 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 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 _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) { debugPrint('[PluriWave][ServicioAudio] Error saltando de emisora: $e'); } } /// 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 customAction( String name, [ Map? extras, ]) async { switch (name) { case accionEqToggle: await setEcualizadorActivo(!_ecualizadorActivo); } } @override Future 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> getChildren( String parentMediaId, [ Map? 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 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 playFromSearch( String query, [ Map? extras, ]) async { try { final fuente = _fuenteNavegacionGlobal; if (fuente == null) return; final candidatas = [ ...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) { debugPrint( '[PluriWave][ServicioAudio] Error en playFromSearch($query): $e', ); } } @override Future playFromMediaId( String mediaId, [ Map? 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. debugPrint( '[PluriWave][ServicioAudio] Error en playFromMediaId($mediaId): $e', ); } } Future> _listaParaCarpeta( FuenteEmisorasAuto fuente, String parentId, ) => switch (parentId) { ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(), ConstructorArbolAuto.idTodas => fuente.todas(), _ => Future.value(const []), }; Future> _universoCompleto(FuenteEmisorasAuto fuente) async { final listas = await Future.wait([ fuente.favoritos(), fuente.misEmisoras(), fuente.todas(), ]); return listas.expand((lista) => lista).toList(); } }