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] 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 | 98 +++++--- .../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, 785 insertions(+), 43 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 00000000..63690712 --- /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 00000000..16017484 --- /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 00000000..e43985b4 --- /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 491320ab..4b466982 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 - ) - } 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 + if (collapsedSeries == null) { + 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 + ) + } 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_NOT_PLAYED + ) + } + + 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 00000000..8a9a798c --- /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 94fa8b33..350dc50b 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 0fbf9576..2e16954f 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 ff8ed6b5..3d4b91de 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 00000000..cf8a5c67 --- /dev/null +++ b/android/app/src/main/res/drawable/md_account_outline.xml @@ -0,0 +1 @@ +