Commit Graph
56 Commits
Author SHA1 Message Date
FreeTLab b1bf289e0d fix(eq): entregar los decibelios que pide el usuario, sin estirarlos
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m56s
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 de a9202c6 el 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. 3449e2c corrigio 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 de 3449e2c:
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.
2026-09-06 00:08:09 +02:00
FreeTLab 8fc3d99fbd fix: el coche recuerda la ultima emisora y deja de publicar una sesion fantasma
Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno
es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es
lo que dejo el hueco a la vista.

La ultima emisora solo la escribia el telefono

La clave `ultima_emisora_v1` tenia como unico escritor a
`EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol
de widgets. El motor que arranca Android Auto es headless de verdad, asi que una
sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar
se ofrecia la emisora de la ultima vez que se uso el movil.

El handler recibe ahora sus puertos de lectura y escritura, con la misma forma
que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`:
el cuello de botella por el que pasan todas las rutas -- telefono, toque en el
coche, voz, saltos, avance de cola y la propia reanudacion.

Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores
independientes de la misma clave acaban divergiendo siempre; es exactamente lo
que ya costo varias rondas con el flag del ecualizador.

Las pistas locales quedan excluidas: un `content://` guardado como ultima
emisora seria una fila de reanudacion que no resuelve a nada.

play() sin fuente levantaba un servicio en primer plano vacio

just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un
`play()` en frio no tocaba la plataforma pero si emitia ese estado sobre
`processingState: idle`. audio_service entraba en estado de reproduccion
mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero
audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche
enruta su tecla de play directamente ahi.

Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal,
y si no hay nada que restaurar no toca el reproductor ni publica nada.

En frio no habia metadatos que enseñar

El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un
motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el
`mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda
antes y despues de la lectura de disco para no pisar una emisora ya sonando.

`getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del
coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una
emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta.

Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:08:09 +02:00
FreeTLab a0fae57219 fix: el ecualizador del coche aplica el preset real y en el orden correcto
Reportado desde el uso real: desde el movil el ecualizador va bien, pero el
boton de Android Auto a veces no hace nada y a veces suena como si se aplicara
una doble ecualizacion.

El preset del handler nunca se sembraba desde disco

`_presetActual` arrancaba en `flat` a fuego. registrarHandler sembraba el flag
de encendido pero no el preset, asi que en un motor donde la interfaz del
telefono nunca corrio -- el que arranca Android Auto -- el toggle del coche
aplicaba `flat`, o lo que hubiera quedado, en vez del preset del usuario. Es la
misma clase de fallo que ya se corrigio para el flag: aquel recibio un puerto
headless y el preset se quedo fuera. Ahora tiene el suyo, con la misma forma:
opcional, el fallo se traza y cae al valor por defecto, nunca propaga.

La siembra respeta un preset ya elegido por EstadoEcualizador, que es mas rico
que la clave principal, para que la lectura de disco en vuelo no lo pise.

El efecto se habilitaba antes de escribir las ganancias

La ruta era setEnabled -> setEnabled -> ganancias: `aplicarPreset` volvia a
llamar a setEnabled por su cuenta. Entre la habilitacion y la escritura sonaban
las ganancias anteriores, y ese hueco es lo que se percibia como doble
ecualizacion. Ahora una funcion pura devuelve los pasos en orden y ambas rutas
la recorren: ganancias primero, habilitacion despues.

Las ganancias NO se resetean al apagar, y es deliberado: setEnabled(false)
puentea el efecto sin liberarlo ni limpiar sus niveles, y la ruta de encendido
los reescribe enteros antes de habilitar, asi que no queda ninguna ventana de
ganancia rancia que un reset pudiera cerrar.

El boton desaparecia en cada cambio de emisora

`_recrearPlayer` bajaba `_eqDisponible` sin republicar controles, asi que cada
cambio de emisora emitia al menos un estado sin la accion de EQ. Peor: las
llamadas nativas estan detras de ese flag, de modo que un toggle en esa ventana
cambiaba el icono sin tocar el audio. Ahora el unico que lo escribe es
`_activarEcualizador`.

Mantenerlo optimista exigia quitar de la ruta del toggle el `await
_eq.parameters`, que es un Completer que solo se completa cuando el reproductor
se engancha: esperarlo dejaba el boton pendiente durante toda la carga, y para
siempre si la carga fallaba. Se cachean los parametros al activarse.

Los fallos nativos dejan de ser mudos

El `catch (_) {}` ocultaba que la llamada nativa habia fallado y dejaba el icono
afirmando un estado que el audio no tenia. Ahora se traza, y un fallo al
habilitar revierte el flag, republica los controles y no persiste.

EstadoEcualizador adopta lo que el motor acepto en vez de asumir que su peticion
prospero: sin eso, el telefono escribia en disco un valor que el handler acababa
de rechazar, reabriendo la divergencia que el dueño unico habia cerrado.

Suite completa: 1515 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:08:09 +02:00
FreeTLab 5f35ab7d6a feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m27s
Dos fallos reportados desde el uso real en el coche.

Los grupos de favoritos no volvian al importar

La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la
asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo
estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar,
que fuerza "sin asignar" a proposito, porque una emisora recien marcada como
favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de
restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se
separa la ruta de restaurar, que respeta el grupo_id del backup y su orden.

Ningun test lo detectaba porque el doble de pruebas era infiel: el fake
conservaba el grupo que la implementacion real destruia, y no tenia
restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo
esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble.

No hace falta subir la version del formato: el envoltorio ya llevaba todo.

El coche perdia la lista al reconectar

El contexto de reproduccion vivia solo en memoria, y el motor que arranca
Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al
reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y
siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se
persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve
contra las listas vivas en cada salto: si el grupo cambia de contenido entre
sesiones, el coche ve lo actual y no una foto vieja.

