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:
HoneyandClaude Opus 4.8 committed 2026-08-11 01:25:38 +02:00
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)