feat(android auto): tapping a song plays its whole list from that song
Browse songs now carry their context in the mediaId (songctx:<contextId>|<songId>). A new onSetMediaItems override expands a single tapped song into its full list (Favoriten / album / playlist) and returns it with the tapped index as the start position, so playback continues through the rest instead of stopping after one song. onAddMediaItems shares the same resolver; onGetItem resolves context songs too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
d35fb01306
commit
249b693936
1 file changed
+75
-31
@@ -36,6 +36,9 @@ class MusicService : MediaLibraryService() {
|
||||
// Pseudo-items for play-all / shuffle at top of each list
|
||||
const val PLAY_ALL_PREFIX = "playall:"
|
||||
const val SHUFFLE_PREFIX = "shuffle:"
|
||||
// Encodes a song's browse context so a tap plays the whole list from that song:
|
||||
// "songctx:<contextId>|<songId>" (contextId may itself be "album:…"/"playlist:…").
|
||||
const val SONG_CTX_PREFIX = "songctx:"
|
||||
// ABS session commands (handled via onCustomCommand)
|
||||
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
||||
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
||||
@@ -349,6 +352,10 @@ class MusicService : MediaLibraryService() {
|
||||
.build()
|
||||
).build()
|
||||
}
|
||||
mediaId.startsWith(SONG_CTX_PREFIX) -> {
|
||||
val songId = mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')
|
||||
repo.getSong(songId).toMediaItem(repo).buildUpon().setMediaId(mediaId).build()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||
@@ -360,48 +367,78 @@ class MusicService : MediaLibraryService() {
|
||||
return settable
|
||||
}
|
||||
|
||||
// When Android Auto selects a browsable item (album/playlist), load its songs.
|
||||
// Android Auto "add to queue" path — resolve browsable items to their songs.
|
||||
override fun onAddMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
): ListenableFuture<MutableList<MediaItem>> {
|
||||
val settable = SettableFuture.create<MutableList<MediaItem>>()
|
||||
scope.launch {
|
||||
try { settable.set(expandForPlayback(mediaItems).first.toMutableList()) }
|
||||
catch (e: Exception) { settable.set(mediaItems) }
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Android Auto "tap to play" path — return the resolved list AND the index to start at, so
|
||||
// tapping a song plays its whole list from that song (not just the one song).
|
||||
override fun onSetMediaItems(
|
||||
mediaSession: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
mediaItems: MutableList<MediaItem>,
|
||||
startIndex: Int,
|
||||
startPositionMs: Long,
|
||||
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
|
||||
val settable = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
|
||||
scope.launch {
|
||||
try {
|
||||
val resolved = mediaItems.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
|
||||
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
|
||||
}
|
||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
|
||||
resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), shuffle = true)
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
album.song.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
|
||||
pl.entry.map { song -> song.toMediaItem(repo) }
|
||||
}
|
||||
// A tapped song (e.g. in Favoriten): Android Auto strips the URI
|
||||
// (localConfiguration) when the browse item crosses the process boundary,
|
||||
// so it comes back unplayable → nothing happens. Re-attach the stream URI.
|
||||
else -> {
|
||||
val uri = repo.streamUrl(item.mediaId)?.let { android.net.Uri.parse(it) }
|
||||
listOf(item.buildUpon().setUri(uri).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
settable.set(resolved.toMutableList())
|
||||
val (list, idx) = expandForPlayback(mediaItems)
|
||||
// A single browse tap expands into a full list → start at the tapped index.
|
||||
// Otherwise honour the caller's startIndex.
|
||||
val expanded = list.size != mediaItems.size
|
||||
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
|
||||
} catch (e: Exception) {
|
||||
settable.set(mediaItems)
|
||||
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||
}
|
||||
}
|
||||
return settable
|
||||
}
|
||||
|
||||
// Resolve browse items (play-all / shuffle / album / playlist / context-song / plain song)
|
||||
// into a flat, playable song list plus the index to start at (the tapped song, else 0).
|
||||
private suspend fun expandForPlayback(items: List<MediaItem>): Pair<List<MediaItem>, Int> {
|
||||
if (items.size == 1 && items[0].mediaId.startsWith(SONG_CTX_PREFIX)) {
|
||||
val rest = items[0].mediaId.removePrefix(SONG_CTX_PREFIX)
|
||||
val ctx = rest.substringBefore('|')
|
||||
val songId = rest.substringAfter('|')
|
||||
val full = resolveSongs(ctx, shuffle = false)
|
||||
if (full.isNotEmpty()) {
|
||||
val idx = full.indexOfFirst { it.mediaId == songId }.coerceAtLeast(0)
|
||||
return full to idx
|
||||
}
|
||||
return listOf(reattachUri(items[0], songId)) to 0
|
||||
}
|
||||
val resolved = items.flatMap { item ->
|
||||
when {
|
||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), false)
|
||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), true)
|
||||
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)).song.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(ITEM_PREFIX_PL) -> repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL)).entry.map { it.toMediaItem(repo) }
|
||||
item.mediaId.startsWith(SONG_CTX_PREFIX) -> listOf(reattachUri(item, item.mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')))
|
||||
else -> listOf(reattachUri(item))
|
||||
}
|
||||
}
|
||||
return resolved to 0
|
||||
}
|
||||
|
||||
// Android Auto strips the URI when a browse item crosses the process boundary — re-resolve it.
|
||||
private fun reattachUri(item: MediaItem, songId: String = item.mediaId): MediaItem {
|
||||
val uri = repo.streamUrl(songId)?.let { android.net.Uri.parse(it) }
|
||||
return item.buildUpon().setMediaId(songId).setUri(uri).build()
|
||||
}
|
||||
|
||||
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
||||
val songs: List<com.novasonic.app.api.model.Song> = when {
|
||||
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
||||
@@ -438,16 +475,16 @@ class MusicService : MediaLibraryService() {
|
||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
||||
FAVORITES_ID -> {
|
||||
val songs = repo.getStarred().song
|
||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { browseSong(it, FAVORITES_ID) }
|
||||
}
|
||||
else -> when {
|
||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { browseSong(it, parentId) }
|
||||
}
|
||||
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { it.toMediaItem(repo) }
|
||||
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { browseSong(it, parentId) }
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
@@ -455,6 +492,13 @@ class MusicService : MediaLibraryService() {
|
||||
return ImmutableList.copyOf(items)
|
||||
}
|
||||
|
||||
// A song shown in a browse list, with its context encoded in the mediaId so a tap can play the
|
||||
// whole list from that song (Android Auto strips the URI, so it's re-resolved on playback).
|
||||
private fun browseSong(song: com.novasonic.app.api.model.Song, contextId: String): MediaItem =
|
||||
song.toMediaItem(repo).buildUpon()
|
||||
.setMediaId("$SONG_CTX_PREFIX$contextId|${song.id}")
|
||||
.build()
|
||||
|
||||
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||
MediaItem.Builder()
|
||||
.setMediaId(id)
|
||||
|
||||
Reference in new issue
Block a user