Cadena de repliegue, decidida por el propietario:
  grupo vivo con la emisora dentro  -> se recorre el grupo
  grupo vivo sin la emisora         -> se permanece en el grupo, primera
  grupo vivo pero vacio             -> se ensancha a favoritos
  grupo borrado                     -> favoritos
  sin favoritos                     -> comportamiento actual

Honrar un grupo de una sola emisora exigia levantar dos barreras, no una:
el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con
menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a
ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora
se salio del grupo" se trata en el flujo del salto.

La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo
solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta
en vez de recorrerse.

Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-02 22:56:10 +02:00
FreeTLab 241f81e535 fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on
stop button makes the entire app useless", citado contra las Android for Cars
App Quality Guidelines. La causa no era el boton de parar.

Maquina de estados del transporte

_cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo
comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de
fuente se encolan incrementando la revision al encolar, no al ejecutar, asi
que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que
las entradas obsoletas reescribieran loading sobre el idle que stop() acababa
de publicar. Estado final: loading para siempre sobre una sesion que
audio_service ya habia desactivado. Ahora la guarda de revision es la primera
sentencia del metodo.

pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual
despues de pulsar pausa; se revalida la intencion antes de llamar a play().
Se anade un suelo de estado que cierra cualquier loading o buffering sin carga
viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando,
para no convertir un tunel en un error. El presupuesto hasta el primer mensaje
baja a menos de diez segundos y los reintentos ya no borran el mensaje visible.

Tier gratuito en el coche

El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando
no habia premium, y un revisor con instalacion limpia siempre es tier
gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId
retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con
emisoras reales y reproducibles, compiladas en el binario para que existan en
frio, y la puerta de entitlement acota contenido en vez de bloquear acciones.
Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la
ultima emisora, que fallaba tambien a los clientes de pago.

Localizacion

El locale del handler solo lo fijaba un widget que el motor headless nunca
construye, asi que todo error del coche salia en castellano. Se resuelve desde
el locale de plataforma. Se traducen las once etiquetas del arbol que estaban
a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si
vuelve a aparecer texto visible fuera del sistema de traduccion.

Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por
mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los
5 avisos preexistentes.
2026-09-02 22:56:10 +02:00
FreeTLab 3449e2cb79 fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m23s
Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.

1. Ecualizador: el estado no tenia dueño unico

El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.

Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.

Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.

El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.

2. Musica Local no aparecia en el arbol de Android Auto

hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.

La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.

EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.

3. El paywall bloqueaba las compras

restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.

Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
2026-08-31 14:34:49 +02:00
FreeTLab e9f47d47c2 fix(eq): resync EstadoEcualizador with car/notification-initiated changes
A toggle from the Android Auto notification or a preset picked from the
car's EQ folder mutated PluriWaveAudioHandler state directly, leaving
EstadoEcualizador (and therefore the phone UI) unaware and never
persisting the change, so it was lost on the next app restart.

Forward the handler's ecualizadorActivo flag through ServicioAudio and,
mirroring EstadoRadio's existing playFromMediaId resync, diff it plus
presetActual against the cached values on every estadoStream tick,
adopting and persisting a divergence via ServicioEcualizador.
2026-08-28 17:48:52 +02:00
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.

- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
  resolvable headlessly via esPremiumPersistido() for the Android Auto
  audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
  users; browsing into any of them (and playFromMediaId/playFromSearch/
  skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
  choke points, with a locked "Función Premium" item as the backstop.
  Current-station play/pause/stop stays untouched. A free -> premium
  transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
  alarm) are gated behind entitlement via ServicioAnuncios, using
  official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
  action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.

Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
2026-08-10 20:37:07 +02:00
FreeTLab d754e28ddf fix(audio): keep local skips local, and stop a failed station blanking Auto
Three reported suspicions. Two confirmed by reading, one not.

1. CONFIRMED, self-inflicted. Playing a song from the phone and pressing
NEXT jumped to a radio station.

3398d02 taught skipToNext/skipToPrevious to fall back to station skipping
when there is no local queue, so the car's buttons would not be dead for
radio. But queue-less does not mean radio: tapping ONE track goes through
reproducirPistaLocal, which never builds a queue -- only folder playback
sets _colaLocal. That is exactly why the report said "at least the first
time".

emisoraActual cannot tell them apart either: _cambiarFuente fills it in for
every source, so a local MP3 arrives as an Emisora whose url is its
content:// document URI. The media id's scheme is the real discriminator,
the same test that already keeps the recorder off local files. A local
track now skips nowhere, which is the correct behaviour for a single item.

2. CONFIRMED mechanism. A failed station made the app disappear from the
Android Auto pane.

The error path published STATE_ERROR and then cleared everything:
`emisoraActual = null; mediaItem.add(null)`. That leaves the session in an
error state with no metadata at all, and Auto drops a session with nothing
to show -- reported as "if a station fails it seems to crash, and going to
1/3 it fails".

Both are kept now. Nothing outside servicio_audio.dart consumes mediaItem
(verified), so the phone is unaffected, and the car gains two things: the
screen can still name the station that failed instead of going blank, and
previous/next stay usable, so a driver can skip out of a dead station
instead of being stranded -- _saltarEmisora needs emisoraActual to know
where it is in the list. The error state itself is unchanged.

3. NOT CONFIRMED. A local track occasionally jumping to another one mid-play.

An advance 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.
Reading the code cannot separate those, so nothing was changed on a guess.
The advance now logs the decision with the processing state, position and
duration that caused it, so the next occurrence arrives with its reason
attached.

Tests: 1192 -> 1195.
2026-08-07 17:17:48 +02:00
FreeTLab f01c0911f7 fix(auto): guard shipped resources, walk favourite groups when skipping
History review requested by the owner: when and why did the Android Auto
UI stop working.

