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.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<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
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()

View file

@ -10,10 +10,9 @@ import androidx.annotation.AnyRes
class BrowseTree(
val context: Context,
val audiobooksInProgress: List<Audiobook>,
val audiobooks: List<Audiobook>,
val localAudio: List<LocalMediaManager.LocalAudio>,
val recentMediaId: String? = null
audiobooksInProgress: List<Audiobook>,
audiobookMetadata: List<MediaMetadataCompat>,
downloadedMetadata: List<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())
}.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
}
audiobookMetadata.forEach {
val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
allChildren += audiobook.toMediaMetadata()
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__"

View file

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

View file

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

View file

@ -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<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 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<MediaMetadataCompat> = audiobookManager.getAudiobooksMediaMetadata()
var downloadedBooks:List<MediaMetadataCompat> = 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<MediaBrowserCompat.MediaItem>?)
}
return
@ -858,7 +921,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
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) {
// 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<MediaMetadataCompat> = audiobookManager.getAudiobooksMediaMetadata()
var downloadedBooks:List<MediaMetadataCompat> = audiobookManager.getDownloadedAudiobooksMediaMetadata()
browseTree = BrowseTree(this, audiobookManager.audiobooksInProgress, audiobooks, downloadedBooks)
val children = browseTree[ALL_ROOT]?.map { item ->
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}

View file

@ -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) {

View file

@ -349,6 +349,7 @@ export default {
})
var audiobookStreamData = {
id: 'download',
title: this.title,
author: this.author,
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>
</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 -->
<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>
<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">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<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 AudioDownloader from '@/plugins/audio-downloader'
import StorageManager from '@/plugins/storage-manager'
import MyNativeAudio from '@/plugins/my-native-audio'
export default {
data() {