fix(alarm): anchor the fade at alarm time and defer the override to first audio
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s

On-device logcat from the latest test showed two defects the previous
design created. The fade-in was gated on the station reaching
`reproduciendo`, and the stream took 18.7 seconds to buffer: the ring
sat frozen at 5% the whole time and the configured fade seconds only
started counting afterwards. And the stream override was raised during
pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0
for an instant before the player gain lands — blasted at the configured
ring level, heard as "starts directly at the alarm volume".

The ramp is now anchored at alarm time: it starts when the screen
starts, buffering just joins it at the elapsed level, and the fade
duration means seconds-from-alarm. _iniciarFadeIn is single-start so
the handoff confirmation and fallback paths can no longer restart an
in-progress ramp from 5%. The stream override moved from the app-side
pre-start into the screen and is raised only when audio is actually
about to flow (first `reproduciendo`, the already-playing branch, or
right before the fallback WAV plays), so track spin-up happens under
the user's original low volume and the blast is physically impossible.
Exit teardown restores the device stream before resetting the player
gain, removing the brief exit blip seen in the capture.
This commit is contained in:
2026-07-12 00:35:29 +02:00
parent f73a12ad48
commit 2e64740b26
3 changed files with 81 additions and 58 deletions
+57 -13
View File
@@ -41,7 +41,9 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
bool _fallbackActivo = false;
bool _radioIntentada = false;
bool _audioFlutterConfirmado = false;
bool _volumenMediaForzado = false;
bool _volumenMediaRestaurado = false;
bool _fadeInArrancado = false;
// Captured while mounted: dispose() also restores the media volume, and by
// then the element is defunct, so context.read() would throw there.
@@ -70,6 +72,13 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
if (!widget.audioPrearrancado) {
unawaited(radio.reproducir(emisora));
}
// The ramp is anchored at ALARM time, not at stream-connect time: the
// configured fade seconds count from the moment the alarm starts, so a
// slow station buffering for many seconds cannot freeze the ring at 5%
// (observed on-device: 18.7s stuck waiting for `reproduciendo`). While
// the stream is still connecting nothing is audible anyway; when audio
// starts it simply joins the ramp at the elapsed level.
_iniciarFadeIn();
// S7-R4 boundary: only `reproduciendo` cancels the fallback timer —
// `reconectando`/`cargando` do NOT count as playing, so the 12-second
@@ -79,6 +88,13 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_estadoSub = radio.estadoStream.listen((estado) {
if (estado == EstadoReproduccion.reproduciendo && mounted) {
_fallbackTimer?.cancel();
// Raise the ring-scoped stream override only now that audio is
// actually flowing: the ExoPlayer AudioTrack spins up at gain 1.0
// for an instant before the player gain lands, and doing that under
// the user's ORIGINAL (low) stream volume makes the spin-up blast
// physically impossible. Order matters: override first, then the
// native handoff stop.
unawaited(_forzarVolumenMediaUnaVez());
_confirmarAudioFlutterListo();
}
if (estado == EstadoReproduccion.error && mounted) {
@@ -92,11 +108,12 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
// Pre-started audio can reach `reproduciendo` BEFORE the listener above
// subscribes (app.dart starts the station before pushing this screen),
// in which case no further state event ever arrives. The handoff
// confirmation and the fade-in must not depend on catching that
// already-missed event, so this branch confirms explicitly too
// confirmation and the stream override must not depend on catching that
// already-missed event, so this branch handles them explicitly too
// (idempotent — the listener firing as well is harmless).
if (widget.audioPrearrancado && radio.audio.estaSonando) {
_fallbackTimer?.cancel();
await _forzarVolumenMediaUnaVez();
await _confirmarAudioFlutterListo();
}
}
@@ -105,14 +122,39 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
if (_fallbackActivo) return;
_fallbackActivo = true;
await _fallbackPlayer.setAsset(_assetFallback(widget.alarma.sonidoInterno));
// The local WAV is about to be audible: raise the stream override before
// play so the fallback honors the configured ring level too.
await _forzarVolumenMediaUnaVez();
await _fallbackPlayer.play();
await _confirmarAudioFlutterListo();
if (mounted) setState(() {});
}
/// Raises the ring-scoped `STREAM_MUSIC` override (device-volume
/// independence: the ring is audible even with the device at 0, capped at
/// the alarm's configured fraction of the device maximum) at most once per
/// screen instance, and only when audio is about to be audible — never
/// during player spin-up, so track creation can't blast at full gain.
Future<void> _forzarVolumenMediaUnaVez() async {
if (_volumenMediaForzado) return;
_volumenMediaForzado = true;
try {
await _estadoAlarmas.android.forzarVolumenMediaParaAlarma(
widget.alarma.volumen.clamp(0.0, 1.0),
);
} catch (e) {
debugPrint('[PluriWave][alarmas] forzar volumen media fallo: $e');
}
}
void _iniciarFadeIn() {
// Anchored, single-start ramp: the first caller (screen start) wins and
// later idempotent calls (handoff confirm, fallback) must NOT restart it
// from 5% — that would audibly drop an already-progressed ring.
if (_fadeInArrancado) return;
_fadeInArrancado = true;
_fadeInTimer?.cancel();
// The media stream is already capped at the alarm's configured volume
// The media stream is capped at the alarm's configured volume
// (forzarVolumenMediaParaAlarma), so the player ramps up to its OWN full
// range under that cap. Perceived peak = configured% of the device max,
// reached gradually from ~5% of the cap; ramping the player to
@@ -153,15 +195,14 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
}
/// Confirms the native-to-Flutter audio handoff at most once per screen
/// instance, then starts the single audible Dart fade-in ramp (Slice 3:
/// fade-in dedup at handoff). The native ramp
/// (`PluriWaveAlarmService.startFadeIn`) owns audio until this
/// confirmation lands; starting the Dart ramp any earlier would
/// interleave both ramps and produce an audible jump/reset. If the
/// native confirmation channel call fails (dead or never-there native
/// side), the fade-in still starts in the `finally` block below — Dart
/// is the only audible source either way, so the ring must never stay
/// stuck at [_volumenInicialFadeIn] forever.
/// instance. The Dart ramp is anchored at screen start and does NOT wait
/// for this confirmation (a slow stream would freeze the ring at 5%); the
/// `finally` below only guarantees the ramp exists on exotic paths where
/// `_iniciarAlarma` never reached it — `_iniciarFadeIn` is single-start,
/// so an already-running ramp is never restarted. Both ramps (native on
/// the ALARM stream, Dart under the capped media stream) start at 5% on
/// the same fade duration, so they stay aligned until the native track is
/// stopped here.
Future<void> _confirmarAudioFlutterListo() async {
if (_audioFlutterConfirmado) return;
_audioFlutterConfirmado = true;
@@ -275,6 +316,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
} catch (e) {
debugPrint('[PluriWave][alarmas] pausar radio fallo: $e');
}
// Restore the DEVICE stream first, then the player gain: raising the
// gain to 1.0 while the stream is still at the ring level would be
// audible for an instant if the pause hasn't fully landed.
await _restaurarVolumenMediaUnaVez();
try {
// The fade-in mutates the SHARED radio handler gain; exiting mid-ramp
// would otherwise leave every later radio play at the partial ramp
@@ -284,7 +329,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
} catch (e) {
debugPrint('[PluriWave][alarmas] restaurar ganancia radio fallo: $e');
}
await _restaurarVolumenMediaUnaVez();
}
/// Dismisses the alarm screen safely in both live-app and dead-app states.