ANSWER: 31 July, commit 2540556, "give the equalizer actions distinct,
state-aware icons".

  9eff760 (31-07)  androidIcon: 'drawable/ic_stat_pluriwave'  -> in the APK
  2540556 (31-07)  androidIcon: 'drawable/ic_auto_eq_on'      -> NEVER in it

That commit swapped a drawable that shipped for two that the stale CI
resource cache never included. From that moment getResourceId returned 0,
PlaybackStateCompat.CustomAction.Builder threw, and the throw aborted
AudioService.setState before the session was published -- so every Android
Auto symptom chased since is one line of that commit. The bitter part is
that 2540556 was itself a fix for a report about two identical icons.

Three changes.

1. CI guard. The build now unzips the release APK and fails if a drawable
resolved by NAME at runtime is missing. Resolution by name cannot fail at
compile time -- it fails in the car, silently, with id 0. This class of
bug shipped undetected for a week; it cannot ship again.

2. Skipping stations now walks the favourites GROUP first, as requested:
group -> all favourites -> my stations -> catalogue. Two deliberate
exclusions, both tested: `sinAsignarId` is the ABSENCE of a group, not a
group, so those walk all favourites; and a one-member group falls through
too, or both buttons would be dead ends. The group is read from the
FAVOURITE record, never from the playing station -- that one is rebuilt by
emisoraDesdeMediaItem, which carries no group id and would always report
"unfiled".

3. Diagnostics on the station skip. It was reported as doing nothing for
radio, and every early return in that method is silent: an empty list and
a single-entry list look identical from outside. The log now names which
one fired, so the next capture answers it instead of another hypothesis.

Tests: 1161 -> 1165.
2026-08-07 11:38:27 +02:00
FreeTLab 1d5332453f fix(audio): make the audio diagnostics visible in release builds
Every diagnostic line in the audio path used `dart:developer`'s `log()`.
That function writes to the VM service, which a RELEASE build does not
have — so in the only build that ever runs in a car, all eleven of them
went nowhere. `debugPrint`/`print` do reach logcat in release; `log()`
does not.

That includes the two channels built specifically to end the guessing:
- `registrarErrorAudioService`, which subscribes to
  `AudioService.asyncError` so the plugin's swallowed platform exceptions
  stop vanishing (b0271fa). It moved them from a dropped PublishSubject
  to a dropped log call.
- `_trazarEstadoPublicado`, the published-state trace added in 7054a4c to
  settle why the car's play button never becomes pause.

So "no evidence" was never a quiet app. It was an app writing its
evidence somewhere release builds discard. Several rounds of hypotheses
were argued without data that the app was already producing.

All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one
filter catches the audio path and the existing alarm lines together:

  adb logcat | grep PluriWave

No behaviour changes. Tests: 1158, unchanged.
2026-08-06 21:48:20 +02:00
FreeTLab 3398d02a43 fix(auto): keep the service alive through interruptions, restore local music
Four car reports, two root causes.

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

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

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

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

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

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

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

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

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

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

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

Tests: 1146 -> 1158.
2026-08-06 19:49:57 +02:00
FreeTLab a6cdf0e72c fix(auto): advertise the transport actions Android for Cars requires
Reported: on the Android Auto playback screen the play/pause button stays
on PLAY while audio is audibly playing, and "it used to work, in the
latest versions it doesn't".

Previous rounds looked for a regression in this repo's audio commits and
found none: every playbackState.add site publishes playing:true with a
ready processingState, and AudioService.getPlaybackState maps that to
STATE_PLAYING. That search was aimed at the wrong thing.

The Android for Cars guide ("Enable playback control") is explicit:
"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."

systemActions has carried only `seek` + `stop` since e9d1f67, the first
commit of the project -- git log -S confirms it was never once edited. So
the required actions have never been advertised, and no audio commit can
explain a change in behaviour. Android Auto ships as its own app and
updates itself, which is how a working screen breaks with a clean repo
history. That fits the report better than any commit here does.

The phone notification was never affected: it builds its play/pause
button from `controls`, not from these bits, which is exactly why the
symptom is car-only.

Skip actions stay conditional on an active queue on purpose -- the same
guide notes Auto reserves the prev/next slots for them and gives the
space to custom actions when the app does not support them, and that is
the space the equalizer toggle needs.

ACTION_PLAY_FROM_SEARCH is now implemented rather than merely claimed:
advertising it unimplemented would have the car's assistant accept "play
Radio X" and silently do nothing. emisoraParaBusqueda ranks exact name,
then prefix, then substring, then country, accent- and case-insensitive
because voice transcription rarely gets diacritics right; favourites are
searched first so they win a name tie, and a miss plays nothing rather
than something arbitrary.

Still a hypothesis for the play/pause symptom, not a confirmed fix -- it
is documentation-backed and cheap, but only a head unit can confirm it.

Tests: 1132 -> 1141.
2026-08-06 01:28:42 +02:00
FreeTLab 80538900db fix(alarmas,auto): guard the last unguarded snooze path, surface car progress
Continuation of 7054a4c: the native anchor guard alone did not fix the
reported ~1444-minute snooze, because Dart runs AFTERWARDS on the
pre-notice path and had no guard at all.

1. Snooze from the pre-notice notification, root cause.

app.dart dispatches AFTER the receiver's postponeNext already ran and
after startActivity, and EstadoAlarmas.posponerProximaDesdePreaviso took
whatever occurrence it was handed on faith, then persisted and
rescheduled from it -- the last snooze path in the codebase with no
occurrence guard. The occurrence itself is not trustworthy either:
app.dart falls back to alarma.proximaEjecucion when the native event
carries none, and that field can already point at tomorrow.

_ocurrenciaSonando is generalized into _ocurrenciaValida with a caller-
supplied forward allowance and an externally-proposed occurrence that
still has to survive the same check. The pre-notice path gets a
ventanaPreaviso (30 min, matching AlarmScheduler.PRE_NOTICE_MILLIS) --
unlike the ringing-screen guard, this occurrence legitimately has not
happened yet, which is exactly why the existing helper could not just be
reused here.

