fix(auto): fall back to brand art and surface quality on dead/missing favicons
Android Auto no longer copies the launcher icon as placeholder art; it rotates through the same 4 on-brand station_art assets the phone UI already uses, keyed by the same per-station hash for visual parity. Malformed or unusable favicon URLs (including a Dart Uri quirk where 'http://' reports hasAuthority=true with an empty host) now fail the validity gate instead of being handed to the OS media browser as-is. Browsable items also show codec/bitrate as a subtitle when known.
This commit is contained in:
@@ -9,15 +9,75 @@ import '../modelos/emisora.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
|
||||
/// URI of the bundled default station artwork, served from
|
||||
/// `android/app/src/main/res/drawable` via `android.resource://` (Design
|
||||
/// "default artwork delivery" — no per-URI grant needed, works offline, and
|
||||
/// cannot 404 unlike a FileProvider content URI or a remote placeholder).
|
||||
const String _defaultArtUri =
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/default_station_art';
|
||||
|
||||
const _prefijoEmisora = 'emisora:';
|
||||
|
||||
/// Canonical on-brand fallback-art names and rotation order, ported
|
||||
/// **verbatim** (same formula, same order) from
|
||||
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
|
||||
/// to guarantee phone/car per-station art parity (Design "Fallback-art
|
||||
/// selection"). Keep this list in sync with that one — there is no
|
||||
/// structural enforcement of order, only this comment and the parity test
|
||||
/// in `navegacion_auto_test.dart` (group `parity: phone/auto art order`).
|
||||
const _nombresArte = ['aurora', 'cosmic', 'pulse', 'nova'];
|
||||
|
||||
/// Returns whether [favicon] is usable as a remote `artUri` (Design
|
||||
/// Decision "Case B detection" — static validity gate, zero network):
|
||||
/// non-null, non-blank after trimming, and parses as an absolute
|
||||
/// `http`/`https` URI with a non-empty authority. Does **not** probe
|
||||
/// reachability — the OS art loader fetches `artUri` independently and
|
||||
/// later, so a build-time network check would be racy (TOCTOU); this only
|
||||
/// catches the deterministic malformed/non-http(s) subset (bare hosts,
|
||||
/// wrong scheme, `http://` with no authority, whitespace, unparseable).
|
||||
bool faviconUsable(String? favicon) {
|
||||
final trimmed = favicon?.trim();
|
||||
if (trimmed == null || trimmed.isEmpty) return false;
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null) return false;
|
||||
// `Uri.hasAuthority` is true whenever a `//` authority slot is present,
|
||||
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
|
||||
// `true`) — check `host.isNotEmpty` explicitly to actually require a
|
||||
// non-empty authority host.
|
||||
return (uri.scheme == 'http' || uri.scheme == 'https') &&
|
||||
uri.host.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Deterministic rotation index over the 4 on-brand fallback arts, same
|
||||
/// formula as `tarjeta_emisora.dart`'s `_fallbackArtFor` (Design
|
||||
/// "Fallback-art selection — port verbatim"): `seed` is the station uuid.
|
||||
int indiceArtePara(String seed) =>
|
||||
seed.codeUnits.fold<int>(0, (a, b) => a + b) % _nombresArte.length;
|
||||
|
||||
/// Resolves the `artUri` for [e] (Design "Data Flow"): the favicon when it
|
||||
/// passes [faviconUsable], otherwise a rotating `station_art_<name>`
|
||||
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
|
||||
/// on-brand art the phone UI would pick for this station (per-station
|
||||
/// parity), never a launcher-icon lookalike.
|
||||
String artUriPara(Emisora e) => faviconUsable(e.favicon)
|
||||
? e.favicon!
|
||||
: 'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
|
||||
|
||||
/// Formats a human-readable audio-quality hint for the browse row's
|
||||
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
|
||||
/// `"<bitrate> kbps · <CODEC>"` when both are known, just the bitrate or
|
||||
/// just the codec when only one is known, and `null` (never `""`, never a
|
||||
/// string containing the literal `"null"`) when both are unknown. Codec is
|
||||
/// trimmed and upper-cased; blank-after-trim counts as unknown. `bitrate`
|
||||
/// `<= 0` counts as unknown (Radio Browser stores `0` for unknown).
|
||||
String? subtituloCalidad(Emisora e) {
|
||||
final codec = e.codec?.trim();
|
||||
final codecConocido = codec != null && codec.isNotEmpty;
|
||||
final bitrate = e.bitrate;
|
||||
final bitrateConocido = bitrate != null && bitrate > 0;
|
||||
|
||||
if (codecConocido && bitrateConocido) {
|
||||
return '$bitrate kbps · ${codec.toUpperCase()}';
|
||||
}
|
||||
if (bitrateConocido) return '$bitrate kbps';
|
||||
if (codecConocido) return codec.toUpperCase();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Browse-source abstraction for the Android Auto media tree (Design
|
||||
/// "getChildren data source, cold-start safe"). Kept separate from
|
||||
/// `EstadoRadio` so a headless Auto bind (`main()` runs but the widget tree
|
||||
@@ -97,21 +157,19 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
|
||||
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
|
||||
/// (Design "media-id scheme"), title, and artUri with the default-art
|
||||
/// fallback (Design "default artwork delivery").
|
||||
/// (Design "media-id scheme"), title, on-brand-fallback-aware `artUri`
|
||||
/// (Design "Case B detection" + "Fallback-art selection") and a
|
||||
/// quality-hint `displaySubtitle` (Design "`displaySubtitle` quality
|
||||
/// format") when codec/bitrate are known.
|
||||
MediaItem itemEmisora(Emisora e) => MediaItem(
|
||||
id: '$_prefijoEmisora${e.uuid}',
|
||||
title: e.nombre,
|
||||
playable: true,
|
||||
artUri: Uri.parse(_artUriPara(e)),
|
||||
artUri: Uri.parse(artUriPara(e)),
|
||||
displaySubtitle: subtituloCalidad(e),
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
|
||||
String _artUriPara(Emisora e) {
|
||||
final favicon = e.favicon;
|
||||
return (favicon != null && favicon.isNotEmpty) ? favicon : _defaultArtUri;
|
||||
}
|
||||
|
||||
/// Resolves `emisora:<uuid>` ids to the matching [Emisora] in [universo].
|
||||
/// Any other shape (no prefix, empty uuid, unmatched uuid) returns `null`
|
||||
/// instead of throwing (Spec "Media Item Resolution by ID").
|
||||
|
||||
Reference in New Issue
Block a user