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 @@ + + + +