100 lines
8.0 KiB
Markdown
100 lines
8.0 KiB
Markdown
# Design: Android Auto (Projected) Media Browsing
|
|
|
|
## Technical Approach
|
|
|
|
Reuse the existing `PluriWaveAudioHandler` (main isolate, `audio_service 0.18`) and add only the browse layer Android Auto needs: three overrides (`getChildren`, `getMediaItem`, `playFromMediaId`) fed by a dedicated, cold-start-safe data source, plus the manifest declaration. Playback still flows through the untouched internal `playMediaItem` (servicio_audio.dart:433). A pure tree builder makes the logic unit-testable without a running car or platform. Realises capability `android-auto-media`; phone playback path is unchanged.
|
|
|
|
## Architecture Decisions
|
|
|
|
### Decision: getChildren data source (cold-start safe)
|
|
|
|
**Choice**: Introduce `FuenteEmisorasAuto` — a small browse-source abstraction registered into the handler via `registrarFuenteNavegacion(...)` (mirrors `registrarHandler`). Production impl reads **local** data directly: favourites from `ServicioFavoritos` (SQLite) and custom stations from the JSON file — both loadable without the network or the widget tree. `EstadoRadio`, when alive, pushes its in-memory lists as a **live snapshot** the source prefers; on a cold Auto bind it falls back to a direct local read.
|
|
**Alternatives considered**: Inject `EstadoRadio` directly into the handler; a callback registered by `EstadoRadio` on init.
|
|
**Rationale**: `ChangeNotifierProvider.create:` is **lazy** (app.dart:41) — a headless Auto bind runs `main()` but may never build `EstadoRadio`, and its `_init()` loads network `populares`. Depending on it would give the car an empty or blocked tree. Favourites+custom are local and reliable; the snapshot keeps car and phone identical when both are live. Provider architecture stays intact.
|
|
|
|
### Decision: media-id scheme
|
|
|
|
**Choice**: Folders use bare stable constants (`root` = `AudioService.browsableRootId`, `favoritos`, `todas`, `mis_emisoras`); stations use `emisora:<uuid>`.
|
|
**Alternatives considered**: Reuse the stream URL as id (as the phone MediaItem does); numeric SQLite `id`.
|
|
**Rationale**: The `emisora:` prefix is collision-free against folder ids and against the raw-URL ids the app uses internally; `uuid` is the stable cross-source key (`Emisora.==` is uuid-based). `playFromMediaId` parses the uuid, looks it up in the source, and builds the real `MediaItem` (id = `emisora.url`, `extras['uuid']`) exactly like the phone.
|
|
|
|
### Decision: default artwork delivery
|
|
|
|
**Choice**: Stations with a favicon use it (http(s), already loadable). Logo-less stations get `android.resource://es.freetimelab.pluriwave/drawable/default_station_art` — a bundled `res/drawable` PNG.
|
|
**Alternatives considered**: content:// via the configured FileProvider (copy asset → build URI); remote placeholder URL; folder-art-only.
|
|
**Rationale**: `android.resource://` is loaded by `ContentResolver` with **no per-URI grant**, works offline, and cannot 404 — unlike FileProvider content URIs (need `FLAG_GRANT_READ_URI_PERMISSION` for the system art loader) or a remote URL (offline/quality-gate risk). Flutter `assets/` are **not** reachable via `android.resource`, so the PNG lives in `res/drawable`. FileProvider (files-path root → segment `files`) remains the documented fallback if a loader rejects `android.resource`.
|
|
|
|
### Decision: which stations surface & ordering
|
|
|
|
**Choice**: `Favoritos` (SQLite), `Mis emisoras` (custom file), `Todas` = top `populares` snapshot when available. Each folder sorted by `ordenarEmisoras(_, ordenListas)` and **capped at 50**.
|
|
**Rationale**: User-curated/local lists are reliable in a car (Google tests playback); a capped list avoids driver-distraction and Auto list limits. `Todas` degrades gracefully to empty-but-valid on cold bind.
|
|
|
|
### Decision: playback coherence with EstadoRadio
|
|
|
|
**Choice**: `playFromMediaId` delegates to internal `playMediaItem`. `EstadoRadio` already subscribes to `audio.estadoStream` and `emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual`; extend that listener to reconcile `_emisoraSeleccionada = audio.emisoraActual` on a car-initiated change so it does not **shadow** the car's station.
|
|
**Rationale**: Reuse over duplication; the one-line reconcile keeps the mini-player/current-station display correct when playback starts from the car.
|
|
|
|
### Decision: content style (optional)
|
|
|
|
**Choice**: Set `CONTENT_STYLE_*` extras — grid (2) for playable stations, list (1) for root folders. Non-blocking polish.
|
|
|
|
## Data Flow
|
|
|
|
Car (MediaBrowser) ──getChildren──▶ Handler ──▶ FuenteEmisorasAuto
|
|
│ ├─ live snapshot (EstadoRadio, if alive)
|
|
│ └─ local read (SQLite favs + custom file) ← cold bind
|
|
Car (tap) ──playFromMediaId(emisora:uuid)──▶ Handler ──lookup──▶ Emisora ──▶ playMediaItem (unchanged)
|
|
│
|
|
EstadoRadio ◀── audio.estadoStream ── PlaybackState ───┘ (reconciles _emisoraSeleccionada)
|
|
|
|
## File Changes
|
|
|
|
| File | Action | Description |
|
|
|------|--------|-------------|
|
|
| `android/app/src/main/res/xml/automotive_app_desc.xml` | Create | `<automotiveApp><uses name="media"/></automotiveApp>` |
|
|
| `android/app/src/main/AndroidManifest.xml` | Modify | Add `com.google.android.gms.car.application` meta-data |
|
|
| `android/app/src/main/res/drawable/default_station_art.png` | Create | Bundled default station artwork |
|
|
| `lib/servicios/navegacion_auto.dart` | Create | `FuenteEmisorasAuto` + local impl, id constants, pure `ConstructorArbolAuto` (tree/leaf builder, art fallback) |
|
|
| `lib/servicios/servicio_audio.dart` | Modify | Override `getChildren`/`getMediaItem`/`playFromMediaId`; `registrarFuenteNavegacion`; delegate to `playMediaItem` |
|
|
| `lib/estado/estado_radio.dart` | Modify | Push live snapshot to source; reconcile `_emisoraSeleccionada` on car-initiated playback |
|
|
| `lib/main.dart` | Modify | Build + register the local browse source |
|
|
| `test/servicios/navegacion_auto_test.dart` | Create | Tree, id resolution, art fallback, routing tests |
|
|
|
|
## Interfaces / Contracts
|
|
|
|
```dart
|
|
abstract class FuenteEmisorasAuto {
|
|
Future<List<Emisora>> favoritos();
|
|
Future<List<Emisora>> misEmisoras();
|
|
Future<List<Emisora>> todas(); // populares snapshot; may be empty (cold)
|
|
Future<Emisora?> porUuid(String uuid);
|
|
}
|
|
|
|
class ConstructorArbolAuto { // pure, no platform
|
|
List<MediaItem> raiz(); // 3 folder MediaItems (playable:false)
|
|
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras});
|
|
MediaItem itemEmisora(Emisora e); // id 'emisora:<uuid>', title, artUri fallback
|
|
Emisora? resolver(String id, List<Emisora> universo);
|
|
static const idFavoritos = 'favoritos', idTodas = 'todas', idMisEmisoras = 'mis_emisoras';
|
|
}
|
|
```
|
|
|
|
## Testing Strategy
|
|
|
|
| Layer | What to Test | Approach |
|
|
|-------|-------------|----------|
|
|
| Unit | Root returns 3 folders (ids/titles/`playable:false`) | Fake `FuenteEmisorasAuto`; assert `getChildren(root)` |
|
|
| Unit | Leaf ids `emisora:<uuid>`, title+artUri always set; favicon vs default art fallback | `ConstructorArbolAuto.itemEmisora` |
|
|
| Unit | `getMediaItem`/`resolver` maps id→Emisora; unknown→null | Pure builder assertions |
|
|
| Unit | `playFromMediaId` builds MediaItem (id=url, extras uuid) and delegates to `playMediaItem` | Spy/seam over `playMediaItem` (existing test pattern) |
|
|
| Manual (DHU) | Discovery, real art render, playback, play/pause car↔phone sync, grid/list | User-side, not `flutter test` |
|
|
|
|
## Migration / Rollout
|
|
|
|
No migration. Additive: revert deletes the XML, the meta-data line, the drawable, the new file, and the three overrides — phone path untouched, zero residual state.
|
|
|
|
## Open Questions
|
|
|
|
- [ ] Confirm the system art loader accepts `android.resource://`; else switch logo-less default to FileProvider content URI (`content://…/files/auto/default_station_art.png`).
|
|
- [ ] `Todas` on a cold bind shows only if a snapshot exists — accept empty folder, or trigger a lightweight local populares cache? (defer)
|