Also heals state already poisoned by the missing guard: a snoozeHasta
parked past a 3-hour ceiling (posponerEjecucion clamps to 120 minutes,
so anything beyond that is corruption, not a long real snooze) is
dropped on recalculation. Without it, an alarm poisoned on a build
before this fix keeps reporting tomorrow after updating, and the user
reasonably concludes nothing changed.

2. Android Auto: no progress bar or time labels on a local track.

updatePosition was never set anywhere in the handler, so it sat at its
Duration.zero default while copyWith refreshed updateTime to now on every
push -- the car was told "position 0, as of right now" on every event, a
bar pinned at the start regardless of what was actually playing. Now set
from _player.position on both the player-state and buffered-position
listeners (the latter ticks ~2/s, which is what keeps the car's bar
smooth between player-state events). Also stream the MediaItem's
duration once the source reports it -- Auto draws no bar at all without
one, and radio streams correctly keep reporting none (live audio has no
length).

3. Android Auto: drop the Ecualizador browsable folder.

Owner decision after driving with it: a browsable six-preset list is
more interaction than a driver wants, and on/off from all three player
views (already fixed in 7054a4c to win the custom-action slot) is the
only equalizer control that belongs in the car. Preset selection stays on
the phone. This lands back on the redesign mockup's original rule ("sin
carpeta de ecualizador"), now for a road-tested reason. getChildren keeps
answering the folder's id transitionally, since a head unit can have the
old tree cached for a session or two.

The two "raiz always includes/ends with Ecualizador" tests are replaced,
not regressed -- same move the codebase already made once in the other
direction for the same folder.

Tests: 1127 -> 1132.
2026-08-05 23:07:10 +02:00
FreeTLab 7054a4c871 fix(alarmas,auto): guard the native snooze anchor, surface the EQ in the car
Three reported issues, two fixed and one instrumented.

1. Posponer left the alarm snoozed ~1444 minutes (24h04m).

Traced end to end in Kotlin. onAlarmFired runs from the receiver BEFORE the
ringing notification exists, and persists snoozeOriginMillis = null plus a
triggerAtMillis already advanced to TOMORROW by computeNextTriggerMillis.
snooze() then anchored on `spec.snoozeOriginMillis ?: spec.triggerAtMillis`
and picked up tomorrow. The existing clamp could not catch it: it only
rescues anchors in the PAST, so an anchor +24h out sails through. The
countdown text is honest -- ceilMinutes(snoozeUntil - now) over Dart's own
template -- the corrupt value is snoozeUntil. With N=5 and a tap at T+1min
the arithmetic lands on 1444 exactly.

