La app promete decibelios en cuatro sitios y no los entregaba en ninguno. El slider abarca un +/-12 fijo, escribe el numero con su unidad debajo de cada banda, y `equalizerBandValue` le dice literalmente "decibelios" a TalkBack. El modelo documenta las bandas como dB y los presets de fabrica estan escritos en dB. just_audio documenta `setGain` en decibelios y multiplica por 1000 para llegar a milibelios sin normalizar nada: `minDecibels`/`maxDecibels` son la CAPACIDAD del dispositivo, no una escala a la que normalizar. Pese a eso, la ganancia se estiraba por `maxDecibels/12`. Un +6 dB llegaba como +10 en un movil de rango ancho. El estiramiento nunca fue una decision de diseño Antes dea9202c6el codigo era `setGain(preset.bandas[i])`, decibelios literales. Ese commit metio la normalizacion sin docstring, sin test y sin nota de diseño, y traia un fallo: 0 dB caia en el punto medio del rango, asi que un preset PLANO realzaba.3449e2ccorrigio exactamente eso y nada mas -- su propio mensaje dice que el objetivo era "0 dB es siempre 0" -- heredando el estiramiento sin discutirlo. No hay ADR, spec ni comentario que lo justifique. Lo que de verdad rompia: la portabilidad Lo que se persiste y se exporta son los dB del usuario, sin escalar; el escalado ocurre solo al escribir en el efecto nativo. Asi que el mismo backup suena distinto en cada telefono, y la interfaz informa de una restauracion perfecta mientras el audio no lo es. Peor con el rango asimetrico habitual de Android ([-12, +19]): los realces se multiplican por 1.58 y los cortes por 1.0, de modo que el preset no solo sube de nivel, CAMBIA DE FORMA. Jazz [3, -1, -1.5, 2, 4] se entregaba como [4.75, -1, -1.5, 3.17, 6.33]. Con presets por dispositivo, el mismo preset se deformaba distinto en el altavoz y en el Bluetooth. No es un clamp a secas `db.clamp(minDecibels, maxDecibels)` habria reintroducido el fallo de3449e2c: en un dispositivo que reporte [+3, +19], el cero se convierte en +3 y el preset plano vuelve a realzar. La ventana se fuerza a contener el cero, asi que se conservan todas las invariantes ganadas -- 0 siempre es 0, el signo nunca se invierte, el resultado nunca escapa del rango nativo, un dispositivo sin margen en un lado no puede realzar por ese lado -- y solo desaparece el estiramiento. Que se oye distinto: en un movil de +/-12 dB, identico a hoy. En uno de rango ancho los realces bajan, hasta un 40% menos en dB en uno de +/-20. Los cortes apenas se mueven, porque la forma habitual es [-12, +N] y el lado negativo ya iba practicamente 1:1. A cambio, los seis presets de fabrica suenan por fin igual en cualquier telefono. Ningun preset guardado necesita migracion: lo almacenado siempre fueron los dB del usuario. Suite completa: 1534 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes.
3923 lines
181 KiB
Dart
3923 lines
181 KiB
Dart
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<bool?> Function();
|
|
|
|
/// Write port for the same flag (eq-estado-unico item B). Bound to
|
|
/// `ServicioEcualizador.guardarActivo`.
|
|
typedef GuardarEqActivoPersistido = Future<void> 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<ContextoSalto?> 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<PresetEcualizador?> Function();
|
|
|
|
/// Write port for the same context. Bound to `guardarContextoSalto`.
|
|
typedef GuardarContextoSaltoPersistido =
|
|
Future<void> 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<Emisora?> 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<void> 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<void> _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<void> _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<List<PresetEcualizador>> Function()? _fuentePresetsPersonalizadosGlobal;
|
|
|
|
void registrarFuentePresetsPersonalizados(
|
|
Future<List<PresetEcualizador>> Function() fuente,
|
|
) {
|
|
_fuentePresetsPersonalizadosGlobal = fuente;
|
|
}
|
|
|
|
/// Reads the registered custom-preset source, tolerating both "never
|
|
/// registered" and "the read blew up" as the same empty result: a
|
|
/// diagnostics-grade failure must degrade the folder to its factory presets,
|
|
/// never make browsing fail.
|
|
Future<List<PresetEcualizador>> _leerPresetsPersonalizados() async {
|
|
final fuente = _fuentePresetsPersonalizadosGlobal;
|
|
if (fuente == null) return const [];
|
|
try {
|
|
return await fuente();
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// The full ordered preset universe the car's `Ecualizador` folder offers:
|
|
/// the six factory presets first, then [personalizados] in save order.
|
|
///
|
|
/// A custom preset whose `nombre` matches a factory preset is DROPPED, not
|
|
/// appended. Identity here is the raw name — [ConstructorArbolAuto.idPresetEq]
|
|
/// builds `eq_preset:<nombre>` from it and
|
|
/// [ConstructorArbolAuto.resolverPresetEq] resolves by first name match — so
|
|
/// two entries sharing a name would produce one media id that can only ever
|
|
/// reach the first of them. Rendering an item that silently applies a
|
|
/// different preset than the one whose name it shows is worse than not
|
|
/// rendering it, and the factory entry is the one the id is guaranteed to
|
|
/// resolve to.
|
|
///
|
|
/// Single source of truth on purpose: [itemsEcualizadorAuto] builds the items
|
|
/// from this list and the tap dispatch resolves against this same list, so
|
|
/// the folder can never show an item that resolution then refuses.
|
|
List<PresetEcualizador> presetsEcualizadorAuto({
|
|
required List<PresetEcualizador> personalizados,
|
|
List<PresetEcualizador>? deFabrica,
|
|
}) {
|
|
final fabrica = deFabrica ?? PresetEcualizador.presets;
|
|
final nombresFabrica = fabrica.map((p) => p.nombre).toSet();
|
|
return [
|
|
...fabrica,
|
|
...personalizados.where((p) => !nombresFabrica.contains(p.nombre)),
|
|
];
|
|
}
|
|
|
|
/// Teardown hook for whatever `main.dart` wired around the handler and must
|
|
/// be undone when the handler itself dies — today only the
|
|
/// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered
|
|
/// from `main.dart`, mirroring [registrarHandler] and the two browse-source
|
|
/// registrations above; run exactly once from
|
|
/// [PluriWaveAudioHandler.onTaskRemoved].
|
|
///
|
|
/// The direction of the dependency matters: the bootstrap layer injects its
|
|
/// cleanup INTO the service layer, so `servicio_audio.dart` never has to
|
|
/// import `arranque_audio.dart` (nor the plugin's static error stream) just to
|
|
/// be able to close it.
|
|
Future<void> Function()? _limpiezaArranqueGlobal;
|
|
|
|
void registrarLimpiezaArranque(Future<void> Function() limpieza) {
|
|
_limpiezaArranqueGlobal = limpieza;
|
|
}
|
|
|
|
/// 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:<uuid>` 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<PresetEcualizador>? presets,
|
|
}) {
|
|
final lista = presets ?? PresetEcualizador.presets;
|
|
final indice = lista.indexWhere((p) => p == actual);
|
|
if (indice == -1) return lista.first;
|
|
return lista[(indice + 1) % lista.length];
|
|
}
|
|
|
|
/// Localizes a preset's raw `nombre` for the equalizer custom action's
|
|
/// label (Design "EQ custom actions", item 4) — mirrors
|
|
/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated
|
|
/// rather than shared: that file is UI-widget layer, this one is the
|
|
/// service/handler layer, and the mapping is a single small switch, not
|
|
/// worth a cross-layer import for). An unrecognized name (e.g. a future
|
|
/// user-named custom preset) falls through to the raw name verbatim.
|
|
String nombrePresetVisible(AppLocalizations l10n, String nombre) {
|
|
return switch (nombre) {
|
|
'Flat' => l10n.equalizerPresetFlat,
|
|
'Rock' => l10n.equalizerPresetRock,
|
|
'Pop' => l10n.equalizerPresetPop,
|
|
'Bass Boost' => l10n.equalizerPresetBassBoost,
|
|
'Jazz' => l10n.equalizerPresetJazz,
|
|
'Voz' => l10n.equalizerPresetVoice,
|
|
'Personalizado' => l10n.equalizerPresetCustom,
|
|
_ => nombre,
|
|
};
|
|
}
|
|
|
|
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
|
|
/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
|
|
/// toggle. The previous design paired this with a SECOND action that cycled
|
|
/// through the six factory presets; that action is REMOVED. On-device
|
|
/// feedback: many head units render custom actions icon-first, so two
|
|
/// static, non-parametrized glyphs sitting side by side looked identical/
|
|
/// dead even though the toggle's own icon DID change and the cycle action
|
|
/// DID work — a monochrome icon simply cannot legibly encode "which of six
|
|
/// presets" the way a browsable list's text rows can. Preset selection now
|
|
/// lives in the "Ecualizador" browsable folder instead (see
|
|
/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
|
|
/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
|
|
/// instead.
|
|
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
|
|
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
|
|
/// without the native Equalizer effect gets no EQ actions at all, not
|
|
/// broken ones.
|
|
///
|
|
/// On-device feedback follow-up: this action used to reuse the SAME
|
|
/// `ic_stat_pluriwave` drawable as everything else and was visually
|
|
/// indistinguishable on a car head unit, which foregrounds the icon over
|
|
/// the label. It now gets its own dedicated drawables
|
|
/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
|
|
/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
|
|
/// handler dependency.
|
|
List<MediaControl> controlesEcualizadorPersonalizados({
|
|
required bool disponible,
|
|
required bool activo,
|
|
required AppLocalizations l10n,
|
|
}) {
|
|
if (!disponible) return const [];
|
|
return [
|
|
MediaControl.custom(
|
|
androidIcon:
|
|
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
|
|
label:
|
|
activo
|
|
? l10n.eqCustomActionDisableLabel
|
|
: l10n.eqCustomActionEnableLabel,
|
|
name: accionEqToggle,
|
|
),
|
|
];
|
|
}
|
|
|
|
/// The handler's full transport `controls` list for a `playbackState` push.
|
|
///
|
|
/// Top-level and public so tests exercise THIS function rather than a copy of
|
|
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
|
|
/// re-declare the list inline, which meant it stayed green while asserting a
|
|
/// shape `lib/` no longer produced — a guard that cannot see the thing it
|
|
/// guards.
|
|
///
|
|
/// 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<MediaControl> construirControlesTransporte({
|
|
required bool colaActiva,
|
|
required bool playing,
|
|
required bool eqDisponible,
|
|
required bool eqActivo,
|
|
required AppLocalizations l10n,
|
|
}) => [
|
|
if (colaActiva) MediaControl.skipToPrevious,
|
|
if (playing) MediaControl.pause else MediaControl.play,
|
|
...controlesEcualizadorPersonalizados(
|
|
disponible: eqDisponible,
|
|
activo: eqActivo,
|
|
l10n: l10n,
|
|
),
|
|
MediaControl.stop,
|
|
if (colaActiva) MediaControl.skipToNext,
|
|
];
|
|
|
|
/// Content-style extras for the Ecualizador folder's items (decision
|
|
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
|
|
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
|
|
/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
|
|
/// are deliberately duplicated across this handler/service layer and the
|
|
/// pure browse-tree builder layer rather than cross-layer-shared). List
|
|
/// style, not grid: these items are short text options with no artwork of
|
|
/// their own, unlike a station or local-track tile.
|
|
const _contentStyleListaEq = {
|
|
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
|
|
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
|
|
};
|
|
|
|
/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
|
|
/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
|
|
/// preset must be visibly marked").
|
|
///
|
|
/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
|
|
/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
|
|
/// and REJECTED as the marking mechanism: this project's `audio_service`
|
|
/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
|
|
/// 's list/grid hints are exposed — and the raw platform key itself is
|
|
/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
|
|
/// was already listened to"), not item SELECTION; repurposing it here could
|
|
/// render as "already played" on some head units, which would be actively
|
|
/// misleading for a preset picker, and there is no way to verify its actual
|
|
/// rendering on a real head unit from this environment. A plain-text
|
|
/// marker renders identically and unambiguously on every head unit, which
|
|
/// an unverifiable, semantically-mismatched extras key cannot guarantee.
|
|
String _marcarActivoEq(String titulo, {required bool activo}) =>
|
|
activo ? '✓ $titulo' : titulo;
|
|
|
|
/// Builds the "Ecualizador" folder's children for the Android Auto browse
|
|
/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
|
|
/// six factory presets in [PresetEcualizador.presets] order, each localized
|
|
/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
|
|
/// label already uses, so a preset's name reads identically whether the
|
|
/// driver sees it in the now-playing screen's tooltip or in this folder.
|
|
/// All items are playable: tapping one is dispatched through
|
|
/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
|
|
/// dart`), the same seam every other browse-tree leaf already uses; this
|
|
/// folder has no sub-browsing. Exactly one item is marked active via
|
|
/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
|
|
/// whichever preset equals [presetActual] — never both at once, and never
|
|
/// zero once this function is reached (an unresolvable [presetActual] with
|
|
/// [activo] `true` simply marks nothing, which cannot happen in practice
|
|
/// since [presetActual] always originates from [PresetEcualizador.presets]
|
|
/// or a "Personalizado" tweak that would just leave every item unmarked
|
|
/// rather than mis-marking one).
|
|
///
|
|
/// [presetsPersonalizados] are the user's own saved presets, appended after
|
|
/// the factory six by [presetsEcualizadorAuto] (which also settles name
|
|
/// collisions). They render through the same [nombrePresetVisible] call as
|
|
/// everything else: that helper passes an unrecognized name through
|
|
/// verbatim, which is exactly right for a name the user typed themselves.
|
|
List<MediaItem> itemsEcualizadorAuto({
|
|
required bool activo,
|
|
required PresetEcualizador presetActual,
|
|
required AppLocalizations l10n,
|
|
List<PresetEcualizador> presetsPersonalizados = const [],
|
|
}) {
|
|
final constructor = ConstructorArbolAuto();
|
|
final items = <MediaItem>[
|
|
MediaItem(
|
|
id: ConstructorArbolAuto.idDesactivarEq,
|
|
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
|
|
playable: true,
|
|
extras: _contentStyleListaEq,
|
|
),
|
|
];
|
|
for (final preset in presetsEcualizadorAuto(
|
|
personalizados: presetsPersonalizados,
|
|
)) {
|
|
items.add(
|
|
MediaItem(
|
|
id: constructor.idPresetEq(preset.nombre),
|
|
title: _marcarActivoEq(
|
|
nombrePresetVisible(l10n, preset.nombre),
|
|
activo: activo && preset == presetActual,
|
|
),
|
|
playable: true,
|
|
extras: _contentStyleListaEq,
|
|
),
|
|
);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/// Wrapper de alto nivel para el UI.
|
|
class ServicioAudio {
|
|
PluriWaveAudioHandler get _handler {
|
|
assert(
|
|
_handlerGlobal != null,
|
|
'registrarHandler() no fue llamado en main.dart',
|
|
);
|
|
return _handlerGlobal!;
|
|
}
|
|
|
|
Emisora? get emisoraActual => _handler.emisoraActual;
|
|
|
|
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
_handler.configurarLocalizaciones(l10n);
|
|
}
|
|
|
|
Stream<EstadoReproduccion> get estadoStream =>
|
|
_handler.playbackState.map((s) {
|
|
if (s.processingState == AudioProcessingState.error) {
|
|
return EstadoReproduccion.error;
|
|
}
|
|
if (_handler.reconectando) return EstadoReproduccion.reconectando;
|
|
if (s.processingState == AudioProcessingState.loading ||
|
|
s.processingState == AudioProcessingState.buffering) {
|
|
return EstadoReproduccion.cargando;
|
|
}
|
|
if (s.playing) return EstadoReproduccion.reproduciendo;
|
|
if (s.processingState == AudioProcessingState.idle) {
|
|
return EstadoReproduccion.detenido;
|
|
}
|
|
return EstadoReproduccion.pausado;
|
|
});
|
|
|
|
Future<void> reproducir(Emisora emisora) async {
|
|
final item = mediaItemParaEmisora(
|
|
emisora,
|
|
l10n: lookupAppLocalizations(const Locale('es')),
|
|
);
|
|
await _handler.playMediaItem(item);
|
|
}
|
|
|
|
Future<void> pausar() => _handler.pause();
|
|
Future<void> reanudar() => _handler.play();
|
|
|
|
Future<void> togglePlay() async {
|
|
if (_handler.playbackState.value.playing) {
|
|
await pausar();
|
|
} else {
|
|
await reanudar();
|
|
}
|
|
}
|
|
|
|
Future<void> detener() => _handler.stop();
|
|
Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
|
|
double get volumen => _handler.volumen;
|
|
bool get estaSonando => _handler.playbackState.value.playing;
|
|
Stream<int?> get androidAudioSessionIdStream async* {
|
|
yield _handler.androidAudioSessionId;
|
|
yield* _handler.androidAudioSessionIdStream;
|
|
}
|
|
|
|
Future<void> dispose() async {}
|
|
|
|
// ── Ecualizador ───────────────────────────────────────────────────────────
|
|
AndroidEqualizer? get ecualizador => _handler.ecualizador;
|
|
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
|
|
PresetEcualizador get presetActual => _handler.presetActual;
|
|
|
|
/// 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<void> aplicarPreset(PresetEcualizador preset) =>
|
|
_handler.aplicarPreset(preset);
|
|
Future<void> setEcualizadorActivo(bool activo) =>
|
|
_handler.setEcualizadorActivo(activo);
|
|
|
|
Future<void> setBanda(int index, double db) => _handler.setBanda(index, db);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AudioHandler
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
class PluriWaveAudioHandler extends BaseAudioHandler
|
|
with SeekHandler
|
|
implements ObjetivoAudioInterrumpible {
|
|
/// 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<PlayerState>? _estadoPlayerSub;
|
|
StreamSubscription<Duration>? _bufferedSub;
|
|
StreamSubscription<Duration?>? _duracionSub;
|
|
StreamSubscription<PlaybackEvent>? _eventosSub;
|
|
StreamSubscription<int?>? _androidAudioSessionIdSub;
|
|
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
|
|
int? _androidAudioSessionId;
|
|
|
|
/// Last session id processed for EQ re-apply purposes (Design "Change-guard
|
|
/// field separate from broadcast field"). Kept apart from
|
|
/// [_androidAudioSessionId] so external broadcast semantics on
|
|
/// [androidAudioSessionIdStream] stay untouched by the EQ re-apply guard.
|
|
int? _ultimaSessionIdEq;
|
|
Future<void> _colaCambioFuente = Future<void>.value();
|
|
int _revisionFuente = 0;
|
|
|
|
/// `true` only for the window inside [_cambiarFuente] where the OLD player
|
|
/// has been disposed and the FRESH one has not loaded its URL yet — the
|
|
/// window in which `playerStateStream` unavoidably emits a transient
|
|
/// `idle` that is NOT a stop. [mapearEstadoProceso] masks that one `idle`
|
|
/// as `loading` so `audio_service` does not tear the foreground service
|
|
/// (and with it the media notification) down mid-source-change; see that
|
|
/// function's doc for the full mechanism.
|
|
///
|
|
/// A value stuck at `true` is the ONLY risk this flag introduces: a real
|
|
/// user stop would then be masked away from `idle` and the service would
|
|
/// never stop, leaving an unkillable notification. It is therefore cleared
|
|
/// by a `finally` in [_cambiarFuente] (which covers normal completion,
|
|
/// both revision-mismatch `return`s, every `rethrow`, and any non-`Exception`
|
|
/// `Error` that no catch clause matches), AND eagerly at the top of every
|
|
/// catch clause, AND at the start of [stop] and [_gestionarErrorReproduccion]
|
|
/// — i.e. before every single `_player.stop()` call in this class.
|
|
bool _cambiandoFuente = false;
|
|
|
|
/// 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(<String, dynamic>{})`, 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 = <String, BehaviorSubject<Map<String, dynamic>>>{};
|
|
|
|
@override
|
|
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
|
_childrenSubjects.putIfAbsent(
|
|
parentMediaId,
|
|
BehaviorSubject<Map<String, dynamic>>.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(<String, dynamic>{});
|
|
}
|
|
|
|
/// 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<PasoEcualizador> get pasosEcualizadorEjecutados =>
|
|
List.unmodifiable(_pasosEqEjecutados);
|
|
final _pasosEqEjecutados = <PasoEcualizador>[];
|
|
|
|
/// 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:<uuid>` 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<void> _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<Emisora?> _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<void> 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<bool> _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<ContextoSalto?> _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<void> _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<ContextoSalto?> _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:<uuid>`), 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<void> _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<PasoEcualizador> 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<int?> 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<MediaControl> _controlesTransporte({
|
|
required bool colaActiva,
|
|
required bool playing,
|
|
}) => construirControlesTransporte(
|
|
colaActiva: colaActiva,
|
|
playing: playing,
|
|
eqDisponible: _eqDisponible,
|
|
eqActivo: _ecualizadorActivo,
|
|
l10n: _textos,
|
|
);
|
|
|
|
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
|
|
/// called whenever EQ availability/enabled state changes outside a
|
|
/// player-state transition (a custom-action tap, or a phone-side toggle),
|
|
/// so the equalizer action's icon and label stay in sync on the car's
|
|
/// now-playing screen without waiting for an unrelated player event.
|
|
/// Idempotent and cheap (no native calls) — safe to call from any EQ
|
|
/// state-changing path.
|
|
void _actualizarControlesEq() {
|
|
playbackState.add(
|
|
playbackState.value.copyWith(
|
|
controls: _controlesTransporte(
|
|
colaActiva: _colaLocal != null,
|
|
playing: playbackState.value.playing,
|
|
),
|
|
// 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 <stale position>, 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<void> _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<void> 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<void> _encolarCambioFuente(MediaItem mediaItem) async {
|
|
_intencionReproducir = true;
|
|
// Fresh user play/source switch: restart the backoff from scratch and
|
|
// leave any previous reconnect window (S7-R2).
|
|
_reconexion.restablecer();
|
|
_reconectando = false;
|
|
final revision = ++_revisionFuente;
|
|
_colaCambioFuente = _colaCambioFuente
|
|
.catchError((_) {})
|
|
.then((_) => _cambiarFuente(mediaItem, revision));
|
|
return _colaCambioFuente;
|
|
}
|
|
|
|
/// Plays a single local-queue track WITHOUT clearing `_colaLocal` (Design
|
|
/// ADR-2's "queue play" transition) — the ONLY other caller of
|
|
/// [_encolarCambioFuente] besides public [playMediaItem]. Callers are
|
|
/// responsible for setting/keeping `_colaLocal` themselves before calling
|
|
/// this (queue start, auto-advance, skip) — this method itself never
|
|
/// touches the field on the happy path.
|
|
///
|
|
/// [colaEsperada], when provided, is the mid-await race guard (Design
|
|
/// ADR-3's advance flow): after resolving [nodo]'s content URI (the
|
|
/// async gap where a user action could replace `_colaLocal`), playback
|
|
/// only proceeds when `_colaLocal` is still IDENTICAL to [colaEsperada].
|
|
/// Skip/queue-start callers omit it (no prior async gap to guard).
|
|
Future<void> _reproducirEntradaCola(
|
|
NodoLocal nodo, {
|
|
ColaLocal? colaEsperada,
|
|
}) async {
|
|
final fuente = _fuenteMusicaLocalGlobal;
|
|
if (fuente == null) {
|
|
if (colaEsperada != null) _avanzandoCola = false;
|
|
return;
|
|
}
|
|
final item = await construirMediaItemColaLocal(
|
|
nodo,
|
|
fuente: fuente,
|
|
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<void> _iniciarColaLocal(List<NodoLocal> pistas) async {
|
|
final cola = ColaLocal(pistas: pistas);
|
|
_colaLocal = cola;
|
|
await _reproducirEntradaCola(cola.actual);
|
|
}
|
|
|
|
/// First line of the `playerStateStream` listener (Design ADR-3, Phase 3
|
|
/// task 3.3): pure-decision-driven queue-advance handling. A no-op for
|
|
/// radio (never emits `completed`) and single-track local playback
|
|
/// (never sets `_colaLocal`) — see [decidirAvanceCola]'s doc comment for
|
|
/// the full gating contract.
|
|
void _manejarFinPista(ProcessingState proc) {
|
|
final decision = decidirAvanceCola(
|
|
colaLocal: _colaLocal,
|
|
avanzandoCola: _avanzandoCola,
|
|
trackCompletado: proc == ProcessingState.completed,
|
|
);
|
|
// 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<void> _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<void> _recrearPlayer() async {
|
|
await _estadoPlayerSub?.cancel();
|
|
await _bufferedSub?.cancel();
|
|
await _duracionSub?.cancel();
|
|
await _eventosSub?.cancel();
|
|
await _androidAudioSessionIdSub?.cancel();
|
|
|
|
final anterior = _player;
|
|
try {
|
|
await anterior.stop().timeout(_timeoutCierrePlayer);
|
|
} catch (_) {}
|
|
try {
|
|
await anterior.dispose().timeout(_timeoutCierrePlayer);
|
|
} catch (_) {}
|
|
|
|
_eq = _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<void> _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<void> 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<void> setBanda(int index, double db) async {
|
|
final bandas = List<double>.from(_presetActual.bandas);
|
|
if (index >= 0 && index < bandas.length) {
|
|
bandas[index] = db;
|
|
_presetActual = _presetActual.copyWithBandas(bandas);
|
|
}
|
|
_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<void> _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<bool> _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<void> 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<void> 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<void> sembrarEcualizadorActivo(bool activo) =>
|
|
_aplicarEcualizadorActivo(activo, persistir: false);
|
|
|
|
Future<void> _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<void> setVolumen(double vol) async {
|
|
_volumen = vol.clamp(0.0, 1.0);
|
|
await _player.setVolume(_volumenEfectivo);
|
|
}
|
|
|
|
double get _volumenEfectivo =>
|
|
_atenuado ? _volumen * _factorAtenuacion : _volumen;
|
|
|
|
// ── ObjetivoAudioInterrumpible (audio-session seam, S3-R1) ───────────────
|
|
|
|
@override
|
|
bool get intencionReproducir => _intencionReproducir;
|
|
|
|
@override
|
|
bool get estaReproduciendo => playbackState.value.playing;
|
|
|
|
@override
|
|
Future<void> pausar() => pause();
|
|
|
|
@override
|
|
Future<void> reanudar() => play();
|
|
|
|
@override
|
|
Future<void> setAtenuado(bool atenuado) async {
|
|
if (_atenuado == atenuado) return;
|
|
_atenuado = atenuado;
|
|
await _player.setVolume(_volumenEfectivo);
|
|
}
|
|
|
|
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
|
|
/// existing [_activarEcualizador] (already does the correct idempotent
|
|
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
|
|
/// [_ecualizadorActivo] rather than forcing it on). Called by
|
|
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
|
|
/// interface member's doc for why the existing session-id-change trigger
|
|
/// misses this case.
|
|
@override
|
|
Future<void> reaplicarEcualizador() => _activarEcualizador();
|
|
|
|
@override
|
|
Future<void> play() 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<void> pause() {
|
|
// User (or audio-session interruption) pause: disarm any pending retry —
|
|
// a stall must never fight an intentional pause (S7-R2-B, S7-R6).
|
|
_intencionReproducir = false;
|
|
_errorTerminal = false;
|
|
_detenerReconexion();
|
|
return _player.pause();
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
// User stop (including the sleep-timer fade-out stop): cancel reconnect
|
|
// so retries never restart playback after a stop (S7-R6).
|
|
_intencionReproducir = false;
|
|
_detenerReconexion();
|
|
// 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<void> seek(Duration position) => _player.seek(position);
|
|
|
|
/// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task
|
|
/// 3.6). Past the last track, clears the queue and stops — mirroring
|
|
/// auto-advance's end-of-queue behavior (no wraparound).
|
|
///
|
|
/// With NO local queue this now moves to the next STATION instead of doing
|
|
/// nothing: the car's transport row offers previous/next for radio too,
|
|
/// and a button that is present but inert is worse than no button.
|
|
@override
|
|
Future<void> skipToNext() async {
|
|
// 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<void> 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<void> _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 <Emisora>[] : await fuente.favoritos();
|
|
final misEmisoras =
|
|
fuente == null ? const <Emisora>[] : await fuente.misEmisoras();
|
|
final todas = fuente == null ? const <Emisora>[] : await fuente.todas();
|
|
final grupos =
|
|
fuente == null ? const <GrupoFavoritos>[] : await fuente.grupos();
|
|
|
|
List<Emisora> 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<dynamic> customAction(
|
|
String name, [
|
|
Map<String, dynamic>? 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<void> 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<void> 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<List<MediaItem>> getChildren(
|
|
String parentMediaId, [
|
|
Map<String, dynamic>? 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 <MediaItem>[]
|
|
: [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 <Emisora>[] : 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<MediaItem?> 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<List<Emisora>> _candidatasBusqueda({
|
|
required bool premium,
|
|
required List<Emisora> 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<void> playFromSearch(
|
|
String query, [
|
|
Map<String, dynamic>? 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<void> playFromMediaId(
|
|
String mediaId, [
|
|
Map<String, dynamic>? extras,
|
|
]) async {
|
|
try {
|
|
// The mandatory backstop against a head unit's CACHED browse tree:
|
|
// `getChildren` alone cannot stop a stale `emisora:<uuid>`/`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:<uuid>` 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<List<Emisora>> _listaParaCarpeta(
|
|
FuenteEmisorasAuto fuente,
|
|
String parentId,
|
|
) => switch (parentId) {
|
|
ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(),
|
|
ConstructorArbolAuto.idTodas => fuente.todas(),
|
|
_ => Future.value(const []),
|
|
};
|
|
|
|
}
|