mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-28 05:53:59 +02:00
Change:Cast support use audio tracks instead of HLS #11, Clean up playernotificationmanager
This commit is contained in:
parent
b9de407c8a
commit
f11efd93c7
8 changed files with 868 additions and 729 deletions
|
@ -81,12 +81,3 @@ dependencies {
|
|||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
var currentIconUri: Uri? = null
|
||||
var currentBitmap: Bitmap? = null
|
||||
|
||||
private val glideOptions = RequestOptions()
|
||||
.fallback(R.drawable.icon)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||
|
||||
override fun createCurrentContentIntent(player: Player): PendingIntent? =
|
||||
controller.sessionActivity
|
||||
|
||||
override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString()
|
||||
|
||||
override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString()
|
||||
|
||||
override fun getCurrentLargeIcon(
|
||||
player: Player,
|
||||
callback: PlayerNotificationManager.BitmapCallback
|
||||
): Bitmap? {
|
||||
val albumArtUri = controller.metadata.description.iconUri
|
||||
|
||||
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
||||
// Cache the bitmap for the current audiobook so that successive calls to
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
Log.d(tag, "ART $currentIconUri")
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
null
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Block on downloading artwork.
|
||||
try {
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,13 @@
|
|||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.lang.Exception
|
||||
|
||||
class AudiobookStreamData {
|
||||
var id:String = "audiobook"
|
||||
|
@ -15,6 +21,7 @@ class AudiobookStreamData {
|
|||
var startTime:Long = 0
|
||||
var playbackSpeed:Float = 1f
|
||||
var duration:Long = 0
|
||||
var tracks:MutableList<String> = mutableListOf()
|
||||
|
||||
var isLocal:Boolean = false
|
||||
var contentUrl:String = ""
|
||||
|
@ -65,5 +72,98 @@ class AudiobookStreamData {
|
|||
if (contentUrl != "") {
|
||||
contentUri = Uri.parse(contentUrl)
|
||||
}
|
||||
|
||||
// Tracks for cast
|
||||
try {
|
||||
var tracksTest = jsondata.getJSONArray("tracks")
|
||||
Log.d("AudiobookStreamData", "Load tracks from json array ${tracksTest.length()}")
|
||||
for (i in 0 until tracksTest.length()) {
|
||||
var track = tracksTest.get(i)
|
||||
Log.d("AudiobookStreamData", "Extracting track $track")
|
||||
tracks.add(track as String)
|
||||
}
|
||||
} catch(e:Exception) {
|
||||
Log.d("AudiobookStreamData", "No tracks found $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaMetadataCompat():MediaMetadataCompat {
|
||||
var metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, series)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
|
||||
if (cover != "") {
|
||||
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, cover)
|
||||
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, cover)
|
||||
}
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMediaMetadata():MediaMetadata {
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
|
||||
if (coverUri != Uri.EMPTY) {
|
||||
metadataBuilder.setArtworkUri(coverUri)
|
||||
}
|
||||
if (playlistUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(playlistUri)
|
||||
}
|
||||
if (contentUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
}
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMimeType():String {
|
||||
return if (isLocal) {
|
||||
MimeTypes.BASE_TYPE_AUDIO
|
||||
} else {
|
||||
MimeTypes.APPLICATION_M3U8
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaUri():Uri {
|
||||
return if (isLocal) {
|
||||
contentUri
|
||||
} else {
|
||||
Uri.parse("$playlistUrl?token=$token")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCastQueue():ArrayList<MediaItem> {
|
||||
var mediaQueue: java.util.ArrayList<MediaItem> = java.util.ArrayList<MediaItem>()
|
||||
|
||||
for (i in 0 until tracks.size) {
|
||||
var track = tracks[i]
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
.setTrackNumber(i + 1)
|
||||
|
||||
if (coverUri != Uri.EMPTY) {
|
||||
metadataBuilder.setArtworkUri(coverUri)
|
||||
}
|
||||
|
||||
var mimeType = MimeTypes.BASE_TYPE_AUDIO
|
||||
|
||||
var mediaMetadata = metadataBuilder.build()
|
||||
var mediaItem = MediaItem.Builder().setUri(Uri.parse(track)).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
mediaQueue.add(mediaItem)
|
||||
}
|
||||
|
||||
return mediaQueue
|
||||
}
|
||||
}
|
||||
|
|
379
android/app/src/main/java/com/audiobookshelf/app/CastManager.kt
Normal file
379
android/app/src/main/java/com/audiobookshelf/app/CastManager.kt
Normal file
|
@ -0,0 +1,379 @@
|
|||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.appcompat.R
|
||||
import androidx.mediarouter.app.MediaRouteChooserDialog
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.google.android.exoplayer2.ext.cast.CastPlayer
|
||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||
import com.google.android.gms.cast.Cast
|
||||
import com.google.android.gms.cast.CastDevice
|
||||
import com.google.android.gms.cast.CastMediaControlIntent
|
||||
import com.google.android.gms.cast.framework.*
|
||||
import org.json.JSONObject
|
||||
import java.util.ArrayList
|
||||
|
||||
class CastManager constructor(playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
private var newConnectionListener:SessionListener? = null
|
||||
private var mainActivity:Activity? = null
|
||||
|
||||
private fun switchToPlayer(useCastPlayer:Boolean) {
|
||||
playerNotificationService.switchToPlayer(useCastPlayer)
|
||||
}
|
||||
|
||||
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
|
||||
|
||||
/**
|
||||
* Called when a Cast session has started and the user wishes to control playback on a
|
||||
* remote Cast receiver rather than play audio locally.
|
||||
*/
|
||||
override fun onCastSessionAvailable() {
|
||||
switchToPlayer(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a Cast session has ended and the user wishes to control playback locally.
|
||||
*/
|
||||
override fun onCastSessionUnavailable() {
|
||||
Log.d(tag, "onCastSessionUnavailable")
|
||||
switchToPlayer(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSession(mainActivity: Activity, callback: RequestSessionCallback) {
|
||||
this.mainActivity = mainActivity
|
||||
|
||||
mainActivity.runOnUiThread(object : Runnable {
|
||||
override fun run() {
|
||||
Log.d(tag, "CAST RUNNING ON MAIN THREAD")
|
||||
|
||||
val session: CastSession? = getSession()
|
||||
if (session == null) {
|
||||
// show the "choose a connection" dialog
|
||||
|
||||
// Add the connection listener callback
|
||||
listenForConnection(callback)
|
||||
|
||||
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
|
||||
builder.routeSelector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build()
|
||||
builder.setCanceledOnTouchOutside(true)
|
||||
builder.setOnCancelListener {
|
||||
getSessionManager()!!.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
callback.onCancel()
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
// We are are already connected, so show the "connection options" Dialog
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
if (session.castDevice != null) {
|
||||
builder.setTitle(session.castDevice.friendlyName)
|
||||
}
|
||||
builder.setOnDismissListener { callback.onCancel() }
|
||||
builder.setPositiveButton("Stop Casting") { dialog, which -> endSession(true, null) }
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
abstract class RequestSessionCallback : ConnectionCallback {
|
||||
abstract fun onError(errorCode: Int)
|
||||
abstract fun onCancel()
|
||||
override fun onSessionEndedBeforeStart(errorCode: Int): Boolean {
|
||||
onSessionStartFailed(errorCode)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(errorCode: Int): Boolean {
|
||||
onError(errorCode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
|
||||
|
||||
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
|
||||
override fun onSessionEnded(castSession: CastSession?, error: Int) {
|
||||
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
|
||||
Log.d(tag, "CAST END SESSION")
|
||||
// media.setSession(null)
|
||||
pluginCall?.resolve()
|
||||
// listener.onSessionEnd(ChromecastUtilities.createSessionObject(castSession, if (stopCasting) "stopped" else "disconnected"))
|
||||
}
|
||||
}, CastSession::class.java)
|
||||
getSessionManager()!!.endCurrentSession(stopCasting)
|
||||
|
||||
}
|
||||
|
||||
open class SessionListener : SessionManagerListener<CastSession> {
|
||||
override fun onSessionStarting(castSession: CastSession?) {}
|
||||
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {}
|
||||
override fun onSessionStartFailed(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionEnding(castSession: CastSession?) {}
|
||||
override fun onSessionEnded(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionResuming(castSession: CastSession?, sessionId: String) {}
|
||||
override fun onSessionResumed(castSession: CastSession?, wasSuspended: Boolean) {}
|
||||
override fun onSessionResumeFailed(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionSuspended(castSession: CastSession?, reason: Int) {}
|
||||
}
|
||||
|
||||
private fun startRouteScan() {
|
||||
var connListener = object: ChromecastListener() {
|
||||
override fun onReceiverAvailableUpdate(available: Boolean) {
|
||||
Log.d(tag, "CAST RECEIVER UPDATE AVAILABLE $available")
|
||||
}
|
||||
|
||||
override fun onSessionRejoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionRejoin")
|
||||
}
|
||||
|
||||
override fun onMediaLoaded(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaLoaded")
|
||||
}
|
||||
|
||||
override fun onMediaUpdate(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionUpdate(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionEnd(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionEnd")
|
||||
}
|
||||
|
||||
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
|
||||
Log.d(tag, "CAST onMessageReceived")
|
||||
}
|
||||
}
|
||||
|
||||
var callback = object : ScanCallback() {
|
||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||
// if the routes have changed, we may have an available device
|
||||
// If there is at least one device available
|
||||
if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) {
|
||||
|
||||
routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") }
|
||||
|
||||
// Stop the scan
|
||||
stopRouteScan(this, null);
|
||||
// Let the client know a receiver is available
|
||||
connListener.onReceiverAvailableUpdate(true);
|
||||
// Since we have a receiver we may also have an active session
|
||||
var session = getSessionManager()?.currentCastSession;
|
||||
// If we do have a session
|
||||
if (session != null) {
|
||||
// Let the client know
|
||||
Log.d(tag, "LET SESSION KNOW ABOUT")
|
||||
// media.setSession(session);
|
||||
// connListener.onSessionRejoin(ChromecastUtilities.createSessionObject(session));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
callback.setMediaRouter(getMediaRouter())
|
||||
|
||||
callback.onFilteredRouteUpdate();
|
||||
|
||||
getMediaRouter()!!.addCallback(MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build(),
|
||||
callback,
|
||||
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
|
||||
}
|
||||
|
||||
internal interface CastListener : Cast.MessageReceivedCallback {
|
||||
fun onMediaLoaded(jsonMedia: JSONObject?)
|
||||
fun onMediaUpdate(jsonMedia: JSONObject?)
|
||||
fun onSessionUpdate(jsonSession: JSONObject?)
|
||||
fun onSessionEnd(jsonSession: JSONObject?)
|
||||
}
|
||||
|
||||
internal abstract class ChromecastListener : CastStateListener, CastListener {
|
||||
abstract fun onReceiverAvailableUpdate(available: Boolean)
|
||||
abstract fun onSessionRejoin(jsonSession: JSONObject?)
|
||||
|
||||
/** CastStateListener functions. */
|
||||
override fun onCastStateChanged(state: Int) {
|
||||
onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
|
||||
if (callback == null) {
|
||||
completionCallback!!.run()
|
||||
return
|
||||
}
|
||||
// ctx.runOnUiThread(Runnable {
|
||||
callback.stop()
|
||||
getMediaRouter()!!.removeCallback(callback)
|
||||
completionCallback?.run()
|
||||
// })
|
||||
}
|
||||
|
||||
abstract class ScanCallback : MediaRouter.Callback() {
|
||||
/**
|
||||
* Called whenever a route is updated.
|
||||
* @param routes the currently available routes
|
||||
*/
|
||||
abstract fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?)
|
||||
|
||||
/** records whether we have been stopped or not. */
|
||||
private var stopped = false
|
||||
|
||||
/** Global mediaRouter object. */
|
||||
private var mediaRouter: MediaRouter? = null
|
||||
|
||||
/**
|
||||
* Sets the mediaRouter object.
|
||||
* @param router mediaRouter object
|
||||
*/
|
||||
fun setMediaRouter(router: MediaRouter?) {
|
||||
mediaRouter = router
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method when you wish to stop scanning.
|
||||
* It is important that it is called, otherwise battery
|
||||
* life will drain more quickly.
|
||||
*/
|
||||
fun stop() {
|
||||
stopped = true
|
||||
}
|
||||
|
||||
fun onFilteredRouteUpdate() {
|
||||
if (stopped || mediaRouter == null) {
|
||||
return
|
||||
}
|
||||
val outRoutes: MutableList<MediaRouter.RouteInfo> = ArrayList()
|
||||
// Filter the routes
|
||||
for (route in mediaRouter!!.routes) {
|
||||
// We don't want default routes, or duplicate active routes
|
||||
// or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32
|
||||
val extras: Bundle? = route.extras
|
||||
if (extras != null) {
|
||||
CastDevice.getFromBundle(extras)
|
||||
if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!route.isDefault
|
||||
&& !route.description.equals("Google Cast Multizone Member")
|
||||
&& route.playbackType === MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
|
||||
outRoutes.add(route)
|
||||
}
|
||||
}
|
||||
onRouteUpdate(outRoutes)
|
||||
}
|
||||
|
||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun listenForConnection(callback: ConnectionCallback) {
|
||||
// We should only ever have one of these listeners active at a time, so remove previous
|
||||
getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
|
||||
newConnectionListener = object : SessionListener() {
|
||||
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {
|
||||
Log.d(tag, "CAST SESSION STARTED ${castSession?.castDevice?.friendlyName}")
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
|
||||
try {
|
||||
val castContext = CastContext.getSharedInstance(mainActivity)
|
||||
playerNotificationService.castPlayer = CastPlayer(castContext).apply {
|
||||
setSessionAvailabilityListener(CastSessionAvailabilityListener())
|
||||
addListener(playerNotificationService.getPlayerListener())
|
||||
}
|
||||
Log.d(tag, "CAST Cast Player Applied")
|
||||
switchToPlayer(true)
|
||||
} catch (e: Exception) {
|
||||
Log.i(tag, "Cast is not available on this device. " +
|
||||
"Exception thrown when attempting to obtain CastContext. " + e.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// media.setSession(castSession)
|
||||
// callback.onJoin(ChromecastUtilities.createSessionObject(castSession))
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionStartFailed(errCode)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionEnded(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionEndedBeforeStart(errCode)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSessionManager()?.addSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
}
|
||||
|
||||
private fun getContext(): CastContext {
|
||||
return CastContext.getSharedInstance(mainActivity)
|
||||
}
|
||||
|
||||
private fun getSessionManager(): SessionManager? {
|
||||
return getContext().sessionManager
|
||||
}
|
||||
|
||||
private fun getMediaRouter(): MediaRouter? {
|
||||
return mainActivity?.let { MediaRouter.getInstance(it) }
|
||||
}
|
||||
|
||||
private fun getSession(): CastSession? {
|
||||
return getSessionManager()?.currentCastSession
|
||||
}
|
||||
|
||||
internal interface ConnectionCallback {
|
||||
/**
|
||||
* Successfully joined a session on a route.
|
||||
* @param jsonSession the session we joined
|
||||
*/
|
||||
fun onJoin(jsonSession: JSONObject?)
|
||||
|
||||
/**
|
||||
* Called if we received an error.
|
||||
* @param errorCode You can find the error meaning here:
|
||||
* https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes
|
||||
* @return true if we are done listening for join, false, if we to keep listening
|
||||
*/
|
||||
fun onSessionStartFailed(errorCode: Int): Boolean
|
||||
|
||||
/**
|
||||
* Called when we detect a session ended event before session started.
|
||||
* See issues:
|
||||
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/49
|
||||
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
|
||||
* @param errorCode error to output
|
||||
* @return true if we are done listening for join, false, if we to keep listening
|
||||
*/
|
||||
fun onSessionEndedBeforeStart(errorCode: Int): Boolean
|
||||
}
|
||||
}
|
|
@ -60,8 +60,6 @@ class MyNativeAudio : Plugin() {
|
|||
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
||||
ContextCompat.startForegroundService(mainActivity, intent)
|
||||
}
|
||||
} else {
|
||||
Log.w(tag, "Service already started --")
|
||||
}
|
||||
var jsobj = JSObject()
|
||||
|
||||
|
@ -179,7 +177,7 @@ class MyNativeAudio : Plugin() {
|
|||
var isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var success:Boolean = playerNotificationService.setSleepTimer(time, isChapterTime)
|
||||
var success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
|
@ -188,7 +186,7 @@ class MyNativeAudio : Plugin() {
|
|||
|
||||
@PluginMethod
|
||||
fun getSleepTimerTime(call: PluginCall) {
|
||||
var time = playerNotificationService.getSleepTimerTime()
|
||||
var time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", time)
|
||||
call.resolve(ret)
|
||||
|
@ -199,7 +197,7 @@ class MyNativeAudio : Plugin() {
|
|||
var time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.increaseSleepTime(time)
|
||||
playerNotificationService.sleepTimerManager.increaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
|
@ -211,7 +209,7 @@ class MyNativeAudio : Plugin() {
|
|||
var time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.decreaseSleepTime(time)
|
||||
playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
|
@ -220,7 +218,7 @@ class MyNativeAudio : Plugin() {
|
|||
|
||||
@PluginMethod
|
||||
fun cancelSleepTimer(call: PluginCall) {
|
||||
playerNotificationService.cancelSleepTimer()
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
@ -228,7 +226,7 @@ class MyNativeAudio : Plugin() {
|
|||
fun requestSession(call:PluginCall) {
|
||||
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
||||
|
||||
playerNotificationService.requestSession(mainActivity, object : PlayerNotificationService.RequestSessionCallback() {
|
||||
playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() {
|
||||
override fun onError(errorCode: Int) {
|
||||
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,190 @@
|
|||
package com.audiobookshelf.app
|
||||
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
||||
|
||||
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
private var sleepTimerRunning:Boolean = false
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerExtensionTime:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
}
|
||||
|
||||
private fun getDuration():Long {
|
||||
return playerNotificationService.getDuration()
|
||||
}
|
||||
|
||||
private fun getIsPlaying():Boolean {
|
||||
return playerNotificationService.currentPlayer.isPlaying
|
||||
}
|
||||
|
||||
private fun setVolume(volume:Float) {
|
||||
playerNotificationService.currentPlayer.volume = volume
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
playerNotificationService.currentPlayer.pause()
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
playerNotificationService.currentPlayer.play()
|
||||
}
|
||||
|
||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
||||
if (sleepTimerEndTime <= 0) return 0
|
||||
var sleepTimeRemaining = sleepTimerEndTime - getCurrentTime()
|
||||
return ((sleepTimeRemaining / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
fun getIsSleepTimerRunning():Boolean {
|
||||
return sleepTimerRunning
|
||||
}
|
||||
|
||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = false
|
||||
sleepTimerFinishedAt = 0L
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
|
||||
var currentTime = getCurrentTime()
|
||||
if (isChapterTime) {
|
||||
if (currentTime > time) {
|
||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
||||
return false
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
|
||||
} else {
|
||||
sleepTimerEndTime = currentTime + time
|
||||
sleepTimerExtensionTime = time
|
||||
}
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
|
||||
sleepTimerRunning = true
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (getIsPlaying()) {
|
||||
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 30) {
|
||||
// Start fading out audio
|
||||
var volume = sleepTimeSecondsRemaining / 30F
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long? {
|
||||
return sleepTimerEndTime
|
||||
}
|
||||
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
clearSleepTimer()
|
||||
playerNotificationService.listener?.onSleepTimerSet(0)
|
||||
}
|
||||
|
||||
private fun extendSleepTime() {
|
||||
if (!sleepTimerRunning) return
|
||||
setVolume(1F)
|
||||
sleepTimerEndTime += sleepTimerExtensionTime
|
||||
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
|
||||
fun checkShouldExtendSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
|
||||
{
|
||||
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
|
||||
sleepTimerFinishedAt = 0L
|
||||
return
|
||||
}
|
||||
|
||||
var newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
|
||||
setSleepTimer(newSleepTime, false)
|
||||
play()
|
||||
return
|
||||
}
|
||||
// Only extend if within 30 seconds of finishing
|
||||
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
if (sleepTimeRemaining <= 30) extendSleepTime()
|
||||
}
|
||||
|
||||
fun handleShake() {
|
||||
Log.d(tag, "HANDLE SHAKE HERE")
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
|
||||
}
|
||||
|
||||
fun increaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Increase Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
var newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
var newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
}
|
|
@ -138,6 +138,18 @@ export default {
|
|||
var coverSrc = this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
|
||||
return coverSrc
|
||||
},
|
||||
tracksForCast() {
|
||||
if (!this.audiobook || !this.audiobook.tracks) {
|
||||
return []
|
||||
}
|
||||
var abpath = this.audiobook.path
|
||||
var tracks = this.audiobook.tracks.map((t) => {
|
||||
var trelpath = t.path.replace(abpath, '')
|
||||
if (trelpath.startsWith('/')) trelpath = trelpath.substr(1)
|
||||
return `${this.$store.state.serverUrl}/s/book/${this.audiobook.id}/${trelpath}?token=${this.userToken}`
|
||||
})
|
||||
return tracks
|
||||
},
|
||||
sleepTimeRemaining() {
|
||||
if (!this.sleepTimerEndTime) return 0
|
||||
return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
|
||||
|
@ -390,8 +402,10 @@ export default {
|
|||
series: this.seriesTxt,
|
||||
playlistUrl: this.$server.url + playlistUrl,
|
||||
token: this.userToken,
|
||||
audiobookId: this.audiobookId
|
||||
audiobookId: this.audiobookId,
|
||||
tracks: this.tracksForCast
|
||||
}
|
||||
|
||||
this.$refs.audioPlayer.set(audiobookStreamData, stream, !this.stream)
|
||||
|
||||
this.stream = stream
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue