From 71b50770b68e4e8b9176a79ae231b2a962421ec4 Mon Sep 17 00:00:00 2001 From: Sam Mingo Date: Thu, 1 Oct 2026 21:44:19 +0000 Subject: [PATCH] feat(auto): add Genres browsing to Android Auto --- .../service/MediaLibrarySessionCallback.kt | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt index 9da1afff..662473af 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/MediaLibrarySessionCallback.kt @@ -90,6 +90,9 @@ private const val MEDIA_BOOKMARK_ITEM = "MEDIA_BOOKMARK_ITEM" private const val MEDIA_PODCAST_ITEM = "MEDIA_PODCAST_ITEM" private const val MEDIA_PODCAST_EPISODE_ITEM = "MEDIA_PODCAST_EPISODE_ITEM" private const val MEDIA_SEARCH_SONG_ITEM = "MEDIA_SEARCH_SONG_ITEM" +private const val MEDIA_GENRE_ID = "MEDIA_GENRE_ID" +private const val MEDIA_GENRE_ITEM = "MEDIA_GENRE_ITEM" +private const val MEDIA_GENRE_SONG_ITEM = "MEDIA_GENRE_SONG_ITEM" // Currently the display limit for long lists is 100 items private const val DISPLAY_LIMIT = 100 @@ -123,6 +126,7 @@ class MediaLibrarySessionCallback( private var starredSongsCache: List? = null private var randomSongsCache: List? = null private var searchSongsCache: List? = null + private var genreSongsCache: List? = null private val musicService get() = musicServiceFactory.getMusicService() private val isOffline get() = activeServerProvider.isOffline() @@ -658,6 +662,10 @@ class MediaLibrarySessionCallback( MEDIA_ARTIST_PLAY_ID -> playAllForArtist(mediaIdParts[1], mediaIdParts[2]) + MEDIA_GENRE_ITEM -> playGenre(mediaIdParts[1]) + + MEDIA_GENRE_SONG_ITEM -> playGenreSong(mediaIdParts[1]) + else -> null } @@ -730,6 +738,10 @@ class MediaLibrarySessionCallback( MEDIA_PODCAST_ITEM -> getPodcastEpisodes(parentIdParts[1]) + MEDIA_GENRE_ID -> getGenres() + + MEDIA_GENRE_ITEM -> getSongsForGenre(parentIdParts[1]) + else -> Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) } } @@ -845,6 +857,10 @@ class MediaLibrarySessionCallback( MEDIA_ARTIST_PLAY_ID -> playAllForArtist(mediaIdParts[1], mediaIdParts[2]) + MEDIA_GENRE_ITEM -> playGenre(mediaIdParts[1]) + + MEDIA_GENRE_SONG_ITEM -> playGenreSong(mediaIdParts[1]) + else -> { listOf() } @@ -878,6 +894,72 @@ class MediaLibrarySessionCallback( }.ifEmpty { null } } + private fun getGenres(): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() + + return mainScope.future { + val genres = serviceScope.future { + callWithErrorHandling { musicService.getGenres(false) } + }.await() + + genres?.sortedBy { it.name }?.map { genre -> + mediaItems.add( + genre.name, + listOf(MEDIA_GENRE_ITEM, genre.name).joinToString("|"), + mediaType = MEDIA_TYPE_FOLDER_MIXED, + isBrowsable = true + ) + } + return@future LibraryResult.ofItemList(mediaItems, null) + } + } + + private fun getSongsForGenre( + genre: String + ): ListenableFuture>> { + val mediaItems: MutableList = ArrayList() + + return mainScope.future { + val songs = serviceScope.future { + callWithErrorHandling { musicService.getSongsByGenre(genre, DISPLAY_LIMIT, 0) } + }.await() + + if (songs != null) { + val tracks = songs.getTracks() + genreSongsCache = tracks + + if (tracks.size > 1) { + mediaItems.addPlayAllItem(listOf(MEDIA_GENRE_ITEM, genre).joinToString("|")) + } + + tracks.map { song -> + mediaItems.add( + song.toMediaItem( + listOf(MEDIA_GENRE_SONG_ITEM, song.id).joinToString("|") + ) + ) + } + } + return@future LibraryResult.ofItemList(mediaItems, null) + } + } + + private fun playGenre(genre: String): List? { + if (genreSongsCache == null) { + val content = serviceScope.future { + callWithErrorHandling { musicService.getSongsByGenre(genre, DISPLAY_LIMIT, 0) } + }.get() + genreSongsCache = content?.getTracks() + } + return genreSongsCache + } + + private fun playGenreSong(songId: String): List? { + val song = genreSongsCache?.firstOrNull { it.id == songId } + if (song != null) return listOf(song) + return null + } + private fun getRootItems(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() @@ -921,6 +1003,17 @@ class MediaLibrarySessionCallback( icon = R.drawable.ic_menu_playlists ) + if (!isOffline) { + mediaItems.add( + R.string.main_genres_title, + MEDIA_GENRE_ID, + null, + isBrowsable = true, + mediaType = MEDIA_TYPE_FOLDER_MIXED, + icon = R.drawable.ic_menu_browse + ) + } + return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null)) }