Add:Native progress sync for android auto streaming #41, Change:Android auto only show samples when no audiobooks

This commit is contained in:
advplyr 2021-12-19 18:58:26 -06:00
parent 1a7f90c93b
commit f59e6521fc
12 changed files with 371 additions and 124 deletions

View file

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
@ -105,13 +106,13 @@ class AudiobookManager {
} }
fun loadAudiobooks(cb: (() -> Unit)) { fun loadAudiobooks(cb: (() -> Unit)) {
Log.d(tag, "LOAD AUDIBOOOSK $serverUrl | $token") Log.d(tag, "Load Audiobooks: $serverUrl | $token")
if (serverUrl == "" || token == "") { if (serverUrl == "" || token == "") {
Log.d(tag, "No Server or Token set") Log.d(tag, "Load Audiobooks: No Server or Token set")
cb() cb()
return return
} else if (!serverUrl.startsWith("http")) { } else if (!serverUrl.startsWith("http")) {
Log.e(tag, "Invalid server url $serverUrl") Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl")
cb() cb()
return return
} }
@ -119,14 +120,14 @@ class AudiobookManager {
// First load currently reading // First load currently reading
loadCategories() { loadCategories() {
// Then load all // 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() val request = Request.Builder()
.url(url).addHeader("Authorization", "Bearer $token") .url(url).addHeader("Authorization", "Bearer $token")
.build() .build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
Log.d(tag, "FAILURE TO CONNECT") Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT")
e.printStackTrace() e.printStackTrace()
cb() cb()
} }
@ -174,6 +175,7 @@ class AudiobookManager {
localMediaManager.loadLocalAudio() localMediaManager.loadLocalAudio()
// Load downloads from sql db
var db = CapacitorDataStorageSqlite(ctx) var db = CapacitorDataStorageSqlite(ctx)
db.openStore("storage", "downloads", false, "no-encryption", 1) db.openStore("storage", "downloads", false, "no-encryption", 1)
var keyvalues = db.keysvalues() var keyvalues = db.keysvalues()
@ -214,13 +216,15 @@ class AudiobookManager {
var bodyString = response.body!!.string() var bodyString = response.body!!.string()
var stream = JSObject(bodyString) var stream = JSObject(bodyString)
var streamId = stream.getString("streamId", "").toString()
var startTime = stream.getDouble("startTime") var startTime = stream.getDouble("startTime")
var streamUrl = stream.getString("streamUrl", "").toString() var streamUrl = stream.getString("streamUrl", "").toString()
var startTimeLong = (startTime * 1000).toLong() var startTimeLong = (startTime * 1000).toLong()
var abStreamDataObj = JSObject() var abStreamDataObj = JSObject()
abStreamDataObj.put("id", audiobook.id) abStreamDataObj.put("id", streamId)
abStreamDataObj.put("audiobookId", audiobook.id)
abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl") abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl")
abStreamDataObj.put("title", audiobook.book.title) abStreamDataObj.put("title", audiobook.book.title)
abStreamDataObj.put("author", audiobook.book.authorFL) abStreamDataObj.put("author", audiobook.book.authorFL)
@ -247,7 +251,8 @@ class AudiobookManager {
fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData { fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData {
var abStreamDataObj = JSObject() var abStreamDataObj = JSObject()
abStreamDataObj.put("id", audiobook.id) abStreamDataObj.put("id", "download")
abStreamDataObj.put("audiobookId", audiobook.id)
abStreamDataObj.put("contentUrl", audiobook.contentUrl) abStreamDataObj.put("contentUrl", audiobook.contentUrl)
abStreamDataObj.put("title", audiobook.book.title) abStreamDataObj.put("title", audiobook.book.title)
abStreamDataObj.put("author", audiobook.book.authorFL) abStreamDataObj.put("author", audiobook.book.authorFL)
@ -265,7 +270,8 @@ class AudiobookManager {
fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData { fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData {
var abStreamDataObj = JSObject() 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("contentUrl", local.uri.toString())
abStreamDataObj.put("title", local.name) abStreamDataObj.put("title", local.name)
abStreamDataObj.put("author", "") abStreamDataObj.put("author", "")
@ -339,4 +345,25 @@ class AudiobookManager {
if (localMediaManager.localAudioFiles.isEmpty()) return null if (localMediaManager.localAudioFiles.isEmpty()) return null
return localMediaManager.localAudioFiles[0] return localMediaManager.localAudioFiles[0]
} }
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
fun getAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
var mediaMetadata:MutableList<MediaMetadataCompat> = 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<MediaMetadataCompat> {
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
if (audiobooks.isEmpty()) {
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
} else {
audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } }
}
return mediaMetadata
}
} }

View file

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

View file

@ -10,7 +10,8 @@ import com.google.android.exoplayer2.util.MimeTypes
import java.lang.Exception import java.lang.Exception
class AudiobookStreamData { class AudiobookStreamData {
var id:String = "audiobook" var id:String = "unset"
var audiobookId:String = ""
var token:String = "" var token:String = ""
var playlistUrl:String = "" var playlistUrl:String = ""
var title:String = "No Title" var title:String = "No Title"
@ -33,7 +34,8 @@ class AudiobookStreamData {
var contentUri:Uri = Uri.EMPTY // For Local only var contentUri:Uri = Uri.EMPTY // For Local only
constructor(jsondata:JSObject) { 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() title = jsondata.getString("title", "No Title").toString()
token = jsondata.getString("token", "").toString() token = jsondata.getString("token", "").toString()
author = jsondata.getString("author", "Unknown").toString() author = jsondata.getString("author", "Unknown").toString()

View file

@ -10,10 +10,9 @@ import androidx.annotation.AnyRes
class BrowseTree( class BrowseTree(
val context: Context, val context: Context,
val audiobooksInProgress: List<Audiobook>, audiobooksInProgress: List<Audiobook>,
val audiobooks: List<Audiobook>, audiobookMetadata: List<MediaMetadataCompat>,
val localAudio: List<LocalMediaManager.LocalAudio>, downloadedMetadata: List<MediaMetadataCompat>
val recentMediaId: String? = null
) { ) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>() private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
@ -57,18 +56,18 @@ class BrowseTree(
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
}.build() }.build()
val localsMetadata = MediaMetadataCompat.Builder().apply { // val localsMetadata = MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT) // putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT)
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples") // putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples")
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString()) // putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
}.build() // }.build()
if (audiobooksInProgress.isNotEmpty()) { if (audiobooksInProgress.isNotEmpty()) {
rootList += continueReadingMetadata rootList += continueReadingMetadata
} }
rootList += allMetadata rootList += allMetadata
rootList += downloadsMetadata rootList += downloadsMetadata
rootList += localsMetadata // rootList += localsMetadata
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
audiobooksInProgress.forEach { audiobook -> audiobooksInProgress.forEach { audiobook ->
@ -77,23 +76,24 @@ class BrowseTree(
mediaIdToChildren[CONTINUE_ROOT] = children mediaIdToChildren[CONTINUE_ROOT] = children
} }
audiobooks.forEach { audiobook -> audiobookMetadata.forEach {
if (audiobook.isDownloaded) {
val downloadsChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
downloadsChildren += audiobook.toMediaMetadata()
mediaIdToChildren[DOWNLOADS_ROOT] = downloadsChildren
}
val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf() val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
allChildren += audiobook.toMediaMetadata() allChildren += it
mediaIdToChildren[ALL_ROOT] = allChildren mediaIdToChildren[ALL_ROOT] = allChildren
} }
localAudio.forEach { local -> downloadedMetadata.forEach {
val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf() val allChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
localChildren += local.toMediaMetadata() allChildren += it
mediaIdToChildren[LOCAL_ROOT] = localChildren 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] operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
@ -103,4 +103,4 @@ const val AUTO_BROWSE_ROOT = "/"
const val ALL_ROOT = "__ALL__" const val ALL_ROOT = "__ALL__"
const val CONTINUE_ROOT = "__CONTINUE__" const val CONTINUE_ROOT = "__CONTINUE__"
const val DOWNLOADS_ROOT = "__DOWNLOADS__" const val DOWNLOADS_ROOT = "__DOWNLOADS__"
const val LOCAL_ROOT = "__LOCAL__" //const val LOCAL_ROOT = "__LOCAL__"

View file

@ -1,6 +1,7 @@
package com.audiobookshelf.app package com.audiobookshelf.app
import android.Manifest import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
@ -12,7 +13,11 @@ import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.util.Log import android.util.Log
import androidx.annotation.AnyRes
import androidx.core.content.ContextCompat 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.File
import java.io.IOException import java.io.IOException
@ -32,6 +37,7 @@ class LocalMediaManager {
val size: Int, val size: Int,
val coverUri: Uri? val coverUri: Uri?
) { ) {
fun toMediaMetadata(): MediaMetadataCompat { fun toMediaMetadata(): MediaMetadataCompat {
return MediaMetadataCompat.Builder().apply { return MediaMetadataCompat.Builder().apply {
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id) putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
@ -40,75 +46,75 @@ class LocalMediaManager {
if (coverUri != null) { if (coverUri != null) {
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString()) 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() }.build()
} }
} }
val localAudioFiles = mutableListOf<LocalAudio>() val localAudioFiles = mutableListOf<LocalAudio>()
@Throws(IOException::class) /**
fun getFileFromAssets(context: Context, fileName: String): File = File(context.cacheDir, fileName) * get uri to drawable or any other resource type if u wish
.also { * @param context - context
if (!it.exists()) { * @param drawableId - drawable res id
it.outputStream().use { cache -> * @return - uri
context.assets.open(fileName).use { inputStream -> */
inputStream.copyTo(cache) 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() { fun loadLocalAudio() {
Log.d(tag, "Media store looking for local audio files")
localAudioFiles.clear() 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/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,null) 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) { // TODO: No longer reading in local audio files - just use samples
Log.e(tag, "Permission not granted to read from external storage") // if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
return // Log.e(tag, "Permission not granted to read from external storage")
} // return
// }
val collection = //
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // val collection =
MediaStore.Audio.Media.getContentUri( // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.VOLUME_EXTERNAL // MediaStore.Audio.Media.getContentUri(
) // MediaStore.VOLUME_EXTERNAL
} else { // )
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI // } 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) // 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. // audioCursor?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) // // Cache column indices.
val nameColumn = // val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) // val nameColumn =
val durationColumn = // cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) // val durationColumn =
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE) // 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. // while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn) // // Get values of columns for a given video.
val name = cursor.getString(nameColumn) // val id = cursor.getLong(idColumn)
val duration = cursor.getInt(durationColumn) // val name = cursor.getString(nameColumn)
val size = cursor.getInt(sizeColumn) // val duration = cursor.getInt(durationColumn)
// val size = cursor.getInt(sizeColumn)
val contentUri: Uri = ContentUris.withAppendedId( //
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, // val contentUri: Uri = ContentUris.withAppendedId(
id // 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, "Found local audio file $name")
} // localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null)
} // }
// }
Log.d(tag, "${localAudioFiles.size} Local Audio Files found") //
// Log.d(tag, "${localAudioFiles.size} Local Audio Files found")
} }
} }

View file

@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.capacitorjs.plugins.app.AppPlugin
import com.getcapacitor.* import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.CapacitorPlugin
import org.json.JSONObject import org.json.JSONObject
@ -22,32 +23,37 @@ class MyNativeAudio : Plugin() {
var foregroundServiceReady : () -> Unit = { var foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService playerNotificationService = mainActivity.foregroundService
playerNotificationService.setCustomObjectListener(object: PlayerNotificationService.MyCustomObjectListener { playerNotificationService.setBridge(bridge)
override fun onPlayingUpdate(isPlaying:Boolean) {
playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener {
override fun onPlayingUpdate(isPlaying: Boolean) {
emit("onPlayingUpdate", isPlaying) emit("onPlayingUpdate", isPlaying)
} }
override fun onMetadata(metadata:JSObject) {
override fun onMetadata(metadata: JSObject) {
notifyListeners("onMetadata", metadata) notifyListeners("onMetadata", metadata)
} }
override fun onPrepare(audiobookId:String, playWhenReady:Boolean) {
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
var jsobj = JSObject() var jsobj = JSObject()
jsobj.put("audiobookId", audiobookId) jsobj.put("audiobookId", audiobookId)
jsobj.put("playWhenReady", playWhenReady) jsobj.put("playWhenReady", playWhenReady)
notifyListeners("onPrepareMedia", jsobj) notifyListeners("onPrepareMedia", jsobj)
} }
override fun onSleepTimerEnded(currentPosition:Long) {
override fun onSleepTimerEnded(currentPosition: Long) {
emit("onSleepTimerEnded", currentPosition) emit("onSleepTimerEnded", currentPosition)
} }
override fun onSleepTimerSet(sleepTimerEndTime:Long) {
override fun onSleepTimerSet(sleepTimerEndTime: Long) {
emit("onSleepTimerSet", sleepTimerEndTime) emit("onSleepTimerSet", sleepTimerEndTime)
} }
}) })
} }
mainActivity.pluginCallback = foregroundServiceReady mainActivity.pluginCallback = foregroundServiceReady
} }
fun emit(evtName: String, value:Any) { fun emit(evtName: String, value: Any) {
var ret:JSObject = JSObject() var ret:JSObject = JSObject()
ret.put("value", value) ret.put("value", value)
notifyListeners(evtName, ret) notifyListeners(evtName, ret)
@ -225,7 +231,7 @@ class MyNativeAudio : Plugin() {
} }
@PluginMethod @PluginMethod
fun requestSession(call:PluginCall) { fun requestSession(call: PluginCall) {
Log.d(tag, "CAST REQUEST SESSION PLUGIN") Log.d(tag, "CAST REQUEST SESSION PLUGIN")
playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() { playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() {

View file

@ -11,6 +11,7 @@ import android.net.Uri
import android.os.* import android.os.*
import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaDescriptionCompat 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.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
@ -20,6 +21,7 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat
import androidx.media.utils.MediaConstants import androidx.media.utils.MediaConstants
import com.getcapacitor.Bridge
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.google.android.exoplayer2.* import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.audio.AudioAttributes 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.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.* 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.*
import com.google.android.gms.cast.framework.* import com.google.android.gms.cast.framework.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -39,7 +40,6 @@ import okhttp3.OkHttpClient
import java.util.* import java.util.*
import kotlin.concurrent.schedule import kotlin.concurrent.schedule
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
class PlayerNotificationService : MediaBrowserServiceCompat() { class PlayerNotificationService : MediaBrowserServiceCompat() {
@ -66,6 +66,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat private lateinit var mediaSession: MediaSessionCompat
private lateinit var transportControls:MediaControllerCompat.TransportControls private lateinit var transportControls:MediaControllerCompat.TransportControls
private lateinit var audiobookManager:AudiobookManager
lateinit var mPlayer: SimpleExoPlayer lateinit var mPlayer: SimpleExoPlayer
lateinit var currentPlayer:Player lateinit var currentPlayer:Player
@ -73,6 +74,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
lateinit var sleepTimerManager:SleepTimerManager lateinit var sleepTimerManager:SleepTimerManager
lateinit var castManager:CastManager lateinit var castManager:CastManager
lateinit var audiobookProgressSyncer:AudiobookProgressSyncer
private var notificationId = 10; private var notificationId = 10;
private var channelId = "audiobookshelf_channel" private var channelId = "audiobookshelf_channel"
@ -87,6 +89,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private var lastPauseTime: Long = 0 //ms private var lastPauseTime: Long = 0 //ms
private var onSeekBack: Boolean = false private var onSeekBack: Boolean = false
var isAndroidAuto = false
var webviewBridge:Bridge? = null
// The following are used for the shake detection // The following are used for the shake detection
private var isShakeSensorRegistered:Boolean = false private var isShakeSensorRegistered:Boolean = false
private var mSensorManager: SensorManager? = null private var mSensorManager: SensorManager? = null
@ -94,11 +99,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
private var mShakeDetector: ShakeDetector? = null private var mShakeDetector: ShakeDetector? = null
private var shakeSensorUnregisterTask:TimerTask? = null private var shakeSensorUnregisterTask:TimerTask? = null
private lateinit var audiobookManager:AudiobookManager
fun setCustomObjectListener(mylistener: MyCustomObjectListener) { fun setCustomObjectListener(mylistener: MyCustomObjectListener) {
listener = mylistener listener = mylistener
} }
fun setBridge(bridge: Bridge) {
webviewBridge = bridge
}
fun getIsWebviewOpen():Boolean {
return webviewBridge?.app?.isActive == true
}
/* /*
Service related stuff Service related stuff
@ -224,6 +233,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playerNotificationManager.setPlayer(null) playerNotificationManager.setPlayer(null)
mPlayer.release() mPlayer.release()
mediaSession.release() mediaSession.release()
audiobookProgressSyncer.reset()
Log.d(tag, "onDestroy") Log.d(tag, "onDestroy")
isStarted = false isStarted = false
@ -262,18 +272,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
currentPlayer = mPlayer currentPlayer = mPlayer
var client: OkHttpClient = OkHttpClient()
// Initialize sleep timer // Initialize sleep timer
sleepTimerManager = SleepTimerManager(this) sleepTimerManager = SleepTimerManager(this)
// Initialize Cast Manager // Initialize Cast Manager
castManager = CastManager(this) castManager = CastManager(this)
// Initialize Audiobook Progress Syncer (Only used for android auto when webview is not open)
audiobookProgressSyncer = AudiobookProgressSyncer(this, client)
// Initialize shake sensor // Initialize shake sensor
Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}") Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}")
initSensor() initSensor()
// Initialize audiobook manager // Initialize audiobook manager
var client: OkHttpClient = OkHttpClient()
audiobookManager = AudiobookManager(ctx, client) audiobookManager = AudiobookManager(ctx, client)
audiobookManager.init() audiobookManager.init()
@ -438,7 +452,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
override fun onPause() { override fun onPause() {
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT") Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT")
pause() pause()
} }
@ -626,6 +640,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
} }
} else lastPauseTime = System.currentTimeMillis() } 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) listener?.onPlayingUpdate(player.isPlaying)
} }
} }
@ -714,6 +739,30 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
return currentPlayer.duration 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 { fun calcPauseSeekBackTime() : Long {
if (lastPauseTime <= 0) return 0 if (lastPauseTime <= 0) return 0
var time: Long = System.currentTimeMillis() - lastPauseTime var time: Long = System.currentTimeMillis() - lastPauseTime
@ -789,24 +838,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
// //
// MEDIA BROWSER STUFF (ANDROID AUTO) // 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<String>(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 AUTO_MEDIA_ROOT = "/"
private val ALL_ROOT = "__ALL__" private val ALL_ROOT = "__ALL__"
private lateinit var browseTree:BrowseTree 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 { 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 return true
} }
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {
// Verify that the specified package is allowed to access your // Verify that the specified package is allowed to access your content
// content! You'll need to write your own logic to do this.
return if (!isValid(clientPackageName, clientUid)) { 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. // No further calls will be made to other media browsing methods.
null null
} else { } else {
// Flag is used to enable syncing progress natively (normally syncing is handled in webview)
isAndroidAuto = true
val extras = Bundle() val extras = Bundle()
extras.putBoolean( extras.putBoolean(
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
@ -833,14 +896,14 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
audiobookManager.isLoading = false audiobookManager.isLoading = false
Log.d(tag, "LOADED AUDIOBOOKS") Log.d(tag, "LOADED AUDIOBOOKS")
browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobookManager.audiobooks, audiobookManager.localMediaManager.localAudioFiles, null)
var audiobooks:List<MediaMetadataCompat> = audiobookManager.getAudiobooksMediaMetadata()
var downloadedBooks:List<MediaMetadataCompat> = audiobookManager.getDownloadedAudiobooksMediaMetadata()
browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks)
val children = browseTree[parentMediaId]?.map { item -> val children = browseTree[parentMediaId]?.map { item ->
Log.d(tag, "Loaded Audiobook description ${item.description.title} - ${item.description.subtitle}")
MediaBrowserCompat.MediaItem(item.description, flag) MediaBrowserCompat.MediaItem(item.description, flag)
} }
if (children != null) {
Log.d(tag, "BROWSE TREE CHILDREN ${children.size}")
}
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
} }
return return
@ -858,7 +921,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
} }
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?) result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
// Check if this is the root menu: // TODO: For using sub menus. Check if this is the root menu:
if (AUTO_MEDIA_ROOT == parentMediaId) { if (AUTO_MEDIA_ROOT == parentMediaId) {
// build the MediaItem objects for the top level, // build the MediaItem objects for the top level,
// and put them in the mediaItems list // and put them in the mediaItems list
@ -878,7 +941,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
audiobookManager.isLoading = false audiobookManager.isLoading = false
Log.d(tag, "LOADED AUDIOBOOKS") Log.d(tag, "LOADED AUDIOBOOKS")
browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobookManager.audiobooks, audiobookManager.localMediaManager.localAudioFiles, null) var audiobooks:List<MediaMetadataCompat> = audiobookManager.getAudiobooksMediaMetadata()
var downloadedBooks:List<MediaMetadataCompat> = audiobookManager.getDownloadedAudiobooksMediaMetadata()
browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks)
val children = browseTree[ALL_ROOT]?.map { item -> val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
} }

View file

@ -450,8 +450,8 @@ export default {
this.playedTrackWidth = ptWidth this.playedTrackWidth = ptWidth
if (this.useChapterTrack) { if (this.useChapterTrack) {
this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px' if (this.$refs.totalPlayedTrack) 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.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
} }
}, },
seek(time) { seek(time) {

View file

@ -349,6 +349,7 @@ export default {
}) })
var audiobookStreamData = { var audiobookStreamData = {
id: 'download',
title: this.title, title: this.title,
author: this.author, author: this.author,
playWhenReady: !!playOnLoad, playWhenReady: !!playOnLoad,

View file

@ -10,7 +10,7 @@
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p> <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
</div> </div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> <img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="hasCover ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author --> <!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }"> <div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">

View file

@ -5,7 +5,7 @@
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
</div> </div>
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> <img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2"> <div class="absolute top-2 right-2">

View file

@ -16,6 +16,7 @@ import { Capacitor } from '@capacitor/core'
import { AppUpdate } from '@robingenz/capacitor-app-update' import { AppUpdate } from '@robingenz/capacitor-app-update'
import AudioDownloader from '@/plugins/audio-downloader' import AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager' import StorageManager from '@/plugins/storage-manager'
import MyNativeAudio from '@/plugins/my-native-audio'
export default { export default {
data() { data() {