/* * CustomMediaLibrarySessionCallback.kt * Copyright (C) 2009-2022 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ package org.moire.ultrasonic.playback import android.net.Uri import android.os.Bundle import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ALBUMS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_ARTISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_MIXED import androidx.media3.common.MediaMetadata.FOLDER_TYPE_NONE import androidx.media3.common.MediaMetadata.FOLDER_TYPE_PLAYLISTS import androidx.media3.common.MediaMetadata.FOLDER_TYPE_TITLES import androidx.media3.common.Player import androidx.media3.session.LibraryResult import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession import com.google.common.collect.ImmutableList import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R import org.moire.ultrasonic.api.subsonic.models.AlbumListType import org.moire.ultrasonic.app.UApp import org.moire.ultrasonic.data.ActiveServerProvider import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.SearchCriteria import org.moire.ultrasonic.domain.SearchResult import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import timber.log.Timber private const val MEDIA_ROOT_ID = "MEDIA_ROOT_ID" private const val MEDIA_ALBUM_ID = "MEDIA_ALBUM_ID" private const val MEDIA_ALBUM_PAGE_ID = "MEDIA_ALBUM_PAGE_ID" private const val MEDIA_ALBUM_NEWEST_ID = "MEDIA_ALBUM_NEWEST_ID" private const val MEDIA_ALBUM_RECENT_ID = "MEDIA_ALBUM_RECENT_ID" private const val MEDIA_ALBUM_FREQUENT_ID = "MEDIA_ALBUM_FREQUENT_ID" private const val MEDIA_ALBUM_RANDOM_ID = "MEDIA_ALBUM_RANDOM_ID" private const val MEDIA_ALBUM_STARRED_ID = "MEDIA_ALBUM_STARRED_ID" private const val MEDIA_SONG_RANDOM_ID = "MEDIA_SONG_RANDOM_ID" private const val MEDIA_SONG_STARRED_ID = "MEDIA_SONG_STARRED_ID" private const val MEDIA_ARTIST_ID = "MEDIA_ARTIST_ID" private const val MEDIA_LIBRARY_ID = "MEDIA_LIBRARY_ID" private const val MEDIA_PLAYLIST_ID = "MEDIA_PLAYLIST_ID" private const val MEDIA_SHARE_ID = "MEDIA_SHARE_ID" private const val MEDIA_BOOKMARK_ID = "MEDIA_BOOKMARK_ID" private const val MEDIA_PODCAST_ID = "MEDIA_PODCAST_ID" private const val MEDIA_ALBUM_ITEM = "MEDIA_ALBUM_ITEM" private const val MEDIA_PLAYLIST_SONG_ITEM = "MEDIA_PLAYLIST_SONG_ITEM" private const val MEDIA_PLAYLIST_ITEM = "MEDIA_PLAYLIST_ITEM" private const val MEDIA_ARTIST_ITEM = "MEDIA_ARTIST_ITEM" private const val MEDIA_ARTIST_SECTION = "MEDIA_ARTIST_SECTION" private const val MEDIA_ALBUM_SONG_ITEM = "MEDIA_ALBUM_SONG_ITEM" private const val MEDIA_SONG_STARRED_ITEM = "MEDIA_SONG_STARRED_ITEM" private const val MEDIA_SONG_RANDOM_ITEM = "MEDIA_SONG_RANDOM_ITEM" private const val MEDIA_SHARE_ITEM = "MEDIA_SHARE_ITEM" private const val MEDIA_SHARE_SONG_ITEM = "MEDIA_SHARE_SONG_ITEM" 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" // Currently the display limit for long lists is 100 items private const val DISPLAY_LIMIT = 100 private const val SEARCH_LIMIT = 10 /** * MediaBrowserService implementation for e.g. Android Auto */ @Suppress("TooManyFunctions", "LargeClass", "UnusedPrivateMember") class AutoMediaBrowserCallback(var player: Player) : MediaLibraryService.MediaLibrarySession.Callback, KoinComponent { private val mediaPlayerController by inject() private val activeServerProvider: ActiveServerProvider by inject() private val musicService = MusicServiceFactory.getMusicService() private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private var playlistCache: List? = null private var starredSongsCache: List? = null private var randomSongsCache: List? = null private var searchSongsCache: List? = null private val isOffline get() = ActiveServerProvider.isOffline() private val useId3Tags get() = Settings.shouldUseId3Tags private val musicFolderId get() = activeServerProvider.getActiveServer().musicFolderId /** * Called when a {@link MediaBrowser} requests the root {@link MediaItem} by {@link * MediaBrowser#getLibraryRoot(LibraryParams)}. * *

Return a {@link ListenableFuture} to send a {@link LibraryResult} back to the browser * asynchronously. You can also return a {@link LibraryResult} directly by using Guava's * {@link Futures#immediateFuture(Object)}. * *

The {@link LibraryResult#params} may differ from the given {@link LibraryParams params} * if the session can't provide a root that matches with the {@code params}. * *

To allow browsing the media library, return a {@link LibraryResult} with {@link * LibraryResult#RESULT_SUCCESS} and a root {@link MediaItem} with a valid {@link * MediaItem#mediaId}. The media id is required for the browser to get the children under the * root. * *

Interoperability: If this callback is called because a legacy {@link * android.support.v4.media.MediaBrowserCompat} has requested a {@link * androidx.media.MediaBrowserServiceCompat.BrowserRoot}, then the main thread may be blocked * until the returned future is done. If your service may be queried by a legacy {@link * android.support.v4.media.MediaBrowserCompat}, you should ensure that the future completes * quickly to avoid blocking the main thread for a long period of time. * * @param session The session for this event. * @param browser The browser information. * @param params The optional parameters passed by the browser. * @return A pending result that will be resolved with a root media item. * @see SessionCommand#COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT */ override fun onGetLibraryRoot( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, params: MediaLibraryService.LibraryParams? ): ListenableFuture> { return Futures.immediateFuture( LibraryResult.ofItem( buildMediaItem( "Root Folder", MEDIA_ROOT_ID, isPlayable = false, folderType = FOLDER_TYPE_MIXED ), params ) ) } override fun onGetItem( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, mediaId: String ): ListenableFuture> { playFromMediaId(mediaId) // TODO: // Create LRU Cache of MediaItems, fill it in the other calls // and retrieve it here. return Futures.immediateFuture( LibraryResult.ofError(LibraryResult.RESULT_ERROR_BAD_VALUE) ) } override fun onGetChildren( session: MediaLibraryService.MediaLibrarySession, browser: MediaSession.ControllerInfo, parentId: String, page: Int, pageSize: Int, params: MediaLibraryService.LibraryParams? ): ListenableFuture>> { // TODO: params??? return onLoadChildren(parentId) } /* * For some reason the LocalConfiguration of MediaItem are stripped somewhere in ExoPlayer, * and thereby customarily it is required to rebuild it.. * See also: https://stackoverflow.com/questions/70096715/adding-mediaitem-when-using-the-media3-library-caused-an-error */ override fun onAddMediaItems( mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList ): ListenableFuture> { val updatedMediaItems = mediaItems.map { mediaItem -> mediaItem.buildUpon() .setUri(mediaItem.requestMetadata.mediaUri) .build() } return Futures.immediateFuture(updatedMediaItems.toMutableList()) } @Suppress("ReturnCount", "ComplexMethod") fun onLoadChildren( parentId: String, ): ListenableFuture>> { Timber.d("AutoMediaBrowserService onLoadChildren called. ParentId: %s", parentId) val parentIdParts = parentId.split('|') when (parentIdParts.first()) { MEDIA_ROOT_ID -> return getRootItems() MEDIA_LIBRARY_ID -> return getLibrary() MEDIA_ARTIST_ID -> return getArtists() MEDIA_ARTIST_SECTION -> return getArtists(parentIdParts[1]) MEDIA_ALBUM_ID -> return getAlbums(AlbumListType.SORTED_BY_NAME) MEDIA_ALBUM_PAGE_ID -> return getAlbums( AlbumListType.fromName(parentIdParts[1]), parentIdParts[2].toInt() ) MEDIA_PLAYLIST_ID -> return getPlaylists() MEDIA_ALBUM_FREQUENT_ID -> return getAlbums(AlbumListType.FREQUENT) MEDIA_ALBUM_NEWEST_ID -> return getAlbums(AlbumListType.NEWEST) MEDIA_ALBUM_RECENT_ID -> return getAlbums(AlbumListType.RECENT) MEDIA_ALBUM_RANDOM_ID -> return getAlbums(AlbumListType.RANDOM) MEDIA_ALBUM_STARRED_ID -> return getAlbums(AlbumListType.STARRED) MEDIA_SONG_RANDOM_ID -> return getRandomSongs() MEDIA_SONG_STARRED_ID -> return getStarredSongs() MEDIA_SHARE_ID -> return getShares() MEDIA_BOOKMARK_ID -> return getBookmarks() MEDIA_PODCAST_ID -> return getPodcasts() MEDIA_PLAYLIST_ITEM -> return getPlaylist(parentIdParts[1], parentIdParts[2]) MEDIA_ARTIST_ITEM -> return getAlbumsForArtist( parentIdParts[1], parentIdParts[2] ) MEDIA_ALBUM_ITEM -> return getSongsForAlbum(parentIdParts[1], parentIdParts[2]) MEDIA_SHARE_ITEM -> return getSongsForShare(parentIdParts[1]) MEDIA_PODCAST_ITEM -> return getPodcastEpisodes(parentIdParts[1]) else -> return Futures.immediateFuture(LibraryResult.ofItemList(listOf(), null)) } } fun onSearch( query: String, extras: Bundle?, ): ListenableFuture>> { Timber.d("AutoMediaBrowserService onSearch query: %s", query) val mediaItems: MutableList = ArrayList() return serviceScope.future { val criteria = SearchCriteria(query, SEARCH_LIMIT, SEARCH_LIMIT, SEARCH_LIMIT) val searchResult = callWithErrorHandling { musicService.search(criteria) } // TODO Add More... button to categories if (searchResult != null) { searchResult.artists.map { artist -> mediaItems.add( artist.name ?: "", listOf(MEDIA_ARTIST_ITEM, artist.id, artist.name).joinToString("|"), FOLDER_TYPE_ARTISTS ) } searchResult.albums.map { album -> mediaItems.add( album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) .joinToString("|"), FOLDER_TYPE_ALBUMS ) } searchSongsCache = searchResult.songs searchResult.songs.map { song -> mediaItems.add( buildMediaItemFromTrack( song, listOf(MEDIA_SEARCH_SONG_ITEM, song.id).joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } @Suppress("MagicNumber", "ComplexMethod") private fun playFromMediaId(mediaId: String?) { Timber.d( "AutoMediaBrowserService onPlayFromMediaIdRequested called. mediaId: %s", mediaId ) if (mediaId == null) return val mediaIdParts = mediaId.split('|') when (mediaIdParts.first()) { MEDIA_PLAYLIST_ITEM -> playPlaylist(mediaIdParts[1], mediaIdParts[2]) MEDIA_PLAYLIST_SONG_ITEM -> playPlaylistSong( mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] ) MEDIA_ALBUM_ITEM -> playAlbum(mediaIdParts[1], mediaIdParts[2]) MEDIA_ALBUM_SONG_ITEM -> playAlbumSong( mediaIdParts[1], mediaIdParts[2], mediaIdParts[3] ) MEDIA_SONG_STARRED_ID -> playStarredSongs() MEDIA_SONG_STARRED_ITEM -> playStarredSong(mediaIdParts[1]) MEDIA_SONG_RANDOM_ID -> playRandomSongs() MEDIA_SONG_RANDOM_ITEM -> playRandomSong(mediaIdParts[1]) MEDIA_SHARE_ITEM -> playShare(mediaIdParts[1]) MEDIA_SHARE_SONG_ITEM -> playShareSong(mediaIdParts[1], mediaIdParts[2]) MEDIA_BOOKMARK_ITEM -> playBookmark(mediaIdParts[1]) MEDIA_PODCAST_ITEM -> playPodcast(mediaIdParts[1]) MEDIA_PODCAST_EPISODE_ITEM -> playPodcastEpisode( mediaIdParts[1], mediaIdParts[2] ) MEDIA_SEARCH_SONG_ITEM -> playSearch(mediaIdParts[1]) } } private fun playFromSearchCommand(query: String?) { Timber.d("AutoMediaBrowserService onPlayFromSearchRequested query: %s", query) if (query.isNullOrBlank()) playRandomSongs() serviceScope.launch { val criteria = SearchCriteria(query!!, 0, 0, DISPLAY_LIMIT) val searchResult = callWithErrorHandling { musicService.search(criteria) } // Try to find the best match if (searchResult != null) { val song = searchResult.songs .asSequence() .sortedByDescending { song -> song.starred } .sortedByDescending { song -> song.averageRating } .sortedByDescending { song -> song.userRating } .sortedByDescending { song -> song.closeness } .firstOrNull() if (song != null) playSong(song) } } } private fun playSearch(id: String) { serviceScope.launch { // If there is no cache, we can't play the selected song. if (searchSongsCache != null) { val song = searchSongsCache!!.firstOrNull { x -> x.id == id } if (song != null) playSong(song) } } } private fun getRootItems(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() if (!isOffline) mediaItems.add( R.string.music_library_label, MEDIA_LIBRARY_ID, null ) mediaItems.add( R.string.main_artists_title, MEDIA_ARTIST_ID, null, folderType = FOLDER_TYPE_ARTISTS ) if (!isOffline) mediaItems.add( R.string.main_albums_title, MEDIA_ALBUM_ID, null, folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.playlist_label, MEDIA_PLAYLIST_ID, null, folderType = FOLDER_TYPE_PLAYLISTS ) return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null)) } private fun getLibrary(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() // Songs mediaItems.add( R.string.main_songs_random, MEDIA_SONG_RANDOM_ID, R.string.main_songs_title, folderType = FOLDER_TYPE_TITLES ) mediaItems.add( R.string.main_songs_starred, MEDIA_SONG_STARRED_ID, R.string.main_songs_title, folderType = FOLDER_TYPE_TITLES ) // Albums mediaItems.add( R.string.main_albums_newest, MEDIA_ALBUM_NEWEST_ID, R.string.main_albums_title ) mediaItems.add( R.string.main_albums_recent, MEDIA_ALBUM_RECENT_ID, R.string.main_albums_title, folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.main_albums_frequent, MEDIA_ALBUM_FREQUENT_ID, R.string.main_albums_title, folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.main_albums_random, MEDIA_ALBUM_RANDOM_ID, R.string.main_albums_title, folderType = FOLDER_TYPE_ALBUMS ) mediaItems.add( R.string.main_albums_starred, MEDIA_ALBUM_STARRED_ID, R.string.main_albums_title, folderType = FOLDER_TYPE_ALBUMS ) // Other mediaItems.add(R.string.button_bar_shares, MEDIA_SHARE_ID, null) mediaItems.add(R.string.button_bar_bookmarks, MEDIA_BOOKMARK_ID, null) mediaItems.add(R.string.button_bar_podcasts, MEDIA_PODCAST_ID, null) return Futures.immediateFuture(LibraryResult.ofItemList(mediaItems, null)) } private fun getArtists( section: String? = null ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val childMediaId: String var artists = if (!isOffline && useId3Tags) { childMediaId = MEDIA_ARTIST_ITEM // TODO this list can be big so we're not refreshing. // Maybe a refresh menu item can be added callWithErrorHandling { musicService.getArtists(false) } } else { // This will be handled at getSongsForAlbum, which supports navigation childMediaId = MEDIA_ALBUM_ITEM callWithErrorHandling { musicService.getIndexes(musicFolderId, false) } } if (artists != null) { if (section != null) artists = artists.filter { artist -> getSectionFromName(artist.name ?: "") == section } // If there are too many artists, create alphabetic index of them if (section == null && artists.count() > DISPLAY_LIMIT) { val index = mutableListOf() // TODO This sort should use ignoredArticles somehow... artists = artists.sortedBy { artist -> artist.name } artists.map { artist -> val currentSection = getSectionFromName(artist.name ?: "") if (!index.contains(currentSection)) { index.add(currentSection) mediaItems.add( currentSection, listOf(MEDIA_ARTIST_SECTION, currentSection).joinToString("|"), FOLDER_TYPE_ARTISTS ) } } } else { artists.map { artist -> mediaItems.add( artist.name ?: "", listOf(childMediaId, artist.id, artist.name).joinToString("|"), FOLDER_TYPE_ARTISTS ) } } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getAlbumsForArtist( id: String, name: String ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val albums = if (!isOffline && useId3Tags) { callWithErrorHandling { musicService.getArtist(id, name, false) } } else { callWithErrorHandling { musicService.getMusicDirectory(id, name, false).getAlbums() } } albums?.map { album -> mediaItems.add( album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) .joinToString("|"), FOLDER_TYPE_ALBUMS ) } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getSongsForAlbum( id: String, name: String ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val songs = listSongsInMusicService(id, name) if (songs != null) { if (songs.getChildren(includeDirs = true, includeFiles = false).isEmpty() && songs.getChildren(includeDirs = false, includeFiles = true).isNotEmpty() ) mediaItems.addPlayAllItem(listOf(MEDIA_ALBUM_ITEM, id, name).joinToString("|")) // TODO: Paging is not implemented for songs, is it necessary at all? val items = songs.getTracks().take(DISPLAY_LIMIT) items.map { item -> if (item.isDirectory) mediaItems.add( item.title ?: "", listOf(MEDIA_ALBUM_ITEM, item.id, item.name).joinToString("|"), FOLDER_TYPE_TITLES ) else mediaItems.add( buildMediaItemFromTrack( item, listOf( MEDIA_ALBUM_SONG_ITEM, id, name, item.id ).joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getAlbums( type: AlbumListType, page: Int? = null ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val offset = (page ?: 0) * DISPLAY_LIMIT val albums = if (useId3Tags) { callWithErrorHandling { musicService.getAlbumList2( type.typeName, DISPLAY_LIMIT, offset, null ) } } else { callWithErrorHandling { musicService.getAlbumList( type.typeName, DISPLAY_LIMIT, offset, null ) } } albums?.map { album -> mediaItems.add( album.title ?: "", listOf(MEDIA_ALBUM_ITEM, album.id, album.name) .joinToString("|"), FOLDER_TYPE_ALBUMS ) } if (albums?.size ?: 0 >= DISPLAY_LIMIT) mediaItems.add( R.string.search_more, listOf(MEDIA_ALBUM_PAGE_ID, type.typeName, (page ?: 0) + 1).joinToString("|"), null ) return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getPlaylists(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val playlists = callWithErrorHandling { musicService.getPlaylists(true) } playlists?.map { playlist -> mediaItems.add( playlist.name, listOf(MEDIA_PLAYLIST_ITEM, playlist.id, playlist.name) .joinToString("|"), FOLDER_TYPE_PLAYLISTS ) } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getPlaylist( id: String, name: String, ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val content = callWithErrorHandling { musicService.getPlaylist(id, name) } if (content != null) { if (content.size > 1) mediaItems.addPlayAllItem( listOf(MEDIA_PLAYLIST_ITEM, id, name).joinToString("|") ) // Playlist should be cached as it may contain random elements playlistCache = content.getTracks() playlistCache!!.take(DISPLAY_LIMIT).map { item -> mediaItems.add( buildMediaItemFromTrack( item, listOf( MEDIA_PLAYLIST_SONG_ITEM, id, name, item.id ).joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun playPlaylist(id: String, name: String) { serviceScope.launch { if (playlistCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content = callWithErrorHandling { musicService.getPlaylist(id, name) } playlistCache = content?.getTracks() } if (playlistCache != null) playSongs(playlistCache!!) } } private fun playPlaylistSong(id: String, name: String, songId: String) { serviceScope.launch { if (playlistCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content = callWithErrorHandling { musicService.getPlaylist(id, name) } playlistCache = content?.getTracks() } val song = playlistCache?.firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } private fun playAlbum(id: String, name: String) { serviceScope.launch { val songs = listSongsInMusicService(id, name) if (songs != null) playSongs(songs.getTracks()) } } private fun playAlbumSong(id: String, name: String, songId: String) { serviceScope.launch { val songs = listSongsInMusicService(id, name) val song = songs?.getTracks()?.firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } private fun getPodcasts(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val podcasts = callWithErrorHandling { musicService.getPodcastsChannels(false) } podcasts?.map { podcast -> mediaItems.add( podcast.title ?: "", listOf(MEDIA_PODCAST_ITEM, podcast.id).joinToString("|"), FOLDER_TYPE_MIXED ) } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getPodcastEpisodes( id: String ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } if (episodes != null) { if (episodes.getTracks().count() > 1) mediaItems.addPlayAllItem(listOf(MEDIA_PODCAST_ITEM, id).joinToString("|")) episodes.getTracks().map { episode -> mediaItems.add( buildMediaItemFromTrack( episode, listOf(MEDIA_PODCAST_EPISODE_ITEM, id, episode.id) .joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun playPodcast(id: String) { serviceScope.launch { val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } if (episodes != null) { playSongs(episodes.getTracks()) } } } private fun playPodcastEpisode(id: String, episodeId: String) { serviceScope.launch { val episodes = callWithErrorHandling { musicService.getPodcastEpisodes(id) } if (episodes != null) { val selectedEpisode = episodes .getTracks() .firstOrNull { episode -> episode.id == episodeId } if (selectedEpisode != null) playSong(selectedEpisode) } } } private fun getBookmarks(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val bookmarks = callWithErrorHandling { musicService.getBookmarks() } if (bookmarks != null) { val songs = Util.getSongsFromBookmarks(bookmarks) songs.getTracks().map { song -> mediaItems.add( buildMediaItemFromTrack( song, listOf(MEDIA_BOOKMARK_ITEM, song.id).joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun playBookmark(id: String) { serviceScope.launch { val bookmarks = callWithErrorHandling { musicService.getBookmarks() } if (bookmarks != null) { val songs = Util.getSongsFromBookmarks(bookmarks) val song = songs.getTracks().firstOrNull { song -> song.id == id } if (song != null) playSong(song) } } } private fun getShares(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val shares = callWithErrorHandling { musicService.getShares(false) } shares?.map { share -> mediaItems.add( share.name ?: "", listOf(MEDIA_SHARE_ITEM, share.id) .joinToString("|"), FOLDER_TYPE_MIXED ) } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun getSongsForShare( id: String ): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val shares = callWithErrorHandling { musicService.getShares(false) } val selectedShare = shares?.firstOrNull { share -> share.id == id } if (selectedShare != null) { if (selectedShare.getEntries().count() > 1) mediaItems.addPlayAllItem(listOf(MEDIA_SHARE_ITEM, id).joinToString("|")) selectedShare.getEntries().map { song -> mediaItems.add( buildMediaItemFromTrack( song, listOf(MEDIA_SHARE_SONG_ITEM, id, song.id).joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun playShare(id: String) { serviceScope.launch { val shares = callWithErrorHandling { musicService.getShares(false) } val selectedShare = shares?.firstOrNull { share -> share.id == id } if (selectedShare != null) { playSongs(selectedShare.getEntries()) } } } private fun playShareSong(id: String, songId: String) { serviceScope.launch { val shares = callWithErrorHandling { musicService.getShares(false) } val selectedShare = shares?.firstOrNull { share -> share.id == id } if (selectedShare != null) { val song = selectedShare.getEntries().firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } } private fun getStarredSongs(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val songs = listStarredSongsInMusicService() if (songs != null) { if (songs.songs.count() > 1) mediaItems.addPlayAllItem(listOf(MEDIA_SONG_STARRED_ID).joinToString("|")) // TODO: Paging is not implemented for songs, is it necessary at all? val items = songs.songs.take(DISPLAY_LIMIT) starredSongsCache = items items.map { song -> mediaItems.add( buildMediaItemFromTrack( song, listOf(MEDIA_SONG_STARRED_ITEM, song.id).joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun playStarredSongs() { serviceScope.launch { if (starredSongsCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content = listStarredSongsInMusicService() starredSongsCache = content?.songs } if (starredSongsCache != null) playSongs(starredSongsCache!!) } } private fun playStarredSong(songId: String) { serviceScope.launch { if (starredSongsCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them val content = listStarredSongsInMusicService() starredSongsCache = content?.songs } val song = starredSongsCache?.firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } private fun getRandomSongs(): ListenableFuture>> { val mediaItems: MutableList = ArrayList() return serviceScope.future { val songs = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } if (songs != null) { if (songs.size > 1) mediaItems.addPlayAllItem(listOf(MEDIA_SONG_RANDOM_ID).joinToString("|")) // TODO: Paging is not implemented for songs, is it necessary at all? val items = songs.getTracks() randomSongsCache = items items.map { song -> mediaItems.add( buildMediaItemFromTrack( song, listOf(MEDIA_SONG_RANDOM_ITEM, song.id).joinToString("|"), isPlayable = true ) ) } } return@future LibraryResult.ofItemList(mediaItems, null) } } private fun playRandomSongs() { serviceScope.launch { if (randomSongsCache == null) { // This can only happen if Android Auto cached items, but Ultrasonic has forgot them // In this case we request a new set of random songs val content = callWithErrorHandling { musicService.getRandomSongs(DISPLAY_LIMIT) } randomSongsCache = content?.getTracks() } if (randomSongsCache != null) playSongs(randomSongsCache!!) } } private fun playRandomSong(songId: String) { serviceScope.launch { // If there is no cache, we can't play the selected song. if (randomSongsCache != null) { val song = randomSongsCache!!.firstOrNull { x -> x.id == songId } if (song != null) playSong(song) } } } private fun listSongsInMusicService(id: String, name: String): MusicDirectory? { return if (!ActiveServerProvider.isOffline() && Settings.shouldUseId3Tags) { callWithErrorHandling { musicService.getAlbum(id, name, false) } } else { callWithErrorHandling { musicService.getMusicDirectory(id, name, false) } } } private fun listStarredSongsInMusicService(): SearchResult? { return if (Settings.shouldUseId3Tags) { callWithErrorHandling { musicService.getStarred2() } } else { callWithErrorHandling { musicService.getStarred() } } } private fun MutableList.add( title: String, mediaId: String, folderType: Int ) { val mediaItem = buildMediaItem( title, mediaId, isPlayable = false, folderType = folderType ) this.add(mediaItem) } private fun MutableList.add( resId: Int, mediaId: String, groupNameId: Int?, browsable: Boolean = true, folderType: Int = FOLDER_TYPE_MIXED ) { val applicationContext = UApp.applicationContext() val mediaItem = buildMediaItem( applicationContext.getString(resId), mediaId, isPlayable = false, folderType = folderType ) this.add(mediaItem) } private fun MutableList.addPlayAllItem( mediaId: String, ) { this.add( R.string.select_album_play_all, mediaId, null, false ) } private fun getSectionFromName(name: String): String { var section = name.first().uppercaseChar() if (!section.isLetter()) section = '#' return section.toString() } private fun playSongs(songs: List) { mediaPlayerController.addToPlaylist( songs, cachePermanently = false, autoPlay = true, shuffle = false, insertionMode = MediaPlayerController.InsertionMode.CLEAR ) } private fun playSong(song: Track) { mediaPlayerController.addToPlaylist( listOf(song), cachePermanently = false, autoPlay = false, shuffle = false, insertionMode = MediaPlayerController.InsertionMode.AFTER_CURRENT ) if (mediaPlayerController.mediaItemCount > 1) mediaPlayerController.next() else mediaPlayerController.play() } private fun callWithErrorHandling(function: () -> T): T? { // TODO Implement better error handling return try { function() } catch (all: Exception) { Timber.i(all) null } } private fun buildMediaItemFromTrack( track: Track, mediaId: String, isPlayable: Boolean ): MediaItem { return buildMediaItem( title = track.title ?: "", mediaId = mediaId, isPlayable = isPlayable, folderType = FOLDER_TYPE_NONE, album = track.album, artist = track.artist, genre = track.genre, ) } @Suppress("LongParameterList") private fun buildMediaItem( title: String, mediaId: String, isPlayable: Boolean, @MediaMetadata.FolderType folderType: Int, album: String? = null, artist: String? = null, genre: String? = null, sourceUri: Uri? = null, imageUri: Uri? = null, ): MediaItem { val metadata = MediaMetadata.Builder() .setAlbumTitle(album) .setTitle(title) .setArtist(artist) .setGenre(genre) .setFolderType(folderType) .setIsPlayable(isPlayable) .setArtworkUri(imageUri) .build() return MediaItem.Builder() .setMediaId(mediaId) .setMediaMetadata(metadata) .setUri(sourceUri) .build() } }