FreeTLab 6da3e69f7e fix(audio): stop emitting a transient idle during a source change
Root cause of the disappearing media notification, and it is self-inflicted
on EVERY Android version — no plugin patch involved.

`audio_service`'s `_observePlaybackState` (audio_service.dart:1131-1136) calls
`AudioService._stop()` on ANY transition into `idle` from a non-idle state.
That reaches `stopService()` -> `deactivateMediaSession()` ->
`notificationManager.cancel(NOTIFICATION_ID)`. The notification is re-posted
at exactly one place, `internalStartForeground()`, reachable only from the
`!wasPlaying && playing` edge in `setState()`, and its FIRST statement is
`ContextCompat.startForegroundService(...)` — which on API 31+ throws
`ForegroundServiceStartNotAllowedException` whenever the process is not in a
foreground state.

Every station change walked straight into that. `_cambiarFuente` pushes
`loading`, then `_recrearPlayer` disposes the old `AudioPlayer` and builds a
FRESH one; a fresh player's first `playerStateStream` event is always `idle`,
and the listener forwarded it verbatim. So `loading -> idle` tore the
foreground service down mid-source-change, and recovery depended on the
following `playing: true` edge restarting it. Screen off, lock screen, or an
Android Auto / Bluetooth-initiated start is precisely where the platform
refuses that restart: audio keeps playing, the notification never returns.
That is exactly what the user reports.

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

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

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

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

Audited: two `_player.stop()` call sites exist and both are preceded by a
clear; `_recrearPlayer` has exactly one caller and it is guarded; the old
player cannot emit during `_recrearPlayer` because its subscriptions are
cancelled first.
2026-08-01 19:15:53 +02:00

PluriWave

Radio mundial con ecualizador personalizable, reconocimiento de canciones y UI premium.

Features

  • +53.000 emisoras de 238 países (Radio Browser API)
  • Ecualizador por emisora — guarda tu preset favorito para cada radio
  • Reconocimiento de canciones — "¿Qué suena?" sin salir de la app
  • Timer de auto-apagado — perfecto para dormir
  • Reproducción en segundo plano — sigue sonando con la pantalla apagada
  • Favoritos — acceso rápido a emisoras preferidas
  • Compartir — envía emisoras a tus amigos
  • UI premium — Material You, visualizador de audio, animaciones fluidas

Monetización

  • 14 días PRO gratis al instalar
  • Free: radio + favoritos + timer + background (con ads)
  • PRO €2.99/año: sin ads + ecualizador + reconocimiento ilimitado

Stack

  • Frontend: Flutter (Android + iOS)
  • Radio API: Radio Browser (gratis, +53K emisoras)
  • Audio: just_audio + audio_service
  • Ecualizador: just_audio equalizer (Android nativo)
  • Reconocimiento: AudD API (1000 req/mes free)
  • Ads: Google AdMob
  • Compras: in_app_purchase

CI/CD

Workflow Gitea Actions en .gitea/workflows/ci.yml, runner macmini-flutter.

Jobs: flutter pub getflutter testbuild apk --releasebuild appbundle --release

Artifacts: APK y AAB guardados en Gitea con nombre pluriwave-apk-<sha> / pluriwave-aab-<sha>.

Notificaciones: Telegram al completar (éxito / fallo ).

Secrets necesarios en el repo:

Secret Uso
TELEGRAM_BOT_TOKEN Notificaciones CI
TELEGRAM_CHAT_ID Canal de destino

Signing: build de release usa clave debug (válido para CI interno). Para Play Store se requiere keystore como secret adicional.

Desarrollador

FreeTimeLab — freetimelab.es

Licencia

MIT

S
Description
PluriWave — Radio mundial con ecualizador, reconocimiento de canciones y UI premium
Readme
49 MiB
Languages
Dart 86%
HTML 7.1%
Kotlin 5.1%
JavaScript 1.6%
Swift 0.1%