13 Commits
Author SHA1 Message Date
ShanaiaBot 05f70af7f1 chore: bump version to 1.3.3+161 [ci skip] 2026-09-04 13:46:34 +02:00
FreeTLab c30bbacbbc ci: no fallar la compilacion cuando falta el secreto de Google Play [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m13s
El paso que prepara las credenciales hacia `exit 1` si no encontraba
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON, y ese secreto no se ha configurado nunca: TODA
compilacion de PRO terminaba en rojo por una automatizacion que jamas llego a
activarse, mientras los AAB se subian a Play a mano. Un rojo permanente entrena
a ignorar los rojos, y entonces el dia que falle algo de verdad tampoco se mira.

Ahora el paso se omite con un aviso y expone `disponible`, del que dependen los
dos pasos siguientes. No se pierde nada: el AAB ya esta compilado, firmado y
subido a ftl-builds antes de llegar aqui. El dia que se configure el secreto,
los tres pasos se reactivan solos sin tocar el workflow.

El aviso de Telegram deja de afirmar "Publicado en Google Play" cuando la subida
se ha omitido. Un mensaje que dice que se publico algo que no se publico es peor
que no mandar mensaje.
2026-09-04 13:45:41 +02:00
ShanaiaBot 192a3aca0e chore: bump version to 1.3.3+160 [ci skip] 2026-09-04 13:33:58 +02:00
FreeTLab ab3554b746 chore(release): alinear PRO con la version probada 1.3.3 [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 30s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 3m12s
PRO venia arrastrando su propia linea de version (1.3.1) mientras main iba por
1.3.3, asi que el mismo codigo tenia dos numeros segun la rama. Lo que se ha
probado en el coche es 1.3.3+160, y ese es el numero que deben ver los testers:
cuando alguien reporte un fallo, la version que diga tiene que coincidir con la
que se valido.

Se fija 1.3.3+159 porque el CI incrementa el numero de build ANTES de compilar,
de modo que el artefacto publicado sale como 1.3.3+160. El marcador
[version set] impide que el paso de bump suba tambien el patch, que es el
comportamiento por defecto en PRO.

El codigo de version 160 esta libre en Play: lo mas alto subido alli es 157, y
los 158, 159 y 160 de main nunca salieron del portal de builds.
2026-09-04 13:33:05 +02:00
FreeTLab 8a71bc237f feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
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-04 13:26:00 +02:00
FreeTLab 8e155cc0ac 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-04 13:26:00 +02:00
FreeTLab 575ba793ae fix: corregir ecualizador desincronizado, musica local en Android Auto y bloqueo del paywall
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-09-04 13:26:00 +02:00
ShanaiaBot a5572d2cbd chore: bump version to 1.3.1+157 [ci skip] 2026-08-28 23:53:03 +02:00
FreeTLab 98b24d84cd Merge branch 'PRO' of https://git.freetimelab.es/FreeTLab/pluriwave into PRO
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 19s
2026-08-28 23:52:27 +02:00
FreeTLab 72c5777508 ci: name build artifacts by branch and version code [version set]
Every build of a given semver was published as `pluriwave-v1.3.0.aab` into
the same folder, so main and PRO overwrote each other and three different
builds became indistinguishable once downloaded — the browser saves them as
"(1)", "(2)" and the version code is only visible by unzipping the bundle.

That cost two rejected uploads to Play Console for reusing a version code.
Artifacts are now `pluriwave-<branch>-v<semver>+<build>.<ext>", which
identifies itself weeks later and outside this repo.
2026-08-28 23:52:16 +02:00
ShanaiaBot b69041f32a chore: bump version to 1.3.0+156 [ci skip] 2026-08-28 23:40:45 +02:00
FreeTLab 9681a47e83 merge: alarm-import recovery, dismissible paywall and complete config export [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m1s
Same three fixes already merged to main (e57f7bb, a2bed18, 4ca2813):
alarms actually come back after a backup import and get re-scheduled
natively, the premium sheet can be dismissed, and the equalizer on/off
toggle finally travels with the backup.

[version set] keeps the 1.3.0 release name; CI advances the build number.

# Conflicts:
#	pubspec.yaml
2026-08-28 23:38:15 +02:00
ShanaiaBot 524b8f0035 chore: bump version to 1.3.0+154 [ci skip] 2026-08-28 19:59:13 +02:00
13 changed files with 175 additions and 1765 deletions
+13 -57
View File
@@ -68,18 +68,7 @@ jobs:
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "✅ Keystore configurado" echo "✅ Keystore configurado"
# PRO owns the version NAME; every branch advances the build NUMBER. - name: Bump versión patch + commit
#
# Previously main also bumped its patch on every push, so main's semver
# raced permanently ahead of PRO's (main hit 1.3.3 while the branch that
# actually ships sat at 1.3.0). That buried the release artifacts under a
# dev branch on builds.freetimelab.es, which sorts by version, and made
# every main<->PRO merge conflict on pubspec.yaml.
#
# The build number still advances everywhere: Google Play requires it to
# be monotonic across the whole app, so two branches must never mint the
# same code.
- name: Bump versión + commit
run: | run: |
BRANCH="${CURRENT_REF#refs/heads/}" BRANCH="${CURRENT_REF#refs/heads/}"
git config user.name "ShanaiaBot" git config user.name "ShanaiaBot"
@@ -88,20 +77,12 @@ jobs:
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1) SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
BUILD=$(echo "$CURRENT" | cut -d'+' -f2) BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
NEW_BUILD=$((BUILD + 1)) NEW_BUILD=$((BUILD + 1))
# If the triggering commit explicitly pins the version name via the
# Look for [version set] across EVERY commit this push introduced, # [version set] marker, ship that semver as-is (a milestone like 1.0.0
# not just the tip. `git pull` inserts an auto-generated merge commit # or a major/minor jump the automatic patch bump cannot reach) and only
# whose message carries no marker, which silently discarded a pinned # advance the build number, which Google Play requires to stay
# version name and bumped 1.3.0 to 1.3.1 behind our backs. # monotonic. Otherwise keep the default automatic patch+build bump.
RANGO="${{ gitea.event.before }}..${{ gitea.sha }}" if git log -1 --pretty=%B | grep -q '\[version set\]'; then
if git log "$RANGO" --pretty=%B 2>/dev/null | grep -q '\[version set\]'; then
MARCADOR="si"
else
MARCADOR="no"
fi
if [ "$BRANCH" != "PRO" ] || [ "$MARCADOR" = "si" ]; then
# Non-release branches never touch the name; PRO respects a pin.
NEW_VERSION="${SEMVER}+${NEW_BUILD}" NEW_VERSION="${SEMVER}+${NEW_BUILD}"
else else
MAJOR=$(echo "$SEMVER" | cut -d. -f1) MAJOR=$(echo "$SEMVER" | cut -d. -f1)
@@ -110,8 +91,6 @@ jobs:
NEW_PATCH=$((PATCH + 1)) NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}" NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
fi fi
echo "rama=${BRANCH} marcador=${MARCADOR} ${CURRENT} -> ${NEW_VERSION}"
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
git add pubspec.yaml git add pubspec.yaml
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]" git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
@@ -276,29 +255,7 @@ jobs:
ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}" ETIQUETA="${BRANCH}-v${VERSION}+${BUILD_NUMBER}"
APK_NOMBRE="pluriwave-${ETIQUETA}.apk" APK_NOMBRE="pluriwave-${ETIQUETA}.apk"
AAB_NOMBRE="pluriwave-${ETIQUETA}.aab" AAB_NOMBRE="pluriwave-${ETIQUETA}.aab"
# La rama va en el NOMBRE DE LA APP, no en una subcarpeta. DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
#
# El objetivo sigue siendo el de siempre: que main y PRO no se mezclen
# en el portal, que ordena por número de versión y mostraba el build
# de desarrollo como "última versión" por delante del de release.
#
# Pero la primera solución metía la rama como TERCER nivel
# (pluriwave/main/v1.3.3/) y el portal indexa solo DOS —
# <app>/<versión>/<ficheros> —, así que desde el 29-08 ningún build de
# main volvió a aparecer en builds.freetimelab.es aunque el job saliera
# verde: el scp subía bien, a una ruta que el indexador no lee. Nada
# avisaba, y el echo de abajo se comía la rama y mandaba a la carpeta
# antigua, que llevaba congelada desde el +157.
#
# Con la rama en el nombre, PRO conserva la entrada limpia "pluriwave"
# y main tiene la suya, igual que ya conviven radar-foral y
# radar-foral-android.
if [ "$BRANCH" = "PRO" ]; then
APP="pluriwave"
else
APP="pluriwave-$(echo "$BRANCH" | tr '/' '-')"
fi
DESTINO="/opt/ftl-builds/builds/${APP}/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519" SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}" ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
@@ -308,11 +265,8 @@ jobs:
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \ scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \ build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}" "ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
# La ruta se imprime desde ${APP}, no a mano: la version anterior tenia echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
# "pluriwave" escrito a fuego y mandaba a la carpeta equivocada cada echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
# vez que se compilaba algo que no fuera PRO.
echo "✅ APK: builds.freetimelab.es → ${APP} → v${VERSION} → ${APK_NOMBRE}"
echo "✅ AAB: builds.freetimelab.es → ${APP} → v${VERSION} → ${AAB_NOMBRE}"
# La publicacion automatica en Google Play es OPCIONAL. # La publicacion automatica en Google Play es OPCIONAL.
# #
@@ -365,7 +319,9 @@ jobs:
if [ -z "$BOT_TOKEN" ]; then exit 0; fi if [ -z "$BOT_TOKEN" ]; then exit 0; fi
if [ "${{ job.status }}" = "success" ]; then if [ "${{ job.status }}" = "success" ]; then
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados" MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
# Solo se anuncia la subida a Play cuando de verdad ocurrio. # Solo se anuncia la subida a Play cuando de verdad ocurrio: el paso
# se omite si falta el secreto, y un aviso que dice "publicado"
# cuando no se publico es peor que no avisar.
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing" MSG="${MSG}%0APublicado en Google Play · Internal Testing"
elif [ "$BRANCH" = "PRO" ]; then elif [ "$BRANCH" = "PRO" ]; then
-17
View File
@@ -663,29 +663,12 @@ class EstadoEcualizador extends ChangeNotifier {
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight /// Each step then re-checks [_activo]: a newer tap that landed mid-flight
/// owns the outcome, and this superseded call must not apply a preset or /// owns the outcome, and this superseded call must not apply a preset or
/// persist a value the user has already changed their mind about. /// persist a value the user has already changed their mind about.
///
/// The handler can also REFUSE the change: when the native `setEnabled`
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
/// flag back and skips its persistence write, so the value we optimistically
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
/// learn that: on divergence we adopt the handler's real value and return
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
/// to disk that would resurrect it on the next start. The supersede check
/// runs FIRST so a newer tap still owns the outcome; the read-back only
/// speaks for a call nobody overtook.
Future<void> cambiarActivo(bool activo) async { Future<void> cambiarActivo(bool activo) async {
_activo = activo; _activo = activo;
notifyListeners(); notifyListeners();
await audio.setEcualizadorActivo(activo); await audio.setEcualizadorActivo(activo);
if (_activo != activo) return; if (_activo != activo) return;
final aceptado = audio.ecualizadorActivo;
if (aceptado != activo) {
_activo = aceptado;
notifyListeners();
return;
}
if (activo) { if (activo) {
await audio.aplicarPreset(_presetActual); await audio.aplicarPreset(_presetActual);
if (_activo != activo) return; if (_activo != activo) return;
+25 -13
View File
@@ -338,6 +338,24 @@ class EstadoRadio extends ChangeNotifier {
} }
} }
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
}
/// Escucha el stream de estado del audio y gestiona errores de reproducción. /// Escucha el stream de estado del audio y gestiona errores de reproducción.
void _escucharErroresReproduccion() { void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) { _suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
@@ -357,12 +375,9 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual; final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) { if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual; _emisoraSeleccionada = actual;
// Issue 4's write used to live here as well. It is gone: the handler // Issue 4: an Android-Auto-initiated selection is a real station
// persists every station itself from `_cambiarFuente`, which is the // change too — remember it the same way `reproducir` does.
// same source change that moved `audio.emisoraActual` and is the unawaited(_persistirUltimaEmisora(actual));
// reason this branch runs at all. Writing again here would make the
// key's final value depend on how two independent fire-and-forget
// chains interleave on a fast station switch.
} }
notifyListeners(); notifyListeners();
}); });
@@ -573,13 +588,10 @@ class EstadoRadio extends ChangeNotifier {
} }
_emisoraSeleccionada = emisora; _emisoraSeleccionada = emisora;
notifyListeners(); notifyListeners();
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens // Issue 4: remembers the station the user just picked so it survives a
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir` // restart — fire-and-forget, same treatment as `radio.registrarClick`
// below reaches for this very station — see // below (a persistence failure here must never block playback).
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have unawaited(_persistirUltimaEmisora(emisora));
// left the key with TWO fire-and-forget writers whose relative order
// decides the value after a fast A -> B switch, and this one cannot see
// the revision guard that already cancels a superseded change.
try { try {
await audio.reproducir(emisora); await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return; if (revision != _revisionReproduccion) return;
-16
View File
@@ -11,7 +11,6 @@ import 'app.dart';
import 'estado/estado_entitlement.dart'; import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart'; import 'servicios/arranque_audio.dart';
import 'servicios/contexto_reproduccion.dart'; import 'servicios/contexto_reproduccion.dart';
import 'servicios/emisoras_destacadas.dart';
import 'servicios/musica_local_auto.dart'; import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart'; import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart'; import 'servicios/servicio_audio.dart';
@@ -198,12 +197,6 @@ Future<void> main() async {
handler, handler,
leerEqActivoPersistido: ecualizador.leerActivo, leerEqActivoPersistido: ecualizador.leerActivo,
guardarEqActivoPersistido: ecualizador.guardarActivo, guardarEqActivoPersistido: ecualizador.guardarActivo,
// The PRESET's half of the same seam. Without it the handler enabled
// the equalizer with its hardcoded FLAT preset on any engine where the
// phone UI never ran — i.e. every headless Android Auto bind. There is
// no write port: `EstadoEcualizador` still owns saving presets (a car
// preset choice goes through it), so the handler only ever reads.
leerPresetPersistido: ecualizador.leerPresetPrincipal,
// Skip context («in which list am I»). Bound here, on the audio // Skip context («in which list am I»). Bound here, on the audio
// bootstrap path of EVERY engine, precisely because the headless // bootstrap path of EVERY engine, precisely because the headless
// Android Auto engine builds no widget tree and therefore no // Android Auto engine builds no widget tree and therefore no
@@ -211,15 +204,6 @@ Future<void> main() async {
// context the car could never have. // context the car could never have.
leerContextoSalto: contextoSaltoPersistido, leerContextoSalto: contextoSaltoPersistido,
guardarContextoSalto: guardarContextoSalto, guardarContextoSalto: guardarContextoSalto,
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
// reason as the skip context: `EstadoRadio` — which used to be its only
// writer — belongs to the widget tree, and the Android Auto engine
// builds none, so a session that happened only in the car never updated
// the key and the head unit was offered whatever the PHONE last played.
// The write port is now the key's single writer; the read port feeds the
// cold-start metadata seed and the bare-`play()` resume.
leerUltimaEmisora: ultimaEmisoraPersistida,
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
); );
// The handler is the only thing this app ever tears down // The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels // (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
-22
View File
@@ -155,28 +155,6 @@ Future<bool> esEmisoraGratuitaPorUuid(
Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) => Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) =>
_ultimaEmisora(prefs: prefs); _ultimaEmisora(prefs: prefs);
/// Writes [emisora] as the last-played station — the SINGLE writer of
/// [claveUltimaEmisora].
///
/// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio`
/// because the key has to be written from the engine Android Auto starts,
/// which builds no widget tree and therefore never constructs `EstadoRadio`
/// at all: a session that happened only in the car used to leave the key
/// holding whatever the PHONE last played, so the head unit's resume row and
/// the free tier's featured folder were both stale on the next connect.
///
/// Deliberately NOT swallowing failures here: the handler port that calls it
/// traces and swallows (a persistence failure must never break playback),
/// and a silent `catch` in BOTH places would make a dead write channel
/// invisible from a car logcat.
Future<void> guardarUltimaEmisoraPersistida(
Emisora emisora, {
SharedPreferences? prefs,
}) async {
final resueltas = prefs ?? await SharedPreferences.getInstance();
await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap()));
}
/// Reads the persisted last-played station, or `null` when there is none, /// Reads the persisted last-played station, or `null` when there is none,
/// the payload is unreadable, or prefs themselves fail. /// the payload is unreadable, or prefs themselves fail.
Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async { Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
+74 -536
View File
@@ -56,34 +56,10 @@ typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
/// tests, fakes), which simply falls back to deriving the context on the spot. /// tests, fakes), which simply falls back to deriving the context on the spot.
typedef LeerContextoSaltoPersistido = Future<ContextoSalto?> Function(); 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`. /// Write port for the same context. Bound to `guardarContextoSalto`.
typedef GuardarContextoSaltoPersistido = typedef GuardarContextoSaltoPersistido =
Future<void> Function(ContextoSalto contexto); 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 /// Last value read from disk for the equalizer on/off flag, or `null` while
/// nothing has been read yet. /// nothing has been read yet.
/// ///
@@ -110,22 +86,6 @@ bool? _eqActivoPersistido;
/// equalizer on, and the app has always behaved that way. /// equalizer on, and the app has always behaved that way.
bool estadoEqInicial({required bool? persistido}) => persistido ?? true; 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 /// Reads the persisted equalizer flag through [leer] exactly once and seeds
/// [handler] with it, without ever writing back. /// [handler] with it, without ever writing back.
/// ///
@@ -150,37 +110,6 @@ Future<void> _sembrarEcualizadorDesdeDisco(
); );
} }
/// 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. /// Wires the freshly built handler into the module-level seams.
/// ///
/// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler /// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler
@@ -194,11 +123,8 @@ void registrarHandler(
PluriWaveAudioHandler handler, { PluriWaveAudioHandler handler, {
LeerEqActivoPersistido? leerEqActivoPersistido, LeerEqActivoPersistido? leerEqActivoPersistido,
GuardarEqActivoPersistido? guardarEqActivoPersistido, GuardarEqActivoPersistido? guardarEqActivoPersistido,
LeerPresetPersistido? leerPresetPersistido,
LeerContextoSaltoPersistido? leerContextoSalto, LeerContextoSaltoPersistido? leerContextoSalto,
GuardarContextoSaltoPersistido? guardarContextoSalto, GuardarContextoSaltoPersistido? guardarContextoSalto,
LeerUltimaEmisoraPersistida? leerUltimaEmisora,
GuardarUltimaEmisoraPersistida? guardarUltimaEmisora,
}) { }) {
_handlerGlobal = handler; _handlerGlobal = handler;
// Registered BEFORE the seeding below is awaited so that a toggle arriving // Registered BEFORE the seeding below is awaited so that a toggle arriving
@@ -213,26 +139,9 @@ void registrarHandler(
leer: leerContextoSalto, leer: leerContextoSalto,
guardar: guardarContextoSalto, 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) { if (leerEqActivoPersistido != null) {
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido)); unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
} }
if (leerPresetPersistido != null) {
unawaited(_sembrarPresetDesdeDisco(handler, leerPresetPersistido));
}
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved), // iap-freemium-unlock (design.md Open Questions, orchestrator-resolved),
// generalizado en fix/android-auto-musica-local item 4: invalida // generalizado en fix/android-auto-musica-local item 4: invalida
// activamente todo id de nivel raíz que un head unit pueda tener cacheado // activamente todo id de nivel raíz que un head unit pueda tener cacheado
@@ -687,51 +596,44 @@ DecisionToggleEq decidirToggleEq({
requiereLlamadaNativa: eqDisponible, requiereLlamadaNativa: eqDisponible,
); );
/// Delivers a gain from the app's ±12 dB slider to the device's native /// Translates a gain on the app's fixed ±12 dB slider scale to the range the
/// equalizer, clamped by what the device reports it can do /// device's native equalizer actually reports
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself /// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
/// `Equalizer.getBandLevelRange()` in millibels divided by 1000). /// `Equalizer.getBandLevelRange()`).
/// ///
/// Top-level and pure so the mapping is testable without a device. /// 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 /// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy
/// asked for. The native range BOUNDS the request; it is not a scale to /// alto»: the previous implementation normalised across the whole range and
/// normalise into. Both sides are already the same unit — `just_audio` /// interpolated linearly,
/// 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 /// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
/// `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 /// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
/// the «suena muy alto» fix earned. Note the clamp window is widened to /// the range is symmetric, and Android guarantees no such thing — the
/// always contain 0: a naive `db.clamp(minDecibels, maxDecibels)` would, on a /// Equalizer contract only promises a min/max pair. On a device reporting,
/// device reporting a wholly positive range such as [+3, +19], turn a FLAT /// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
/// preset's 0 dB into +3 dB of real boost on every band — exactly the bug /// real boost: audibly louder, with the on/off button still reading "off"
/// that was fixed. So 0 dB is always exactly 0, the sign of the user's intent /// and nothing in the UI to explain it.
/// 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 /// The contract here instead: 0 dB is always exactly 0, and each side of the
/// collapses to 0. /// scale is stretched independently against its own end of the native range,
/// so a cut can never become a boost. A range with no headroom on one side
/// (or none at all) collapses that side to 0 rather than inverting it.
double mapearGananciaNativa( double mapearGananciaNativa(
double db, { double db, {
required double minDecibels, required double minDecibels,
required double maxDecibels, required double maxDecibels,
}) { }) {
final limitado = db.clamp(-12.0, 12.0); final limitado = db.clamp(-12.0, 12.0);
// The clamp window is the device's range widened to include 0, so that a if (limitado == 0) return 0;
// device reporting no headroom on one side collapses that side to "no if (limitado > 0) {
// change" instead of forcing a gain the user never asked for. // Only genuine headroom above unity counts as boost.
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
return (limitado / 12.0) * techo;
}
final suelo = minDecibels < 0 ? minDecibels : 0.0; final suelo = minDecibels < 0 ? minDecibels : 0.0;
final techo = maxDecibels > 0 ? maxDecibels : 0.0; return (limitado.abs() / 12.0) * suelo;
return limitado.clamp(suelo, techo);
} }
/// Advances to the NEXT factory preset after [actual] in [presets] order /// Advances to the NEXT factory preset after [actual] in [presets] order
@@ -1155,7 +1057,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
), ),
); );
AndroidEqualizer _eq = _crearEq(); AndroidEqualizer _eq = AndroidEqualizer();
late AudioPlayer _player = _crearPlayer(); late AudioPlayer _player = _crearPlayer();
StreamSubscription<PlayerState>? _estadoPlayerSub; StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub; StreamSubscription<Duration>? _bufferedSub;
@@ -1320,39 +1222,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
bool _eqDisponible = false; bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible; 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 /// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE
/// in-memory owner. `EstadoEcualizador._activo` is now a pure display /// in-memory owner. `EstadoEcualizador._activo` is now a pure display
/// mirror of this field, and `ServicioEcualizador` is its durable copy. /// mirror of this field, and `ServicioEcualizador` is its durable copy.
@@ -1415,127 +1284,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_guardarContextoSalto = guardar; _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. /// The remembered context: memory first, then the read port ONCE.
/// ///
/// Never throws — an unreadable context must mean "derive it again", not a /// Never throws — an unreadable context must mean "derive it again", not a
@@ -1653,36 +1401,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual; 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; int? get androidAudioSessionId => _androidAudioSessionId;
Stream<int?> get androidAudioSessionIdStream => Stream<int?> get androidAudioSessionIdStream =>
_androidAudioSessionIdController.stream; _androidAudioSessionIdController.stream;
@@ -1857,21 +1575,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
)? )?
fabricaReproductorPrueba; 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() { AudioPlayer _crearPlayer() {
final pipeline = AudioPipeline(androidAudioEffects: [_eq]); final pipeline = AudioPipeline(androidAudioEffects: [_eq]);
final fabrica = fabricaReproductorPrueba; final fabrica = fabricaReproductorPrueba;
@@ -2493,24 +2196,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (revision != _revisionFuente) return; if (revision != _revisionFuente) return;
this.mediaItem.add(mediaItem); this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(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 // A new source is being opened, so no previous terminal error owns the
// screen any more (see [_errorTerminal]). // screen any more (see [_errorTerminal]).
_errorTerminal = false; _errorTerminal = false;
@@ -2645,31 +2330,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await anterior.dispose().timeout(_timeoutCierrePlayer); await anterior.dispose().timeout(_timeoutCierrePlayer);
} catch (_) {} } catch (_) {}
_eq = _crearEq(); _eq = AndroidEqualizer();
// `_eqDisponible` is deliberately NOT reset here. It answers "does this _eqDisponible = false;
// 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, // Resets alongside its siblings above: the fresh player starts detached,
// so the next non-idle event is a genuine idle -> active edge that // so the next non-idle event is a genuine idle -> active edge that
// [debeReasertarEcualizadorNativo] must see. A value stuck at `true` // [debeReasertarEcualizadorNativo] must see. A value stuck at `true`
@@ -2727,12 +2389,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
try { try {
final params = await _eq.parameters; final params = await _eq.parameters;
_eqDisponible = params.bands.isNotEmpty; _eqDisponible = params.bands.isNotEmpty;
// eq-estado-unico item E: the ONE number that decides how much of the // eq-estado-unico item E: the ONE number that decides whether
// ±12 dB slider [mapearGananciaNativa] can actually honour on this // [mapearGananciaNativa] can be silently boosting a FLAT preset on
// device — anything past this range is clamped, so a report of "the // this device. `Equalizer.getBandLevelRange()` is not required to be
// slider stops doing anything past N" is answered from this line. // symmetric, and nothing else in the app can observe what it returned.
// `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 // `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: // the release build, which is the only one that ever runs in a car:
// //
@@ -2743,8 +2403,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler
'activo=$_ecualizadorActivo preset=${_presetActual.nombre}', 'activo=$_ecualizadorActivo preset=${_presetActual.nombre}',
); );
if (_eqDisponible) { if (_eqDisponible) {
_paramsEq = params; await _eq.setEnabled(_ecualizadorActivo);
await _conmutarEcualizadorNativo(_ecualizadorActivo); await aplicarPreset(_presetActual);
} }
} catch (_) { } catch (_) {
_eqDisponible = false; _eqDisponible = false;
@@ -2824,27 +2484,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// Aplica un preset al ecualizador nativo Android. /// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async { Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset; _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) { if (_eqDisponible) {
try { 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); await _eq.setEnabled(_ecualizadorActivo);
if (_ecualizadorActivo) { if (_ecualizadorActivo) {
await _empujarGananciasNativas(preset); final params = await _eq.parameters;
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(
mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
} }
} catch (e) { } catch (_) {}
_registrarFalloEq('aplicarPreset(${preset.nombre})', e);
}
} }
// Item 4: keeps the EQ custom action's preset-cycle label in sync // 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 // regardless of WHO changed the preset (a car customAction tap or the
@@ -2859,11 +2518,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler
bandas[index] = db; bandas[index] = db;
_presetActual = _presetActual.copyWithBandas(bandas); _presetActual = _presetActual.copyWithBandas(bandas);
} }
_presetElegido = true;
if (!_eqDisponible || !_ecualizadorActivo) return; if (!_eqDisponible || !_ecualizadorActivo) return;
final params = _paramsEq;
if (params == null) return;
try { try {
final params = await _eq.parameters;
if (index < params.bands.length) { if (index < params.bands.length) {
await params.bands[index].setGain( await params.bands[index].setGain(
mapearGananciaNativa( mapearGananciaNativa(
@@ -2873,68 +2530,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
), ),
); );
} }
} catch (e) { } catch (_) {}
_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 /// Sets the equalizer on/off state AND persists it — the single entry
@@ -2943,24 +2539,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> setEcualizadorActivo(bool activo) => Future<void> setEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: true); _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 /// Adopts a value that came FROM disk (eq-estado-unico item A). Identical
/// to [setEcualizadorActivo] except that it does not write back — seeding /// 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 /// is a read, and echoing it to disk would only add a pointless write on
@@ -2972,19 +2550,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler
bool activo, { bool activo, {
required bool persistir, required bool persistir,
}) async { }) async {
final anterior = _ecualizadorActivo;
_ecualizadorActivo = activo; _ecualizadorActivo = activo;
if (_eqDisponible && !await _conmutarEcualizadorNativo(activo)) { if (_eqDisponible) {
// The device REFUSED the on/off call. Publishing `activo` anyway would try {
// put an icon on the car's now-playing screen claiming a state the await _eq.setEnabled(activo);
// audio does not have — and persisting it would resurrect that lie on if (activo) {
// the next engine start. Rolling back is cheap here because await aplicarPreset(_presetActual);
// `_ecualizadorActivo` is the single in-memory owner (eq-estado-unico) }
// and the controls are rebuilt from it one line below; the toggle then } catch (_) {}
// 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 // 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 // of WHO toggled it (a car customAction tap or the phone settings
@@ -3051,31 +2624,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> reaplicarEcualizador() => _activarEcualizador(); Future<void> reaplicarEcualizador() => _activarEcualizador();
@override @override
Future<void> play() async { Future<void> play() {
// 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; _intencionReproducir = true;
// Fresh user intent: whatever terminal error was standing no longer owns // Fresh user intent: whatever terminal error was standing no longer owns
// the screen, so stop masking the player's `idle` (see [_errorTerminal]). // the screen, so stop masking the player's `idle` (see [_errorTerminal]).
@@ -3125,11 +2674,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// The session is over: whatever this run proved about the mount does not // The session is over: whatever this run proved about the mount does not
// carry into the next one (see [_reproduccionEstablecida]). // carry into the next one (see [_reproduccionEstablecida]).
_reproduccionEstablecida = false; _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++; _revisionFuente++;
await _player.stop(); await _player.stop();
// Publish `idle` OURSELVES rather than trusting the player to emit it. // Publish `idle` OURSELVES rather than trusting the player to emit it.
@@ -3606,26 +3150,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
@override @override
Future<MediaItem?> getMediaItem(String mediaId) async { Future<MediaItem?> getMediaItem(String mediaId) async {
try { try {
final uuid = uuidDeMediaIdEmisora(mediaId); final fuente = _fuenteNavegacionGlobal;
// Not a station id at all (`pista:`, `carpeta_local_*:`, `eq_preset:`, if (fuente == null) return null;
// a folder, or `emisora:` with an empty tail) — unchanged behaviour. final universo = await _universoCompleto(fuente);
if (uuid == null) return null; final constructor = ConstructorArbolAuto();
// Was `_universoCompleto` (favoritos + misEmisoras + todas) alone, which final emisora = constructor.resolver(mediaId, universo);
// is EMPTY on a headless bind, while `porUuid` has always also fallen return emisora == null ? null : constructor.itemEmisora(emisora);
// 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 (_) { } catch (_) {
return null; return null;
} }
@@ -3919,4 +3449,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_ => Future.value(const []), _ => Future.value(const []),
}; };
Future<List<Emisora>> _universoCompleto(FuenteEmisorasAuto fuente) async {
final listas = await Future.wait([
fuente.favoritos(),
fuente.misEmisoras(),
fuente.todas(),
]);
return listas.expand((lista) => lista).toList();
}
} }
-21
View File
@@ -256,27 +256,6 @@ class ServicioEcualizador {
return prefs.getBool(_keyActivo); return prefs.getBool(_keyActivo);
} }
/// The persisted principal preset, or `null` when the user has never saved
/// one.
///
/// The exact sibling of [leerActivo] and narrow for the same reason: its
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
/// engine — including the headless one Android Auto starts, where there is
/// no widget tree and `EstadoEcualizador` never exists to push a preset
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
/// and mutates nothing.
///
/// `null` (nothing saved, or an unreadable value) is preserved rather than
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
/// not this service — decides what "never persisted" means, and so a seed
/// with nothing to say does not overwrite anything.
Future<PresetEcualizador?> leerPresetPrincipal() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyPresetPrincipal);
if (raw == null || raw.isEmpty) return null;
return _leerPresetPrincipal(prefs);
}
Future<void> eliminarPorEmisora(String uuid) async { Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs(); final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs); final mapa = _leerPresetsPorEmisora(prefs);
+1 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium" description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none' publish_to: 'none'
version: 1.3.3+162 version: 1.3.3+161
environment: environment:
sdk: ^3.7.0 sdk: ^3.7.0
-53
View File
@@ -1944,59 +1944,6 @@ void main() {
}, },
); );
}); });
// ---------------------------------------------------------------------------
// The handler REJECTED the toggle (native setEnabled threw): the handler
// rolls its own flag back, so this class must not keep — nor persist — a
// value the engine refused.
// ---------------------------------------------------------------------------
group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () {
test(
'adopta el valor real del handler y NO persiste el valor rechazado',
() async {
final fakeAudio = _FakeAudioEqRechazaConmutacion();
final fakeServicio = FakeServicioEcualizador(activo: true);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
fakeServicio.guardarActivoLlamadas = 0;
var avisos = 0;
eq.addListener(() => avisos++);
await eq.cambiarActivo(false);
await Future<void>.delayed(const Duration(milliseconds: 50));
// The native call failed, so the handler kept the equalizer ON.
expect(fakeAudio.ecualizadorActivo, isTrue);
expect(
eq.activo,
isTrue,
reason: 'the UI must show what the engine really does',
);
expect(avisos, greaterThanOrEqualTo(1));
expect(
fakeServicio.guardarActivoLlamadas,
equals(0),
reason: 'a rejected value must never reach disk',
);
expect(fakeServicio.config.activo, isTrue);
eq.dispose();
},
);
});
}
/// Fake handler that REFUSES every on/off change: it records the call (the
/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo]
/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo`
/// rolling its flag back when the native `setEnabled` throws.
class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio {
@override
Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
}
} }
/// Fake whose [guardarActivo] stays pending until released, and releases the /// Fake whose [guardarActivo] stays pending until released, and releases the
+29 -39
View File
@@ -7,7 +7,6 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -793,16 +792,21 @@ void main() {
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no ' '(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async { 'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM'); final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
// The record is now written by the audio handler's `_cambiarFuente` final estadoUno = EstadoRadio(
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of esPremium: () => true,
// `ultima_emisora_v1` and the only one that also exists on the headless audio: FakeServicioAudio(),
// Android Auto engine — `EstadoRadio` used to write it too and no favoritos: FakeServicioFavoritos(),
// longer does. Seeded through that same production function here, so radio: FakeServicioRadio(),
// this test covers what `EstadoRadio` actually owns (the RESTORE) with servicioEcualizador: FakeServicioEcualizador(),
// a real payload instead of one a fake invented. The write itself is resolverArchivoCustom: _archivoCustomVacio,
// covered end to end in iniciarAutomaticamente: false,
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. );
await guardarUltimaEmisoraPersistida(emisora); await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
final audioDos = FakeServicioAudio(); final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio( final estadoDos = EstadoRadio(
@@ -844,18 +848,9 @@ void main() {
}); });
test('una emisora seleccionada desde el auto (fuera de reproducir()) ' test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'deja de estar ensombrecida por la seleccion previa del telefono', 'también se recuerda para la próxima instancia', () async {
() async {
// The PERSISTENCE half of this scenario moved to the handler, which is
// the only writer that exists on a car-only session — it is covered by
// «playFromMediaId desde el coche persiste ESA emisora» in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
// `EstadoRadio` still owns here, and what this test now pins, is the
// shadowing fix: a car selection bypasses `reproducir()`, so without
// the `estadoStream` listener `_emisoraSeleccionada` would keep
// shadowing the car's station on the `emisoraActual` getter.
final audio = _AudioControlado(); final audio = _AudioControlado();
final estado = EstadoRadio( final estadoUno = EstadoRadio(
esPremium: () => true, esPremium: () => true,
audio: audio, audio: audio,
favoritos: FakeServicioFavoritos(), favoritos: FakeServicioFavoritos(),
@@ -864,16 +859,7 @@ void main() {
resolverArchivoCustom: _archivoCustomVacio, resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false, iniciarAutomaticamente: false,
); );
await estado.inicializar(); await estadoUno.inicializar();
final desdeElTelefono = emisoraDemo(
uuid: 'phone-picked',
nombre: 'Elegida en el telefono',
);
unawaited(estado.reproducir(desdeElTelefono));
audio.completar(desdeElTelefono.uuid);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
final desdeCoche = emisoraDemo( final desdeCoche = emisoraDemo(
uuid: 'auto-remembered', uuid: 'auto-remembered',
nombre: 'Recordada desde el auto', nombre: 'Recordada desde el auto',
@@ -881,14 +867,18 @@ void main() {
audio.seleccionarDesdeAuto(desdeCoche); audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect( final estadoDos = EstadoRadio(
estado.emisoraActual?.uuid, esPremium: () => true,
desdeCoche.uuid, audio: FakeServicioAudio(),
reason: favoritos: FakeServicioFavoritos(),
'the car changed the station without going through reproducir(); ' radio: FakeServicioRadio(),
'the phone UI must follow it instead of keeping the previous ' servicioEcualizador: FakeServicioEcualizador(),
'selection on screen', resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
); );
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
}); });
}); });
} }
@@ -1,374 +0,0 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import '../helpers/handlers_audio.dart';
/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO.
///
/// Reported by the owner: the toggle behaves correctly from the phone screen
/// but from the car it «sometimes sounds like a doubled equalization and
/// sometimes does nothing».
///
/// Three independent causes, one per group below:
///
/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat`
/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`,
/// `eq-estado-unico` item A); the preset never did. On a headless Android
/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador`
/// to push the real preset — enabling the equalizer from the car applied
/// FLAT.
///
/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing
/// the preset's gains, so the native effect was re-activated carrying
/// whatever band levels the previous preset had left in it and only
/// afterwards were the intended ones written, band by band. That audible
/// gap is the «doubled equalization».
///
/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station
/// change and never restored it until the fresh player attached. Every
/// native EQ path is gated on that flag, so a car toggle landing inside
/// the window flipped the icon and the flag but never touched the audio —
/// the «does nothing» — and the EQ button itself vanished from the car's
/// now-playing screen (`controlesEcualizadorPersonalizados` returns
/// `const []` when unavailable) and came back seconds later.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductorEq guion;
setUp(() {
guion = _GuionReproductorEq();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga);
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
PluriWaveAudioHandler.fabricaEcualizadorPrueba = null;
});
group('A — the preset is seeded from disk on a headless engine', () {
test('registrarHandler consults the injected preset port exactly once '
'and seeds the handler with it, with no widget tree', () async {
final handler = crearHandler();
var lecturas = 0;
registrarHandler(
handler,
leerPresetPersistido: () async {
lecturas++;
return PresetEcualizador.jazz;
},
);
await pumpEventQueue();
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
expect(
handler.presetActual,
PresetEcualizador.jazz,
reason:
'from the car the handler is the ONLY owner of the preset — '
'nothing else ever pushes one on a headless engine',
);
});
test('a read failure leaves the historical default instead of '
'propagating', () async {
final handler = crearHandler();
registrarHandler(
handler,
leerPresetPersistido: () async => throw StateError('sin disco'),
);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.flat);
});
test('without a preset port the handler is left untouched (widget tests, '
'fakes)', () async {
final handler = crearHandler();
await handler.aplicarPreset(PresetEcualizador.rock);
registrarHandler(handler);
await pumpEventQueue();
expect(handler.presetActual, PresetEcualizador.rock);
});
test('a preset already chosen while the disk read was in flight WINS — '
'seeding never clobbers a live choice', () async {
final handler = crearHandler();
final lectura = Completer<PresetEcualizador?>();
registrarHandler(handler, leerPresetPersistido: () => lectura.future);
// The phone UI (`EstadoEcualizador`) resolves a per-station preset and
// pushes it while the seed's disk read is still pending.
await handler.aplicarPreset(PresetEcualizador.pop);
lectura.complete(PresetEcualizador.jazz);
await pumpEventQueue();
expect(
handler.presetActual,
PresetEcualizador.pop,
reason:
'the seed exists to fill a VOID, not to overrule the richer '
'per-station/per-device preset the phone UI resolves',
);
});
});
group('B — the preset is pushed BEFORE the effect is enabled', () {
test('enabling applies the gains first and only then flips the native '
'effect on', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: true),
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'enabling first would re-activate the native Equalizer carrying '
'the PREVIOUS preset gains, which is the doubled equalization '
'the owner hears',
);
});
test('disabling only flips the effect off — the band gains are NOT '
'reset', () {
expect(
PluriWaveAudioHandler.pasosEcualizador(activo: false),
[PasoEcualizador.habilitacion],
reason:
'android.media.audiofx.AudioEffect.setEnabled(false) bypasses '
'the effect and RETAINS its band levels, and the enable path '
'rewrites them before re-enabling anyway — zeroing them would be '
'one native round trip per band for no audible difference',
);
});
test('the real toggle path executes those steps IN THAT ORDER', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.pasosEcualizadorEjecutados,
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
reason:
'the ORDER is the fix; asserting only that both happened would '
'stay green against the exact bug being fixed',
);
});
test('the real disable path executes only the habilitacion step', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(false);
expect(handler.pasosEcualizadorEjecutados, [
PasoEcualizador.habilitacion,
]);
});
});
group('C — a station change no longer drops the equalizer', () {
test('once the EQ was available, no state published across a station '
'change and a car toggle has zero custom actions', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
// The EQ action is on the car's now-playing screen before the station
// changes — that is the state the driver is looking at.
await handler.setEcualizadorActivo(true);
final acciones = <int>[];
final sub = handler.playbackState.listen(
(estado) => acciones.add(
estado.controls.where((c) => c.customAction != null).length,
),
);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
// The car tap that used to land inside the window `_recrearPlayer`
// opened. It republishes the controls from `_eqDisponible`, so a flag
// reset to `false` shows up here as an EQ button that disappeared.
await handler.customAction(accionEqToggle);
await sub.cancel();
expect(
acciones,
isNotEmpty,
reason: 'the station change must publish at least one state',
);
expect(
acciones.every((n) => n > 0),
isTrue,
reason:
'the EQ button vanished and reappeared on every station change '
'because `_recrearPlayer` reset `_eqDisponible`; availability is '
'a DEVICE property and does not change with the station. Got '
'$acciones',
);
});
test('the availability flag survives the player rebuild, so a car toggle '
'inside the window still reaches the native effect', () async {
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.playMediaItem(
const MediaItem(id: 'https://a', title: 'A'),
);
await pumpEventQueue();
expect(
handler.ecualizadorDisponible,
isTrue,
reason:
'this is the flag every native EQ path is gated on; false here '
'is exactly the reported «does nothing»',
);
});
});
group('D — a failed native call is traced and never lies', () {
test('a throwing setEnabled is traced instead of swallowed', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.setEcualizadorActivo(true);
expect(
handler.fallosNativosEcualizador,
greaterThan(0),
reason:
'the silent `catch (_) {}` made a dead native equalizer '
'indistinguishable from a working one in a car logcat',
);
});
test('a failed on/off call leaves the published state honest instead of '
'claiming a state the audio does not have', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
registrarHandler(handler);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
handler.ecualizadorActivo,
isFalse,
reason:
'the native effect refused, so the car icon must not read "on" '
'over audio that is not equalized',
);
});
test('a failed on/off call is not persisted', () async {
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
() => _EcualizadorQueFalla();
final handler = crearHandler();
final escrituras = <bool>[];
registrarHandler(
handler,
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
);
handler.simularEcualizadorDisponible(true);
await handler.sembrarEcualizadorActivo(false);
await handler.setEcualizadorActivo(true);
expect(
escrituras,
isEmpty,
reason:
'persisting a state the device rejected would resurrect it on '
'the next engine start',
);
});
});
}
/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a
/// device whose native `Equalizer` effect refuses the call. Nothing else is
/// overridden, so the rest of the handler runs unchanged.
class _EcualizadorQueFalla extends AndroidEqualizer {
@override
Future<void> setEnabled(bool enabled) async {
throw StateError('el efecto nativo rechazo la llamada');
}
}
/// Minimal script/observation record shared by every [_ReproductorFalsoEq]
/// the handler builds (it rebuilds its player on every source change).
class _GuionReproductorEq {
int llamadasSetUrl = 0;
_ReproductorFalsoEq? ultimoReproductor;
}
/// An [AudioPlayer] whose platform-touching methods are replaced, so a real
/// station change can be driven under `flutter test`. Mirrors the double in
/// `servicio_audio_transporte_test.dart`.
class _ReproductorFalsoEq extends AudioPlayer {
_ReproductorFalsoEq(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
_guion.ultimoReproductor = this;
}
final _GuionReproductorEq _guion;
final _estados = StreamController<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}
@@ -1,43 +1,28 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_audio.dart';
/// `mapearGananciaNativa` the hand-off from the app's ±12 dB slider to the /// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
/// device's native `Equalizer`, whose capability is reported as /// app's fixed ±12 dB slider scale to whatever range the device's native
/// `AndroidEqualizerParameters.min/maxDecibels` /// `Equalizer.getBandLevelRange()` reports.
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
/// ///
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the /// This is the only source-plausible explanation for the reported «suena muy
/// slider against its own end of the native range, so `+6` on a device /// alto» half of the bug. The original implementation normalised the input
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping /// across the WHOLE range and mapped it linearly:
/// are already the SAME unit, so that multiplication was a unit error:
/// ///
/// * `just_audio` documents `setGain` as "Sets the gain for this band in /// normalizado = (db.clamp(-12, 12) + 12) / 24
/// decibels", and its Android bridge does `setBandLevel(band, /// return minDecibels + normalizado * (maxDecibels - minDecibels)
/// round(gain * 1000.0))` — plain dB to millibels, no normalisation.
/// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a
/// bound on the control, not a scale to normalise into.
/// * The app makes the user a decibel promise in three places at once: the
/// slider is hard-coded `min: -12.0, max: 12.0`, the label under each
/// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out
/// `equalizerBandValue` = "{value} decibels". Stretching made that label
/// a lie on every device whose range is not exactly ±12.
/// * Presets are persisted and EXPORTED as those same raw slider dB
/// (`PresetEcualizador.toJson`), so under the old mapping a backup
/// restored on a wider-range phone showed identical numbers and played
/// louder — and on the common asymmetric shape `[-12, +19]` boosts were
/// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE
/// rather than merely its depth.
/// ///
/// So: the number the user reads is the number the device is asked for. The /// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
/// native range only CLAMPS it. /// the range happens to be symmetric. Android does not guarantee that: the
/// AudioEffect Equalizer contract only requires a min/max pair, and real
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
/// band 0 dB — was silently pushing a positive boost into every band, which
/// is audibly louder while the on/off button still reads "off".
/// ///
/// What this deliberately KEEPS from the previous contract — every invariant /// The contract asserted here: 0 dB always maps to exactly 0, and the two
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a /// sides of the scale are stretched INDEPENDENTLY against their own end of
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly /// the native range, so the sign of the user's intent is never inverted and
/// positive reported range, turning a FLAT preset into a boost again), the /// the extremes still reach the device's real limits.
/// 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.
void main() { void main() {
group('mapearGananciaNativa — 0 dB is always exactly 0', () { group('mapearGananciaNativa — 0 dB is always exactly 0', () {
test('symmetric range (the common case) is unchanged', () { test('symmetric range (the common case) is unchanged', () {
@@ -58,10 +43,6 @@ void main() {
}); });
test('a wholly positive range still cannot boost a FLAT preset', () { test('a wholly positive range still cannot boost a FLAT preset', () {
// This is precisely why the mapping cannot be a plain
// `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and
// bring the «suena muy alto» bug straight back. The clamp window has
// to be widened so that it always contains 0.
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0); expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
}); });
@@ -70,61 +51,36 @@ void main() {
}); });
}); });
group('mapearGananciaNativa — the slider dB reach the device literally', () { group('mapearGananciaNativa — the extremes reach the native limits', () {
test('+12 dB is delivered as +12 dB, not stretched to the native max', () { test('+12 dB maps to the native maximum', () {
// CONTRACT CHANGE: this used to assert 19, i.e. the whole of the expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
// device's headroom. The slider says "12.0dB" and the accessibility
// label says "12.0 decibels", so 12 dB is what the device must be
// asked for. The 7 dB of extra hardware headroom is unreachable by
// design until the slider itself is widened and says so.
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12);
}); });
test('-12 dB is delivered as -12 dB', () { test('-12 dB maps to the native minimum', () {
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12); expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
}); });
test('values beyond the slider scale clamp to the slider limit', () { test('values beyond the slider scale are clamped, not extrapolated', () {
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15). expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
// The slider scale is the first bound; the device range is the second. expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15);
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12);
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12);
}); });
}); });
group('mapearGananciaNativa — the label is the value the device gets', () { group('mapearGananciaNativa — each side scales against its own end', () {
test('+6 dB on a wide-range device is +6 dB, never 10', () { test('half boost is half of the positive headroom', () {
// CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is
// half of the positive headroom". A slider reading "6.0dB" that
// produced +10 dB of real boost is exactly what made a restored backup
// sound different on a different phone.
expect( expect(
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20), mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
closeTo(6, 1e-9), closeTo(10, 1e-9),
); );
}); });
test('-6 dB on that same device is -6 dB', () { test('half cut is half of the negative headroom', () {
expect( expect(
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20), mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
closeTo(-6, 1e-9), closeTo(-6, 1e-9),
); );
}); });
test('the six factory presets keep their shape on an asymmetric device', () {
// Jazz, authored in true dB before any scaling existed. Under the old
// mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a
// different tonal curve, not merely a louder one.
const jazz = [3.0, -1.0, -1.5, 2.0, 4.0];
final entregado = jazz
.map(
(db) =>
mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19),
)
.toList();
expect(entregado, jazz);
});
test('the sign of the user intent is never inverted', () { test('the sign of the user intent is never inverted', () {
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) { for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
final nativo = mapearGananciaNativa( final nativo = mapearGananciaNativa(
@@ -141,44 +97,12 @@ void main() {
}); });
}); });
group('mapearGananciaNativa — a device narrower than the slider', () {
test('a request that fits is still delivered literally', () {
// CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB,
// so a modest device silently under-delivered every request too.
expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3);
expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3);
});
test('a request beyond the device range clamps to the device limit', () {
expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6);
expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6);
});
test('a very narrow device still gets a sane, in-range value', () {
for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) {
final nativo = mapearGananciaNativa(
db,
minDecibels: -1.5,
maxDecibels: 1.5,
);
expect(nativo, greaterThanOrEqualTo(-1.5));
expect(nativo, lessThanOrEqualTo(1.5));
expect(nativo.sign, db.sign);
}
});
});
group('mapearGananciaNativa — degenerate ranges reported by the device', () { group('mapearGananciaNativa — degenerate ranges reported by the device', () {
test('a device with no headroom above unity can never boost', () { test('a range with no headroom on one side clamps that side to 0', () {
// A device that reports max == 0 can only cut. Asking for a boost must
// resolve to "no change", never to a negative value.
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0); expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0); expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
});
test('a cut the device could honour exactly is not over-delivered', () {
// CONTRACT CHANGE: this used to answer -15, spending the device's whole
// range on a request for -12 dB. The user asked for -12; -12 is
// representable here, so -12 is what is sent.
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12);
}); });
test('a zero-width range collapses everything to 0', () { test('a zero-width range collapses everything to 0', () {
@@ -1,507 +0,0 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Resuming the last station in Android Auto — the three defects that made a
/// car-only session unable to remember, restart or even NAME what it was
/// playing.
///
/// Every test here runs with NO widget tree and NO browse source registered:
/// that is the engine Android Auto actually starts
/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with
/// no Activity), so `EstadoRadio` — the only thing that used to write
/// `ultima_emisora_v1` — is never constructed there.
///
/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a
/// session that happened only in the car never updated the key and the
/// head unit was offered the station from the last time the PHONE was
/// used. The same key feeds `resolverEmisorasDestacadas`, so the free
/// tier's featured folder was stale too.
///
/// A2. `play()` with no source called `_player.play()`, and
/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)`
/// BEFORE the `_audioSource != null` gate — so the platform was never
/// touched, the returned Future never completed, and `playing: true`
/// was forwarded over `processingState: idle`.
/// `AudioService.java:559-560` then runs `enterPlayingState()` while
/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause
/// button, no audio, no title and no artwork (or a
/// `ForegroundServiceStartNotAllowedException` on API 31+).
///
/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites
/// are the duration update, `_cambiarFuente` and `stop` — so
/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the
/// native side got no metadata at all.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
/// The free set's first station: resolvable from the binary alone, so it
/// works on a bind where no browse source was ever registered — which is
/// the whole point of these tests.
const emisoraFip = Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
);
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
// Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ??
// false`) and no `ultima_emisora_v1`.
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('A1 — el coche escribe la ultima emisora', () {
test(
'playFromMediaId desde el coche persiste ESA emisora por el puerto '
'inyectado, sin arbol de widgets',
() async {
final prefs = await SharedPreferences.getInstance();
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async {
guardadas.add(emisora);
await guardarUltimaEmisoraPersistida(emisora, prefs: prefs);
},
);
await handler.playFromMediaId('emisora:${emisoraFip.uuid}');
await pumpEventQueue();
expect(
guardadas.map((e) => e.uuid),
[emisoraFip.uuid],
reason:
'a car-only session must update `ultima_emisora_v1` itself — '
'`EstadoRadio` is never built on a headless engine',
);
final persistida = await ultimaEmisoraPersistida(prefs: prefs);
expect(persistida?.uuid, emisoraFip.uuid);
expect(
persistida?.url,
emisoraFip.url,
reason:
'the record has to be PLAYABLE: it is what the recent root and '
'`resolverEmisorasDestacadas` hand back to the head unit',
);
},
);
test('playMediaItem directo (voz, telefono) persiste igual', () async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']);
expect(guardadas.single.url, 'https://ejemplo/stream');
});
test(
'una pista local NO se persiste como ultima emisora',
() async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'content://media/audio/7',
title: 'Pista local',
extras: {'documentId': 'doc-7'},
),
);
await pumpEventQueue();
expect(
guardadas,
isEmpty,
reason:
'`ultima_emisora_v1` feeds the recent root and the featured '
'folder as an `emisora:<uuid>` row — a `content://` track '
'there is a row that does nothing when tapped',
);
},
);
test('un fallo del puerto se traza y NUNCA propaga', () async {
final handler = crearHandler();
registrarHandler(
handler,
guardarUltimaEmisora: (_) async => throw StateError('sin disco'),
);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
await pumpEventQueue();
});
test('sin puerto (tests de widget, fakes) no pasa nada', () async {
final handler = crearHandler();
registrarHandler(handler);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
});
});
group('A2 — play() sin fuente no publica una sesion fantasma', () {
test(
'con una emisora persistida, play() resuelve y arranca ESA emisora: el '
'reproductor recibe su url',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
contains(emisoraFip.url),
reason:
'`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY '
'straight into play(); on a cold engine there is no source, so '
'it has to resolve the persisted station instead',
);
},
);
test(
'y NINGUN estado publicado lleva playing:true sobre processingState '
'idle',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
await sub.cancel();
expect(
fantasmas,
isEmpty,
reason:
'playing:true over idle is what makes `AudioService.java:559` '
'call enterPlayingState() with STATE_NONE — a PluriWave '
'notification with a pause button, no audio and no title',
);
},
);
test(
'sin nada persistido: no se toca el reproductor, no hay estado '
'fantasma y play() no se queda colgado',
() async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
await expectLater(
handler.play().timeout(const Duration(seconds: 2)),
completes,
);
await pumpEventQueue();
await sub.cancel();
expect(
guion.llamadasPlay,
0,
reason:
'with nothing to restore the player must not be touched at '
'all: `just_audio` publishes playing:true before its source '
'gate and never completes the future it returns',
);
expect(guion.llamadasSetUrl, 0);
expect(fantasmas, isEmpty);
},
);
test(
'con una fuente ya abierta, play() sigue siendo la reanudacion de '
'siempre (pausa -> play no reabre nada)',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
await handler.pause();
final urlsAntes = List<String>.from(guion.urlsSolicitadas);
await handler.play();
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
urlsAntes,
reason:
'a resume must NOT re-open the source, and must never replace '
'the live station with the persisted one',
);
expect(handler.intencionReproducir, isTrue);
},
);
});
group('A3 — arranque en frio: el coche recibe metadatos', () {
test(
'con una emisora persistida se publica su mediaItem SIN arrancar '
'reproduccion',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
expect(
handler.mediaItem.value,
isNotNull,
reason:
'`audio_service.dart:1029-1033` returns before setMediaItem '
'when mediaItem is null, so a cold engine sent the head unit '
'no metadata whatsoever',
);
expect(handler.mediaItem.value?.id, emisoraFip.url);
expect(handler.playbackState.value.playing, isFalse);
expect(
guion.llamadasSetUrl,
0,
reason:
'publishing metadata must not open a stream: a cold bind '
'happens on every reconnect and must stay silent',
);
},
);
test('sin nada persistido el mediaItem sigue vacio', () async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
expect(handler.mediaItem.value, isNull);
});
test(
'una emisora que ya empezo a sonar NO es pisada por la siembra',
() async {
final handler = crearHandler();
final lectura = Completer<Emisora?>();
registrarHandler(handler, leerUltimaEmisora: () => lectura.future);
await handler.playMediaItem(
const MediaItem(
id: 'https://enVivo/stream',
title: 'En vivo',
extras: {'uuid': 'uuid-en-vivo'},
),
);
lectura.complete(emisoraFip);
await pumpEventQueue();
expect(
handler.mediaItem.value?.id,
'https://enVivo/stream',
reason:
'the seed exists to fill a VOID; clobbering the live station '
'would rename what the driver is listening to',
);
},
);
});
group('getMediaItem resuelve tambien el set destacado', () {
test(
'sin fuente de navegacion registrada, una emisora destacada resuelve',
() async {
final handler = crearHandler();
registrarHandler(handler);
final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}');
expect(
item,
isNotNull,
reason:
'`porUuid` already falls back to the featured set, so the car '
'could BROWSE a featured station and not resolve its media '
'item — the asymmetry is the bug',
);
expect(item?.id, 'emisora:${emisoraFip.uuid}');
expect(item?.title, emisoraFip.nombre);
},
);
test('un id que no es de emisora sigue devolviendo null', () async {
final handler = crearHandler();
registrarHandler(handler);
expect(await handler.getMediaItem('pista:doc-1'), isNull);
expect(await handler.getMediaItem('emisora:'), isNull);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
}
/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour
/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes
/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source
/// it never touches the platform and never completes the future it returned.
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga);
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
/// A fresh player has no source, exactly like the real one.
bool _fuenteCargada = false;
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
_fuenteCargada = true;
return null;
}
@override
Future<void> play() {
_guion.llamadasPlay++;
if (!_estados.isClosed) {
_estados.add(
PlayerState(
true,
_fuenteCargada ? ProcessingState.ready : ProcessingState.idle,
),
);
}
// The dangling future: with no source, upstream `play()` awaits a
// `_playingSubject` transition the platform will never produce.
if (!_fuenteCargada) return Completer<void>().future;
return Future<void>.value();
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}