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/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index 22e57ee1..f6e8800a 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,12 +1,15 @@ 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 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) @@ -25,7 +28,8 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) { open fun removeAudioTrack(localFileId:String) { } @JsonIgnore open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) } - + @JsonIgnore + open fun checkHasTracks():Boolean { return false } } @JsonIgnoreProperties(ignoreUnknown = true) @@ -93,6 +97,11 @@ class Podcast( return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0) } + @JsonIgnore + override fun checkHasTracks():Boolean { + return (episodes?.size ?: numEpisodes ?: 0) > 0 + } + @JsonIgnore fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode { val localEpisodeId = "local_ep_" + episode.id @@ -182,6 +191,11 @@ class Book( override fun getLocalCopy(): Book { return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0) } + + @JsonIgnore + override fun checkHasTracks():Boolean { + return (tracks?.size ?: numTracks ?: 0) > 0 + } } // This auto-detects whether it is a BookMetadata or PodcastMetadata @@ -214,7 +228,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" } @@ -299,11 +315,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 { @@ -342,18 +365,39 @@ 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 { + fun getMediaMetadata(context: Context, 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) + putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToAbsIconDrawable(context, icon).toString()) }.build() } } +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryStats( + var totalItems: Int, + var totalSize: Long, + var totalDuration: Double, + var numAudioFiles: 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, @@ -387,3 +431,84 @@ 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? +) + +// For personalized shelves +@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/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index a002c141..40763a67 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, @@ -133,7 +137,9 @@ data class DeviceSettings( var disableSleepTimerResetFeedback: Boolean, var languageCode: String, var downloadUsingCellular: DownloadUsingCellularSetting, - var streamingUsingCellular: StreamingUsingCellularSetting + var streamingUsingCellular: StreamingUsingCellularSetting, + var androidAutoBrowseLimitForGrouping: Int, + var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting ) { companion object { // Static method to get default device settings @@ -159,7 +165,9 @@ data class DeviceSettings( disableSleepTimerResetFeedback = false, languageCode = "en-us", downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, - streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS + streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, + androidAutoBrowseLimitForGrouping = 100, + androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC ) } } 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..97ce5551 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt @@ -0,0 +1,63 @@ +package com.audiobookshelf.app.data + +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 +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 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 + 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() + .setMediaId(mediaId) + .setTitle(title) + .setIconUri(getPortraitUri()) + .setSubtitle("${bookCount} books") + .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/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..7b2dcf70 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,18 @@ class LibraryItem( var media:MediaType, var libraryFiles:MutableList?, var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads) - var localLibraryItemId:String? // For Android Auto + var collapsedSeries: CollapsedSeries?, + var localLibraryItemId:String?, // For Android Auto + val recentEpisode: PodcastEpisode? // Podcast episode shelf uses this ) : 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() @@ -50,57 +58,116 @@ class LibraryItem( @JsonIgnore fun checkHasTracks():Boolean { - return if (mediaType == "podcast") { - ((media as Podcast).numEpisodes ?: 0) > 0 - } else { - ((media as Book).numTracks ?: 0) > 0 + return media.checkHasTracks() + } + + @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?, showSeriesNumber: Boolean?, groupTitle: String?): MediaDescriptionCompat { + val extras = Bundle() + + 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 + ) + } + } + if (groupTitle !== null) { + extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle) + } + + 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" + } + var itemTitle = title + if (showSeriesNumber == true && seriesSequence != "") { + itemTitle = "$seriesSequence. $itemTitle" + } + return MediaDescriptionCompat.Builder() + .setMediaId(mediaId) + .setTitle(itemTitle) + .setIconUri(getCoverUri()) + .setSubtitle(subtitle) + .setExtras(extras) + .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, null) } @JsonIgnore override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): 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 - ) - } - } 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 = localLibraryItemId ?: id - return MediaDescriptionCompat.Builder() - .setMediaId(mediaId) - .setTitle(title) - .setIconUri(getCoverUri()) - .setSubtitle(authorName) - .setExtras(extras) - .build() + /* + This is needed so Android auto library hierarchy for author series can be implemented + */ + 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 new file mode 100644 index 00000000..47ff50c2 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt @@ -0,0 +1,60 @@ +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 + +@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.checkHasTracks() } + return booksWithAudio?.size ?: 0 + } + + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat { + val extras = Bundle() + + if (localLibraryItemId != null) { + extras.putLong( + MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS, + 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() + .setMediaId(mediaId) + .setTitle(title) + //.setIconUri(getCoverUri()) + .setSubtitle("$audiobookCount books") + .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/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt index ef1c7994..aa838046 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 37e81cf0..fd502cbd 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,12 @@ object DeviceManager { if (deviceData.deviceSettings?.streamingUsingCellular == null) { deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS } + if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) { + deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100 + } + 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 94fa8b33..59f22840 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,13 +20,23 @@ 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() + private var cachedLibraryAuthorSeriesItems : MutableMap>> = hashMapOf() + private var cachedLibrarySeries : MutableMap> = hashMapOf() + private var cachedLibrarySeriesItem : MutableMap>> = hashMapOf() + private var cachedLibraryCollections : MutableMap> = hashMapOf() + private var cachedLibraryRecentShelves : MutableMap> = hashMapOf() + 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 private var podcastEpisodeLibraryItemMap = mutableMapOf() - private var serverLibraryCategories = listOf() private var serverConfigIdUsed:String? = null private var serverConfigLastPing:Long = 0L var serverUserMediaProgress:MutableList = mutableListOf() @@ -39,6 +49,35 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { return serverLibraries.find { it.id == id } != null } + /** + * Check if there is discovery shelf for [libraryId] + * If personalized shelves are not yet populated for library then populate + * + */ + 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 } + } + + /** + * Add [libraryItem] to [serverLibraryItems] if it is not already added + */ + 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 @@ -91,19 +130,32 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } - fun checkResetServerItems() { + fun checkResetServerItems():Boolean { // When opening android auto need to check if still connected to server // and reset any server data already set 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() - serverLibraryCategories = listOf() serverLibraries = listOf() serverLibraryItems = mutableListOf() - selectedLibraryItems = mutableListOf() - selectedLibraryId = "" + cachedLibraryAuthors = hashMapOf() + cachedLibraryAuthorItems = hashMapOf() + cachedLibraryAuthorSeriesItems = hashMapOf() + cachedLibrarySeries = hashMapOf() + cachedLibrarySeriesItem = hashMapOf() + cachedLibraryCollections = hashMapOf() + cachedLibraryRecentShelves = hashMapOf() + cachedLibraryDiscovery = hashMapOf() + cachedLibraryPodcasts = hashMapOf() + isLibraryPodcastsCached = hashMapOf() + serverItemsInProgress = listOf() + allLibraryPersonalizationsDone = false + libraryPersonalizationsDone = 0 + return true } + return false } private fun loadItemsInProgressForAllLibraries(cb: (List) -> Unit) { @@ -120,28 +172,458 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } - fun loadLibraryItemsWithAudio(libraryId:String, cb: (List) -> Unit) { - if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) { - cb(selectedLibraryItems) + /** + * Load personalized shelves from server for all libraries. + * [cb] resolves when all libraries are processed + */ + 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) { } + + Log.d(tag, "Finished loading all library personalization data") + allLibraryPersonalizationsDone = true + cb() + } + + /** + * Get personalized shelves from server for selected [libraryId]. + * Populates [cachedLibraryRecentShelves] and [cachedLibraryDiscovery]. + */ + private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) { + apiHandler.getLibraryPersonalized(libraryId) { shelves -> + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId") + if (shelves === null) return@getLibraryPersonalized + shelves.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 (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[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 (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[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 (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + + val podcastLibraryItemIds = mutableListOf() + (shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem -> + if (!podcastLibraryItemIds.contains(libraryItem.id)) { + podcastLibraryItemIds.add(libraryItem.id) + loadPodcastItem(libraryItem.libraryId, libraryItem.id) {} + } + } + } + } else if (shelf.type == "podcast") { + if (shelf.id == "recently-added"){ + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + else if (shelf.id == "discover"){ + return@map + } + } else if (shelf.type =="authors") { + if (shelf.id == "newest-authors") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + } + + } + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId DONE") + cb() + } + } + + /** + * Returns podcasts for selected library. + * If data is not found from local cache it is loaded from server + */ + 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) } } + isLibraryPodcastsCached[libraryId] = true + Log.d(tag, "loadLibraryPodcasts: loaded from server: $libraryId") + cb(libraryItemsWithAudio.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) + } + } + } + + /** + * 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) + } + + /** + * Sorts books in series. Assumes that sequence is main.minor + */ + private 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 + */ + 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() } + + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsWithAudio) + cachedLibrarySeriesItem[libraryId]!![seriesId] = sortedLibraryItemsWithAudio + + sortedLibraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + cb(sortedLibraryItemsWithAudio) + } + } + } + + /** + * 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 + 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 + 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 } + + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsFromAuthorWithAudio) + cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = sortedLibraryItemsWithAudio + + sortedLibraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + cb(sortedLibraryItemsWithAudio) + } + } + } + + /** + * 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) + } + } + + /** + * Returns audiobooks from discovery shelf for [libraryId] + * If data is not found from local cache then it will be fetched from server + */ + 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 as List) + } + + /** + * Returns recent shelves for [libraryId] + * If data is not shelves are found returns empty list + */ + fun getLibraryRecentShelfs(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + Log.d(tag, "getLibraryRecentShelfs: No shelves $libraryId") + cb(listOf()) + return + } + cb(cachedLibraryRecentShelves[libraryId] as List) + } + + /** + * Returns recent shelf by [type] for [libraryId] + * If shelf is not found returns null + */ + fun getLibraryRecentShelfByType(libraryId: String, type:String, cb: (LibraryShelfType?) -> Unit) { + Log.d(tag, "getLibraryRecentShelfByType: $libraryId | $type") + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cb(null) + return + } + for (shelf in cachedLibraryRecentShelves[libraryId]!!) { + if (shelf.type == type.lowercase()) { + cb(shelf) + return + } + } + cb(null) + } + + /** + * Loads podcasts for newest episodes shelf + */ + 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, "loadPodcastItem: Podcast found from cache | Library $libraryItemId ") + cb(cachedLibraryPodcasts[libraryId]?.get(libraryItemId)) + } else { + Log.d(tag, "loadPodcastItem: Calling getLibraryItem $libraryItemId") + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + if (libraryItem !== null) { + Log.d(tag, "loadPodcastItem: Got library item ${libraryItem.id} ${libraryItem.media.metadata.title}") + 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)) @@ -187,8 +669,8 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } selectedLibraryItemId = libraryItemWrapper.id selectedPodcast = podcast - - val children = podcast.episodes?.map { podcastEpisode -> + val episodes = podcast.episodes?.sortedByDescending { it.publishedAt } + val children = episodes?.map { podcastEpisode -> val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id } @@ -209,13 +691,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } + /** + * Loads libraries for selected server with stats + */ private fun loadLibraries(cb: (List) -> Unit) { if (serverLibraries.isNotEmpty()) { cb(serverLibraries) } else { - apiHandler.getLibraries { - serverLibraries = it - cb(it) + apiHandler.getLibraries { loadedLibraries -> + serverLibraries = loadedLibraries + cb(serverLibraries) } } } @@ -329,6 +814,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") @@ -343,23 +847,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 @@ -369,6 +857,65 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } + /** + * Handles search requests. + * Searches from books, series and authors + */ + suspend fun doSearch(libraryId: String, queryString: String) : Map> { + return suspendCoroutine { + apiHandler.getSearchResults(libraryId, queryString) { searchResult -> + Log.d(tag, "searchLocalCache: $searchResult") + // Nothing found from server + if (searchResult === null) { + it.resume(mapOf()) + return@getSearchResults + } + + val 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!!.filter { it.libraryItem.checkHasTracks() }.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) + } + } + } + fun getFirstItem() : LibraryItemWrapper? { if (serverLibraryItems.isNotEmpty()) { return serverLibraryItems[0] 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 00000000..0cfaa941 --- /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 d752b5d5..8dd5cd19 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,51 +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, "Listening") - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.exo_icon_localaudio).toString()) + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue") + 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(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()) { @@ -53,13 +47,28 @@ class BrowseTree( } if (libraries.isNotEmpty()) { + if (recentsLoaded) { + rootList += recentMetadata + } rootList += librariesMetadata libraries.forEach { library -> - val libraryMediaMetadata = library.getMediaMetadata() + // Skip libraries without audio content + if (library.stats?.numAudioFiles == 0) return@forEach + Log.d("BrowseTree", "Library $library | ${library.icon}") + // Generate library list items for Libraries menu + val libraryMediaMetadata = library.getMediaMetadata(context) val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf() children += libraryMediaMetadata mediaIdToChildren[LIBRARIES_ROOT] = children + + 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 + } } } @@ -75,3 +84,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 7457da41..b7a096c7 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 @@ -47,6 +49,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 @@ -119,6 +122,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var mShakeDetector: ShakeDetector? = null private var shakeSensorUnregisterTask:TimerTask? = null + // 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 = "" + private var cachedSearchResults : MutableList = mutableListOf() + /* Service related stuff */ @@ -977,6 +992,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 @@ -999,8 +1015,13 @@ 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 + + // Reset cache if no longer connected to server or server changed + if (mediaManager.checkResetServerItems()) { + forceReloadingAndroidAuto = true + } isAndroidAuto = true @@ -1026,33 +1047,45 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { result.detach() + // Prevent crashing if app is restarted while browsing + if ((parentMediaId != DOWNLOADS_ROOT && parentMediaId != AUTO_MEDIA_ROOT) && !firstLoadDone){ + result.sendResult(null) + return + } + if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads 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}") @@ -1082,32 +1115,459 @@ 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) + } else if (parentMediaId == AUTO_MEDIA_ROOT) { + Log.d(tag, "Trying to initialize browseTree.") + if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) { + forceReloadingAndroidAuto = false + mediaManager.loadAndroidAutoItems { + 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?) + 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, "Loading Browser Media Item ${item.description.title}") + 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) { + Log.d(tag, "First load done: $firstLoadDone") + if (!firstLoadDone) { + result.sendResult(null) + return + } + // Wait until top-menu is initialized + 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.loadLibraryPodcasts(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("Authors") + .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "authors")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Collections") + .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_book_multiple_outline)) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + if (mediaManager.getHasDiscovery(parentMediaId)) { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Discovery") + .setMediaId("__LIBRARY__${parentMediaId}__DISCOVERY") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_telescope)) + .build(), + 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") - 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) + } 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") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_book_open_blank_variant_outline)) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "series") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("${parentMediaId}__SERIES") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "episode") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Episodes") + .setMediaId("${parentMediaId}__EPISODE") + .setIconUri(getUriToAbsIconDrawable(ctx, "microphone_2")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "podcast") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Podcast") + .setMediaId("${parentMediaId}__PODCAST") + .setIconUri(getUriToAbsIconDrawable(ctx, "podcast")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "authors") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Authors") + .setMediaId("${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "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("__") + /* + MediaIdParts for Library + 1: LIBRARY + 2: mediaId for library + 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS, DISCOVERY) + 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") + + val seriesLetters = seriesItems.groupingBy { iwb -> iwb.title.substring(0, mediaIdParts[4].length + 1).uppercase() }.eachCount() + if (seriesItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping && seriesItems.size > 1 && seriesLetters.size > 1) { + 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 > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping && seriesItems.size > 1) { + 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") + 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, null, true) + 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") + + val authorLetters = authorItems.groupingBy { iwb -> iwb.name.substring(0, mediaIdParts[4].length + 1).uppercase() }.eachCount() + if (authorItems.size > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping && authorItems.size > 1 && authorLetters.size > 1) { + 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 > DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseLimitForGrouping && authorItems.size > 1) { + 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 -> + 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, null, true) + 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 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 { Log.d(tag, "Loading podcast episodes for podcast $parentMediaId") @@ -1119,13 +1579,35 @@ 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) + 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?.numAudioFiles == 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) + } + } + } } - result.sendResult(children as MutableList?) + foundBooks.addAll(foundSeries) + foundBooks.addAll(foundAuthors) + cachedSearchResults = foundBooks } + result.sendResult(cachedSearchResults) + cachedSearch = query + Log.d(tag, "onSearch: Done") } // @@ -1188,6 +1670,14 @@ 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) { + // 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/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 5b4d1620..dfd97064 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 @@ -154,7 +155,7 @@ class ApiHandler(var ctx:Context) { fun getLibraries(cb: (List) -> Unit) { val mapper = jacksonMapper - getRequest("/api/libraries", null,null) { + getRequest("/api/libraries?include=stats", null,null) { val libraries = mutableListOf() var array = JSONArray() @@ -171,6 +172,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")) { @@ -211,6 +229,103 @@ 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 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() 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 00000000..6fa1e718 --- /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_account_outline.png b/android/app/src/main/res/drawable-hdpi/md_account_outline.png new file mode 100644 index 00000000..cddf7919 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/md_account_outline.png differ 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 00000000..a0dc9c16 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/md_clock_outline.png differ diff --git a/android/app/src/main/res/drawable-mdpi/md_account_outline.png b/android/app/src/main/res/drawable-mdpi/md_account_outline.png new file mode 100644 index 00000000..3e21f51e Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/md_account_outline.png differ diff --git a/android/app/src/main/res/drawable-mdpi/md_clock_outline.png b/android/app/src/main/res/drawable-mdpi/md_clock_outline.png new file mode 100644 index 00000000..4320332c Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/md_clock_outline.png differ 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 00000000..9ca56505 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/md_account_outline.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/md_clock_outline.png b/android/app/src/main/res/drawable-xhdpi/md_clock_outline.png new file mode 100644 index 00000000..39f8d719 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/md_clock_outline.png differ 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 00000000..e0fcbf97 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/md_account_outline.png differ 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 00000000..c1c30bb6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png differ 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 00000000..f6fef021 --- /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 00000000..9f7e9a4d --- /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 00000000..fc946fae --- /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 00000000..e9910594 --- /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 00000000..64b308ef --- /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 00000000..a51ab6ad --- /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 00000000..18fa4186 --- /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 00000000..0d3f5344 --- /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 00000000..2e228cf4 --- /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 00000000..06647dc6 --- /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 00000000..e2c07211 --- /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 00000000..0db2ffe1 --- /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 00000000..25d3a655 --- /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 00000000..4f473bc8 --- /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 00000000..261e3db6 --- /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 00000000..980f938e --- /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 00000000..e6673308 --- /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 00000000..21259ee4 --- /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 00000000..6da979c9 --- /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_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 @@ + 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 00000000..d215e9eb --- /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 00000000..09cb77a8 --- /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_clock_outline.xml b/android/app/src/main/res/drawable/md_clock_outline.xml new file mode 100644 index 00000000..6fa1e718 --- /dev/null +++ b/android/app/src/main/res/drawable/md_clock_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 00000000..8e8c5751 --- /dev/null +++ b/android/app/src/main/res/drawable/md_telescope.xml @@ -0,0 +1 @@ + diff --git a/pages/settings.vue b/pages/settings.vue index c1d09b6b..258d7375 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -150,6 +150,22 @@ + + +
@@ -193,7 +209,9 @@ export default { autoSleepTimerAutoRewindTime: 300000, // 5 minutes languageCode: 'en-us', downloadUsingCellular: 'ALWAYS', - streamingUsingCellular: 'ALWAYS' + streamingUsingCellular: 'ALWAYS', + androidAutoBrowseLimitForGrouping: 100, + androidAutoBrowseSeriesSequenceOrder: 'ASC' }, theme: 'dark', lockCurrentOrientation: false, @@ -221,6 +239,10 @@ export default { enableMp3IndexSeeking: { name: this.$strings.LabelEnableMp3IndexSeeking, message: this.$strings.LabelEnableMp3IndexSeekingHelp + }, + androidAutoBrowseLimitForGrouping: { + name: this.$strings.LabelAndroidAutoBrowseLimitForGrouping, + message: this.$strings.LabelAndroidAutoBrowseLimitForGroupingHelp } }, hapticFeedbackItems: [ @@ -290,6 +312,16 @@ export default { text: this.$strings.LabelNever, value: 'NEVER' } + ], + androidAutoBrowseSeriesSequenceOrderItems: [ + { + text: this.$strings.LabelSequenceAscending, + value: 'ASC' + }, + { + text: this.$strings.LabelSequenceDescending, + value: 'DESC' + } ] } }, @@ -372,6 +404,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 @@ -379,6 +415,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 [] } }, @@ -421,6 +458,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') { @@ -441,6 +482,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) { @@ -451,6 +495,12 @@ export default { if (!val) return // invalid times return falsy this.saveSettings() }, + androidAutoBrowseLimitForGroupingUpdated(val) { + if (!val) return // invalid times return falsy + if (val > 1000) val = 1000 + if (val < 30) val = 30 + this.saveSettings() + }, hapticFeedbackUpdated(val) { this.$store.commit('globals/setHapticFeedback', val) this.saveSettings() @@ -576,6 +626,9 @@ export default { this.settings.downloadUsingCellular = deviceSettings.downloadUsingCellular || 'ALWAYS' this.settings.streamingUsingCellular = deviceSettings.streamingUsingCellular || 'ALWAYS' + + 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 3176ab5e..23877df7 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -54,6 +54,7 @@ "ButtonYes": "Yes", "HeaderAccount": "Account", "HeaderAdvanced": "Advanced", + "HeaderAndroidAutoSettings": "Android Auto Settings", "HeaderAudioTracks": "Audio Tracks", "HeaderChapters": "Chapters", "HeaderCollection": "Collection", @@ -92,6 +93,9 @@ "LabelAll": "All", "LabelAllowSeekingOnMediaControls": "Allow position seeking on media notification controls", "LabelAlways": "Always", + "LabelAndroidAutoBrowseLimitForGrouping": "Alphabetical drawdown limit", + "LabelAndroidAutoBrowseLimitForGroupingHelp": "Don't use alphabetical drawdown when there is less than this amount of items to show", + "LabelAndroidAutoBrowseSeriesSequenceOrder": "Series books order", "LabelAskConfirmation": "Ask for confirmation", "LabelAuthor": "Author", "LabelAuthorFirstLast": "Author (First Last)", @@ -218,6 +222,8 @@ "LabelScaleElapsedTimeBySpeed": "Scale Elapsed Time by Speed", "LabelSeason": "Season", "LabelSelectADevice": "Select a device", + "LabelSequenceAscending": "Sequence Ascending", + "LabelSequenceDescending": "Sequence Descending", "LabelSeries": "Series", "LabelServerAddress": "Server address", "LabelSetEbookAsPrimary": "Set as primary",