From a3a58a25ef58032a218b65409584c33eec45ad05 Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Fri, 13 Sep 2024 22:51:54 +0300 Subject: [PATCH 01/15] Added better library browsing for Android Auto Each library has 3 options: Library, Series and Collection. Library is grouped by authors --- .../app/data/CollapsedSeries.kt | 36 +++ .../app/data/LibraryAuthorItem.kt | 55 +++++ .../app/data/LibraryCollection.kt | 40 +++ .../audiobookshelf/app/data/LibraryItem.kt | 90 ++++--- .../app/data/LibrarySeriesItem.kt | 51 ++++ .../audiobookshelf/app/media/MediaManager.kt | 230 +++++++++++++++++ .../app/player/PlayerNotificationService.kt | 232 +++++++++++++++++- .../audiobookshelf/app/server/ApiHandler.kt | 85 +++++++ .../res/drawable-hdpi/md_account_outline.png | Bin 0 -> 560 bytes .../res/drawable-mdpi/md_account_outline.png | Bin 0 -> 387 bytes .../res/drawable-xhdpi/md_account_outline.png | Bin 0 -> 707 bytes .../drawable-xxhdpi/md_account_outline.png | Bin 0 -> 1063 bytes .../main/res/drawable/md_account_outline.xml | 1 + 13 files changed, 781 insertions(+), 39 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt create mode 100644 android/app/src/main/res/drawable-hdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable-mdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable-xhdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/md_account_outline.png create mode 100644 android/app/src/main/res/drawable/md_account_outline.xml diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt b/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt new file mode 100644 index 000000000..636907124 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt @@ -0,0 +1,36 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class CollapsedSeries( + id:String, + var libraryId:String?, + var name:String, + //var nameIgnorePrefix:String, + var sequence:String?, + var libraryItemIds:MutableList +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + @get:JsonIgnore + val numBooks get() = libraryItemIds.size + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + val extras = Bundle() + + val mediaId = "__LIBRARY__${libraryId}__SERIE__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + //.setIconUri(getCoverUri()) + .setSubtitle("${numBooks} books") + .setExtras(extras) + .build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt new file mode 100644 index 000000000..160174842 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt @@ -0,0 +1,55 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.net.Uri +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.R +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class LibraryAuthorItem( + id:String, + var libraryId:String, + var name:String, + var lastFirst:String, + var description:String?, + var imagePath:String?, + var addedAt:Long, + var updatedAt:Long, + var numBooks:Int?, + var libraryItems:MutableList?, + var series:MutableList? +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + + @get:JsonIgnore + val bookCount get() = if (numBooks != null) numBooks else libraryItems!!.size + + @JsonIgnore + fun getPortraitUri(): Uri { + if (imagePath == null) { + return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.md_account_outline) + } + + return Uri.parse("${DeviceManager.serverAddress}/api/authors/$id/image?token=${DeviceManager.token}") + } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + val extras = Bundle() + + val mediaId = "__LIBRARY__${libraryId}__AUTHOR__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + .setIconUri(getPortraitUri()) + .setSubtitle("${bookCount} books") + .setExtras(extras) + .build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt new file mode 100644 index 000000000..e43985b4b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt @@ -0,0 +1,40 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class LibraryCollection( + id:String, + var libraryId:String, + var name:String, + //var userId:String?, + var description:String?, + var books:MutableList?, +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + + @get:JsonIgnore + val bookCount get() = if (books != null) books!!.size else 0 + + @get:JsonIgnore + val audiobookCount get() = books?.filter { book -> (book.media as Book).getAudioTracks().isNotEmpty() }?.size ?: 0 + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + val extras = Bundle() + + val mediaId = "__LIBRARY__${libraryId}__COLLECTION__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + //.setIconUri(getCoverUri()) + .setSubtitle("${bookCount} books") + .setExtras(extras) + .build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 491320ab7..4b466982e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -32,10 +32,17 @@ class LibraryItem( var media:MediaType, var libraryFiles:MutableList?, var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads) + var collapsedSeries: CollapsedSeries?, var localLibraryItemId:String? // For Android Auto ) : LibraryItemWrapper(id) { @get:JsonIgnore - val title get() = media.metadata.title + val title: String + get() { + if (collapsedSeries != null) { + return collapsedSeries!!.title + } + return media.metadata.title + } @get:JsonIgnore val authorName get() = media.metadata.getAuthorDisplayName() @@ -58,49 +65,76 @@ class LibraryItem( } @JsonIgnore - override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat { val extras = Bundle() - if (localLibraryItemId != null) { - extras.putLong( - MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, - MediaDescriptionCompat.STATUS_DOWNLOADED - ) - } - - if (progress != null) { - if (progress.isFinished) { - extras.putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED + if (collapsedSeries == null) { + if (localLibraryItemId != null) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED ) - } else { + } + + if (progress != null) { + if (progress.isFinished) { + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED + ) + } else { + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED + ) + extras.putDouble( + MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress + ) + } + } else if (mediaType != "podcast") { extras.putInt( MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED + MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED ) - extras.putDouble( - MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress + } + + if (media.metadata.explicit) { + extras.putLong( + MediaConstants.METADATA_KEY_IS_EXPLICIT, + MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT ) } - } else if (mediaType != "podcast") { - extras.putInt( - MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS, - MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED - ) } - if (media.metadata.explicit) { - extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT) + val mediaId = if (localLibraryItemId != null) { + localLibraryItemId + } else if (collapsedSeries != null) { + if (authorId != null) { + "__LIBRARY__${libraryId}__AUTHOR_SERIES__${authorId}__${collapsedSeries!!.id}" + } else { + "__LIBRARY__${libraryId}__SERIES__${collapsedSeries!!.id}" + } + } else { + id + } + var subtitle = authorName + if (collapsedSeries != null) { + subtitle = "${collapsedSeries!!.numBooks} books" } - - val mediaId = localLibraryItemId ?: id return MediaDescriptionCompat.Builder() .setMediaId(mediaId) .setTitle(title) .setIconUri(getCoverUri()) - .setSubtitle(authorName) + .setSubtitle(subtitle) .setExtras(extras) .build() } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + /* + This is needed so Android auto library hierarchy for author series can be implemented + */ + return getMediaDescription(progress, ctx, null) + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt new file mode 100644 index 000000000..8a9a798cf --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt @@ -0,0 +1,51 @@ +package com.audiobookshelf.app.data + +import android.content.Context +import android.os.Bundle +import android.support.v4.media.MediaDescriptionCompat +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +@JsonIgnoreProperties(ignoreUnknown = true) +class LibrarySeriesItem( + id:String, + var libraryId:String, + var name:String, + var description:String?, + var addedAt:Long, + var updatedAt:Long, + var books:MutableList?, + var localLibraryItemId:String? // For Android Auto +) : LibraryItemWrapper(id) { + @get:JsonIgnore + val title get() = name + + @get:JsonIgnore + val audiobookCount: Int + get() { + if (books == null) return 0 + val booksWithAudio = books?.filter { b -> (b.media as Book).numTracks != 0 } + return booksWithAudio?.size ?: 0 + } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + val extras = Bundle() + + if (localLibraryItemId != null) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + MediaDescriptionCompat.STATUS_DOWNLOADED + ) + } + + val mediaId = "__LIBRARY__${libraryId}__SERIES__${id}" + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(title) + //.setIconUri(getCoverUri()) + .setSubtitle("$audiobookCount books") + .setExtras(extras) + .build() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 94fa8b331..350dc50be 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -23,6 +23,13 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { private var selectedLibraryItems = mutableListOf() private var selectedLibraryId = "" + private var cachedLibraryAuthors : MutableMap> = hashMapOf() + private var cachedLibraryAuthorItems : MutableMap>> = hashMapOf() + private var cachedLibraryAuthorSeriesItems : MutableMap>> = hashMapOf() + private var cachedLibrarySeries : MutableMap> = hashMapOf() + private var cachedLibrarySeriesItem : MutableMap>> = hashMapOf() + private var cachedLibraryCollections : MutableMap> = hashMapOf() + private var selectedPodcast:Podcast? = null private var selectedLibraryItemId:String? = null private var podcastEpisodeLibraryItemMap = mutableMapOf() @@ -142,6 +149,229 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } + /** + * Returns series with audio books from selected library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesWithAudio(libraryId:String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibrarySeries.containsKey(libraryId)) { + Log.d(tag, "Series with audio found from cache | Library $libraryId ") + cb(cachedLibrarySeries[libraryId] as List) + } else { + apiHandler.getLibrarySeries(libraryId) { seriesItems -> + Log.d(tag, "Series with audio loaded from server | Library $libraryId") + val seriesItemsWithAudio = seriesItems.filter { si -> si.audiobookCount > 0 } + + cachedLibrarySeries[libraryId] = seriesItemsWithAudio + + cb(seriesItemsWithAudio) + } + } + } + + /** + * Returns series with audiobooks from selected library using filter for paging. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesWithAudio(libraryId:String, seriesFilter:String, cb: (List) -> Unit) { + // Check "cache" first + if (!cachedLibrarySeries.containsKey(libraryId)) { + loadLibrarySeriesWithAudio(libraryId) {} + } else { + Log.d(tag, "Series with audio found from cache | Library $libraryId ") + } + val seriesWithBooks = cachedLibrarySeries[libraryId]!!.filter { ls -> ls.title.uppercase().startsWith(seriesFilter) }.toList() + cb(seriesWithBooks) + } + + /** + * Returns books for series from library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesItemsWithAudio(libraryId:String, seriesId:String, cb: (List) -> Unit) { + // Check "cache" first + if (!cachedLibrarySeriesItem.containsKey(libraryId)) { + cachedLibrarySeriesItem[libraryId] = hashMapOf() + } + if (cachedLibrarySeriesItem[libraryId]!!.containsKey(seriesId)) { + Log.d(tag, "Items for series $seriesId found from cache | Library $libraryId") + cachedLibrarySeriesItem[libraryId]!![seriesId]?.let { cb(it) } + } else { + apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems -> + Log.d(tag, "Items for series $seriesId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + cachedLibrarySeriesItem[libraryId]!![seriesId] = libraryItemsWithAudio + + libraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + cb(libraryItemsWithAudio) + } + } + } + + /** + * Returns authors with books from library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorsWithBooks(libraryId:String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibraryAuthors.containsKey(libraryId)) { + Log.d(tag, "Authors with books found from cache | Library $libraryId ") + cb(cachedLibraryAuthors[libraryId]!!.values.toList()) + } else { + // Fetch data from server and add it to local "cache" + apiHandler.getLibraryAuthors(libraryId) { authorItems -> + Log.d(tag, "Authors with books loaded from server | Library $libraryId ") + // TO-DO: This check won't ensure that there is audiobooks. Current API won't offer ability to do so + val authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 } + + // Ensure that there is map for library + cachedLibraryAuthors[libraryId] = mutableMapOf() + // Cache authors + authorItemsWithBooks.forEach { + if (!cachedLibraryAuthors[libraryId]!!.containsKey(it.id)) { + cachedLibraryAuthors[libraryId]!![it.id] = it + } + } + cb(authorItemsWithBooks) + } + } + } + + /** + * Returns authors with books from selected library using filter for paging. + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorsWithBooks(libraryId:String, authorFilter: String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibraryAuthors.containsKey(libraryId)) { + Log.d(tag, "Authors with books found from cache | Library $libraryId ") + } else { + loadAuthorsWithBooks(libraryId) {} + } + val authorsWithBooks = cachedLibraryAuthors[libraryId]!!.values.filter { lai -> lai.name.uppercase().startsWith(authorFilter) }.toList() + cb(authorsWithBooks) + } + + /** + * Returns audiobooks for author from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorBooksWithAudio(libraryId:String, authorId:String, cb: (List) -> Unit) { + // Ensure that there is map for library + if (!cachedLibraryAuthorItems.containsKey(libraryId)) { + cachedLibraryAuthorItems[libraryId] = mutableMapOf() + } + // Check "cache" first + if (cachedLibraryAuthorItems[libraryId]!!.containsKey(authorId)) { + Log.d(tag, "Items for author $authorId found from cache | Library $libraryId") + cachedLibraryAuthorItems[libraryId]!![authorId]?.let { cb(it) } + } else { + apiHandler.getLibraryItemsFromAuthor(libraryId, authorId) { libraryItems -> + Log.d(tag, "Items for author $authorId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + cachedLibraryAuthorItems[libraryId]!![authorId] = libraryItemsWithAudio + + libraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + cb(libraryItemsWithAudio) + } + } + } + + /** + * Returns audiobooks for author from specified series within library + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorSeriesBooksWithAudio(libraryId:String, authorId:String, seriesId: String, cb: (List) -> Unit) { + val authorSeriesKey = "$authorId|$seriesId" + // Ensure that there is map for library + if (!cachedLibraryAuthorSeriesItems.containsKey(libraryId)) { + cachedLibraryAuthorSeriesItems[libraryId] = mutableMapOf() + } + // Check "cache" first + if (cachedLibraryAuthorSeriesItems[libraryId]!!.containsKey(authorSeriesKey)) { + Log.d(tag, "Items for series $seriesId with author $authorId found from cache | Library $libraryId") + cachedLibraryAuthorSeriesItems[libraryId]!![authorSeriesKey]?.let { cb(it) } + } else { + apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems -> + Log.d(tag, "Items for series $seriesId with author $authorId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + if (!cachedLibraryAuthors[libraryId]!!.containsKey(authorId)) { + Log.d(tag, "Author data is missing") + } + val authorName = cachedLibraryAuthors[libraryId]!![authorId]?.name ?: "" + Log.d(tag, "Using author name: $authorName") + val libraryItemsFromAuthorWithAudio = libraryItemsWithAudio.filter { li -> li.authorName.indexOf(authorName, ignoreCase = true) >= 0 } + + cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = libraryItemsFromAuthorWithAudio + + libraryItemsFromAuthorWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + cb(libraryItemsFromAuthorWithAudio) + } + } + } + + /** + * Returns collections with audiobooks from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryCollectionsWithAudio(libraryId:String, cb: (List) -> Unit) { + if (cachedLibraryCollections.containsKey(libraryId)) { + Log.d(tag, "Collections with books found from cache | Library $libraryId ") + cb(cachedLibraryCollections[libraryId]!!.values.toList()) + } else { + apiHandler.getLibraryCollections(libraryId) { libraryCollections -> + Log.d(tag, "Collections with books loaded from server | Library $libraryId ") + val libraryCollectionsWithAudio = libraryCollections.filter { lc -> lc.audiobookCount > 0 } + + // Cache collections + cachedLibraryCollections[libraryId] = hashMapOf() + libraryCollectionsWithAudio.forEach { + if (!cachedLibraryCollections[libraryId]!!.containsKey(it.id)) { + cachedLibraryCollections[libraryId]!![it.id] = it + } + } + cb(libraryCollectionsWithAudio) + } + } + } + + /** + * Returns audiobooks for collection from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryCollectionBooksWithAudio(libraryId: String, collectionId: String, cb: (List) -> Unit) { + if (!cachedLibraryCollections.containsKey(libraryId)) { + loadLibraryCollectionsWithAudio(libraryId) {} + } + Log.d(tag, "Trying to find collection $collectionId items from from cache | Library $libraryId ") + if ( cachedLibraryCollections[libraryId]!!.containsKey(collectionId)) { + val libraryCollectionBookswithAudio = cachedLibraryCollections[libraryId]!![collectionId]?.books + libraryCollectionBookswithAudio?.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + cb(libraryCollectionBookswithAudio as List) + } + } + private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) { if (libraryItemId.startsWith("local")) { cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 0fbf9576d..2e16954fd 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -1029,29 +1029,35 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") - val localBrowseItems:MutableList = mutableListOf() + val localBrowseItems: MutableList = mutableListOf() localBooks.forEach { localLibraryItem -> if (localLibraryItem.media.getAudioTracks().isNotEmpty()) { val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id) val description = localLibraryItem.getMediaDescription(progress, ctx) - localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + localBrowseItems += MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) } } localPodcasts.forEach { localLibraryItem -> val mediaDescription = localLibraryItem.getMediaDescription(null, ctx) - localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + localBrowseItems += MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) } result.sendResult(localBrowseItems) } else if (parentMediaId == CONTINUE_ROOT) { - val localBrowseItems:MutableList = mutableListOf() + val localBrowseItems: MutableList = mutableListOf() mediaManager.serverItemsInProgress.forEach { itemInProgress -> val progress: MediaProgressWrapper? - val mediaDescription:MediaDescriptionCompat + val mediaDescription: MediaDescriptionCompat if (itemInProgress.episode != null) { if (itemInProgress.isLocal) { progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}") @@ -1093,20 +1099,224 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library Log.d(tag, "Loading items for library $parentMediaId") - mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> - val children = libraryItems.map { libraryItem -> - if (libraryItem.mediaType == "podcast") { // Podcasts are browseable - val mediaDescription = libraryItem.getMediaDescription(null, ctx) - MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + val children = mutableListOf( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Library") + .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Collections") + .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + result.sendResult(children as MutableList?) + } else if (parentMediaId.startsWith("__LIBRARY__")) { + Log.d(tag, "Browsing library $parentMediaId") + val mediaIdParts = parentMediaId.split("__") + /* + MediaIdParts for Library + 1: LIBRARY + 2: mediaId for library + 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS) + 4: + - Paging: SERIES_LIST, AUTHORS + - SeriesId: SERIES + - AuthorId: AUTHOR, AUTHOR_SERIES + - CollectionId: COLLECTIONS + 5: SeriesId: AUTHOR_SERIES + */ + if (!mediaManager.getIsLibrary(mediaIdParts[2])) { + Log.d(tag, "${mediaIdParts[2]} is not library") + result.sendResult(null) + return + } + Log.d(tag, "$mediaIdParts") + if (mediaIdParts[3] == "SERIES_LIST" && mediaIdParts.size == 5) { + Log.d(tag, "Loading series from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") + mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2], mediaIdParts[4]) { seriesItems -> + Log.d(tag, "Received ${seriesItems.size} series") + if (seriesItems.size > 500) { + val seriesLetters = seriesItems.groupingBy { iwb -> iwb.title.substring(0, mediaIdParts[4].length + 1).uppercase() }.eachCount() + val children = seriesLetters.map { (seriesLetter, seriesCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(seriesLetter) + .setMediaId("${parentMediaId}${seriesLetter.last()}") + .setSubtitle("$seriesCount series") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) } else { + val children = seriesItems.map { seriesItem -> + val description = seriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + } + }else if (mediaIdParts[3] == "SERIES_LIST") { + Log.d(tag, "Loading series from library ${mediaIdParts[2]}") + mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2]) { seriesItems -> + Log.d(tag, "Received ${seriesItems.size} series") + if (seriesItems.size > 1000) { + val seriesLetters = seriesItems.groupingBy { iwb -> iwb.title.first().uppercaseChar() }.eachCount() + val children = seriesLetters.map { (seriesLetter, seriesCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(seriesLetter.toString()) + .setSubtitle("$seriesCount series") + .setMediaId("${parentMediaId}__${seriesLetter}") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = seriesItems.map { seriesItem -> + val description = seriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "SERIES") { + Log.d(tag, "Loading items for serie ${mediaIdParts[4]} from library ${mediaIdParts[2]}") + mediaManager.loadLibrarySeriesItemsWithAudio( + mediaIdParts[2], + mediaIdParts[4] + ) { libraryItems -> + Log.d(tag, "Received ${libraryItems.size} library items") + val children = libraryItems.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "AUTHORS" && mediaIdParts.size == 5) { + Log.d(tag, "Loading authors from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") + mediaManager.loadAuthorsWithBooks(mediaIdParts[2], mediaIdParts[4]) { authorItems -> + Log.d(tag, "Received ${authorItems.size} authors") + if (authorItems.size > 100) { + val authorLetters = authorItems.groupingBy { iwb -> iwb.name.substring(0, mediaIdParts[4].length + 1).uppercase() }.eachCount() + val children = authorLetters.map { (authorLetter, authorCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(authorLetter) + .setMediaId("${parentMediaId}${authorLetter.last()}") + .setSubtitle("$authorCount authors") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = authorItems.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "AUTHORS") { + Log.d(tag, "Loading authors from library ${mediaIdParts[2]}") + mediaManager.loadAuthorsWithBooks(mediaIdParts[2]) { authorItems -> + Log.d(tag, "Received ${authorItems.size} authors") + if (authorItems.size > 1000) { + val authorLetters = authorItems.groupingBy { iwb -> iwb.name.first().uppercaseChar() }.eachCount() + val children = authorLetters.map { (authorLetter, authorCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(authorLetter.toString()) + .setSubtitle("$authorCount authors") + .setMediaId("${parentMediaId}__${authorLetter}") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = authorItems.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "AUTHOR") { + mediaManager.loadAuthorBooksWithAudio(mediaIdParts[2], mediaIdParts[4]) { libraryItems -> + val children = libraryItems.map { libraryItem -> + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + if (libraryItem.collapsedSeries != null) { + val description = libraryItem.getMediaDescription(progress, ctx, mediaIdParts[4]) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } else { + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "AUTHOR_SERIES") { + mediaManager.loadAuthorSeriesBooksWithAudio(mediaIdParts[2], mediaIdParts[4], mediaIdParts[5]) { libraryItems -> + val children = libraryItems.map { libraryItem -> + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + if (libraryItem.collapsedSeries != null) { + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } else { + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "COLLECTIONS") { + Log.d(tag, "Loading collections from library ${mediaIdParts[2]}") + mediaManager.loadLibraryCollectionsWithAudio(mediaIdParts[2]) { collectionItems -> + Log.d(tag, "Received ${collectionItems.size} collections") + val children = collectionItems.map { collectionItem -> + val description = collectionItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "COLLECTION") { + Log.d(tag, "Loading collection ${mediaIdParts[4]} books from library ${mediaIdParts[2]}") + mediaManager.loadLibraryCollectionBooksWithAudio(mediaIdParts[2], mediaIdParts[4]) { libraryItems -> + Log.d(tag, "Received ${libraryItems.size} collections") + val children = libraryItems.map { libraryItem -> val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) libraryItem.localLibraryItemId = localLibraryItem?.id val description = libraryItem.getMediaDescription(progress, ctx) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } + result.sendResult(children as MutableList?) } - result.sendResult(children as MutableList?) + } else { + result.sendResult(null) } } else { Log.d(tag, "Loading podcast episodes for podcast $parentMediaId") diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index ff8ed6b55..3d4b91de1 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.os.Build import android.provider.Settings +import android.util.Base64 import android.util.Log import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager @@ -189,6 +190,90 @@ class ApiHandler(var ctx:Context) { } } + fun getLibrarySeries(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting series") + getRequest("/api/libraries/$libraryId/series?minified=1&sort=name&limit=10000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibrarySeriesItems(libraryId:String, seriesId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting items for series") + val seriesIdBase64 = Base64.encodeToString(seriesId.toByteArray(), Base64.DEFAULT) + getRequest("/api/libraries/$libraryId/items?minified=1&sort=media.metadata.title&filter=series.${seriesIdBase64}&limit=1000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibraryAuthors(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting series") + getRequest("/api/libraries/$libraryId/authors", null, null) { + val items = mutableListOf() + if (it.has("authors")) { + val array = it.getJSONArray("authors") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + }else{ + Log.e(tag, "No results") + } + cb(items) + } + } + + fun getLibraryItemsFromAuthor(libraryId:String, authorId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting author items") + val authorIdBase64 = Base64.encodeToString(authorId.toByteArray(), Base64.DEFAULT) + getRequest("/api/libraries/$libraryId/items?limit=1000&minified=1&filter=authors.${authorIdBase64}&sort=media.metadata.title&collapseseries=1", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + if (item.collapsedSeries != null) { + item.collapsedSeries?.libraryId = libraryId + } + items.add(item) + } + }else{ + Log.e(tag, "No results") + } + cb(items) + } + } + + fun getLibraryCollections(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting collections") + getRequest("/api/libraries/$libraryId/collections?minified=1&sort=name&limit=1000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + fun getAllItemsInProgress(cb: (List) -> Unit) { getRequest("/api/me/items-in-progress", null, null) { val items = mutableListOf() diff --git a/android/app/src/main/res/drawable-hdpi/md_account_outline.png b/android/app/src/main/res/drawable-hdpi/md_account_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..cddf7919ab25cbdc7e1f8590e6f573e149faac24 GIT binary patch literal 560 zcmV-00?+-4P)tLmnZor8y#574vc3s09hFs6xypoRaIRgz!fZq9l<_euUVF@tiZ1Rq?7@K!5?7+ z>S_d=0&E=%mJ0Ojfl-D6K-0YgL7lm%!&p?{9#+V2D)!KTxQQK#QOS{cWd+?|2rVAF4hs-0tzfWDT^-bl$5b>Sx4Gx zDA@|MsF^mlUo~c>FB*sr5&R90iQ&9J|15SdN(G_K)_i)!7G#^OI850hwz}_5}(N yAZpCt2a5XK9S$~k>K(=kay0;1G7Sn@3jF|+tFtBi()kGh0000Nkla|q()rm|F%jG0sO^! z)53LA`CdrC7roO2wTl-5l%a(|gDaDQ91~MQCz_W4Y|Ue$XaBKua1G!}{omzLz)h+R ho9=tjx~Qi23JzDnbQ!iz@Gt-X002ovPDHLkV1jc!qig^G literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xhdpi/md_account_outline.png b/android/app/src/main/res/drawable-xhdpi/md_account_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..9ca56505f8321c5d193bd8715ea4b8bd1ffdca55 GIT binary patch literal 707 zcmV;!0zCbRP);yvGNj3+^ ze%_mTv$IQaapqQ>IsS`G;M5V!5y%lp^gxzS)q$JM<_7P2Xt!t`v`e(5w6~%tR*6pE z8SWYZfFEG?X8~P@a|{wwFA{D90RA1I9+bdJkhoL!J=o2K0KkvL^KCG>L?W30z*Fa6 z71^xu{Ryo^<7ei?qvS+P@;WsqJP1_1PrwAm=bwQnlbJ+GIM@XJNYrc|1ghE}0@Q3D zB1!Vs=(jog_RZ)q(1XBt#jCZlkSP2_fY%^!l?kMH0A8ge2GJacB*bl&k_(o+oq9-! zyUV0W79yWd^kbzCHid-7DGu}?&;bDEL2TTC2y8Ad37U~&-(c+12Z8dW1h}}dkR@-S zTst`e!FwP-0r{tahp;*z z1WFHnpb@|pe7x7o&5h$JHEy$XM4jx^$Bae-H8&}GhKo!E^vY70M2<}S$sSj8{%%tw zGz9oJ$tYUDy_$0Fn)4Ksdw57k8Upc+OC8!q@$Ts$QsuolSmTXNLtqn4xP(W#7I4}z z)k7iHJBab$5uhVWtv8MRc8a2R1dN#M!!iLRRh-0jR~knqN5Hu5(S|kdLPtYlTqqMT pI?ioaj)0phjT7Yv7#-&}>=%eR^f8j(cQ60|002ovPDHLkV1k^yI3551 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/md_account_outline.png b/android/app/src/main/res/drawable-xxhdpi/md_account_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..e0fcbf9736bf759f625906e2b5193d9ef7cb68fa GIT binary patch literal 1063 zcmV+?1laqDP){@3bmD9suZ~86DiX2cTc# zDdT=5)#EAv5CIePI6VQ-%cTS2?6vlEBHKU2$NoN7K4M@wj|9KmsYYFU#%AEtGSWTUcAJ zDd|ABy%if3{YYu4A8Lwl5=L~H?PpXGif6ilWx^K^daX*d=1UrJH4q}YOWvw zLI|&sxrJcXBm$#(b4I&s4XgE@#iO=BmorG`bjL656YU1v{YMW98NR-2)iySy?7(HRKEyG}D7!F=wlHe}g%0A~gV_*5pT^+HJ{+TCfMO$SfD*b70CEe!mQhoBZ@0MouijiCR@(U4 z${U~rdIMyDbkS)Fnp;7-@mpT);)UY!x~OZh0ZN6Wj_>oYZ3;34=~QkCG6k7}bOcO6 hI)3IsrXZby{sH%XY*>5+!Ak%D002ovPDHLkV1lml@74eS literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/md_account_outline.xml b/android/app/src/main/res/drawable/md_account_outline.xml new file mode 100644 index 000000000..cf8a5c677 --- /dev/null +++ b/android/app/src/main/res/drawable/md_account_outline.xml @@ -0,0 +1 @@ + From e4a3cc5290c6a13a85d370d166a31db7437be301 Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Mon, 16 Sep 2024 23:06:49 +0300 Subject: [PATCH 02/15] Added Android Auto browsing settings --- .../audiobookshelf/app/data/DeviceClasses.kt | 10 +++- .../app/device/DeviceManager.kt | 10 ++++ .../app/player/PlayerNotificationService.kt | 8 +-- pages/settings.vue | 55 ++++++++++++++++++- strings/en-us.json | 7 +++ 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index a002c1411..f1831770b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -133,7 +133,10 @@ data class DeviceSettings( var disableSleepTimerResetFeedback: Boolean, var languageCode: String, var downloadUsingCellular: DownloadUsingCellularSetting, - var streamingUsingCellular: StreamingUsingCellularSetting + var streamingUsingCellular: StreamingUsingCellularSetting, + var androidAutoBrowseForceGrouping: Boolean, + var androidAutoBrowseTopLevelLimitForGrouping: Int, + var androidAutoBrowseLimitForGrouping: Int ) { companion object { // Static method to get default device settings @@ -159,7 +162,10 @@ data class DeviceSettings( disableSleepTimerResetFeedback = false, languageCode = "en-us", downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, - streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS + streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, + androidAutoBrowseForceGrouping = false, + androidAutoBrowseTopLevelLimitForGrouping = 100, + androidAutoBrowseLimitForGrouping = 50 ) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index 37e81cf05..f14654dcb 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -61,6 +61,16 @@ object DeviceManager { if (deviceData.deviceSettings?.streamingUsingCellular == null) { deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS } + + if (deviceData.deviceSettings?.androidAutoBrowseForceGrouping == null) { + deviceData.deviceSettings?.androidAutoBrowseForceGrouping = false + } + if (deviceData.deviceSettings?.androidAutoBrowseTopLevelLimitForGrouping == null) { + deviceData.deviceSettings?.androidAutoBrowseTopLevelLimitForGrouping = 100 + } + if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) { + deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 50 + } } fun getBase64Id(id:String):String { diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 2e16954fd..4a6067899 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -1148,7 +1148,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Loading series from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2], mediaIdParts[4]) { seriesItems -> Log.d(tag, "Received ${seriesItems.size} series") - if (seriesItems.size > 500) { + if (seriesItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping) { val seriesLetters = seriesItems.groupingBy { iwb -> iwb.title.substring(0, mediaIdParts[4].length + 1).uppercase() }.eachCount() val children = seriesLetters.map { (seriesLetter, seriesCount) -> MediaBrowserCompat.MediaItem( @@ -1173,7 +1173,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Loading series from library ${mediaIdParts[2]}") mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2]) { seriesItems -> Log.d(tag, "Received ${seriesItems.size} series") - if (seriesItems.size > 1000) { + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseForceGrouping || seriesItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseTopLevelLimitForGrouping) { val seriesLetters = seriesItems.groupingBy { iwb -> iwb.title.first().uppercaseChar() }.eachCount() val children = seriesLetters.map { (seriesLetter, seriesCount) -> MediaBrowserCompat.MediaItem( @@ -1215,7 +1215,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Loading authors from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") mediaManager.loadAuthorsWithBooks(mediaIdParts[2], mediaIdParts[4]) { authorItems -> Log.d(tag, "Received ${authorItems.size} authors") - if (authorItems.size > 100) { + if (authorItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping) { val authorLetters = authorItems.groupingBy { iwb -> iwb.name.substring(0, mediaIdParts[4].length + 1).uppercase() }.eachCount() val children = authorLetters.map { (authorLetter, authorCount) -> MediaBrowserCompat.MediaItem( @@ -1240,7 +1240,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Loading authors from library ${mediaIdParts[2]}") mediaManager.loadAuthorsWithBooks(mediaIdParts[2]) { authorItems -> Log.d(tag, "Received ${authorItems.size} authors") - if (authorItems.size > 1000) { + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseForceGrouping || authorItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseTopLevelLimitForGrouping) { val authorLetters = authorItems.groupingBy { iwb -> iwb.name.first().uppercaseChar() }.eachCount() val children = authorLetters.map { (authorLetter, authorCount) -> MediaBrowserCompat.MediaItem( diff --git a/pages/settings.vue b/pages/settings.vue index c1d09b6b8..724dc3b1b 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -150,6 +150,28 @@ + + +
@@ -193,7 +215,10 @@ export default { autoSleepTimerAutoRewindTime: 300000, // 5 minutes languageCode: 'en-us', downloadUsingCellular: 'ALWAYS', - streamingUsingCellular: 'ALWAYS' + streamingUsingCellular: 'ALWAYS', + androidAutoBrowseForceGrouping: false, + androidAutoBrowseTopLevelLimitForGrouping: 100, + androidAutoBrowseLimitForGrouping: 50 }, theme: 'dark', lockCurrentOrientation: false, @@ -221,6 +246,14 @@ export default { enableMp3IndexSeeking: { name: this.$strings.LabelEnableMp3IndexSeeking, message: this.$strings.LabelEnableMp3IndexSeekingHelp + }, + androidAutoBrowseForceGrouping: { + name: this.$strings.LabelAndroidAutoBrowseForceGrouping, + message: this.$strings.LabelAndroidAutoBrowseForceGroupingHelp + }, + androidAutoBrowseTopLevelLimitForGrouping: { + name: this.$strings.LabelAndroidAutoBrowseTopLevelLimitForGrouping, + message: this.$strings.LabelAndroidAutoBrowseTopLevelLimitForGroupingHelp } }, hapticFeedbackItems: [ @@ -451,6 +484,18 @@ export default { if (!val) return // invalid times return falsy this.saveSettings() }, + androidAutoBrowseTopLevelLimitForGroupingUpdated(val) { + if (!val) return // invalid times return falsy + if (val > 1000) val = 1000 + if (val < 20) val = 20 + this.saveSettings() + }, + androidAutoBrowseLimitForGroupingUpdated(val) { + if (!val) return // invalid times return falsy + if (val > 1000) val = 1000 + if (val < 20) val = 20 + this.saveSettings() + }, hapticFeedbackUpdated(val) { this.$store.commit('globals/setHapticFeedback', val) this.saveSettings() @@ -493,6 +538,10 @@ export default { this.settings.disableShakeToResetSleepTimer = !this.settings.disableShakeToResetSleepTimer this.saveSettings() }, + toggleAndroidAutoBrowseForceGrouping() { + this.settings.androidAutoBrowseForceGrouping = !this.settings.androidAutoBrowseForceGrouping + this.saveSettings() + }, toggleDisableSleepTimerResetFeedback() { this.settings.disableSleepTimerResetFeedback = !this.settings.disableSleepTimerResetFeedback this.saveSettings() @@ -576,6 +625,10 @@ export default { this.settings.downloadUsingCellular = deviceSettings.downloadUsingCellular || 'ALWAYS' this.settings.streamingUsingCellular = deviceSettings.streamingUsingCellular || 'ALWAYS' + + this.settings.androidAutoBrowseForceGrouping = deviceSettings.androidAutoBrowseForceGrouping + this.settings.androidAutoBrowseTopLevelLimitForGrouping = deviceSettings.androidAutoBrowseTopLevelLimitForGrouping + this.settings.androidAutoBrowseLimitForGrouping = deviceSettings.androidAutoBrowseLimitForGrouping }, async init() { this.loading = true diff --git a/strings/en-us.json b/strings/en-us.json index 24fb35ef3..3c3081b31 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -52,6 +52,7 @@ "ButtonYes": "Yes", "HeaderAccount": "Account", "HeaderAdvanced": "Advanced", + "HeaderAndroidAutoSettings": "Android Auto Settings", "HeaderAudioTracks": "Audio Tracks", "HeaderChapters": "Chapters", "HeaderCollection": "Collection", @@ -90,6 +91,12 @@ "LabelAll": "All", "LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls", "LabelAlways": "Always", + "LabelAndroidAutoBrowseForceGrouping": "Force alphabetical drawdown", + "LabelAndroidAutoBrowseForceGroupingHelp": "Forces alphabetical drawdown while browsing library and series in Android Auto", + "LabelAndroidAutoBrowseLimitForGrouping": "Alphabetical drawdown stopitems", + "LabelAndroidAutoBrowseLimitForGroupingHelp": "Stop alphabetical drawdown when there is less than this amount of items to show", + "LabelAndroidAutoBrowseTopLevelLimitForGrouping": "Alphabetical drawdown start items", + "LabelAndroidAutoBrowseTopLevelLimitForGroupingHelp": "If top-level has more items than this alphabetical drawdown will be used", "LabelAskConfirmation": "Ask for confirmation", "LabelAuthor": "Author", "LabelAuthorFirstLast": "Author (First Last)", From 8134ec84c6bcda11fc41100ea74316b001d46e04 Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Sun, 27 Oct 2024 21:55:17 +0200 Subject: [PATCH 03/15] Enchancements for Android Auto library - Hide libraries without audiobooks - Sort books in series by sequence value - Added option for selecting ASC or DESC sorting for series - Order authors alphabetically --- .../audiobookshelf/app/data/DataClasses.kt | 21 ++++- .../audiobookshelf/app/data/DeviceClasses.kt | 10 ++- .../audiobookshelf/app/data/LibraryItem.kt | 34 ++++++++- .../audiobookshelf/app/data/LocalMediaItem.kt | 2 +- .../app/device/DeviceManager.kt | 3 + .../audiobookshelf/app/media/MediaManager.kt | 42 +++++++--- .../audiobookshelf/app/player/BrowseTree.kt | 1 + .../app/player/PlayerNotificationService.kt | 76 ++++++++++++------- .../audiobookshelf/app/server/ApiHandler.kt | 12 +++ pages/settings.vue | 32 +++++++- strings/en-us.json | 3 + 11 files changed, 189 insertions(+), 47 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index 22e57ee15..cbf76a818 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -214,7 +214,9 @@ class BookMetadata( var authorName:String?, var authorNameLF:String?, var narratorName:String?, - var seriesName:String? + var seriesName:String?, + @JsonFormat(with=[JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY]) + var series:List? ) : MediaTypeMetadata(title, explicit) { @JsonIgnore override fun getAuthorDisplayName():String { return authorName ?: "Unknown" } @@ -342,7 +344,8 @@ data class Library( var name:String, var folders:MutableList, var icon:String, - var mediaType:String + var mediaType:String, + var stats: LibraryStats? ) { @JsonIgnore fun getMediaMetadata(): MediaMetadataCompat { @@ -354,6 +357,20 @@ data class Library( } } +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryStats( + var totalItems: Int, + var totalAuthors: Int, + var numAudioTracks: Int +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class SeriesType( + var id: String, + var name: String, + var sequence: String? +) + @JsonIgnoreProperties(ignoreUnknown = true) data class Folder( var id:String, diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index f1831770b..1d763b374 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -28,6 +28,10 @@ enum class StreamingUsingCellularSetting { ASK, ALWAYS, NEVER } +enum class AndroidAutoBrowseSeriesSequenceOrderSetting { + ASC, DESC +} + data class ServerConnectionConfig( var id:String, var index:Int, @@ -136,7 +140,8 @@ data class DeviceSettings( var streamingUsingCellular: StreamingUsingCellularSetting, var androidAutoBrowseForceGrouping: Boolean, var androidAutoBrowseTopLevelLimitForGrouping: Int, - var androidAutoBrowseLimitForGrouping: Int + var androidAutoBrowseLimitForGrouping: Int, + var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting ) { companion object { // Static method to get default device settings @@ -165,7 +170,8 @@ data class DeviceSettings( streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, androidAutoBrowseForceGrouping = false, androidAutoBrowseTopLevelLimitForGrouping = 100, - androidAutoBrowseLimitForGrouping = 50 + androidAutoBrowseLimitForGrouping = 50, + androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC ) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 4b466982e..7afbc259d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -64,8 +64,27 @@ class LibraryItem( } } + @get:JsonIgnore + val seriesSequence: String + get() { + if (mediaType != "podcast") { + return ((media as Book).metadata as BookMetadata).series?.get(0)?.sequence.orEmpty() + } else { + return "" + } + } + + @get:JsonIgnore + val seriesSequenceParts: List + get() { + if (seriesSequence.isEmpty()) { + return listOf("") + } + return seriesSequence.split(".", limit = 2) + } + @JsonIgnore - fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat { + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat { val extras = Bundle() if (collapsedSeries == null) { @@ -121,20 +140,29 @@ class LibraryItem( if (collapsedSeries != null) { subtitle = "${collapsedSeries!!.numBooks} books" } + var itemTitle = title + if (showSeriesNumber == true && seriesSequence != "") { + itemTitle = "$seriesSequence. $itemTitle" + } return MediaDescriptionCompat.Builder() .setMediaId(mediaId) - .setTitle(title) + .setTitle(itemTitle) .setIconUri(getCoverUri()) .setSubtitle(subtitle) .setExtras(extras) .build() } + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, authorId, null) + } + @JsonIgnore override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { /* This is needed so Android auto library hierarchy for author series can be implemented */ - return getMediaDescription(progress, ctx, null) + return getMediaDescription(progress, ctx, null, null) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt index ef1c79948..aa8380462 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt @@ -41,7 +41,7 @@ data class LocalMediaItem( @JsonIgnore fun getMediaMetadata():MediaTypeMetadata { return if (mediaType == "book") { - BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null) + BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null, null, null) } else { PodcastMetadata(name,null,null, mutableListOf(), false) } diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index f14654dcb..1cc6c9ff9 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -71,6 +71,9 @@ object DeviceManager { if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) { deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 50 } + if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) { + deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC + } } fun getBase64Id(id:String):String { diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 350dc50be..49dd92eb6 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -46,6 +46,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { return serverLibraries.find { it.id == id } != null } + fun getLibrary(id:String) : Library? { + return serverLibraries.find { it.id == id } + } + fun getSavedPlaybackRate():Float { if (userSettingsPlaybackRate != null) { return userSettingsPlaybackRate ?: 1f @@ -185,6 +189,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { cb(seriesWithBooks) } + fun sortSeriesBooks(seriesBooks: List) : List { + val sortingLogic = compareBy { it.seriesSequenceParts[0].length } + .thenBy { it.seriesSequenceParts[0].ifEmpty { "" } } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" }.length } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" } } + return seriesBooks.sortedWith(sortingLogic) + } + /** * Returns books for series from library. * If data is not found from local cache then it will be fetched from server @@ -202,14 +214,15 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { Log.d(tag, "Items for series $seriesId loaded from server | Library $libraryId") val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } - cachedLibrarySeriesItem[libraryId]!![seriesId] = libraryItemsWithAudio + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsWithAudio) + cachedLibrarySeriesItem[libraryId]!![seriesId] = sortedLibraryItemsWithAudio - libraryItemsWithAudio.forEach { libraryItem -> + sortedLibraryItemsWithAudio.forEach { libraryItem -> if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { serverLibraryItems.add(libraryItem) } } - cb(libraryItemsWithAudio) + cb(sortedLibraryItemsWithAudio) } } } @@ -228,8 +241,8 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { apiHandler.getLibraryAuthors(libraryId) { authorItems -> Log.d(tag, "Authors with books loaded from server | Library $libraryId ") // TO-DO: This check won't ensure that there is audiobooks. Current API won't offer ability to do so - val authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 } - + var authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 } + authorItemsWithBooks = authorItemsWithBooks.sortedBy { it.name } // Ensure that there is map for library cachedLibraryAuthors[libraryId] = mutableMapOf() // Cache authors @@ -314,15 +327,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { Log.d(tag, "Using author name: $authorName") val libraryItemsFromAuthorWithAudio = libraryItemsWithAudio.filter { li -> li.authorName.indexOf(authorName, ignoreCase = true) >= 0 } - cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = libraryItemsFromAuthorWithAudio + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsFromAuthorWithAudio) + cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = sortedLibraryItemsWithAudio - libraryItemsFromAuthorWithAudio.forEach { libraryItem -> + sortedLibraryItemsWithAudio.forEach { libraryItem -> if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { serverLibraryItems.add(libraryItem) } } - cb(libraryItemsFromAuthorWithAudio) + cb(sortedLibraryItemsWithAudio) } } } @@ -443,9 +457,15 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (serverLibraries.isNotEmpty()) { cb(serverLibraries) } else { - apiHandler.getLibraries { - serverLibraries = it - cb(it) + apiHandler.getLibraries { loadedLibraries -> + serverLibraries = loadedLibraries.map { library -> + apiHandler.getLibraryStats(library.id) { libraryStats -> + Log.d(tag, "Library stats for library ${library.id} | $libraryStats") + library.stats = libraryStats + } + library + } + cb(serverLibraries) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index d752b5d51..9c374a5b4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -56,6 +56,7 @@ class BrowseTree( rootList += librariesMetadata libraries.forEach { library -> + if (library.stats?.numAudioTracks == 0) return@forEach val libraryMediaMetadata = library.getMediaMetadata() val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf() children += libraryMediaMetadata diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 4a6067899..5d96c60e8 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -1099,30 +1099,44 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library Log.d(tag, "Loading items for library $parentMediaId") - val children = mutableListOf( - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Library") - .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ), - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Series") - .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ), - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Collections") - .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + val selectedLibrary = mediaManager.getLibrary(parentMediaId) + if (selectedLibrary?.mediaType == "podcast") { // Podcasts are browseable + mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> + val children = libraryItems.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } else { + val children = mutableListOf( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Library") + .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Collections") + .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) - ) - result.sendResult(children as MutableList?) + result.sendResult(children as MutableList?) + } } else if (parentMediaId.startsWith("__LIBRARY__")) { Log.d(tag, "Browsing library $parentMediaId") val mediaIdParts = parentMediaId.split("__") @@ -1201,12 +1215,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mediaIdParts[4] ) { libraryItems -> Log.d(tag, "Received ${libraryItems.size} library items") - val children = libraryItems.map { libraryItem -> + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === AndroidAutoBrowseSeriesSequenceOrderSetting.DESC) { + items = libraryItems.reversed() + } + val children = items.map { libraryItem -> val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) libraryItem.localLibraryItemId = localLibraryItem?.id - val description = libraryItem.getMediaDescription(progress, ctx) + val description = libraryItem.getMediaDescription(progress, ctx, null, true) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } result.sendResult(children as MutableList?) @@ -1279,11 +1297,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } else if (mediaIdParts[3] == "AUTHOR_SERIES") { mediaManager.loadAuthorSeriesBooksWithAudio(mediaIdParts[2], mediaIdParts[4], mediaIdParts[5]) { libraryItems -> - val children = libraryItems.map { libraryItem -> + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === AndroidAutoBrowseSeriesSequenceOrderSetting.DESC) { + items = libraryItems.reversed() + } + val children = items.map { libraryItem -> val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) libraryItem.localLibraryItemId = localLibraryItem?.id - val description = libraryItem.getMediaDescription(progress, ctx) + val description = libraryItem.getMediaDescription(progress, ctx, null, true) if (libraryItem.collapsedSeries != null) { MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) } else { diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 3d4b91de1..dc52c82c5 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -150,6 +150,18 @@ class ApiHandler(var ctx:Context) { } } + fun getLibraryStats(libraryItemId:String, cb: (LibraryStats?) -> Unit) { + getRequest("/api/libraries/$libraryItemId/stats", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryStats Failed") + cb(null) + } else { + val libraryStats = jacksonMapper.readValue(it.toString()) + cb(libraryStats) + } + } + } + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) { getRequest("/api/items/$libraryItemId?expanded=1", null, null) { if (it.has("error")) { diff --git a/pages/settings.vue b/pages/settings.vue index 724dc3b1b..147e8b18a 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -170,6 +170,12 @@ info +
+

{{ $strings.LabelAndroidAutoBrowseSeriesSequenceOrder }}

+
+ +
+
@@ -218,7 +224,8 @@ export default { streamingUsingCellular: 'ALWAYS', androidAutoBrowseForceGrouping: false, androidAutoBrowseTopLevelLimitForGrouping: 100, - androidAutoBrowseLimitForGrouping: 50 + androidAutoBrowseLimitForGrouping: 50, + androidAutoBrowseSeriesSequenceOrder: 'ASC' }, theme: 'dark', lockCurrentOrientation: false, @@ -323,6 +330,16 @@ export default { text: this.$strings.LabelNever, value: 'NEVER' } + ], + androidAutoBrowseSeriesSequenceOrderItems: [ + { + text: this.$strings.LabelAscending, + value: 'ASC' + }, + { + text: this.$strings.LabelDescending, + value: 'DESC' + } ] } }, @@ -405,6 +422,10 @@ export default { const item = this.streamingUsingCellularItems.find((i) => i.value === this.settings.streamingUsingCellular) return item?.text || 'Error' }, + androidAutoBrowseSeriesSequenceOrderOption() { + const item = this.androidAutoBrowseSeriesSequenceOrderItems.find((i) => i.value === this.settings.androidAutoBrowseSeriesSequenceOrder) + return item?.text || 'Error' + }, moreMenuItems() { if (this.moreMenuSetting === 'shakeSensitivity') return this.shakeSensitivityItems else if (this.moreMenuSetting === 'hapticFeedback') return this.hapticFeedbackItems @@ -412,6 +433,7 @@ export default { else if (this.moreMenuSetting === 'theme') return this.themeOptionItems else if (this.moreMenuSetting === 'downloadUsingCellular') return this.downloadUsingCellularItems else if (this.moreMenuSetting === 'streamingUsingCellular') return this.streamingUsingCellularItems + else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.androidAutoBrowseSeriesSequenceOrderItems return [] } }, @@ -454,6 +476,10 @@ export default { this.moreMenuSetting = 'streamingUsingCellular' this.showMoreMenuDialog = true }, + showAndroidAutoBrowseSeriesSequenceOrderOptions() { + this.moreMenuSetting = 'androidAutoBrowseSeriesSequenceOrder' + this.showMoreMenuDialog = true + }, clickMenuAction(action) { this.showMoreMenuDialog = false if (this.moreMenuSetting === 'shakeSensitivity') { @@ -474,6 +500,9 @@ export default { } else if (this.moreMenuSetting === 'streamingUsingCellular') { this.settings.streamingUsingCellular = action this.saveSettings() + } else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') { + this.settings.androidAutoBrowseSeriesSequenceOrder = action + this.saveSettings() } }, saveTheme(theme) { @@ -629,6 +658,7 @@ export default { this.settings.androidAutoBrowseForceGrouping = deviceSettings.androidAutoBrowseForceGrouping this.settings.androidAutoBrowseTopLevelLimitForGrouping = deviceSettings.androidAutoBrowseTopLevelLimitForGrouping this.settings.androidAutoBrowseLimitForGrouping = deviceSettings.androidAutoBrowseLimitForGrouping + this.settings.androidAutoBrowseSeriesSequenceOrder = deviceSettings.androidAutoBrowseSeriesSequenceOrder || 'ASC' }, async init() { this.loading = true diff --git a/strings/en-us.json b/strings/en-us.json index 3c3081b31..62f973e11 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -85,6 +85,7 @@ "HeaderTableOfContents": "Table of Contents", "HeaderUserInterfaceSettings": "User Interface Settings", "HeaderYourStats": "Your Stats", + "LabelAscending": "Ascending", "LabelAddToPlaylist": "Add to Playlist", "LabelAdded": "Added", "LabelAddedAt": "Added At", @@ -95,6 +96,7 @@ "LabelAndroidAutoBrowseForceGroupingHelp": "Forces alphabetical drawdown while browsing library and series in Android Auto", "LabelAndroidAutoBrowseLimitForGrouping": "Alphabetical drawdown stopitems", "LabelAndroidAutoBrowseLimitForGroupingHelp": "Stop alphabetical drawdown when there is less than this amount of items to show", + "LabelAndroidAutoBrowseSeriesSequenceOrder": "Series books order", "LabelAndroidAutoBrowseTopLevelLimitForGrouping": "Alphabetical drawdown start items", "LabelAndroidAutoBrowseTopLevelLimitForGroupingHelp": "If top-level has more items than this alphabetical drawdown will be used", "LabelAskConfirmation": "Ask for confirmation", @@ -120,6 +122,7 @@ "LabelContinueReading": "Continue Reading", "LabelContinueSeries": "Continue Series", "LabelCustomTime": "Custom time", + "LabelDescending": "Descending", "LabelDescription": "Description", "LabelDisableAudioFadeOut": "Disable audio fade out", "LabelDisableAudioFadeOutHelp": "Audio volume will start decreasing when there is less than 1 minute remaining on the sleep timer. Enable this setting to not fade out.", From eedcd188c3bff3d9d7fe769bfa407aa929965e52 Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:49:47 +0200 Subject: [PATCH 04/15] Android Auto: Fixed and improved search Search now queries data from server. Results are grouped by books, series and authors. --- .../audiobookshelf/app/data/DataClasses.kt | 19 +++++++ .../app/data/LibraryAuthorItem.kt | 12 ++++- .../audiobookshelf/app/data/LibraryItem.kt | 14 +++-- .../app/data/LibrarySeriesItem.kt | 11 +++- .../audiobookshelf/app/media/MediaManager.kt | 8 +++ .../app/player/PlayerNotificationService.kt | 53 +++++++++++++++++-- .../audiobookshelf/app/server/ApiHandler.kt | 13 +++++ 7 files changed, 119 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index cbf76a818..37c877f3d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -404,3 +404,22 @@ data class LibraryItemWithEpisode( var libraryItemWrapper:LibraryItemWrapper, var episode:PodcastEpisode ) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultSeriesItemType( + var series: LibrarySeriesItem, + var books: List? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultLibraryItemType( + val libraryItem: LibraryItem +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultType( + var book:List?, + var podcast:List?, + var series:List?, + var authors:List? +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt index 160174842..97ce55515 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import android.os.Bundle import android.support.v4.media.MediaDescriptionCompat +import androidx.media.utils.MediaConstants import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.R import com.audiobookshelf.app.device.DeviceManager @@ -15,7 +16,6 @@ class LibraryAuthorItem( id:String, var libraryId:String, var name:String, - var lastFirst:String, var description:String?, var imagePath:String?, var addedAt:Long, @@ -40,8 +40,11 @@ class LibraryAuthorItem( } @JsonIgnore - override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat { val extras = Bundle() + if (groupTitle !== null) { + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) + } val mediaId = "__LIBRARY__${libraryId}__AUTHOR__${id}" return MediaDescriptionCompat.Builder() @@ -52,4 +55,9 @@ class LibraryAuthorItem( .setExtras(extras) .build() } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, null) + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 7afbc259d..89b3fab69 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -84,7 +84,7 @@ class LibraryItem( } @JsonIgnore - fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat { + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?, groupTitle: String?): MediaDescriptionCompat { val extras = Bundle() if (collapsedSeries == null) { @@ -124,6 +124,9 @@ class LibraryItem( ) } } + if (groupTitle !== null) { + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) + } val mediaId = if (localLibraryItemId != null) { localLibraryItemId @@ -153,9 +156,14 @@ class LibraryItem( .build() } + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, authorId, showSeriesNumber, null) + } + @JsonIgnore fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat { - return getMediaDescription(progress, ctx, authorId, null) + return getMediaDescription(progress, ctx, authorId, null, null) } @JsonIgnore @@ -163,6 +171,6 @@ class LibraryItem( /* This is needed so Android auto library hierarchy for author series can be implemented */ - return getMediaDescription(progress, ctx, null, null) + return getMediaDescription(progress, ctx, null, null, null) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt index 8a9a798cf..921a909a1 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt @@ -3,6 +3,7 @@ package com.audiobookshelf.app.data import android.content.Context import android.os.Bundle import android.support.v4.media.MediaDescriptionCompat +import androidx.media.utils.MediaConstants import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties @@ -29,7 +30,7 @@ class LibrarySeriesItem( } @JsonIgnore - override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat { val extras = Bundle() if (localLibraryItemId != null) { @@ -38,6 +39,9 @@ class LibrarySeriesItem( MediaDescriptionCompat.STATUS_DOWNLOADED ) } + if (groupTitle !== null) { + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) + } val mediaId = "__LIBRARY__${libraryId}__SERIES__${id}" return MediaDescriptionCompat.Builder() @@ -48,4 +52,9 @@ class LibrarySeriesItem( .setExtras(extras) .build() } + + @JsonIgnore + override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, null) + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 49dd92eb6..feb755bc8 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -619,6 +619,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } + suspend fun searchLocalCache(libraryId: String, queryString: String) : LibraryItemSearchResultType? { + return suspendCoroutine { + apiHandler.getSearchResults(libraryId, queryString) { results -> + it.resume(results) + } + } + } + fun getFirstItem() : LibraryItemWrapper? { if (serverLibraryItems.isNotEmpty()) { return serverLibraryItems[0] diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 5d96c60e8..73fa6530e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -47,6 +47,7 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.upstream.* +import kotlinx.coroutines.runBlocking import java.util.* import kotlin.concurrent.schedule @@ -1350,13 +1351,55 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { override fun onSearch(query: String, extras: Bundle?, result: Result>) { result.detach() - mediaManager.loadAndroidAutoItems { - browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries) - val children = browseTree[LIBRARIES_ROOT]?.map { item -> - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + Log.d(tag, "Search bundle: $extras") + var foundBooks: MutableList = mutableListOf() + var foundPodcasts: MutableList = mutableListOf() + var foundSeries: MutableList = mutableListOf() + var foundAuthors: MutableList = mutableListOf() + + mediaManager.serverLibraries.forEach { serverLibrary -> + runBlocking{ + val searchResult = mediaManager.searchLocalCache(serverLibrary.id, query) + Log.d(tag, "onSearch: SearchResult: $searchResult") + if (searchResult === null) return@runBlocking + if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.book!!.size} books") + val children = searchResult.book!!.map { bookResult -> + val libraryItem = bookResult.libraryItem + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + foundBooks.addAll(children) + } + if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.series!!.size} series") + val children = searchResult.series!!.map { seriesResult -> + val seriesItem = seriesResult.series + seriesItem.books = seriesResult.books as MutableList + val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundSeries.addAll(children) + } + if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors") + val children = searchResult.authors!!.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundAuthors.addAll(children) + } + Log.d(tag, "onSearch: Library ${serverLibrary.id} processed") } - result.sendResult(children as MutableList?) + Log.d(tag, "onSearch: Library ${serverLibrary.id} scanned") } + foundBooks.addAll(foundSeries) + foundBooks.addAll(foundAuthors) + result.sendResult(foundBooks as MutableList?) + Log.d(tag, "onSearch: Done") } // diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index dc52c82c5..0ba7acf62 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -286,6 +286,19 @@ class ApiHandler(var ctx:Context) { } } + fun getSearchResults(libraryId:String, queryString:String, cb: (LibraryItemSearchResultType?) -> Unit) { + Log.d(tag, "Doing search for library $libraryId") + getRequest("/api/libraries/$libraryId/search?q=$queryString", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getSearchResults Failed") + cb(null) + } else { + val librarySearchResults = jacksonMapper.readValue(it.toString()) + cb(librarySearchResults) + } + } + } + fun getAllItemsInProgress(cb: (List) -> Unit) { getRequest("/api/me/items-in-progress", null, null) { val items = mutableListOf() From b335fd30d12854c428bee47f0f424ca1f2747544 Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:20:03 +0200 Subject: [PATCH 05/15] Android Auto improvements General: - New top menu item Recent is added - Library caches are cleared when switching server Search: - Is done using server API - Latest search is cache to prevent need to make new request when returning from browsable item. - Results are grouped by book, series, author and split by library - Only searches libraries with audio content Library personalized shelves: - Recent books, series, authors, podcasts and episodes shelves are listed under Recent top menu - Discovery shelves can be found under library many from corresponding library --- .../audiobookshelf/app/data/DataClasses.kt | 69 ++++- .../audiobookshelf/app/data/LibraryItem.kt | 3 +- .../audiobookshelf/app/media/MediaManager.kt | 251 ++++++++++++++-- .../audiobookshelf/app/player/BrowseTree.kt | 19 +- .../app/player/PlayerNotificationService.kt | 274 ++++++++++++++---- .../audiobookshelf/app/server/ApiHandler.kt | 17 ++ .../res/drawable-anydpi/md_clock_outline.xml | 1 + .../res/drawable-hdpi/md_clock_outline.png | Bin 0 -> 798 bytes .../res/drawable-mdpi/md_clock_outline.png | Bin 0 -> 532 bytes .../res/drawable-xhdpi/md_clock_outline.png | Bin 0 -> 1063 bytes .../res/drawable-xxhdpi/md_clock_outline.png | Bin 0 -> 1562 bytes .../main/res/drawable/md_clock_outline.xml | 1 + 12 files changed, 556 insertions(+), 79 deletions(-) create mode 100644 android/app/src/main/res/drawable-anydpi/md_clock_outline.xml create mode 100644 android/app/src/main/res/drawable-hdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-mdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-xhdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable/md_clock_outline.xml diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index 37c877f3d..e81e84cf4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -348,9 +348,13 @@ data class Library( var stats: LibraryStats? ) { @JsonIgnore - fun getMediaMetadata(): MediaMetadataCompat { + fun getMediaMetadata(targetType: String? = null): MediaMetadataCompat { + var mediaId = id + if (targetType !== null) { + mediaId = "__RECENTLY__$id" + } return MediaMetadataCompat.Builder().apply { - putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name) putString(MediaMetadataCompat.METADATA_KEY_TITLE, name) }.build() @@ -423,3 +427,64 @@ data class LibraryItemSearchResultType( var series:List?, var authors:List? ) + +@JsonTypeInfo( + use=JsonTypeInfo.Id.NAME, + property = "type", + include = JsonTypeInfo.As.PROPERTY, + visible = true +) +@JsonSubTypes( + JsonSubTypes.Type(LibraryShelfBookEntity::class, name = "book"), + JsonSubTypes.Type(LibraryShelfSeriesEntity::class, name = "series"), + JsonSubTypes.Type(LibraryShelfAuthorEntity::class, name = "authors"), + JsonSubTypes.Type(LibraryShelfEpisodeEntity::class, name = "episode"), + JsonSubTypes.Type(LibraryShelfPodcastEntity::class, name = "podcast") +) +@JsonIgnoreProperties(ignoreUnknown = true) +sealed class LibraryShelfType( + open val id: String, + open val label: String, + open val total: Int, + open val type: String, +) + +data class LibraryShelfBookEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfSeriesEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfAuthorEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfEpisodeEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfPodcastEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 89b3fab69..965ca35d5 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -33,7 +33,8 @@ class LibraryItem( var libraryFiles:MutableList?, var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads) var collapsedSeries: CollapsedSeries?, - var localLibraryItemId:String? // For Android Auto + var localLibraryItemId:String?, // For Android Auto + val recentEpisode: PodcastEpisode? // Podcast episode shelf uses this ) : LibraryItemWrapper(id) { @get:JsonIgnore val title: String diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index feb755bc8..3506e7776 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -20,8 +20,6 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { val tag = "MediaManager" private var serverLibraryItems = mutableListOf() // Store all items here - private var selectedLibraryItems = mutableListOf() - private var selectedLibraryId = "" private var cachedLibraryAuthors : MutableMap> = hashMapOf() private var cachedLibraryAuthorItems : MutableMap>> = hashMapOf() @@ -29,11 +27,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { private var cachedLibrarySeries : MutableMap> = hashMapOf() private var cachedLibrarySeriesItem : MutableMap>> = hashMapOf() private var cachedLibraryCollections : MutableMap> = hashMapOf() + private var cachedLibraryRecentShelfs : MutableMap> = hashMapOf() + private var cachedLibraryDiscovery : MutableMap> = hashMapOf() + private var cachedLibraryPodcasts : MutableMap> = hashMapOf() + private var isLibraryPodcastsCached : MutableMap = hashMapOf() private var selectedPodcast:Podcast? = null private var selectedLibraryItemId:String? = null private var podcastEpisodeLibraryItemMap = mutableMapOf() - private var serverLibraryCategories = listOf() private var serverConfigIdUsed:String? = null private var serverConfigLastPing:Long = 0L var serverUserMediaProgress:MutableList = mutableListOf() @@ -46,10 +47,27 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { return serverLibraries.find { it.id == id } != null } + fun getHasDiscovery(libraryId: String) : Boolean { + if (cachedLibraryDiscovery.containsKey(libraryId)) { + if (cachedLibraryDiscovery[libraryId]!!.isNotEmpty()) { + return true + } + } else { + populatePersonalizedDataForLibrary(libraryId){} + } + return false + } + fun getLibrary(id:String) : Library? { return serverLibraries.find { it.id == id } } + private fun addServerLibrary(libraryItem: LibraryItem) { + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + fun getSavedPlaybackRate():Float { if (userSettingsPlaybackRate != null) { return userSettingsPlaybackRate ?: 1f @@ -109,11 +127,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) { podcastEpisodeLibraryItemMap = mutableMapOf() - serverLibraryCategories = listOf() serverLibraries = listOf() serverLibraryItems = mutableListOf() - selectedLibraryItems = mutableListOf() - selectedLibraryId = "" + cachedLibraryAuthors = hashMapOf() + cachedLibraryAuthorItems = hashMapOf() + cachedLibraryAuthorSeriesItems = hashMapOf() + cachedLibrarySeries = hashMapOf() + cachedLibrarySeriesItem = hashMapOf() + cachedLibraryCollections = hashMapOf() + cachedLibraryRecentShelfs = hashMapOf() + cachedLibraryDiscovery = hashMapOf() + cachedLibraryPodcasts = hashMapOf() + isLibraryPodcastsCached = hashMapOf() } } @@ -131,24 +156,112 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } - fun loadLibraryItemsWithAudio(libraryId:String, cb: (List) -> Unit) { - if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) { - cb(selectedLibraryItems) + fun populatePersonalizedDataForAllLibraries(cb: () -> Unit ) { + serverLibraries.forEach { + Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}") + populatePersonalizedDataForLibrary(it.id) { + Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}") + } + } + } + + private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) { + apiHandler.getLibraryPersonalized(libraryId) { shelfs -> + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId") + if (shelfs === null) return@getLibraryPersonalized + shelfs.map { shelf -> + Log.d(tag, "$shelf") + if (shelf.type == "book") { + if (shelf.id == "continue-listening") return@map + else if (shelf.id == "listen-again") return@map + else if (shelf.id == "recently-added") { + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } + else if (shelf.id == "discover") { + if (!cachedLibraryDiscovery.containsKey(libraryId)) { + cachedLibraryDiscovery[libraryId] = mutableListOf() + } + (shelf as LibraryShelfBookEntity).entities?.map { + cachedLibraryDiscovery[libraryId]!!.add(it) + } + } + else if (shelf.id == "continue-reading") return@map + else if (shelf.id == "continue-series") return@map + shelf as LibraryShelfBookEntity + } else if (shelf.type == "series") { + if (shelf.id == "recent-series") { + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } + } else if (shelf.type == "episode") { + if (shelf.id == "continue-listening") return@map + else if (shelf.id == "listen-again") return@map + else if (shelf.id == "newest-episodes") { + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + + (shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem -> + loadPodcastItem(libraryItem.libraryId, libraryItem.id) {} + } + } + } else if (shelf.type == "podcast") { + if (shelf.id == "recently-added"){ + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } + else if (shelf.id == "discover"){ + return@map + } + } else if (shelf.type =="authors") { + if (shelf.id == "newest-authors") { + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } + } + + } + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId DONE") + cb() + } + } + + fun loadLibraryPodcasts(libraryId:String, cb: (List?) -> Unit) { + // Without this there is possibility that only recent podcasts get loaded + // Loading recent podcasts will also create cachedLibraryPodcasts entry for library + if (!isLibraryPodcastsCached.containsKey(libraryId)) { + isLibraryPodcastsCached[libraryId] = false + } + // Ensure that there is map for library + if (!cachedLibraryPodcasts.containsKey(libraryId)) { + cachedLibraryPodcasts[libraryId] = mutableMapOf() + } + if (isLibraryPodcastsCached.getOrElse(libraryId) {false}) { + Log.d(tag, "loadLibraryPodcasts: Found from cache: $libraryId") + cb(cachedLibraryPodcasts[libraryId]?.values?.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) } else { apiHandler.getLibraryItems(libraryId) { libraryItems -> val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } - if (libraryItemsWithAudio.isNotEmpty()) { - selectedLibraryId = libraryId - } - selectedLibraryItems = mutableListOf() libraryItemsWithAudio.forEach { libraryItem -> - selectedLibraryItems.add(libraryItem) + cachedLibraryPodcasts[libraryId]?.set(libraryItem.id, libraryItem) if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { serverLibraryItems.add(libraryItem) } } - cb(libraryItemsWithAudio) + isLibraryPodcastsCached[libraryId] = true + Log.d(tag, "loadLibraryPodcasts: loaded from server: $libraryId") + cb(libraryItemsWithAudio.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) } } } @@ -386,6 +499,62 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } + fun loadLibraryDiscoveryBooksWithAudio(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryDiscovery.containsKey(libraryId)) { + cb(listOf()) + } + val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]!!.filter { li -> li.checkHasTracks() } + libraryItemsWithAudio.forEach { libraryItem -> addServerLibrary(libraryItem) } + cb(libraryItemsWithAudio) + } + + fun getLibraryRecentShelfs(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cb(listOf()) + return + } + cb(cachedLibraryRecentShelfs[libraryId] as List) + } + + fun getLibraryRecentShelfByType(libraryId: String, type:String, cb: (LibraryShelfType?) -> Unit) { + Log.d(tag, "getLibraryRecentShelfByType: $libraryId | $type") + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cb(null) + return + } + for (shelf in cachedLibraryRecentShelfs[libraryId]!!) { + if (shelf.type == type.lowercase()) { + cb(shelf) + return + } + } + cb(null) + } + + private fun loadPodcastItem(libraryId: String, libraryItemId: String, cb: (LibraryItem?) -> Unit) { + // Ensure that there is map for library + if (!cachedLibraryPodcasts.containsKey(libraryId)) { + cachedLibraryPodcasts[libraryId] = mutableMapOf() + } + if (cachedLibraryPodcasts[libraryId]!!.containsKey(libraryItemId)) { + Log.d(tag, "Podcast found from cache | Library $libraryItemId ") + cb(cachedLibraryPodcasts[libraryId]?.get(libraryItemId)) + } else { + Log.d(tag, "loadPodcastItem: $libraryItemId") + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + if (libraryItem !== null) { + Log.d(tag, "loadPodcastItem: Got library item $libraryItem") + val podcast = libraryItem.media as Podcast + podcast.episodes?.forEach { podcastEpisode -> + podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItem, podcastEpisode) + } + cachedLibraryPodcasts[libraryId]?.set(libraryItemId, libraryItem) + cb(libraryItem) + } + } + } + } + private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) { if (libraryItemId.startsWith("local")) { cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)) @@ -619,10 +788,56 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } - suspend fun searchLocalCache(libraryId: String, queryString: String) : LibraryItemSearchResultType? { + suspend fun doSearch(libraryId: String, queryString: String) : Map> { return suspendCoroutine { - apiHandler.getSearchResults(libraryId, queryString) { results -> - it.resume(results) + apiHandler.getSearchResults(libraryId, queryString) { searchResult -> + Log.d(tag, "searchLocalCache: $searchResult") + // Nothing found from server + if (searchResult === null) { + it.resume(mapOf()) + return@getSearchResults + } + + var foundItems: MutableMap> = mutableMapOf() + + val serverLibrary = serverLibraries.find { sl -> sl.id == libraryId } + + // Books + if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) { + Log.d(tag, "searchLocalCache: found ${searchResult.book!!.size} books") + val children = searchResult.book!!.map { bookResult -> + val libraryItem = bookResult.libraryItem + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + foundItems["book"] = children + } + if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.series!!.size} series") + val children = searchResult.series!!.map { seriesResult -> + val seriesItem = seriesResult.series + seriesItem.books = seriesResult.books as MutableList + val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["series"] = children + } + if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors") + val children = searchResult.authors!!.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["authors"] = children + } + + it.resume(foundItems) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index 9c374a5b4..9bc5c16eb 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -32,10 +32,16 @@ class BrowseTree( val continueListeningMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) - putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening") + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue") putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString()) }.build() + val recentMetadata = MediaMetadataCompat.Builder().apply { + putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, RECENTLY_ROOT) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Recent") + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.md_clock_outline).toString()) + }.build() + val downloadsMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") @@ -53,14 +59,24 @@ class BrowseTree( } if (libraries.isNotEmpty()) { + rootList += recentMetadata rootList += librariesMetadata libraries.forEach { library -> + // Skip libraries without audio content if (library.stats?.numAudioTracks == 0) return@forEach + + // Generate library list items for Libraries menu val libraryMediaMetadata = library.getMediaMetadata() val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf() children += libraryMediaMetadata mediaIdToChildren[LIBRARIES_ROOT] = children + + // Generate library list items for Recent menu + val recentlyMediaMetadata = library.getMediaMetadata("recently") + val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf() + childrenRecently += recentlyMediaMetadata + mediaIdToChildren[RECENTLY_ROOT] = childrenRecently } } @@ -76,3 +92,4 @@ const val AUTO_BROWSE_ROOT = "/" const val CONTINUE_ROOT = "__CONTINUE__" const val DOWNLOADS_ROOT = "__DOWNLOADS__" const val LIBRARIES_ROOT = "__LIBRARIES__" +const val RECENTLY_ROOT = "__RECENTLY__" diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 73fa6530e..36629c12d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -120,6 +120,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var mShakeDetector: ShakeDetector? = null private var shakeSensorUnregisterTask:TimerTask? = null + // These are used to prevent things running multiple times or simultaneously + private var isLoadingAndroidAutoItems:Boolean = false + + // Cache latest search so it wont trigger again when returning from series for example + private var cachedSearch : String = "" + private var cachedSearchResults : MutableList = mutableListOf() + /* Service related stuff */ @@ -977,6 +984,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private val AUTO_MEDIA_ROOT = "/" private val LIBRARIES_ROOT = "__LIBRARIES__" + private val RECENTLY_ROOT = "__RECENTLY__" private val DOWNLOADS_ROOT = "__DOWNLOADS__" private val CONTINUE_ROOT = "__CONTINUE__" private lateinit var browseTree:BrowseTree @@ -1088,22 +1096,37 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } result.sendResult(localBrowseItems) - } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) { - mediaManager.loadAndroidAutoItems { - browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries) - - val children = browseTree[parentMediaId]?.map { item -> - Log.d(tag, "Loading Browser Media Item ${item.description.title}") - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } else if (parentMediaId == AUTO_MEDIA_ROOT) { + Log.d(tag, "Trying to initialize browseTree.") + if (!this::browseTree.isInitialized) { + isLoadingAndroidAutoItems = true + mediaManager.loadAndroidAutoItems { + browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries) + + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "Found top menu item: ${item.description.title}") + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + Log.d(tag, "browseTree initialize and android auto loaded") + result.sendResult(children as MutableList?) + isLoadingAndroidAutoItems = false + Log.d(tag, "Starting personalization fetch") + mediaManager.populatePersonalizedDataForAllLibraries {} } - result.sendResult(children as MutableList?) } + } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) { + while (!this::browseTree.isInitialized) {} + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library Log.d(tag, "Loading items for library $parentMediaId") val selectedLibrary = mediaManager.getLibrary(parentMediaId) if (selectedLibrary?.mediaType == "podcast") { // Podcasts are browseable - mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> - val children = libraryItems.map { libraryItem -> + mediaManager.loadLibraryPodcasts(parentMediaId) { libraryItems -> + val children = libraryItems?.map { libraryItem -> val mediaDescription = libraryItem.getMediaDescription(null, ctx) MediaBrowserCompat.MediaItem( mediaDescription, @@ -1116,7 +1139,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { val children = mutableListOf( MediaBrowserCompat.MediaItem( MediaDescriptionCompat.Builder() - .setTitle("Library") + .setTitle("Authors") .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE @@ -1136,8 +1159,149 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) ) + if (mediaManager.getHasDiscovery(parentMediaId)) { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Discovery") + .setMediaId("__LIBRARY__${parentMediaId}__DISCOVERY") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } result.sendResult(children as MutableList?) } + } else if (parentMediaId.startsWith(RECENTLY_ROOT)) { + Log.d(tag, "Browsing recently $parentMediaId") + val mediaIdParts = parentMediaId.split("__") + if (!mediaManager.getIsLibrary(mediaIdParts[2])) { + Log.d(tag, "${mediaIdParts[2]} is not library") + result.sendResult(null) + return + } + Log.d(tag, "Mediaparts: ${mediaIdParts.size} | $mediaIdParts") + if(mediaIdParts.size == 3) { + mediaManager.getLibraryRecentShelfs(mediaIdParts[2]) { availableShelfs -> + Log.d(tag, "Found ${availableShelfs.size} shelfs") + val children : MutableList = mutableListOf() + for (shelf in availableShelfs) { + if (shelf.type == "book") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Books") + .setMediaId("${parentMediaId}__BOOK") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "series") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("${parentMediaId}__SERIES") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "episode") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Episodes") + .setMediaId("${parentMediaId}__EPISODE") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "podcast") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Podcast") + .setMediaId("${parentMediaId}__PODCAST") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "authors") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Authors") + .setMediaId("${parentMediaId}__AUTHORS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts.size == 4) { + mediaManager.getLibraryRecentShelfByType(mediaIdParts[2], mediaIdParts[3]) { shelf -> + if (shelf === null) { + result.sendResult(mutableListOf()) + }else { + if (shelf.type == "book") { + val children = (shelf as LibraryShelfBookEntity).entities?.map { libraryItem -> + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, false) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + }else if (shelf.type == "episode") { + val episodesWithRecentEpisode = (shelf as LibraryShelfEpisodeEntity).entities?.filter { libraryItem -> libraryItem.recentEpisode !== null } + val children = episodesWithRecentEpisode?.map { libraryItem -> + val podcast = libraryItem.media as Podcast + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.libraryId && it.episodeId == libraryItem.recentEpisode?.id } + + // to show download icon + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.recentEpisode!!.id) + localLibraryItem?.let { lli -> + val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == libraryItem.recentEpisode.id } + libraryItem.recentEpisode.localEpisodeId = localEpisode?.id + } + + val description = libraryItem.recentEpisode.getMediaDescription(libraryItem, progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + }else if (shelf.type == "podcast") { + val children = (shelf as LibraryShelfPodcastEntity).entities?.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + else if (shelf.type == "series") { + val children = (shelf as LibraryShelfSeriesEntity).entities?.map { librarySeriesItem -> + val description = librarySeriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + else if (shelf.type == "authors") { + val children = (shelf as LibraryShelfAuthorEntity).entities?.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + else { + result.sendResult(mutableListOf()) + } + + } + } + } } else if (parentMediaId.startsWith("__LIBRARY__")) { Log.d(tag, "Browsing library $parentMediaId") val mediaIdParts = parentMediaId.split("__") @@ -1145,7 +1309,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaIdParts for Library 1: LIBRARY 2: mediaId for library - 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS) + 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS, DISCOVERY) 4: - Paging: SERIES_LIST, AUTHORS - SeriesId: SERIES @@ -1338,7 +1502,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } result.sendResult(children as MutableList?) } - } else { + } else if (mediaIdParts[3] == "DISCOVERY") { + Log.d(tag, "Loading discovery from library ${mediaIdParts[2]}") + mediaManager.loadLibraryDiscoveryBooksWithAudio(mediaIdParts[2]) { libraryItems -> + Log.d(tag, "Received ${libraryItems.size} libraryItems for discovery") + val children = libraryItems.map { libraryItem -> + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + } + }else { result.sendResult(null) } } else { @@ -1351,54 +1528,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { override fun onSearch(query: String, extras: Bundle?, result: Result>) { result.detach() - Log.d(tag, "Search bundle: $extras") - var foundBooks: MutableList = mutableListOf() - var foundPodcasts: MutableList = mutableListOf() - var foundSeries: MutableList = mutableListOf() - var foundAuthors: MutableList = mutableListOf() - - mediaManager.serverLibraries.forEach { serverLibrary -> - runBlocking{ - val searchResult = mediaManager.searchLocalCache(serverLibrary.id, query) - Log.d(tag, "onSearch: SearchResult: $searchResult") - if (searchResult === null) return@runBlocking - if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) { - Log.d(tag, "onSearch: found ${searchResult.book!!.size} books") - val children = searchResult.book!!.map { bookResult -> - val libraryItem = bookResult.libraryItem - val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } - val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) - libraryItem.localLibraryItemId = localLibraryItem?.id - val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary.name})") - MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) - } - foundBooks.addAll(children) - } - if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) { - Log.d(tag, "onSearch: found ${searchResult.series!!.size} series") - val children = searchResult.series!!.map { seriesResult -> - val seriesItem = seriesResult.series - seriesItem.books = seriesResult.books as MutableList - val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary.name})") - MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) - } - foundSeries.addAll(children) - } - if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) { - Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors") - val children = searchResult.authors!!.map { authorItem -> - val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary.name})") - MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + if (cachedSearch != query) { + Log.d(tag, "Search bundle: $extras") + var foundBooks: MutableList = mutableListOf() + var foundPodcasts: MutableList = mutableListOf() + var foundSeries: MutableList = mutableListOf() + var foundAuthors: MutableList = mutableListOf() + + mediaManager.serverLibraries.forEach { serverLibrary -> + runBlocking { + // Skip searching library if it doesn't have any audio files + if (serverLibrary.stats?.numAudioTracks == 0) return@runBlocking + val searchResult = mediaManager.doSearch(serverLibrary.id, query) + for (resultData in searchResult.entries.iterator()) { + when (resultData.key) { + "book" -> foundBooks.addAll(resultData.value) + "series" -> foundSeries.addAll(resultData.value) + "authors" -> foundAuthors.addAll(resultData.value) + "podcast" -> foundPodcasts.addAll(resultData.value) + } } - foundAuthors.addAll(children) } - Log.d(tag, "onSearch: Library ${serverLibrary.id} processed") } - Log.d(tag, "onSearch: Library ${serverLibrary.id} scanned") + foundBooks.addAll(foundSeries) + foundBooks.addAll(foundAuthors) + cachedSearchResults = foundBooks } - foundBooks.addAll(foundSeries) - foundBooks.addAll(foundAuthors) - result.sendResult(foundBooks as MutableList?) + result.sendResult(cachedSearchResults) + cachedSearch = query Log.d(tag, "onSearch: Done") } @@ -1462,6 +1619,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { hasNetworkConnectivity = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) Log.i(tag, "Network capabilities changed. hasNetworkConnectivity=$hasNetworkConnectivity | isUnmeteredNetwork=$isUnmeteredNetwork") clientEventEmitter?.onNetworkMeteredChanged(isUnmeteredNetwork) + if (hasNetworkConnectivity) { + // TODO: Trigger android auto loading if it is not loaded previously + } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 0ba7acf62..9c564ae3f 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -162,6 +162,23 @@ class ApiHandler(var ctx:Context) { } } + fun getLibraryPersonalized(libraryItemId:String, cb: (List?) -> Unit) { + getRequest("/api/libraries/$libraryItemId/personalized", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryStats Failed") + cb(null) + } else { + val items = mutableListOf() + val array = it.getJSONArray("value") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + cb(items) + } + } + } + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) { getRequest("/api/items/$libraryItemId?expanded=1", null, null) { if (it.has("error")) { diff --git a/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml new file mode 100644 index 000000000..6fa1e7188 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-hdpi/md_clock_outline.png b/android/app/src/main/res/drawable-hdpi/md_clock_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..a0dc9c163faf7529e138332b567dcac3dccd8a05 GIT binary patch literal 798 zcmV+(1L6FMP)kcM^VINfyTRPQNa9UDs`=MxdD?fxz%e z@=@}p(a@9jtp)6+aerILjFB%BfTZ?e?MlGD*MLk_CUAc6U6iX#vdZlo9>#bwTXGLXJ^tFz~02S)*!GQxCJq=yeJKvHAU z^-b^Ihxa$qbCm;?1tbL=h8?Gzq$D}3tc-b{VH@WLIW+)e)$~J(y^^*iX2KQlO9nQj z7$!_TY5!@uKL>~;_E5pdS&11LA>pqj#oWNKIeQ;+YLMhPKtD?dGWpRA$UvR@kUPpxpK~pRzhTHO33`2&X5wM#=uSrD3)io3jQ=? z#t>DEEmkl0Qb2^utLv?(d`JO26CkdWM^BAdZ7HA_TdHgKw*i6LFT>N8i!kI6MF6W+ zCoF7xcUEVP#5_mHmQu_`>Z@7|a$=#)M1!>xp0gvk7!qwnF$P#oNp0SecQnzJorNvN zjZM_=&vv`J6UC&CVN?jMRAc9M+h@}dW;q?#$2yy0hpN-dY7O6 zAB(3KiCXKxyDaTBV0P;hM#2f8!=zx7gx^7fZcuHmb6E=cvhd2wOcM3xYFruln5QO6npo63WpaM(v#gdPS%(Y}ft)`%=h!v^7jc?gvL_Jkup*Kp0vQv{ zw~LkUE$u1}xt_m%EZw!;buFsg;MjFw@@a@6U+mCjxgXO>B;^I1foyEOAdXa%B( z$}&WNL!JL>qY4kY-yCJ2!#Dl0$VKlQWHWH*sBR@}L@*Bts6Fb)*8YSHtO|P(oQDLK z-Ylo-69|y?pO7Tr0D}@i_F*XiBbX@2gMfM!>Cj5lHz=VLdPJRRWTZ4tuDz0c0&8+o zhCZ1bSu;@@kL#cq?~x{(+{@J0ahQeucC+e{vZ5GQ9%NqoLJ1$5l_~kqQe?b-MB!yc zk8bQ8rA7s6Vqvt)We}8%>?tt|nfLcf!)`DWR~GuQpj1NCzudU?Cn_m3^cdGR#CVY4 z-t9D-`P76Xru4xfZ5$0PNRf6wL4D&>KAEI{E!4tPAX?B0?_@Xhy(khCqAwngPVf(- WRq+`b&M|BN0000-|b4j^y{&=I63mMICA|@o&fxB56q>xZTQ+P0Aj)I$_kq<>-~n35yqC%SUX@uYV6LxZVuWbiw9u*wfKujz zNL)KoQ4}6;L@>Xrh8lzT0hArY#zoCWwE&=uRlZActCRx4I9(u$00IGBe2pwBGDy4M zWZEotEuOf`c_IryHYPn{{d8k^emPejACyy?0s=7UKvo#oA%I{*EPo*z0%G=|GJhI_ zJ&L^3NLUU4+2$r$*hCvq<}j=k1&UNnJb?$k8->HnlyFdqiqDh z+u*D*O?G)e0T6R|VVQWsHDCmQU|N+H=Yn902Nc9wt|X9QxUnz-Ac8?nAf*HA?{f;< zXEOl63%^QwcmQY@(!xUPd)cDATCkf!gDf$EEuf>i-~q6Unu0eGJ_i6xqmeN^0AEB2 z3fM5Rg(ULKR0^pVS8^pDhcW7rJOD(0S`A5G&4p_w05%Rrf$RYwfC=PbGzajEG0+8o z$WMEp)0X`2#en?RWdv&o?rl~7Clpdp&BsP3rSAp6SsHg909(Xp-gZ&2W`cMv"r z9eJH&wESF0YJ2AkSxk49ZRQ3)LJVS{<{fd-+@ zCj8yiR+KhoGfOjh5T30s#m@VAW8?Nu$wc`kg%EAXTJkH2?)+Ket$gxAMomOsGZYxL0Md zE)$Bpk-2g#{>Q8GBWy3Mz$O3+0^v}~y1dHznU|V9$zII>1_grP?g4?~G<6?qC^Oju z=>|yX002ovPDHLkV1hR|+b{qC literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png b/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..c1c30bb6780f5ff1a3f079d520931e600bc48edb GIT binary patch literal 1562 zcmV+#2IcvQP)A)EOR7((KINbB zxn_G0^oe@MpMUA|cC}jV+w>5>dKLu{kO9JLGV#p_MBL(&lN-9|+gz}^0wOR_$y=&h zR!R*J?V7Lj&r5;@wRI_70I^~|bObYn0abnK0V);H5x_hGMUXBtrIZGU1Minkt`r$F z0{eMZj7kASaNd(qa47_1Pr)E=sqU#htWZ>k6${s|xuU=4R5x5beGhFAo8Wp)r3ui; zr2xn(001E0sD35m@q4Jy{jdUJCEPkMylHj0YCsI>Ih6_~Z5O#u2#{l@pcojD7HcAdL0hsBTMaEPA|CC% z{kj414E<7MjF~~aIU1Z{&;}&f_sXmA2q5fpt_1Q;D}{G9Bkc&%55?Vf1n9!kp9>J( zY)zfvhNU1Fnvs%G)c^hD?^iT{YtI0rHmrEq4BJcP)T1IVAZ^t`KyF&CdH~{hgn$i5 zrQ%xEOhATu+)~xl&Sh)oYz)_hfi|yP<348sBzgn6*fVJ|ML>M4C@qoA`<|&WGXc_qwRrr{0O7P= z%D1Gt7t23=qgYY|O@y=}i`hpFkQDiwvvkK$kg=MHW6}Ugw8JR_D_GCrVhl)%L!wt` zfTXlRYJi^E#Wr+5muJ+~BdL>H3+^_{emX@I09-gOF&K$_9TQUT%o zQc7<;%T9{T1xRq`hqF}GEFSXGlu zHM3ioZI9dGr75V&EJpHA-2B$2JJOFE(g0@Y-xLrF__Pltbw-vAHi4KKpH{bJQbLLFaRZ0iGQc3KU$#; z*R#r`7pqM5pdT+FEf%FdBTIw>o23`3+)ssFfE+3k4|`byj`YKEM|;84y%UcB(o_Wd zU6PFqw6?9X=Rud|$~C^P8xX5#!?KlZKPn_v4tCx+M@l7>Qx5@RNYWv8FbJs$tPbJ} z0kz7|u5-8*E_b`uaIY{RM?4y2obqan2PUspcnY&t+E;B!ssJ%0DdRY{@rHguqN|IV zMlQ9b`;_wM&8ltzq%EVIBAhgje4Vlc6i}d+>aTT1XK6M-1z1>_P_T55S1dpn z>VbYn{mp7Y)}VUHj>19vqX4Qg^jOy{fDG~rpaoEyb1n*66x61mf1bSTXJwyMmjD0& M07*qoM6N<$f@k%*@c;k- literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/md_clock_outline.xml b/android/app/src/main/res/drawable/md_clock_outline.xml new file mode 100644 index 000000000..6fa1e7188 --- /dev/null +++ b/android/app/src/main/res/drawable/md_clock_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file From 802c16c0dfb474624333bf117d1e1ab456f7b601 Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Thu, 14 Nov 2024 19:23:55 +0200 Subject: [PATCH 06/15] Android Auto improvements. Icons, Start up Icons: - Static browsable list items everywhere have hardcoded icons - Libraries use icons that are defined on server Start up / Initial loading: - Initial loading first loads libraries from server. After that personalized shelves and items in progress are fetched simultaneously. Top menu items are update after every stage. - If network connection is not available when android auto starts app tries to do initial loading again when network connection becomes available again --- .../audiobookshelf/app/data/DataClasses.kt | 4 +- .../audiobookshelf/app/media/MediaManager.kt | 71 ++++++++++++------- .../com/audiobookshelf/app/media/icons.kt | 55 ++++++++++++++ .../audiobookshelf/app/player/BrowseTree.kt | 48 ++++++------- .../app/player/PlayerNotificationService.kt | 59 ++++++++++++--- .../main/res/drawable/abs_audiobookshelf.xml | 9 +++ .../app/src/main/res/drawable/abs_authors.xml | 9 +++ .../app/src/main/res/drawable/abs_book_1.xml | 9 +++ .../app/src/main/res/drawable/abs_books_1.xml | 9 +++ .../app/src/main/res/drawable/abs_books_2.xml | 9 +++ .../app/src/main/res/drawable/abs_columns.xml | 9 +++ .../src/main/res/drawable/abs_database.xml | 9 +++ .../main/res/drawable/abs_file_picture.xml | 9 +++ .../src/main/res/drawable/abs_headphones.xml | 9 +++ .../app/src/main/res/drawable/abs_heart.xml | 9 +++ .../main/res/drawable/abs_microphone_1.xml | 9 +++ .../main/res/drawable/abs_microphone_2.xml | 9 +++ .../main/res/drawable/abs_microphone_3.xml | 9 +++ .../app/src/main/res/drawable/abs_music.xml | 9 +++ .../app/src/main/res/drawable/abs_podcast.xml | 9 +++ .../app/src/main/res/drawable/abs_radio.xml | 9 +++ .../app/src/main/res/drawable/abs_rocket.xml | 9 +++ android/app/src/main/res/drawable/abs_rss.xml | 9 +++ .../app/src/main/res/drawable/abs_star.xml | 9 +++ .../res/drawable/md_book_multiple_outline.xml | 1 + .../md_book_open_blank_variant_outline.xml | 1 + .../src/main/res/drawable/md_telescope.xml | 1 + 27 files changed, 347 insertions(+), 64 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/media/icons.kt create mode 100644 android/app/src/main/res/drawable/abs_audiobookshelf.xml create mode 100644 android/app/src/main/res/drawable/abs_authors.xml create mode 100644 android/app/src/main/res/drawable/abs_book_1.xml create mode 100644 android/app/src/main/res/drawable/abs_books_1.xml create mode 100644 android/app/src/main/res/drawable/abs_books_2.xml create mode 100644 android/app/src/main/res/drawable/abs_columns.xml create mode 100644 android/app/src/main/res/drawable/abs_database.xml create mode 100644 android/app/src/main/res/drawable/abs_file_picture.xml create mode 100644 android/app/src/main/res/drawable/abs_headphones.xml create mode 100644 android/app/src/main/res/drawable/abs_heart.xml create mode 100644 android/app/src/main/res/drawable/abs_microphone_1.xml create mode 100644 android/app/src/main/res/drawable/abs_microphone_2.xml create mode 100644 android/app/src/main/res/drawable/abs_microphone_3.xml create mode 100644 android/app/src/main/res/drawable/abs_music.xml create mode 100644 android/app/src/main/res/drawable/abs_podcast.xml create mode 100644 android/app/src/main/res/drawable/abs_radio.xml create mode 100644 android/app/src/main/res/drawable/abs_rocket.xml create mode 100644 android/app/src/main/res/drawable/abs_rss.xml create mode 100644 android/app/src/main/res/drawable/abs_star.xml create mode 100644 android/app/src/main/res/drawable/md_book_multiple_outline.xml create mode 100644 android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml create mode 100644 android/app/src/main/res/drawable/md_telescope.xml diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index e81e84cf4..c00b3a502 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -7,6 +7,7 @@ import android.support.v4.media.MediaMetadataCompat import androidx.media.utils.MediaConstants import com.audiobookshelf.app.media.MediaManager import com.fasterxml.jackson.annotation.* +import com.audiobookshelf.app.media.getUriToAbsIconDrawable // This auto-detects whether it is a Book or Podcast @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @@ -348,7 +349,7 @@ data class Library( var stats: LibraryStats? ) { @JsonIgnore - fun getMediaMetadata(targetType: String? = null): MediaMetadataCompat { + fun getMediaMetadata(context: Context, targetType: String? = null): MediaMetadataCompat { var mediaId = id if (targetType !== null) { mediaId = "__RECENTLY__$id" @@ -357,6 +358,7 @@ data class Library( putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId) putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name) putString(MediaMetadataCompat.METADATA_KEY_TITLE, name) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToAbsIconDrawable(context, icon).toString()) }.build() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 3506e7776..970e1ab0a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -31,6 +31,8 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { private var cachedLibraryDiscovery : MutableMap> = hashMapOf() private var cachedLibraryPodcasts : MutableMap> = hashMapOf() private var isLibraryPodcastsCached : MutableMap = hashMapOf() + var allLibraryPersonalizationsDone : Boolean = false + private var libraryPersonalizationsDone : Int = 0 private var selectedPodcast:Podcast? = null private var selectedLibraryItemId:String? = null @@ -158,11 +160,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { fun populatePersonalizedDataForAllLibraries(cb: () -> Unit ) { serverLibraries.forEach { + libraryPersonalizationsDone++ Log.d(tag, "Loading personalization for library ${it.name} - ${it.id} - ${it.mediaType}") populatePersonalizedDataForLibrary(it.id) { Log.d(tag, "Loaded personalization for library ${it.name} - ${it.id} - ${it.mediaType}") + libraryPersonalizationsDone-- } } + while (libraryPersonalizationsDone > 0) { } + allLibraryPersonalizationsDone = true + cb() } private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) { @@ -178,7 +185,9 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { cachedLibraryRecentShelfs[libraryId] = mutableListOf() } - cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + if (cachedLibraryRecentShelfs[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } } else if (shelf.id == "discover") { if (!cachedLibraryDiscovery.containsKey(libraryId)) { @@ -196,7 +205,9 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { cachedLibraryRecentShelfs[libraryId] = mutableListOf() } - cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + if (cachedLibraryRecentShelfs[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } } } else if (shelf.type == "episode") { if (shelf.id == "continue-listening") return@map @@ -205,7 +216,9 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { cachedLibraryRecentShelfs[libraryId] = mutableListOf() } - cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + if (cachedLibraryRecentShelfs[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } (shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem -> loadPodcastItem(libraryItem.libraryId, libraryItem.id) {} @@ -216,7 +229,9 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { cachedLibraryRecentShelfs[libraryId] = mutableListOf() } - cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + if (cachedLibraryRecentShelfs[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } } else if (shelf.id == "discover"){ return@map @@ -226,7 +241,9 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { cachedLibraryRecentShelfs[libraryId] = mutableListOf() } - cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + if (cachedLibraryRecentShelfs[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } } } @@ -503,13 +520,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!cachedLibraryDiscovery.containsKey(libraryId)) { cb(listOf()) } - val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]!!.filter { li -> li.checkHasTracks() } - libraryItemsWithAudio.forEach { libraryItem -> addServerLibrary(libraryItem) } - cb(libraryItemsWithAudio) + val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]?.filter { li -> li.checkHasTracks() } + libraryItemsWithAudio?.forEach { libraryItem -> addServerLibrary(libraryItem) } + cb(libraryItemsWithAudio as List) } fun getLibraryRecentShelfs(libraryId: String, cb: (List) -> Unit) { if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + Log.d(tag, "getLibraryRecentShelfs: No shelves $libraryId") cb(listOf()) return } @@ -748,6 +766,25 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } + fun initializeInProgressItems(cb: () -> Unit) { + Log.d(tag, "Initializing inprogress items") + + loadItemsInProgressForAllLibraries { itemsInProgress -> + itemsInProgress.forEach { + val libraryItem = it.libraryItemWrapper as LibraryItem + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + + if (it.episode != null) { + podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode) + } + } + Log.d(tag, "Initializing inprogress items done") + cb() + } + } + fun loadAndroidAutoItems(cb: () -> Unit) { Log.d(tag, "Load android auto items") @@ -762,23 +799,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { Log.w(tag, "No libraries returned from server request") cb() } else { - val library = libraries[0] - Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}") - - loadItemsInProgressForAllLibraries { itemsInProgress -> - itemsInProgress.forEach { - val libraryItem = it.libraryItemWrapper as LibraryItem - if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { - serverLibraryItems.add(libraryItem) - } - - if (it.episode != null) { - podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode) - } - } - - cb() // Fully loaded - } + cb() // Fully loaded } } } else { // Not connected to server diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt b/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt new file mode 100644 index 000000000..0cfaa9415 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt @@ -0,0 +1,55 @@ +package com.audiobookshelf.app.media + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.annotation.AnyRes +import com.audiobookshelf.app.R + +/** + * get uri to drawable or any other resource type if u wish + * @param drawableId - drawable res id + * @return - uri + */ +fun getUriToDrawable(context: Context, @AnyRes drawableId: Int): Uri { + return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId)) +} + + +/** + * get uri to drawable or any other resource type if u wish + * @param drawableId - drawable res id + * @return - uri + */ +fun getUriToAbsIconDrawable(context: Context, absIconName: String): Uri { + val drawableId = when(absIconName) { + "audiobookshelf" -> R.drawable.abs_audiobookshelf + "authors" -> R.drawable.abs_authors + "book-1" -> R.drawable.abs_book_1 + "books-1" -> R.drawable.abs_books_1 + "books-2" -> R.drawable.abs_books_2 + "columns" -> R.drawable.abs_columns + "database" -> R.drawable.abs_database + "file-picture" -> R.drawable.abs_file_picture + "headphones" -> R.drawable.abs_headphones + "heart" -> R.drawable.abs_heart + "microphone_1" -> R.drawable.abs_microphone_1 + "microphone_2" -> R.drawable.abs_microphone_2 + "microphone_3" -> R.drawable.abs_microphone_3 + "music" -> R.drawable.abs_music + "podcast" -> R.drawable.abs_podcast + "radio" -> R.drawable.abs_radio + "rocket" -> R.drawable.abs_rocket + "rss" -> R.drawable.abs_rss + "star" -> R.drawable.abs_star + else -> R.drawable.icon_library_folder + } + return Uri.parse( + ContentResolver.SCHEME_ANDROID_RESOURCE + + "://" + context.resources.getResourcePackageName(drawableId) + + '/' + context.resources.getResourceTypeName(drawableId) + + '/' + context.resources.getResourceEntryName(drawableId)) +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index 9bc5c16eb..126ed5882 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -1,57 +1,45 @@ package com.audiobookshelf.app.player -import android.content.ContentResolver import android.content.Context -import android.net.Uri import android.support.v4.media.MediaMetadataCompat -import androidx.annotation.AnyRes +import android.util.Log import com.audiobookshelf.app.R import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.media.getUriToDrawable class BrowseTree( val context: Context, itemsInProgress: List, - libraries: List + libraries: List, + recentsLoaded: Boolean ) { private val mediaIdToChildren = mutableMapOf>() - /** - * get uri to drawable or any other resource type if u wish - * @param drawableId - drawable res id - * @return - uri - */ - private fun getUriToDrawable(@AnyRes drawableId: Int): Uri { - return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE - + "://" + context.resources.getResourcePackageName(drawableId) - + '/' + context.resources.getResourceTypeName(drawableId) - + '/' + context.resources.getResourceEntryName(drawableId)) - } - init { val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf() val continueListeningMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue") - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) }.build() val recentMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, RECENTLY_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Recent") - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.md_clock_outline).toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.md_clock_outline).toString()) }.build() val downloadsMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads") - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_downloaddone).toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) }.build() val librariesMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT) putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries") - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString()) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString()) }.build() if (itemsInProgress.isNotEmpty()) { @@ -59,24 +47,28 @@ class BrowseTree( } if (libraries.isNotEmpty()) { - rootList += recentMetadata + if (recentsLoaded) { + rootList += recentMetadata + } rootList += librariesMetadata libraries.forEach { library -> // Skip libraries without audio content if (library.stats?.numAudioTracks == 0) return@forEach - + Log.d("BrowseTree", "Library $library | ${library.icon}") // Generate library list items for Libraries menu - val libraryMediaMetadata = library.getMediaMetadata() + val libraryMediaMetadata = library.getMediaMetadata(context) val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf() children += libraryMediaMetadata mediaIdToChildren[LIBRARIES_ROOT] = children - // Generate library list items for Recent menu - val recentlyMediaMetadata = library.getMediaMetadata("recently") - val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf() - childrenRecently += recentlyMediaMetadata - mediaIdToChildren[RECENTLY_ROOT] = childrenRecently + if (recentsLoaded) { + // Generate library list items for Recent menu + val recentlyMediaMetadata = library.getMediaMetadata(context,"recently") + val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf() + childrenRecently += recentlyMediaMetadata + mediaIdToChildren[RECENTLY_ROOT] = childrenRecently + } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 36629c12d..30b43ef7d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -35,6 +35,8 @@ import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.media.MediaProgressSyncer import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.media.getUriToAbsIconDrawable +import com.audiobookshelf.app.media.getUriToDrawable import com.google.android.exoplayer2.* import com.google.android.exoplayer2.audio.AudioAttributes import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector @@ -120,8 +122,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var mShakeDetector: ShakeDetector? = null private var shakeSensorUnregisterTask:TimerTask? = null - // These are used to prevent things running multiple times or simultaneously - private var isLoadingAndroidAutoItems:Boolean = false + // These are used to trigger reloading if + private var forceReloadingAndroidAuto:Boolean = false + private var firstLoadDone:Boolean = false + + fun isBrowsetreeInitialized() : Boolean { + return this::browseTree.isInitialized + } // Cache latest search so it wont trigger again when returning from series for example private var cachedSearch : String = "" @@ -1098,21 +1105,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { result.sendResult(localBrowseItems) } else if (parentMediaId == AUTO_MEDIA_ROOT) { Log.d(tag, "Trying to initialize browseTree.") - if (!this::browseTree.isInitialized) { - isLoadingAndroidAutoItems = true + if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) { + forceReloadingAndroidAuto = false mediaManager.loadAndroidAutoItems { - browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries) - + Log.d(tag, "android auto loaded. Starting browseTree initialize") + browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries, mediaManager.allLibraryPersonalizationsDone) val children = browseTree[parentMediaId]?.map { item -> Log.d(tag, "Found top menu item: ${item.description.title}") MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) } Log.d(tag, "browseTree initialize and android auto loaded") result.sendResult(children as MutableList?) - isLoadingAndroidAutoItems = false - Log.d(tag, "Starting personalization fetch") - mediaManager.populatePersonalizedDataForAllLibraries {} + firstLoadDone = true + if (mediaManager.serverLibraries.isNotEmpty()) { + Log.d(tag, "Starting personalization fetch") + mediaManager.populatePersonalizedDataForAllLibraries { + notifyChildrenChanged("/") + } + + Log.d(tag, "Initialize inprogress items") + mediaManager.initializeInProgressItems { + notifyChildrenChanged("/") + } + } + } + } else { + Log.d(tag, "Starting browseTree refresh") + browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries, mediaManager.allLibraryPersonalizationsDone) + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "Found top menu item: ${item.description.title}") + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) } + Log.d(tag, "browseTree initialize and android auto loaded") + result.sendResult(children as MutableList?) } } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) { while (!this::browseTree.isInitialized) {} @@ -1141,6 +1166,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Authors") .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "authors")) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ), @@ -1148,6 +1174,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Series") .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ), @@ -1155,6 +1182,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Collections") .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_book_multiple_outline)) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) @@ -1165,6 +1193,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Discovery") .setMediaId("__LIBRARY__${parentMediaId}__DISCOVERY") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_telescope)) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) @@ -1192,6 +1221,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Books") .setMediaId("${parentMediaId}__BOOK") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_book_open_blank_variant_outline)) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) @@ -1202,6 +1232,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Series") .setMediaId("${parentMediaId}__SERIES") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) @@ -1212,6 +1243,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Episodes") .setMediaId("${parentMediaId}__EPISODE") + .setIconUri(getUriToAbsIconDrawable(ctx, "microphone_2")) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) @@ -1222,6 +1254,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Podcast") .setMediaId("${parentMediaId}__PODCAST") + .setIconUri(getUriToAbsIconDrawable(ctx, "podcast")) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) @@ -1232,6 +1265,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaDescriptionCompat.Builder() .setTitle("Authors") .setMediaId("${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "authors")) .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) @@ -1620,7 +1654,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.i(tag, "Network capabilities changed. hasNetworkConnectivity=$hasNetworkConnectivity | isUnmeteredNetwork=$isUnmeteredNetwork") clientEventEmitter?.onNetworkMeteredChanged(isUnmeteredNetwork) if (hasNetworkConnectivity) { - // TODO: Trigger android auto loading if it is not loaded previously + // Force android auto loading if libraries are empty. + // Lack of network connectivity is most likely reason for libraries being empty + if (isBrowsetreeInitialized() && firstLoadDone && mediaManager.serverLibraries.isEmpty()) { + forceReloadingAndroidAuto = true + notifyChildrenChanged("/") + } } } } diff --git a/android/app/src/main/res/drawable/abs_audiobookshelf.xml b/android/app/src/main/res/drawable/abs_audiobookshelf.xml new file mode 100644 index 000000000..f6fef0216 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_audiobookshelf.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_authors.xml b/android/app/src/main/res/drawable/abs_authors.xml new file mode 100644 index 000000000..9f7e9a4d9 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_authors.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_book_1.xml b/android/app/src/main/res/drawable/abs_book_1.xml new file mode 100644 index 000000000..fc946fae9 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_book_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_books_1.xml b/android/app/src/main/res/drawable/abs_books_1.xml new file mode 100644 index 000000000..e99105945 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_books_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_books_2.xml b/android/app/src/main/res/drawable/abs_books_2.xml new file mode 100644 index 000000000..64b308ef3 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_books_2.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_columns.xml b/android/app/src/main/res/drawable/abs_columns.xml new file mode 100644 index 000000000..a51ab6add --- /dev/null +++ b/android/app/src/main/res/drawable/abs_columns.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_database.xml b/android/app/src/main/res/drawable/abs_database.xml new file mode 100644 index 000000000..18fa41867 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_database.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_file_picture.xml b/android/app/src/main/res/drawable/abs_file_picture.xml new file mode 100644 index 000000000..0d3f53444 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_file_picture.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_headphones.xml b/android/app/src/main/res/drawable/abs_headphones.xml new file mode 100644 index 000000000..2e228cf4f --- /dev/null +++ b/android/app/src/main/res/drawable/abs_headphones.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_heart.xml b/android/app/src/main/res/drawable/abs_heart.xml new file mode 100644 index 000000000..06647dc68 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_microphone_1.xml b/android/app/src/main/res/drawable/abs_microphone_1.xml new file mode 100644 index 000000000..e2c072113 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_microphone_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_microphone_2.xml b/android/app/src/main/res/drawable/abs_microphone_2.xml new file mode 100644 index 000000000..0db2ffe17 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_microphone_2.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_microphone_3.xml b/android/app/src/main/res/drawable/abs_microphone_3.xml new file mode 100644 index 000000000..25d3a655a --- /dev/null +++ b/android/app/src/main/res/drawable/abs_microphone_3.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_music.xml b/android/app/src/main/res/drawable/abs_music.xml new file mode 100644 index 000000000..4f473bc87 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_podcast.xml b/android/app/src/main/res/drawable/abs_podcast.xml new file mode 100644 index 000000000..261e3db6e --- /dev/null +++ b/android/app/src/main/res/drawable/abs_podcast.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_radio.xml b/android/app/src/main/res/drawable/abs_radio.xml new file mode 100644 index 000000000..980f938ee --- /dev/null +++ b/android/app/src/main/res/drawable/abs_radio.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_rocket.xml b/android/app/src/main/res/drawable/abs_rocket.xml new file mode 100644 index 000000000..e66733088 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_rocket.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_rss.xml b/android/app/src/main/res/drawable/abs_rss.xml new file mode 100644 index 000000000..21259ee4d --- /dev/null +++ b/android/app/src/main/res/drawable/abs_rss.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_star.xml b/android/app/src/main/res/drawable/abs_star.xml new file mode 100644 index 000000000..6da979c9d --- /dev/null +++ b/android/app/src/main/res/drawable/abs_star.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/md_book_multiple_outline.xml b/android/app/src/main/res/drawable/md_book_multiple_outline.xml new file mode 100644 index 000000000..d215e9ebe --- /dev/null +++ b/android/app/src/main/res/drawable/md_book_multiple_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml b/android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml new file mode 100644 index 000000000..09cb77a8c --- /dev/null +++ b/android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/md_telescope.xml b/android/app/src/main/res/drawable/md_telescope.xml new file mode 100644 index 000000000..8e8c5751a --- /dev/null +++ b/android/app/src/main/res/drawable/md_telescope.xml @@ -0,0 +1 @@ + From b7c8e72ce2daeb9f13420515cfcdab649e5cb96f Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Sun, 17 Nov 2024 21:15:16 +0200 Subject: [PATCH 07/15] Android Auto: Podcast episodes show publish date --- .../java/com/audiobookshelf/app/data/DataClasses.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index c00b3a502..603516e73 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -1,6 +1,7 @@ package com.audiobookshelf.app.data import android.content.Context +import android.icu.text.DateFormat import android.os.Bundle import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat @@ -8,6 +9,7 @@ import androidx.media.utils.MediaConstants import com.audiobookshelf.app.media.MediaManager import com.fasterxml.jackson.annotation.* import com.audiobookshelf.app.media.getUriToAbsIconDrawable +import java.util.Date // This auto-detects whether it is a Book or Podcast @JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) @@ -302,11 +304,18 @@ data class PodcastEpisode( val libraryItemDescription = libraryItem.getMediaDescription(null, ctx) val mediaId = localEpisodeId ?: id + var subtitle = libraryItemDescription.title + if (publishedAt !== null) { + val sdf = DateFormat.getDateInstance() + val publishedAtDT = Date(publishedAt!!) + subtitle = "${sdf.format(publishedAtDT)} / $subtitle" + } + val mediaDescriptionBuilder = MediaDescriptionCompat.Builder() .setMediaId(mediaId) .setTitle(title) .setIconUri(coverUri) - .setSubtitle(libraryItemDescription.title) + .setSubtitle(subtitle) .setExtras(extras) libraryItemDescription.iconBitmap?.let { From f68f31c80fa4a2c69787e0e2c225c1f0b99fbb4e Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Sun, 17 Nov 2024 21:16:39 +0200 Subject: [PATCH 08/15] Android Auto: Streamlined browsing settings to single option --- .../audiobookshelf/app/data/DeviceClasses.kt | 4 -- .../app/device/DeviceManager.kt | 9 +---- .../audiobookshelf/app/media/MediaManager.kt | 3 ++ .../app/player/PlayerNotificationService.kt | 10 ++++- pages/settings.vue | 38 ++----------------- strings/en-us.json | 6 +-- 6 files changed, 17 insertions(+), 53 deletions(-) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 1d763b374..4bb5c77e2 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -138,8 +138,6 @@ data class DeviceSettings( var languageCode: String, var downloadUsingCellular: DownloadUsingCellularSetting, var streamingUsingCellular: StreamingUsingCellularSetting, - var androidAutoBrowseForceGrouping: Boolean, - var androidAutoBrowseTopLevelLimitForGrouping: Int, var androidAutoBrowseLimitForGrouping: Int, var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting ) { @@ -168,8 +166,6 @@ data class DeviceSettings( languageCode = "en-us", downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, - androidAutoBrowseForceGrouping = false, - androidAutoBrowseTopLevelLimitForGrouping = 100, androidAutoBrowseLimitForGrouping = 50, androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC ) diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt index 1cc6c9ff9..fd502cbdb 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -61,15 +61,8 @@ object DeviceManager { if (deviceData.deviceSettings?.streamingUsingCellular == null) { deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS } - - if (deviceData.deviceSettings?.androidAutoBrowseForceGrouping == null) { - deviceData.deviceSettings?.androidAutoBrowseForceGrouping = false - } - if (deviceData.deviceSettings?.androidAutoBrowseTopLevelLimitForGrouping == null) { - deviceData.deviceSettings?.androidAutoBrowseTopLevelLimitForGrouping = 100 - } if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) { - deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 50 + deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100 } if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) { deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 970e1ab0a..4532f9b39 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -128,6 +128,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig() if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) { + Log.d(tag, "Reset caches") podcastEpisodeLibraryItemMap = mutableMapOf() serverLibraries = listOf() serverLibraryItems = mutableListOf() @@ -141,6 +142,8 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { cachedLibraryDiscovery = hashMapOf() cachedLibraryPodcasts = hashMapOf() isLibraryPodcastsCached = hashMapOf() + allLibraryPersonalizationsDone = false + libraryPersonalizationsDone = 0 } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 30b43ef7d..66449f355 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -1014,6 +1014,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // No further calls will be made to other media browsing methods. null } else { + Log.d(tag, "Android Auto starting") isStarted = true mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server @@ -1140,6 +1141,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { result.sendResult(children as MutableList?) } } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) { + Log.d(tag, "First load done: $firstLoadDone") + if (!firstLoadDone) { + result.sendResult(null) + return + } while (!this::browseTree.isInitialized) {} val children = browseTree[parentMediaId]?.map { item -> Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") @@ -1386,7 +1392,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Loading series from library ${mediaIdParts[2]}") mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2]) { seriesItems -> Log.d(tag, "Received ${seriesItems.size} series") - if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseForceGrouping || seriesItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseTopLevelLimitForGrouping) { + if (seriesItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping) { val seriesLetters = seriesItems.groupingBy { iwb -> iwb.title.first().uppercaseChar() }.eachCount() val children = seriesLetters.map { (seriesLetter, seriesCount) -> MediaBrowserCompat.MediaItem( @@ -1457,7 +1463,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { Log.d(tag, "Loading authors from library ${mediaIdParts[2]}") mediaManager.loadAuthorsWithBooks(mediaIdParts[2]) { authorItems -> Log.d(tag, "Received ${authorItems.size} authors") - if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseForceGrouping || authorItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseTopLevelLimitForGrouping) { + if (authorItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping) { val authorLetters = authorItems.groupingBy { iwb -> iwb.name.first().uppercaseChar() }.eachCount() val children = authorLetters.map { (authorLetter, authorCount) -> MediaBrowserCompat.MediaItem( diff --git a/pages/settings.vue b/pages/settings.vue index 147e8b18a..ddc8cceef 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -153,18 +153,6 @@