From d754e28ddf8fe4da1d23f7896579f1480309cf0b Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 7 Aug 2026 17:17:48 +0200 Subject: [PATCH] fix(audio): keep local skips local, and stop a failed station blanking Auto Three reported suspicions. Two confirmed by reading, one not. 1. CONFIRMED, self-inflicted. Playing a song from the phone and pressing NEXT jumped to a radio station. 3398d02 taught skipToNext/skipToPrevious to fall back to station skipping when there is no local queue, so the car's buttons would not be dead for radio. But queue-less does not mean radio: tapping ONE track goes through reproducirPistaLocal, which never builds a queue -- only folder playback sets _colaLocal. That is exactly why the report said "at least the first time". emisoraActual cannot tell them apart either: _cambiarFuente fills it in for every source, so a local MP3 arrives as an Emisora whose url is its content:// document URI. The media id's scheme is the real discriminator, the same test that already keeps the recorder off local files. A local track now skips nowhere, which is the correct behaviour for a single item. 2. CONFIRMED mechanism. A failed station made the app disappear from the Android Auto pane. The error path published STATE_ERROR and then cleared everything: `emisoraActual = null; mediaItem.add(null)`. That leaves the session in an error state with no metadata at all, and Auto drops a session with nothing to show -- reported as "if a station fails it seems to crash, and going to 1/3 it fails". Both are kept now. Nothing outside servicio_audio.dart consumes mediaItem (verified), so the phone is unaffected, and the car gains two things: the screen can still name the station that failed instead of going blank, and previous/next stay usable, so a driver can skip out of a dead station instead of being stranded -- _saltarEmisora needs emisoraActual to know where it is in the list. The error state itself is unchanged. 3. NOT CONFIRMED. A local track occasionally jumping to another one mid-play. An advance requires a genuine `completed` from just_audio, so either the player reports the end early -- plausible for a content:// SAF source, whose duration is not always exact -- or something else moved the track. Reading the code cannot separate those, so nothing was changed on a guess. The advance now logs the decision with the processing state, position and duration that caused it, so the next occurrence arrives with its reason attached. Tests: 1192 -> 1195. --- lib/servicios/servicio_audio.dart | 58 +++++++++++++++++-- .../servicio_audio_salto_fuente_test.dart | 52 +++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 test/servicios/servicio_audio_salto_fuente_test.dart diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index d760a45..1150273 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -925,9 +925,22 @@ class PluriWaveAudioHandler extends BaseAudioHandler errorMessage: mensaje, ), ); - emisoraActual = null; - mediaItem.add(null); - + // The failed item and station are KEPT, deliberately. + // + // This used to do `emisoraActual = null; mediaItem.add(null);`, which + // left the media session in STATE_ERROR with no metadata at all. Android + // Auto drops a session with nothing to show, which is what made PluriWave + // vanish from the car pane the moment a station failed -- reported as + // "if a station fails it seems to crash, and going to 1/3 it fails". + // + // Keeping them costs nothing on the phone (nothing outside this file + // consumes `mediaItem`, verified) and buys two things in the car: the + // screen can still name the station that failed instead of going blank, + // and previous/next stay usable, so the driver skips out of a dead + // station instead of being stranded -- `_saltarEmisora` needs + // `emisoraActual` to know where it is in the list. + // + // The error state itself is unchanged: STATE_ERROR with the message. _player.stop().catchError((_) {}); } @@ -1126,6 +1139,21 @@ class PluriWaveAudioHandler extends BaseAudioHandler avanzandoCola: _avanzandoCola, trackCompletado: proc == ProcessingState.completed, ); + // Reported: a local track sometimes jumped to another one on its own, + // without reaching the end and without anyone pressing a thing. Reading + // the code cannot settle it -- an advance here requires a genuine + // `completed` from just_audio, so either the player reports the end + // early (plausible for a `content://` SAF source, whose duration is not + // always exact) or something else moved the track. Rather than guess, + // log the transition with the state that caused it, so the next report + // arrives with the reason attached instead of another hypothesis. + if (decision != DecisionAvanceCola.ninguna) { + debugPrint( + '[PluriWave][ServicioAudio] avance de cola decision=${decision.name} ' + 'proc=${proc.name} pos=${_player.position} dur=${_player.duration} ' + 'pista=${mediaItem.value?.title}', + ); + } switch (decision) { case DecisionAvanceCola.ninguna: return; @@ -1495,7 +1523,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future skipToNext() async { final cola = _colaLocal; if (cola == null) { - await _saltarEmisora(haciaAtras: false); + if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: false); return; } final siguiente = cola.conSiguiente(); @@ -1516,7 +1544,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future skipToPrevious() async { final cola = _colaLocal; if (cola == null) { - await _saltarEmisora(haciaAtras: true); + if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true); return; } final anterior = cola.conAnterior(); @@ -1524,6 +1552,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _reproducirEntradaCola(anterior.actual); } + /// Whether what is playing right now is a RADIO STREAM, as opposed to a + /// local file. + /// + /// Reported: playing a single local track and pressing next jumped to a + /// radio station. `emisoraActual` cannot answer this — `_cambiarFuente` + /// fills it in for every source, so a local MP3 arrives as an `Emisora` + /// whose `url` is its `content://` document URI. The scheme of the media + /// id is what actually distinguishes them, and it is the same test + /// `esEmisoraGrabable` uses to keep the recorder off local files. + /// + /// Only a queue-less local track reaches this: folder playback sets + /// `_colaLocal` and skips within the queue, which is why the report said + /// "at least the first time" — tapping one track never builds a queue. + bool get _reproduciendoRadio { + final id = mediaItem.value?.id; + if (id == null) return false; + final esquema = Uri.tryParse(id)?.scheme.toLowerCase(); + return esquema == 'http' || esquema == 'https'; + } + /// Station-to-station skipping for the car's transport row. /// /// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest diff --git a/test/servicios/servicio_audio_salto_fuente_test.dart b/test/servicios/servicio_audio_salto_fuente_test.dart new file mode 100644 index 0000000..2a23ed1 --- /dev/null +++ b/test/servicios/servicio_audio_salto_fuente_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// Reported: playing a song stored on the phone and pressing NEXT jumped to a +/// radio station — "at least the first time". +/// +/// Self-inflicted, by 3398d02. That commit taught `skipToNext`/`skipToPrevious` +/// to fall back to station-to-station skipping when there is no local queue, +/// so the car's transport buttons would not be dead for radio. But a +/// queue-less state does NOT mean "radio is playing": tapping ONE local track +/// goes through `reproducirPistaLocal`, which never builds a queue. Only +/// folder playback sets `_colaLocal` — which is exactly why the report said +/// "at least the first time". +/// +/// `emisoraActual` cannot tell them apart either: `_cambiarFuente` fills it in +/// for every source, so a local MP3 arrives as an `Emisora` whose `url` is its +/// `content://` document URI. The media id's SCHEME is the real +/// discriminator, and this asserts on that rule — the same one +/// `esEmisoraGrabable` uses to keep the recorder off local files. +/// +/// `PluriWaveAudioHandler` needs platform MethodChannels and cannot be built +/// in a unit test, so this pins the predicate rather than the private getter. +void main() { + bool esRadio(String? id) { + if (id == null) return false; + final esquema = Uri.tryParse(id)?.scheme.toLowerCase(); + return esquema == 'http' || esquema == 'https'; + } + + test('un stream de radio SÍ permite saltar de emisora', () { + expect(esRadio('http://stream.example.com/live'), isTrue); + expect(esRadio('https://stream.example.com/live'), isTrue); + }); + + test('una pista local NO: es el caso exacto del reporte', () { + expect( + esRadio( + 'content://com.android.externalstorage.documents/tree/' + 'primary%3AMusic/document/primary%3AMusic%2FNew%20Limit%20-%20Smile.mp3', + ), + isFalse, + reason: + 'con una pista suelta no hay cola, y antes de esto el boton ' + 'siguiente se iba a una emisora de radio', + ); + expect(esRadio('file:///storage/emulated/0/musica/a.mp3'), isFalse); + }); + + test('sin nada sonando tampoco se salta', () { + expect(esRadio(null), isFalse); + expect(esRadio(''), isFalse); + }); +}