From b335fd30d12854c428bee47f0f424ca1f2747544 Mon Sep 17 00:00:00 2001 From: ISO-B <3048685+ISO-B@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:20:03 +0200 Subject: [PATCH] Android Auto improvements General: - New top menu item Recent is added - Library caches are cleared when switching server Search: - Is done using server API - Latest search is cache to prevent need to make new request when returning from browsable item. - Results are grouped by book, series, author and split by library - Only searches libraries with audio content Library personalized shelves: - Recent books, series, authors, podcasts and episodes shelves are listed under Recent top menu - Discovery shelves can be found under library many from corresponding library --- .../audiobookshelf/app/data/DataClasses.kt | 69 ++++- .../audiobookshelf/app/data/LibraryItem.kt | 3 +- .../audiobookshelf/app/media/MediaManager.kt | 251 ++++++++++++++-- .../audiobookshelf/app/player/BrowseTree.kt | 19 +- .../app/player/PlayerNotificationService.kt | 270 ++++++++++++++---- .../audiobookshelf/app/server/ApiHandler.kt | 17 ++ .../res/drawable-anydpi/md_clock_outline.xml | 1 + .../res/drawable-hdpi/md_clock_outline.png | Bin 0 -> 798 bytes .../res/drawable-mdpi/md_clock_outline.png | Bin 0 -> 532 bytes .../res/drawable-xhdpi/md_clock_outline.png | Bin 0 -> 1063 bytes .../res/drawable-xxhdpi/md_clock_outline.png | Bin 0 -> 1562 bytes .../main/res/drawable/md_clock_outline.xml | 1 + 12 files changed, 554 insertions(+), 77 deletions(-) create mode 100644 android/app/src/main/res/drawable-anydpi/md_clock_outline.xml create mode 100644 android/app/src/main/res/drawable-hdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-mdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-xhdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png create mode 100644 android/app/src/main/res/drawable/md_clock_outline.xml diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt index 37c877f3..e81e84cf 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -348,9 +348,13 @@ data class Library( var stats: LibraryStats? ) { @JsonIgnore - fun getMediaMetadata(): MediaMetadataCompat { + fun getMediaMetadata(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) }.build() @@ -423,3 +427,64 @@ data class LibraryItemSearchResultType( var series:List?, var authors:List? ) + +@JsonTypeInfo( + use=JsonTypeInfo.Id.NAME, + property = "type", + include = JsonTypeInfo.As.PROPERTY, + visible = true +) +@JsonSubTypes( + JsonSubTypes.Type(LibraryShelfBookEntity::class, name = "book"), + JsonSubTypes.Type(LibraryShelfSeriesEntity::class, name = "series"), + JsonSubTypes.Type(LibraryShelfAuthorEntity::class, name = "authors"), + JsonSubTypes.Type(LibraryShelfEpisodeEntity::class, name = "episode"), + JsonSubTypes.Type(LibraryShelfPodcastEntity::class, name = "podcast") +) +@JsonIgnoreProperties(ignoreUnknown = true) +sealed class LibraryShelfType( + open val id: String, + open val label: String, + open val total: Int, + open val type: String, +) + +data class LibraryShelfBookEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfSeriesEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfAuthorEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfEpisodeEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) + +data class LibraryShelfPodcastEntity( + override val id: String, + override val label: String, + override val total: Int, + override val type: String, + val entities: List? +) : LibraryShelfType(id, label, total, type) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt index 89b3fab6..965ca35d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -33,7 +33,8 @@ class LibraryItem( var libraryFiles:MutableList?, var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads) var collapsedSeries: CollapsedSeries?, - var localLibraryItemId:String? // For Android Auto + var localLibraryItemId:String?, // For Android Auto + val recentEpisode: PodcastEpisode? // Podcast episode shelf uses this ) : LibraryItemWrapper(id) { @get:JsonIgnore val title: String diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index feb755bc..3506e777 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -20,8 +20,6 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { val tag = "MediaManager" private var serverLibraryItems = mutableListOf() // Store all items here - private var selectedLibraryItems = mutableListOf() - private var selectedLibraryId = "" private var cachedLibraryAuthors : MutableMap> = hashMapOf() private var cachedLibraryAuthorItems : MutableMap>> = hashMapOf() @@ -29,11 +27,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { private var cachedLibrarySeries : MutableMap> = hashMapOf() private var cachedLibrarySeriesItem : MutableMap>> = hashMapOf() private var cachedLibraryCollections : MutableMap> = hashMapOf() + private var cachedLibraryRecentShelfs : MutableMap> = hashMapOf() + private var cachedLibraryDiscovery : MutableMap> = hashMapOf() + private var cachedLibraryPodcasts : MutableMap> = hashMapOf() + private var isLibraryPodcastsCached : MutableMap = hashMapOf() private var selectedPodcast:Podcast? = null private var selectedLibraryItemId:String? = null private var podcastEpisodeLibraryItemMap = mutableMapOf() - private var serverLibraryCategories = listOf() private var serverConfigIdUsed:String? = null private var serverConfigLastPing:Long = 0L var serverUserMediaProgress:MutableList = mutableListOf() @@ -46,10 +47,27 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { return serverLibraries.find { it.id == id } != null } + 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 } } + 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 @@ -109,11 +127,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) { podcastEpisodeLibraryItemMap = mutableMapOf() - serverLibraryCategories = listOf() serverLibraries = listOf() serverLibraryItems = mutableListOf() - selectedLibraryItems = mutableListOf() - selectedLibraryId = "" + cachedLibraryAuthors = hashMapOf() + cachedLibraryAuthorItems = hashMapOf() + cachedLibraryAuthorSeriesItems = hashMapOf() + cachedLibrarySeries = hashMapOf() + cachedLibrarySeriesItem = hashMapOf() + cachedLibraryCollections = hashMapOf() + cachedLibraryRecentShelfs = hashMapOf() + cachedLibraryDiscovery = hashMapOf() + cachedLibraryPodcasts = hashMapOf() + isLibraryPodcastsCached = hashMapOf() } } @@ -131,24 +156,112 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } - fun loadLibraryItemsWithAudio(libraryId:String, cb: (List) -> Unit) { - if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) { - cb(selectedLibraryItems) + fun populatePersonalizedDataForAllLibraries(cb: () -> Unit ) { + serverLibraries.forEach { + 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}") + } + } + } + + private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) { + apiHandler.getLibraryPersonalized(libraryId) { shelfs -> + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId") + if (shelfs === null) return@getLibraryPersonalized + shelfs.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 (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[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 (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[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 (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + + (shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem -> + loadPodcastItem(libraryItem.libraryId, libraryItem.id) {} + } + } + } else if (shelf.type == "podcast") { + if (shelf.id == "recently-added"){ + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } + else if (shelf.id == "discover"){ + return@map + } + } else if (shelf.type =="authors") { + if (shelf.id == "newest-authors") { + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cachedLibraryRecentShelfs[libraryId] = mutableListOf() + } + cachedLibraryRecentShelfs[libraryId]!!.add(shelf) + } + } + + } + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId DONE") + cb() + } + } + + fun loadLibraryPodcasts(libraryId:String, cb: (List?) -> Unit) { + // Without this there is possibility that only recent podcasts get loaded + // Loading recent podcasts will also create cachedLibraryPodcasts entry for library + if (!isLibraryPodcastsCached.containsKey(libraryId)) { + isLibraryPodcastsCached[libraryId] = false + } + // Ensure that there is map for library + if (!cachedLibraryPodcasts.containsKey(libraryId)) { + cachedLibraryPodcasts[libraryId] = mutableMapOf() + } + if (isLibraryPodcastsCached.getOrElse(libraryId) {false}) { + Log.d(tag, "loadLibraryPodcasts: Found from cache: $libraryId") + cb(cachedLibraryPodcasts[libraryId]?.values?.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) } else { apiHandler.getLibraryItems(libraryId) { libraryItems -> val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } - if (libraryItemsWithAudio.isNotEmpty()) { - selectedLibraryId = libraryId - } - selectedLibraryItems = mutableListOf() libraryItemsWithAudio.forEach { libraryItem -> - selectedLibraryItems.add(libraryItem) + cachedLibraryPodcasts[libraryId]?.set(libraryItem.id, libraryItem) if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { serverLibraryItems.add(libraryItem) } } - cb(libraryItemsWithAudio) + isLibraryPodcastsCached[libraryId] = true + Log.d(tag, "loadLibraryPodcasts: loaded from server: $libraryId") + cb(libraryItemsWithAudio.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) } } } @@ -386,6 +499,62 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } + fun loadLibraryDiscoveryBooksWithAudio(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryDiscovery.containsKey(libraryId)) { + cb(listOf()) + } + val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]!!.filter { li -> li.checkHasTracks() } + libraryItemsWithAudio.forEach { libraryItem -> addServerLibrary(libraryItem) } + cb(libraryItemsWithAudio) + } + + fun getLibraryRecentShelfs(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cb(listOf()) + return + } + cb(cachedLibraryRecentShelfs[libraryId] as List) + } + + fun getLibraryRecentShelfByType(libraryId: String, type:String, cb: (LibraryShelfType?) -> Unit) { + Log.d(tag, "getLibraryRecentShelfByType: $libraryId | $type") + if (!cachedLibraryRecentShelfs.containsKey(libraryId)) { + cb(null) + return + } + for (shelf in cachedLibraryRecentShelfs[libraryId]!!) { + if (shelf.type == type.lowercase()) { + cb(shelf) + return + } + } + cb(null) + } + + 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, "Podcast found from cache | Library $libraryItemId ") + cb(cachedLibraryPodcasts[libraryId]?.get(libraryItemId)) + } else { + Log.d(tag, "loadPodcastItem: $libraryItemId") + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + if (libraryItem !== null) { + Log.d(tag, "loadPodcastItem: Got library item $libraryItem") + 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)) @@ -619,10 +788,56 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { } } - suspend fun searchLocalCache(libraryId: String, queryString: String) : LibraryItemSearchResultType? { + suspend fun doSearch(libraryId: String, queryString: String) : Map> { return suspendCoroutine { - apiHandler.getSearchResults(libraryId, queryString) { results -> - it.resume(results) + apiHandler.getSearchResults(libraryId, queryString) { searchResult -> + Log.d(tag, "searchLocalCache: $searchResult") + // Nothing found from server + if (searchResult === null) { + it.resume(mapOf()) + return@getSearchResults + } + + var foundItems: MutableMap> = mutableMapOf() + + val serverLibrary = serverLibraries.find { sl -> sl.id == libraryId } + + // Books + if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) { + Log.d(tag, "searchLocalCache: found ${searchResult.book!!.size} books") + val children = searchResult.book!!.map { bookResult -> + val libraryItem = bookResult.libraryItem + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + foundItems["book"] = children + } + if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.series!!.size} series") + val children = searchResult.series!!.map { seriesResult -> + val seriesItem = seriesResult.series + seriesItem.books = seriesResult.books as MutableList + val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["series"] = children + } + if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors") + val children = searchResult.authors!!.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["authors"] = children + } + + it.resume(foundItems) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt index 9c374a5b..9bc5c16e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -32,10 +32,16 @@ class BrowseTree( val continueListeningMetadata = MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT) - putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening") + putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue") putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(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(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") @@ -53,14 +59,24 @@ class BrowseTree( } if (libraries.isNotEmpty()) { + rootList += recentMetadata rootList += librariesMetadata libraries.forEach { library -> + // Skip libraries without audio content if (library.stats?.numAudioTracks == 0) return@forEach + + // Generate library list items for Libraries menu val libraryMediaMetadata = library.getMediaMetadata() val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf() children += libraryMediaMetadata mediaIdToChildren[LIBRARIES_ROOT] = children + + // Generate library list items for Recent menu + val recentlyMediaMetadata = library.getMediaMetadata("recently") + val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf() + childrenRecently += recentlyMediaMetadata + mediaIdToChildren[RECENTLY_ROOT] = childrenRecently } } @@ -76,3 +92,4 @@ const val AUTO_BROWSE_ROOT = "/" const val CONTINUE_ROOT = "__CONTINUE__" const val DOWNLOADS_ROOT = "__DOWNLOADS__" const val LIBRARIES_ROOT = "__LIBRARIES__" +const val RECENTLY_ROOT = "__RECENTLY__" diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 73fa6530..36629c12 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -120,6 +120,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var mShakeDetector: ShakeDetector? = null private var shakeSensorUnregisterTask:TimerTask? = null + // These are used to prevent things running multiple times or simultaneously + private var isLoadingAndroidAutoItems:Boolean = false + + // Cache latest search so it wont trigger again when returning from series for example + private var cachedSearch : String = "" + private var cachedSearchResults : MutableList = mutableListOf() + /* Service related stuff */ @@ -977,6 +984,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 @@ -1088,22 +1096,37 @@ 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) { + isLoadingAndroidAutoItems = true + mediaManager.loadAndroidAutoItems { + browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries) - val children = browseTree[parentMediaId]?.map { item -> - Log.d(tag, "Loading Browser Media Item ${item.description.title}") - MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + 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?) + isLoadingAndroidAutoItems = false + Log.d(tag, "Starting personalization fetch") + mediaManager.populatePersonalizedDataForAllLibraries {} } - result.sendResult(children as MutableList?) } + } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) { + while (!this::browseTree.isInitialized) {} + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library Log.d(tag, "Loading items for library $parentMediaId") val selectedLibrary = mediaManager.getLibrary(parentMediaId) if (selectedLibrary?.mediaType == "podcast") { // Podcasts are browseable - mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems -> - val children = libraryItems.map { libraryItem -> + mediaManager.loadLibraryPodcasts(parentMediaId) { libraryItems -> + val children = libraryItems?.map { libraryItem -> val mediaDescription = libraryItem.getMediaDescription(null, ctx) MediaBrowserCompat.MediaItem( mediaDescription, @@ -1116,7 +1139,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { val children = mutableListOf( MediaBrowserCompat.MediaItem( MediaDescriptionCompat.Builder() - .setTitle("Library") + .setTitle("Authors") .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") .build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE @@ -1136,8 +1159,149 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) ) + if (mediaManager.getHasDiscovery(parentMediaId)) { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Discovery") + .setMediaId("__LIBRARY__${parentMediaId}__DISCOVERY") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } result.sendResult(children as MutableList?) } + } else if (parentMediaId.startsWith(RECENTLY_ROOT)) { + Log.d(tag, "Browsing recently $parentMediaId") + val mediaIdParts = parentMediaId.split("__") + if (!mediaManager.getIsLibrary(mediaIdParts[2])) { + Log.d(tag, "${mediaIdParts[2]} is not library") + result.sendResult(null) + return + } + Log.d(tag, "Mediaparts: ${mediaIdParts.size} | $mediaIdParts") + if(mediaIdParts.size == 3) { + mediaManager.getLibraryRecentShelfs(mediaIdParts[2]) { availableShelfs -> + Log.d(tag, "Found ${availableShelfs.size} shelfs") + val children : MutableList = mutableListOf() + for (shelf in availableShelfs) { + if (shelf.type == "book") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Books") + .setMediaId("${parentMediaId}__BOOK") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "series") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("${parentMediaId}__SERIES") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "episode") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Episodes") + .setMediaId("${parentMediaId}__EPISODE") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "podcast") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Podcast") + .setMediaId("${parentMediaId}__PODCAST") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "authors") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Authors") + .setMediaId("${parentMediaId}__AUTHORS") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts.size == 4) { + mediaManager.getLibraryRecentShelfByType(mediaIdParts[2], mediaIdParts[3]) { shelf -> + if (shelf === null) { + result.sendResult(mutableListOf()) + }else { + if (shelf.type == "book") { + val children = (shelf as LibraryShelfBookEntity).entities?.map { libraryItem -> + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, false) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + }else if (shelf.type == "episode") { + val episodesWithRecentEpisode = (shelf as LibraryShelfEpisodeEntity).entities?.filter { libraryItem -> libraryItem.recentEpisode !== null } + val children = episodesWithRecentEpisode?.map { libraryItem -> + val podcast = libraryItem.media as Podcast + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.libraryId && it.episodeId == libraryItem.recentEpisode?.id } + + // to show download icon + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.recentEpisode!!.id) + localLibraryItem?.let { lli -> + val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == libraryItem.recentEpisode.id } + libraryItem.recentEpisode.localEpisodeId = localEpisode?.id + } + + val description = libraryItem.recentEpisode.getMediaDescription(libraryItem, progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + }else if (shelf.type == "podcast") { + val children = (shelf as LibraryShelfPodcastEntity).entities?.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + else if (shelf.type == "series") { + val children = (shelf as LibraryShelfSeriesEntity).entities?.map { librarySeriesItem -> + val description = librarySeriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + else if (shelf.type == "authors") { + val children = (shelf as LibraryShelfAuthorEntity).entities?.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + result.sendResult(children as MutableList?) + } + else { + result.sendResult(mutableListOf()) + } + + } + } + } } else if (parentMediaId.startsWith("__LIBRARY__")) { Log.d(tag, "Browsing library $parentMediaId") val mediaIdParts = parentMediaId.split("__") @@ -1145,7 +1309,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { MediaIdParts for Library 1: LIBRARY 2: mediaId for library - 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS) + 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS, DISCOVERY) 4: - Paging: SERIES_LIST, AUTHORS - SeriesId: SERIES @@ -1338,7 +1502,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } result.sendResult(children as MutableList?) } - } else { + } else if (mediaIdParts[3] == "DISCOVERY") { + Log.d(tag, "Loading discovery from library ${mediaIdParts[2]}") + mediaManager.loadLibraryDiscoveryBooksWithAudio(mediaIdParts[2]) { libraryItems -> + Log.d(tag, "Received ${libraryItems.size} libraryItems for discovery") + val children = libraryItems.map { libraryItem -> + val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + result.sendResult(children as MutableList?) + } + }else { result.sendResult(null) } } else { @@ -1351,54 +1528,34 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { override fun onSearch(query: String, extras: Bundle?, result: Result>) { result.detach() - Log.d(tag, "Search bundle: $extras") - var foundBooks: MutableList = mutableListOf() - var foundPodcasts: MutableList = mutableListOf() - var foundSeries: MutableList = mutableListOf() - var foundAuthors: MutableList = mutableListOf() + if (cachedSearch != query) { + Log.d(tag, "Search bundle: $extras") + var foundBooks: MutableList = mutableListOf() + var foundPodcasts: MutableList = mutableListOf() + var foundSeries: MutableList = mutableListOf() + var foundAuthors: MutableList = mutableListOf() - mediaManager.serverLibraries.forEach { serverLibrary -> - runBlocking{ - val searchResult = mediaManager.searchLocalCache(serverLibrary.id, query) - Log.d(tag, "onSearch: SearchResult: $searchResult") - if (searchResult === null) return@runBlocking - if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) { - Log.d(tag, "onSearch: found ${searchResult.book!!.size} books") - val children = searchResult.book!!.map { bookResult -> - val libraryItem = bookResult.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, null, "Books (${serverLibrary.name})") - MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + mediaManager.serverLibraries.forEach { serverLibrary -> + runBlocking { + // Skip searching library if it doesn't have any audio files + if (serverLibrary.stats?.numAudioTracks == 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) + } } - foundBooks.addAll(children) } - if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) { - Log.d(tag, "onSearch: found ${searchResult.series!!.size} series") - val children = searchResult.series!!.map { seriesResult -> - val seriesItem = seriesResult.series - seriesItem.books = seriesResult.books as MutableList - val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary.name})") - MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) - } - foundSeries.addAll(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) - } - foundAuthors.addAll(children) - } - Log.d(tag, "onSearch: Library ${serverLibrary.id} processed") } - Log.d(tag, "onSearch: Library ${serverLibrary.id} scanned") + foundBooks.addAll(foundSeries) + foundBooks.addAll(foundAuthors) + cachedSearchResults = foundBooks } - foundBooks.addAll(foundSeries) - foundBooks.addAll(foundAuthors) - result.sendResult(foundBooks as MutableList?) + result.sendResult(cachedSearchResults) + cachedSearch = query Log.d(tag, "onSearch: Done") } @@ -1462,6 +1619,9 @@ 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) { + // TODO: Trigger android auto loading if it is not loaded previously + } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 0ba7acf6..9c564ae3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -162,6 +162,23 @@ class ApiHandler(var ctx:Context) { } } + fun getLibraryPersonalized(libraryItemId:String, cb: (List?) -> Unit) { + getRequest("/api/libraries/$libraryItemId/personalized", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryStats Failed") + cb(null) + } else { + val items = mutableListOf() + val array = it.getJSONArray("value") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + cb(items) + } + } + } + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) { getRequest("/api/items/$libraryItemId?expanded=1", null, null) { if (it.has("error")) { diff --git a/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml new file mode 100644 index 00000000..6fa1e718 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-hdpi/md_clock_outline.png b/android/app/src/main/res/drawable-hdpi/md_clock_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..a0dc9c163faf7529e138332b567dcac3dccd8a05 GIT binary patch literal 798 zcmV+(1L6FMP)kcM^VINfyTRPQNa9UDs`=MxdD?fxz%e z@=@}p(a@9jtp)6+aerILjFB%BfTZ?e?MlGD*MLk_CUAc6U6iX#vdZlo9>#bwTXGLXJ^tFz~02S)*!GQxCJq=yeJKvHAU z^-b^Ihxa$qbCm;?1tbL=h8?Gzq$D}3tc-b{VH@WLIW+)e)$~J(y^^*iX2KQlO9nQj z7$!_TY5!@uKL>~;_E5pdS&11LA>pqj#oWNKIeQ;+YLMhPKtD?dGWpRA$UvR@kUPpxpK~pRzhTHO33`2&X5wM#=uSrD3)io3jQ=? z#t>DEEmkl0Qb2^utLv?(d`JO26CkdWM^BAdZ7HA_TdHgKw*i6LFT>N8i!kI6MF6W+ zCoF7xcUEVP#5_mHmQu_`>Z@7|a$=#)M1!>xp0gvk7!qwnF$P#oNp0SecQnzJorNvN zjZM_=&vv`J6UC&CVN?jMRAc9M+h@}dW;q?#$2yy0hpN-dY7O6 zAB(3KiCXKxyDaTBV0P;hM#2f8!=zx7gx^7fZcuHmb6E=cvhd2wOcM3xYFruln5QO6npo63WpaM(v#gdPS%(Y}ft)`%=h!v^7jc?gvL_Jkup*Kp0vQv{ zw~LkUE$u1}xt_m%EZw!;buFsg;MjFw@@a@6U+mCjxgXO>B;^I1foyEOAdXa%B( z$}&WNL!JL>qY4kY-yCJ2!#Dl0$VKlQWHWH*sBR@}L@*Bts6Fb)*8YSHtO|P(oQDLK z-Ylo-69|y?pO7Tr0D}@i_F*XiBbX@2gMfM!>Cj5lHz=VLdPJRRWTZ4tuDz0c0&8+o zhCZ1bSu;@@kL#cq?~x{(+{@J0ahQeucC+e{vZ5GQ9%NqoLJ1$5l_~kqQe?b-MB!yc zk8bQ8rA7s6Vqvt)We}8%>?tt|nfLcf!)`DWR~GuQpj1NCzudU?Cn_m3^cdGR#CVY4 z-t9D-`P76Xru4xfZ5$0PNRf6wL4D&>KAEI{E!4tPAX?B0?_@Xhy(khCqAwngPVf(- WRq+`b&M|BN0000-|b4j^y{&=I63mMICA|@o&fxB56q>xZTQ+P0Aj)I$_kq<>-~n35yqC%SUX@uYV6LxZVuWbiw9u*wfKujz zNL)KoQ4}6;L@>Xrh8lzT0hArY#zoCWwE&=uRlZActCRx4I9(u$00IGBe2pwBGDy4M zWZEotEuOf`c_IryHYPn{{d8k^emPejACyy?0s=7UKvo#oA%I{*EPo*z0%G=|GJhI_ zJ&L^3NLUU4+2$r$*hCvq<}j=k1&UNnJb?$k8->HnlyFdqiqDh z+u*D*O?G)e0T6R|VVQWsHDCmQU|N+H=Yn902Nc9wt|X9QxUnz-Ac8?nAf*HA?{f;< zXEOl63%^QwcmQY@(!xUPd)cDATCkf!gDf$EEuf>i-~q6Unu0eGJ_i6xqmeN^0AEB2 z3fM5Rg(ULKR0^pVS8^pDhcW7rJOD(0S`A5G&4p_w05%Rrf$RYwfC=PbGzajEG0+8o z$WMEp)0X`2#en?RWdv&o?rl~7Clpdp&BsP3rSAp6SsHg909(Xp-gZ&2W`cMv"r z9eJH&wESF0YJ2AkSxk49ZRQ3)LJVS{<{fd-+@ zCj8yiR+KhoGfOjh5T30s#m@VAW8?Nu$wc`kg%EAXTJkH2?)+Ket$gxAMomOsGZYxL0Md zE)$Bpk-2g#{>Q8GBWy3Mz$O3+0^v}~y1dHznU|V9$zII>1_grP?g4?~G<6?qC^Oju z=>|yX002ovPDHLkV1hR|+b{qC literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png b/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..c1c30bb6780f5ff1a3f079d520931e600bc48edb GIT binary patch literal 1562 zcmV+#2IcvQP)A)EOR7((KINbB zxn_G0^oe@MpMUA|cC}jV+w>5>dKLu{kO9JLGV#p_MBL(&lN-9|+gz}^0wOR_$y=&h zR!R*J?V7Lj&r5;@wRI_70I^~|bObYn0abnK0V);H5x_hGMUXBtrIZGU1Minkt`r$F z0{eMZj7kASaNd(qa47_1Pr)E=sqU#htWZ>k6${s|xuU=4R5x5beGhFAo8Wp)r3ui; zr2xn(001E0sD35m@q4Jy{jdUJCEPkMylHj0YCsI>Ih6_~Z5O#u2#{l@pcojD7HcAdL0hsBTMaEPA|CC% z{kj414E<7MjF~~aIU1Z{&;}&f_sXmA2q5fpt_1Q;D}{G9Bkc&%55?Vf1n9!kp9>J( zY)zfvhNU1Fnvs%G)c^hD?^iT{YtI0rHmrEq4BJcP)T1IVAZ^t`KyF&CdH~{hgn$i5 zrQ%xEOhATu+)~xl&Sh)oYz)_hfi|yP<348sBzgn6*fVJ|ML>M4C@qoA`<|&WGXc_qwRrr{0O7P= z%D1Gt7t23=qgYY|O@y=}i`hpFkQDiwvvkK$kg=MHW6}Ugw8JR_D_GCrVhl)%L!wt` zfTXlRYJi^E#Wr+5muJ+~BdL>H3+^_{emX@I09-gOF&K$_9TQUT%o zQc7<;%T9{T1xRq`hqF}GEFSXGlu zHM3ioZI9dGr75V&EJpHA-2B$2JJOFE(g0@Y-xLrF__Pltbw-vAHi4KKpH{bJQbLLFaRZ0iGQc3KU$#; z*R#r`7pqM5pdT+FEf%FdBTIw>o23`3+)ssFfE+3k4|`byj`YKEM|;84y%UcB(o_Wd zU6PFqw6?9X=Rud|$~C^P8xX5#!?KlZKPn_v4tCx+M@l7>Qx5@RNYWv8FbJs$tPbJ} z0kz7|u5-8*E_b`uaIY{RM?4y2obqan2PUspcnY&t+E;B!ssJ%0DdRY{@rHguqN|IV zMlQ9b`;_wM&8ltzq%EVIBAhgje4Vlc6i}d+>aTT1XK6M-1z1>_P_T55S1dpn z>VbYn{mp7Y)}VUHj>19vqX4Qg^jOy{fDG~rpaoEyb1n*66x61mf1bSTXJwyMmjD0& M07*qoM6N<$f@k%*@c;k- literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/md_clock_outline.xml b/android/app/src/main/res/drawable/md_clock_outline.xml new file mode 100644 index 00000000..6fa1e718 --- /dev/null +++ b/android/app/src/main/res/drawable/md_clock_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file