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
|
// Pseudo-items for play-all / shuffle at top of each list
|
||||||
const val PLAY_ALL_PREFIX = "playall:"
|
const val PLAY_ALL_PREFIX = "playall:"
|
||||||
const val SHUFFLE_PREFIX = "shuffle:"
|
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)
|
// ABS session commands (handled via onCustomCommand)
|
||||||
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
|
||||||
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
|
||||||
@@ -349,6 +352,10 @@ class MusicService : MediaLibraryService() {
|
|||||||
.build()
|
.build()
|
||||||
).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
|
else -> null
|
||||||
}
|
}
|
||||||
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
if (item != null) settable.set(LibraryResult.ofItem(item, null))
|
||||||
@@ -360,48 +367,78 @@ class MusicService : MediaLibraryService() {
|
|||||||
return settable
|
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(
|
override fun onAddMediaItems(
|
||||||
mediaSession: MediaSession,
|
mediaSession: MediaSession,
|
||||||
controller: MediaSession.ControllerInfo,
|
controller: MediaSession.ControllerInfo,
|
||||||
mediaItems: MutableList<MediaItem>,
|
mediaItems: MutableList<MediaItem>,
|
||||||
): ListenableFuture<MutableList<MediaItem>> {
|
): ListenableFuture<MutableList<MediaItem>> {
|
||||||
val settable = SettableFuture.create<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 {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val resolved = mediaItems.flatMap { item ->
|
val (list, idx) = expandForPlayback(mediaItems)
|
||||||
when {
|
// A single browse tap expands into a full list → start at the tapped index.
|
||||||
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
|
// Otherwise honour the caller's startIndex.
|
||||||
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
|
val expanded = list.size != mediaItems.size
|
||||||
}
|
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
|
||||||
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
|
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
|
||||||
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())
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
settable.set(mediaItems)
|
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return settable
|
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> {
|
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
|
||||||
val songs: List<com.novasonic.app.api.model.Song> = when {
|
val songs: List<com.novasonic.app.api.model.Song> = when {
|
||||||
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
|
||||||
@@ -438,16 +475,16 @@ class MusicService : MediaLibraryService() {
|
|||||||
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
|
||||||
FAVORITES_ID -> {
|
FAVORITES_ID -> {
|
||||||
val songs = repo.getStarred().song
|
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 {
|
else -> when {
|
||||||
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
|
||||||
val album = repo.getAlbum(parentId.removePrefix(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) -> {
|
parentId.startsWith(ITEM_PREFIX_PL) -> {
|
||||||
val pl = repo.getPlaylist(parentId.removePrefix(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()
|
else -> emptyList()
|
||||||
}
|
}
|
||||||
@@ -455,6 +492,13 @@ class MusicService : MediaLibraryService() {
|
|||||||
return ImmutableList.copyOf(items)
|
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) =
|
private fun browseFolder(id: String, title: String, mediaType: Int) =
|
||||||
MediaItem.Builder()
|
MediaItem.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
|
|||||||
Reference in new issue
Block a user