Merge pull request #1322 from ISO-B/feat_android_auto_browse

Added better library browsing for Android Auto
This commit is contained in:
advplyr 2025-01-11 16:59:29 -06:00 committed by GitHub
commit 2c1f5081f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1999 additions and 141 deletions

View file

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

View file

@ -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<SeriesType>?
) : 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<Folder>,
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<LibraryItem>?
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItemSearchResultLibraryItemType(
val libraryItem: LibraryItem
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryItemSearchResultType(
var book:List<LibraryItemSearchResultLibraryItemType>?,
var podcast:List<LibraryItemSearchResultLibraryItemType>?,
var series:List<LibraryItemSearchResultSeriesItemType>?,
var authors:List<LibraryAuthorItem>?
)
// 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<LibraryItem>?
) : 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<LibrarySeriesItem>?
) : 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<LibraryAuthorItem>?
) : 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<LibraryItem>?
) : 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<LibraryItem>?
) : LibraryShelfType(id, label, total, type)

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

View file

@ -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<LibraryItem>?,
var series:MutableList<LibrarySeriesItem>?
) : 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)
}
}

View file

@ -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<LibraryItem>?,
) : 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()
}
}

View file

@ -32,10 +32,18 @@ class LibraryItem(
var media:MediaType,
var libraryFiles:MutableList<LibraryFile>?,
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<String>
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)
}
}

View file

@ -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<LibraryItem>?,
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)
}
}

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

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

View file

