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:
co-authored by
Claude Sonnet 5
parent
4c116eb84f
commit
53d4a73309
@@ -20,6 +20,15 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
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
|
||||
android:name=".MainActivity"
|
||||
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]
|
||||
* 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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
Reference in New Issue
Block a user