diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 438372c..421ba7e 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -134,6 +134,36 @@ jobs: - name: Build APK release run: flutter build apk --release + # Guardian de recursos: el APK debe contener los drawables que el codigo + # resuelve POR NOMBRE en runtime (getResources().getIdentifier). + # + # Un nombre que no resuelve devuelve id 0, y eso no falla la + # compilacion: falla en el coche. Concretamente + # PlaybackStateCompat.CustomAction.Builder lanza con icono 0, ese throw + # aborta AudioService.setState antes de activar la sesion de medios, y + # Android Auto se queda con la interfaz congelada. Paso exactamente eso + # entre el 31-07 (commit 2540556) y el 07-08 sin que nada lo detectara. + # + # Anadir un drawable nuevo referenciado por nombre => anadirlo aqui. + - name: Verificar recursos criticos en el APK + run: | + APK=build/app/outputs/flutter-apk/app-release.apk + FALTAN=0 + for RECURSO in ic_auto_eq_on ic_auto_eq_off ic_stat_pluriwave; do + if unzip -l "$APK" | grep -q "res/drawable/$RECURSO.xml"; then + echo "OK res/drawable/$RECURSO.xml" + else + echo "FALTA res/drawable/$RECURSO.xml" + FALTAN=1 + fi + done + if [ "$FALTAN" -ne 0 ]; then + echo "" + echo "Hay drawables resueltos por nombre que NO estan en el APK." + echo "En runtime resolveran a id 0 y tumbaran la sesion de medios." + exit 1 + fi + - name: Build AAB release run: flutter build appbundle --release diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 9f14fb8..300128f 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -936,15 +936,26 @@ 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. +/// Which list previous/next should walk for [actual]: the NARROWEST context +/// the station belongs to. /// -/// 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. +/// Tightest first: +/// 1. its FAVOURITES GROUP, when it is a favourite filed under a real group, +/// 2. all favourites, +/// 3. my stations, +/// 4. the full catalogue. +/// +/// The group tier is what the owner asked for: driving with a themed group, +/// "next" should stay inside that group rather than wander across every +/// favourite. And "next" from a favourite must never land 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 alive for a station reached by search. +/// +/// [GrupoFavoritos.sinAsignarId] is deliberately NOT treated as a group: it +/// is the ABSENCE of one, so those stations walk all favourites instead of a +/// bucket that only means "unfiled". A group with a single member also falls +/// through to all favourites — otherwise both buttons would be dead ends. /// /// Returns an empty list when [actual] is in none of them, which /// [emisoraVecina] turns into "do nothing". @@ -954,10 +965,28 @@ List listaParaSaltoEmisora({ 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; + Emisora? enLista(List lista) { + for (final e in lista) { + if (e.uuid == actual.uuid) return e; + } + return null; + } + + // The FAVOURITE record is the authority on the group, never `actual`: the + // playing station is rebuilt from a MediaItem by `emisoraDesdeMediaItem`, + // which carries no group id and would always report "sin asignar". + final favorita = enLista(favoritos); + if (favorita != null) { + final grupo = favorita.grupoFavoritosId; + if (grupo != GrupoFavoritos.sinAsignarId) { + final delGrupo = + favoritos.where((e) => e.grupoFavoritosId == grupo).toList(); + if (delGrupo.length > 1) return delGrupo; + } + return favoritos; + } + if (enLista(misEmisoras) != null) return misEmisoras; + if (enLista(todas) != null) return todas; return const []; } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 87be70f..d760a45 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -1544,6 +1544,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler todas: await fuente.todas(), ); final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras); + // Reported: in the car these buttons did nothing for radio. Every early + // return here is silent, so the log has to say WHICH one fired -- + // an empty list (the station matched none of the three) and a station + // that is in a list of one are indistinguishable from outside. + debugPrint( + '[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras ' + 'actual=${actual.nombre} uuid=${actual.uuid} ' + 'lista=${lista.length} destino=${destino?.nombre ?? "NINGUNO"}', + ); if (destino == null) return; await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos)); } catch (e) { diff --git a/test/servicios/auto_salto_emisora_test.dart b/test/servicios/auto_salto_emisora_test.dart index cb5cb0c..6bb0bd7 100644 --- a/test/servicios/auto_salto_emisora_test.dart +++ b/test/servicios/auto_salto_emisora_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; /// Requested: the Android Auto playback screen must offer previous/next for @@ -99,4 +100,76 @@ void main() { ); }); }); + + group('navegación por GRUPO de favoritos (pedido por el dueño)', () { + Emisora favorita(String uuid, String grupo) => Emisora( + uuid: uuid, + nombre: uuid, + url: 'https://example.com/$uuid', + grupoFavoritosId: grupo, + ); + + final rock1 = favorita('rock1', 'g-rock'); + final rock2 = favorita('rock2', 'g-rock'); + final jazz1 = favorita('jazz1', 'g-jazz'); + final suelta = favorita('suelta', GrupoFavoritos.sinAsignarId); + + test('sonando una favorita de un grupo, se recorre SOLO ese grupo', () { + expect( + listaParaSaltoEmisora( + actual: rock1, + favoritos: [rock1, jazz1, rock2, suelta], + misEmisoras: const [], + todas: [rock1, jazz1, rock2, suelta], + ), + [rock1, rock2], + ); + }); + + test('el grupo se lee del registro de FAVORITOS, no de lo que suena: la ' + 'emisora reconstruida desde el MediaItem no lleva grupo', () { + // emisoraDesdeMediaItem no puede saber el grupo -> llega "sin asignar". + final reconstruida = Emisora( + uuid: 'rock1', + nombre: 'Rock 1', + url: 'https://example.com/rock1', + ); + expect(reconstruida.grupoFavoritosId, GrupoFavoritos.sinAsignarId); + expect( + listaParaSaltoEmisora( + actual: reconstruida, + favoritos: [rock1, jazz1, rock2], + misEmisoras: const [], + todas: const [], + ), + [rock1, rock2], + reason: 'si se leyera de `actual` caeríamos a todos los favoritos', + ); + }); + + test('"sin asignar" NO es un grupo: recorre todos los favoritos', () { + expect( + listaParaSaltoEmisora( + actual: suelta, + favoritos: [rock1, suelta, jazz1], + misEmisoras: const [], + todas: const [], + ), + [rock1, suelta, jazz1], + ); + }); + + test('un grupo de UNA sola emisora cae a todos los favoritos, para no ' + 'dejar los dos botones muertos', () { + expect( + listaParaSaltoEmisora( + actual: jazz1, + favoritos: [rock1, rock2, jazz1], + misEmisoras: const [], + todas: const [], + ), + [rock1, rock2, jazz1], + ); + }); + }); }