diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/User.kt b/android/app/src/main/java/com/audiobookshelf/app/models/User.kt index 6d610bf..4ca808b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/User.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/User.kt @@ -4,8 +4,16 @@ import com.audiobookshelf.app.data.MediaProgress import com.fasterxml.jackson.annotation.JsonIgnoreProperties @JsonIgnoreProperties(ignoreUnknown = true) -data class User( - val id:String, - val username: String, - val mediaProgress:List +data class UserBookmark( + val libraryItemId: String, + val title: String? = null, + val time: Double +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class User( + val id: String, + val username: String, + val mediaProgress: List = emptyList(), + val bookmarks: List = emptyList() ) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt index 0039438..315ad2a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -219,8 +219,7 @@ override fun onSeekTo(pos: Long) { ) playerNotificationService - .clientEventEmitter - ?.onBookmarkDeleteRequested( + .requestAndroidAutoBookmarkDelete( libraryItemId, bookmarkTime ) 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 76d2e47..9b50389 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 @@ -612,16 +612,24 @@ mediaSessionConnector.setMediaMetadataProvider { _ -> currentPlaybackSession = playbackSession - // The "Capitoli" browser entry depends on the active playback session. - // Notify Android Auto so it reloads the application root. + // The Android Auto entries depend on the active playback session. + // Invalidate both the application root and the already-cached subtrees. notifyChildrenChanged(AUTO_MEDIA_ROOT) + notifyChildrenChanged(ANDROID_AUTO_CHAPTERS_ROOT) + notifyChildrenChanged(ANDROID_AUTO_BOOKMARKS_ROOT) -lastAndroidAutoChapterStartMs = null + playbackSession.libraryItemId?.let { libraryItemId -> + refreshAndroidAutoBookmarksFromServer( + libraryItemId + ) + } -if (isAndroidAutoPlayback()) { - startAndroidAutoMediaSessionUpdates() - updateAndroidAutoMediaSessionState() -} + lastAndroidAutoChapterStartMs = null + + if (isAndroidAutoPlayback()) { + startAndroidAutoMediaSessionUpdates() + updateAndroidAutoMediaSessionState() + } DeviceManager.setLastPlaybackSession( playbackSession @@ -1159,6 +1167,105 @@ val actions = * Returns the browsable Android Auto entry used to open the current * audiobook's chapter list. */ + private fun refreshAndroidAutoBookmarksFromServer( + libraryItemId: String + ) { + apiHandler.getCurrentUser { user -> + val bookmarks = + user + ?.bookmarks + ?.filter { + it.libraryItemId == libraryItemId + } + ?.map { + AndroidAutoBookmark( + title = + it.title + ?.takeIf { title -> + title.isNotBlank() + } + ?: "Segnalibro", + timeSeconds = + it.time + .toLong() + .coerceAtLeast(0L) + ) + } + ?: emptyList() + + Handler(Looper.getMainLooper()).post { + if ( + currentPlaybackSession?.libraryItemId == + libraryItemId + ) { + setAndroidAutoBookmarks(bookmarks) + + Log.d( + tag, + "Android Auto native bookmarks loaded: " + + "libraryItemId=$libraryItemId, " + + "count=${bookmarks.size}" + ) + } + } + } + } + + fun requestAndroidAutoBookmarkDelete( + libraryItemId: String, + timeSeconds: Long + ) { + val emitter = clientEventEmitter + + if (emitter != null) { + emitter.onBookmarkDeleteRequested( + libraryItemId, + timeSeconds + ) + return + } + + Log.d( + tag, + "Android Auto native bookmark delete: " + + "libraryItemId=$libraryItemId, " + + "time=$timeSeconds" + ) + + apiHandler.deleteBookmark( + libraryItemId, + timeSeconds + ) { success -> + Handler(Looper.getMainLooper()).post { + if (!success) { + Log.e( + tag, + "Android Auto native bookmark delete failed: " + + "libraryItemId=$libraryItemId, " + + "time=$timeSeconds" + ) + return@post + } + + androidAutoBookmarks = + androidAutoBookmarks.filterNot { + it.timeSeconds == timeSeconds + } + + notifyChildrenChanged(AUTO_MEDIA_ROOT) + notifyChildrenChanged( + ANDROID_AUTO_BOOKMARKS_ROOT + ) + + Log.d( + tag, + "Android Auto native bookmark deleted: " + + "remaining=${androidAutoBookmarks.size}" + ) + } + } + } + fun setAndroidAutoBookmarks( bookmarks: List ) { @@ -1747,10 +1854,56 @@ fun skipToNextChapter() { "libraryItemId=$libraryItemId, time=$timeSeconds" ) - clientEventEmitter?.onBookmarkRequested( - libraryItemId, - timeSeconds + val emitter = clientEventEmitter + + if (emitter != null) { + emitter.onBookmarkRequested( + libraryItemId, + timeSeconds + ) + return + } + + val title = "Android Auto" + + Log.d( + tag, + "Android Auto native bookmark create: " + + "libraryItemId=$libraryItemId, " + + "time=$timeSeconds" ) + + apiHandler.createBookmark( + libraryItemId, + title, + timeSeconds + ) { success -> + Handler(Looper.getMainLooper()).post { + if (!success) { + Log.e( + tag, + "Android Auto native bookmark create failed: " + + "libraryItemId=$libraryItemId, " + + "time=$timeSeconds" + ) + return@post + } + + setAndroidAutoBookmarks( + androidAutoBookmarks + + AndroidAutoBookmark( + title = title, + timeSeconds = timeSeconds + ) + ) + + Log.d( + tag, + "Android Auto native bookmark created: " + + "count=${androidAutoBookmarks.size}" + ) + } + } } fun setPlaybackSpeed(speed: Float) { @@ -2029,7 +2182,7 @@ lastAndroidAutoChapterStartMs = null "time=$timeSeconds" ) - clientEventEmitter?.onBookmarkDeleteRequested( + requestAndroidAutoBookmarkDelete( libraryItemId, timeSeconds ) 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 2ad61a7..f17b364 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 @@ -106,6 +106,37 @@ class ApiHandler(var ctx:Context) { } } + + private fun deleteRequest( + endpoint: String, + cb: (JSObject) -> Unit + ) { + val requestUrl = "${DeviceManager.serverAddress}$endpoint" + + try { + val request = + Request.Builder() + .delete() + .url(requestUrl) + .addHeader( + "Authorization", + "Bearer ${DeviceManager.token}" + ) + .build() + + makeRequest(request, null, cb) + } catch (error: Exception) { + Log.e(tag, "deleteRequest failed for $requestUrl", error) + + val result = JSObject() + result.put( + "error", + "Request failed: ${error.message}" + ) + cb(result) + } + } + private fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) { val client = httpClient ?: defaultClient @@ -135,8 +166,9 @@ class ApiHandler(var ctx:Context) { return } - val bodyString = it.body!!.string() - if (bodyString == "OK") { + val bodyString = it.body?.string().orEmpty() + + if (bodyString.isBlank() || bodyString == "OK") { cb(JSObject()) } else { try { @@ -390,6 +422,38 @@ class ApiHandler(var ctx:Context) { } } + + fun createBookmark( + libraryItemId: String, + title: String, + timeSeconds: Long, + cb: (Boolean) -> Unit + ) { + val payload = JSObject() + payload.put("title", title) + payload.put("time", timeSeconds) + + postRequest( + "/api/me/item/$libraryItemId/bookmark", + payload, + null + ) { result -> + cb(!result.has("error")) + } + } + + fun deleteBookmark( + libraryItemId: String, + timeSeconds: Long, + cb: (Boolean) -> Unit + ) { + deleteRequest( + "/api/me/item/$libraryItemId/bookmark/$timeSeconds" + ) { result -> + cb(!result.has("error")) + } + } + fun getCurrentUser(cb: (User?) -> Unit) { getRequest("/api/me", null, null) { if (it.has("error")) { diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index d886dfb..aed3170 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -823,6 +823,14 @@ closeAndroidAutoDebug() { this.showAndroidAutoDebug = false }, async onBookmarkDeleteRequested(payload) { + console.log( + '[AA BOOKMARK DELETE REQUEST]', + JSON.stringify({ + payload, + currentServerLibraryItemId: this.serverLibraryItemId + }) + ) + const libraryItemId = payload?.libraryItemId || this.serverLibraryItemId @@ -842,6 +850,16 @@ closeAndroidAutoDebug() { `/api/me/item/${libraryItemId}/bookmark/${time}` ) + console.log( + '[AA BOOKMARK DELETE API OK]', + JSON.stringify({ + libraryItemId, + time, + bookmarksBefore: + this.$store.state.user?.user?.bookmarks || [] + }) + ) + this.$store.commit( 'user/deleteBookmark', { @@ -850,6 +868,16 @@ closeAndroidAutoDebug() { } ) + console.log( + '[AA BOOKMARK DELETE STORE OK]', + JSON.stringify({ + libraryItemId, + time, + bookmarksAfter: + this.$store.state.user?.user?.bookmarks || [] + }) + ) + this.$toast.success('Bookmark removed') } catch (error) { this.$toast.error( @@ -904,6 +932,11 @@ closeAndroidAutoDebug() { bookmark ) + this.$store.commit('user/updateBookmark', { + ...bookmark, + libraryItemId + }) + this.$toast.success('Bookmark added') } catch (error) { this.$toast.error( diff --git a/components/modals/BookmarksModal.vue b/components/modals/BookmarksModal.vue index edd9335..3665ee3 100644 --- a/components/modals/BookmarksModal.vue +++ b/components/modals/BookmarksModal.vue @@ -146,7 +146,16 @@ export default { } this.$nativeHttp .post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark) - .then(() => { + .then((createdBookmark) => { + this.$store.commit( + 'user/updateBookmark', + createdBookmark?.libraryItemId + ? createdBookmark + : { + ...bookmark, + libraryItemId: this.libraryItemId + } + ) this.$toast.success('Bookmark added') }) .catch((error) => { diff --git a/store/user.js b/store/user.js index e476514..22b3498 100644 --- a/store/user.js +++ b/store/user.js @@ -257,19 +257,33 @@ export const mutations = { state.settings = settings }, updateBookmark(state, bookmark) { - if (!state.user?.bookmarks) return - state.user.bookmarks = state.user.bookmarks.map((bm) => { - if (bm.libraryItemId === bookmark.libraryItemId && bm.time === bookmark.time) { - return bookmark - } - return bm - }) + if (!state.user) return + + const bookmarks = state.user.bookmarks || [] + const bookmarkIndex = bookmarks.findIndex( + (bm) => + bm.libraryItemId === bookmark.libraryItemId && + Number(bm.time) === Number(bookmark.time) + ) + + if (bookmarkIndex === -1) { + state.user.bookmarks = [...bookmarks, bookmark] + return + } + + state.user.bookmarks = bookmarks.map((bm, index) => + index === bookmarkIndex ? bookmark : bm + ) }, deleteBookmark(state, { libraryItemId, time }) { if (!state.user?.bookmarks) return - state.user.bookmarks = state.user.bookmarks.filter((bm) => { - if (bm.libraryItemId === libraryItemId && bm.time === time) return false - return true - }) + + state.user.bookmarks = state.user.bookmarks.filter( + (bm) => + !( + bm.libraryItemId === libraryItemId && + Number(bm.time) === Number(time) + ) + ) } }