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..cbf76a81 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -214,7 +214,9 @@ class BookMetadata( var authorName:String?, var authorNameLF:String?, var narratorName:String?, - var seriesName:String? + var seriesName:String?, + @JsonFormat(with=[JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY]) + var series:List? ) : MediaTypeMetadata(title, explicit) { @JsonIgnore override fun getAuthorDisplayName():String { return authorName ?: "Unknown" } @@ -342,7 +344,8 @@ data class Library( var name:String, var folders:MutableList, var icon:String, - var mediaType:String + var mediaType:String, + var stats: LibraryStats? ) { @JsonIgnore fun getMediaMetadata(): MediaMetadataCompat { @@ -354,6 +357,20 @@ data class Library( } } +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryStats( + var totalItems: Int, + var totalAuthors: Int, + var numAudioTracks: Int +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class SeriesType( + var id: String, + var name: String, + var sequence: String? +) + @JsonIgnoreProperties(ignoreUnknown = true) data class Folder( var id:String, diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index f1831770..1d763b37 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -28,6 +28,10 @@ enum class StreamingUsingCellularSetting { ASK, ALWAYS, NEVER } +enum class AndroidAutoBrowseSeriesSequenceOrderSetting { + ASC, DESC +} + data class ServerConnectionConfig( var id:String, var index:Int, @@ -136,7 +140,8 @@ data class DeviceSettings( var streamingUsingCellular: StreamingUsingCellularSetting, var androidAutoBrowseForceGrouping: Boolean, var androidAutoBrowseTopLevelLimitForGrouping: Int, - var androidAutoBrowseLimitForGrouping: Int + var androidAutoBrowseLimitForGrouping: Int, + var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting ) { companion object { // Static method to get default device settings @@ -165,7 +170,8 @@ data class DeviceSettings( streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, androidAutoBrowseForceGrouping = false, androidAutoBrowseTopLevelLimitForGrouping = 100, - androidAutoBrowseLimitForGrouping = 50 + androidAutoBrowseLimitForGrouping = 50, + androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC ) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 4b466982..7afbc259 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -64,8 +64,27 @@ class LibraryItem( } } + @get:JsonIgnore + val seriesSequence: String + get() { + if (mediaType != "podcast") { + return ((media as Book).metadata as BookMetadata).series?.get(0)?.sequence.orEmpty() + } else { + return "" + } + } + + @get:JsonIgnore + val seriesSequenceParts: List + get() { + if (seriesSequence.isEmpty()) { + return listOf("") + } + return seriesSequence.split(".", limit = 2) + } + @JsonIgnore - fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat { + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat { val extras = Bundle() if (collapsedSeries == null) { @@ -121,20 +140,29 @@ class LibraryItem( if (collapsedSeries != null) { subtitle = "${collapsedSeries!!.numBooks} books" } + var itemTitle = title + if (showSeriesNumber == true && seriesSequence != "") { + itemTitle = "$seriesSequence. $itemTitle" + } return MediaDescriptionCompat.Builder() .setMediaId(mediaId) - .setTitle(title) + .setTitle(itemTitle) .setIconUri(getCoverUri()) .setSubtitle(subtitle) .setExtras(extras) .build() } + @JsonIgnore + fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat { + return getMediaDescription(progress, ctx, authorId, null) + } + @JsonIgnore override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { /* This is needed so Android auto library hierarchy for author series can be implemented */ - return getMediaDescription(progress, ctx, null) + return getMediaDescription(progress, ctx, null, null) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt index 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 f14654dc..1cc6c9ff 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -71,6 +71,9 @@ object DeviceManager { if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) { deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 50 } + if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) { + deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC + } } fun getBase64Id(id:String):String { diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 350dc50b..49dd92eb 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -46,6 +46,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { return serverLibraries.find { it.id == id } != null } + fun getLibrary(id:String) : Library? { + return serverLibraries.find { it.id == id } + } + fun getSavedPlaybackRate():Float { if (userSettingsPlaybackRate != null) { return userSettingsPlaybackRate ?: 1f @@ -185,6 +189,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { cb(seriesWithBooks) } + fun sortSeriesBooks(seriesBooks: List) : List { + val sortingLogic = compareBy { it.seriesSequenceParts[0].length } + .thenBy { it.seriesSequenceParts[0].ifEmpty { "" } } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" }.length } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" } } + return seriesBooks.sortedWith(sortingLogic) + } + /** * Returns books for series from library. * If data is not found from local cache then it will be fetched from server @@ -202,14 +214,15 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { Log.d(tag, "Items for series $seriesId loaded from server | Library $libraryId") val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } - cachedLibrarySeriesItem[libraryId]!![seriesId] = libraryItemsWithAudio + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsWithAudio) + cachedLibrarySeriesItem[libraryId]!![seriesId] = sortedLibraryItemsWithAudio - libraryItemsWithAudio.forEach { libraryItem -> + sortedLibraryItemsWithAudio.forEach { libraryItem -> if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { serverLibraryItems.add(libraryItem) } } - cb(libraryItemsWithAudio) + cb(sortedLibraryItemsWithAudio) } } } @@ -228,8 +241,8 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { apiHandler.getLibraryAuthors(libraryId) { authorItems -> Log.d(tag, "Authors with books loaded from server | Library $libraryId ") // TO-DO: This check won't ensure that there is audiobooks. Current API won't offer ability to do so - val authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 } - + var authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 } + authorItemsWithBooks = authorItemsWithBooks.sortedBy { it.name } // Ensure that there is map for library cachedLibraryAuthors[libraryId] = mutableMapOf() // Cache authors @@ -314,15 +327,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { Log.d(tag, "Using author name: $authorName") val libraryItemsFromAuthorWithAudio = libraryItemsWithAudio.filter { li -> li.authorName.indexOf(authorName, ignoreCase = true) >= 0 } - cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = libraryItemsFromAuthorWithAudio + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsFromAuthorWithAudio) + cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = sortedLibraryItemsWithAudio - libraryItemsFromAuthorWithAudio.forEach { libraryItem -> + sortedLibraryItemsWithAudio.forEach { libraryItem -> if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { serverLibraryItems.add(libraryItem) } } - cb(libraryItemsFromAuthorWithAudio) + cb(sortedLibraryItemsWithAudio) } } } @@ -443,9 +457,15 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (serverLibraries.isNotEmpty()) { cb(serverLibraries) } else { - apiHandler.getLibraries { - serverLibraries = it - cb(it) + apiHandler.getLibraries { loadedLibraries -> + serverLibraries = loadedLibraries.map { library -> + apiHandler.getLibraryStats(library.id) { libraryStats -> + Log.d(tag, "Library stats for library ${library.id} | $libraryStats") + library.stats = libraryStats + } + library + } + cb(serverLibraries) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index d752b5d5..9c374a5b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -56,6 +56,7 @@ class BrowseTree( rootList += librariesMetadata libraries.forEach { library -> + if (library.stats?.numAudioTracks == 0) return@forEach val libraryMediaMetadata = library.getMediaMetadata() val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf() children += libraryMediaMetadata diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 4a606789..5d96c60e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -1099,30 +1099,44 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library Log.d(tag, "Loading items for library $parentMediaId") - val children = mutableListOf( - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Library") - .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ), - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Series") - .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE - ), - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setTitle("Collections") - .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") - .build(), - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + val selectedLibrary = mediaManager.getLibrary(parentMediaId) + if (selectedLibrary?.mediaType == "podcast") { // Podcasts are browseable + mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> + val children = libraryItems.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } else { + val children = mutableListOf( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Library") + .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Collections") + .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) ) - ) - result.sendResult(children as MutableList?) + result.sendResult(children as MutableList?) + } } else if (parentMediaId.startsWith("__LIBRARY__")) { Log.d(tag, "Browsing library $parentMediaId") val mediaIdParts = parentMediaId.split("__") @@ -1201,12 +1215,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { mediaIdParts[4] ) { libraryItems -> Log.d(tag, "Received ${libraryItems.size} library items") - val children = libraryItems.map { libraryItem -> + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === AndroidAutoBrowseSeriesSequenceOrderSetting.DESC) { + items = libraryItems.reversed() + } + val children = items.map { libraryItem -> val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) libraryItem.localLibraryItemId = localLibraryItem?.id - val description = libraryItem.getMediaDescription(progress, ctx) + val description = libraryItem.getMediaDescription(progress, ctx, null, true) MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } result.sendResult(children as MutableList?) @@ -1279,11 +1297,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } else if (mediaIdParts[3] == "AUTHOR_SERIES") { mediaManager.loadAuthorSeriesBooksWithAudio(mediaIdParts[2], mediaIdParts[4], mediaIdParts[5]) { libraryItems -> - val children = libraryItems.map { libraryItem -> + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === AndroidAutoBrowseSeriesSequenceOrderSetting.DESC) { + items = libraryItems.reversed() + } + val children = items.map { libraryItem -> val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) libraryItem.localLibraryItemId = localLibraryItem?.id - val description = libraryItem.getMediaDescription(progress, ctx) + val description = libraryItem.getMediaDescription(progress, ctx, null, true) if (libraryItem.collapsedSeries != null) { MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) } else { diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 3d4b91de..dc52c82c 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -150,6 +150,18 @@ class ApiHandler(var ctx:Context) { } } + fun getLibraryStats(libraryItemId:String, cb: (LibraryStats?) -> Unit) { + getRequest("/api/libraries/$libraryItemId/stats", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryStats Failed") + cb(null) + } else { + val libraryStats = jacksonMapper.readValue(it.toString()) + cb(libraryStats) + } + } + } + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) { getRequest("/api/items/$libraryItemId?expanded=1", null, null) { if (it.has("error")) { diff --git a/pages/settings.vue b/pages/settings.vue index 724dc3b1..147e8b18 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -170,6 +170,12 @@ info +
+

{{ $strings.LabelAndroidAutoBrowseSeriesSequenceOrder }}

+
+ +
+
@@ -218,7 +224,8 @@ export default { streamingUsingCellular: 'ALWAYS', androidAutoBrowseForceGrouping: false, androidAutoBrowseTopLevelLimitForGrouping: 100, - androidAutoBrowseLimitForGrouping: 50 + androidAutoBrowseLimitForGrouping: 50, + androidAutoBrowseSeriesSequenceOrder: 'ASC' }, theme: 'dark', lockCurrentOrientation: false, @@ -323,6 +330,16 @@ export default { text: this.$strings.LabelNever, value: 'NEVER' } + ], + androidAutoBrowseSeriesSequenceOrderItems: [ + { + text: this.$strings.LabelAscending, + value: 'ASC' + }, + { + text: this.$strings.LabelDescending, + value: 'DESC' + } ] } }, @@ -405,6 +422,10 @@ export default { const item = this.streamingUsingCellularItems.find((i) => i.value === this.settings.streamingUsingCellular) return item?.text || 'Error' }, + androidAutoBrowseSeriesSequenceOrderOption() { + const item = this.androidAutoBrowseSeriesSequenceOrderItems.find((i) => i.value === this.settings.androidAutoBrowseSeriesSequenceOrder) + return item?.text || 'Error' + }, moreMenuItems() { if (this.moreMenuSetting === 'shakeSensitivity') return this.shakeSensitivityItems else if (this.moreMenuSetting === 'hapticFeedback') return this.hapticFeedbackItems @@ -412,6 +433,7 @@ export default { else if (this.moreMenuSetting === 'theme') return this.themeOptionItems else if (this.moreMenuSetting === 'downloadUsingCellular') return this.downloadUsingCellularItems else if (this.moreMenuSetting === 'streamingUsingCellular') return this.streamingUsingCellularItems + else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') return this.androidAutoBrowseSeriesSequenceOrderItems return [] } }, @@ -454,6 +476,10 @@ export default { this.moreMenuSetting = 'streamingUsingCellular' this.showMoreMenuDialog = true }, + showAndroidAutoBrowseSeriesSequenceOrderOptions() { + this.moreMenuSetting = 'androidAutoBrowseSeriesSequenceOrder' + this.showMoreMenuDialog = true + }, clickMenuAction(action) { this.showMoreMenuDialog = false if (this.moreMenuSetting === 'shakeSensitivity') { @@ -474,6 +500,9 @@ export default { } else if (this.moreMenuSetting === 'streamingUsingCellular') { this.settings.streamingUsingCellular = action this.saveSettings() + } else if (this.moreMenuSetting === 'androidAutoBrowseSeriesSequenceOrder') { + this.settings.androidAutoBrowseSeriesSequenceOrder = action + this.saveSettings() } }, saveTheme(theme) { @@ -629,6 +658,7 @@ export default { this.settings.androidAutoBrowseForceGrouping = deviceSettings.androidAutoBrowseForceGrouping this.settings.androidAutoBrowseTopLevelLimitForGrouping = deviceSettings.androidAutoBrowseTopLevelLimitForGrouping this.settings.androidAutoBrowseLimitForGrouping = deviceSettings.androidAutoBrowseLimitForGrouping + this.settings.androidAutoBrowseSeriesSequenceOrder = deviceSettings.androidAutoBrowseSeriesSequenceOrder || 'ASC' }, async init() { this.loading = true diff --git a/strings/en-us.json b/strings/en-us.json index 3c3081b3..62f973e1 100644 --- a/strings/en-us.json +++ b/strings/en-us.json @@ -85,6 +85,7 @@ "HeaderTableOfContents": "Table of Contents", "HeaderUserInterfaceSettings": "User Interface Settings", "HeaderYourStats": "Your Stats", + "LabelAscending": "Ascending", "LabelAddToPlaylist": "Add to Playlist", "LabelAdded": "Added", "LabelAddedAt": "Added At", @@ -95,6 +96,7 @@ "LabelAndroidAutoBrowseForceGroupingHelp": "Forces alphabetical drawdown while browsing library and series in Android Auto", "LabelAndroidAutoBrowseLimitForGrouping": "Alphabetical drawdown stopitems", "LabelAndroidAutoBrowseLimitForGroupingHelp": "Stop alphabetical drawdown when there is less than this amount of items to show", + "LabelAndroidAutoBrowseSeriesSequenceOrder": "Series books order", "LabelAndroidAutoBrowseTopLevelLimitForGrouping": "Alphabetical drawdown start items", "LabelAndroidAutoBrowseTopLevelLimitForGroupingHelp": "If top-level has more items than this alphabetical drawdown will be used", "LabelAskConfirmation": "Ask for confirmation", @@ -120,6 +122,7 @@ "LabelContinueReading": "Continue Reading", "LabelContinueSeries": "Continue Series", "LabelCustomTime": "Custom time", + "LabelDescending": "Descending", "LabelDescription": "Description", "LabelDisableAudioFadeOut": "Disable audio fade out", "LabelDisableAudioFadeOutHelp": "Audio volume will start decreasing when there is less than 1 minute remaining on the sleep timer. Enable this setting to not fade out.",