This is the defect a9da855 fixed on the Dart side with
EstadoAlarmas._ocurrenciaSonando, after 9c7cf4e had fixed only one of two
adjacent callers. The native lane never got that guard. Now it has
anchorOccurrenceMillis, with a per-surface forward allowance: none for
snooze() (the ringing notification closes an occurrence that has arrived)
and a full PRE_NOTICE_MILLIS for postponeNext() (the pre-notice
notification's occurrence legitimately has not).

No Kotlin test source set exists in this project, so CI cannot verify this
and no Dart test sees it (all use FakePuertoAlarmasAndroid). Verified by
reading; needs an on-device pass.

2. The equalizer toggle stayed invisible on the Android Auto playback
screen even on v1.2.14+136, which does contain it.

On Android 13+ createCustomAction (AudioService.java:466-469) turns
MediaControl.stop into a custom action too, so the car receives TWO in list
order and stop was first -- a head unit exposing one custom-action slot
shows that and buries the rest in an overflow menu. The equalizer now
precedes stop and wins the slot; it is the better occupant, since the car
has its own path to stop playback while the equalizer is reachable no other
way from that screen.

No platform detection needed, and the phone notification is untouched on
every API level: nativeActions comes out [prev?, play/pause, stop, next?]
below 13 and [prev?, play/pause, next?] on 13+, exactly as before. Both are
now asserted.

The list also moves to a public construirControlesTransporte. The guard
test used to re-declare its own copy of the shape, so it stayed green while
asserting a list lib/ no longer produced. It calls the real builder now.

3. Android Auto shows PLAY while a station is audibly playing: NOT fixed,
deliberately.

The car takes that icon from PlaybackStateCompat.getState()
(AudioService.java:601-611), not from controls -- so none of the recent
controls work can be the cause. All eight playbackState.add sites were
audited and none publishes playing:false while audio runs, which leaves no
traced input to fix. A proposed resync off bufferedPositionStream was
rejected: it can publish a spurious idle, which AudioService.java:565-567
turns into stop() and tears down the foreground service -- the exact
regression abc6b47 fixed, on the highest-frequency listener in the handler.

Added instead a change-gated trace of the state actually published, with
eqDisponible alongside it (that flag gates the equalizer action and is
otherwise unobservable). One car session with `adb logcat -s ServicioAudio`
settles both this and issue 2.

Tests: 1124 -> 1127.
2026-08-05 10:17:41 +02:00
FreeTLab f2f706b342 fix(auto): restore the equalizer toggle and list the user's own presets
Two Android Auto regressions reported from the car.

1. The on/off equalizer action disappeared from the playback screen.

That was self-inflicted: commit cacd3ec removed it on the theory that a
custom action in `controls` aborts `AudioService.setState` and kills the
media notification. Reading the plugin source refutes it. setState
(AudioService.java:513-520) SPLITS the list -- a control carrying a
customAction goes to `customActions` (PlaybackStateCompat, i.e. the car),
everything else becomes a NotificationCompat.Action in `nativeActions`
(the phone notification). The two never mix. And the throw the theory
depended on cannot happen here: ic_auto_eq_on/ic_auto_eq_off both exist
under res/drawable, and the labels are non-empty in all 13 locales.

The notification outage was already fixed by abc6b47 (transient idle on
a source change, which setState turns into a full stop() at :557).

The action is back, with both state-aware icons. The real invariant --
a custom action's icon must resolve and its label must be non-empty --
is now a test that reads res/drawable and fails on a missing file,
instead of a comment claiming custom actions are forbidden outright.

2. The Ecualizador folder never listed the user's saved presets.

itemsEcualizadorAuto iterated PresetEcualizador.presets, so only the six
factory presets appeared -- the user's own were unreachable from the
car, the surface where a preset picker matters most. They now arrive
through a registered read function (same seam as stations and local
music, re-read per browse so a preset saved on the phone shows up
without an app restart).

presetsEcualizadorAuto is the single source of truth for the ordered
universe, used to BUILD the items and to RESOLVE a tap, so the folder
cannot show an item that resolution then refuses -- which is what the
factory-only default in seleccionarPresetEqPorMediaId would have caused.
A custom preset whose name collides with a factory one is dropped: the
media id is the raw name, so it could only ever resolve to the factory
entry, and an item that applies a preset other than the one it names is
worse than an absent one.

Tests: 1108 -> 1120.
2026-08-03 21:32:09 +02:00
FreeTLab cacd3ece57 fix(audio): keep custom actions out of the media notification controls
The equalizer toggle appended to the transport controls was aborting the
whole notification. controls feeds BOTH the phone notification and the
car playback screen, and AudioService.setState walks every control
through createCustomAction (AudioService.java:513-520) BEFORE reaching
mediaSession.setPlaybackState (:552) and enterPlayingState (:559) -- the
only place the notification is ever posted.

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

Nothing is lost in the car: the Ecualizador browse folder already lists
Desactivar plus every preset by name, which is Auto's own idiom for
choosing among options.
2026-08-01 20:33:34 +02:00
FreeTLab 1b126d5147 fix(audio): publish idle from stop() instead of trusting the player
just_audio's playerStateStream is .distinct() over a value-equal
PlayerState, so stopping an already-idle player emits nothing. Paired
with the source-change mask -- which writes loading into playbackState
rather than filtering at read time -- a stop landing before native init
completed would leave the state at loading forever.

audio_service only tears the foreground service down on a non-idle to
idle transition, so that window produced an unkillable notification
stuck on "cargando" with a dead Stop button: strictly worse than the
teardown this branch removes.

Additive and idempotent -- when the player does emit its own idle, this
just lands first.
2026-08-01 19:19:30 +02:00
FreeTLab 6da3e69f7e fix(audio): stop emitting a transient idle during a source change
Root cause of the disappearing media notification, and it is self-inflicted
on EVERY Android version — no plugin patch involved.

`audio_service`'s `_observePlaybackState` (audio_service.dart:1131-1136) calls
`AudioService._stop()` on ANY transition into `idle` from a non-idle state.
That reaches `stopService()` -> `deactivateMediaSession()` ->
`notificationManager.cancel(NOTIFICATION_ID)`. The notification is re-posted
at exactly one place, `internalStartForeground()`, reachable only from the
`!wasPlaying && playing` edge in `setState()`, and its FIRST statement is
`ContextCompat.startForegroundService(...)` — which on API 31+ throws
`ForegroundServiceStartNotAllowedException` 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; a fresh player's first `playerStateStream` event is always `idle`,
and the listener forwarded it verbatim. So `loading -> idle` tore the
foreground service down mid-source-change, and recovery depended on the
following `playing: true` edge restarting it. Screen off, lock screen, or an
Android Auto / Bluetooth-initiated start is precisely where the platform
refuses that restart: audio keeps playing, the notification never returns.
That is exactly what the user reports.

The mapping decision moves out of the private `_mapProcState` into a pure
top-level `mapearEstadoProceso(proc, {required bool cambiandoFuente})`, so the
one line that decides whether the foreground service dies is unit-testable
without instantiating the handler (which needs MethodChannels). It is
byte-for-byte identical to the old switch in every case except `idle` while a
source change is in flight, which now maps to `loading`. The test asserts the
full ProcessingState x cambiandoFuente matrix against a literal transcription
of the previous mapping, and asserts both directions explicitly: a real stop
still yields `idle`, a source-change idle yields `loading`, and `idle` is the
only case where the two branches differ at all.

The only risk this introduces is a `_cambiandoFuente` stuck at `true`: a real
user stop would be masked away from `idle`, the service would never stop, and
the notification would become unkillable. So the flag is cleared by four
independent mechanisms rather than one audited path:

  - a `finally` around the whole body of `_cambiarFuente`, which covers normal
    completion, BOTH `revision != _revisionFuente` early returns, every
    `rethrow` out of a catch clause, and any non-`Exception` `Error` that none
    of the three clauses matches;
  - eagerly at the top of each of the three catch clauses — needed on top of
    the `finally` because `_gestionarErrorReproduccion` calls `_player.stop()`
    WITHOUT awaiting it, so that `idle` could otherwise land while the mask
    was still up;
  - right after `setUrl` resolves, before anything below can await, since the
    fresh player's transient `idle` is already behind us at that point;
  - at the start of `stop()` — before `_player.stop()` — and at the start of
    `_gestionarErrorReproduccion`, which makes the invariant total: the flag
    is `false` before every single `_player.stop()` call in this class.

`stop()` matters most: `BaseAudioHandler.stop()` is empty, so the handler
never pushes `idle` itself — teardown is driven entirely by the player's
emission. A stop landing while a station change was still in flight would
otherwise be masked and the notification would survive the stop.

Audited: two `_player.stop()` call sites exist and both are preceded by a
clear; `_recrearPlayer` has exactly one caller and it is guarded; the old
player cannot emit during `_recrearPlayer` because its subscriptions are
cancelled first.
2026-08-01 19:15:53 +02:00
FreeTLab b0271fa953 feat(audio): log AudioService.asyncError instead of swallowing it
`AudioService.asyncError` had ZERO subscribers app-wide. The plugin funnels
every asynchronous failure of its own observers into that stream and nowhere
else — `_observePlaybackState`, `_observeMediaItem` and `_observeQueue` each
wrap their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
path uses `.catchError(_asyncError.add)` — and a `PublishSubject` with no
listeners simply drops what it is given. The platform-side exception behind
"the media playback notification disappeared" was therefore being discarded
without a single log line, which is why that report arrives with no evidence
attached.

`observarErroresAudio` is a pure, injectable seam in `arranque_audio.dart`
(stream in, logger callback out), matching the seam convention this codebase
already uses for `esperarArranqueAudio`, `decidirAvanceCola` and
`debeReaplicarEcualizador`: the unit tests exercise the wiring with a plain
`StreamController`, never the real plugin. The default logger emits one
`[PluriWave]`-prefixed `developer.log` line at `level: 900`, the same level
and prefix `servicio_audio.dart` already uses, so one logcat filter catches
both.

Wired from `lib/main.dart`, not from `arranque_audio.dart`: main.dart is the
module that genuinely owns handler lifecycle — it is the only caller of
`AudioService.init`, `registrarHandler` and `ServicioAudioSession`, and both
the on-time and the degraded/timeout startup branches converge on its
`conectarHandler` closure. `arranque_audio.dart` owns only the timeout race
and the degraded loading shell; it never creates or registers a handler
(`alListo` is injected into it from main.dart), so it has no lifecycle to
hang a subscription on. Subscribing happens before `AudioService.init` — the
getter only touches a static subject — so nothing reported during the
MediaBrowser handshake is missed, and one subscription covers both paths.

The subscription is cancellable and its `cancel` is registered into the
handler via `registrarLimpiezaArranque`, mirroring the existing
`registrarHandler` / `registrarFuenteNavegacion` / `registrarFuenteMusicaLocal`
registration convention. `onTaskRemoved` — the only handler teardown in this
app — runs it, so the subscription cannot outlive what it instruments. The
dependency points bootstrap -> service, so `servicio_audio.dart` never has to
import the bootstrap module or the plugin's static stream.

Zero behaviour change: nothing but log output is added.
2026-08-01 19:15:53 +02:00
FreeTLab f19666508d fix(auto): remove the preset-cycling custom action, superseded by folder
The equalizer's preset-cycling custom action (eq_preset_siguiente) and
its ic_auto_eq_preset drawable are no longer needed now that the
"Ecualizador" folder lists all six presets directly: the folder replaces
what the cycle action did, and this frees a scarce Android Auto custom
action slot.

The on/off toggle is now the equalizer's only custom action.
2026-07-31 19:15:31 +02:00
FreeTLab 8423ccdd0c feat(auto): add an Ecualizador browsable folder with preset selection
On-device feedback showed the equalizer's preset-cycling custom action
looked dead: many head units render custom actions icon-first, and a
monochrome icon cannot legibly encode "which of six presets" the way a
browsable list's text rows can.

This adds an "Ecualizador" folder to the car's browse tree, listing
"Desactivar" first, then the six factory presets by name, with the
currently-active one marked. Selecting a preset routes through the same
playFromMediaId seam every other browse-tree leaf already uses; picking
a preset while the equalizer is off turns it on and applies that preset.

Supersedes the earlier "no equalizer folder" rule (commit 2403da3),
which predated this feedback -- see decision auto/ecualizador-diseno.

The preset-cycling custom action still coexists with the folder in this
commit; it is removed in the next one.
2026-07-31 19:11:01 +02:00
FreeTLab 25405564ee fix(auto): give the equalizer actions distinct, state-aware icons
On a car head unit the custom actions render icon-first, so two actions
sharing ic_stat_pluriwave were indistinguishable and the toggle gave no
sign of whether the equalizer was on.

Each action now has its own drawable, and the toggle swaps between
ic_auto_eq_on and ic_auto_eq_off so its state is legible at a glance.
2026-07-31 18:11:57 +02:00
FreeTLab 491585ad12 fix(eq): re-apply the equalizer after an audio-focus interruption
The equalizer stopped applying after another app interrupted audio (e.g. a
navigation app's voice prompt): play a station with EQ working, let the
prompt speak, resume -- the audio sounds flat until the station is
re-tapped.

debeReaplicarEcualizador only re-attaches the equalizer when the native
player session id actually changes. A short transient interruption keeps
the SAME session (no id rotation), so that trigger never fires, while
Android's AudioEffect framework can let a higher-priority client silently
disable this app's effect instance in the meantime.

Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a
thin delegate to the existing _activarEcualizador() (already the correct
idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it
on resume-from-pause (after reanudar()) and on un-duck (after
setAtenuado(false)) -- additive to the existing session-id trigger, not a
replacement. The method takes no argument, so it can only re-assert
whatever enabled/disabled state the handler already holds -- an
interruption cycle with the equalizer OFF stays OFF.
2026-07-31 00:56:37 +02:00
FreeTLab 9eff760462 feat(auto): equalizer enable/disable and preset cycling from the car
Expose the equalizer's on/off toggle and preset choice as PlaybackStateCompat
custom actions on the now-playing screen. The redesign's removal of the
in-car equalizer FOLDER from the browse tree stays as-is (2403da3) -- this
is a different surface (playback screen custom actions, not a browse
folder) and does not reintroduce it.

Deliberately just 2 actions -- an on/off toggle plus a cycling preset
action, not one action per preset -- since Android Auto only surfaces a
limited number of custom actions. Both reuse the existing
setEcualizadorActivo/aplicarPreset entry points (the same ones
EstadoEcualizador's phone settings screen uses), so a car tap and a phone
tap behave identically and both keep the action labels in sync. Reuses the
bundled ic_stat_pluriwave drawable (the notification's own equalizer-bars
icon) -- zero new native assets. The 5-band constraint is untouched.

New pure, unit-tested functions in servicio_audio.dart: presetSiguiente,
nombrePresetVisible, controlesEcualizadorPersonalizados. New ARB keys
(eqCustomActionEnableLabel/DisableLabel/PresetLabel) across all 13 locales,
regenerated via flutter gen-l10n.
2026-07-31 00:54:05 +02:00
FreeTLab 1b0bea5492 fix(auto): fall back to on-brand artwork when a station or track has none
Stations and tracks with no artwork showed empty tiles in the car. The
browse tree's itemEmisora/_itemLocal already fell back to the rotating
station_art_* drawable via artUriPara/artUriLocal, but the "now playing"
MediaItem built when actually playing something (car tap, phone-initiated
play, folder-queue advance, direct local-track tap) did not, so the car's
now-playing screen still went blank.

Reuse the SAME artUriPara/artUriLocal fallback (already the project's one
selection scheme, mirroring PluriStationArtFallback) at every "now playing"
construction site: reproducirPorMediaId, ServicioAudio.reproducir (now via
the extracted, unit-tested mediaItemParaEmisora), construirMediaItemColaLocal
and reproducirPistaLocal.

Guard the reverse direction too: emisoraDesdeMediaItem (extracted from the
handler's private method, now unit-tested) only reflects artUri back into
Emisora.favicon when it passes faviconUsable, so the phone UI's
CachedNetworkImage widgets never attempt a doomed fetch of the car's
android.resource:// fallback URI -- they keep falling back to
PluriStationArtFallback exactly as before.
2026-07-31 00:47:26 +02:00
FreeTLab 2403da3c2e refactor(auto): drop the in-car equalizer, keep EQ on the phone
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
The car tree carried a whole equalizer feature: an `Ecualizador` root folder
with the six factory presets, a browsable `Personalizado` folder, five band
folders and thirteen gain leaves each, plus the media-id namespaces, routing
predicates, persistence-targeting and children-changed plumbing that fed
them. Equalization is a phone task; the driver should not be tuning 5 bands
from a car screen.

Removed: the `eq_preset:`/`eq_banda:`/`eq_gain:` namespaces and their
predicates and parsers, the `ecualizador`/`eq_custom` folder ids and their
getChildren branches, itemPresetEq/presetsEq/itemEqPersonalizado/bandasEq/
gananciasBandaEq, resolverPresetEq, presetPersonalizadoEfectivo,
dispositivoDestinoEq, debeAplicarPrincipalAhora/debeAplicarSeleccionAhora,
aplicarPresetPorMediaId, aplicarGananciaPorMediaId, and in the handler the
playFromMediaId branches, _presetPersonalizadoAuto, _dispositivoActivoAuto,
_dispositivoDestinoEqAuto and the subscribeToChildren/_hijosSubjects
notification machinery that existed only to refresh band titles after a gain
tap.

Deliberately KEPT: automatic per-device EQ. Reaching the car still applies
that device's preset, because that lives in EstadoEcualizador and the
output-device detection, not in this tree — it works with Android Auto or
without it. Configuring is what moves to the phone; applying stays automatic.

Also kept: the `eq_preset_*_v1` SharedPreferences keys in
ServicioEcualizador, which share a name with the deleted media-id prefix by
coincidence only and hold the phone's own presets.

The root folder set goes from five entries to four (three without local
music); its test now asserts no equalizer folder is offered at all, so a
reintroduction has to be deliberate.
2026-07-27 15:50:24 +02:00
Javier Bautista Fernández 163ff69f7a feat(eq): android auto custom equalizer and robust device detection
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s
- MainActivity: onListen re-emits the current active device and registers
  the audio device callback idempotently, so recreated activities resync
  instead of freezing the active-device id on a disconnected device.
- servicio_dispositivo_audio: resubscribir() re-opens the event channel;
  estado_ecualizador exposes refrescarDispositivoActual() with an
  in-flight guard, invoked on app resume and when opening advanced EQ
  options, clearing stale green-dot device selections.
- navegacion_auto/servicio_audio: new 'Personalizado' browse tree in
  Android Auto (5 band folders, 13 gain steps each) applied live via
  setBanda; preset and gain taps persist at device level when
  multi-device EQ is active and respect station/matrix overrides,
  with apply-before-persist ordering and children-changed notifications.
- l10n: regenerate stale generated localizations; add rxdart as direct
  dependency for the subscribeToChildren override.
2026-07-22 10:26:02 +02:00
FreeTLab dfd40ca937 feat(auto): queue playback and shuffle for local music folders [size:exception]
Adds "Reproducir carpeta" (sequential) and "Reproducir aleatorio"
(Fisher-Yates over the name-sorted order) as folder-scoped playable
actions, with auto-advance on track completion and skip next/prev.

Isolation from live radio is structural, not disciplinary: the
public playMediaItem always clears the local queue on any call, and
a new private _encolarCambioFuente is the only path that can advance
within it. _cambiarFuente, ControladorReconexion, and the reconnect
error path are untouched -- confirmed by a byte-for-byte empty diff
on all 4 pre-existing radio/reconnect regression suites, independently
re-run before and after (21/21 both times).

Handler wiring itself is static-review-only (PluriWaveAudioHandler
can't be unit-instantiated); the isolation/advance/race-guard
decision logic is extracted into cola_local.dart's pure functions,
which are fully unit-tested.
2026-07-20 01:08:15 +02:00
FreeTLab 6ae7e378c4 feat(auto): browse and play local music folders in Android Auto [size:exception]
Phase 1: pick a device folder via SAF (persisted grant, no new
permission), browse its nested subfolders/tracks as a 5th Android
Auto root folder (hidden until configured), and play tracks through
the existing pipeline (EQ, art rotation, cold-start-safe source).
No metadata/sort/filter/shuffle yet -- filename is the title, generic
rotating art is the placeholder; deferred to a follow-up phase.

Adds a new pluriwave/file_actions native method (listAudioChildren)
and an onActivityResult override in MainActivity for the SAF folder
picker -- both static-review-only, no Android build available here.
2026-07-19 20:30:50 +02:00
FreeTLab 90cd232ad2 feat(auto): expose EQ presets as a browsable Android Auto folder
Adds an Ecualizador folder listing the 6 fixed presets; selecting one
applies and persists it through the existing headless-safe seam
without touching playback or the now-playing media item.
2026-07-19 14:12:39 +02:00
FreeTLab f368bcc777 feat(auto): surface favorite groups as Android Auto sub-folders
Favoritos now renders non-empty custom groups as grupo:<id>
sub-folders (hidden when empty) with ungrouped stations left as
direct leaves, reusing the existing hijos() path so the zero-groups
case stays byte-identical to today's flat list.
2026-07-19 13:42:25 +02:00
Javier Bautista Fernández 35bb180612 feat(auto): browsable Android Auto media tree with play-by-id [size:exception]
Expose PluriWave to Android Auto (projected) as a media app:

- Declare car media support (automotive_app_desc.xml + manifest meta-data)
  so Android Auto discovers the existing MediaBrowserService.
- New navegacion_auto.dart: ConstructorArbolAuto builds the browse tree
  (Favoritos / Todas las emisoras / Mis emisoras, 50-item cap, stable
  emisora:<id> media ids), reproducirPorMediaId routes a car tap to the
  existing playMediaItem pipeline, FuenteEmisorasAutoLocal serves the tree
  cold-start-safe (local favorites/custom stations before Flutter UI runs).
- PluriWaveAudioHandler overrides getChildren/getMediaItem/playFromMediaId
  as thin delegations; playback pipeline untouched.
- EstadoRadio pushes live station snapshots to the browse source and
  reconciles the selected station when playback starts from the car.
- Every playable item ships title + artUri; stations without logo fall
  back to a bundled default art (android.resource://).

Tests: 52/52 green (10 new navegacion_auto, 2 new estado_radio, plus
audio safety-net suites). Handler overrides and native XML are
static-review-only (no Android build env). Size exception approved for a
single reviewable commit.
2026-07-16 16:28:44 +02:00
FreeTLab 0ab63731d0 fix(eq): re-apply equalizer when the native audio session rotates
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s
ExoPlayer assigns a new audio session id after transient audio-focus
interruptions (navigation prompts, radar warnings), leaving the
AndroidEqualizer attached to the dead session so playback resumed
without equalization until the next station switch. The session-id
listener now detects genuine rotations through a dedicated guard and
re-activates the equalizer with the current preset, gated on EQ
availability to stay clear of player teardown/rebuild.
2026-07-10 18:51:45 +02:00
FreeTLab 0380bbb1e7 feat(streaming): buffer resilience and automatic reconnection
- Construct the audio player with an enlarged live-stream buffer (15-50s forward cushion, 2.5s to start, 5s after rebuffer) so short network drops play through silently
- Add reconnect-on-stall state machine with bounded exponential backoff (1/2/4/8/16s, ~90s total window, 5 attempts) that re-prepares to the live edge; backoff/decision logic extracted to controlador_reconexion.dart as pure testable code
- Surface a new reconnecting playback state in the mini player and full player (localized in all 13 locales) instead of error dialogs during the retry window; a single friendly error appears only after exhaustion
- Guard interplay: user pause/stop cancels retries, audio interruptions cancel reconnect, alarm wake-up path keeps precedence, recording fails cleanly during drops
- Reset retry budget on station change; route stream timeouts through the network-error class
- 10 new tests (99 total green), flutter analyze clean
2026-06-11 19:54:30 +02:00
FreeTLab 079e19f0ee feat(audio): audio session integration and runtime robustness
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
2026-06-11 16:25:09 +02:00
FreeTLab 089b8b4227 fix(i18n): normalize translations and fallbacks
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s
2026-06-03 21:20:08 +02:00
FreeTLab a9202c6eb3 fix(settings): show real version and map equalizer gains
Build & Deploy Pluriwave / Análisis de código (push) Successful in 13s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m4s
2026-05-21 22:16:18 +02:00
FreeTLab 921e972183 fix(player): stabilize equalizer and visualizer
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m50s
2026-05-21 21:59:59 +02:00
FreeTLab a6a91af402 feat(player): add radio recording and real waveform
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m27s
2026-05-21 21:17:59 +02:00
FreeTLab 0e18c82292 fix(player): recreate audio player on station switch
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m52s
2026-05-21 20:52:28 +02:00
FreeTLab ef22454350 fix(player): separate selection from audio state
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m15s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-21 01:12:20 +02:00
FreeTLab 1791207bd4 fix(player): restore setUrl source loading
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m16s
2026-05-21 00:57:49 +02:00
FreeTLab 6b0faebc7f fix(player): serialize live stream switching
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m24s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
2026-05-21 00:50:23 +02:00
FreeTLab f49d349616 fix(player): restore historical station switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m22s
2026-05-21 00:36:16 +02:00
FreeTLab ee26c78d82 fix(player): handle play errors on station switch
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m23s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
2026-05-21 00:24:04 +02:00
FreeTLab 01135e8a3d fix(player): prevent stale station overwrite
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-21 00:13:12 +02:00
FreeTLab be0d6c5a9e fix(player): restore stable audio switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-20 23:56:03 +02:00
FreeTLab 10520fef48 fix(ui): unify scroll and improve playback switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
2026-05-20 23:44:24 +02:00
FreeTLab 7fcd0f544e feat(radio): add nearby discovery and paged search
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m34s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-20 23:22:23 +02:00