Enchancements for Android Auto library

- Hide libraries without audiobooks
- Sort books in series by sequence value
- Added option for selecting ASC or DESC sorting for series
- Order authors alphabetically
This commit is contained in:
ISO-B 2024-10-27 21:55:17 +02:00
parent e4a3cc5290
commit 8134ec84c6
11 changed files with 189 additions and 47 deletions

View file

@ -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<SeriesType>?
) : MediaTypeMetadata(title, explicit) {
@JsonIgnore
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
@ -342,7 +344,8 @@ data class Library(
var name:String,
var folders:MutableList<Folder>,
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,

View file

@ -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
)
}
}

View file

@ -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<String>
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)
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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<LibraryItem>) : List<LibraryItem> {
val sortingLogic = compareBy<LibraryItem> { 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)
}
}
}

View file

@ -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

View file

@ -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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
@ -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 {

View file

@ -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<LibraryStats>(it.toString())
cb(libraryStats)
}
}
}
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) {
getRequest("/api/items/$libraryItemId?expanded=1", null, null) {
if (it.has("error")) {

View file

@ -170,6 +170,12 @@
<ui-text-input type="number" v-model="settings.androidAutoBrowseLimitForGrouping" style="width: 145px; max-width: 145px" @input="androidAutoBrowseLimitForGroupingUpdated" />
<span class="material-icons-outlined ml-2" @click.stop="showInfo('androidAutoBrowseLimitForGrouping')">info</span>
</div>
<div class="py-3 flex items-center">
<p class="pr-4 w-36">{{ $strings.LabelAndroidAutoBrowseSeriesSequenceOrder }}</p>
<div @click.stop="showAndroidAutoBrowseSeriesSequenceOrderOptions">
<ui-text-input :value="androidAutoBrowseSeriesSequenceOrderOption" readonly append-icon="expand_more" style="max-width: 200px" />
</div>
</div>
</template>
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
@ -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

View file

@ -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.",