fix(alarm): start the fade-in when pre-started audio is already playing
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s

The ringing screen only confirmed the native-to-Flutter handoff from
its playback-state listener, but app.dart pre-starts the station
before pushing the screen, so `reproduciendo` could be emitted before
the listener subscribed and no further event ever arrived. That
branch only cancelled the fallback timer: the gated fade-in never
started and the native alarm player was never told to stop, so the
alarm blared at the alarm-stream volume with no 5%-to-target ramp.
Previously this was a timing race the stream usually lost; gating the
ramp on the confirmation made the failure deterministic.

The already-playing branch now confirms the handoff explicitly
(idempotent with the listener), and the ramp re-imposes its 5% start
volume immediately instead of waiting for the first periodic tick.
Adds the regression test mounting in the real pre-started path.
This commit is contained in:
2026-07-11 11:08:33 +02:00
parent f6ea4e64b9
commit 7eaa87b462
2 changed files with 35 additions and 0 deletions
@@ -88,8 +88,15 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
_fallbackTimer = Timer(const Duration(seconds: 12), () {
if (mounted) _iniciarFallback();
});
// 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
// (idempotent — the listener firing as well is harmless).
if (widget.audioPrearrancado && radio.audio.estaSonando) {
_fallbackTimer?.cancel();
await _confirmarAudioFlutterListo();
}
}
@@ -111,6 +118,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
unawaited(_aplicarVolumenGlobal(volumenObjetivo));
return;
}
// Re-impose the ramp's start volume immediately: the first periodic tick
// only lands after _fadeStep, and by now the player may have been
// recreated or re-leveled since the pre-start set it to 5%.
unawaited(_aplicarVolumenGlobal(inicio));
final duracionTotalMs = segundosFade * 1000;
final pasos = (duracionTotalMs / _fadeStep.inMilliseconds).ceil();
var pasoActual = 0;
@@ -229,6 +229,30 @@ void main() {
});
});
group('handoff con audio prearrancado ya reproduciendo (regresion)', () {
testWidgets('si la radio ya esta reproduciendo al montar, la confirmacion '
'del handoff y el fade-in arrancan igual', (tester) async {
// Real production path: app.dart pre-starts the station BEFORE the
// screen mounts, so `reproduciendo` is emitted before the state
// listener subscribes and no further state event ever arrives. The
// handoff confirmation (native stop) and the Dart ramp must not
// depend on catching that already-missed event.
final entorno = await _montarPantalla(tester);
expect(
entorno.android.detenidas,
contains('ring1'),
reason: 'el nativo debe recibir el stop del handoff aunque '
'"reproduciendo" haya llegado antes del mount',
);
expect(
entorno.audio.volumenesAplicados,
[0.05, 0.85],
reason: 'el fade-in debe arrancar tambien en el camino ya-sonando',
);
});
});
group('restore de volumen de medios con dispose como unico llamador', () {
testWidgets('desmontar la pantalla sin detener ni posponer restaura el '
'volumen exactamente una vez', (tester) async {