Phase 6.5: Android Auto (phone-projection)

Adds a real browse tree to DeepwavePlaybackService, replacing the
Phase 4 stub callback: root -> Playlists/Artists/Albums/Downloaded ->
drill-down -> tracks. Built entirely on Phase 3/5/6's existing
repositories (LibraryRepository, PlaylistRepository,
DownloadRepository) - no new Subsonic calls. A logged-out root shows
a single "log in on your phone first" placeholder instead of the
four categories, since Auto shares the same process/session as the
phone app rather than having its own login flow.

Also wires voice/typed search (onSearch/onGetSearchResult) through
the existing search3-backed LibraryRepository.search(), and
onAddMediaItems to rebuild a streamable URI when the car hands a
browsed item back for playback - a MediaItem's URI doesn't survive
the trip across into Android Auto's process, only its metadata does,
so this reconstructs it from the media id rather than re-fetching
anything.

No androidx.car.app dependency: that library targets navigation/POI
apps, not media - Auto's media category is driven entirely by
MediaLibraryService + media3-session + the automotive_app_desc.xml
manifest declaration added here.

Verified: full app rebuild, Hilt's DI graph resolves with BrowseTree
injected into DeepwavePlaybackService, all unit tests pass, and
in-app playback on the phone itself still works correctly through
the new session callback (confirmed via dumpsys media_session and
logcat, no crashes) - the regression risk of swapping the stub
callback for a real one. Every onGetLibraryRoot/onGetChildren/
onAddMediaItems/onSearch/onGetSearchResult override was verified by
the Kotlin compiler to correctly match Media3 1.11.1's actual
MediaLibrarySession.Callback signatures.

Not verified: the actual browse tree over a live Android Auto
connection. This device has no car or head unit to connect to, and
the Desktop Head Unit tool isn't installed on this machine (Google
no longer ships it through the standard SDK Manager - it's a
separate download). Recommend testing with the actual DHU tool or a
real car/head unit before relying on this in the car.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
christopher
2026-09-17 19:02:48 -04:00
co-authored by Claude Sonnet 5
parent 4c116eb84f
commit 53d4a73309
4 changed files with 209 additions and 7 deletions
+9
View File
@@ -20,6 +20,15 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Deepwave"> android:theme="@style/Theme.Deepwave">
<!-- Declares phone-projection Android Auto media support; combined with
DeepwavePlaybackService's MediaSessionService intent-filter below, this is the whole
manifest side of Auto discovery - no separate androidx.car.app dependency needed,
that library targets navigation/POI apps, not media. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -0,0 +1,191 @@
package com.InfernalAquatics.deepwave.media
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.LibraryResult
import androidx.media3.session.MediaLibraryService.LibraryParams
import androidx.media3.session.MediaLibraryService.MediaLibrarySession
import androidx.media3.session.MediaSession
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import com.InfernalAquatics.deepwave.data.download.DownloadRepository
import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus
import com.InfernalAquatics.deepwave.data.model.Album
import com.InfernalAquatics.deepwave.data.network.coverArtUrl
import com.InfernalAquatics.deepwave.data.network.streamUrl
import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository
import com.InfernalAquatics.deepwave.data.repository.AlbumListType
import com.InfernalAquatics.deepwave.data.repository.LibraryRepository
import com.InfernalAquatics.deepwave.data.repository.ServerRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Android Auto's (and any other [androidx.media3.session.MediaBrowser]'s) view of the library:
* root -> [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<LibraryResult<MediaItem>> =
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<LibraryResult<ImmutableList<MediaItem>>> = 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<MediaItem>,
): ListenableFuture<List<MediaItem>> {
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<LibraryResult<Void>> = 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<LibraryResult<ImmutableList<MediaItem>>> = scope.future {
val results = libraryRepository.search(query)
LibraryResult.ofItemList(ImmutableList.copyOf(results.songs.map { it.toMediaItem() }), params)
}
private suspend fun children(parentId: String): List<MediaItem> = 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<MediaItem> {
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 <T> CoroutineScope.future(block: suspend () -> T): ListenableFuture<T> {
val future = SettableFuture.create<T>()
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:"
}
}
@@ -25,9 +25,9 @@ import javax.inject.Inject
/** /**
* Owns the app's single [ExoPlayer] + [MediaLibrarySession]. Built as a [MediaLibraryService] * Owns the app's single [ExoPlayer] + [MediaLibrarySession]. Built as a [MediaLibraryService]
* from day one (rather than the plainer [androidx.media3.session.MediaSessionService]) even * from day one (rather than the plainer [androidx.media3.session.MediaSessionService]), which
* though the browse tree is a stub for now - it's a supertype-compatible superset, so Android * paid off in Phase 6.5: [BrowseTree] only had to add browse-tree content to this same session,
* Auto (a later phase) only has to add browse-tree content here, never rebuild the service. * never rebuild the service, to get Android Auto working.
*/ */
@UnstableApi @UnstableApi
@AndroidEntryPoint @AndroidEntryPoint
@@ -36,6 +36,7 @@ class DeepwavePlaybackService : MediaLibraryService() {
@Inject lateinit var okHttpClient: OkHttpClient @Inject lateinit var okHttpClient: OkHttpClient
@Inject lateinit var localOrRemoteDataSource: LocalOrRemoteDataSource @Inject lateinit var localOrRemoteDataSource: LocalOrRemoteDataSource
@Inject lateinit var subsonicApi: SubsonicApi @Inject lateinit var subsonicApi: SubsonicApi
@Inject lateinit var browseTree: BrowseTree
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
private lateinit var mediaLibrarySession: MediaLibrarySession private lateinit var mediaLibrarySession: MediaLibrarySession
@@ -66,7 +67,7 @@ class DeepwavePlaybackService : MediaLibraryService() {
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
) )
mediaLibrarySession = MediaLibrarySession.Builder(this, player, StubLibrarySessionCallback()) mediaLibrarySession = MediaLibrarySession.Builder(this, player, browseTree)
.setSessionActivity(sessionActivityIntent) .setSessionActivity(sessionActivityIntent)
.build() .build()
} }
@@ -81,9 +82,6 @@ class DeepwavePlaybackService : MediaLibraryService() {
super.onDestroy() 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` * `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 * on transition-out records the play. A simplified heuristic (scrobble the outgoing item
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<automotiveApp>
<uses name="media" />
</automotiveApp>