From 249b6939362b34ad1f2eccfd23d1175432bff6a6 Mon Sep 17 00:00:00 2001 From: Honey Date: Tue, 11 Aug 2026 01:25:38 +0200 Subject: [PATCH] feat(android auto): tapping a song plays its whole list from that song Browse songs now carry their context in the mediaId (songctx:|). 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 --- .../com/novasonic/app/player/MusicService.kt | 106 +++++++++++++----- 1 file changed, 75 insertions(+), 31 deletions(-) diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt index c9f13df..c92ec53 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/player/MusicService.kt @@ -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 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, ): ListenableFuture> { val settable = SettableFuture.create>() + 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, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture { + val settable = SettableFuture.create() 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): Pair, 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 { val songs: List = 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)