diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 84a78ef..46f53b7 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -836,10 +836,12 @@ Future reproducirPorMediaId( title: emisora.nombre, artist: emisora.pais ?? '', album: 'PluriWave', - artUri: - emisora.favicon != null && emisora.favicon!.isNotEmpty - ? Uri.tryParse(emisora.favicon!) - : null, + // Item 3: reuses [artUriPara] (the SAME fallback the browse tree's + // itemEmisora already applies) so the "now playing" media item never + // falls back to a blank tile — a real usable favicon still wins, a + // missing/unusable one gets the on-brand rotating drawable instead of + // `null`. + artUri: Uri.parse(artUriPara(emisora)), extras: {'uuid': emisora.uuid}, ); await reproducir(item); @@ -1143,6 +1145,11 @@ Future construirMediaItemColaLocal( id: contentUri, title: _tituloDesdeDocumentId(nodo.documentId), album: 'PluriWave', + // Item 3: a queued local track had NO artUri at all before — reuses + // [artUriLocal] (the SAME on-brand rotation the browse tree's + // `_itemLocal` already falls back to) so the car's now-playing screen + // never shows a blank tile for a track with no embedded art. + artUri: Uri.parse(artUriLocal(nodo.documentId)), extras: {'documentId': nodo.documentId}, ); } @@ -1322,6 +1329,9 @@ Future reproducirPistaLocal( id: pista.contentUri, title: pista.titulo, album: 'PluriWave', + // Item 3: same fallback as construirMediaItemColaLocal, for a track + // tapped directly (not via a folder-play queue). + artUri: Uri.parse(artUriLocal(pista.documentId)), extras: {'documentId': pista.documentId}, ); await reproducir(item); diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 2999cca..0545728 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -60,6 +60,46 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) { _fuenteMusicaLocalGlobal = fuente; } +/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android +/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a +/// station with no usable favicon gets the SAME on-brand rotating fallback +/// the browse tree and the car-tap path already show, instead of a blank +/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler] +/// dependency — so it is unit-testable without instantiating the handler. +MediaItem mediaItemParaEmisora(Emisora emisora, {required AppLocalizations l10n}) { + return MediaItem( + id: emisora.url, + title: localizedStationName(l10n, emisora.nombre), + artist: emisora.pais ?? '', + album: 'PluriWave', + artUri: Uri.parse(artUriPara(emisora)), + extras: {'uuid': emisora.uuid}, + ); +} + +/// Reconstructs the phone-side [Emisora] from the handler's current +/// [MediaItem] (item 3): gates `favicon` through [faviconUsable] +/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand +/// FALLBACK `artUri` (an `android.resource://` drawable, never a real +/// favicon) is never misread as a genuine station favicon — the phone UI's +/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty` +/// (not on `faviconUsable`'s scheme check), so without this guard they would +/// attempt a doomed network fetch of the fallback's non-http URI before +/// falling back to [PluriStationArtFallback] themselves. A genuine http(s) +/// favicon still round-trips exactly as before. Pure — no handler +/// dependency — unit-testable directly. +Emisora emisoraDesdeMediaItem(MediaItem mediaItem) { + final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; + final artUriTexto = mediaItem.artUri?.toString(); + return Emisora( + uuid: uuid, + nombre: mediaItem.title, + url: mediaItem.id, + pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null, + favicon: faviconUsable(artUriTexto) ? artUriTexto : null, + ); +} + /// Wrapper de alto nivel para el UI. class ServicioAudio { PluriWaveAudioHandler get _handler { @@ -94,19 +134,9 @@ class ServicioAudio { }); Future reproducir(Emisora emisora) async { - final item = MediaItem( - id: emisora.url, - title: localizedStationName( - lookupAppLocalizations(const Locale('es')), - emisora.nombre, - ), - artist: emisora.pais ?? '', - album: 'PluriWave', - artUri: - emisora.favicon != null && emisora.favicon!.isNotEmpty - ? Uri.tryParse(emisora.favicon!) - : null, - extras: {'uuid': emisora.uuid}, + final item = mediaItemParaEmisora( + emisora, + l10n: lookupAppLocalizations(const Locale('es')), ); await _handler.playMediaItem(item); } @@ -911,14 +941,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler } Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { - final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; - return Emisora( - uuid: uuid, - nombre: mediaItem.title, - url: mediaItem.id, - pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null, - favicon: mediaItem.artUri?.toString(), - ); + // Item 3: delegates to the top-level, unit-testable function so the + // `faviconUsable` guard (never reflect the on-brand fallback artUri + // back as a real favicon) is covered without instantiating the handler. + return emisoraDesdeMediaItem(mediaItem); } // ── Android Auto browsing (thin delegation to navegacion_auto.dart's diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index f7f6abc..4f19239 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2189,6 +2189,28 @@ void main() { }, ); + test( + 'item 3: el MediaItem de una pista local tocada directamente usa el ' + 'fallback de marca (artUriLocal), no queda sin artUri', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + MediaItem? recibido; + + await reproducirPistaLocal( + 'pista:doc1', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido!.artUri, isNotNull); + expect(recibido!.artUri.toString(), artUriLocal('doc1')); + }, + ); + test( 'id obsoleto/desconocido (uriContenidoDePista devuelve null) no ' 'llama a reproducir ni lanza excepción', @@ -2494,6 +2516,27 @@ void main() { expect(item, isNull); }, ); + + test( + 'item 3: el MediaItem de una pista en cola de "Reproducir carpeta" ' + 'usa el fallback de marca (artUriLocal), no queda sin artUri (antes ' + 'una pista en cola no tenía NINGÚN arte)', + () async { + final fuente = _FakeFuenteMusicaLocalAuto( + uriPorDocId: const {'doc1': 'content://provider/doc1'}, + ); + const nodo = NodoLocal( + documentId: 'doc1', + nombre: 'ignorado.mp3', + esDirectorio: false, + ); + + final item = await construirMediaItemColaLocal(nodo, fuente: fuente); + + expect(item!.artUri, isNotNull); + expect(item.artUri.toString(), artUriLocal('doc1')); + }, + ); }); group('ConstructorArbolAuto.itemEmisora', () { @@ -2866,6 +2909,61 @@ void main() { expect(llamadas, 0); }, ); + + test( + 'item 3: emisora SIN favicon usable -- el MediaItem "en reproducción" ' + 'usa el fallback de marca (artUriPara), no queda con artUri null ' + '(antes se perdía el arte al reproducir desde el auto)', + () async { + final emisora = _emisora( + uuid: 'uuid-sin-arte', + nombre: 'Radio sin logo', + favicon: null, + ); + final fuente = _FakeFuenteEmisorasAuto( + porUuidResultado: {emisora.uuid: emisora}, + ); + MediaItem? recibido; + + await reproducirPorMediaId( + 'emisora:${emisora.uuid}', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido, isNotNull); + expect(recibido!.artUri, isNotNull); + expect(recibido!.artUri.toString(), artUriPara(emisora)); + }, + ); + + test( + 'item 3: emisora CON favicon http(s) usable -- el MediaItem "en ' + 'reproducción" sigue usando ese favicon real, no el fallback', + () async { + final emisora = _emisora( + uuid: 'uuid-con-arte', + nombre: 'Radio con logo', + favicon: 'https://cdn.example.com/logo.png', + ); + final fuente = _FakeFuenteEmisorasAuto( + porUuidResultado: {emisora.uuid: emisora}, + ); + MediaItem? recibido; + + await reproducirPorMediaId( + 'emisora:${emisora.uuid}', + fuente: fuente, + reproducir: (item) async { + recibido = item; + }, + ); + + expect(recibido!.artUri.toString(), emisora.favicon); + }, + ); }); // ── Item 1: Android Auto subfolder visibility ─────────────────────────── diff --git a/test/servicios/servicio_audio_fallback_art_test.dart b/test/servicios/servicio_audio_fallback_art_test.dart new file mode 100644 index 0000000..306f746 --- /dev/null +++ b/test/servicios/servicio_audio_fallback_art_test.dart @@ -0,0 +1,139 @@ +import 'dart:ui' show Locale; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/navegacion_auto.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// Item 3 (Android Auto fallback artwork) — the pure, handler-independent +/// half of the fix. `PluriWaveAudioHandler` cannot be instantiated in unit +/// tests (a real `just_audio.AudioPlayer` requires platform MethodChannels, +/// confirmed by `servicio_audio_source_switch_test.dart`), so the actual +/// "now playing" MediaItem construction and the reverse Emisora +/// reconstruction are extracted as pure top-level functions here, exactly +/// like `debeReaplicarEcualizador` was extracted for the EQ re-apply fix. +void main() { + final l10n = lookupAppLocalizations(const Locale('es')); + + group('mediaItemParaEmisora (item 3)', () { + test( + 'estación SIN favicon usable: el MediaItem usa el fallback de marca ' + '(artUriPara), no queda con artUri null', + () { + const emisora = Emisora( + uuid: 'uuid-sin-arte', + nombre: 'Radio sin logo', + url: 'https://stream.demo/sin-logo', + ); + + final item = mediaItemParaEmisora(emisora, l10n: l10n); + + expect(item.artUri, isNotNull); + expect(item.artUri.toString(), artUriPara(emisora)); + }, + ); + + test( + 'estación CON favicon http(s) usable: el MediaItem sigue usando ese ' + 'favicon real, no el fallback', + () { + const emisora = Emisora( + uuid: 'uuid-con-arte', + nombre: 'Radio con logo', + url: 'https://stream.demo/con-logo', + favicon: 'https://cdn.example.com/logo.png', + ); + + final item = mediaItemParaEmisora(emisora, l10n: l10n); + + expect(item.artUri.toString(), emisora.favicon); + }, + ); + + test('preserva id, artist y extras.uuid como antes', () { + const emisora = Emisora( + uuid: 'uuid-forma', + nombre: 'Radio Forma', + url: 'https://stream.demo/forma', + pais: 'Argentina', + ); + + final item = mediaItemParaEmisora(emisora, l10n: l10n); + + expect(item.id, emisora.url); + expect(item.artist, 'Argentina'); + expect(item.album, 'PluriWave'); + expect(item.extras?['uuid'], emisora.uuid); + }); + }); + + group('emisoraDesdeMediaItem (item 3 — no phone-UI regression)', () { + test( + 'artUri de marca (android.resource://…, no http) NUNCA se refleja ' + 'como favicon -- evitaría un intento de red inválido en ' + 'CachedNetworkImage del lado telefono', + () { + final mediaItem = MediaItem( + id: 'https://stream.demo/sin-logo', + title: 'Radio sin logo', + artUri: Uri.parse( + 'android.resource://es.freetimelab.pluriwave/drawable/' + 'station_art_aurora', + ), + extras: const {'uuid': 'uuid-sin-arte'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.favicon, isNull); + }, + ); + + test( + 'artUri http(s) real SÍ se refleja como favicon (comportamiento ' + 'previo preservado)', + () { + final mediaItem = MediaItem( + id: 'https://stream.demo/con-logo', + title: 'Radio con logo', + artUri: Uri.parse('https://cdn.example.com/logo.png'), + extras: const {'uuid': 'uuid-con-arte'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.favicon, 'https://cdn.example.com/logo.png'); + }, + ); + + test('sin artUri: favicon queda null, sin lanzar', () { + final mediaItem = MediaItem( + id: 'https://stream.demo/sin-arturi', + title: 'Radio', + extras: const {'uuid': 'uuid-x'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.favicon, isNull); + }); + + test('preserva uuid, nombre, url y pais como antes', () { + final mediaItem = MediaItem( + id: 'https://stream.demo/forma', + title: 'Radio Forma', + artist: 'Argentina', + extras: const {'uuid': 'uuid-forma'}, + ); + + final emisora = emisoraDesdeMediaItem(mediaItem); + + expect(emisora.uuid, 'uuid-forma'); + expect(emisora.nombre, 'Radio Forma'); + expect(emisora.url, mediaItem.id); + expect(emisora.pais, 'Argentina'); + }); + }); +}