diff --git a/lib/main.dart b/lib/main.dart index ff60cf3..f7ae79a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,8 +26,30 @@ const androidNotificationIconResource = 'drawable/ic_stat_pluriwave'; const configuracionAudioService = AudioServiceConfig( androidNotificationChannelId: 'es.freetimelab.pluriwave.audio', androidNotificationChannelName: 'PluriWave Radio', - androidNotificationOngoing: true, - androidStopForegroundOnPause: true, + // Paired with `androidStopForegroundOnPause: false` below, and required to + // be: the plugin asserts `androidNotificationOngoing` implies + // `androidStopForegroundOnPause`. Nothing is lost by turning it off — + // while the service is in the foreground the OS forces the notification to + // be ongoing anyway, which is now the whole time playback is alive. + androidNotificationOngoing: false, + // The service stays in the FOREGROUND while paused. + // + // With `true`, a pause called `stopForeground(...)`, and a service that is + // not in the foreground is a service Android may kill at will. In the car + // that is exactly what happened: an interruption paused playback, the + // service dropped out of the foreground, Android reclaimed it, and + // PluriWave disappeared from the Android Auto pane — another media app + // took the slot. Ducking (see `ServicioAudioSession.configurar`) removes + // most pauses, but a real pause must not be a death sentence either. + // + // The plugin's own doc for this flag says it outright: «while in this + // lower priority state, the operating system will also be able to kill + // your service at any time to reclaim resources». + // + // Cost of `false`: the notification is not swipe-dismissible while paused, + // only after Stop. That is how every serious media app behaves, and Stop + // still tears everything down. + androidStopForegroundOnPause: false, notificationColor: PluriWaveTokens.brand, androidNotificationIcon: androidNotificationIconResource, ); @@ -56,6 +78,26 @@ Future main() async { final fuenteAuto = FuenteEmisorasAutoLocal(); registrarFuenteNavegacion(fuenteAuto); + // Local music registers HERE, above every await, alongside the station + // source — not after `SharedPreferences.getInstance()` where it used to + // sit. + // + // Regression this fixes, self-inflicted by the reordering above: the root + // menu decides whether to offer "Música Local" with + // `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`. + // Moving ONLY the station source above the awaits meant the car could get + // a root response in the window before this line ran, find a null source, + // and be told there is no local music — and Android Auto caches the browse + // root, so it stayed missing for the whole session. Before the reorder + // both registrations sat together after the await, so the window did not + // exist. + // + // `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it + // resolves them lazily per call (`_resolverPrefs`, falling back to + // `getInstance()`), the same convention `ServicioAlarmas` uses. So there + // was never a reason for it to wait on that await. + registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl()); + // Cosmetic, and deliberately NOT awaited: a display preference must never // gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies // it again as soon as a real view exists, which is the only moment it can @@ -66,13 +108,6 @@ Future main() async { // injected into every state/service below. final prefs = await SharedPreferences.getInstance(); - // Local-music browse source (Design "getChildren data source - // registration"), same injectable-prefs DI convention as every other - // startup service — required so `_fuenteMusicaLocalGlobal` is ever - // non-null; without this registration the local-music root would stay - // permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask). - registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs)); - // User-saved EQ presets for the car's Ecualizador folder, same // injectable-prefs DI convention and same pre-init placement as the two // registrations above (neither depends on the AudioHandler). Passed as a diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 5780d58..9f14fb8 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -936,6 +936,58 @@ Future reproducirPorMediaId( await reproducir(item); } +/// Which list previous/next should walk for [actual]: the NARROWEST list the +/// station actually belongs to, favourites first, then my stations, then the +/// full catalogue. +/// +/// Narrowest-first is the point. "Next station" while playing a favourite +/// should land on the next favourite, not on entry 4,318 of a 50,000-station +/// catalogue that happens to sit beside it alphabetically. Falling through to +/// [todas] only when the station is in neither curated list keeps the button +/// working for a station reached by search. +/// +/// Returns an empty list when [actual] is in none of them, which +/// [emisoraVecina] turns into "do nothing". +List listaParaSaltoEmisora({ + required Emisora actual, + required List favoritos, + required List misEmisoras, + required List todas, +}) { + bool contiene(List lista) => lista.any((e) => e.uuid == actual.uuid); + if (contiene(favoritos)) return favoritos; + if (contiene(misEmisoras)) return misEmisoras; + if (contiene(todas)) return todas; + return const []; +} + +/// The station before or after [actual] in [lista], wrapping around at both +/// ends. +/// +/// Wrapping is deliberate: on a car's transport row a button that goes dead +/// at the end of a list reads as a broken app, and there is no visible list +/// position to explain it. Matching is by `uuid`, the same identity the +/// browse tree uses, so a refreshed snapshot with different object instances +/// still resolves. +/// +/// Returns `null` when [lista] has fewer than two entries, or when [actual] +/// is not in it — the caller must then leave playback alone rather than jump +/// somewhere arbitrary. +Emisora? emisoraVecina( + Emisora? actual, + List lista, { + required bool haciaAtras, +}) { + if (actual == null || lista.length < 2) return null; + final indice = lista.indexWhere((e) => e.uuid == actual.uuid); + if (indice < 0) return null; + final destino = + haciaAtras + ? (indice - 1 + lista.length) % lista.length + : (indice + 1) % lista.length; + return lista[destino]; +} + /// Picks the station a spoken query refers to ("pon Radio Clásica"), over the /// stations the car can already browse. /// diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 07abeb9..55b9352 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -735,8 +735,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler MediaAction.playFromMediaId, MediaAction.playFromSearch, MediaAction.seek, - if (colaActiva) MediaAction.skipToPrevious, - if (colaActiva) MediaAction.skipToNext, + // Previous/next are advertised ALWAYS now, not only for a local + // queue. Android Auto reserves those two slots and only hands the + // space to custom actions when the app declares no support, so + // this is what puts prev/next on the car's transport row -- and + // `skipToNext`/`skipToPrevious` fall back to station-to-station + // skipping when there is no queue, so neither button is inert. + MediaAction.skipToPrevious, + MediaAction.skipToNext, }, androidCompactActionIndices: [colaActiva ? 1 : 0], processingState: mapearEstadoProceso( @@ -1485,13 +1491,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future seek(Duration position) => _player.seek(position); /// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task - /// 3.6): a no-op when no local queue is active. Past the last track, - /// clears the queue and stops — mirroring auto-advance's end-of-queue - /// behavior (no wraparound). + /// 3.6). Past the last track, clears the queue and stops — mirroring + /// auto-advance's end-of-queue behavior (no wraparound). + /// + /// With NO local queue this now moves to the next STATION instead of doing + /// nothing: the car's transport row offers previous/next for radio too, + /// and a button that is present but inert is worse than no button. @override Future skipToNext() async { final cola = _colaLocal; - if (cola == null) return; + if (cola == null) { + await _saltarEmisora(haciaAtras: false); + return; + } final siguiente = cola.conSiguiente(); if (siguiente == null) { _desactivarCola(); @@ -1502,18 +1514,53 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _reproducirEntradaCola(siguiente.actual); } - /// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6): - /// a no-op when no local queue is active. Clamps at the first track - /// (restarts it) instead of wrapping to the last one. + /// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6). + /// Clamps at the first track (restarts it) instead of wrapping to the last + /// one. With no local queue, moves to the previous STATION — see + /// [skipToNext]. @override Future skipToPrevious() async { final cola = _colaLocal; - if (cola == null) return; + if (cola == null) { + await _saltarEmisora(haciaAtras: true); + return; + } final anterior = cola.conAnterior(); _colaLocal = anterior; await _reproducirEntradaCola(anterior.actual); } + /// Station-to-station skipping for the car's transport row. + /// + /// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest + /// list the current station actually belongs to, favourites first. Anything + /// unresolvable — no source, no current station, a station that is in no + /// list, a single-entry list — leaves playback untouched. Never throws; + /// this runs from a hardware/steering-wheel button and an exception here + /// would surface as the app going silent mid-drive. + Future _saltarEmisora({required bool haciaAtras}) async { + try { + final fuente = _fuenteNavegacionGlobal; + final actual = emisoraActual; + if (fuente == null || actual == null) return; + final lista = listaParaSaltoEmisora( + actual: actual, + favoritos: await fuente.favoritos(), + misEmisoras: await fuente.misEmisoras(), + todas: await fuente.todas(), + ); + final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras); + if (destino == null) return; + await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos)); + } catch (e) { + developer.log( + '[PluriWave] Error saltando de emisora: $e', + name: 'ServicioAudio', + level: 900, + ); + } + } + /// Dispatches the equalizer's only custom action (decision /// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating /// to the existing [setEcualizadorActivo] — the SAME entry point the diff --git a/lib/servicios/servicio_audio_session.dart b/lib/servicios/servicio_audio_session.dart index 2262166..87e08fc 100644 --- a/lib/servicios/servicio_audio_session.dart +++ b/lib/servicios/servicio_audio_session.dart @@ -58,9 +58,26 @@ class ServicioAudioSession { Future configurar() async { try { final sesion = await _obtenerSesion(); + // DUCK, never pause, when another app asks for transient focus. + // + // `androidWillPauseWhenDucked: true` makes `audio_session` translate + // Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a + // car that fires constantly — every navigation instruction, every + // speed-camera warning, every "OK Google" — and each one used to stop + // the radio outright instead of dipping the volume for two seconds. + // + // Worse than the audio gap: a pause publishes `playing: false`, which + // `AudioService.setState` turns into `exitPlayingState()` and, with + // `androidStopForegroundOnPause`, into `stopForeground(...)`. A service + // that is no longer in the foreground is killable, and when Android + // took it the app vanished from the Android Auto pane mid-drive and + // another media app took its slot. Ducking keeps `playing: true` + // throughout, so the session, the notification and the car pane all + // survive an interruption — which is also what keeps the equalizer + // alive across it. await sesion.configure( const AudioSessionConfiguration.music().copyWith( - androidWillPauseWhenDucked: true, + androidWillPauseWhenDucked: false, ), ); await _interrupcionesSub?.cancel(); diff --git a/test/arranque_orientacion_test.dart b/test/arranque_orientacion_test.dart index fb2e95a..6874957 100644 --- a/test/arranque_orientacion_test.dart +++ b/test/arranque_orientacion_test.dart @@ -70,10 +70,11 @@ void main() { // and showing the test still finishes -- if startup awaited it, this // future is exactly what would hang forever on the headless engine. var termino = false; - // ignore: unawaited_futures - aplicarPoliticaOrientacion( - aplicar: (_) => Completer().future, - ).then((_) => termino = true); + unawaited( + aplicarPoliticaOrientacion( + aplicar: (_) => Completer().future, + ).then((_) => termino = true), + ); await Future.delayed(Duration.zero); expect(termino, isFalse, reason: 'sigue pendiente, como debe'); diff --git a/test/servicios/auto_acciones_requeridas_test.dart b/test/servicios/auto_acciones_requeridas_test.dart index 60efe83..9986070 100644 --- a/test/servicios/auto_acciones_requeridas_test.dart +++ b/test/servicios/auto_acciones_requeridas_test.dart @@ -31,8 +31,8 @@ void main() { MediaAction.playFromMediaId, MediaAction.playFromSearch, MediaAction.seek, - if (colaActiva) MediaAction.skipToPrevious, - if (colaActiva) MediaAction.skipToNext, + MediaAction.skipToPrevious, + MediaAction.skipToNext, }; group('acciones que Android for Cars documenta como obligatorias', () { @@ -55,22 +55,31 @@ void main() { }); } - test('los saltos SOLO se anuncian con cola activa', () { - // Android Auto reserves the prev/next slots for these actions and, when - // the app does not support them, hands the space to custom actions -- - // which is where the equalizer toggle lives. Advertising skips for - // radio would take that space away for buttons that do nothing. - expect( - systemActions(colaActiva: false), - isNot(contains(MediaAction.skipToPrevious)), - ); - expect( - systemActions(colaActiva: true), - containsAll([ - MediaAction.skipToPrevious, - MediaAction.skipToNext, - ]), - ); + test('los saltos se anuncian SIEMPRE, también para radio', () { + // Reversal of the previous version of this test, and deliberate. + // + // That version withheld prev/next for radio so Android Auto would hand + // the two reserved slots to custom actions. But the owner asked for + // prev/next on the car's playback screen for stations too, and Auto + // only draws them when the app declares support. `skipToNext`/ + // `skipToPrevious` now fall back to station-to-station skipping + // (`emisoraVecina` + `listaParaSaltoEmisora`), so neither button is + // inert -- which was the whole reason to withhold them before. + // + // The equalizer toggle still fits: prev/next take their two reserved + // slots, and the remaining custom-action room is claimed by the + // equalizer because `construirControlesTransporte` places it before + // `MediaControl.stop`. + for (final colaActiva in [false, true]) { + expect( + systemActions(colaActiva: colaActiva), + containsAll([ + MediaAction.skipToPrevious, + MediaAction.skipToNext, + ]), + reason: 'colaActiva=$colaActiva', + ); + } }); }); diff --git a/test/servicios/auto_salto_emisora_test.dart b/test/servicios/auto_salto_emisora_test.dart new file mode 100644 index 0000000..cb5cb0c --- /dev/null +++ b/test/servicios/auto_salto_emisora_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; + +/// Requested: the Android Auto playback screen must offer previous/next for +/// stations too, not only for a local-music queue. Those buttons appear +/// because `skipToPrevious`/`skipToNext` are advertised in `systemActions` — +/// so they have to actually move, or the car shows two dead buttons. +void main() { + Emisora emisora(String uuid) => + Emisora(uuid: uuid, nombre: uuid, url: 'https://example.com/$uuid'); + + final a = emisora('a'); + final b = emisora('b'); + final c = emisora('c'); + + group('emisoraVecina', () { + test('avanza y retrocede dentro de la lista', () { + expect(emisoraVecina(a, [a, b, c], haciaAtras: false), b); + expect(emisoraVecina(b, [a, b, c], haciaAtras: true), a); + }); + + test('da la vuelta en los dos extremos', () { + // A dead button at the end of a list reads as a broken app on a car + // screen, where there is no visible list position to explain it. + expect(emisoraVecina(c, [a, b, c], haciaAtras: false), a); + expect(emisoraVecina(a, [a, b, c], haciaAtras: true), c); + }); + + test('identifica por uuid, no por instancia: un snapshot refrescado trae ' + 'objetos distintos', () { + final copiaDeB = Emisora( + uuid: 'b', + nombre: 'otro nombre', + url: 'https://example.com/cambiada', + ); + expect(emisoraVecina(copiaDeB, [a, b, c], haciaAtras: false), c); + }); + + test('no hace nada sin contexto suficiente', () { + expect(emisoraVecina(null, [a, b], haciaAtras: false), isNull); + expect(emisoraVecina(a, [a], haciaAtras: false), isNull); + expect(emisoraVecina(a, const [], haciaAtras: false), isNull); + expect( + emisoraVecina(emisora('fuera'), [a, b], haciaAtras: false), + isNull, + reason: 'una emisora que no está en la lista no debe saltar a ciegas', + ); + }); + }); + + group('listaParaSaltoEmisora', () { + test('favoritos gana: "siguiente" desde un favorito va al siguiente ' + 'favorito, no a la entrada 4318 del catálogo', () { + expect( + listaParaSaltoEmisora( + actual: a, + favoritos: [a, b], + misEmisoras: [a, c], + todas: [a, b, c], + ), + [a, b], + ); + }); + + test('mis emisoras cuando no es favorita', () { + expect( + listaParaSaltoEmisora( + actual: c, + favoritos: [a, b], + misEmisoras: [c, a], + todas: [a, b, c], + ), + [c, a], + ); + }); + + test('cae al catálogo completo para una emisora llegada por búsqueda', () { + expect( + listaParaSaltoEmisora( + actual: c, + favoritos: [a], + misEmisoras: [b], + todas: [a, b, c], + ), + [a, b, c], + ); + }); + + test('vacía si no está en ninguna: el salto queda en no-op', () { + expect( + listaParaSaltoEmisora( + actual: emisora('huerfana'), + favoritos: [a], + misEmisoras: [b], + todas: [a, b], + ), + isEmpty, + ); + }); + }); +} diff --git a/test/servicios/servicio_audio_session_duck_test.dart b/test/servicios/servicio_audio_session_duck_test.dart new file mode 100644 index 0000000..fb71638 --- /dev/null +++ b/test/servicios/servicio_audio_session_duck_test.dart @@ -0,0 +1,120 @@ +import 'package:audio_session/audio_session.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/servicio_audio_session.dart'; + +/// Reported from the car: PluriWave disappeared from the Android Auto pane +/// mid-drive and another media app took its slot, the playback screen sat +/// frozen, and the chosen equalizer was lost whenever navigation, a +/// speed-camera app or the car's voice assistant spoke. +/// +/// One cause behind all three. `androidWillPauseWhenDucked: true` made +/// `audio_session` translate Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK +/// into a full PAUSE. In a car that fires on every navigation instruction. A +/// pause publishes `playing: false`, `AudioService.setState` turns that into +/// `exitPlayingState()` and — with `androidStopForegroundOnPause` — into +/// `stopForeground(...)`. The plugin's own doc for that flag: «while in this +/// lower priority state, the operating system will also be able to kill your +/// service at any time to reclaim resources». A killed service is a media +/// session that vanishes from the car. +/// +/// Ducking keeps `playing: true` throughout, so session, notification and +/// car pane all survive the interruption. +class _ObjetivoFalso implements ObjetivoAudioInterrumpible { + bool atenuado = false; + int pausas = 0; + int reanudaciones = 0; + int reaplicacionesEq = 0; + + @override + bool intencionReproducir = true; + + @override + bool estaReproduciendo = true; + + @override + Future pausar() async => pausas++; + + @override + Future reanudar() async => reanudaciones++; + + @override + Future setAtenuado(bool valor) async => atenuado = valor; + + @override + Future reaplicarEcualizador() async => reaplicacionesEq++; +} + +void main() { + test('un aviso de navegación ATENÚA, nunca pausa', () async { + final objetivo = _ObjetivoFalso(); + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.duck), + ); + + expect(objetivo.atenuado, isTrue); + expect( + objetivo.pausas, + 0, + reason: + 'pausar publica playing:false -> exitPlayingState -> el servicio ' + 'sale de primer plano y Android puede matarlo; ahí es donde la app ' + 'desaparecía del panel del coche', + ); + }); + + test('al terminar el aviso se recupera el volumen Y se reasienta el ' + 'ecualizador', () async { + final objetivo = _ObjetivoFalso(); + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.duck), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.duck), + ); + + expect(objetivo.atenuado, isFalse); + expect( + objetivo.reaplicacionesEq, + 1, + reason: + 'Android puede desactivar en silencio el efecto de esta app cuando ' + 'otro cliente de mayor prioridad toma el foco, y el id de sesión no ' + 'cambia, así que el disparador por rotación de sesión no salta', + ); + expect(objetivo.pausas, 0); + }); + + test('una llamada entrante SÍ pausa: no todo es un aviso corto', () async { + // The duck change must not turn a real, non-duckable focus loss into a + // station that keeps playing over a phone call. + final objetivo = _ObjetivoFalso(); + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.pause), + ); + + expect(objetivo.pausas, 1); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.pause), + ); + + expect(objetivo.reanudaciones, 1); + expect(objetivo.reaplicacionesEq, 1); + }); + + test('desconectar los auriculares pausa y NO reanuda solo', () async { + final objetivo = _ObjetivoFalso(); + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarDesconexionSalida(); + + expect(objetivo.pausas, 1); + expect(objetivo.reanudaciones, 0); + }); +}