From 461733854a87fc91984992dd453c1eb16e2357dc Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 24 Mar 2022 19:28:26 -0500 Subject: [PATCH] New data model in android: Adding jackson for JSON deserialization, adding data classes, setting up API caller --- android/app/build.gradle | 3 + .../com/audiobookshelf/app/MyNativeAudio.kt | 35 +++ .../app/PlayerNotificationService.kt | 31 ++- .../audiobookshelf/app/data/DataClasses.kt | 211 ++++++++++++++++++ .../audiobookshelf/app/server/ApiHandler.kt | 124 ++++++++++ components/app/AudioPlayerContainer.vue | 6 + pages/account.vue | 12 + 7 files changed, 416 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index c4fd591e..1531b342 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -85,6 +85,9 @@ dependencies { // OK HTTP implementation 'com.squareup.okhttp3:okhttp:4.9.2' + + // Jackson for JSON + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1' } apply from: 'capacitor.build.gradle' diff --git a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt b/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt index 2da64dbd..8eb7fd2a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt @@ -5,7 +5,9 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat +import com.audiobookshelf.app.server.ApiHandler import com.capacitorjs.plugins.app.AppPlugin +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin import org.json.JSONObject @@ -15,10 +17,12 @@ class MyNativeAudio : Plugin() { private val tag = "MyNativeAudio" lateinit var mainActivity:MainActivity + lateinit var apiHandler:ApiHandler lateinit var playerNotificationService: PlayerNotificationService override fun load() { mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) var foregroundServiceReady : () -> Unit = { playerNotificationService = mainActivity.foregroundService @@ -59,6 +63,37 @@ class MyNativeAudio : Plugin() { notifyListeners(evtName, ret) } + @PluginMethod + fun prepareLibraryItem(call: PluginCall) { + var libraryItemId = call.getString("libraryItemId", "").toString() + var mediaEntityId = call.getString("mediaEntityId", "").toString() + + apiHandler.playLibraryItem(libraryItemId) { + + Handler(Looper.getMainLooper()).post() { + Log.d(tag, "Preparing Player TEST ${jacksonObjectMapper().writeValueAsString(it)}") + playerNotificationService.preparePlayer(it) + } + + call.resolve(JSObject(jacksonObjectMapper().writeValueAsString(it))) + } + } + + @PluginMethod + fun getLibraryItems(call: PluginCall) { + var libraryId = call.getString("libraryId", "").toString() + apiHandler.getLibraryItems(libraryId) { + val mapper = jacksonObjectMapper() + var jsobj = JSObject() + var libarray = JSArray() + it.map { + libarray.put(JSObject(mapper.writeValueAsString(it))) + } + jsobj.put("value", libarray) + call.resolve(jsobj) + } + } + @PluginMethod fun initPlayer(call: PluginCall) { if (!PlayerNotificationService.isStarted) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt index 4b83dc56..c4b590b0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt @@ -24,6 +24,7 @@ import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants import com.anggrayudi.storage.file.isExternalStorageDocument import com.audiobookshelf.app.data.DbManager +import com.audiobookshelf.app.data.PlaybackSession import com.getcapacitor.Bridge import com.getcapacitor.JSObject import com.google.android.exoplayer2.* @@ -85,6 +86,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var channelName = "Audiobookshelf Channel" private var currentAudiobookStreamData:AudiobookStreamData? = null + private var currentPlaybackSession:PlaybackSession? = null private var mediaButtonClickCount: Int = 0 var mediaButtonClickTimeout: Long = 1000 //ms @@ -376,11 +378,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) { override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat { var builder = MediaDescriptionCompat.Builder() - .setMediaId(currentAudiobookStreamData!!.id) - .setTitle(currentAudiobookStreamData!!.title) - .setSubtitle(currentAudiobookStreamData!!.author) - .setMediaUri(currentAudiobookStreamData!!.playlistUri) - .setIconUri(currentAudiobookStreamData!!.coverUri) + .setMediaId(currentPlaybackSession!!.id) + .setTitle(currentPlaybackSession!!.getTitle()) + .setSubtitle(currentPlaybackSession!!.getAuthor()) +// .setMediaUri(currentPlaybackSession!!.getContentUri()) +// .setIconUri(currentAudiobookStreamData!!.) return builder.build() } } @@ -600,7 +602,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { if (currentPlayer.playbackState == Player.STATE_READY) { Log.d(tag, "STATE_READY : " + mPlayer.duration.toString()) - currentAudiobookStreamData!!.hasPlayerLoaded = true +// currentAudiobookStreamData!!.hasPlayerLoaded = true if (lastPauseTime == 0L) { sendClientMetadata("ready_no_sync") lastPauseTime = -1; @@ -664,6 +666,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { /* User callable methods */ + fun preparePlayer(playbackSession: PlaybackSession) { + currentPlaybackSession = playbackSession + var metadata = playbackSession.getMediaMetadataCompat() + mediaSession.setMetadata(metadata) + var mediaMetadata = playbackSession.getMediaMetadata() + var mediaUrl = playbackSession.getContentUri() + var mimeType = playbackSession.getMimeType() + Log.d(tag, "Media URL $mediaUrl") + var mediaUri = Uri.parse(mediaUrl) + var mediaItem = MediaItem.Builder().setUri(mediaUri).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build() + var dataSourceFactory = DefaultDataSourceFactory(ctx, channelId) + var mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) + mPlayer.setMediaSource(mediaSource, 0L) + mPlayer.prepare() + mPlayer.playWhenReady = true + } + fun initPlayer(audiobookStreamData: AudiobookStreamData) { currentAudiobookStreamData = audiobookStreamData 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 new file mode 100644 index 00000000..db9c643b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -0,0 +1,211 @@ +package com.audiobookshelf.app.data + +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import com.fasterxml.jackson.annotation.* +import com.google.android.exoplayer2.MediaMetadata + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItem( + var id:String, + var ino:String, + var libraryId:String, + var folderId:String, + var path:String, + var relPath:String, + var mtimeMs:Long, + var ctimeMs:Long, + var birthtimeMs:Long, + var addedAt:Long, + var updatedAt:Long, + var lastScan:Long?, + var scanVersion:String?, + var isMissing:Boolean, + var isInvalid:Boolean, + var mediaType:String, + var media:MediaEntity, + var libraryFiles:MutableList +) + +// This auto-detects whether it is a Book or Podcast +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(Book::class), + JsonSubTypes.Type(Podcast::class) +) +open class MediaEntity {} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Podcast( + var metadata:PodcastMetadata, + var coverPath:String?, + var tags:MutableList, + var episodes:MutableList, + var autoDownloadEpisodes:Boolean +) : MediaEntity() + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Book( + var metadata:BookMetadata, + var coverPath:String?, + var tags:MutableList, + var audiobooks:MutableList +) : MediaEntity() + +// This auto-detects whether it is a Book or Podcast +@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes( + JsonSubTypes.Type(BookMetadata::class), + JsonSubTypes.Type(PodcastMetadata::class) +) +open class MediaEntityMetadata {} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class BookMetadata( + var title:String, + var subtitle:String?, + var authors:MutableList +) : MediaEntityMetadata() + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PodcastMetadata( + var title:String, + var author:String?, + var feedUrl:String, + var genres:MutableList +) : MediaEntityMetadata() + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Author( + var id:String, + var name:String, + var coverPath:String? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Audiobook( + var id:String, + var index:Int, + var name:String, + var audioFiles:MutableList +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class PodcastEpisode( + var id:String, + var index:Int, + var episode:String?, + var episodeType:String?, + var title:String?, + var subtitle:String?, + var description:String?, + var audioFile:AudioFile +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryFile( + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class FileMetadata( + var filename:String, + var ext:String, + var path:String, + var relPath:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioFile( + var index:Int, + var ino:String, + var metadata:FileMetadata +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Library( + var id:String, + var name:String, + var folders:MutableList, + var icon:String, + var mediaType:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class Folder( + var id:String, + var fullPath:String +) + +@JsonIgnoreProperties(ignoreUnknown = true) +class PlaybackSession( + var id:String, + var userId:String, + var libraryItemId:String, + var mediaEntityId:String, + var mediaType:String, + var mediaMetadata:MediaEntityMetadata, + var duration:Double, + var playMethod:Int, + var audioTracks:MutableList, + var currentTime:Double, + var serverUrl:String, + var token:String +) { + fun getTitle():String { + var metadata = mediaMetadata as BookMetadata + return metadata.title + } + fun getAuthor():String { + var metadata = mediaMetadata as BookMetadata + return metadata.authors.joinToString(",") { it.name } + } + fun getContentUri():String { + // TODO: Using Uri.parse here is throwing error with jackson + var audioTrack = audioTracks[0] + return "$serverUrl${audioTrack.contentUrl}?token=$token" + } + fun getMimeType():String { + var audioTrack = audioTracks[0] + return audioTrack.mimeType + } + fun getMediaMetadataCompat(): MediaMetadataCompat { + var metadata = mediaMetadata as BookMetadata + + var metadataBuilder = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata.title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, metadata.title) + .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, metadata.authors.joinToString(",") { it.name }) + .putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, metadata.authors.joinToString(",") { it.name }) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, metadata.authors.joinToString(",") { it.name }) + .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series") + .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) + return metadataBuilder.build() + } + fun getMediaMetadata(): MediaMetadata { + var metadata = mediaMetadata as BookMetadata + var authorName = metadata.authors.joinToString(",") { it.name } + var metadataBuilder = MediaMetadata.Builder() + .setTitle(metadata.title) + .setDisplayTitle(metadata.title) + .setArtist(authorName) + .setAlbumArtist(authorName) + .setSubtitle(authorName) + +// var contentUri = this.getContentUri() +// metadataBuilder.setMediaUri(contentUri) + + return metadataBuilder.build() + } +} + +@JsonIgnoreProperties(ignoreUnknown = true) +data class AudioTrack( + var index:Int, + var startOffset:Double, + var duration:Double, + var title:String, + var contentUrl:String, + var mimeType:String +) 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 new file mode 100644 index 00000000..94c2d8e2 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -0,0 +1,124 @@ +package com.audiobookshelf.app.server + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.audiobookshelf.app.data.Library +import com.audiobookshelf.app.data.LibraryItem +import com.audiobookshelf.app.data.PlaybackSession +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException + +class ApiHandler { + val tag = "ApiHandler" + private var client = OkHttpClient() + var ctx: Context + var serverUrl = "" + var token = "" + var storageSharedPreferences: SharedPreferences? = null + + constructor(_ctx: Context) { + ctx = _ctx + init() + } + + fun init() { + storageSharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) + serverUrl = storageSharedPreferences?.getString("serverUrl", "").toString() + Log.d(tag, "SHARED PREF SERVERURL $serverUrl") + token = storageSharedPreferences?.getString("token", "").toString() + Log.d(tag, "SHARED PREF TOKEN $token") + } + + fun getRequest(endpoint:String, cb: (JSObject) -> Unit) { + val request = Request.Builder() + .url("$serverUrl$endpoint").addHeader("Authorization", "Bearer $token") + .build() + makeRequest(request, cb) + } + + fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload.toString().toRequestBody(mediaType) + val request = Request.Builder().post(requestBody) + .url("$serverUrl$endpoint").addHeader("Authorization", "Bearer $token") + .build() + makeRequest(request, cb) + } + + fun makeRequest(request:Request, cb: (JSObject) -> Unit) { + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + cb(JSObject()) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) throw IOException("Unexpected code $response") + var bodyString = response.body!!.string() + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + var array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + cb(jsonObj) + } + } + }) + } + + fun getLibraries(cb: (List) -> Unit) { + val mapper = jacksonObjectMapper() + getRequest("/api/libraries") { + val libraries = mutableListOf() + if (it.has("value")) { + var array = it.getJSONArray("value")!! + for (i in 0 until array.length()) { + val library = mapper.readValue(array.get(i).toString()) + libraries.add(library) + } + } + cb(libraries) + } + } + + fun getLibraryItems(libraryId:String, cb: (List) -> Unit) { + val mapper = jacksonObjectMapper() + getRequest("/api/libraries/$libraryId/items") { + val items = mutableListOf() + if (it.has("results")) { + var array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = mapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun playLibraryItem(libraryItemId:String, cb: (PlaybackSession) -> Unit) { + val mapper = jacksonObjectMapper() + var payload = JSObject() + payload.put("mediaPlayer", "exo-player") + payload.put("forceDirectPlay", true) + + postRequest("/api/items/$libraryItemId/play", payload) { + it.put("serverUrl", serverUrl) + it.put("token", token) + val playbackSession = mapper.readValue(it.toString()) + cb(playbackSession) + } + } +} diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 4da0eb85..f4f0565c 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -479,6 +479,12 @@ export default { this.$store.commit('setLibraryItemStream', libraryItem) // TODO: Call load library item in native + console.log('TEST prepare library item', libraryItemId) + MyNativeAudio.prepareLibraryItem({ libraryItemId }).then((data) => { + console.log('TEST library item play response', JSON.stringify(data)) + }).catch((error) => { + console.error('TEST failed', error) + }) } }, mounted() { diff --git a/pages/account.vue b/pages/account.vue index 25e5d011..89e41382 100644 --- a/pages/account.vue +++ b/pages/account.vue @@ -26,12 +26,16 @@ Open app store + + Test Call +

UA: {{ updateAvailability }} | Avail: {{ availableVersion }} | Curr: {{ currentVersion }} | ImmedAllowed: {{ immediateUpdateAllowed }}