From f59e6521fc8297a6141f9ee98d83a8bdf7ff30c1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 19 Dec 2021 18:58:26 -0600 Subject: [PATCH] Add:Native progress sync for android auto streaming #41, Change:Android auto only show samples when no audiobooks --- .../audiobookshelf/app/AudiobookManager.kt | 43 +++++- .../app/AudiobookProgressSyncer.kt | 138 ++++++++++++++++++ .../audiobookshelf/app/AudiobookStreamData.kt | 6 +- .../java/com/audiobookshelf/app/BrowseTree.kt | 50 +++---- .../audiobookshelf/app/LocalMediaManager.kt | 124 ++++++++-------- .../com/audiobookshelf/app/MyNativeAudio.kt | 24 +-- .../app/PlayerNotificationService.kt | 100 ++++++++++--- components/app/AudioPlayer.vue | 4 +- components/app/AudioPlayerContainer.vue | 1 + components/cards/LazyBookCard.vue | 2 +- components/covers/BookCover.vue | 2 +- layouts/default.vue | 1 + 12 files changed, 371 insertions(+), 124 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/AudiobookProgressSyncer.kt diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt b/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt index 277c6fcf..c892c4b7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/AudiobookManager.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.os.Handler import android.os.Looper +import android.support.v4.media.MediaMetadataCompat import android.util.Log import com.getcapacitor.JSArray @@ -105,13 +106,13 @@ class AudiobookManager { } fun loadAudiobooks(cb: (() -> Unit)) { - Log.d(tag, "LOAD AUDIBOOOSK $serverUrl | $token") + Log.d(tag, "Load Audiobooks: $serverUrl | $token") if (serverUrl == "" || token == "") { - Log.d(tag, "No Server or Token set") + Log.d(tag, "Load Audiobooks: No Server or Token set") cb() return } else if (!serverUrl.startsWith("http")) { - Log.e(tag, "Invalid server url $serverUrl") + Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl") cb() return } @@ -119,14 +120,14 @@ class AudiobookManager { // First load currently reading loadCategories() { // Then load all - var url = "$serverUrl/api/libraries/main/books/all" + var url = "$serverUrl/api/libraries/main/books/all?sort=book.title" val request = Request.Builder() .url(url).addHeader("Authorization", "Bearer $token") .build() client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - Log.d(tag, "FAILURE TO CONNECT") + Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT") e.printStackTrace() cb() } @@ -174,6 +175,7 @@ class AudiobookManager { localMediaManager.loadLocalAudio() + // Load downloads from sql db var db = CapacitorDataStorageSqlite(ctx) db.openStore("storage", "downloads", false, "no-encryption", 1) var keyvalues = db.keysvalues() @@ -214,13 +216,15 @@ class AudiobookManager { var bodyString = response.body!!.string() var stream = JSObject(bodyString) + var streamId = stream.getString("streamId", "").toString() var startTime = stream.getDouble("startTime") var streamUrl = stream.getString("streamUrl", "").toString() var startTimeLong = (startTime * 1000).toLong() var abStreamDataObj = JSObject() - abStreamDataObj.put("id", audiobook.id) + abStreamDataObj.put("id", streamId) + abStreamDataObj.put("audiobookId", audiobook.id) abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl") abStreamDataObj.put("title", audiobook.book.title) abStreamDataObj.put("author", audiobook.book.authorFL) @@ -247,7 +251,8 @@ class AudiobookManager { fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData { var abStreamDataObj = JSObject() - abStreamDataObj.put("id", audiobook.id) + abStreamDataObj.put("id", "download") + abStreamDataObj.put("audiobookId", audiobook.id) abStreamDataObj.put("contentUrl", audiobook.contentUrl) abStreamDataObj.put("title", audiobook.book.title) abStreamDataObj.put("author", audiobook.book.authorFL) @@ -265,7 +270,8 @@ class AudiobookManager { fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData { var abStreamDataObj = JSObject() - abStreamDataObj.put("id", local.id) + abStreamDataObj.put("id", "local") + abStreamDataObj.put("audiobookId", local.id) abStreamDataObj.put("contentUrl", local.uri.toString()) abStreamDataObj.put("title", local.name) abStreamDataObj.put("author", "") @@ -339,4 +345,25 @@ class AudiobookManager { if (localMediaManager.localAudioFiles.isEmpty()) return null return localMediaManager.localAudioFiles[0] } + + // Used for media browser loadChildren, fallback to using the samples if no audiobooks are there + fun getAudiobooksMediaMetadata() : List { + var mediaMetadata:MutableList = mutableListOf() + if (audiobooks.isEmpty()) { + localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) } + } else { + audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) } + } + return mediaMetadata + } + // Used for media browser loadChildren, fallback to using the samples if no audiobooks are there + fun getDownloadedAudiobooksMediaMetadata() : List { + var mediaMetadata:MutableList = mutableListOf() + if (audiobooks.isEmpty()) { + localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) } + } else { + audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } } + } + return mediaMetadata + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudiobookProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/AudiobookProgressSyncer.kt new file mode 100644 index 00000000..a7dcdd5c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/AudiobookProgressSyncer.kt @@ -0,0 +1,138 @@ +package com.audiobookshelf.app + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.getcapacitor.JSObject +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.* +import kotlin.concurrent.schedule + +/* + * Normal progress sync is handled in webview, but when using android auto webview may not be open. + * If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set. + */ +class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) { + private val tag = "AudiobookProgressSync" + private val playerNotificationService:PlayerNotificationService = playerNotificationService + private val client:OkHttpClient = client + + private var listeningTimerTask: TimerTask? = null + var listeningTimerRunning:Boolean = false + + private var webviewOpenOnStart:Boolean = false + private var listeningBookTitle:String? = "" + private var listeningBookIsLocal:Boolean = false + private var listeningBookId:String? = "" + private var listeningStreamId:String? = "" + + private var lastPlaybackTime:Long = 0 + private var lastUpdateTime:Long = 0 + + fun start() { + if (listeningTimerRunning) { + Log.d(tag, "start: Timer already running for $listeningBookTitle") + if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) { + Log.d(tag, "start: Changed audiobook stream - resetting timer") + listeningTimerTask?.cancel() + } + } + listeningTimerRunning = true + + webviewOpenOnStart = playerNotificationService.getIsWebviewOpen() + listeningBookTitle = playerNotificationService.getCurrentBookTitle() + listeningBookIsLocal = playerNotificationService.getCurrentBookIsLocal() + listeningBookId = playerNotificationService.getCurrentBookId() + listeningStreamId = playerNotificationService.getCurrentStreamId() + + lastPlaybackTime = playerNotificationService.getCurrentTime() + lastUpdateTime = System.currentTimeMillis() / 1000L + + listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) { + Handler(Looper.getMainLooper()).post() { + // Webview was closed while android auto is open - switch to native sync + if (!playerNotificationService.getIsWebviewOpen() && webviewOpenOnStart) { + Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking") + webviewOpenOnStart = false + lastUpdateTime = System.currentTimeMillis() / 1000L + } + if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) { + sync() + } + } + } + } + + fun stop() { + if (!listeningTimerRunning) return + Log.d(tag, "stop: Stopping listening for $listeningBookTitle") + + reset() + sync() + } + + fun reset() { + listeningTimerTask?.cancel() + listeningTimerTask = null + listeningTimerRunning = false + listeningBookTitle = "" + listeningBookId = "" + listeningBookIsLocal = false + listeningStreamId = "" + } + + fun sync() { + var currTime = System.currentTimeMillis() / 1000L + var elapsed = currTime - lastUpdateTime + lastUpdateTime = currTime + + if (!listeningBookIsLocal) { + Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId") + + // Send sync data only for streaming books + var syncData: JSObject = JSObject() + syncData.put("timeListened", elapsed) + syncData.put("currentTime", playerNotificationService.getCurrentTime()) + syncData.put("streamId", listeningStreamId) + syncData.put("audiobookId", listeningBookId) + sendStreamSyncData(syncData) { + Log.d(tag, "Stream sync done") + } + } else if (listeningStreamId == "download") { + // TODO: Save downloaded audiobook progress & send to server if connected + Log.d(tag, "ListeningTimer: Is listening download") + } + } + + fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) { + var serverUrl = playerNotificationService.getServerUrl() + var token = playerNotificationService.getUserToken() + + Log.d(tag, "Sync Stream $serverUrl | $token") + var url = "$serverUrl/api/syncStream" + + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload.toString().toRequestBody(mediaType) + val request = Request.Builder().post(requestBody) + .url(url).addHeader("Authorization", "Bearer $token") + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + cb() + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) throw IOException("Unexpected code $response") + cb() + } + } + }) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/AudiobookStreamData.kt b/android/app/src/main/java/com/audiobookshelf/app/AudiobookStreamData.kt index c1932ec2..56f9d57e 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/AudiobookStreamData.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/AudiobookStreamData.kt @@ -10,7 +10,8 @@ import com.google.android.exoplayer2.util.MimeTypes import java.lang.Exception class AudiobookStreamData { - var id:String = "audiobook" + var id:String = "unset" + var audiobookId:String = "" var token:String = "" var playlistUrl:String = "" var title:String = "No Title" @@ -33,7 +34,8 @@ class AudiobookStreamData { var contentUri:Uri = Uri.EMPTY // For Local only constructor(jsondata:JSObject) { - id = jsondata.getString("id", "audiobook").toString() + id = jsondata.getString("id", "unset").toString() + audiobookId = jsondata.getString("audiobookId", "").toString() title = jsondata.getString("title", "No Title").toString() token = jsondata.getString("token", "").toString() author = jsondata.getString("author", "Unknown").toString() diff --git a/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt index 94cf80ca..1bcece9c 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/BrowseTree.kt @@ -10,10 +10,9 @@ import androidx.annotation.AnyRes class BrowseTree( val context: Context, - val audiobooksInProgress: List, - val audiobooks: List, - val localAudio: List, - val recentMediaId: String? = null + audiobooksInProgress: List, + audiobookMetadata: List, + downloadedMetadata: List ) { private val mediaIdToChildren = mutableMapOf>() @@ -57,18 +56,18 @@ class BrowseTree( putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) }.build() - val localsMetadata = MediaMetadataCompat.Builder().apply { - putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT) - putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples") - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) - }.build() +// val localsMetadata = MediaMetadataCompat.Builder().apply { +// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT) +// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples") +// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) +// }.build() if (audiobooksInProgress.isNotEmpty()) { rootList += continueReadingMetadata } rootList += allMetadata rootList += downloadsMetadata - rootList += localsMetadata +// rootList += localsMetadata mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList audiobooksInProgress.forEach { audiobook -> @@ -77,23 +76,24 @@ class BrowseTree( mediaIdToChildren[CONTINUE_ROOT] = children } - audiobooks.forEach { audiobook -> - if (audiobook.isDownloaded) { - val downloadsChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf() - downloadsChildren += audiobook.toMediaMetadata() - mediaIdToChildren[DOWNLOADS_ROOT] = downloadsChildren - } - val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf() - allChildren += audiobook.toMediaMetadata() - mediaIdToChildren[ALL_ROOT] = allChildren + audiobookMetadata.forEach { + val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf() + allChildren += it + mediaIdToChildren[ALL_ROOT] = allChildren } - localAudio.forEach { local -> - val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf() - localChildren += local.toMediaMetadata() - mediaIdToChildren[LOCAL_ROOT] = localChildren + downloadedMetadata.forEach { + val allChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf() + allChildren += it + mediaIdToChildren[DOWNLOADS_ROOT] = allChildren } - Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}") + +// localAudio.forEach { local -> +// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf() +// localChildren += local.toMediaMetadata() +// mediaIdToChildren[LOCAL_ROOT] = localChildren +// } +// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}") } operator fun get(mediaId: String) = mediaIdToChildren[mediaId] @@ -103,4 +103,4 @@ const val AUTO_BROWSE_ROOT = "/" const val ALL_ROOT = "__ALL__" const val CONTINUE_ROOT = "__CONTINUE__" const val DOWNLOADS_ROOT = "__DOWNLOADS__" -const val LOCAL_ROOT = "__LOCAL__" +//const val LOCAL_ROOT = "__LOCAL__" diff --git a/android/app/src/main/java/com/audiobookshelf/app/LocalMediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/LocalMediaManager.kt index 9345b91f..5d234125 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/LocalMediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/LocalMediaManager.kt @@ -1,6 +1,7 @@ package com.audiobookshelf.app import android.Manifest +import android.content.ContentResolver import android.content.ContentUris import android.content.Context import android.content.pm.PackageManager @@ -12,7 +13,11 @@ import android.os.Build import android.provider.MediaStore import android.support.v4.media.MediaMetadataCompat import android.util.Log +import androidx.annotation.AnyRes import androidx.core.content.ContextCompat +import androidx.core.net.toFile +import androidx.core.net.toUri +import com.bumptech.glide.Glide import java.io.File import java.io.IOException @@ -32,6 +37,7 @@ class LocalMediaManager { val size: Int, val coverUri: Uri? ) { + fun toMediaMetadata(): MediaMetadataCompat { return MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) @@ -40,75 +46,75 @@ class LocalMediaManager { if (coverUri != null) { putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString()) - } else { - putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, "android.resource://com.audiobookshelf.app/" + R.drawable.icon) } }.build() } } val localAudioFiles = mutableListOf() - @Throws(IOException::class) - fun getFileFromAssets(context: Context, fileName: String): File = File(context.cacheDir, fileName) - .also { - if (!it.exists()) { - it.outputStream().use { cache -> - context.assets.open(fileName).use { inputStream -> - inputStream.copyTo(cache) - } - } - } - } + /** + * get uri to drawable or any other resource type if u wish + * @param context - context + * @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)) + } fun loadLocalAudio() { - Log.d(tag, "Media store looking for local audio files") localAudioFiles.clear() - localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, null) - localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000,null) + localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio)) + localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio)) - if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { - Log.e(tag, "Permission not granted to read from external storage") - return - } - - val collection = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaStore.Audio.Media.getContentUri( - MediaStore.VOLUME_EXTERNAL - ) - } else { - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - } - - val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE) - val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null) - - audioCursor?.use { cursor -> - // Cache column indices. - val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) - val nameColumn = - cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) - val durationColumn = - cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) - val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) - - while (cursor.moveToNext()) { - // Get values of columns for a given video. - val id = cursor.getLong(idColumn) - val name = cursor.getString(nameColumn) - val duration = cursor.getInt(durationColumn) - val size = cursor.getInt(sizeColumn) - - val contentUri: Uri = ContentUris.withAppendedId( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - id - ) - Log.d(tag, "Found local audio file $name") - localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null) - } - } - - Log.d(tag, "${localAudioFiles.size} Local Audio Files found") + // TODO: No longer reading in local audio files - just use samples +// if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { +// Log.e(tag, "Permission not granted to read from external storage") +// return +// } +// +// val collection = +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { +// MediaStore.Audio.Media.getContentUri( +// MediaStore.VOLUME_EXTERNAL +// ) +// } else { +// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI +// } +// +// val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE) +// val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null) +// +// audioCursor?.use { cursor -> +// // Cache column indices. +// val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) +// val nameColumn = +// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) +// val durationColumn = +// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) +// val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) +// +// while (cursor.moveToNext()) { +// // Get values of columns for a given video. +// val id = cursor.getLong(idColumn) +// val name = cursor.getString(nameColumn) +// val duration = cursor.getInt(durationColumn) +// val size = cursor.getInt(sizeColumn) +// +// val contentUri: Uri = ContentUris.withAppendedId( +// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, +// id +// ) +// Log.d(tag, "Found local audio file $name") +// localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null) +// } +// } +// +// Log.d(tag, "${localAudioFiles.size} Local Audio Files found") } } 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 8fdc01ea..58462bf0 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MyNativeAudio.kt @@ -5,6 +5,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.core.content.ContextCompat +import com.capacitorjs.plugins.app.AppPlugin import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin import org.json.JSONObject @@ -22,32 +23,37 @@ class MyNativeAudio : Plugin() { var foregroundServiceReady : () -> Unit = { playerNotificationService = mainActivity.foregroundService - playerNotificationService.setCustomObjectListener(object: PlayerNotificationService.MyCustomObjectListener { - override fun onPlayingUpdate(isPlaying:Boolean) { + playerNotificationService.setBridge(bridge) + + playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener { + override fun onPlayingUpdate(isPlaying: Boolean) { emit("onPlayingUpdate", isPlaying) } - override fun onMetadata(metadata:JSObject) { + + override fun onMetadata(metadata: JSObject) { notifyListeners("onMetadata", metadata) } - override fun onPrepare(audiobookId:String, playWhenReady:Boolean) { + + override fun onPrepare(audiobookId: String, playWhenReady: Boolean) { var jsobj = JSObject() jsobj.put("audiobookId", audiobookId) jsobj.put("playWhenReady", playWhenReady) notifyListeners("onPrepareMedia", jsobj) } - override fun onSleepTimerEnded(currentPosition:Long) { + + override fun onSleepTimerEnded(currentPosition: Long) { emit("onSleepTimerEnded", currentPosition) } - override fun onSleepTimerSet(sleepTimerEndTime:Long) { + + override fun onSleepTimerSet(sleepTimerEndTime: Long) { emit("onSleepTimerSet", sleepTimerEndTime) } }) } mainActivity.pluginCallback = foregroundServiceReady - } - fun emit(evtName: String, value:Any) { + fun emit(evtName: String, value: Any) { var ret:JSObject = JSObject() ret.put("value", value) notifyListeners(evtName, ret) @@ -225,7 +231,7 @@ class MyNativeAudio : Plugin() { } @PluginMethod - fun requestSession(call:PluginCall) { + fun requestSession(call: PluginCall) { Log.d(tag, "CAST REQUEST SESSION PLUGIN") playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() { 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 62e4461a..7d7339ea 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/PlayerNotificationService.kt @@ -11,6 +11,7 @@ import android.net.Uri import android.os.* import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaControllerCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat @@ -20,6 +21,7 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.media.MediaBrowserServiceCompat import androidx.media.utils.MediaConstants +import com.getcapacitor.Bridge import com.getcapacitor.JSObject import com.google.android.exoplayer2.* import com.google.android.exoplayer2.audio.AudioAttributes @@ -31,7 +33,6 @@ 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 com.google.android.exoplayer2.util.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.* import kotlinx.coroutines.* @@ -39,7 +40,6 @@ import okhttp3.OkHttpClient import java.util.* import kotlin.concurrent.schedule - const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m class PlayerNotificationService : MediaBrowserServiceCompat() { @@ -66,6 +66,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var mediaSession: MediaSessionCompat private lateinit var transportControls:MediaControllerCompat.TransportControls + private lateinit var audiobookManager:AudiobookManager lateinit var mPlayer: SimpleExoPlayer lateinit var currentPlayer:Player @@ -73,6 +74,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { lateinit var sleepTimerManager:SleepTimerManager lateinit var castManager:CastManager + lateinit var audiobookProgressSyncer:AudiobookProgressSyncer private var notificationId = 10; private var channelId = "audiobookshelf_channel" @@ -87,6 +89,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var lastPauseTime: Long = 0 //ms private var onSeekBack: Boolean = false + var isAndroidAuto = false + var webviewBridge:Bridge? = null + // The following are used for the shake detection private var isShakeSensorRegistered:Boolean = false private var mSensorManager: SensorManager? = null @@ -94,11 +99,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private var mShakeDetector: ShakeDetector? = null private var shakeSensorUnregisterTask:TimerTask? = null - private lateinit var audiobookManager:AudiobookManager - fun setCustomObjectListener(mylistener: MyCustomObjectListener) { listener = mylistener } + fun setBridge(bridge: Bridge) { + webviewBridge = bridge + } + fun getIsWebviewOpen():Boolean { + return webviewBridge?.app?.isActive == true + } /* Service related stuff @@ -224,6 +233,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { playerNotificationManager.setPlayer(null) mPlayer.release() mediaSession.release() + audiobookProgressSyncer.reset() Log.d(tag, "onDestroy") isStarted = false @@ -262,18 +272,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { currentPlayer = mPlayer + var client: OkHttpClient = OkHttpClient() + // Initialize sleep timer sleepTimerManager = SleepTimerManager(this) // Initialize Cast Manager castManager = CastManager(this) + // Initialize Audiobook Progress Syncer (Only used for android auto when webview is not open) + audiobookProgressSyncer = AudiobookProgressSyncer(this, client) + // Initialize shake sensor Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}") initSensor() // Initialize audiobook manager - var client: OkHttpClient = OkHttpClient() audiobookManager = AudiobookManager(ctx, client) audiobookManager.init() @@ -438,7 +452,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } override fun onPause() { - Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") + Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT") pause() } @@ -626,6 +640,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } } } else lastPauseTime = System.currentTimeMillis() + + // If app is only running in android auto then webview will not be open + // so progress needs to be synced natively + Log.d(tag, "Playing ${getCurrentBookTitle()} | ${currentPlayer.mediaMetadata.title} | ${currentPlayer.mediaMetadata.displayTitle}") + if (player.isPlaying) { + audiobookProgressSyncer.start() + } + if (!player.isPlaying && audiobookProgressSyncer.listeningTimerRunning) { + audiobookProgressSyncer.stop() + } + listener?.onPlayingUpdate(player.isPlaying) } } @@ -714,6 +739,30 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { return currentPlayer.duration } + fun getCurrentBookTitle() : String? { + return currentAudiobookStreamData?.title + } + + fun getCurrentBookIsLocal() : Boolean { + return currentAudiobookStreamData?.isLocal == true + } + + fun getCurrentBookId() : String? { + return currentAudiobookStreamData?.audiobookId + } + + fun getCurrentStreamId() : String? { + return currentAudiobookStreamData?.id + } + + fun getServerUrl(): String { + return audiobookManager.serverUrl + } + + fun getUserToken() : String { + return audiobookManager.token + } + fun calcPauseSeekBackTime() : Long { if (lastPauseTime <= 0) return 0 var time: Long = System.currentTimeMillis() - lastPauseTime @@ -789,24 +838,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // // MEDIA BROWSER STUFF (ANDROID AUTO) // + private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead" + private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator" + private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" + private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox" + private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant" + private val VALID_MEDIA_BROWSERS = mutableListOf(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME) + private val AUTO_MEDIA_ROOT = "/" private val ALL_ROOT = "__ALL__" private lateinit var browseTree:BrowseTree + // Only allowing android auto or similar to access media browser service + // normal loading of audiobooks is handled in webview (not natively) private fun isValid(packageName: String, uid: Int) : Boolean { - Log.d(tag, "Check package $packageName is valid with uid $uid") + Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid") + if (!VALID_MEDIA_BROWSERS.contains(packageName)) { + Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service") + return false + } return true } override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { - // Verify that the specified package is allowed to access your - // content! You'll need to write your own logic to do this. + // Verify that the specified package is allowed to access your content return if (!isValid(clientPackageName, clientUid)) { - // If the request comes from an untrusted package, return null. // No further calls will be made to other media browsing methods. null } else { + // Flag is used to enable syncing progress natively (normally syncing is handled in webview) + isAndroidAuto = true + val extras = Bundle() extras.putBoolean( MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) @@ -833,14 +896,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { audiobookManager.isLoading = false Log.d(tag, "LOADED AUDIOBOOKS") - browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobookManager.audiobooks, audiobookManager.localMediaManager.localAudioFiles, null) + + var audiobooks:List = audiobookManager.getAudiobooksMediaMetadata() + var downloadedBooks:List = audiobookManager.getDownloadedAudiobooksMediaMetadata() + + browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks) val children = browseTree[parentMediaId]?.map { item -> - Log.d(tag, "Loaded Audiobook description ${item.description.title} - ${item.description.subtitle}") MediaBrowserCompat.MediaItem(item.description, flag) } - if (children != null) { - Log.d(tag, "BROWSE TREE CHILDREN ${children.size}") - } result.sendResult(children as MutableList?) } return @@ -858,7 +921,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } result.sendResult(children as MutableList?) - // Check if this is the root menu: + // TODO: For using sub menus. Check if this is the root menu: if (AUTO_MEDIA_ROOT == parentMediaId) { // build the MediaItem objects for the top level, // and put them in the mediaItems list @@ -878,7 +941,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { audiobookManager.isLoading = false Log.d(tag, "LOADED AUDIOBOOKS") - browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobookManager.audiobooks, audiobookManager.localMediaManager.localAudioFiles, null) + var audiobooks:List = audiobookManager.getAudiobooksMediaMetadata() + var downloadedBooks:List = audiobookManager.getDownloadedAudiobooksMediaMetadata() + + browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks) val children = browseTree[ALL_ROOT]?.map { item -> MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) } diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 237e10d6..76d5a003 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -450,8 +450,8 @@ export default { this.playedTrackWidth = ptWidth if (this.useChapterTrack) { - this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px' - this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px' + if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px' + if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px' } }, seek(time) { diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 1bdd2981..fd2f3094 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -349,6 +349,7 @@ export default { }) var audiobookStreamData = { + id: 'download', title: this.title, author: this.author, playWhenReady: !!playOnLoad, diff --git a/components/cards/LazyBookCard.vue b/components/cards/LazyBookCard.vue index 7d35616c..72ccc822 100644 --- a/components/cards/LazyBookCard.vue +++ b/components/cards/LazyBookCard.vue @@ -10,7 +10,7 @@

{{ title }}

- +
diff --git a/components/covers/BookCover.vue b/components/covers/BookCover.vue index c6063e2c..6c806c7d 100644 --- a/components/covers/BookCover.vue +++ b/components/covers/BookCover.vue @@ -5,7 +5,7 @@
- +

{{ title }}

diff --git a/layouts/default.vue b/layouts/default.vue index 41a73eb5..8ac7c036 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -16,6 +16,7 @@ import { Capacitor } from '@capacitor/core' import { AppUpdate } from '@robingenz/capacitor-app-update' import AudioDownloader from '@/plugins/audio-downloader' import StorageManager from '@/plugins/storage-manager' +import MyNativeAudio from '@/plugins/my-native-audio' export default { data() {