diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6747fb3..ec70b80 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,6 +20,15 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Deepwave">
+
+
+
+
[Playlists, Artists, Albums, Downloaded] -> drill-down -> tracks. Built entirely on
+ * the existing repositories - no new Subsonic calls. Installed as
+ * [DeepwavePlaybackService]'s [MediaLibrarySession.Callback], so the phone app's own UI never
+ * touches this; it's presentation-only for external browsers.
+ */
+@UnstableApi
+@Singleton
+class BrowseTree @Inject constructor(
+ private val libraryRepository: LibraryRepository,
+ private val playlistRepository: PlaylistRepository,
+ private val downloadRepository: DownloadRepository,
+ private val serverRepository: ServerRepository,
+) : MediaLibrarySession.Callback {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ override fun onGetLibraryRoot(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ params: LibraryParams?,
+ ): ListenableFuture> =
+ Futures.immediateFuture(LibraryResult.ofItem(browsableItem(ROOT_ID, "Deepwave"), params))
+
+ override fun onGetChildren(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ parentId: String,
+ page: Int,
+ pageSize: Int,
+ params: LibraryParams?,
+ ): ListenableFuture>> = scope.future {
+ LibraryResult.ofItemList(ImmutableList.copyOf(children(parentId)), params)
+ }
+
+ /**
+ * A browsed [MediaItem]'s URI is stripped crossing into another process (Android Auto's own,
+ * here) - only [MediaItem.mediaMetadata] survives. When the car sends one back to actually
+ * play it, this rebuilds a streamable URI from the id, reusing whatever metadata (title,
+ * artist, artwork) is still attached rather than re-fetching it.
+ */
+ override fun onAddMediaItems(
+ mediaSession: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ mediaItems: List,
+ ): ListenableFuture> {
+ val resolved = mediaItems.map { item ->
+ val songId = songIdFromMediaId(item.mediaId)
+ if (songId == null) item else item.buildUpon().setUri(streamUrl(songId)).build()
+ }
+ return Futures.immediateFuture(resolved)
+ }
+
+ override fun onSearch(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ query: String,
+ params: LibraryParams?,
+ ): ListenableFuture> = scope.future {
+ val results = libraryRepository.search(query)
+ session.notifySearchResultChanged(browser, query, results.songs.size, params)
+ LibraryResult.ofVoid()
+ }
+
+ override fun onGetSearchResult(
+ session: MediaLibrarySession,
+ browser: MediaSession.ControllerInfo,
+ query: String,
+ page: Int,
+ pageSize: Int,
+ params: LibraryParams?,
+ ): ListenableFuture>> = scope.future {
+ val results = libraryRepository.search(query)
+ LibraryResult.ofItemList(ImmutableList.copyOf(results.songs.map { it.toMediaItem() }), params)
+ }
+
+ private suspend fun children(parentId: String): List = when {
+ parentId == ROOT_ID -> rootChildren()
+ parentId == CATEGORY_PLAYLISTS -> playlistRepository.observePlaylists().first().map { playlist ->
+ browsableItem("$PREFIX_PLAYLIST${playlist.id}", playlist.name, coverArtUrl(playlist.coverArtId))
+ }
+ parentId == CATEGORY_ARTISTS -> libraryRepository.getArtists().map { artist ->
+ browsableItem("$PREFIX_ARTIST${artist.id}", artist.name, coverArtUrl(artist.coverArtId))
+ }
+ parentId == CATEGORY_ALBUMS -> libraryRepository.getAlbumList(AlbumListType.NEWEST, size = 50).map(::albumItem)
+ parentId == CATEGORY_DOWNLOADED -> downloadRepository.downloadedTracks().first()
+ .filter { it.status == DownloadStatus.COMPLETE }
+ .map { it.toMediaItem() }
+ parentId.startsWith(PREFIX_PLAYLIST) ->
+ playlistRepository.observeTracks(parentId.removePrefix(PREFIX_PLAYLIST)).first().map { it.toMediaItem() }
+ parentId.startsWith(PREFIX_ARTIST) ->
+ libraryRepository.getArtistDetail(parentId.removePrefix(PREFIX_ARTIST)).second.map(::albumItem)
+ parentId.startsWith(PREFIX_ALBUM) ->
+ libraryRepository.getAlbumDetail(parentId.removePrefix(PREFIX_ALBUM)).second.map { it.toMediaItem() }
+ else -> emptyList()
+ }
+
+ private suspend fun rootChildren(): List {
+ if (!serverRepository.isLoggedIn.first()) {
+ return listOf(browsableItem(NOT_LOGGED_IN_ID, "Log in on your phone first", playable = false))
+ }
+ return listOf(
+ browsableItem(CATEGORY_PLAYLISTS, "Playlists"),
+ browsableItem(CATEGORY_ARTISTS, "Artists"),
+ browsableItem(CATEGORY_ALBUMS, "Albums"),
+ browsableItem(CATEGORY_DOWNLOADED, "Downloaded"),
+ )
+ }
+
+ private fun albumItem(album: Album) = browsableItem(
+ id = "$PREFIX_ALBUM${album.id}",
+ title = album.name,
+ artworkUrl = coverArtUrl(album.coverArtId),
+ subtitle = album.artistName,
+ )
+
+ private fun browsableItem(
+ id: String,
+ title: String,
+ artworkUrl: String? = null,
+ subtitle: String? = null,
+ playable: Boolean = false,
+ ): MediaItem {
+ val metadata = MediaMetadata.Builder()
+ .setTitle(title)
+ .setArtist(subtitle)
+ .setIsBrowsable(!playable)
+ .setIsPlayable(playable)
+ .apply { artworkUrl?.let { setArtworkUri(Uri.parse(it)) } }
+ .build()
+ return MediaItem.Builder().setMediaId(id).setMediaMetadata(metadata).build()
+ }
+
+ /** Bridges a suspend block to the [ListenableFuture] Media3's callbacks require, with no new
+ * dependency - [SettableFuture] is already transitively on the classpath via media3-session. */
+ private fun CoroutineScope.future(block: suspend () -> T): ListenableFuture {
+ val future = SettableFuture.create()
+ launch {
+ try {
+ future.set(block())
+ } catch (e: Exception) {
+ future.setException(e)
+ }
+ }
+ return future
+ }
+
+ private companion object {
+ const val ROOT_ID = "root"
+ const val NOT_LOGGED_IN_ID = "not_logged_in"
+ const val CATEGORY_PLAYLISTS = "playlists"
+ const val CATEGORY_ARTISTS = "artists"
+ const val CATEGORY_ALBUMS = "albums"
+ const val CATEGORY_DOWNLOADED = "downloaded"
+ const val PREFIX_PLAYLIST = "playlist:"
+ const val PREFIX_ARTIST = "artist:"
+ const val PREFIX_ALBUM = "album:"
+ }
+}
diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt
index 7987ca4..44d54ac 100644
--- a/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt
+++ b/app/src/main/java/com/InfernalAquatics/deepwave/media/DeepwavePlaybackService.kt
@@ -25,9 +25,9 @@ import javax.inject.Inject
/**
* Owns the app's single [ExoPlayer] + [MediaLibrarySession]. Built as a [MediaLibraryService]
- * from day one (rather than the plainer [androidx.media3.session.MediaSessionService]) even
- * though the browse tree is a stub for now - it's a supertype-compatible superset, so Android
- * Auto (a later phase) only has to add browse-tree content here, never rebuild the service.
+ * from day one (rather than the plainer [androidx.media3.session.MediaSessionService]), which
+ * paid off in Phase 6.5: [BrowseTree] only had to add browse-tree content to this same session,
+ * never rebuild the service, to get Android Auto working.
*/
@UnstableApi
@AndroidEntryPoint
@@ -36,6 +36,7 @@ class DeepwavePlaybackService : MediaLibraryService() {
@Inject lateinit var okHttpClient: OkHttpClient
@Inject lateinit var localOrRemoteDataSource: LocalOrRemoteDataSource
@Inject lateinit var subsonicApi: SubsonicApi
+ @Inject lateinit var browseTree: BrowseTree
private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession
@@ -66,7 +67,7 @@ class DeepwavePlaybackService : MediaLibraryService() {
PendingIntent.FLAG_IMMUTABLE,
)
- mediaLibrarySession = MediaLibrarySession.Builder(this, player, StubLibrarySessionCallback())
+ mediaLibrarySession = MediaLibrarySession.Builder(this, player, browseTree)
.setSessionActivity(sessionActivityIntent)
.build()
}
@@ -81,9 +82,6 @@ class DeepwavePlaybackService : MediaLibraryService() {
super.onDestroy()
}
- /** Root/browse tree is filled in when Android Auto is added; until then this just denies browsing. */
- private class StubLibrarySessionCallback : MediaLibrarySession.Callback
-
/**
* `submission=false` on transition-in marks the new song "now playing"; `submission=true`
* on transition-out records the play. A simplified heuristic (scrobble the outgoing item
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..e64f442
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,4 @@
+
+
+
+