@ -20,13 +20,23 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
private var selectedLibraryItems = mutableListOf<LibraryItem>()
private var selectedLibraryId = ""
private var cachedLibraryAuthors : MutableMap<String, MutableMap<String, LibraryAuthorItem>> = hashMapOf()
private var cachedLibraryAuthorItems : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
private var cachedLibraryAuthorSeriesItems : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
private var cachedLibrarySeries : MutableMap<String, List<LibrarySeriesItem>> = hashMapOf()
private var cachedLibrarySeriesItem : MutableMap<String, MutableMap<String, List<LibraryItem>>> = hashMapOf()
private var cachedLibraryCollections : MutableMap<String, MutableMap<String, LibraryCollection>> = hashMapOf()
private var cachedLibraryRecentShelves : MutableMap<String, MutableList<LibraryShelfType>> = hashMapOf()
private var cachedLibraryDiscovery : MutableMap<String, MutableList<LibraryItem>> = hashMapOf()
private var cachedLibraryPodcasts : MutableMap<String, MutableMap<String, LibraryItem>> = hashMapOf()
private var isLibraryPodcastsCached : MutableMap<String, Boolean> = hashMapOf()
var allLibraryPersonalizationsDone : Boolean = false
private var libraryPersonalizationsDone : Int = 0
private var selectedPodcast:Podcast? = null
private var selectedLibraryItemId:String? = null
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
private var serverLibraryCategories = listOf<LibraryCategory>()
private var serverConfigIdUsed:String? = null
private var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = 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<ItemInProgress>) -> Unit) {
@ -120,28 +172,458 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
}
}
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> 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<String>()
(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<LibraryItem>?) -> 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<LibrarySeriesItem>) -> Unit) {
// Check "cache" first
if (cachedLibrarySeries.containsKey(libraryId)) {
Log.d(tag, "Series with audio found from cache | Library $libraryId ")
cb(cachedLibrarySeries[libraryId] as List<LibrarySeriesItem>)
} 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<LibrarySeriesItem>) -> 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<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
*/
fun loadLibrarySeriesItemsWithAudio(libraryId:String, seriesId:String, cb: (List<LibraryItem>) -> 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<LibraryAuthorItem>) -> 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<LibraryAuthorItem>) -> 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<LibraryItem>) -> 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<LibraryItem>) -> 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<LibraryCollection>) -> 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<LibraryItem>) -> 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<LibraryItem>)
}
}
/**
* 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<LibraryItem>) -> Unit) {
if (!cachedLibraryDiscovery.containsKey(libraryId)) {
cb(listOf())
}
val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]?.filter { li -> li.checkHasTracks() }
libraryItemsWithAudio?.forEach { libraryItem -> addServerLibrary(libraryItem) }
cb(libraryItemsWithAudio as List<LibraryItem>)
}
/**
* Returns recent shelves for [libraryId]
* If data is not shelves are found returns empty list
*/
fun getLibraryRecentShelfs(libraryId: String, cb: (List<LibraryShelfType>) -> Unit) {
if (!cachedLibraryRecentShelves.containsKey(libraryId)) {
Log.d(tag, "getLibraryRecentShelfs: No shelves $libraryId")
cb(listOf())
return
}
cb(cachedLibraryRecentShelves[libraryId] as List<LibraryShelfType>)
}
/**
* 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<Library>) -> 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<String, List<MediaBrowserCompat.MediaItem>> {
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<String, List<MediaBrowserCompat.MediaItem>> = 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<LibraryItem>
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]

View file

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

View file

@ -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<ItemInProgress>,
libraries: List<Library>
libraries: List<Library>,
recentsLoaded: Boolean
) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
/**
* 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__"

View file

@ -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<MediaBrowserCompat.MediaItem> = 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<MediaBrowserCompat.MediaItem> = mutableListOf()
val localBrowseItems: MutableList<MediaBrowserCompat.MediaItem> = 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<MediaBrowserCompat.MediaItem> = mutableListOf()
val localBrowseItems: MutableList<MediaBrowserCompat.MediaItem> = 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<MediaBrowserCompat.MediaItem>?)
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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
} 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem> = 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
} 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<MediaBrowserCompat.MediaItem>?)
} 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<MediaBrowserCompat.MediaItem>?)
} 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<MediaBrowserCompat.MediaItem>?)
} 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<MediaBrowserCompat.MediaItem>?)
} 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<MediaBrowserCompat.MediaItem>?)
} else {
val children = seriesItems.map { seriesItem ->
val description = seriesItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
} 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<MediaBrowserCompat.MediaItem>?)
} else {
val children = seriesItems.map { seriesItem ->
val description = seriesItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
} 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
} else {
val children = authorItems.map { authorItem ->
val description = authorItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
} 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<MediaBrowserCompat.MediaItem>?)
} else {
val children = authorItems.map { authorItem ->
val description = authorItem.getMediaDescription(null, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
}
}
} 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MediaBrowserCompat.MediaItem>?)
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} 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<MediaBrowserCompat.MediaItem>?)
}
} 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<MutableList<MediaBrowserCompat.MediaItem>>) {
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<MediaBrowserCompat.MediaItem> = mutableListOf()
var foundPodcasts: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
var foundSeries: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
var foundAuthors: MutableList<MediaBrowserCompat.MediaItem> = 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<MediaBrowserCompat.MediaItem>?)
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("/")
}
}
}
}

View file

@ -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<Library>) -> Unit) {
val mapper = jacksonMapper
getRequest("/api/libraries", null,null) {
getRequest("/api/libraries?include=stats", null,null) {
val libraries = mutableListOf<Library>()
var array = JSONArray()
@ -171,6 +172,23 @@ class ApiHandler(var ctx:Context) {
}
}
fun getLibraryPersonalized(libraryItemId:String, cb: (List<LibraryShelfType>?) -> 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<LibraryShelfType>()
val array = it.getJSONArray("value")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryShelfType>(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<LibrarySeriesItem>) -> Unit) {
Log.d(tag, "Getting series")
getRequest("/api/libraries/$libraryId/series?minified=1&sort=name&limit=10000", null, null) {
val items = mutableListOf<LibrarySeriesItem>()
if (it.has("results")) {
val array = it.getJSONArray("results")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibrarySeriesItem>(array.get(i).toString())
items.add(item)
}
}
cb(items)
}
}
fun getLibrarySeriesItems(libraryId:String, seriesId:String, cb: (List<LibraryItem>) -> 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<LibraryItem>()
if (it.has("results")) {
val array = it.getJSONArray("results")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
items.add(item)
}
}
cb(items)
}
}
fun getLibraryAuthors(libraryId:String, cb: (List<LibraryAuthorItem>) -> Unit) {
Log.d(tag, "Getting series")
getRequest("/api/libraries/$libraryId/authors", null, null) {
val items = mutableListOf<LibraryAuthorItem>()
if (it.has("authors")) {
val array = it.getJSONArray("authors")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryAuthorItem>(array.get(i).toString())
items.add(item)
}
}else{
Log.e(tag, "No results")
}
cb(items)
}
}
fun getLibraryItemsFromAuthor(libraryId:String, authorId:String, cb: (List<LibraryItem>) -> 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<LibraryItem>()
if (it.has("results")) {
val array = it.getJSONArray("results")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryItem>(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<LibraryCollection>) -> Unit) {
Log.d(tag, "Getting collections")
getRequest("/api/libraries/$libraryId/collections?minified=1&sort=name&limit=1000", null, null) {
val items = mutableListOf<LibraryCollection>()
if (it.has("results")) {
val array = it.getJSONArray("results")
for (i in 0 until array.length()) {
val item = jacksonMapper.readValue<LibraryCollection>(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<LibraryItemSearchResultType>(it.toString())
cb(librarySearchResults)
}
}
}
fun getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
getRequest("/api/me/items-in-progress", null, null) {
val items = mutableListOf<ItemInProgress>()

View file

@ -0,0 +1 @@
<!-- drawable/clock_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" /></vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="27dp"
android:height="32dp"
android:viewportWidth="27"
android:viewportHeight="32">
<path
android:pathData="M26.719,15.761c-0.165,-0.138 -0.422,-0.341 -0.77,-0.581v-2.703c0,-6.891 -5.586,-12.477 -12.478,-12.477v0c-6.891,0 -12.477,5.586 -12.477,12.477v2.703c-0.348,0.241 -0.605,0.443 -0.77,0.581 -0.137,0.114 -0.223,0.285 -0.223,0.476 0,0 0,0 0,0.001v-0,3.238c0,0 0,0.001 0,0.001 0,0.191 0.086,0.361 0.222,0.475l0.001,0.001c0.385,0.321 1.269,0.993 2.645,1.683v0.315c0,0.849 0.548,1.537 1.223,1.537v0c0.675,0 1.223,-0.688 1.223,-1.537v-7.767c0,-0.849 -0.548,-1.537 -1.223,-1.537v0c-0.647,0 -1.177,0.632 -1.22,1.431l-0.003,0.002v-1.601c0,-5.856 4.747,-10.602 10.603,-10.602v0c5.856,0 10.603,4.747 10.603,10.602v1.601l-0.003,-0.002c-0.043,-0.799 -0.573,-1.431 -1.22,-1.431v0c-0.675,0 -1.223,0.688 -1.223,1.537v7.766c0,0.849 0.548,1.537 1.223,1.537v0c0.676,0 1.223,-0.688 1.223,-1.537v-0.315c1.376,-0.69 2.26,-1.362 2.645,-1.683 0.137,-0.114 0.223,-0.285 0.223,-0.476 0,-0 0,-0.001 0,-0.001v0,-3.237c0,-0 0,-0 0,-0 0,-0.191 -0.086,-0.361 -0.222,-0.475l-0.001,-0.001zM9.12,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM6.673,13.731h3.368v0.352h-3.368zM14.234,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM11.787,13.731h3.367v0.352h-3.367zM19.348,29.262c0.816,0 1.477,-0.661 1.477,-1.477v0,-16.543c0,-0 0,-0 0,-0 0,-0.816 -0.661,-1.477 -1.477,-1.477h-1.526c-0.816,0 -1.478,0.662 -1.478,1.478v0,16.543c0,0.816 0.661,1.477 1.477,1.477 0,0 0,0 0,0v0zM16.901,13.731h3.367v0.352h-3.367zM3.566,29.773h19.81c0.615,0 1.113,0.498 1.113,1.113v0c0,0.615 -0.498,1.113 -1.113,1.113h-19.81c-0.615,0 -1.113,-0.498 -1.113,-1.113v-0c0,-0.615 0.498,-1.113 1.113,-1.113z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M16,7.333c2.577,0 4.667,2.089 4.667,4.667v0c0,2.577 -2.089,4.667 -4.667,4.667v0c-2.577,0 -4.667,-2.089 -4.667,-4.667v0c0,-2.577 2.089,-4.667 4.667,-4.667v0zM6.667,10.667c0.747,0 1.44,0.2 2.04,0.56 -0.2,1.907 0.36,3.8 1.507,5.28 -0.667,1.28 -2,2.16 -3.547,2.16 -2.209,0 -4,-1.791 -4,-4v0c0,-2.209 1.791,-4 4,-4v0zM25.333,10.667c2.209,0 4,1.791 4,4v0c0,2.209 -1.791,4 -4,4v0c-1.547,0 -2.88,-0.88 -3.547,-2.16 1.147,-1.48 1.707,-3.373 1.507,-5.28 0.6,-0.36 1.293,-0.56 2.04,-0.56zM7.333,24.333c0,-2.76 3.88,-5 8.667,-5s8.667,2.24 8.667,5v2.333h-17.333v-2.333zM0,26.667v-2c0,-1.853 2.52,-3.413 5.933,-3.867 -0.787,0.907 -1.267,2.16 -1.267,3.533v2.333h-4.667zM32,26.667h-4.667v-2.333c0,-1.373 -0.48,-2.627 -1.267,-3.533 3.413,0.453 5.933,2.013 5.933,3.867v2z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M28,4v26h-21c-1.657,0 -3,-1.343 -3,-3s1.343,-3 3,-3h19v-24h-20c-2.2,0 -4,1.8 -4,4v24c0,2.2 1.8,4 4,4h24v-28h-2zM7.002,26v0c-0.001,0 -0.001,0 -0.002,0 -0.552,0 -1,0.448 -1,1s0.448,1 1,1c0.001,0 0.001,-0 0.002,-0v0h18.997v-2h-18.997z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M12,4v20h4v-20h-4zM16,6.667l5.333,17.333 4,-1.333 -5.333,-17.333 -4,1.333zM6.667,6.667v17.333h4v-17.333h-4zM4,25.333v2.667h24v-2.667h-24z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="32dp"
android:viewportWidth="36"
android:viewportHeight="32">
<path
android:pathData="M7,4h-6c-0.55,0 -1,0.45 -1,1v22c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-22c0,-0.55 -0.45,-1 -1,-1zM6,10h-4v-2h4v2zM17,4h-6c-0.55,0 -1,0.45 -1,1v22c0,0.55 0.45,1 1,1h6c0.55,0 1,-0.45 1,-1v-22c0,-0.55 -0.45,-1 -1,-1zM16,10h-4v-2h4v2zM23.909,5.546l-5.358,2.7c-0.491,0.247 -0.691,0.852 -0.443,1.343l8.999,17.861c0.247,0.491 0.852,0.691 1.343,0.443l5.358,-2.7c0.491,-0.247 0.691,-0.852 0.443,-1.343l-8.999,-17.861c-0.247,-0.491 -0.852,-0.691 -1.343,-0.443z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M19.2,6h-6.4v19.2h6.4v-19.2zM22.4,6v19.2h6.4v-19.2h-6.4zM9.6,6h-6.4v19.2h6.4v-19.2zM0,2.8h32v25.6h-32v-25.6z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="27dp"
android:height="32dp"
android:viewportWidth="27"
android:viewportHeight="32">
<path
android:pathData="M13.68,0c7.518,0 13.612,2.854 13.612,6.37 0,3.518 -6.096,6.37 -13.612,6.37s-13.612,-2.854 -13.612,-6.37c0,-3.516 6.096,-6.37 13.612,-6.37v0zM0.068,21.31v4.891c2.422,8.602 26.349,6.94 27.227,-0.44v-4.885c-1.195,8.102 -25.313,8.685 -27.227,0.435v0,0zM0,8.578v4.776c2.422,8.401 26.482,7.266 27.362,0.06v-4.773c-1.198,7.914 -25.448,7.995 -27.362,-0.063v0zM0,14.75v4.891c2.422,8.602 26.482,7.44 27.362,0.06v-4.885c-1.198,8.102 -25.448,8.185 -27.362,-0.065v0z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M26,28h-20v-4l6,-10 8.219,10 5.781,-4v8zM26,15c0,1.657 -1.343,3 -3,3s-3,-1.343 -3,-3 1.343,-3 3,-3c1.657,0 3,1.343 3,3zM28.681,7.159c-0.694,-0.947 -1.662,-2.053 -2.724,-3.116s-2.169,-2.03 -3.116,-2.724c-1.612,-1.182 -2.393,-1.319 -2.841,-1.319h-15.5c-1.378,0 -2.5,1.121 -2.5,2.5v27c0,1.378 1.122,2.5 2.5,2.5h23c1.378,0 2.5,-1.122 2.5,-2.5v-19.5c0,-0.448 -0.137,-1.23 -1.319,-2.841zM24.543,5.457c0.959,0.959 1.712,1.825 2.268,2.543h-4.811v-4.811c0.718,0.556 1.584,1.309 2.543,2.268zM28,29.5c0,0.271 -0.229,0.5 -0.5,0.5h-23c-0.271,0 -0.5,-0.229 -0.5,-0.5v-27c0,-0.271 0.229,-0.5 0.5,-0.5 0,0 15.499,-0 15.5,0v7c0,0.552 0.448,1 1,1h7v19.5z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M9,18h-2v14h2c0.55,0 1,-0.45 1,-1v-12c0,-0.55 -0.45,-1 -1,-1zM23,18c-0.55,0 -1,0.45 -1,1v12c0,0.55 0.45,1 1,1h2v-14h-2zM32,16c0,-8.837 -7.163,-16 -16,-16s-16,7.163 -16,16c0,1.919 0.338,3.759 0.958,5.464 -0.609,1.038 -0.958,2.246 -0.958,3.536 0,3.526 2.608,6.443 6,6.929v-13.857c-0.997,0.143 -1.927,0.495 -2.742,1.012 -0.168,-0.835 -0.258,-1.699 -0.258,-2.584 0,-7.18 5.82,-13 13,-13s13,5.82 13,13c0,0.885 -0.088,1.749 -0.257,2.584 -0.816,-0.517 -1.745,-0.87 -2.743,-1.013v13.858c3.392,-0.485 6,-3.402 6,-6.929 0,-1.29 -0.349,-2.498 -0.958,-3.536 0.62,-1.705 0.958,-3.545 0.958,-5.465z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M23.6,2c-3.363,0 -6.258,2.736 -7.599,5.594 -1.342,-2.858 -4.237,-5.594 -7.601,-5.594 -4.637,0 -8.4,3.764 -8.4,8.401 0,9.433 9.516,11.906 16.001,21.232 6.13,-9.268 15.999,-12.1 15.999,-21.232 0,-4.637 -3.763,-8.401 -8.4,-8.401z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="32dp"
android:viewportWidth="24"
android:viewportHeight="32">
<path
android:pathData="M6,6v10c0,3.313 2.688,6 6,6s6,-2.688 6,-6h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5v-2h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5v-2h-5c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h5c0,-3.313 -2.688,-6 -6,-6s-6,2.688 -6,6zM20,15v1c0,4.419 -3.581,8 -8,8s-8,-3.581 -8,-8v-2.5c0,-0.831 -0.669,-1.5 -1.5,-1.5s-1.5,0.669 -1.5,1.5v2.5c0,5.569 4.138,10.169 9.5,10.9v2.1h-3c-0.831,0 -1.5,0.669 -1.5,1.5s0.669,1.5 1.5,1.5h9c0.831,0 1.5,-0.669 1.5,-1.5s-0.669,-1.5 -1.5,-1.5h-3v-2.1c5.363,-0.731 9.5,-5.331 9.5,-10.9v-2.5c0,-0.831 -0.669,-1.5 -1.5,-1.5s-1.5,0.669 -1.5,1.5v1.5z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="32dp"
android:viewportWidth="22"
android:viewportHeight="32">
<path
android:pathData="M22.292,14.872c0,-0.836 -0.677,-1.51 -1.51,-1.51 -0.836,0 -1.51,0.677 -1.51,1.51 0,2.977 -0.714,5.435 -2.159,7.076 -1.31,1.484 -3.289,2.341 -5.964,2.341s-4.654,-0.854 -5.964,-2.339c-1.448,-1.641 -2.159,-4.099 -2.159,-7.078 0,-0.836 -0.677,-1.51 -1.51,-1.51 -0.836,0 -1.51,0.677 -1.51,1.51 0,3.711 0.961,6.857 2.914,9.073 1.438,1.63 3.375,2.734 5.815,3.164v2.479h-3.703c-0.661,0 -1.203,0.542 -1.203,1.203v1.206h14.646v-1.206c0,-0.661 -0.542,-1.203 -1.203,-1.203h-3.711v-2.479c2.44,-0.432 4.375,-1.534 5.815,-3.167 1.953,-2.214 2.917,-5.359 2.917,-9.07v0,0zM11.146,0c3.083,0 5.604,2.523 5.604,5.604v0.146h-3.013v3.57h3.016v1.818h-3.016v3.57h3.016v1.284c0,3.083 -2.523,5.604 -5.604,5.604 -3.083,0 -5.604,-2.523 -5.604,-5.604v-1.284h3.016v-3.57h-3.018v-1.818h3.016v-3.57h-3.016v-0.146c0,-3.081 2.521,-5.604 5.604,-5.604v0z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M15,22c2.761,0 5,-2.239 5,-5v-12c0,-2.761 -2.239,-5 -5,-5s-5,2.239 -5,5v12c0,2.761 2.239,5 5,5zM22,14v3c0,3.866 -3.134,7 -7,7s-7,-3.134 -7,-7v-3h-2v3c0,4.632 3.5,8.447 8,8.944v4.056h-4v2h10v-2h-4v-4.056c4.5,-0.497 8,-4.312 8,-8.944v-3h-2z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M30,0h2v23c0,2.761 -3.134,5 -7,5s-7,-2.239 -7,-5c0,-2.761 3.134,-5 7,-5 1.959,0 3.729,0.575 5,1.501v-11.501l-16,3.556v15.444c0,2.761 -3.134,5 -7,5s-7,-2.239 -7,-5c0,-2.761 3.134,-5 7,-5 1.959,0 3.729,0.575 5,1.501v-19.501l18,-4z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="33dp"
android:height="32dp"
android:viewportWidth="33"
android:viewportHeight="32">
<path
android:pathData="M18.289,18.549v13.45h-3.973v-13.45c-1.305,-0.706 -2.191,-2.087 -2.191,-3.676 0,-2.307 1.871,-4.178 4.178,-4.178s4.178,1.871 4.178,4.178c0,1.589 -0.887,2.97 -2.192,3.676v0zM10.434,2.963c0.774,-0.382 1.091,-1.319 0.709,-2.091s-1.318,-1.091 -2.091,-0.71c-3.158,1.558 -5.613,4.134 -7.165,7.169 -1.146,2.241 -1.8,4.734 -1.879,7.251 -0.079,2.538 0.421,5.101 1.585,7.46 1.433,2.906 3.863,5.491 7.44,7.322 0.767,0.392 1.706,0.088 2.098,-0.679s0.088,-1.706 -0.679,-2.097c-2.929,-1.498 -4.905,-3.589 -6.059,-5.926 -0.93,-1.887 -1.33,-3.942 -1.267,-5.981 0.064,-2.058 0.599,-4.098 1.536,-5.93 1.256,-2.455 3.234,-4.536 5.771,-5.786v0zM23.554,0.161c-0.773,-0.382 -1.71,-0.064 -2.091,0.71s-0.064,1.71 0.71,2.091c2.537,1.251 4.515,3.332 5.771,5.787 0.937,1.832 1.472,3.871 1.536,5.93 0.064,2.038 -0.336,4.094 -1.267,5.981 -1.153,2.337 -3.13,4.428 -6.059,5.926 -0.767,0.392 -1.07,1.331 -0.679,2.097s1.331,1.071 2.098,0.679c3.577,-1.83 6.007,-4.415 7.44,-7.322 1.164,-2.359 1.664,-4.922 1.585,-7.46 -0.079,-2.516 -0.732,-5.01 -1.878,-7.251 -1.552,-3.034 -4.008,-5.611 -7.165,-7.169v0zM21.968,7.033c-0.582,-0.42 -1.395,-0.287 -1.814,0.295s-0.287,1.395 0.295,1.814c0.235,0.169 0.465,0.359 0.691,0.566 1.319,1.216 2.101,2.838 2.266,4.553 0.166,1.731 -0.295,3.566 -1.464,5.188 -0.198,0.276 -0.427,0.555 -0.686,0.836 -0.487,0.529 -0.453,1.353 0.076,1.839s1.353,0.452 1.84,-0.076c0.313,-0.339 0.607,-0.701 0.88,-1.081 1.555,-2.16 2.167,-4.617 1.943,-6.951 -0.225,-2.349 -1.293,-4.566 -3.091,-6.224 -0.283,-0.261 -0.595,-0.514 -0.935,-0.76v0zM12.156,9.143c0.582,-0.42 0.715,-1.232 0.295,-1.814s-1.232,-0.715 -1.814,-0.296c-0.341,0.246 -0.652,0.499 -0.935,0.76 -1.799,1.658 -2.866,3.875 -3.091,6.224 -0.224,2.334 0.387,4.792 1.943,6.952 0.274,0.379 0.567,0.741 0.88,1.08 0.487,0.529 1.311,0.563 1.839,0.076s0.563,-1.311 0.076,-1.839c-0.259,-0.281 -0.487,-0.56 -0.686,-0.836 -1.169,-1.623 -1.63,-3.457 -1.464,-5.188 0.164,-1.715 0.947,-3.337 2.266,-4.553 0.226,-0.207 0.456,-0.397 0.691,-0.566v0z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M30.925,2.938c0.794,-0.231 1.25,-1.069 1.019,-1.863s-1.069,-1.25 -1.869,-1.012l-26.844,7.869c-0.587,0.169 -1.119,0.456 -1.569,0.825 -1.006,0.725 -1.663,1.906 -1.663,3.244v16c0,2.206 1.794,4 4,4h24c2.206,0 4,-1.794 4,-4v-16c0,-2.206 -1.794,-4 -4,-4h-14.344l17.269,-5.063zM23,25c-2.762,0 -5,-2.238 -5,-5s2.238,-5 5,-5 5,2.238 5,5 -2.238,5 -5,5zM5,16c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-6c-0.55,0 -1,-0.45 -1,-1zM4,20c0,-0.55 0.45,-1 1,-1h8c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-8c-0.55,0 -1,-0.45 -1,-1zM5,24c0,-0.55 0.45,-1 1,-1h6c0.55,0 1,0.45 1,1s-0.45,1 -1,1h-6c-0.55,0 -1,-0.45 -1,-1z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M22,2l-10,10h-6l-6,8c0,0 6.357,-1.77 10.065,-0.94l-10.065,12.94 13.184,-10.255c1.839,4.208 -1.184,10.255 -1.184,10.255l8,-6v-6l10,-10 2,-10 -10,2z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M4.259,23.467c-2.35,0 -4.259,1.917 -4.259,4.252 0,2.349 1.909,4.244 4.259,4.244 2.358,0 4.265,-1.895 4.265,-4.244 -0,-2.336 -1.907,-4.252 -4.265,-4.252zM0.005,10.873v6.133c3.993,0 7.749,1.562 10.577,4.391 2.825,2.822 4.384,6.595 4.384,10.603h6.16c-0,-11.651 -9.478,-21.127 -21.121,-21.127zM0.012,0v6.136c14.243,0 25.836,11.604 25.836,25.864h6.152c0,-17.64 -14.352,-32 -31.988,-32z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:pathData="M32,12.408l-11.056,-1.607 -4.944,-10.018 -4.944,10.018 -11.056,1.607 8,7.798 -1.889,11.011 9.889,-5.199 9.889,5.199 -1.889,-11.011 8,-7.798z"
android:fillColor="#fff"/>
</vector>

View file

@ -0,0 +1 @@
<!-- drawable/account_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" /></vector>

View file

@ -0,0 +1 @@
<!-- drawable/book_multiple_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M19 2A2 2 0 0 1 21 4V16A2 2 0 0 1 19 18H9A2 2 0 0 1 7 16V4A2 2 0 0 1 9 2H19M19 4H16V10L13.5 7.75L11 10V4H9V16H19M3 20A2 2 0 0 0 5 22H17V20H5V6H3Z" /></vector>

View file

@ -0,0 +1 @@
<!-- drawable/book_open_blank_variant_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12 21.5C10.65 20.65 8.2 20 6.5 20C4.85 20 3.15 20.3 1.75 21.05C1.65 21.1 1.6 21.1 1.5 21.1C1.25 21.1 1 20.85 1 20.6V6C1.6 5.55 2.25 5.25 3 5C4.11 4.65 5.33 4.5 6.5 4.5C8.45 4.5 10.55 4.9 12 6C13.45 4.9 15.55 4.5 17.5 4.5C18.67 4.5 19.89 4.65 21 5C21.75 5.25 22.4 5.55 23 6V20.6C23 20.85 22.75 21.1 22.5 21.1C22.4 21.1 22.35 21.1 22.25 21.05C20.85 20.3 19.15 20 17.5 20C15.8 20 13.35 20.65 12 21.5M11 7.5C9.64 6.9 7.84 6.5 6.5 6.5C5.3 6.5 4.1 6.65 3 7V18.5C4.1 18.15 5.3 18 6.5 18C7.84 18 9.64 18.4 11 19V7.5M13 19C14.36 18.4 16.16 18 17.5 18C18.7 18 19.9 18.15 21 18.5V7C19.9 6.65 18.7 6.5 17.5 6.5C16.16 6.5 14.36 6.9 13 7.5V19Z" /></vector>

View file

@ -0,0 +1 @@
<!-- drawable/clock_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" /></vector>

View file

@ -0,0 +1 @@
<!-- drawable/telescope.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFF" android:pathData="M21.9,8.9L20.2,9.9L16.2,3L17.9,2L21.9,8.9M9.8,7.9L12.8,13.1L18.9,9.6L15.9,4.4L9.8,7.9M11.4,12.7L9.4,9.2L5.1,11.7L7.1,15.2L11.4,12.7M2.1,14.6L3.1,16.3L5.7,14.8L4.7,13.1L2.1,14.6M12.1,14L11.8,13.6L7.5,16.1L7.8,16.5C8,16.8 8.3,17.1 8.6,17.3L7,22H9L10.4,17.7H10.5L12,22H14L12.1,16.4C12.6,15.7 12.6,14.8 12.1,14Z" /></vector>

View file

@ -150,6 +150,22 @@
</div>
</div>
<!-- Android Auto settings -->
<template v-if="!isiOS">
<p class="uppercase text-xs font-semibold text-fg-muted mb-2 mt-10">{{ $strings.HeaderAndroidAutoSettings }}</p>
<div class="py-3 flex items-center">
<p class="pr-4 w-36">{{ $strings.LabelAndroidAutoBrowseLimitForGrouping }}</p>
<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">
<ui-loading-indicator />
</div>
@ -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

View file

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