import 'dart:async'; import 'dart:ui' show Locale, PlatformDispatcher; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; import 'package:just_audio/just_audio.dart'; import 'package:rxdart/rxdart.dart'; import '../estado/estado_entitlement.dart' show esPremiumPersistido; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; import 'cola_local.dart'; import 'contexto_reproduccion.dart'; import 'controlador_reconexion.dart'; import 'emisoras_destacadas.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; /// Read port for the persisted equalizer on/off flag (eq-estado-unico item A). /// In production `main.dart` binds it to `ServicioEcualizador.leerActivo`, /// which needs nothing but the `SharedPreferences` instance already resolved /// before `AudioService.init`. `null` for any caller that has no disk (widget /// tests, fakes) — seeding is then skipped entirely. typedef LeerEqActivoPersistido = Future Function(); /// Write port for the same flag (eq-estado-unico item B). Bound to /// `ServicioEcualizador.guardarActivo`. typedef GuardarEqActivoPersistido = Future Function(bool activo); /// Read port for the persisted skip context — «which list is the driver /// walking» (see [ContextoSalto]). In production `main.dart` binds it to /// `contextoSaltoPersistido`; `null` for any caller with no disk (widget /// tests, fakes), which simply falls back to deriving the context on the spot. typedef LeerContextoSaltoPersistido = Future Function(); /// Read port for the equalizer's persisted PRESET, the exact sibling of /// [LeerEqActivoPersistido]. Bound to `ServicioEcualizador.leerPresetPrincipal` /// in `main.dart`; `null` for any caller with no disk (widget tests, fakes). typedef LeerPresetPersistido = Future Function(); /// Write port for the same context. Bound to `guardarContextoSalto`. typedef GuardarContextoSaltoPersistido = Future Function(ContextoSalto contexto); /// Read port for the persisted last-played station (`ultima_emisora_v1`). /// Bound to `ultimaEmisoraPersistida` in `main.dart`; `null` for any caller /// with no disk (widget tests, fakes), which then neither seeds the cold-start /// metadata nor resumes anything from a bare `play()`. typedef LeerUltimaEmisoraPersistida = Future Function(); /// Write port for the same key, and — since this seam exists — its ONLY /// writer. /// /// It had none: `EstadoRadio._persistirUltimaEmisora` was the sole writer and /// `EstadoRadio` is built by the lazy `ChangeNotifierProvider` in `app.dart`, /// which a headless Android Auto engine (`AudioServicePlugin.java:75-111` /// builds `new FlutterEngine(applicationContext)` with no Activity) never /// reaches. So a session that happened ONLY in the car never updated the key, /// and on the next connect the head unit was offered the station from the /// last time the PHONE was used — the same stale record /// `resolverEmisorasDestacadas` puts first in the free tier's featured folder. typedef GuardarUltimaEmisoraPersistida = Future Function(Emisora emisora); /// Last value read from disk for the equalizer on/off flag, or `null` while /// nothing has been read yet. /// /// This exists purely to close the construction window: `AudioService.init` /// builds the handler through its `builder` callback, and only AFTER that /// future resolves does `main.dart` reach [registrarHandler]. A car tap /// landing inside that window would otherwise hit a handler whose flag had /// never seen disk. Once one engine has read the value, any handler built /// afterwards starts from it instead of from a hardcoded default. bool? _eqActivoPersistido; /// The equalizer's initial on/off state for a freshly started engine. /// /// Pure seam (eq-estado-unico item A): [PluriWaveAudioHandler] used to /// hardcode `_ecualizadorActivo = true`, so a process started HEADLESSLY by /// Android Auto — no Activity, no Provider tree, so no /// `EstadoEcualizador.cargarPersistido()` — played with the equalizer forced /// on while disk and the phone UI both said off. That is the reported «suena /// muy alto con el boton desactivado». /// /// `null` means "nothing was ever persisted" (first install, or a wiped /// preference) and keeps the historical default of ON. It must NOT be /// confused with "off": a user who has never touched the toggle expects the /// equalizer on, and the app has always behaved that way. bool estadoEqInicial({required bool? persistido}) => persistido ?? true; /// The two native operations an equalizer on/off transition is made of, as /// values so their ORDER is a testable fact rather than the incidental shape /// of a method body. /// /// Off-device neither operation is observable (`_eqDisponible` is `false`, and /// `AndroidEqualizer.parameters` never completes without an attached player), /// so before this enum the sequence could only be asserted by reading the /// source — which is how the wrong one shipped. enum PasoEcualizador { /// Write the current preset's band levels into the native effect. ganancias, /// Flip the native effect on or off (`AudioEffect.setEnabled`). habilitacion, } /// Reads the persisted equalizer flag through [leer] exactly once and seeds /// [handler] with it, without ever writing back. /// /// Never throws: an unreadable preference store leaves the handler on /// [estadoEqInicial]'s default rather than taking down the audio bootstrap. Future _sembrarEcualizadorDesdeDisco( PluriWaveAudioHandler handler, LeerEqActivoPersistido leer, ) async { bool? persistido; try { persistido = await leer(); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo leer el estado EQ persistido: $e', ); persistido = null; } _eqActivoPersistido = persistido; await handler.sembrarEcualizadorActivo( estadoEqInicial(persistido: persistido), ); } /// Reads the persisted equalizer PRESET through [leer] exactly once and seeds /// [handler] with it. /// /// The exact sibling of [_sembrarEcualizadorDesdeDisco], and it exists for the /// exact same reason. eq-estado-unico gave the on/off FLAG a UI-independent /// link to disk; the preset never got one, so `_presetActual` stayed on its /// hardcoded `PresetEcualizador.flat`. On a phone that is invisible — /// `EstadoEcualizador` owns the real preset and pushes it into the handler as /// soon as the widget tree exists. On the headless engine Android Auto starts /// there is no widget tree and no `EstadoEcualizador`, so a car toggle /// enabled the equalizer and applied FLAT. /// /// Never throws: an unreadable preference store leaves the handler on the /// historical default rather than taking down the audio bootstrap. Future _sembrarPresetDesdeDisco( PluriWaveAudioHandler handler, LeerPresetPersistido leer, ) async { PresetEcualizador? persistido; try { persistido = await leer(); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo leer el preset EQ persistido: $e', ); persistido = null; } if (persistido == null) return; await handler.sembrarPresetEcualizador(persistido); } /// Wires the freshly built handler into the module-level seams. /// /// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler /// its own, UI-independent link to the equalizer's persisted on/off flag /// (eq-estado-unico items A and B). Before them the flag reached the handler /// only through `EstadoEcualizador.cargarPersistido()`, i.e. only on an /// engine that had actually built the widget tree — which a headless Android /// Auto bind never does. Both are optional so every existing caller (widget /// tests, fakes) keeps compiling and behaving exactly as before. void registrarHandler( PluriWaveAudioHandler handler, { LeerEqActivoPersistido? leerEqActivoPersistido, GuardarEqActivoPersistido? guardarEqActivoPersistido, LeerPresetPersistido? leerPresetPersistido, LeerContextoSaltoPersistido? leerContextoSalto, GuardarContextoSaltoPersistido? guardarContextoSalto, LeerUltimaEmisoraPersistida? leerUltimaEmisora, GuardarUltimaEmisoraPersistida? guardarUltimaEmisora, }) { _handlerGlobal = handler; // Registered BEFORE the seeding below is awaited so that a toggle arriving // during the disk read is still persisted. handler.registrarPersistenciaEq(guardarEqActivoPersistido); // Same seam shape for the skip context (`contexto_reproduccion.dart`). // NOT seeded eagerly like the equalizer flag: the equalizer has to be right // before the first sample plays, whereas the context is only ever needed // when a skip button is pressed — so it is read LAZILY, at most once per // handler, and a driver who never presses skip pays no disk read at all. handler.registrarPersistenciaContextoSalto( leer: leerContextoSalto, guardar: guardarContextoSalto, ); // Same seam shape again for the last-played station. The WRITE half is // registered before anything is awaited for the same reason the equalizer's // is: a station change arriving during the read below must still be // persisted. handler.registrarPersistenciaUltimaEmisora( leer: leerUltimaEmisora, guardar: guardarUltimaEmisora, ); // Cold-start metadata (A3). Seeded eagerly, like the equalizer flag and // unlike the skip context: a head unit asks for the now-playing metadata // the moment it binds, and `audio_service` cannot send any while // `mediaItem` is null. Fire-and-forget and internally guarded, so it is a // no-op without a read port and never clobbers a live station. unawaited(handler.sembrarUltimaEmisoraDesdeDisco()); if (leerEqActivoPersistido != null) { unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido)); } if (leerPresetPersistido != null) { unawaited(_sembrarPresetDesdeDisco(handler, leerPresetPersistido)); } // iap-freemium-unlock (design.md Open Questions, orchestrator-resolved), // generalizado en fix/android-auto-musica-local item 4: invalida // activamente todo id de nivel raíz que un head unit pueda tener cacheado // en vez de esperar a su propio re-bind — ver [registrarInvalidacionArbolAuto]. registrarInvalidacionArbolAuto(() { handler.notificarHijosCambiaron(AudioService.browsableRootId); handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos); handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas); handler.notificarHijosCambiaron(ConstructorArbolAuto.idMisEmisoras); handler.notificarHijosCambiaron(ConstructorArbolAuto.idMusicaLocal); }); } // ───────────────────────────────────────────────────────────────────────────── // 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; } /// Android Auto browse-cache invalidation hook (design.md Open Questions, /// orchestrator-resolved): registered from [registrarHandler] so callers /// can trigger it WITHOUT ever touching `PluriWaveAudioHandler` directly (a /// layering choice — the entitlement layer has no business knowing the /// handler type; it is not, as this doc used to claim, because the handler /// cannot be constructed in a unit test, which is false — see /// [construirControlesTransporte]). `null` until a handler registers /// (headless cold bind, or a widget-only test that never wires audio) — /// [invalidarArbolAuto] tolerates that silently. /// /// GENERALIZADO (fix/android-auto-musica-local, item 4): nació atado a la /// transición free -> premium, y ese nombre escondía para qué sirve de /// verdad. Android Auto CACHEA la raíz, así que hay que invalidarla cada /// vez que el árbol pasa a poder mostrar algo que antes no podía. Hoy lo /// disparan tres sitios: la compra premium (`estado_entitlement.dart`), la /// primera vez que existe una View de verdad — es decir, cuando por fin hay /// Activity y con ella el handler nativo de `pluriwave/file_actions` /// (`main.dart`) — y la elección de carpeta de música local /// (`pantalla_ajustes_musica_local.dart`). void Function()? _invalidarArbolAutoGlobal; /// Registers the hook [invalidarArbolAuto] invokes. Exposed at module /// level (like every other `registrar*` seam in this file) purely so tests /// can inject a fake hook and assert it fires, without instantiating a real /// [PluriWaveAudioHandler]. void registrarInvalidacionArbolAuto(void Function() alInvalidar) { _invalidarArbolAutoGlobal = alInvalidar; } /// Fires the registered Android Auto browse-cache invalidation hook, if /// any. A no-op before a handler ever registers — never throws. void invalidarArbolAuto() { _invalidarArbolAutoGlobal?.call(); } /// Whether a head unit has actually SUBSCRIBED to at least one browse id on /// the live handler (fix/android-auto-musica-local, item 4 — corrected). /// /// This is the precondition that makes [invalidarArbolAuto] worth firing at /// all: [PluriWaveAudioHandler.notificarHijosCambiaron] is /// `_childrenSubjects[id]?.add(...)`, so invalidating before the car has /// subscribed to ANYTHING is provably a silent no-op — which is exactly how /// the old `View.maybeOf(context) != null` trigger managed to burn its /// one-shot latch during the headless cold start and never fire again. /// /// Module-level, like every other seam in this file, so `main.dart` can ask /// the question without importing the handler type, and `false` when no /// handler has registered yet (headless cold bind, widget-only tests). bool hayCocheSuscritoAlArbol() => _handlerGlobal?.hayCocheSuscrito ?? false; /// Pure Android Auto play-path gate decision: whether a station-switch /// dispatch (`playFromMediaId`, `playFromSearch`) must be refused. /// /// NARROWED (fix/auto-quality-guidelines, item 11) from `!premium`. It used /// to refuse EVERY switch for a free-tier user, which is what made the car /// surface useless for the only tier a Play reviewer can ever be in: the /// browse tree offered rows, and tapping any of them did nothing at all. /// /// The original safety property survives intact and is the whole reason this /// takes [esEmisoraGratuita] rather than being deleted: a stale /// `emisora:` from a head unit's CACHED tree, fetched before a /// downgrade or from another device, still cannot play, because its uuid is /// not in the free set. `getChildren` gating alone cannot stop that tap. /// /// Deliberately does NOT gate `play`/`pause`/`stop` — transport control of /// whatever is ALREADY loaded stays free for every tier. bool debeBloquearCambioDeEmisora({ required bool premium, required bool esEmisoraGratuita, }) => !premium && !esEmisoraGratuita; /// `PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` (4) — the exact /// platform code for "this content needs a paid account", forwarded verbatim /// by the plugin alongside `errorMessage`. /// /// Only the `ERROR_RESOLUTION_*` extras (which would render a tappable /// "upgrade" button on the head unit) are unreachable from Dart on /// audio_service 0.18.18, and Google's Android for Cars errors documentation /// explicitly accepts putting the "open the app on your phone" instruction in /// the message text instead — which is what `autoErrorEmisoraPremium` does. const codigoErrorEmisoraPremium = 4; /// `PlaybackStateCompat.ERROR_CODE_APP_ERROR` (1) — a requested media id the /// app could not turn into anything playable (stale/unknown uuid, or a voice /// query that matched nothing). const codigoErrorEmisoraNoDisponible = 1; /// Whether an Android Auto ACTION refusal (a premium tap, a voice query that /// matched nothing) may be published as a full [AudioProcessingState.error], /// given the state the session is already in. /// /// This is the guard on the one-way door in `getPlaybackState()` /// (`AudioService.java:601-611`): `error` maps to `STATE_ERROR` /// UNCONDITIONALLY there — the `playing` flag is not consulted — so an /// `error` published over live audio replaces the entire now-playing screen, /// play/pause/stop included, while the station is still audibly playing. /// /// And it cannot self-heal. `just_audio`'s `playerStateStream` is /// `.distinct()` (`just_audio.dart:279-285`), so a steadily playing ExoPlayer /// emits nothing further and [manejarEstadoPlayer] never runs again; /// `_bufferedSub` then re-asserts the same `error` ~2x/second through /// `copyWith`; and the transient-state floor deliberately excludes `error` /// (see [_esEstadoTransitorio]). Nothing left in the process would ever clear /// it. The free candidate set is only the six compiled-in stations, so almost /// any spoken station name misses — one voice miss and the car is stranded. /// /// So: only a session with nothing to lose (`idle`, or an error already on /// screen) may be moved to `error`. A LOADED session — playing, paused, /// buffering or loading — keeps its state, and the refusal is carried by /// `errorCode`/`errorMessage` alone, which `setState` forwards to /// `PlaybackStateCompat.setErrorMessage` regardless of the state /// (`AudioService.java:541-544`). The driver is told; the session survives. /// /// Pure and top-level so the whole matrix is testable without a handler, /// exactly like [mapearEstadoProceso]. bool puedePublicarErrorTerminalAuto(AudioProcessingState estado) => estado == AudioProcessingState.idle || estado == AudioProcessingState.error; /// 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. /// /// [errorTerminal] is the SECOND masked `idle`, and it exists for the same /// class of bug as the first. After a terminal playback error this file /// publishes `error` + a message and then calls `_player.stop()`, which /// switches `just_audio` to the idle dummy platform /// (`just_audio.dart:1016-1025`); `playerStateStream` emits a distinct /// `(playing: false, idle)`, this function forwarded it verbatim, and /// `audio_service`'s `_observePlaybackState` (`audio_service.dart:1131-1135`) /// answered that non-idle -> idle transition with `AudioService._stop()` — /// `deactivateMediaSession()` + `stopSelf()` (`AudioService.java:355-357`). /// The error message therefore survived on the car screen for at most one /// event-loop turn before PluriWave dropped off the Android Auto playback /// surface entirely, with nothing left to explain why. This is a world-radio /// app; dead streams are routine, and the reviewer is explicitly told to try /// one. Holding `error` keeps the session (and the message, and a route back /// to browse) alive; a genuine user `stop()` clears the latch BEFORE /// `_player.stop()`, exactly like [cambiandoFuente], so the Stop button never /// becomes unkillable. AudioProcessingState mapearEstadoProceso( ProcessingState proc, { required bool cambiandoFuente, bool errorTerminal = false, }) { if (cambiandoFuente && proc == ProcessingState.idle) { return AudioProcessingState.loading; } if (errorTerminal && proc == ProcessingState.idle) { return AudioProcessingState.error; } return switch (proc) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, ProcessingState.buffering => AudioProcessingState.buffering, ProcessingState.ready => AudioProcessingState.ready, ProcessingState.completed => AudioProcessingState.completed, }; } /// Resolves the localizations to use when no widget tree has ever handed the /// handler an [AppLocalizations] — i.e. on the headless Android Auto engine. /// /// Matches on `languageCode` only: a device reporting `en_GB`, `pt_BR` or /// `zh_Hans_CN` must still get English, Portuguese and Chinese rather than the /// Spanish fallback. Anything we do not ship falls back to `es`, which is the /// exact behaviour this file had before — unchanged for every locale that /// could not be served anyway. /// /// Pure and top-level so the whole supported/unsupported matrix is testable /// without a handler (same convention as [mapearEstadoProceso]). AppLocalizations resolverLocalizacionesRespaldo(Locale plataforma) { for (final soportado in AppLocalizations.supportedLocales) { if (soportado.languageCode == plataforma.languageCode) { return lookupAppLocalizations(soportado); } } return lookupAppLocalizations(const Locale('es')); } /// Bridges [AppLocalizations] into the `AppLocalizations`-free browse-tree /// builder (`navegacion_auto.dart`), exactly like [itemsEcualizadorAuto] /// bridges it into the equalizer folder. /// /// THE RULE this exists to enforce: anything a user can read in the car gets /// translated. Every label the browse tree stamps onto a `MediaItem` comes /// through here, so a new car-tree label cannot ship untranslated without /// first getting an ARB key — and `test/l10n/etiquetas_arbol_auto_test.dart` /// fails the build if one tries. /// /// Top-level and pure so the mapping is testable without a handler (same /// convention as [resolverLocalizacionesRespaldo]). EtiquetasArbolAuto etiquetasArbolAutoDesde(AppLocalizations l10n) => EtiquetasArbolAuto( escuchar: l10n.autoCarpetaEscuchar, favoritos: l10n.autoCarpetaFavoritos, todasLasEmisoras: l10n.autoCarpetaTodas, misEmisoras: l10n.autoCarpetaMisEmisoras, musicaLocal: l10n.autoCarpetaMusicaLocal, musicaLocalNoDisponible: l10n.autoMusicaLocalNoDisponible, cargarMas: l10n.autoCargarMas, ordenarPorCalidad: l10n.autoOrdenarPorCalidad, reproducirCarpeta: l10n.autoReproducirCarpeta, reproducirAleatorio: l10n.autoReproducirAleatorio, pistaSinNombre: l10n.autoPistaSinNombre, ); /// 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'; /// What an [accionEqToggle] tap resolves to (eq-estado-unico item C). class DecisionToggleEq { const DecisionToggleEq({ required this.nuevoActivo, required this.requiereLlamadaNativa, }); /// The on/off value the handler must end up holding. final bool nuevoActivo; /// Whether the native `AndroidEqualizer` effect must also be told. `false` /// on a device with no usable Equalizer effect: the flag still flips (so /// the car button never looks inert and the label still updates) but /// nothing is pushed to the platform. final bool requiereLlamadaNativa; @override bool operator ==(Object other) => other is DecisionToggleEq && other.nuevoActivo == nuevoActivo && other.requiereLlamadaNativa == requiereLlamadaNativa; @override int get hashCode => Object.hash(nuevoActivo, requiereLlamadaNativa); @override String toString() => 'DecisionToggleEq(nuevoActivo: $nuevoActivo, ' 'requiereLlamadaNativa: $requiereLlamadaNativa)'; } /// The equalizer toggle decision, extracted out of `customAction` and /// `setEcualizadorActivo` so it can be tested on its own (eq-estado-unico /// item C — this dispatch had ZERO tests: `rg "customAction\(" test/` /// returned nothing). /// /// Reported: «pulsando sobre el boton de ecualizar en Android Auto tampoco /// activaba ni desactivaba». Note what this function deliberately does NOT /// do: gate the flip on [eqDisponible]. The flag always flips, because the /// notification/car label is built from it — a tap that changed nothing at /// all is exactly the "the button does nothing" symptom. DecisionToggleEq decidirToggleEq({ required bool activoActual, required bool eqDisponible, }) => DecisionToggleEq( nuevoActivo: !activoActual, requiereLlamadaNativa: eqDisponible, ); /// Delivers a gain from the app's ±12 dB slider to the device's native /// equalizer, clamped by what the device reports it can do /// (`AndroidEqualizerParameters.min/maxDecibels`, itself /// `Equalizer.getBandLevelRange()` in millibels divided by 1000). /// /// Top-level and pure so the mapping is testable without a device. /// /// THE CONTRACT: the decibels the user reads are the decibels the device is /// asked for. The native range BOUNDS the request; it is not a scale to /// normalise into. Both sides are already the same unit — `just_audio` /// documents `setGain` as taking decibels and its Android bridge does /// `setBandLevel(band, round(gain * 1000.0))`, plain dB to millibels with no /// normalisation — so multiplying by the device's headroom was a unit error. /// /// WHY IT MATTERS, in the app's own terms. The slider is hard-coded /// `min: -12.0, max: 12.0`, the label under each band prints /// `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads `equalizerBandValue` /// = "{value} decibels": one promise, made three ways. Presets are persisted /// and exported as those same raw slider values (`PresetEcualizador.toJson`), /// so scaling at this boundary made an exported backup mean a different SOUND /// on a different phone while displaying identical numbers — and on the /// common asymmetric shape [-12, +19] it multiplied boosts by 1.58 and cuts /// by 1.0, deforming a preset's shape rather than just its depth. /// /// WHAT IS DELIBERATELY KEPT from the mapping this replaces — every invariant /// the «suena muy alto» fix earned. Note the clamp window is widened to /// always contain 0: a naive `db.clamp(minDecibels, maxDecibels)` would, on a /// device reporting a wholly positive range such as [+3, +19], turn a FLAT /// preset's 0 dB into +3 dB of real boost on every band — exactly the bug /// that was fixed. So 0 dB is always exactly 0, the sign of the user's intent /// is never inverted, the result never escapes the native range, a device /// with no headroom above unity can never boost, and a zero-width range /// collapses to 0. double mapearGananciaNativa( double db, { required double minDecibels, required double maxDecibels, }) { final limitado = db.clamp(-12.0, 12.0); // The clamp window is the device's range widened to include 0, so that a // device reporting no headroom on one side collapses that side to "no // change" instead of forcing a gain the user never asked for. final suelo = minDecibels < 0 ? minDecibels : 0.0; final techo = maxDecibels > 0 ? maxDecibels : 0.0; return limitado.clamp(suelo, techo); } /// 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. /// /// This doc used to add that `PluriWaveAudioHandler` "cannot be instantiated /// in a unit test (a real `just_audio.AudioPlayer` needs platform /// MethodChannels)". That is NOT true with just_audio 0.9.46: /// `AudioPlayer`'s constructor resolves its platform lazily and only becomes /// `_active` on a `setUrl`, so the handler constructs fine under /// `flutter test` and `servicio_audio_eq_estado_unico_test.dart` drives its /// real `customAction` dispatch. Only calls that reach the native effect stay /// out of reach (they sit behind `_eqDisponible`, `false` off-device). /// Extracting the pure part is still worth it — it is cheaper and states the /// contract explicitly — but it is no longer the ONLY way. /// /// 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; /// Forwards the handler's own on/off flag, which since eq-estado-unico is /// the flag's SINGLE in-memory owner: `EstadoEcualizador._activo` is a /// display mirror of this getter and `ServicioEcualizador` is its durable /// copy. /// /// Corrects a stale claim that stood here: a car/notification toggle does /// NOT bypass [setEcualizadorActivo]. `PluriWaveAudioHandler.customAction` /// resolves `accionEqToggle` through `decidirToggleEq` and then calls /// `setEcualizadorActivo` — the same entry point the phone settings screen /// uses — so every surface shares one write path, and that path is what /// persists the value. [EstadoEcualizador] still polls this getter on every /// [estadoStream] tick, but only to keep its own display in sync. bool get ecualizadorActivo => _handler.ecualizadorActivo; 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 { /// Per-attempt source-change timeout. /// /// Was 12 s, which blew the Android for Cars ten-second budget on the FIRST /// attempt alone: the driver tapped a station and got a silent spinner for /// twelve seconds before anything at all happened, then five more silent /// attempts behind 1/2/4/8/16 s of backoff — roughly 100 s of nothing per /// tap. 8 s leaves two seconds of headroom for the message to be published /// and rendered, and the retries continue behind it (see /// [_intentarReconexion]), so a slow-but-alive station still recovers. static const timeoutCambioFuentePorDefecto = Duration(seconds: 8); /// See [timeoutCambioFuentePorDefecto]. Mutable ONLY so a test can shrink /// the window to milliseconds; production never writes it. @visibleForTesting static Duration timeoutCambioFuente = timeoutCambioFuentePorDefecto; static const _timeoutCierrePlayer = Duration(seconds: 3); /// How long a `loading`/`buffering` published state may stand before the /// terminal-state floor forces it to a terminal one. /// /// Android for Cars App Quality Guidelines: a media app must never leave the /// head unit on an indefinite spinner. The only exits from a transient state /// today are player events, and `playerStateStream` is `.distinct()`, so a /// repeat of a state we already hold emits NOTHING — a stalled state machine /// stays on the spinner forever with no event left to rescue it. /// /// 8 s, not the 20 s this shipped with. For the failure mode this floor is /// the ONLY exit from — an icecast mount whose socket opens but never /// delivers data, one of the most common failures in this catalogue — the /// window IS the time to the first user-visible message: `setUrl` returns /// inside [timeoutCambioFuente], so no `TimeoutException` and no /// `PlayerException` is ever raised, `_esErrorDeRed` never fires, the /// reconnect machine is never entered, and `_cambiosEnVuelo` is already /// back to 0 (the `finally` runs as soon as the non-blocking /// `_iniciarPlaySinBloquear` returns). Twenty seconds was double the /// ten-second budget the paragraph above cites. Same value as /// [timeoutCambioFuentePorDefecto], for the same reason: two seconds of /// headroom for the message to be published and rendered. static const vigilanciaTransitoriaPorDefecto = Duration(seconds: 8); /// See [vigilanciaTransitoriaPorDefecto]. Mutable ONLY so a test can shrink /// the window to milliseconds; production never writes it. @visibleForTesting static Duration vigilanciaTransitoria = vigilanciaTransitoriaPorDefecto; /// How long an Android Auto ACTION refusal published over a LIVE session /// (see [_publicarErrorAuto]) keeps its `errorCode`/`errorMessage` on the /// head unit before they are cleared again. /// /// A refusal over live audio describes the REQUESTED ACTION, not the /// session, so it has to expire: `_bufferedSub` republishes /// `playbackState.value.copyWith(...)` roughly twice a second and /// `copyWith` carries every omitted field forward /// (`audio_service.dart:400-427`), so `AudioService.java:541-544` re-calls /// `setErrorMessage(code, msg)` on every one of those pushes. Left /// unbounded, a single voice miss makes the session advertise an error /// code and message for the rest of that station's playback, over audio /// that is playing perfectly. /// /// Six seconds: long enough to be read at a glance from a head unit while /// driving (the Cars guidelines' own budget for telling the driver /// something at all is ten), short enough that it cannot be mistaken for a /// description of the session it is riding on. static const ventanaErrorAccionAutoPorDefecto = Duration(seconds: 6); /// See [ventanaErrorAccionAutoPorDefecto]. Mutable ONLY so a test can /// shrink the window to milliseconds; production never writes it. @visibleForTesting static Duration ventanaErrorAccionAuto = ventanaErrorAccionAutoPorDefecto; 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 = _crearEq(); 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; /// How many [_cambiarFuente] bodies are currently between their entry guard /// and their `finally`. A counter rather than a bool because /// `_colaCambioFuente` is a chain, and a reconnect retry can be enqueued /// while the previous attempt is still unwinding. /// /// Read ONLY by the terminal-state floor, to tell «a station is genuinely /// still opening» apart from «the state machine stalled on a spinner». int _cambiosEnVuelo = 0; /// Terminal-state floor (see [vigilanciaTransitoriaPorDefecto]). Timer? _vigilanciaTimer; /// The `errorCode`/`errorMessage` pair the LAST non-terminal /// [_publicarErrorAuto] put on the head unit, and the timer that takes it /// back off again. `null` when no action refusal is standing. /// /// Both halves are remembered so the clear can verify it is removing its /// OWN fields: anything else may have published over them in the meantime /// (a reconnect status message, a terminal playback error), and clearing /// those would blank the car screen for a reason that is still true. ({int codigo, String mensaje})? _errorAccionAuto; Timer? _temporizadorErrorAccionAuto; /// True once the CURRENT source has actually produced audio — i.e. the /// player reached `ready` while `playing`. Reset at the entry of every /// [_cambiarFuente] (a reconnect attempt included) and by [stop]. /// /// This is the terminal-state floor's discriminator, and it exists because /// the obvious one does not work: `just_audio`'s `PlayerState.playing` is /// the play-when-ready INTENT flag, set the instant `play()` is called /// (`just_audio.dart` `_playInterrupted`/`playing`), so the stalled icecast /// mount the floor was built for — socket accepted, not one byte delivered /// — publishes `buffering` with `playing: true` exactly like a healthy /// stream refilling its buffer. Only "did this run ever reach `ready`?" /// separates them. bool _reproduccionEstablecida = false; /// A terminal playback error is standing on the car screen, so the `idle` /// that follows it must NOT be forwarded — see [mapearEstadoProceso]'s /// `errorTerminal` parameter for the teardown chain that idle triggers. /// /// Cleared by every path that represents a fresh user intent: [play], /// [pause], [stop] and the entry of [_cambiarFuente]. [stop] in particular /// clears it BEFORE `_player.stop()`, so a genuine user stop still tears /// the session down. bool _errorTerminal = 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(); /// Per-`parentMediaId` "children changed" subjects (iap-freemium-unlock, /// design.md Open Questions): `audio_service`'s OWN internal listener /// (registered once `AudioService.init` completes) subscribes to /// [subscribeToChildren] and forwards every new value to the platform's /// `notifyChildrenChanged` — the plugin's top-level `notifyChildrenChanged` /// helper is deprecated precisely in favor of this stream-based path. A /// `BehaviorSubject` per id, created lazily on first subscription; /// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic) /// value to trigger the platform notification for that id. /// /// SIN semilla (fix/android-auto-musica-local, item 5). Antes se creaba /// con `.seeded({})`, y un `BehaviorSubject` reenvía su /// valor actual a cada nuevo suscriptor: el listener interno de /// `audio_service` se suscribe la primera vez que el head unit navega un /// id, recibía esa semilla al instante y la reenviaba como /// `notifyChildrenChanged` — o sea, el primer browse de CADA id disparaba /// un `getChildren` extra que nadie pidió. En la raíz eso era un segundo /// round trip de permisos por `pluriwave/file_actions`, justo en la ruta /// que ya estaba fallando en el motor sin Activity. Sin semilla no hay /// nada que reenviar y la invalidación explícita sigue igual. final _childrenSubjects = >>{}; @override ValueStream> subscribeToChildren(String parentMediaId) => _childrenSubjects.putIfAbsent( parentMediaId, BehaviorSubject>.new, ); /// Invalidates a head unit's cached browse listing for [parentMediaId] /// (Design "Open Questions" — actively invalidate on the free -> premium /// transition rather than waiting for the head unit's own re-bind). A /// no-op if nothing ever subscribed to this id. void notificarHijosCambiaron(String parentMediaId) { _childrenSubjects[parentMediaId]?.add({}); } /// True once a head unit has subscribed to at least one browse id, i.e. /// once [notificarHijosCambiaron] can actually reach the car. Read through /// the module-level [hayCocheSuscritoAlArbol]; see its doc for why the /// browse-tree invalidation is gated on it. bool get hayCocheSuscrito => _childrenSubjects.isNotEmpty; /// 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; /// Last [AndroidEqualizerParameters] resolved by [_activarEcualizador]. /// /// Cached rather than re-awaited because `AndroidEqualizer.parameters` is a /// `Completer` future that only completes when the platform player attaches /// (`just_audio.dart` `AndroidEqualizer._activate`). Awaiting it from a /// toggle path therefore does not "read the device", it BLOCKS until the /// next successful load — potentially forever if that load fails — which /// would leave the car's equalizer button pending and its icon stale. /// `null` means "not resolved yet on this player": the gains are skipped and /// [_activarEcualizador] pushes them as soon as the player attaches. AndroidEqualizerParameters? _paramsEq; /// The [PasoEcualizador]s the LAST on/off transition actually executed, in /// execution order. Reset at the start of every transition, so it stays /// bounded and says exactly what the most recent toggle did. /// /// This is the only way a test can see the order: both operations are /// invisible off-device. Asserting "both happened" would have stayed green /// against the very bug this exists for. @visibleForTesting List get pasosEcualizadorEjecutados => List.unmodifiable(_pasosEqEjecutados); final _pasosEqEjecutados = []; /// How many native equalizer calls have thrown. /// /// The native effect is write-only (`just_audio` exposes no /// `Equalizer.getEnabled()`), so a failure used to be indistinguishable /// from success both in a logcat and in a test. @visibleForTesting int get fallosNativosEcualizador => _fallosNativosEq; int _fallosNativosEq = 0; /// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE /// in-memory owner. `EstadoEcualizador._activo` is now a pure display /// mirror of this field, and `ServicioEcualizador` is its durable copy. /// /// It used to be an unconditional `true`, which is exactly why a headless /// Android Auto engine played with the equalizer on while both the phone /// UI and disk said off. It now starts from whatever the last disk read /// produced ([_eqActivoPersistido]); [registrarHandler] then seeds it /// again from the read port, which is the authoritative path. bool _ecualizadorActivo = estadoEqInicial(persistido: _eqActivoPersistido); bool get ecualizadorActivo => _ecualizadorActivo; /// Write port for [_ecualizadorActivo] (eq-estado-unico item B). Injected /// by [registrarHandler] so a car/notification toggle is persisted even /// when no `EstadoEcualizador` has ever been built — which is precisely /// the headless-bind case where the divergence used to be created. GuardarEqActivoPersistido? _persistirEqActivo; /// See [_persistirEqActivo]. Accepts `null` to clear the port (the default /// for every caller that has no disk). void registrarPersistenciaEq(GuardarEqActivoPersistido? guardar) { _persistirEqActivo = guardar; } /// The remembered skip context — «which list am I walking» (see /// [ContextoSalto]). `null` until something derives or reads one. /// /// FROZEN across skips on purpose. Before it, `_saltarEmisora` re-derived /// the list from `emisoraActual` on EVERY button press, so the walk drifted /// under the driver whenever the underlying list changed between two /// presses — and on the free tier it always does, because /// `resolverEmisorasDestacadas` rebuilds itself as `[última reproducida, /// ...curadas]` and therefore reorders itself as you skip, leaving /// `previous` no longer the inverse of `next`. ContextoSalto? _contextoSalto; /// Whether the read port has already been consulted for this handler. The /// disk read happens at most once: after it, `_contextoSalto` (set or still /// null) is the answer. bool _contextoSaltoLeido = false; /// True while [_saltarEmisora] is handing its destination to /// [playMediaItem]. It suppresses the context re-derivation that every /// OTHER play path performs — a skip is a move WITHIN the remembered /// context, never a new choice of context, and re-deriving there is exactly /// what un-freezes the walk. bool _saltandoEmisora = false; LeerContextoSaltoPersistido? _leerContextoSalto; GuardarContextoSaltoPersistido? _guardarContextoSalto; /// Injects the skip context's persistence ports (see [registrarHandler]). /// Both accept `null` — a handler with no disk simply derives the context /// every time, exactly as before this seam existed. void registrarPersistenciaContextoSalto({ LeerContextoSaltoPersistido? leer, GuardarContextoSaltoPersistido? guardar, }) { _leerContextoSalto = leer; _guardarContextoSalto = guardar; } LeerUltimaEmisoraPersistida? _leerUltimaEmisora; GuardarUltimaEmisoraPersistida? _guardarUltimaEmisora; /// Injects the last-played station's persistence ports (see /// [GuardarUltimaEmisoraPersistida]). Both accept `null` — a handler with no /// disk simply never remembers and never restores, exactly as before this /// seam existed. void registrarPersistenciaUltimaEmisora({ LeerUltimaEmisoraPersistida? leer, GuardarUltimaEmisoraPersistida? guardar, }) { _leerUltimaEmisora = leer; _guardarUltimaEmisora = guardar; } /// Whether [item] is a RADIO STATION rather than a local track. /// /// `ultima_emisora_v1` is read back as an `emisora:` row by the car's /// recent root and by `resolverEmisorasDestacadas`, so a `content://` local /// track written there would occupy that slot with a row that resolves to /// nothing when tapped. Every station path builds its item through /// [mediaItemParaEmisora] or `reproducirPorMediaId`, both of which stamp /// `extras['uuid']`; `construirMediaItemColaLocal`/`reproducirPistaLocal` /// stamp `extras['documentId']` instead. Private: it is asserted through /// the real source-change path (a local track must leave the record /// untouched), not as a predicate in isolation. static bool _esMediaItemDeEmisora(MediaItem item) { final uuid = item.extras?['uuid']; return uuid is String && uuid.isNotEmpty; } /// Best-effort write of the last-played station through the injected port. /// /// Never throws and never blocks the source change: a persistence failure /// must cost the driver a stale resume row, never the station they just /// asked for. Traced rather than swallowed, so a dead write channel is /// visible in a car logcat instead of looking exactly like a working one. Future _persistirUltimaEmisora(MediaItem item) async { if (!_esMediaItemDeEmisora(item)) return; final guardar = _guardarUltimaEmisora; if (guardar == null) return; try { await guardar(emisoraDesdeMediaItem(item)); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo guardar la ultima emisora: $e', ); } } /// The persisted last-played station, or `null` when there is no port, no /// record, or the read failed. Never throws — an unreadable record must /// mean "nothing to resume", not a dead Play button. Future _ultimaEmisoraRecordada() async { final leer = _leerUltimaEmisora; if (leer == null) return null; try { return await leer(); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo leer la ultima emisora: $e', ); return null; } } /// Whether a source has actually been opened on this handler — set by /// [_cambiarFuente] once it is past its revision guard, cleared by [stop]. /// /// Deliberately NOT `mediaItem.value != null`: since /// [sembrarUltimaEmisoraDesdeDisco] publishes metadata on a cold start /// WITHOUT loading anything, the two questions stopped being the same one. /// Reading the metadata there would send a bare `play()` straight into /// `_player.play()` on a player with no source, which is defect A2 all over /// again. bool _fuenteAbierta = false; /// Publishes the persisted station's metadata on a COLD start, without /// touching the player. /// /// The handler constructor only wires streams, and the only `mediaItem.add` /// sites are the duration update (which needs an item to already exist), /// [_cambiarFuente] and [stop] (which publishes `null`). So on a headless /// bind `mediaItem` was null, `audio_service.dart:1029-1033` returned before /// `setMediaItem`, and the head unit received no metadata at all — no title, /// no artwork, nothing to put on the now-playing surface. /// /// Checked before AND after the disk read: a station that started while the /// read was in flight owns the metadata, and renaming what the driver is /// actually listening to would be far worse than a blank tile. Future sembrarUltimaEmisoraDesdeDisco() async { if (mediaItem.value != null || _fuenteAbierta) return; final ultima = await _ultimaEmisoraRecordada(); if (ultima == null) return; if (mediaItem.value != null || _fuenteAbierta) return; mediaItem.add(mediaItemParaEmisora(ultima, l10n: _textos)); } /// Resolves the persisted station and starts it through the ordinary play /// path. Returns `false` when there was nothing to resume. /// /// Routed through [playMediaItem] on purpose — the revision guard, the /// queue clearing, the skip-context recording and the terminal-state floor /// all live behind that choke point, and a parallel path would have to /// re-earn every one of them. Future _reanudarUltimaEmisora() async { final ultima = await _ultimaEmisoraRecordada(); if (ultima == null) return false; try { await playMediaItem(mediaItemParaEmisora(ultima, l10n: _textos)); } catch (e) { // The failure is already published to `playbackState` by // `_cambiarFuente`; a transport button must not additionally throw out // of the handler (Spec "never propagate from the handler"). debugPrint( '[PluriWave][ServicioAudio] no se pudo reanudar la ultima emisora: $e', ); } return true; } /// The remembered context: memory first, then the read port ONCE. /// /// Never throws — an unreadable context must mean "derive it again", not a /// dead steering-wheel button. Future _contextoSaltoRecordado() async { final enMemoria = _contextoSalto; if (enMemoria != null) return enMemoria; if (_contextoSaltoLeido) return null; _contextoSaltoLeido = true; final leer = _leerContextoSalto; if (leer == null) return null; try { return _contextoSalto = await leer(); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo leer el contexto de salto: $e', ); return null; } } /// Freezes [contexto] in memory and pushes it through the write port. /// Never throws, and never writes the same context twice in a row. Future _fijarContextoSalto(ContextoSalto contexto) async { _contextoSaltoLeido = true; if (_contextoSalto == contexto) return; _contextoSalto = contexto; final guardar = _guardarContextoSalto; if (guardar == null) return; try { await guardar(contexto); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo guardar el contexto de ' 'salto: $e', ); } } /// Derives the context [actual] belongs to RIGHT NOW, or `null` when it /// cannot be derived (no browse source registered yet — a real race on a /// cold headless bind — or a station that is in none of the lists). A /// `null` deliberately LEAVES the remembered context alone rather than /// clearing it: the memory is the only thing that still knows the answer. /// /// A free-tier driver's context is ALWAYS the curated set /// (`emisorasDestacadas`), frozen in its compiled-in order. Entitlement is /// re-read here rather than cached so a purchase mid-session takes effect /// on the next play, exactly like every other gate in this file. Future _derivarContextoSalto(Emisora actual) async { if (!await esPremiumPersistido()) { return ContextoSalto.destacadas( uuidsCongeladosDestacadas( actual: actual, destacadas: emisorasDestacadas, ), ); } final fuente = _fuenteNavegacionGlobal; if (fuente == null) return null; return contextoParaSaltoEmisora( actual: actual, favoritos: await fuente.favoritos(), misEmisoras: await fuente.misEmisoras(), todas: await fuente.todas(), ); } /// Records the skip context for a station that is STARTING. /// /// Wired into [playMediaItem], which is the single choke point every /// external play funnels through — that is the whole point, because a /// context set only on the browse path is a context the bug walks straight /// back around. The paths that reach it: /// 1. `playFromMediaId` — a car browse tap (`emisora:`), including /// the `recent` resume row Android Auto shows on every reconnect. /// 2. `playFromSearch` — a voice command, empty ("resume") or named. /// 3. `ServicioAudio.reproducir` — the phone UI, via `playMediaItem`. /// 4. `_saltarEmisora`'s own destination — SUPPRESSED by /// [_saltandoEmisora], because a skip moves within the context rather /// than choosing a new one. /// 5. `_reproducirEntradaCola` — local-queue playback. It does NOT go /// through `playMediaItem`, and a local track is filtered out below /// anyway. /// /// Local files are ignored: their `id` is a `content://` URI, they have /// their own queue (`_colaLocal`), and letting one overwrite the station /// context would leave the radio walking a list it never chose. Same scheme /// test as [_reproduciendoRadio]. Future _recordarContextoSalto(MediaItem item) async { try { final esquema = Uri.tryParse(item.id)?.scheme.toLowerCase(); if (esquema != 'http' && esquema != 'https') return; final contexto = await _derivarContextoSalto(emisoraDesdeMediaItem(item)); if (contexto == null) return; await _fijarContextoSalto(contexto); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo recordar el contexto de ' 'salto: $e', ); } } /// The player's live position, used to keep `updatePosition` honest on /// every `playbackState` push. Exposed so tests can assert the re-push /// without reaching into the private player. Duration get posicionActual => _player.position; /// True while the platform player is attached, i.e. while `just_audio` /// actually forwards `AudioEffect.setEnabled` to the device /// (`just_audio.dart:3842-3848` gates it on `_player._active`). Tracked so /// [debeReasertarEcualizadorNativo] can spot the idle -> active edge. bool _reproductorActivo = false; PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; /// True once anybody has chosen a preset on this handler. Guards the disk /// seed against clobbering a live choice — see [_sembrarPresetDesdeDisco]. bool _presetElegido = false; /// The ordered native steps an on/off transition performs. /// /// Pure and public so the ORDER is asserted directly. @visibleForTesting static List pasosEcualizador({required bool activo}) => activo // GAINS FIRST. `AudioEffect.setEnabled(true)` re-activates the // native `android.media.audiofx.Equalizer`, which still holds the // band levels the PREVIOUS preset left in it — so enabling first // means the driver hears the old equalization and then, one native // round trip per band, the new one sliding in over it. That is the // «doubled equalization» the owner reports from the car. Writing // the levels while the effect is still bypassed makes the // transition a single audible step. ? const [PasoEcualizador.ganancias, PasoEcualizador.habilitacion] // DISABLING DOES NOT RESET THE GAINS, on purpose. // `AudioEffect.setEnabled(false)` (just_audio's // `AudioPlayer.java:820-822` → `AudioEffect.setEnabled`) BYPASSES // the effect; it neither releases it nor clears its band levels, // and a bypassed effect is inaudible whatever they hold. Zeroing // them would be one `setBandLevel` IPC per band for no audible // difference, and the enable path above rewrites them all before // re-enabling anyway — so there is no stale-gain window left for a // reset to close. : const [PasoEcualizador.habilitacion]; int? get androidAudioSessionId => _androidAudioSessionId; Stream get androidAudioSessionIdStream => _androidAudioSessionIdController.stream; PluriWaveAudioHandler() { _conectarStreamsPlayer(); _vigilarEstadosTransitorios(); } /// Arms the terminal-state floor by watching our OWN published stream /// rather than any single push site: `playbackState.add` is called from a /// dozen places in this file, and a floor that only covered some of them /// would be exactly as good as no floor at all on the path it missed. /// /// The subscription is deliberately not held: it is on the handler's OWN /// `playbackState` subject, so it lives and dies with the handler — exactly /// like the subject itself — and there is no teardown that could cancel it /// without also ending the object it belongs to. void _vigilarEstadosTransitorios() { playbackState.listen((estado) { if (!_esEstadoTransitorio(estado.processingState)) { _vigilanciaTimer?.cancel(); _vigilanciaTimer = null; return; } // Already armed for THIS transient run: do not restart the window. // `bufferedPositionStream` republishes ~2/s while buffering, and // re-arming on each of those would push the deadline out forever — // the spinner would once again have no bound. if (_vigilanciaTimer?.isActive ?? false) return; _vigilanciaTimer = Timer( vigilanciaTransitoria, _cerrarEstadoTransitorio, ); }); } static bool _esEstadoTransitorio(AudioProcessingState estado) => estado == AudioProcessingState.loading || estado == AudioProcessingState.buffering; /// Fires [vigilanciaTransitoria] after the first transient publish of a run. void _cerrarEstadoTransitorio() { _vigilanciaTimer = null; if (!_esEstadoTransitorio(playbackState.value.processingState)) return; if (_cambiosEnVuelo > 0 || _reconexion.reintentoPendiente) { // A station really is still opening, or a backoff retry is already // scheduled to end this. The floor exists for a STALLED machine, not to // cap how long a slow stream may take — re-arm and look again. _vigilanciaTimer = Timer( vigilanciaTransitoria, _cerrarEstadoTransitorio, ); return; } // ORDINARY MID-STREAM RE-BUFFER — never a floor case. // // The rule: the floor only fires while NOTHING is known to be happening. // A run that already reached `ready` while playing has proven the mount // delivers audio, and `bufferForPlaybackAfterRebufferDuration` is 5 s, so // a tunnel or an LTE handover routinely parks this session in // `buffering` for longer than [vigilanciaTransitoria] — with // `_cambiosEnVuelo` at 0 (the `finally` ran the moment the non-blocking // `_iniciarPlaySinBloquear` returned) and `reintentoPendiente` false // (ExoPlayer raised no error, so `_intentarReconexion` never ran). The // floor as first written turned every one of those into a latched // `error` over a stream that was still alive: the single worst outcome // in a car, and unrecoverable, because `_errorTerminal` then suppresses // the player's own recovery events. // // Deferring here does NOT reopen the unbounded spinner the guidelines // forbid. This branch is reachable only after audio actually played, and // that path keeps two independent exits the stalled-mount path lacks: // ExoPlayer's own source read timeout raises a `PlayerException` into // `_eventosSub`, which enters `_gestionarErrorReproduccion` and the // reconnect machine, and any real transition re-publishes and disarms // this timer. Re-arm rather than return so the floor stays authoritative // if the session later drops back to a state nothing owns: the flag is // cleared by [_cambiarFuente] and [stop], so the very next attempt to // reopen the source is floored normally. // // `playing` alone is deliberately NOT the test: it is `just_audio`'s // play-when-ready intent flag, true for a mount that never delivered a // byte. See [_reproduccionEstablecida]. if (_reproduccionEstablecida && playbackState.value.playing) { _vigilanciaTimer = Timer( vigilanciaTransitoria, _cerrarEstadoTransitorio, ); return; } debugPrint( '[PluriWave][ServicioAudio] suelo de estado: ' '${playbackState.value.processingState.name} sin carga viva -> error', ); // CORRECTED: this used to publish a BARE `idle` — no message, no code — // on the theory that "nothing we can name actually failed" and that idle // at least let `audio_service` tear the session down. Both halves were // wrong for the case that actually reaches here. // // Something DID fail and it has a name: a mount that accepted the socket // and then delivered nothing. `setUrl` returns inside // [timeoutCambioFuente], so no exception is raised, `_esErrorDeRed` never // fires and the reconnect machine is never entered; this floor is the // only exit. And the teardown was not a mercy: a bare idle routes into // `AudioService._stop()` (`audio_service.dart:1131-1135`), so the driver // got a wordless spinner followed by silence, a dead session and no // explanation whatsoever. // // `error` + `audioErrorTimeout` instead. It keeps the session (and the // route back to browse) alive, and it says the one true thing we know: // the connection never produced audio. The latch stops the player's own // `idle`, if one ever arrives, from undoing it. _errorTerminal = true; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorCode: codigoErrorEmisoraNoDisponible, errorMessage: _textos.audioErrorTimeout, ), ); } /// Production reader for the device locale. static Locale lectorLocalePlataformaPorDefecto() => PlatformDispatcher.instance.locale; /// See [lectorLocalePlataformaPorDefecto]. Mutable ONLY so a test can pin a /// locale; production never writes it. @visibleForTesting static Locale Function() lectorLocalePlataforma = lectorLocalePlataformaPorDefecto; AppLocalizations get _textos { final actual = _l10n; if (actual != null) return actual; // The headless Android Auto engine NEVER reaches // [configurarLocalizaciones]: its only production caller chain is // `EstadoRadio.configurarLocalizaciones` <- `mini_reproductor.dart`'s // `didChangeDependencies`, and a car bind has no Activity and no widget // tree. So `_l10n` stays null there and every message the car ever // showed — including the error text the whole compliance story rests // on — came out in Spanish no matter what language the driver uses. return resolverLocalizacionesRespaldo(lectorLocalePlataforma()); } void configurarLocalizaciones(AppLocalizations l10n) { _l10n = l10n; } /// Test seam for the player factory. `null` in production, where /// [_crearPlayer] builds the real `just_audio.AudioPlayer` exactly as it /// always has. /// /// It exists because the car transport state machine (stop during an /// in-flight source change, pause during a load, the transient-state /// watchdog, the time-to-first-message budget) is a sequence of /// `playbackState` pushes, and asserting a SEQUENCE requires driving the /// real handler — the pure-predicate style used elsewhere in this file /// cannot see the order in which those pushes land, which is precisely /// where the "Stop leaves the app useless" defect lived. /// /// Static rather than a constructor parameter because `_player` is a /// `late` field initialized by the constructor itself (through /// `_conectarStreamsPlayer`), so the factory must already be installed /// before `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`. @visibleForTesting static AudioPlayer Function( AudioPipeline pipeline, AudioLoadConfiguration carga, )? fabricaReproductorPrueba; /// Same seam as [fabricaReproductorPrueba], for the native equalizer effect. /// /// `AudioEffect.setEnabled` is a silent no-op while the player is detached /// (`just_audio.dart` gates it on `_player._active`), so off-device a /// failing native equalizer cannot otherwise be simulated at all — which is /// why the silent `catch (_) {}` on that path shipped with zero coverage. /// Static for the same reason as [fabricaReproductorPrueba]: `_eq` is a /// field initializer, so the factory must already be installed before /// `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`. @visibleForTesting static AndroidEqualizer Function()? fabricaEcualizadorPrueba; static AndroidEqualizer _crearEq() => fabricaEcualizadorPrueba?.call() ?? AndroidEqualizer(); AudioPlayer _crearPlayer() { final pipeline = AudioPipeline(androidAudioEffects: [_eq]); final fabrica = fabricaReproductorPrueba; if (fabrica != null) return fabrica(pipeline, configuracionCargaAndroid); return AudioPlayer( audioPipeline: pipeline, audioLoadConfiguration: configuracionCargaAndroid, ); } /// The `playerStateStream` listener's whole body, as a named method. /// /// Extracted verbatim so a test can drive a real player-state transition /// through the REAL handler. It used to be an anonymous closure, which is /// why the equalizer's idle -> active re-assert below shipped with /// producer-only coverage: [debeReasertarEcualizadorNativo] had five tests /// and not one of them could reach this wiring, so deleting the re-assert /// block left the suite green. The only thing left outside a test's reach /// is the one-line `.listen(manejarEstadoPlayer)` subscription in /// [_conectarStreamsPlayer]. @visibleForTesting void manejarEstadoPlayer(PlayerState state) { final playing = state.playing; final proc = state.processingState; // A real player transition supersedes a refusal about an ACTION: the // session just moved, so the code/message describing something the // driver asked for a moment ago must not ride along on the new state. // Done BEFORE the publish below so its `copyWith` carries the cleared // fields forward rather than the stale pair (`audio_service.dart` // :400-427 keeps every omitted field). _limpiarErrorAccionAuto(); // 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); // eq-estado-unico item D: `AudioEffect.setEnabled` is a no-op while // the platform player is detached, so any toggle made while stopped // never landed natively. Re-assert the value we own on the idle -> // active edge. See [debeReasertarEcualizadorNativo]. if (debeReasertarEcualizadorNativo( estado: proc, reproductorActivoAntes: _reproductorActivo, eqDisponible: _eqDisponible, )) { unawaited(_reasertarEcualizadorNativo()); } // Turns a stream of many events into a single idle -> active EDGE: the // re-assert above fires once per activation, not on every event. _reproductorActivo = proc != ProcessingState.idle; if (playing && proc == ProcessingState.ready) { // The mount has proven it delivers audio on THIS run. Read only by the // terminal-state floor, to tell an ordinary re-buffer apart from a // source that never produced a byte — see [_reproduccionEstablecida]. _reproduccionEstablecida = true; // 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, errorTerminal: _errorTerminal, ), 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(); } void _conectarStreamsPlayer() { _estadoPlayerSub = _player.playerStateStream.listen( manejarEstadoPlayer, ); _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, ), // Must ride along, exactly as in the two sibling emissions in // `_conectarStreamsPlayer`: `copyWith` stamps a fresh `updateTime` // but keeps the OLD `updatePosition`, so a push without it tells the // client "you are at , as of right now". Every // equalizer tap therefore snapped the car's progress bar backwards // to wherever it stood at the last real player event. updatePosition: _player.position, ), ); } /// Re-states [_ecualizadorActivo] (and the current preset's gains) on the /// native effect now that the platform player is attached again /// (eq-estado-unico item D). Delegates to [_activarEcualizador], which is /// already idempotent and already re-asserts the CURRENT value rather than /// forcing the equalizer on. Future _reasertarEcualizadorNativo() async { _reasercionesEcualizador++; debugPrint( '[PluriWave][ServicioAudio] reasertando EQ nativo ' 'activo=$_ecualizadorActivo', ); await _activarEcualizador(); } /// Gestiona cualquier error de reproducción de ExoPlayer. /// /// Network-class failures while the user still intends to play enter the /// reconnect state machine (S7-R2) instead of surfacing a terminal error; /// only retry exhaustion (or non-network errors) falls through to the /// existing error path, so the user sees a single error — no spam per retry. void _gestionarErrorReproduccion(Object error) { // 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(); // Armed BEFORE the publish and BEFORE `_player.stop()`: the stop below // makes `playerStateStream` emit a distinct `idle`, and forwarding that // idle is what used to tear the whole media session down one event-loop // turn after the message appeared. See [mapearEstadoProceso]'s // `errorTerminal` parameter for the full chain. _errorTerminal = true; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorMessage: mensaje, ), ); // The failed item and station are KEPT, deliberately. // // This used to do `emisoraActual = null; mediaItem.add(null);`, which // left the media session in STATE_ERROR with no metadata at all. // // CORRECTED: an earlier revision of this comment claimed that keeping // `mediaItem`/`emisoraActual` was what stopped PluriWave vanishing from // the car pane when a station failed. It was not, and the app kept // vanishing. The teardown is driven by the `idle` `_player.stop()` // produces, not by missing metadata: `audio_service` calls // `AudioService._stop()` on any non-idle -> idle transition // (`audio_service.dart:1131-1135`) whatever the media item holds. The // `_errorTerminal` latch above is the actual fix; keeping the metadata is // still worth doing for two smaller reasons — the screen can name the // station that failed instead of going blank, and previous/next stay // usable so the driver skips out of a dead station instead of being // stranded (`_saltarEmisora` needs `emisoraActual` to know where it is). // // `_player.stop()` itself stays: it releases the decoders behind a stream // that is already dead, and its `idle` is now suppressed rather than // forwarded, so the error state stands until the driver does something. _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, // NOT `null` any more. `copyWith` treats an explicit `null` as // "clear" (audio_service.dart:419-420), so the whole reconnect window // used to be a spinner with nothing written on it — up to ~100 s in // which the driver was told exactly nothing. `buffering` + a message // is the shape the Cars guidelines ask for: say what is happening, // and keep trying behind it. errorMessage: _textos.playbackStatusReconnecting, ), ); 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; // The ONE place the skip context is recorded — see // [_recordarContextoSalto] for the enumerated paths that arrive here. // Fire-and-forget: it reads prefs and the browse source, and a station // change must not wait on either. if (!_saltandoEmisora) unawaited(_recordarContextoSalto(mediaItem)); 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, etiquetas: etiquetasArbolAutoDesde(_textos), ); 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, ); // Reported: a local track sometimes jumped to another one on its own, // without reaching the end and without anyone pressing a thing. Reading // the code cannot settle it -- an advance here requires a genuine // `completed` from just_audio, so either the player reports the end // early (plausible for a `content://` SAF source, whose duration is not // always exact) or something else moved the track. Rather than guess, // log the transition with the state that caused it, so the next report // arrives with the reason attached instead of another hypothesis. if (decision != DecisionAvanceCola.ninguna) { debugPrint( '[PluriWave][ServicioAudio] avance de cola decision=${decision.name} ' 'proc=${proc.name} pos=${_player.position} dur=${_player.duration} ' 'pista=${mediaItem.value?.title}', ); } 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 { // Revision guard ON ENTRY — the P0 "Stop makes the app useless" fix. // // `_encolarCambioFuente` bumps `_revisionFuente` when a change is // ENQUEUED, not when it runs, so several changes can be waiting on // `_colaCambioFuente` at once. `stop()` bumps the revision again and // publishes `idle` itself. Every queued change then woke up, REWROTE // `loading` over that `idle`, and only afterwards (past `_recrearPlayer`) // discovered its revision was stale. The last thing Android Auto saw was // `loading` on a session `audio_service` had already torn down: a // permanent spinner with a dead Stop button. // // Nothing below this line can strand `_cambiandoFuente`: the flag is only // set AFTER this return, so an early exit here leaves it exactly as the // caller found it. if (revision != _revisionFuente) return; this.mediaItem.add(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem); // A source is now genuinely open on this handler — see [_fuenteAbierta]. _fuenteAbierta = true; // THE SINGLE WRITER of `ultima_emisora_v1`. Placed here, past the // revision guard and beside the `mediaItem` publish, because this is the // one point EVERY play path funnels through: the phone (`EstadoRadio. // reproducir` -> `ServicioAudio.reproducir` -> `playMediaItem`), a car // browse tap (`playFromMediaId`), voice (`playFromSearch`), a skip, a // queue advance and the bare-`play()` resume below. // // `EstadoRadio._persistirUltimaEmisora` was deleted rather than kept // alongside this. Two writers of one key is exactly the shape that // produced the equalizer divergence twice: both wrote fire-and-forget, so // on a fast A -> B station switch the interleaving of two independent // unawaited chains decided the final value, and the phone's copy could // not see the revision guard that already cancels a superseded change. // One writer behind one serialized queue has neither problem, and it is // the only writer that exists on the engine Android Auto starts. unawaited(_persistirUltimaEmisora(mediaItem)); // A new source is being opened, so no previous terminal error owns the // screen any more (see [_errorTerminal]). _errorTerminal = false; // A fresh source (a reconnect ATTEMPT included) has proven nothing yet: // re-arm the terminal-state floor for it. Without this reset, a station // that played and then died would inherit the previous run's "audio is // flowing" verdict and its spinner would never be floored. _reproduccionEstablecida = false; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.loading, playing: false, // A FRESH user source change clears whatever was on screen; a // reconnect ATTEMPT does not. Retries run behind the "reconnecting" // message [_intentarReconexion] published, and blanking it here on // every attempt would put the driver back on a wordless spinner for // most of the backoff window. errorMessage: _reconectando ? playbackState.value.errorMessage : null, // ALWAYS cleared, unlike the message. `copyWith` carries an OMITTED // field forward (`audio_service.dart:419-420`), and this used to omit // `errorCode`, so a code published by an earlier refusal (a premium // tap, a voice miss) rode along on every later state indefinitely — // pairing a stale `ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` with whatever // message came next. It is cleared even while reconnecting because // "Reconectando..." is a STATUS, not an error: `setState` still // forwards a message with a null code // (`AudioService.java:541-544`). errorCode: 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; // Paired with the `finally` below: tells the terminal-state floor that a // station is genuinely still opening, so it re-arms instead of forcing a // slow-but-healthy load to `idle`. _cambiosEnVuelo++; 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) { // Same shape as [_gestionarErrorReproduccion]'s terminal path, and // for the same reasons — this clause is the THIRD way a station can // die and it used to be the least survivable of them. // // The latch stops the `idle` that follows (`_recrearPlayer` and the // player's own teardown both produce one) from being forwarded into // `AudioService._stop()` (`audio_service.dart:1131-1135`). // // And the metadata is now KEPT. Clearing it here contradicted the // sibling path outright: Android Auto drops a session with nothing to // show, so `mediaItem.add(null)` made PluriWave disappear from the // car pane the moment an unexpected error hit, and `emisoraActual = // null` additionally left `_saltarEmisora` with no idea where it was, // so previous/next stopped working — stranding the driver on the one // failure they most need to skip out of. _errorTerminal = true; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorMessage: _textos.audioErrorUnexpectedPlayback, ), ); } 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; _cambiosEnVuelo--; } } 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 = _crearEq(); // `_eqDisponible` is deliberately NOT reset here. It answers "does this // DEVICE have a usable native Equalizer effect", which no station change // can alter — and resetting it on every source change is what made a car // toggle land in a window where every native EQ path was gated off (the // reported «does nothing») and made the EQ custom action disappear from // the now-playing screen and come back seconds later // (`controlesEcualizadorPersonalizados` returns `const []` when // unavailable). [_activarEcualizador] is the only writer now: it sets it // true when the fresh effect reports bands, false when it throws. // // Keeping it true across the rebuild cannot lie or throw, and that was // verified against just_audio 0.9.46 rather than assumed: // - `AudioEffect.setEnabled` short-circuits on `_player._active`, so on // the detached fresh player it only records the Dart-side intent and // never reaches the platform — no throw, no native call. // - that recorded intent is NOT lost: the effect's `_toMessage()` is // only read when the player attaches (`AudioPlayer._setPlatformActive` // → `InitRequest.androidAudioEffects`), so a toggle made inside this // window is carried into the new native pipeline verbatim. // - the one call that WOULD hang is `await AndroidEqualizer.parameters`: // its `Completer` only completes in `_activate`, i.e. when the player // attaches. No toggle path awaits it any more — they read the // [_paramsEq] cache cleared just below and skip while it is null. _paramsEq = null; // Resets alongside its siblings above: the fresh player starts detached, // so the next non-idle event is a genuine idle -> active edge that // [debeReasertarEcualizadorNativo] must see. A value stuck at `true` // across the rebuild would swallow exactly the re-assert this exists for. _reproductorActivo = false; _androidAudioSessionId = null; _ultimaSessionIdEq = null; _player = _crearPlayer(); await _player.setVolume(_volumenEfectivo); _conectarStreamsPlayer(); } void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) { // Re-validate the user's intent, not just the source revision. // // Only `stop()` bumps `_revisionFuente`, so a `pause()` landing WHILE a // station was still loading did not cancel anything: the load finished, // this method ran, and the station started playing right after the user // had asked for silence. `_intencionReproducir` is the flag `pause()` // already clears (and `play()` already sets), so reading it here is the // narrow fix. // // Deliberately NOT done by bumping `_revisionFuente` inside `pause()`: // that would abort the whole source change, and a pause landing before // `_recrearPlayer()` returned would leave a fresh player with NO source // loaded — the subsequent resume would then call `play()` on nothing and // produce silence. Letting the load COMPLETE and only withholding the // `play()` keeps resume working: the source is already there. if (!_intencionReproducir) { // The source IS loaded, the user just does not want it playing. Say so // explicitly: without this the car keeps the `loading` spinner the // source change opened with, and nothing else would ever replace it — // the player makes no transition when `play()` is never called. playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.ready, playing: false, ), ); return; } 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; // eq-estado-unico item E: the ONE number that decides how much of the // ±12 dB slider [mapearGananciaNativa] can actually honour on this // device — anything past this range is clamped, so a report of "the // slider stops doing anything past N" is answered from this line. // `Equalizer.getBandLevelRange()` is not required to be symmetric, and // nothing else in the app can observe what it returned. // `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in // the release build, which is the only one that ever runs in a car: // // adb logcat | grep PluriWave debugPrint( '[PluriWave][ServicioAudio] eq rango bandas=${params.bands.length} ' 'minDecibels=${params.minDecibels} maxDecibels=${params.maxDecibels} ' 'activo=$_ecualizadorActivo preset=${_presetActual.nombre}', ); if (_eqDisponible) { _paramsEq = params; await _conmutarEcualizadorNativo(_ecualizadorActivo); } } 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; /// Pure decision for re-asserting the on/off state on the NATIVE effect /// when the platform player becomes active again (eq-estado-unico item D). /// No side effects. /// /// Why it is needed: `just_audio`'s `AudioEffect.setEnabled` /// (`just_audio.dart:3842-3848`) only reaches the platform while /// `_player._active` is true. After a `stop()` — or any transition to /// `idle` — the Dart-side intent is updated but the native effect is not. /// A user who turns the equalizer off while stopped, then presses play, /// would get audio that is still equalized with the button reading "off". /// /// The native effect is treated as WRITE-ONLY throughout: `just_audio` /// exposes no read-back of `Equalizer.getEnabled()`, so this never /// compares against the device — it simply re-states the value the app /// already owns, which is idempotent and cheap. /// /// [reproductorActivoAntes] is the tracked state BEFORE [estado] arrived, /// so only the idle -> active edge fires; a player already active does not /// re-assert on every one of its many events. @visibleForTesting static bool debeReasertarEcualizadorNativo({ required ProcessingState estado, required bool reproductorActivoAntes, required bool eqDisponible, }) => eqDisponible && !reproductorActivoAntes && estado != ProcessingState.idle; /// Forces [_eqDisponible] for a test. /// /// `_eqDisponible` is only ever set from `AndroidEqualizer.parameters` /// (see [_activarEcualizador]), whose future only completes on a real /// device, so off-device it is permanently `false` — and every EQ path /// worth testing is gated on it. Without this seam /// [manejarEstadoPlayer]'s re-assert can only ever be exercised on its /// false branch. @visibleForTesting void simularEcualizadorDisponible(bool disponible) { _eqDisponible = disponible; } /// How many times [_reasertarEcualizadorNativo] has actually run. /// /// The native call it makes is unobservable off-device (see /// [simularEcualizadorDisponible]), so this counter is the only evidence a /// test can assert on that the re-assert HAPPENED, rather than that the /// predicate would have said yes. @visibleForTesting int get reasercionesEcualizador => _reasercionesEcualizador; int _reasercionesEcualizador = 0; /// Aplica un preset al ecualizador nativo Android. Future aplicarPreset(PresetEcualizador preset) async { _presetActual = preset; // A preset chosen by anyone (car folder, phone screen) claims ownership: // a disk seed still in flight must not overwrite it. See // [_sembrarPresetDesdeDisco]. _presetElegido = true; if (_eqDisponible) { try { // Enable-then-gains here does NOT contradict [pasosEcualizador]'s // gains-then-enable. That order matters only on an on/off TRANSITION, // where enabling first un-bypasses an effect still holding the // previous preset. Choosing a preset is not a transition: the effect // is already in its final on/off state, so this `setEnabled` is the // idempotent re-assert that keeps the native effect honest after a // `stop()` (see [debeReasertarEcualizadorNativo]) and opens no // stale-gain window of its own. await _eq.setEnabled(_ecualizadorActivo); if (_ecualizadorActivo) { await _empujarGananciasNativas(preset); } } catch (e) { _registrarFalloEq('aplicarPreset(${preset.nombre})', e); } } // 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); } _presetElegido = true; if (!_eqDisponible || !_ecualizadorActivo) return; final params = _paramsEq; if (params == null) return; try { if (index < params.bands.length) { await params.bands[index].setGain( mapearGananciaNativa( db, minDecibels: params.minDecibels, maxDecibels: params.maxDecibels, ), ); } } catch (e) { _registrarFalloEq('setBanda($index)', e); } } /// Writes [preset]'s band levels into the native effect. /// /// Skips silently while [_paramsEq] is `null` (the player has not attached /// since the last rebuild): the gains have nowhere to go yet and /// [_activarEcualizador] pushes them the moment it does. Future _empujarGananciasNativas(PresetEcualizador preset) async { final params = _paramsEq; if (params == null) return; 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, ), ); } } /// The native operations an on/off transition performs, in /// [pasosEcualizador] order. /// /// Returns `false` when the [PasoEcualizador.habilitacion] step itself /// threw, i.e. when the device did NOT adopt [activo]. A failed gains step /// does not make the transition dishonest: the effect really is in the /// requested on/off state, just carrying stale band levels. Future _conmutarEcualizadorNativo(bool activo) async { _pasosEqEjecutados.clear(); var conmutado = true; for (final paso in pasosEcualizador(activo: activo)) { try { switch (paso) { case PasoEcualizador.ganancias: await _empujarGananciasNativas(_presetActual); case PasoEcualizador.habilitacion: await _eq.setEnabled(activo); } _pasosEqEjecutados.add(paso); } catch (e) { _registrarFalloEq('$paso(activo=$activo)', e); if (paso == PasoEcualizador.habilitacion) conmutado = false; } } return conmutado; } /// Single trace/count point for every native equalizer failure. /// /// [debugPrint] and never `dart:developer`'s `log`, for the same reason as /// the rest of this file: `log()` writes to the VM service, which the /// RELEASE build a car runs does not have. void _registrarFalloEq(String operacion, Object error) { _fallosNativosEq++; debugPrint( '[PluriWave][ServicioAudio] fallo nativo del ecualizador en ' '$operacion: $error', ); } /// Sets the equalizer on/off state AND persists it — the single entry /// point every surface goes through (phone settings via /// `EstadoEcualizador`, the notification, and the car's [accionEqToggle]). Future setEcualizadorActivo(bool activo) => _aplicarEcualizadorActivo(activo, persistir: true); /// Adopts a PRESET that came from disk, the sibling of /// [sembrarEcualizadorActivo]. Bound through /// `registrarHandler(leerPresetPersistido: ...)`. /// /// Unlike the on/off flag's seed this one YIELDS to a live choice. The flag /// has exactly one persisted value and the handler owns writing it, so /// seeding it can never contradict anybody. The preset does not: the phone /// UI resolves a richer value (per-station, and per-Bluetooth-device when /// the multi-device toggle is on) that this narrow "principal preset" read /// knows nothing about. The seed's disk read is `unawaited`, so without the /// [_presetElegido] guard a slow read could land after `EstadoEcualizador` /// had already pushed the right preset and silently replace it with the /// principal one. The seed exists to fill a VOID, never to overrule. Future sembrarPresetEcualizador(PresetEcualizador preset) async { if (_presetElegido) return; await aplicarPreset(preset); } /// Adopts a value that came FROM disk (eq-estado-unico item A). Identical /// to [setEcualizadorActivo] except that it does not write back — seeding /// is a read, and echoing it to disk would only add a pointless write on /// every engine start. Future sembrarEcualizadorActivo(bool activo) => _aplicarEcualizadorActivo(activo, persistir: false); Future _aplicarEcualizadorActivo( bool activo, { required bool persistir, }) async { final anterior = _ecualizadorActivo; _ecualizadorActivo = activo; if (_eqDisponible && !await _conmutarEcualizadorNativo(activo)) { // The device REFUSED the on/off call. Publishing `activo` anyway would // put an icon on the car's now-playing screen claiming a state the // audio does not have — and persisting it would resurrect that lie on // the next engine start. Rolling back is cheap here because // `_ecualizadorActivo` is the single in-memory owner (eq-estado-unico) // and the controls are rebuilt from it one line below; the toggle then // honestly reads "unchanged" and the failure is in the logcat. _ecualizadorActivo = anterior; _actualizarControlesEq(); return; } // 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(); if (!persistir) return; _eqActivoPersistido = activo; final guardar = _persistirEqActivo; if (guardar == null) return; // eq-estado-unico item B: the handler owns this write now. It used to // be `EstadoEcualizador._resincronizarConHandler`'s job, which meant a // toggle made in the car or from the notification was only saved if a // phone UI object happened to exist — on a headless Android Auto engine // it never did, so the car toggle was silently lost on every restart. // // Failures are swallowed on purpose: a full disk must not turn the // equalizer button into a crash. try { await guardar(activo); } catch (e) { debugPrint( '[PluriWave][ServicioAudio] no se pudo persistir el estado EQ: $e', ); } } 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() async { // NO SOURCE LOADED — the cold-engine case, and the reason this override // is no longer a one-liner. // // `AudioService.java:920` routes the car's `KEYCODE_MEDIA_PLAY` straight // in here, and there is no `prepare`/`onPrepare`/`prepareFromMediaId` // override anywhere in this app to have loaded anything first. Handed to // `_player.play()`, `just_audio.dart:937-967` publishes // `_playingSubject.add(true)` BEFORE its `_audioSource != null` gate: the // platform is never touched, the returned Future NEVER completes, and yet // `playing: true` is forwarded by [manejarEstadoPlayer] over // `processingState: idle`. `AudioService.java:559-560` then runs // `enterPlayingState()` while `getPlaybackState()` is `STATE_NONE` — a // PluriWave notification with a pause button, no audio, no title and no // artwork, or a `ForegroundServiceStartNotAllowedException` on API 31+. // // So: resolve the persisted station and go through the ordinary play // path, and when there is nothing to resume touch neither the player nor // `playbackState` and complete immediately. Doing nothing is the correct // answer there — a phantom foreground session is strictly worse than a // Play button that did not find anything to play. if (!_fuenteAbierta) { await _reanudarUltimaEmisora(); return; } _intencionReproducir = true; // Fresh user intent: whatever terminal error was standing no longer owns // the screen, so stop masking the player's `idle` (see [_errorTerminal]). _errorTerminal = false; 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; _errorTerminal = 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(); // Disarm the action-refusal window before it can fire against the idle // this method is about to publish (see [_limpiarErrorAccionAuto]). _limpiarErrorAccionAuto(); // 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; // Same reason and the same ordering as `_cambiandoFuente` above: a mask // still armed here would swallow the real `idle` and leave the // notification unkillable with a dead Stop button — the exact citation // this branch exists to answer. _errorTerminal = false; // The session is over: whatever this run proved about the mount does not // carry into the next one (see [_reproduccionEstablecida]). _reproduccionEstablecida = false; // The session is over and `mediaItem` is cleared below, so the next bare // `play()` — a car transport button on a torn-down session — must resolve // a station again instead of calling `_player.play()` on nothing (see // [_fuenteAbierta] and [play]). _fuenteAbierta = 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, // Paired with `errorMessage` above, which shipped alone. `copyWith` // carries an OMITTED field forward (`audio_service.dart:400-427`), // so a stopped, idle session went on advertising the code of the // last refusal — an ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED (4) with the // message deliberately blanked out, i.e. an error the head unit // could show but never explain. errorCode: 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 { // fix/auto-quality-guidelines item 14: the free-tier no-op is GONE. The // buttons stay advertised for every tier and CYCLE WITHIN the free set // instead — see [_saltarEmisora]. Withdrawing them from `controls` and // `systemActions` was the alternative and was rejected: those two lists // are rebuilt inside a SYNCHRONOUS `playerStateStream` listener, so // making them tier-dependent would mean either awaiting a prefs read on // every player event or caching entitlement in a second place; and a // button that works is better UX than a hole, on a surface where Auto // reserves the slots anyway. final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) 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 { // Mirrors [skipToNext] — see its comment for why the free-tier gate is // gone (fix/auto-quality-guidelines, item 14). final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true); return; } final anterior = cola.conAnterior(); _colaLocal = anterior; await _reproducirEntradaCola(anterior.actual); } /// Whether what is playing right now is a RADIO STREAM, as opposed to a /// local file. /// /// Reported: playing a single local track and pressing next jumped to a /// radio station. `emisoraActual` cannot answer this — `_cambiarFuente` /// fills it in for every source, so a local MP3 arrives as an `Emisora` /// whose `url` is its `content://` document URI. The scheme of the media /// id is what actually distinguishes them, and it is the same test /// `esEmisoraGrabable` uses to keep the recorder off local files. /// /// Only a queue-less local track reaches this: folder playback sets /// `_colaLocal` and skips within the queue, which is why the report said /// "at least the first time" — tapping one track never builds a queue. bool get _reproduciendoRadio { final id = mediaItem.value?.id; if (id == null) return false; final esquema = Uri.tryParse(id)?.scheme.toLowerCase(); return esquema == 'http' || esquema == 'https'; } /// Station-to-station skipping for the car's transport row. /// /// The list to walk is the REMEMBERED context re-resolved against the live /// lists ([resolverListaContexto]), falling back to a fresh derivation /// ([listaParaSaltoEmisora]'s decision, named) only once the memory resolves /// to nothing at all. Anything unresolvable — no source, no current station, /// a station that is in no list — leaves playback untouched, and so does a /// single-entry list the station is already on. 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 actual = emisoraActual; if (actual == null) return; // fix/auto-quality-guidelines item 14: the list is SCOPED by tier // rather than the buttons being refused. A free driver cycles the free // set — which always has at least [emisorasDestacadas.length] entries, // so neither button is ever a dead end — and a premium driver keeps the // narrowest-context walk (`contextoParaSaltoEmisora`) unchanged. final premium = await esPremiumPersistido(); // Only a premium walk ever touches the browse source; the free set is // compiled in, so a free driver needs no source at all (and a cold // headless bind may not have one yet). final fuente = premium ? _fuenteNavegacionGlobal : null; final favoritos = fuente == null ? const [] : await fuente.favoritos(); final misEmisoras = fuente == null ? const [] : await fuente.misEmisoras(); final todas = fuente == null ? const [] : await fuente.todas(); final grupos = fuente == null ? const [] : await fuente.grupos(); List listaDe(ContextoSalto? contexto) => contexto == null ? const [] : resolverListaContexto( contexto: contexto, actual: actual, favoritos: favoritos, misEmisoras: misEmisoras, todas: todas, destacadas: emisorasDestacadas, grupos: grupos, ); // 1. The REMEMBERED context wins — that is the whole point: the car // restarts the engine on every reconnect, and re-deriving from // scratch is what lost the driver's list. It is only honoured while // it still resolves to a walkable list (`resolverListaContexto` // returns empty once it has expired). var contexto = await _contextoSaltoRecordado(); // Entitlement gate: a FREE driver cycles the free set and nothing else. // A context frozen while the account was paying (a group, all // favourites, the catalogue) is DISCARDED rather than walked, so a // downgrade cannot keep skipping through premium content. if (!premium && contexto?.tipo != TipoContextoSalto.destacadas) { contexto = null; } var lista = listaDe(contexto); // 2. EXPIRED (or absent) memory: derive it again from what is live now. // Expired means "resolves to nothing at all". A list of ONE is not // expired: a favourites group down to a single station is still the // group the driver chose, and re-deriving there is exactly what used // to widen the walk to every favourite behind their back. if (lista.isEmpty) { contexto = await _derivarContextoSalto(actual); lista = listaDe(contexto); } // 3. Freeze whatever we are actually about to walk, so the NEXT press // (this process or the next one) walks the same list. if (contexto != null && lista.isNotEmpty) { await _fijarContextoSalto(contexto); } // The context can resolve to a list the playing station is NOT on: the // owner's rule is that a surviving group (or, once the group is gone, // the favourites) keeps the walk even when the station left it. Taking // `lista.first` is that rule. `emisoraVecina` is deliberately NOT // loosened for it — its "not in the list means do nothing" contract is // a safety property other callers rely on, so the exception lives here, // where the context has already said which list to stay on. final destino = lista.isEmpty ? null : lista.any((e) => e.uuid == actual.uuid) ? emisoraVecina(actual, lista, haciaAtras: haciaAtras) : lista.first; // Reported: in the car these buttons did nothing for radio. Every early // return here is silent, so the log has to say WHICH one fired -- // an empty list (the station matched none of the three) and a station // that is in a list of one are indistinguishable from outside. debugPrint( '[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras ' 'actual=${actual.nombre} uuid=${actual.uuid} ' 'contexto=$contexto lista=${lista.length} ' 'destino=${destino?.nombre ?? "NINGUNO"}', ); if (destino == null) return; // Suppresses the re-derivation `playMediaItem` performs for every other // play path: a skip MOVES WITHIN the context, it does not choose a new // one. Without this latch the walk un-freezes on every press. _saltandoEmisora = true; try { await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos)); } finally { _saltandoEmisora = false; } } 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: final decision = decidirToggleEq( activoActual: _ecualizadorActivo, eqDisponible: _eqDisponible, ); debugPrint( '[PluriWave][ServicioAudio] customAction $name -> ' 'activo=${decision.nuevoActivo} ' 'nativo=${decision.requiereLlamadaNativa}', ); await setEcualizadorActivo(decision.nuevoActivo); } } @override Future onTaskRemoved() async { await stop(); await liberar(); } /// Releases everything this handler owns that can still run on its own: /// the terminal-state floor timer, the action-refusal window, the /// reconnect backoff (1/2/4/8/16 s — easily longer than whatever created /// the handler lives for), anything still queued on `_colaCambioFuente`, /// every player subscription, the player itself and the browse subjects. /// /// The revision bump is the load-bearing part: a queued `_cambiarFuente` /// body that outlives its owner calls `_crearPlayer()`, which reads the /// CURRENT [fabricaReproductorPrueba]. In a test suite that means a /// released handler building a double bound to a LATER test's script and /// driving it. Bumping the revision makes every queued body return at its /// entry guard, before it can publish or build anything. /// /// Idempotent, and never throws: teardown that can fail halfway is not /// teardown. Called by [onTaskRemoved] in production and by every test /// that constructs a handler. Future liberar() async { // Invalidates every queued and in-flight source change (see above). _revisionFuente++; _intencionReproducir = false; _cambiandoFuente = false; _errorTerminal = false; _reproduccionEstablecida = false; _vigilanciaTimer?.cancel(); _vigilanciaTimer = null; _detenerReconexion(); _limpiarErrorAccionAuto(); _desactivarCola(); await _estadoPlayerSub?.cancel(); _estadoPlayerSub = null; await _bufferedSub?.cancel(); _bufferedSub = null; await _duracionSub?.cancel(); _duracionSub = null; await _eventosSub?.cancel(); _eventosSub = null; await _androidAudioSessionIdSub?.cancel(); _androidAudioSessionIdSub = null; // A player built without the test factory talks to platform channels that // do not exist under `flutter test`, so disposal is allowed to fail — // the subscriptions above are already gone either way. try { await _player.dispose(); } catch (_) {} if (!_androidAudioSessionIdController.isClosed) { await _androidAudioSessionIdController.close(); } for (final subject in _childrenSubjects.values) { await subject.close(); } _childrenSubjects.clear(); // 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 this method 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 { // Every user-readable label the car tree stamps onto a `MediaItem` // comes from here. `_textos` resolves headlessly through // [resolverLocalizacionesRespaldo], so this works on the engine // Android Auto starts without an Activity -- which is the only engine // a Play reviewer ever gets. final etiquetas = etiquetasArbolAutoDesde(_textos); final constructor = ConstructorArbolAuto(etiquetas: etiquetas); // The "recent" root, resolved BEFORE the entitlement gate. // // `onGetRoot` (`AudioService.java:817-821`) hands back `RECENT_ROOT_ID` // whenever the head unit sets `BrowserRoot.EXTRA_RECENT`, which Android // Auto does on every reconnect. There was no branch for it, so it fell // through: free tier hit `respuestaBloqueadaPorEntitlement` and got all // six stations where the platform expects exactly ONE resume item, and // premium fell past every branch to `const []` — a dead resume tile. // // Tier-independent on purpose (see [ultimaEmisoraPersistida]): the // station offered here is by definition one this device has already // played, so resuming it leaks nothing. An absent/corrupt record yields // an empty list rather than a placeholder — a non-playable row is the // thing Play cited. if (parentMediaId == AudioService.recentRootId) { final ultima = await ultimaEmisoraPersistida(); return ultima == null ? const [] : [constructor.itemEmisora(ultima)]; } // The AUTHORITATIVE entitlement gate, resolved ONCE per call and // checked BEFORE any other resolution — the backstop against a // stale/deep-linked non-root id from a head unit's cached tree. // // fix/auto-quality-guidelines item 10: it now SCOPES content instead of // blocking actions, so its answer for a premium id is the free tier's // own playable stations, never a non-playable row. final premium = await esPremiumPersistido(); final destacadas = premium ? const [] : await resolverEmisorasDestacadas(); final bloqueada = respuestaBloqueadaPorEntitlement( parentMediaId: parentMediaId, premium: premium, destacadas: destacadas, ); if (bloqueada != null) return bloqueada; // The free tier's only folder (item 9). Resolved HERE, before the // `_fuenteNavegacionGlobal` gate below, exactly like the local-music and // equalizer branches: its content comes from the binary, so it must // survive a bind where no browse source has been registered yet — which // is precisely the bind a Play reviewer's first launch performs. if (parentMediaId == ConstructorArbolAuto.idDestacadas) { return constructor.hijosDestacadas( premium ? await resolverEmisorasDestacadas() : destacadas, ); } final fuenteLocal = _fuenteMusicaLocalGlobal; if (parentMediaId == AudioService.browsableRootId) { // fix/android-auto-musica-local: la RAÍZ ya no se decide con el // round trip de permisos. Ese round trip viaja por // `pluriwave/file_actions`, cuyo handler nativo solo se registra en // `MainActivity.configureFlutterEngine` — en el motor headless que // Android Auto levanta sin Activity no existe, la llamada lanzaba // `MissingPluginException` y el nodo desaparecía del árbol. Y como // el head unit CACHEA la raíz, seguía desaparecido toda la sesión. // // Ahora solo `noConfigurada` (sin URI persistida, o permiso // revocado confirmado por el nativo) oculta el nodo; // `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica // el problema (`hijosMusicaLocal`) en vez de dejar una carpeta // vacía. // // fix/auto-quality-guidelines item 9: `premium &&` comes FIRST on // purpose. Dart short-circuits `&&`, so a free-tier root never makes // the `estadoCarpeta()` call at all — it cannot show Música Local // anyway, and that call travels over a native channel whose handler // only exists when an Activity has registered it, so skipping it // removes a failure mode from the one browse call that must never // fail. final incluirMusicaLocal = premium && fuenteLocal != null && await fuenteLocal.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada; return constructor.raiz( incluirMusicaLocal: incluirMusicaLocal, premium: premium, ); } final musicaLocal = await hijosMusicaLocal( parentMediaId, fuente: fuenteLocal, etiquetas: etiquetas, ); 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 uuid = uuidDeMediaIdEmisora(mediaId); // Not a station id at all (`pista:`, `carpeta_local_*:`, `eq_preset:`, // a folder, or `emisora:` with an empty tail) — unchanged behaviour. if (uuid == null) return null; // Was `_universoCompleto` (favoritos + misEmisoras + todas) alone, which // is EMPTY on a headless bind, while `porUuid` has always also fallen // back to the featured set. The car could therefore BROWSE a featured // station and then fail to resolve its media item — an asymmetry, not a // policy. Delegating to `porUuid` removes it (and short-circuits on the // first list that matches instead of always awaiting all three), and the // `FuenteEmisorasAutoDestacadas` stand-in covers the window before // `main.dart` registers the real source, exactly as [playFromMediaId] // already does. final fuente = _fuenteNavegacionGlobal ?? FuenteEmisorasAutoDestacadas(await resolverEmisorasDestacadas()); final emisora = await fuente.porUuid(uuid); return emisora == null ? null : ConstructorArbolAuto().itemEmisora(emisora); } catch (_) { return null; } } /// Publishes a terminal, EXPLAINED refusal to the car /// (fix/auto-quality-guidelines, item 12). /// /// Every dispatch path that used to `return;` in silence now ends here /// instead. A silent return is the worst possible answer on a head unit: /// the driver taps a row (or speaks a command), the assistant accepts it, /// and absolutely nothing happens with nothing on screen to say why — /// which is the shape of the defect Google Play cited. /// `ACTION_PLAY_FROM_SEARCH` in particular is forced into the plugin's /// `AUTO_ENABLED_ACTIONS`, so a mute handler stays advertised forever and /// cannot be withdrawn from Dart. /// /// `playing`, `mediaItem` and the player itself are deliberately untouched: /// this error describes the requested ACTION that could not be carried out, /// not the session. Whatever was already playing keeps playing. /// /// CORRECTED: it used to publish [AudioProcessingState.error] /// unconditionally, which was worse than the silent return it replaced — /// see [puedePublicarErrorTerminalAuto] for the full mechanism. The /// processing state now only moves when there is no live session to /// destroy; otherwise the refusal travels as `errorCode`/`errorMessage` /// alone, which still reaches `PlaybackStateCompat.setErrorMessage` /// (`AudioService.java:541-544`) whatever the state is. void _publicarErrorAuto(int codigo, String mensaje) { final actual = playbackState.value; final terminal = puedePublicarErrorTerminalAuto(actual.processingState); debugPrint( '[PluriWave][ServicioAudio] rechazo auto codigo=$codigo ' 'terminal=$terminal sobre ${actual.processingState.name}: $mensaje', ); // A previous refusal's window must not outlive the refusal it belonged // to: this publish takes ownership of the two fields. _temporizadorErrorAccionAuto?.cancel(); _temporizadorErrorAccionAuto = null; _errorAccionAuto = null; playbackState.add( actual.copyWith( processingState: terminal ? AudioProcessingState.error : actual.processingState, errorCode: codigo, errorMessage: mensaje, ), ); // A TERMINAL refusal stands until something else happens: it IS the // session state now, the driver has nothing playing to go back to, and // `error` is not a field that can be quietly withdrawn. // // A refusal over a LIVE session is the opposite — it describes an action, // not the session, and nothing in this class would ever have taken it // back: `_bufferedSub` republishes through `copyWith` about twice a // second and `copyWith` carries omitted fields forward // (`audio_service.dart:400-427`), so `AudioService.java:541-544` re-armed // `setErrorMessage(code, msg)` on every push for the rest of the // station's playback. Bound it here. if (terminal) return; _errorAccionAuto = (codigo: codigo, mensaje: mensaje); _temporizadorErrorAccionAuto = Timer( ventanaErrorAccionAuto, _limpiarErrorAccionAuto, ); } /// Takes a standing action refusal back off the head unit (see /// [ventanaErrorAccionAutoPorDefecto]). Idempotent, and a no-op unless the /// fields on screen are still the exact pair this handler published. /// /// Called by the window timer, by [manejarEstadoPlayer] (a real player /// transition supersedes a refusal about an action) and by [stop]. void _limpiarErrorAccionAuto() { _temporizadorErrorAccionAuto?.cancel(); _temporizadorErrorAccionAuto = null; final pendiente = _errorAccionAuto; if (pendiente == null) return; _errorAccionAuto = null; final actual = playbackState.value; // Someone else owns these fields now — a reconnect status message, or a // terminal playback error that moved the state itself. Blanking those // would remove a message that is still true. if (actual.processingState == AudioProcessingState.error) return; if (actual.errorCode != pendiente.codigo || actual.errorMessage != pendiente.mensaje) { return; } playbackState.add(actual.copyWith(errorCode: null, errorMessage: null)); } /// The stations a voice query is matched against, and the station an EMPTY /// query starts, for the tier resolved by [premium]. /// /// Free tier searches ONLY the free set — not favourites + my stations + /// the catalogue — so a match can never resolve to something the tier /// cannot then play. Future> _candidatasBusqueda({ required bool premium, required List destacadas, }) async { if (!premium) return destacadas; final fuente = _fuenteNavegacionGlobal; if (fuente == null) return destacadas; return [ ...await fuente.favoritos(), ...await fuente.misEmisoras(), ...await fuente.todas(), // Appended last so they only ever win a query nothing else matched. ...destacadas, ]; } /// 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 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. /// /// An EMPTY query ("Reproduce PluriWave") starts the last played station, /// or the first featured one (fix/auto-quality-guidelines, item 13). This /// used to do NOTHING for either tier — `emisoraParaBusqueda` returns /// `null` on an empty query and the caller just returned — so the single /// most likely voice command failed even for a paying customer. Google's /// voice-actions documentation requires an empty query to start playback. /// /// Never throws. A miss now publishes an explained error rather than /// returning silently; it still never plays something arbitrary. @override Future playFromSearch( String query, [ Map? extras, ]) async { try { final premium = await esPremiumPersistido(); final destacadas = await resolverEmisorasDestacadas(); if (query.trim().isEmpty) { // `resolverEmisorasDestacadas` puts the last played station first // when one is persisted, so this is "resume what I was listening // to", falling back to the first featured station on a fresh install. final arranque = destacadas.isEmpty ? null : destacadas.first; if (arranque == null) { _publicarErrorAuto( codigoErrorEmisoraNoDisponible, _textos.autoErrorBusquedaSinResultados, ); return; } await playMediaItem(mediaItemParaEmisora(arranque, l10n: _textos)); return; } final candidatas = await _candidatasBusqueda( premium: premium, destacadas: destacadas, ); final emisora = emisoraParaBusqueda(query, candidatas); if (emisora == null) { _publicarErrorAuto( codigoErrorEmisoraNoDisponible, _textos.autoErrorBusquedaSinResultados, ); 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 { // The mandatory backstop against a head unit's CACHED browse tree: // `getChildren` alone cannot stop a stale `emisora:`/`pista:`/ // `eq_preset:` tap from a tree fetched before a downgrade (or from // another device). Checked BEFORE every branch below, including local // tracks and the equalizer. // // fix/auto-quality-guidelines item 11/12: the gate is now content // scoped, and its refusal is EXPLAINED instead of silent. Only an // `emisora:` whose uuid is in the free set is free content — // every other id shape (local tracks, folder-play actions, equalizer // presets, catalogue stations) resolves `uuid` to `null` here and is // therefore premium, exactly as before. final premium = await esPremiumPersistido(); final destacadas = await resolverEmisorasDestacadas(); final uuid = uuidDeMediaIdEmisora(mediaId); if (debeBloquearCambioDeEmisora( premium: premium, esEmisoraGratuita: esEmisoraGratuita(uuid, destacadas), )) { _publicarErrorAuto( codigoErrorEmisoraPremium, _textos.autoErrorEmisoraPremium, ); return; } // 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, etiquetas: etiquetasArbolAutoDesde(_textos), ); 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; } // Station ids. `_fuenteNavegacionGlobal` is null until `main.dart` // registers it, and used to make this branch return in silence — so a // tap that arrived before registration did nothing, with nothing said. // The free set is resolvable straight from the binary, so it stands in // as the browse source instead (item 12). final fuente = _fuenteNavegacionGlobal ?? FuenteEmisorasAutoDestacadas(destacadas); final reproducida = await reproducirPorMediaId( mediaId, fuente: fuente, reproducir: playMediaItem, ); if (!reproducida) { _publicarErrorAuto( codigoErrorEmisoraNoDisponible, _textos.autoErrorBusquedaSinResultados, ); } } 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 []), }; }