diff --git a/lib/app.dart b/lib/app.dart index 11edbbd..7d01d9a 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -374,21 +374,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } Future _prearrancarAudioAlarma(AlarmaMusical alarma) async { - // Must run FIRST, before any early return: the override needs to be in - // effect for the whole ring, including fallback-WAV-only alarms that - // never reach the station-playback branch below (Requirement: - // Ring-scoped device-volume override). - // - // The media stream is capped at the alarm's CONFIGURED volume, not the - // device max: this makes the ring independent of the device's own volume - // (audible even at 0) while keeping "50%" meaning 50% of the phone's - // maximum. The player then ramps from ~5% up to full under this cap, so - // the perceived peak is exactly the configured fraction of max, reached - // gradually — not the device-relative level, and not a full-blast max. - await context.read().android.forzarVolumenMediaParaAlarma( - alarma.volumen.clamp(0.0, 1.0), - ); - if (!mounted) return; + // The ring-scoped stream override is NOT raised here anymore: the ringing + // screen owns it and raises it only when audio is actually about to be + // audible (first `reproduciendo`, or right before the fallback WAV + // plays). Raising it during pre-start let the ExoPlayer track spin-up — + // which briefly runs at gain 1.0 — blast at the ring level; under the + // user's original stream volume that spin-up is inaudible. final emisora = alarma.emisora; if (emisora == null) return; final radio = context.read(); diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index a492110..a971763 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -41,7 +41,9 @@ class _PantallaAlarmaSonandoState extends State { 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 { 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 { _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 { // 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 { 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 _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 { } /// 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 _confirmarAudioFlutterListo() async { if (_audioFlutterConfirmado) return; _audioFlutterConfirmado = true; @@ -275,6 +316,10 @@ class _PantallaAlarmaSonandoState extends State { } 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 { } 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. diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart index 12b32f2..5d1f985 100644 --- a/test/pantallas/pantalla_alarma_sonando_test.dart +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -169,47 +169,35 @@ void main() { }, ); - group('fade-in dedup en el handoff (Slice 3)', () { - testWidgets('el fade-in de Dart se retiene hasta que el nativo confirma el ' - 'handoff, y arranca justo despues (camino radio)', (tester) async { + group('rampa anclada y override diferido al primer audio', () { + testWidgets('la rampa arranca al montar sin esperar al stream, y el ' + 'override del volumen del dispositivo espera a "reproduciendo"', + (tester) async { final entorno = await _montarPantalla( tester, audioYaReproduciendo: false, ); - entorno.android.puertaConfirmarAudioFlutter = Completer(); - // Antes de que la radio confirme "reproduciendo", solo debe existir - // el volumen de arranque previo (0.05): el ramp real hacia - // alarma.volumen todavia NO debe haber arrancado. + // La rampa esta anclada al inicio de la alarma: con fade 0 salta ya al + // objetivo (1.0) aunque el stream siga bufferizando. Lo que NO debe + // haber ocurrido todavia es el override del stream del dispositivo + // (el spin-up del reproductor debe pasar bajo el volumen original) ni + // el stop nativo del handoff. + expect(entorno.audio.volumenesAplicados, [0.05, 1.0]); + expect( + entorno.android.volumenForzado, + isEmpty, + reason: 'el override debe esperar a que el audio realmente fluya', + ); expect(entorno.android.detenidas, isEmpty); - expect(entorno.audio.volumenesAplicados, [0.05]); - // La radio confirma que esta reproduciendo -> dispara - // _confirmarAudioFlutterListo(), que queda bloqueado en la puerta - // (todavia no hay confirmacion nativa real). + // La radio llega a "reproduciendo": recien ahi se sube el stream al + // nivel configurado y se confirma el handoff (stop nativo). entorno.audio.emitirEstado(EstadoReproduccion.reproduciendo); await tester.pumpAndSettle(); - expect( - entorno.android.detenidas, - isEmpty, - reason: 'confirmarAudioFlutter sigue bloqueado en la puerta de prueba', - ); - expect( - entorno.audio.volumenesAplicados, - [0.05], - reason: 'el fade-in de Dart no debe arrancar antes del handoff', - ); - - // Se libera la puerta: recien ahi "confirma" el nativo, y solo - // entonces debe arrancar el fade-in de Dart (una unica rampa - // audible a la vez). - entorno.android.puertaConfirmarAudioFlutter!.complete(); - await tester.pumpAndSettle(); + expect(entorno.android.volumenForzado, [0.85]); expect(entorno.android.detenidas, contains('ring1')); - // Player ramps to its OWN full range (1.0); the configured level is - // enforced by the media-stream cap, not by the player target. - expect(entorno.audio.volumenesAplicados, [0.05, 1.0]); }); testWidgets('si confirmar el audio con el nativo falla, el fade-in de Dart '