Co-authored-by: antoinevandenhurk <35188384+antoinevandenhurk@users.noreply.github.com>
13 KiB
Android Auto Implementation Plan for Ensemble
1. Overview
This document describes adding Android Auto support to Ensemble. When a user connects their Android phone to a car head unit, Android Auto will browse and play music through Ensemble without touching the phone.
What we're building
- A browse hierarchy (Favorites, Playlists, Albums, Radio) accessible from the car screen
- Playback from Android Auto routes through the builtin (local) player, which streams through MA as normal
- Voice search support
Why builtin player only
Android Auto requires local audio output. The builtin player plays audio through just_audio on the device, which the car receives over Bluetooth. Remote MA players are irrelevant in a car context.
2. Architecture
Provider reference
MassivAudioHandler has no reference to MusicAssistantProvider today. The handler is created before the provider (in main() before runApp). Solution: add a setProvider() method and call it from _MusicAssistantAppState.initState().
main.dart
audioHandler = await AudioService.init(...) // global, created first
runApp(MusicAssistantApp())
_MusicAssistantAppState.initState()
_musicProvider = MusicAssistantProvider()
audioHandler.setProvider(_musicProvider) // NEW
Browse → play call flow
Android Auto MassivAudioHandler MusicAssistantProvider
| | |
getChildren('root') -----> | returns category items |
getChildren('cat|playlists')-> | SyncService.cachedPlaylists |
getChildren('playlist|lib|5')-> | provider.getPlaylistTracksWithCache()
| populates _trackQueueCache |
playFromMediaId('track|lib|7|plist|lib|5') -> |
| lookup _trackQueueCache |
| provider.playTracks(builtinId, tracks, startIndex: i)
3. Media ID Scheme
Separator: | (pipe). Provider names use underscores/colons, never pipes — safe.
| ID | Meaning |
|---|---|
root |
Root (browsable) |
recent |
Currently playing track (playable) |
cat|favorites |
Favorites category (browsable) |
cat|playlists |
Playlists category (browsable) |
cat|albums |
Albums category (browsable) |
cat|radio |
Radio stations category (browsable) |
playlist|{provider}|{itemId} |
A playlist (browsable) |
album|{provider}|{itemId} |
An album (browsable) |
track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId} |
A track in context (playable) |
radio|{provider}|{itemId} |
A radio station (playable) |
The ctxType|ctxProvider|ctxId suffix in track IDs encodes the parent container. Context types: plist, album, favs, search.
Track queue cache key = {ctxType}|{ctxProvider}|{ctxId} (e.g. plist|spotify|abc). Stored when building children, looked up in playFromMediaId.
4. Browse Hierarchy
root
├── recent (playable, shown if something is/was playing)
├── cat|favorites (browsable → favorite tracks, all playable)
├── cat|playlists (browsable → list of playlists, each browsable)
│ └── playlist|{provider}|{id} → tracks (playable)
├── cat|albums (browsable → list of albums, each browsable)
│ └── album|{provider}|{id} → tracks (playable)
└── cat|radio (browsable → radio stations, each playable)
Artists are not included. Navigating Artist → Albums → Album → Tracks is four taps — too many while driving. Can be added later.
5. Track Queue Cache
Tapping a track in Android Auto should play the full album/playlist from that track, not just the single track. The handler caches track lists when building children:
// Key: ctxKey e.g. "plist|spotify|abc" or "album|library|42" or "favs||"
// Value: ordered list of tracks in that container
final Map<String, List<ma.Track>> _trackQueueCache = {};
When getChildren('playlist|spotify|abc') is called:
- Fetch tracks via
provider.getPlaylistTracksWithCache('spotify', 'abc') - Store
_trackQueueCache['plist|spotify|abc'] = tracks - Return each track as a playable
MediaItemwith idtrack|{t.provider}|{t.itemId}|plist|spotify|abc
When playFromMediaId('track|lib|7|plist|spotify|abc') is called:
- Parse ctxKey =
plist|spotify|abc - Look up
_trackQueueCache['plist|spotify|abc'] - Find track index where
itemId == '7' - Call
provider.playTracks(builtinId, tracks, startIndex: index)
6. Key Code for massiv_audio_handler.dart
New imports
import 'package:rxdart/rxdart.dart';
import '../../providers/music_assistant_provider.dart';
import '../settings_service.dart';
import '../sync_service.dart';
import '../../models/media_item.dart' as ma;
Note: audio_service exports its own MediaItem. Use ma.MediaItem, ma.Track, ma.Album, ma.Playlist for app models. The bare MediaItem refers to the audio_service type throughout new methods.
New fields
MusicAssistantProvider? _provider;
final Map<String, ma.Track> _trackQueueCache = {}; // ctxKey -> tracks (see section 5)
final Map<String, BehaviorSubject<Map<String, dynamic>>> _childrenSubjects = {};
void setProvider(MusicAssistantProvider provider) {
_provider = provider;
}
getChildren
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
final provider = _provider;
if (provider == null) return [];
switch (parentMediaId) {
case AudioService.browsableRootId:
return _buildRoot(provider);
case 'cat|favorites':
return _buildFavorites(provider);
case 'cat|playlists':
return _buildPlaylistList(provider);
case 'cat|albums':
return _buildAlbumList(provider);
case 'cat|radio':
return _buildRadioList(provider);
default:
if (parentMediaId.startsWith('playlist|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildPlaylistTracks(provider, parts[1], parts[2]);
}
if (parentMediaId.startsWith('album|')) {
final parts = parentMediaId.split('|');
if (parts.length >= 3) return _buildAlbumTracks(provider, parts[1], parts[2]);
}
return [];
}
}
playFromMediaId
@override
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null) return;
final playerId = await SettingsService.getBuiltinPlayerId();
if (playerId == null) return;
_logger.log('AndroidAuto: playFromMediaId $mediaId');
try {
if (mediaId == 'recent') {
await provider.playPauseSelectedPlayer();
return;
}
if (mediaId.startsWith('radio|')) {
final parts = mediaId.split('|'); // ['radio', provider, itemId]
if (parts.length < 3) return;
final station = provider.radioStations.firstWhere(
(s) => s.provider == parts[1] && s.itemId == parts[2],
orElse: () => throw Exception('Station not found: $mediaId'),
);
await provider.playRadio(playerId, station);
return;
}
if (mediaId.startsWith('track|')) {
// Format: track|{tProvider}|{tItemId}|{ctxType}|{ctxProvider}|{ctxId}
final parts = mediaId.split('|');
if (parts.length < 6) return;
final tProvider = parts[1];
final tItemId = parts[2];
final ctxKey = parts.sublist(3).join('|');
final trackList = _trackQueueCache[ctxKey];
if (trackList == null || trackList.isEmpty) {
// Fallback: play single track (minimal Track object)
await provider.playTrack(playerId, ma.Track(itemId: tItemId, provider: tProvider, name: ''));
return;
}
final index = trackList.indexWhere((t) => t.provider == tProvider && t.itemId == tItemId);
await provider.playTracks(playerId, trackList, startIndex: index < 0 ? 0 : index);
return;
}
} catch (e) {
_logger.log('AndroidAuto: playFromMediaId error: $e');
}
}
search
@override
Future<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) async {
final provider = _provider;
if (provider == null || query.trim().isEmpty) return [];
try {
final results = await provider.searchWithCache(query);
final tracks = (results['tracks'] ?? []).whereType<ma.Track>().toList();
const ctxKey = 'search||';
_trackQueueCache[ctxKey] = tracks;
return tracks.map((t) => MediaItem(
id: 'track|${t.provider}|${t.itemId}|$ctxKey',
title: t.name,
artist: t.artistsString,
album: t.album?.name,
duration: t.duration,
artUri: _artUri(provider, t),
playable: true,
)).toList();
} catch (e) {
_logger.log('AndroidAuto: search error: $e');
return [];
}
}
subscribeToChildren
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) {
_childrenSubjects[parentMediaId] ??= BehaviorSubject.seeded({});
return _childrenSubjects[parentMediaId]!.stream;
}
_artUri helper
Uri? _artUri(MusicAssistantProvider provider, ma.MediaItem item) {
final url = provider.getImageUrl(item, size: 256);
return url != null ? Uri.tryParse(url) : null;
}
7. Files to Change
pubspec.yaml
Add rxdart: ^0.28.0 as direct dependency. The ^0.28.0 constraint matches the already-resolved transitive version so no upgrade occurs.
android/app/build.gradle
Change:
minSdkVersion flutter.minSdkVersion
To:
minSdkVersion 23
Android Auto requires API 23+. flutter.minSdkVersion resolves to 21 — too low.
android/app/src/main/AndroidManifest.xml
Add inside <application> tag:
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
The MediaBrowserService intent filter on AudioService is already present — no changes needed there.
android/app/src/main/res/xml/automotive_app_desc.xml (NEW)
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>
lib/main.dart
In _MusicAssistantAppState.initState(), after creating _musicProvider:
audioHandler.setProvider(_musicProvider);
lib/services/audio/massiv_audio_handler.dart
Add ~200 lines. New imports, fields, setProvider(), getChildren(), playFromMediaId(), search(), subscribeToChildren(), and private builder helpers. All existing code is untouched.
8. Testing with DHU (No Car Required)
Install DHU
In Android Studio: SDK Manager → SDK Tools → Android Auto Desktop Head Unit Emulator.
Or via command line:
sdkmanager "extras;google;auto"
Enable Android Auto developer mode on phone
- Settings → Apps → Android Auto → tap version number 10 times
- In Android Auto developer settings: enable "Unknown sources"
Run the test session
# Terminal 1: forward the port
adb forward tcp:5277 tcp:5277
# Terminal 2: start the DHU
$ANDROID_SDK_ROOT/extras/google/auto/desktop-head-unit
In Android Auto developer settings on phone: tap "Start head unit server".
Build and install
flutter build apk --debug
adb install build/app/outputs/flutter-apk/app-debug.apk
Launch Ensemble on phone first (so audio service starts). Then tap the media icon in DHU — Ensemble should appear.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| App not visible in DHU | Missing automotive_app_desc.xml or wrong meta-data name |
Check manifest and XML |
| App visible, browse is empty | setProvider not yet called |
Check initState wiring |
| Build fails | minSdkVersion too low |
Check build.gradle |
| Track plays but wrong song | _trackQueueCache miss |
Ensure getChildren ran before playFromMediaId |
9. Implementation Order
Do these steps in order to test incrementally:
- Android config files (
build.gradle,AndroidManifest.xml,automotive_app_desc.xml) — verify app appears in DHU before writing Dart pubspec.yaml+flutter pub getmassiv_audio_handler.dart: addsetProvider, fields,subscribeToChildren— minimum to not crashmain.dart: wiresetProvidermassiv_audio_handler.dart:getChildrenwith hardcoded root categories — verify tree appears in DHUmassiv_audio_handler.dart: add all_build*helpers one by one, test eachmassiv_audio_handler.dart: addplayFromMediaId— test track playbackmassiv_audio_handler.dart: addsearch— test voice search
10. Not In Scope
- Artists browse — too many taps for driving; albums alone suffice
- Audiobooks/podcasts — different UX needs; add later
- Queue management from Android Auto — MA queue model is complex
- Push updates —
notifyChildrenChangedstubbed but not wired to sync events - Android Automotive OS — different platform, requires a separate flavor