From 53d4a73309fe1664cf5e061be48d2dfbb9f7ee43 Mon Sep 17 00:00:00 2001 From: christopher Date: Thu, 17 Sep 2026 19:02:48 -0400 Subject: [PATCH] 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 --- app/src/main/AndroidManifest.xml | 9 + .../deepwave/media/BrowseTree.kt | 191 ++++++++++++++++++ .../deepwave/media/DeepwavePlaybackService.kt | 12 +- app/src/main/res/xml/automotive_app_desc.xml | 4 + 4 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/InfernalAquatics/deepwave/media/BrowseTree.kt create mode 100644 app/src/main/res/xml/automotive_app_desc.xml 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 @@ + + + +