fix(auto): fall back to on-brand artwork when a station or track has none

Stations and tracks with no artwork showed empty tiles in the car. The
browse tree's itemEmisora/_itemLocal already fell back to the rotating
station_art_* drawable via artUriPara/artUriLocal, but the "now playing"
MediaItem built when actually playing something (car tap, phone-initiated
play, folder-queue advance, direct local-track tap) did not, so the car's
now-playing screen still went blank.

Reuse the SAME artUriPara/artUriLocal fallback (already the project's one
selection scheme, mirroring PluriStationArtFallback) at every "now playing"
construction site: reproducirPorMediaId, ServicioAudio.reproducir (now via
the extracted, unit-tested mediaItemParaEmisora), construirMediaItemColaLocal
and reproducirPistaLocal.

Guard the reverse direction too: emisoraDesdeMediaItem (extracted from the
handler's private method, now unit-tested) only reflects artUri back into
Emisora.favicon when it passes faviconUsable, so the phone UI's
CachedNetworkImage widgets never attempt a doomed fetch of the car's
android.resource:// fallback URI -- they keep falling back to
PluriStationArtFallback exactly as before.
This commit is contained in:
2026-07-31 00:47:26 +02:00
parent 6822432a51
commit 1b0bea5492
4 changed files with 298 additions and 25 deletions
+14 -4
View File
@@ -836,10 +836,12 @@ Future<void> reproducirPorMediaId(
title: emisora.nombre, title: emisora.nombre,
artist: emisora.pais ?? '', artist: emisora.pais ?? '',
album: 'PluriWave', album: 'PluriWave',
artUri: // Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
emisora.favicon != null && emisora.favicon!.isNotEmpty // itemEmisora already applies) so the "now playing" media item never
? Uri.tryParse(emisora.favicon!) // falls back to a blank tile — a real usable favicon still wins, a
: null, // missing/unusable one gets the on-brand rotating drawable instead of
// `null`.
artUri: Uri.parse(artUriPara(emisora)),
extras: {'uuid': emisora.uuid}, extras: {'uuid': emisora.uuid},
); );
await reproducir(item); await reproducir(item);
@@ -1143,6 +1145,11 @@ Future<MediaItem?> construirMediaItemColaLocal(
id: contentUri, id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId), title: _tituloDesdeDocumentId(nodo.documentId),
album: 'PluriWave', 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}, extras: {'documentId': nodo.documentId},
); );
} }
@@ -1322,6 +1329,9 @@ Future<void> reproducirPistaLocal(
id: pista.contentUri, id: pista.contentUri,
title: pista.titulo, title: pista.titulo,
album: 'PluriWave', 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}, extras: {'documentId': pista.documentId},
); );
await reproducir(item); await reproducir(item);
+47 -21
View File
@@ -60,6 +60,46 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
_fuenteMusicaLocalGlobal = 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. /// Wrapper de alto nivel para el UI.
class ServicioAudio { class ServicioAudio {
PluriWaveAudioHandler get _handler { PluriWaveAudioHandler get _handler {
@@ -94,19 +134,9 @@ class ServicioAudio {
}); });
Future<void> reproducir(Emisora emisora) async { Future<void> reproducir(Emisora emisora) async {
final item = MediaItem( final item = mediaItemParaEmisora(
id: emisora.url, emisora,
title: localizedStationName( l10n: lookupAppLocalizations(const Locale('es')),
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},
); );
await _handler.playMediaItem(item); await _handler.playMediaItem(item);
} }
@@ -911,14 +941,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
} }
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; // Item 3: delegates to the top-level, unit-testable function so the
return Emisora( // `faviconUsable` guard (never reflect the on-brand fallback artUri
uuid: uuid, // back as a real favicon) is covered without instantiating the handler.
nombre: mediaItem.title, return emisoraDesdeMediaItem(mediaItem);
url: mediaItem.id,
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
favicon: mediaItem.artUri?.toString(),
);
} }
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's // ── Android Auto browsing (thin delegation to navegacion_auto.dart's
+98
View File
@@ -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( test(
'id obsoleto/desconocido (uriContenidoDePista devuelve null) no ' 'id obsoleto/desconocido (uriContenidoDePista devuelve null) no '
'llama a reproducir ni lanza excepción', 'llama a reproducir ni lanza excepción',
@@ -2494,6 +2516,27 @@ void main() {
expect(item, isNull); 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', () { group('ConstructorArbolAuto.itemEmisora', () {
@@ -2866,6 +2909,61 @@ void main() {
expect(llamadas, 0); 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 ─────────────────────────── // ── Item 1: Android Auto subfolder visibility ───────────────────────────
@@ -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');
});
});
}