From 493d7aecc9afa7a8cb69bcd768284e17eef28c50 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Apr 2022 16:59:49 -0500 Subject: [PATCH] Add chromecast support for android, update package versions --- android/app/build.gradle | 21 +- .../com/audiobookshelf/app/MainActivity.kt | 18 +- .../com/audiobookshelf/app/data/DbManager.kt | 13 +- .../app/data/LocalLibraryItem.kt | 2 +- .../app/data/PlaybackSession.kt | 37 +- .../audiobookshelf/app/media/MediaManager.kt | 4 +- .../app/player/AbMediaDescriptionAdapter.kt | 3 +- .../audiobookshelf/app/player/CastManager.kt | 249 +- .../audiobookshelf/app/player/CastPlayer.kt | 983 + .../audiobookshelf/app/player/CastTimeline.kt | 162 + .../app/player/CastTimelineTracker.kt | 109 + .../app/player/CastTrackSelection.kt | 79 + .../app/player/MediaSessionCallback.kt | 6 +- .../player/MediaSessionPlaybackPreparer.kt | 9 +- .../app/player/PlayerListener.kt | 16 +- .../app/player/PlayerNotificationService.kt | 114 +- .../app/plugins/AbsAudioPlayer.kt | 64 +- .../audiobookshelf/app/server/ApiHandler.kt | 4 +- android/build.gradle | 9 +- android/gradle.properties | 12 +- .../gradle/wrapper/gradle-wrapper.properties | 5 +- android/variables.gradle | 16 +- components/app/Appbar.vue | 36 +- components/app/AudioPlayer.vue | 15 +- components/app/AudioPlayerContainer.vue | 18 + components/connection/ServerConnectForm.vue | 41 +- package-lock.json | 18823 +--------------- package.json | 20 +- store/index.js | 7 + 29 files changed, 1821 insertions(+), 19074 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 121c2674..da79d656 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -4,6 +4,22 @@ plugins { id 'kotlin-kapt' } +kotlin { + kotlinDaemonJvmArgs = [ + "-Dfile.encoding=UTF-8", + "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED" + ] +} + android { kotlinOptions { freeCompilerArgs = ['-Xjvm-default=all'] @@ -42,14 +58,15 @@ dependencies { implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation project(':capacitor-android') - implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' + implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation project(':capacitor-cordova-android-plugins') - implementation "androidx.core:core-ktx:1.6.0" + implementation "androidx.core:core-ktx:$androidx_core_ktx_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" diff --git a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt index 654f77f1..8ab2cb5d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt @@ -5,18 +5,20 @@ import android.app.DownloadManager import android.content.* import android.content.pm.PackageManager import android.os.* +import android.os.StrictMode.VmPolicy import android.util.Log import androidx.core.app.ActivityCompat import com.anggrayudi.storage.SimpleStorage import com.anggrayudi.storage.SimpleStorageHelper import com.audiobookshelf.app.data.AbsDatabase import com.audiobookshelf.app.player.PlayerNotificationService -import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsAudioPlayer +import com.audiobookshelf.app.plugins.AbsDownloader import com.audiobookshelf.app.plugins.AbsFileSystem import com.getcapacitor.BridgeActivity import io.paperdb.Paper + class MainActivity : BridgeActivity() { private val tag = "MainActivity" @@ -51,6 +53,20 @@ class MainActivity : BridgeActivity() { } public override fun onCreate(savedInstanceState: Bundle?) { + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites().detectAll() + .detectNetwork() // or .detectAll() for all detectable problems + .penaltyLog() + .build()) + StrictMode.setVmPolicy(VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .penaltyLog() +// .penaltyDeath() + .build()) + + super.onCreate(savedInstanceState) Log.d(tag, "onCreate") diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt index 4f37950e..bf0bf721 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DbManager.kt @@ -20,19 +20,8 @@ class DbManager { var localLibraryItems:MutableList = mutableListOf() Paper.book("localLibraryItems").allKeys.forEach { var localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it) - if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem?.mediaType)) { - // TODO: Check to make sure all file paths exist -// if (localMediaItem.coverContentUrl != null) { -// var file = DocumentFile.fromSingleUri(ctx) -// if (!file.exists()) { -// Log.e(tag, "Local media item cover url does not exist ${localMediaItem.coverContentUrl}") -// removeLocalMediaItem(localMediaItem.id) -// } else { -// localMediaItems.add(localMediaItem) -// } -// } else { + if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) { localLibraryItems.add(localLibraryItem) -// } } } return localLibraryItems diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index 8c641ff7..c515dfb4 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -73,7 +73,7 @@ data class LocalLibraryItem( } var dateNow = System.currentTimeMillis() - return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress) + return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player") } @JsonIgnore diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index 9b5c35eb..773a5c04 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -2,11 +2,13 @@ package com.audiobookshelf.app.data import android.net.Uri import android.support.v4.media.MediaMetadataCompat +import androidx.core.app.NotificationCompat import com.audiobookshelf.app.R import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.player.MediaProgressSyncData import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.google.android.exoplayer2.C import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaMetadata import com.google.android.gms.cast.MediaInfo @@ -42,7 +44,8 @@ class PlaybackSession( var localLibraryItem:LocalLibraryItem?, var localEpisodeId:String?, var serverConnectionConfigId:String?, - var serverAddress:String? + var serverAddress:String?, + var mediaPlayer:String? ) { @get:JsonIgnore @@ -152,9 +155,14 @@ class PlaybackSession( @JsonIgnore fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata { var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER) - castMetadata.addImage(WebImage(getCoverUri())) - castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle) - castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor) + + coverPath?.let { + castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"))) + } + + castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "") + castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "") + castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title) castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index) return castMetadata } @@ -164,21 +172,22 @@ class PlaybackSession( var castMetadata = getCastMediaMetadata(audioTrack) var mediaUri = getContentUri(audioTrack) - var mediaInfoBuilder = MediaInfo.Builder(mediaUri.toString()) - mediaInfoBuilder.setContentUrl(mediaUri.toString()) - mediaInfoBuilder.setMetadata(castMetadata) - mediaInfoBuilder.setContentType(audioTrack.mimeType) - var mediaInfo = mediaInfoBuilder.build() - var queueItem = MediaQueueItem.Builder(mediaInfo) - queueItem.setItemId(audioTrack.index) - queueItem.setPlaybackDuration(audioTrack.duration) - return queueItem.build() + var mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply { + setContentUrl(mediaUri.toString()) + setContentType(audioTrack.mimeType) + setMetadata(castMetadata) + setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + }.build() + + return MediaQueueItem.Builder(mediaInfo).apply { + setPlaybackDuration(audioTrack.duration) + }.build() } @JsonIgnore fun clone():PlaybackSession { - return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress) + return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer) } @JsonIgnore diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt index 94366594..3a1edb65 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -34,8 +34,8 @@ class MediaManager(var apiHandler: ApiHandler) { } } - fun play(libraryItem:LibraryItem, cb: (PlaybackSession) -> Unit) { - apiHandler.playLibraryItem(libraryItem.id,"",false) { + fun play(libraryItem:LibraryItem, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { + apiHandler.playLibraryItem(libraryItem.id,"",false, mediaPlayer) { cb(it) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt index 2fa70e92..b3117b77 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt @@ -15,9 +15,8 @@ import kotlinx.coroutines.* const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px -class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter { +class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter { private val tag = "MediaDescriptionAdapter" - private val playerNotificationService:PlayerNotificationService = playerNotificationService var currentIconUri: Uri? = null var currentBitmap: Bitmap? = null diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt index ead754f5..1104288a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt @@ -2,31 +2,30 @@ package com.audiobookshelf.app.player import android.app.Activity import android.app.AlertDialog -import android.net.Uri import android.os.Bundle +import android.os.Handler +import android.os.Looper 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.MediaItem -import com.google.android.exoplayer2.ext.cast.CastPlayer -import com.google.android.exoplayer2.ext.cast.MediaItemConverter import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.* import org.json.JSONObject -class CastManager constructor(playerNotificationService:PlayerNotificationService) { +class CastManager constructor(val mainActivity:Activity) { private val tag = "CastManager" - private val playerNotificationService:PlayerNotificationService = playerNotificationService + private var playerNotificationService:PlayerNotificationService? = null private var newConnectionListener: SessionListener? = null - private var mainActivity:Activity? = null private fun switchToPlayer(useCastPlayer:Boolean) { - playerNotificationService.switchToPlayer(useCastPlayer) + Handler(Looper.getMainLooper()).post() { + playerNotificationService?.switchToPlayer(useCastPlayer) + } } private inner class CastSessionAvailabilityListener : SessionAvailabilityListener { @@ -36,6 +35,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic * remote Cast receiver rather than play audio locally. */ override fun onCastSessionAvailable() { + Log.d(tag, "SessionAvailabilityListener: onCastSessionAvailable") switchToPlayer(true) } @@ -48,42 +48,39 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic } } - fun requestSession(mainActivity: Activity, callback: RequestSessionCallback) { - this.mainActivity = mainActivity + fun requestSession(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) { + this.playerNotificationService = playerNotificationService - mainActivity.runOnUiThread(object : Runnable { - override fun run() { - Log.d(tag, "CAST RUNNING ON MAIN THREAD") + mainActivity.runOnUiThread { + val session: CastSession? = getSession() + if (session == null) { + // show the "choose a connection" dialog + // Add the connection listener callback + listenForConnection(callback) - 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() + 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 { + newConnectionListener?.let { ncl -> + getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java) } - 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() + callback.onCancel() } + builder.show() + } else { + // We are are already connected, so show the "connection options" Dialog + val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) + session.castDevice?.let { + builder.setTitle(it.friendlyName) + } + builder.setOnDismissListener { callback.onCancel() } + builder.setPositiveButton("Stop Casting") { dialog, which -> endSession(true, null) } + builder.show() } - }) + } } abstract class RequestSessionCallback : ConnectionCallback { @@ -103,12 +100,10 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) { getSessionManager()!!.addSessionManagerListener(object : SessionListener() { - override fun onSessionEnded(castSession: CastSession?, error: Int) { + 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) @@ -116,78 +111,47 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic } open class SessionListener : SessionManagerListener { - 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) {} + 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") - } - } - + fun startRouteScan(connListener:ChromecastListener) { var callback = object : ScanCallback() { override fun onRouteUpdate(routes: List?) { 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); + stopRouteScan(this, null) // Let the client know a receiver is available - connListener.onReceiverAvailableUpdate(true); + connListener.onReceiverAvailableUpdate(true) // Since we have a receiver we may also have an active session - var session = getSessionManager()?.currentCastSession; + 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)); } + } else { + Log.d(tag, "No cast devices available") } } } + callback.setMediaRouter(getMediaRouter()) - callback.onFilteredRouteUpdate(); + callback.onFilteredRouteUpdate() - getMediaRouter()!!.addCallback(MediaRouteSelector.Builder() + getMediaRouter()?.addCallback(MediaRouteSelector.Builder() .addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)) .build(), callback, @@ -201,7 +165,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic fun onSessionEnd(jsonSession: JSONObject?) } - internal abstract class ChromecastListener : CastStateListener, CastListener { + abstract class ChromecastListener : CastStateListener, CastListener { abstract fun onReceiverAvailableUpdate(available: Boolean) abstract fun onSessionRejoin(jsonSession: JSONObject?) @@ -212,15 +176,19 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic } fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) { + Log.d(tag, "stopRouteScan") if (callback == null) { - completionCallback!!.run() + completionCallback?.run() return } -// ctx.runOnUiThread(Runnable { - callback.stop() - getMediaRouter()!!.removeCallback(callback) - completionCallback?.run() -// }) + +// mainActivity.runOnUiThread { + Log.d(tag, "Removing callback on media router") + callback.stop() + getMediaRouter()?.removeCallback(callback) + completionCallback?.run() +// } + } abstract class ScanCallback : MediaRouter.Callback() { @@ -271,7 +239,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic } if (!route.isDefault && !route.description.equals("Google Cast Multizone Member") - && route.playbackType === MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) { + && route.playbackType == MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) { outRoutes.add(route) } } @@ -291,87 +259,50 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic } } - inner class CustomConverter : MediaItemConverter { - override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem { - // The MediaQueueItem you build is expected to be in the tag. - var queueItem = (mediaItem.playbackProperties!!.tag as MediaQueueItem?)!! - Log.d(tag, "Test toMediaQueueItem ${queueItem.media!!.contentUrl} | ${queueItem.playbackDuration} | ${queueItem.itemId}") - return queueItem - } - - override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem { - return MediaItem.Builder() - .setUri(mediaQueueItem.media!!.contentUrl) - .setTag(mediaQueueItem) - .build() - } - } - 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?.let { ncl -> + getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java) + } newConnectionListener = object : SessionListener() { - override fun onSessionStarted(castSession: CastSession?, sessionId: String) { - Log.d(tag, "CAST SESSION STARTED ${castSession?.castDevice?.friendlyName}") + 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) + val castContext = CastContext.getSharedInstance(mainActivity) - // Work in progress using the cast api - var currentSession = playerNotificationService.getCurrentPlaybackSessionCopy() - var firstTrack = currentSession?.audioTracks?.get(0) - var uri = firstTrack?.let { currentSession?.getContentUri(it) } ?: Uri.EMPTY - var url = uri.toString() - var mimeType = firstTrack?.mimeType ?: "" - var castMediaMetadata = firstTrack?.let { currentSession?.getCastMediaMetadata(it) } - Log.d(tag, "CastManager set url $url") - var duration = (currentSession?.getTotalDuration() ?: 0L * 1000L).toLong() - - if (castMediaMetadata != null) { - Log.d(tag, "CastManager duration $duration got cast media metadata $castMediaMetadata") - - val mediaInfo = MediaInfo.Builder(url) - .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) - .setContentType(mimeType) - .setMetadata(castMediaMetadata) - .setStreamDuration(duration) - .build() - val remoteMediaClient = castSession?.remoteMediaClient - remoteMediaClient?.load(MediaLoadRequestData.Builder().setMediaInfo(mediaInfo).build()) + playerNotificationService?.let { + if (it.castPlayer == null) { + Log.d(tag, "Initializing castPlayer on session started - switch to cast player") + it.castPlayer = CastPlayer(castContext).apply { + addListener(PlayerListener(it)) + setSessionAvailabilityListener(CastSessionAvailabilityListener()) + } + switchToPlayer(true) + } else { + Log.d(tag, "castPlayer is already initialized on session started") } - - // Not working using the exo player CastPlayer -// playerNotificationService.castPlayer = CastPlayer(castContext, CustomConverter()).apply { -// setSessionAvailabilityListener(CastSessionAvailabilityListener()) -// addListener(PlayerListener(playerNotificationService)) -// } -// 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) { + override fun onSessionStartFailed(castSession: CastSession, errCode: Int) { if (callback.onSessionStartFailed(errCode)) { getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java) } } - override fun onSessionEnded(castSession: CastSession?, errCode: Int) { + override fun onSessionEnded(castSession: CastSession, errCode: Int) { if (callback.onSessionEndedBeforeStart(errCode)) { getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java) } } } - getSessionManager()?.addSessionManagerListener(newConnectionListener, CastSession::class.java) + newConnectionListener?.let { + Log.d(tag, "Add session manager listener") + getSessionManager()?.addSessionManagerListener(it, CastSession::class.java) + } } private fun getContext(): CastContext { @@ -383,7 +314,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic } private fun getMediaRouter(): MediaRouter? { - return mainActivity?.let { MediaRouter.getInstance(it) } + return MediaRouter.getInstance(mainActivity) } private fun getSession(): CastSession? { diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt new file mode 100644 index 00000000..20eca534 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt @@ -0,0 +1,983 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.os.Looper +import android.util.Log +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.C.TrackType +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.exoplayer2.Player.* +import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.source.TrackGroupArray +import com.google.android.exoplayer2.text.Cue +import com.google.android.exoplayer2.trackselection.TrackSelection +import com.google.android.exoplayer2.trackselection.TrackSelectionArray +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters +import com.google.android.exoplayer2.util.* +import com.google.android.exoplayer2.util.Util.castNonNull +import com.google.android.exoplayer2.video.VideoSize +import com.google.android.gms.cast.* +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.cast.framework.media.RemoteMediaClient +import com.google.android.gms.common.api.PendingResult +import com.google.android.gms.common.api.ResultCallback +import org.json.JSONObject + + +class CastPlayer(var castContext: CastContext) : BasePlayer() { + val tag = "CastPlayer" + + private val RENDERER_COUNT = 3 + private val RENDERER_INDEX_VIDEO = 0 + private val RENDERER_INDEX_AUDIO = 1 + private val RENDERER_INDEX_TEXT = 2 + private val PROGRESS_REPORT_PERIOD_MS: Long = 1000 + private val EMPTY_TRACK_SELECTION_ARRAY = TrackSelectionArray() + private val EMPTY_TRACK_ID_ARRAY = LongArray(0) + + private val seekBackIncrementMs: Long = 5000L + private val seekForwardIncrementMs: Long = 5000L + private var myPlayWhenReady:Boolean = false + private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT + private var myAvailableCommands: Commands + private var myCurrentTracksInfo: TracksInfo + private var myCurrentTrackGroups: TrackGroupArray + private var myCurrentTrackSelections: TrackSelectionArray + + var currentMediaItems:List = mutableListOf() + var remoteMediaClient:RemoteMediaClient? = null + var sessionAvailabilityListener:SessionAvailabilityListener? = null + var myCurrentTimeline: CastTimeline + val timelineTracker: CastTimelineTracker + val period: Timeline.Period + var listeners: ListenerSet + + /* package */ + val PERMANENT_AVAILABLE_COMMANDS = Commands.Builder() + .addAll( + COMMAND_PLAY_PAUSE, + COMMAND_PREPARE, + COMMAND_STOP, + COMMAND_SEEK_TO_DEFAULT_POSITION, + COMMAND_SEEK_TO_MEDIA_ITEM, + COMMAND_SET_REPEAT_MODE, + COMMAND_SET_SPEED_AND_PITCH, + COMMAND_GET_CURRENT_MEDIA_ITEM, + COMMAND_GET_TIMELINE, + COMMAND_GET_MEDIA_ITEMS_METADATA, + COMMAND_SET_MEDIA_ITEMS_METADATA, + COMMAND_CHANGE_MEDIA_ITEMS, + COMMAND_GET_TRACK_INFOS) + .build() + + var currentPlaybackState = Player.STATE_IDLE + var statusListener:StatusListener + + val deviceInfo get() = castContext.sessionManager.currentCastSession?.castDevice.toString() + var lastReportedPositionMs = 0L + + private var currentMediaItemIndex = 0 + private var pendingMediaItemRemovalPosition:PositionInfo? = null + private var pendingSeekCount = 0 + private var pendingSeekWindowIndex = 0 + private var pendingSeekPositionMs = 0L + + init { + Log.d(tag, "Init CastPlayer") + myCurrentTrackGroups = TrackGroupArray.EMPTY + myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY + myCurrentTracksInfo = TracksInfo.EMPTY + statusListener = StatusListener() + timelineTracker = CastTimelineTracker() + myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE + period = Timeline.Period() + myAvailableCommands = Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build() + + listeners = ListenerSet( + Looper.getMainLooper(), + Clock.DEFAULT + ) { listener: Player.Listener, flags: FlagSet? -> listener.onEvents(this, Player.Events(flags ?: FlagSet.Builder().build())) } + + val sessionManager = castContext.sessionManager + sessionManager.addSessionManagerListener(statusListener, CastSession::class.java) + val session = sessionManager.currentCastSession + setRemoteMediaClient(session?.remoteMediaClient) + } + + fun load(mediaItems:List, startIndex:Int, startTime:Long, playWhenReady:Boolean, playbackRate:Float, mediaType:String) { + Log.d(tag, "Load called") + + if (remoteMediaClient == null) { + Log.d(tag, "Remote Media Client not set") + return + } + + currentMediaItems = mediaItems + + var mediaQueueItems = mediaItems.map { toMediaQueueItem(it) } + + var queueData = MediaQueueData.Builder().apply { + setItems(mediaQueueItems) + setQueueType(if (mediaType == "book") MediaQueueData.MEDIA_QUEUE_TYPE_AUDIO_BOOK else MediaQueueData.MEDIA_QUEUE_TYPE_PODCAST_SERIES) + setRepeatMode(MediaStatus.REPEAT_MODE_REPEAT_OFF) + setStartIndex(startIndex) + setStartTime(startTime) + }.build() + + var loadRequestData = MediaLoadRequestData.Builder().apply { + setPlaybackRate(playbackRate.toDouble()) + setQueueData(queueData) + setAutoplay(playWhenReady) + setCurrentTime(startTime) + }.build() + + remoteMediaClient?.load(loadRequestData)?.setResultCallback { + Log.d(tag, "Loaded cast player result ${it.status} | ${it.mediaError} | ${it.customData}") + } + + Log.d(tag, "Loaded cast player request data $loadRequestData") + } + + private fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem { + // The MediaQueueItem you build is expected to be in the tag. + return (mediaItem.playbackProperties!!.tag as MediaQueueItem?)!! + } + + @JvmName("setRemoteMediaClient1") + private fun setRemoteMediaClient(remoteMediaClient: RemoteMediaClient?) { + if (this.remoteMediaClient === remoteMediaClient) { + // Do nothing. + return + } + if (this.remoteMediaClient != null) { + this.remoteMediaClient?.unregisterCallback(statusListener) + this.remoteMediaClient?.removeProgressListener(statusListener) + } + this.remoteMediaClient = remoteMediaClient + if (remoteMediaClient != null) { + sessionAvailabilityListener?.let { + it.onCastSessionAvailable() + } + + remoteMediaClient.registerCallback(statusListener) + remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS) + updateInternalStateAndNotifyIfChanged() + } else { + updateTimelineAndNotifyIfChanged() + sessionAvailabilityListener?.let { + it.onCastSessionUnavailable() + } + } + } + + private fun setPlayerStateAndNotifyIfChanged( + playWhenReady: Boolean, + @PlayWhenReadyChangeReason playWhenReadyChangeReason: Int, + @Player.State playbackState: Int) { + + val wasPlaying = this.currentPlaybackState == STATE_READY && this.playWhenReady + val playWhenReadyChanged = this.playWhenReady != playWhenReady + val playbackStateChanged = this.currentPlaybackState != playbackState + + Log.d(tag, "setPlayerStateAndNotifyIfChanged newPlayWhenReady:$playWhenReady | playbackStateChanged:$playbackStateChanged | playWhenReadyChanged:$playWhenReadyChanged") + if (playWhenReadyChanged || playbackStateChanged) { + this.currentPlaybackState = playbackState + this.myPlayWhenReady = playWhenReady + + if (playbackStateChanged) { + Log.d(tag, "CastPlayer About to emit onPlaybackStateChanged") + listeners.queueEvent(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(playbackState) + } + } + + if (playWhenReadyChanged) { + listeners.queueEvent(EVENT_PLAY_WHEN_READY_CHANGED) { + it.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason) + } + } + + val isPlaying = playbackState == STATE_READY && playWhenReady + if (wasPlaying != isPlaying) { + Log.d(tag, "CastPlayer About to emit onIsPlayingChanged $isPlaying") + listeners.queueEvent(EVENT_IS_PLAYING_CHANGED) { + it.onIsPlayingChanged(isPlaying) + } + } else { + Log.d(tag, "Is playing and was playing are equal $wasPlaying") + } + } else { + Log.d(tag, "setPlayerStateAndNotifyIfChanged No changes") + } + listeners.flushEvents() + } + + private fun updatePlayerStateAndNotifyIfChanged() { + var newPlayWhenReadyValue = remoteMediaClient?.isPaused != true + + @PlayWhenReadyChangeReason val playWhenReadyChangeReason = if (newPlayWhenReadyValue != playWhenReady) PLAY_WHEN_READY_CHANGE_REASON_REMOTE else PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST + // We do not mask the playback state, so try setting it regardless of the playWhenReady masking. + setPlayerStateAndNotifyIfChanged( + newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient)) + } + + private fun fetchPlaybackState(remoteMediaClient: RemoteMediaClient?): Int { + return when (remoteMediaClient?.playerState) { + MediaStatus.PLAYER_STATE_BUFFERING -> STATE_BUFFERING + MediaStatus.PLAYER_STATE_PLAYING, MediaStatus.PLAYER_STATE_PAUSED -> STATE_READY + MediaStatus.PLAYER_STATE_IDLE, MediaStatus.PLAYER_STATE_UNKNOWN -> STATE_IDLE + else -> STATE_IDLE + } + } + + private fun updatePlaybackRateAndNotifyIfChanged() { + val mediaStatus = remoteMediaClient!!.mediaStatus + val speed = mediaStatus?.playbackRate?.toFloat() ?: PlaybackParameters.DEFAULT.speed + if (speed > 0.0f) { + // Set the speed if not paused. + setPlaybackParametersAndNotifyIfChanged(PlaybackParameters(speed)) + } + } + + private fun setPlaybackParametersAndNotifyIfChanged(playbackParameters: PlaybackParameters) { + if (this.playbackParameters == playbackParameters) { + return + } + this.playbackParameters = playbackParameters + listeners.queueEvent( + EVENT_PLAYBACK_PARAMETERS_CHANGED + ) { listener -> listener.onPlaybackParametersChanged(playbackParameters) } + listeners.flushEvents() + updateAvailableCommandsAndNotifyIfChanged() + } + + private fun updateTimelineAndNotifyIfChanged(): Boolean { + val oldTimeline: Timeline = this.myCurrentTimeline + val oldWindowIndex = currentMediaItemIndex + var playingPeriodChanged = false + if (updateTimeline()) { + val timeline: Timeline = this.myCurrentTimeline + // Call onTimelineChanged. + listeners.queueEvent( + EVENT_TIMELINE_CHANGED + ) { listener: Player.Listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) } + + // Call onPositionDiscontinuity if required. + val currentTimeline = currentTimeline + var playingPeriodRemoved = false + if (!oldTimeline.isEmpty) { + val oldPeriodUid = castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid) + playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET + } + if (playingPeriodRemoved) { + var oldPosition: PositionInfo = getCurrentPositionInfo() + if (pendingMediaItemRemovalPosition != null) { + pendingMediaItemRemovalPosition?.let { + oldPosition = it + } + pendingMediaItemRemovalPosition = null + } else { + // If the media item has been removed by another client, we don't know the removal + // position. We use the current position as a fallback. + oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true) + oldTimeline.getWindow(period.windowIndex, window) + oldPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, + currentPosition, + contentPosition, + C.INDEX_UNSET,/* adGroupIndex= */ + C.INDEX_UNSET) /* adIndexInAdGroup= */ + } + + val newPosition: PositionInfo = getCurrentPositionInfo() + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Player.Listener -> + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE) + } + } + + // Call onMediaItemTransition if required. + playingPeriodChanged = currentTimeline.isEmpty != oldTimeline.isEmpty || playingPeriodRemoved + if (playingPeriodChanged) { + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Player.Listener -> + listener.onMediaItemTransition( + currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + } + } + updateAvailableCommandsAndNotifyIfChanged() + } + return playingPeriodChanged + } + + private fun updateInternalStateAndNotifyIfChanged() { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return + } + + val oldWindowIndex = this.currentMediaItemIndex + val oldPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid else null + updatePlayerStateAndNotifyIfChanged() +// updateRepeatModeAndNotifyIfChanged( /* resultCallback= */null) + updatePlaybackRateAndNotifyIfChanged() + val playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged() + val currentTimeline = currentTimeline + currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline) + val currentPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true).uid else null + if (!playingPeriodChangedByTimelineChange + && !Util.areEqual(oldPeriodUid, currentPeriodUid) + && pendingSeekCount == 0) { + // Report discontinuity and media item auto transition. + currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true) + currentTimeline.getWindow(oldWindowIndex, window) + val windowDurationMs = window.durationMs + val oldPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, /* positionMs= */ + windowDurationMs, /* contentPositionMs= */ + windowDurationMs, /* adGroupIndex= */ + C.INDEX_UNSET, /* adIndexInAdGroup= */ + C.INDEX_UNSET) + currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true) + currentTimeline.getWindow(currentMediaItemIndex, window) + val newPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, /* positionMs= */ + window.defaultPositionMs, /* contentPositionMs= */ + window.defaultPositionMs, /* adGroupIndex= */ + C.INDEX_UNSET, /* adIndexInAdGroup= */ + C.INDEX_UNSET) + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Listener -> + listener.onPositionDiscontinuity(DISCONTINUITY_REASON_AUTO_TRANSITION) + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION) + } + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Listener -> + listener.onMediaItemTransition( + currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_AUTO) + } + } + if (updateTracksAndSelectionsAndNotifyIfChanged()) { + listeners.queueEvent( + EVENT_TRACKS_CHANGED + ) { listener: Listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelections) } + listeners.queueEvent( + EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) } + } + updateAvailableCommandsAndNotifyIfChanged() + listeners.flushEvents() + } + + /** Updates the internal tracks and selection and returns whether they have changed. */ + @SuppressLint("WrongConstant") + private fun updateTracksAndSelectionsAndNotifyIfChanged(): Boolean { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return false + } + val mediaStatus = getMediaStatus() + val mediaInfo = mediaStatus?.mediaInfo + val castMediaTracks = mediaInfo?.mediaTracks + if (castMediaTracks == null || castMediaTracks.isEmpty()) { + val hasChanged = !myCurrentTrackGroups.isEmpty + myCurrentTrackGroups = TrackGroupArray.EMPTY + myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY + myCurrentTracksInfo = TracksInfo.EMPTY + return hasChanged + } + var activeTrackIds = mediaStatus.activeTrackIds + if (activeTrackIds == null) { + activeTrackIds = EMPTY_TRACK_ID_ARRAY + } + val trackGroups = arrayOfNulls(castMediaTracks.size) + val trackSelections = arrayOfNulls(RENDERER_COUNT) + val trackGroupInfos = arrayOfNulls(castMediaTracks.size) + for (i in castMediaTracks.indices) { + val mediaTrack = castMediaTracks[i] + trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack)) + val id = mediaTrack.id + val trackType = MimeTypes.getTrackType(mediaTrack.contentType) + val rendererIndex: Int = getRendererIndexForTrackType(trackType) + val supported = rendererIndex != C.INDEX_UNSET + val selected = isTrackActive(id, activeTrackIds) && supported && trackSelections[rendererIndex] == null + if (selected) { + trackSelections[rendererIndex] = trackGroups[i]?.let { CastTrackSelection(it) } + } + val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE) + val trackSelected = booleanArrayOf(selected) + trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected) + } + + val tg = trackGroups.filterNotNull().toTypedArray() + var ts = trackSelections.filterNotNull().toTypedArray() + var tgi = trackGroupInfos.filterNotNull().toMutableList() + val newTrackGroups = TrackGroupArray(*tg) + val newTrackSelections = TrackSelectionArray(*ts) + val newTracksInfo = TracksInfo(tgi) + if (newTrackGroups != currentTrackGroups + || newTrackSelections != currentTrackSelections + || newTracksInfo != currentTracksInfo) { + myCurrentTrackSelections = newTrackSelections + myCurrentTrackGroups = newTrackGroups + myCurrentTracksInfo = newTracksInfo + return true + } + return false + } + + /** + * Creates a [Format] instance containing all information contained in the given [ ] object. + * + * @param mediaTrack The [MediaTrack]. + * @return The equivalent [Format]. + */ + fun mediaTrackToFormat(mediaTrack: MediaTrack): Format { + return Format.Builder() + .setId(mediaTrack.contentId) + .setContainerMimeType(mediaTrack.contentType) + .setLanguage(mediaTrack.language) + .build() + } + + private fun getRendererIndexForTrackType(trackType: @TrackType Int): Int { + return if (trackType == C.TRACK_TYPE_VIDEO) RENDERER_INDEX_VIDEO else if (trackType == C.TRACK_TYPE_AUDIO) RENDERER_INDEX_AUDIO else if (trackType == C.TRACK_TYPE_TEXT) RENDERER_INDEX_TEXT else C.INDEX_UNSET + } + + private fun isTrackActive(id: Long, activeTrackIds: LongArray): Boolean { + for (activeTrackId in activeTrackIds) { + if (activeTrackId == id) { + return true + } + } + return false + } + + private fun getCurrentPositionInfo(): PositionInfo { + val currentTimeline = currentTimeline + var newPeriodUid: Any? = null + var newWindowUid: Any? = null + var newMediaItem: MediaItem? = null + if (!currentTimeline.isEmpty) { + newPeriodUid = currentTimeline.getPeriod(currentPeriodIndex, period, /* setIds= */true).uid + newWindowUid = currentTimeline.getWindow(period.windowIndex, window).uid + newMediaItem = window.mediaItem + } + return PositionInfo( + newWindowUid, + currentMediaItemIndex, + newMediaItem, + newPeriodUid, + currentPeriodIndex, + currentPosition, + contentPosition, + C.INDEX_UNSET,/* adGroupIndex= */ + C.INDEX_UNSET) /* adIndexInAdGroup= */ + } + + private fun updateTimeline(): Boolean { + val oldTimeline = this.myCurrentTimeline + val status: MediaStatus? = getMediaStatus() + + remoteMediaClient?.let { + this.myCurrentTimeline = if (status != null) timelineTracker.getCastTimeline(it) else CastTimeline.EMPTY_CAST_TIMELINE + } + + val timelineChanged = oldTimeline != this.myCurrentTimeline + if (timelineChanged) { + currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, this.myCurrentTimeline) + Log.d(tag, "timelineChanged $currentMediaItemIndex") + } + return timelineChanged + } + + private fun fetchCurrentWindowIndex(remoteMediaClient: RemoteMediaClient?, timeline: Timeline): Int { + if (remoteMediaClient == null) { + return 0 + } + var currentWindowIndex = C.INDEX_UNSET + val currentItem = remoteMediaClient.currentItem + if (currentItem != null) { + currentWindowIndex = timeline.getIndexOfPeriod(currentItem.itemId) + } + if (currentWindowIndex == C.INDEX_UNSET) { + // The timeline is empty. Fall back to index 0. + currentWindowIndex = 0 + } + return currentWindowIndex + } + + private fun getMediaStatus():MediaStatus? { + return remoteMediaClient?.mediaStatus + } + + @JvmName("setSessionAvailabilityListener1") + fun setSessionAvailabilityListener(listener: SessionAvailabilityListener) { + sessionAvailabilityListener = listener + } + + override fun getApplicationLooper(): Looper { + return Looper.getMainLooper() + } + + override fun addListener(listener: Player.Listener) { + listeners.add(listener) + Log.d(tag, "addListener player listener $listener") + } + + override fun removeListener(listener: Player.Listener) { + listeners.remove(listener) + } + + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + } + + override fun setMediaItems(mediaItems: MutableList, startWindowIndex: Int, startPositionMs: Long) { + } + + override fun addMediaItems(index: Int, mediaItems: MutableList) { + } + + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) { + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + Log.d(tag, "removeMediaItems called not configured yet $fromIndex to $toIndex") + } + + override fun getAvailableCommands(): Player.Commands { + return myAvailableCommands + } + + override fun prepare() { + } + + override fun getPlaybackState(): Int { + return currentPlaybackState + } + + override fun getPlaybackSuppressionReason(): Int { + return Player.PLAYBACK_SUPPRESSION_REASON_NONE + } + + override fun getPlayerError(): PlaybackException? { + return null + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + setPlayerStateAndNotifyIfChanged( + playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState) + + listeners.flushEvents() + val pendingResult: PendingResult = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause() + + var resultCb = ResultCallback { + updatePlayerStateAndNotifyIfChanged() + } + + pendingResult.setResultCallback(resultCb) + } + + override fun getPlayWhenReady(): Boolean { + return myPlayWhenReady + } + + override fun setRepeatMode(repeatMode: Int) { + } + + override fun getRepeatMode(): Int { + return Player.REPEAT_MODE_OFF + } + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + } + + override fun getShuffleModeEnabled(): Boolean { + return false + } + + override fun isLoading(): Boolean { + return false + } + + override fun seekTo(mediaItemIndex: Int, positionMs: Long) { + Log.d(tag, "seekTo $mediaItemIndex position $positionMs") + + var resultCb = ResultCallback { + val statusCode: Int = it.status.statusCode + if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) { + Log.e(tag, "Seek failed. Error code $statusCode") + } + if (--pendingSeekCount == 0) { + currentMediaItemIndex = pendingSeekWindowIndex + Log.d(tag, "seekTo CB $currentMediaItemIndex") + pendingSeekWindowIndex = C.INDEX_UNSET + pendingSeekPositionMs = C.TIME_UNSET + + + listeners.sendEvent( /* eventFlag= */C.INDEX_UNSET) { obj: Player.Listener -> obj.onSeekProcessed() } + // Playback state change will send metadata to client and stop seek loading + listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) } + } + } + + val mediaStatus = getMediaStatus() + + // We assume the default position is 0. There is no support for seeking to the default position + // in RemoteMediaClient. + var positionMs = if (positionMs != C.TIME_UNSET) positionMs else 0 + if (mediaStatus != null) { + if (currentMediaItemIndex != mediaItemIndex) { + Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex") + remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMs, JSONObject())?.setResultCallback(resultCb) + } else { + Log.d(tag, "seekTo: Same media index seek to position $positionMs") + var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMs).build() + remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb) + } + val oldPosition = getCurrentPositionInfo() + pendingSeekCount++ + pendingSeekWindowIndex = mediaItemIndex + pendingSeekPositionMs = positionMs + val newPosition = getCurrentPositionInfo() + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Player.Listener -> + listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK) + } + if (oldPosition.windowIndex != newPosition.windowIndex) { + val mediaItem = currentTimeline.getWindow(mediaItemIndex, window).mediaItem + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Player.Listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK) } + } + updateAvailableCommandsAndNotifyIfChanged() + } else if (pendingSeekCount == 0) { + Log.w(tag, "seekTo Media Status is null") + listeners.queueEvent( /* eventFlag= */C.INDEX_UNSET) { obj: Player.Listener -> obj.onSeekProcessed() } + } + listeners.flushEvents() + } + + private fun updateAvailableCommandsAndNotifyIfChanged() { + val previousAvailableCommands = availableCommands + myAvailableCommands = Util.getAvailableCommands(this,PERMANENT_AVAILABLE_COMMANDS) + if (availableCommands != previousAvailableCommands) { + listeners.queueEvent( + EVENT_AVAILABLE_COMMANDS_CHANGED + ) { listener: Listener -> listener.onAvailableCommandsChanged(availableCommands) } + } + } + + override fun getSeekBackIncrement(): Long { + return seekBackIncrementMs + } + + override fun getSeekForwardIncrement(): Long { + return seekForwardIncrementMs + } + + override fun getMaxSeekToPreviousPosition(): Long { + return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS + } + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + val actualPlaybackParameters = PlaybackParameters(playbackParameters.speed) + setPlaybackParametersAndNotifyIfChanged(actualPlaybackParameters) + listeners.flushEvents() + + val pendingResult: PendingResult? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null) + + var resultCb = ResultCallback { + updatePlaybackRateAndNotifyIfChanged() + listeners.flushEvents() + } + pendingResult?.setResultCallback(resultCb) + } + + override fun getPlaybackParameters(): PlaybackParameters { + return playbackParameters + } + + override fun stop() { + Log.d(tag, "stop CastPlayer") + currentPlaybackState = Player.STATE_IDLE + remoteMediaClient?.stop() + } + + override fun stop(reset: Boolean) { + stop() + } + + override fun release() { + val sessionManager = castContext.sessionManager + sessionManager.removeSessionManagerListener(statusListener, CastSession::class.java) + sessionManager.endCurrentSession(false) + } + + override fun getCurrentTrackGroups(): TrackGroupArray { + return this.myCurrentTrackGroups + } + + override fun getCurrentTrackSelections(): TrackSelectionArray { + return this.myCurrentTrackSelections + } + + override fun getCurrentTracksInfo(): TracksInfo { + return this.myCurrentTracksInfo + } + + override fun getTrackSelectionParameters(): TrackSelectionParameters { + return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + } + + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {} + + override fun getMediaMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun getPlaylistMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { + } + + override fun getCurrentTimeline(): Timeline { + return this.myCurrentTimeline + } + + override fun getCurrentPeriodIndex(): Int { + return getCurrentMediaItemIndex() + } + + override fun getCurrentMediaItemIndex(): Int { + return if (pendingSeekWindowIndex != C.INDEX_UNSET) pendingSeekWindowIndex else currentMediaItemIndex + } + + override fun getDuration(): Long { + return contentDuration + } + + override fun getCurrentPosition(): Long { + return remoteMediaClient?.approximateStreamPosition ?: 0L + } + + override fun getBufferedPosition(): Long { + return currentPosition + } + + override fun getTotalBufferedDuration(): Long { + return 0L + } + + override fun isPlayingAd(): Boolean { + return false + } + + override fun getCurrentAdGroupIndex(): Int { + return 0 + } + + override fun getCurrentAdIndexInAdGroup(): Int { + return 0 + } + + override fun getContentPosition(): Long { + return currentPosition + } + + override fun getContentBufferedPosition(): Long { + return currentPosition + } + + override fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.DEFAULT + } + + override fun setVolume(audioVolume: Float) { + + } + + override fun getVolume(): Float { + return 0F + } + + override fun clearVideoSurface() { + } + + override fun clearVideoSurface(surface: Surface?) { + } + + override fun setVideoSurface(surface: Surface?) { + } + + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + } + + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + } + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + } + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + } + + override fun setVideoTextureView(textureView: TextureView?) { + + } + + override fun clearVideoTextureView(textureView: TextureView?) { + + } + + override fun getVideoSize(): VideoSize { + return VideoSize.UNKNOWN + } + + override fun getCurrentCues(): MutableList { + return mutableListOf() + } + + override fun getDeviceInfo(): DeviceInfo { + return DeviceInfo.UNKNOWN + } + + override fun getDeviceVolume(): Int { + return 0 + } + + override fun isDeviceMuted(): Boolean { + return false + } + + override fun setDeviceVolume(volume: Int) { + + } + + override fun increaseDeviceVolume() { + + } + + override fun decreaseDeviceVolume() { + + } + + override fun setDeviceMuted(muted: Boolean) { + + } + + inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener, RemoteMediaClient.ProgressListener { + val TAG = "StatusListener" + + // RemoteMediaClient.ProgressListener implementation. + override fun onProgressUpdated(progressMs: Long, unusedDurationMs: Long) { + lastReportedPositionMs = progressMs + } + + // RemoteMediaClient.Callback implementation. + override fun onStatusUpdated() { + Log.d(TAG, "StatusListener status queue items " + remoteMediaClient?.mediaStatus?.queueItemCount) + if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_IDLE) { + Log.d(TAG, "StatusListener CastPlayer STATE_IDLE") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_BUFFERING) { + Log.d(TAG, "StatusListener CastPlayer BUFFERING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_LOADING) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_LOADING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PAUSED) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PAUSED") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PLAYING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_UNKNOWN) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_UNKNOWN") + } + + updateInternalStateAndNotifyIfChanged() + } + + override fun onMetadataUpdated() {} + override fun onQueueStatusUpdated() { + Log.d(TAG, "onQueueStatusUpdated") + updateTimelineAndNotifyIfChanged() + listeners.flushEvents() + } + + override fun onPreloadStatusUpdated() {} + override fun onSendingRemoteMediaRequest() {} + override fun onAdBreakStatusUpdated() {} + + // SessionManagerListener implementation. + override fun onSessionStarted(castSession: CastSession, s: String) { + Log.d(TAG, "StatusListener onSessionStarted") + setRemoteMediaClient(castSession.remoteMediaClient) + } + + override fun onSessionResumed(castSession: CastSession, b: Boolean) { + setRemoteMediaClient(castSession.remoteMediaClient) + } + + override fun onSessionEnded(castSession: CastSession, i: Int) { + Log.d(TAG, "StatusListener onSessionEnded") + setRemoteMediaClient(null) + } + + override fun onSessionSuspended(castSession: CastSession, i: Int) { + setRemoteMediaClient(null) + } + + override fun onSessionResumeFailed(castSession: CastSession, statusCode: Int) { + Log.e( + TAG, + "Session resume failed. Error code " + + statusCode) + } + + override fun onSessionStarting(castSession: CastSession) { + Log.d(TAG, "StatusListener onSessionStarting") + // Do nothing. + } + + override fun onSessionStartFailed(castSession: CastSession, statusCode: Int) { + Log.e( + TAG, + ("Session start failed. Error code " + + statusCode)) + } + + override fun onSessionEnding(castSession: CastSession) { + // Do nothing. + } + + override fun onSessionResuming(castSession: CastSession, s: String) { + // Do nothing. + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt new file mode 100644 index 00000000..a241547c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.audiobookshelf.app.player + +import android.net.Uri +import android.util.Log +import android.util.SparseArray +import android.util.SparseIntArray +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Timeline +import java.util.* + +/** A [Timeline] for Cast media queues. */ /* package */ +class CastTimeline(itemIds: IntArray, itemIdToData: SparseArray) : Timeline() { + /** Holds [Timeline] related data for a Cast media item. */ + class ItemData( + /** The duration of the item in microseconds, or [C.TIME_UNSET] if unknown. */ + val durationUs: Long, + /** + * The default start position of the item in microseconds, or [C.TIME_UNSET] if unknown. + */ + var defaultPositionUs: Long, isLive: Boolean) { + /** Whether the item is live content, or `false` if unknown. */ + val isLive: Boolean = isLive + + /** + * Returns a copy of this instance with the given values. + * + * @param durationUs The duration in microseconds, or [C.TIME_UNSET] if unknown. + * @param defaultPositionUs The default start position in microseconds, or [C.TIME_UNSET] + * if unknown. + * @param isLive Whether the item is live, or `false` if unknown. + */ + fun copyWithNewValues(durationUs: Long, defaultPositionUs: Long, isLive: Boolean): ItemData { + return if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs && isLive == this.isLive) { + this + } else ItemData(durationUs, defaultPositionUs, isLive) + } + + companion object { + /** Holds no media information. */ + val EMPTY = ItemData( /* durationUs= */ + C.TIME_UNSET, /* defaultPositionUs= */ + C.TIME_UNSET, /* isLive= */ + false) + } + + } + + private val idsToIndex: SparseIntArray + private val ids: IntArray + private val durationsUs: LongArray + private val defaultPositionsUs: LongArray + private val isLive: BooleanArray + + // Timeline implementation. + override fun getWindowCount(): Int { + return ids.size + } + + override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window { + val durationUs = durationsUs[windowIndex] + val isDynamic = durationUs == C.TIME_UNSET + val mediaItem = MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build() + return window.set( /* uid= */ + ids[windowIndex], /* mediaItem= */ + mediaItem, /* manifest= */ + null, /* presentationStartTimeMs= */ + C.TIME_UNSET, /* windowStartTimeMs= */ + C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */ + C.TIME_UNSET, /* isSeekable= */ + !isDynamic, + isDynamic, + if (isLive[windowIndex]) mediaItem.liveConfiguration else null, + defaultPositionsUs[windowIndex], + durationUs, /* firstPeriodIndex= */ + windowIndex, /* lastPeriodIndex= */ + windowIndex, /* positionInFirstPeriodUs= */ + 0) + } + + override fun getPeriodCount(): Int { + return ids.size + } + + override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period { + val id = ids[periodIndex] + return period.set(id, id, periodIndex, durationsUs[periodIndex], 0) + } + + override fun getIndexOfPeriod(uid: Any): Int { + return if (uid is Int) idsToIndex[uid, C.INDEX_UNSET] else C.INDEX_UNSET + } + + override fun getUidOfPeriod(periodIndex: Int): Int { + return ids[periodIndex] + } + + // equals and hashCode implementations. + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } else if (other !is CastTimeline) { + return false + } + val that = other + return (Arrays.equals(ids, that.ids) + && Arrays.equals(durationsUs, that.durationsUs) + && Arrays.equals(defaultPositionsUs, that.defaultPositionsUs) + && Arrays.equals(isLive, that.isLive)) + } + + override fun hashCode(): Int { + var result = Arrays.hashCode(ids) + result = 31 * result + Arrays.hashCode(durationsUs) + result = 31 * result + Arrays.hashCode(defaultPositionsUs) + result = 31 * result + Arrays.hashCode(isLive) + return result + } + + companion object { + /** [Timeline] for a cast queue that has no items. */ + val EMPTY_CAST_TIMELINE = CastTimeline(IntArray(0), SparseArray()) + } + + /** + * Creates a Cast timeline from the given data. + * + * @param itemIds The ids of the items in the timeline. + * @param itemIdToData Maps item ids to [ItemData]. + */ + init { + val itemCount = itemIds.size + idsToIndex = SparseIntArray(itemCount) + ids = itemIds.copyOf(itemCount) + durationsUs = LongArray(itemCount) + defaultPositionsUs = LongArray(itemCount) + isLive = BooleanArray(itemCount) + for (i in ids.indices) { + val id = ids[i] + idsToIndex.put(id, i) + val data = itemIdToData[id, ItemData.EMPTY] + durationsUs[i] = data.durationUs + defaultPositionsUs[i] = if (data.defaultPositionUs == C.TIME_UNSET) 0 else data.defaultPositionUs + isLive[i] = data.isLive + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt new file mode 100644 index 00000000..56d986e7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.audiobookshelf.app.player + +import android.util.SparseArray +import com.google.android.exoplayer2.C +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.framework.media.RemoteMediaClient + +/** + * Creates [CastTimelines][CastTimeline] from cast receiver app status updates. + * + * + * This class keeps track of the duration reported by the current item to fill any missing + * durations in the media queue items [See internal: b/65152553]. + */ +/* package */ +class CastTimelineTracker { + private val itemIdToData: SparseArray + + /** + * Returns a [CastTimeline] that represents the state of the given `remoteMediaClient`. + * + * + * Returned timelines may contain values obtained from `remoteMediaClient` in previous + * invocations of this method. + * + * @param remoteMediaClient The Cast media client. + * @return A [CastTimeline] that represents the given `remoteMediaClient` status. + */ + fun getCastTimeline(remoteMediaClient: RemoteMediaClient): CastTimeline { + val itemIds = remoteMediaClient.mediaQueue.itemIds + if (itemIds.size > 0) { + // Only remove unused items when there is something in the queue to avoid removing all entries + // if the remote media client clears the queue temporarily. See [Internal ref: b/128825216]. + removeUnusedItemDataEntries(itemIds) + } + + // TODO: Reset state when the app instance changes [Internal ref: b/129672468]. + val mediaStatus = remoteMediaClient.mediaStatus + ?: return CastTimeline.EMPTY_CAST_TIMELINE + val currentItemId = mediaStatus.currentItemId + updateItemData( + currentItemId, mediaStatus.mediaInfo, /* defaultPositionUs= */C.TIME_UNSET) + for (item in mediaStatus.queueItems) { + val defaultPositionUs = (item.startTime * C.MICROS_PER_SECOND).toLong() + updateItemData(item.itemId, item.media, defaultPositionUs) + } + return CastTimeline(itemIds, itemIdToData) + } + + private fun updateItemData(itemId: Int, mediaInfo: MediaInfo?, defaultPositionUs: Long) { + var defaultPositionUs = defaultPositionUs + val previousData = itemIdToData[itemId, CastTimeline.ItemData.EMPTY] + var durationUs = getStreamDurationUs(mediaInfo) + if (durationUs == C.TIME_UNSET) { + durationUs = previousData.durationUs + } + val isLive = if (mediaInfo == null) previousData.isLive else mediaInfo.streamType == MediaInfo.STREAM_TYPE_LIVE + if (defaultPositionUs == C.TIME_UNSET) { + defaultPositionUs = previousData.defaultPositionUs + } + itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive)) + } + + private fun getStreamDurationUs(mediaInfo: MediaInfo?): Long { + if (mediaInfo == null) { + return C.TIME_UNSET + } + val durationMs = mediaInfo.streamDuration + return if (durationMs != MediaInfo.UNKNOWN_DURATION) msToUs(durationMs) else C.TIME_UNSET + } + + fun msToUs(timeMs: Long): Long { + return if (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) timeMs else timeMs * 1000 + } + + private fun removeUnusedItemDataEntries(itemIds: IntArray) { + val scratchItemIds = HashSet( /* initialCapacity= */itemIds.size * 2) + for (id in itemIds) { + scratchItemIds.add(id) + } + var index = 0 + while (index < itemIdToData.size()) { + if (!scratchItemIds.contains(itemIdToData.keyAt(index))) { + itemIdToData.removeAt(index) + } else { + index++ + } + } + } + + init { + itemIdToData = SparseArray() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt new file mode 100644 index 00000000..33f4d84c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.audiobookshelf.app.player + +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.Format +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.trackselection.TrackSelection +import com.google.android.exoplayer2.util.Assertions + +/** + * [TrackSelection] that only selects the first track of the provided [TrackGroup]. + * + * + * This relies on [CastPlayer] track groups only having one track. + */ +/* package */ +internal class CastTrackSelection +/** @param trackGroup The [TrackGroup] from which the first track will only be selected. + */(private val trackGroup: TrackGroup) : TrackSelection { + override fun getType(): Int { + return TrackSelection.TYPE_UNSET + } + + override fun getTrackGroup(): TrackGroup { + return trackGroup + } + + override fun length(): Int { + return 1 + } + + override fun getFormat(index: Int): Format { + Assertions.checkArgument(index == 0) + return trackGroup.getFormat(0) + } + + override fun getIndexInTrackGroup(index: Int): Int { + return if (index == 0) 0 else C.INDEX_UNSET + } + + override fun indexOf(format: Format): Int { + return if (format === trackGroup.getFormat(0)) 0 else C.INDEX_UNSET + } + + override fun indexOf(indexInTrackGroup: Int): Int { + return if (indexInTrackGroup == 0) 0 else C.INDEX_UNSET + } + + // Object overrides. + override fun hashCode(): Int { + return System.identityHashCode(trackGroup) + } + + // Track groups are compared by identity not value, as distinct groups may have the same value. + override fun equals(obj: Any?): Boolean { + if (this === obj) { + return true + } + if (obj == null || javaClass != obj.javaClass) { + return false + } + val other = obj as CastTrackSelection + return trackGroup === other.trackGroup + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt index c8f4817d..1b85e79d 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -26,7 +26,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi override fun onPrepare() { Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT") playerNotificationService.mediaManager.getFirstItem()?.let { li -> - playerNotificationService.mediaManager.play(li) { + playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { Log.d(tag, "About to prepare player with li ${li.title}") Handler(Looper.getMainLooper()).post() { playerNotificationService.preparePlayer(it,true) @@ -48,7 +48,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi override fun onPlayFromSearch(query: String?, extras: Bundle?) { Log.d(tag, "ON PLAY FROM SEARCH $query") playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> - playerNotificationService.mediaManager.play(li) { + playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { Log.d(tag, "About to prepare player with li ${li.title}") Handler(Looper.getMainLooper()).post() { playerNotificationService.preparePlayer(it,true) @@ -96,7 +96,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi } libraryItem?.let { li -> - playerNotificationService.mediaManager.play(li) { + playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { Log.d(tag, "About to prepare player with li ${li.title}") Handler(Looper.getMainLooper()).post() { playerNotificationService.preparePlayer(it,true) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt index e04ec0b9..8bf3e8af 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt @@ -8,14 +8,13 @@ import android.os.ResultReceiver import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import com.audiobookshelf.app.data.LibraryItem -import com.google.android.exoplayer2.ControlDispatcher import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer { var tag = "MediaSessionPlaybackPreparer" - override fun onCommand(player: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { + override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean { Log.d(tag, "ON COMMAND $command") return false } @@ -30,7 +29,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat override fun onPrepare(playWhenReady: Boolean) { Log.d(tag, "ON PREPARE $playWhenReady") playerNotificationService.mediaManager.getFirstItem()?.let { li -> - playerNotificationService.mediaManager.play(li) { + playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { Handler(Looper.getMainLooper()).post() { playerNotificationService.preparePlayer(it,playWhenReady) } @@ -43,7 +42,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat var libraryItem: LibraryItem? = playerNotificationService.mediaManager.getById(mediaId) libraryItem?.let { li -> - playerNotificationService.mediaManager.play(li) { + playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { Log.d(tag, "About to prepare player with li ${li.title}") Handler(Looper.getMainLooper()).post() { playerNotificationService.preparePlayer(it,playWhenReady) @@ -55,7 +54,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { Log.d(tag, "ON PREPARE FROM SEARCH $query") playerNotificationService.mediaManager.getFromSearch(query)?.let { li -> - playerNotificationService.mediaManager.play(li) { + playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) { Log.d(tag, "About to prepare player with li ${li.title}") Handler(Looper.getMainLooper()).post() { playerNotificationService.preparePlayer(it,playWhenReady) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt index e5c67a17..74eca609 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -21,17 +21,21 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : } override fun onEvents(player: Player, events: Player.Events) { + Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}") + if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { Log.d(tag, "EVENT_POSITION_DISCONTINUITY") } if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) { - Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.mPlayer.isLoading.toString()) + Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.currentPlayer.isLoading) } if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + Log.d(tag, "EVENT_PLAYBACK_STATE_CHANGED MediaPlayer = ${playerNotificationService.getMediaPlayer()}") + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) { - Log.d(tag, "STATE_READY : " + playerNotificationService.mPlayer.duration.toString()) + Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration) if (lastPauseTime == 0L) { lastPauseTime = -1; @@ -39,7 +43,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : playerNotificationService.sendClientMetadata(PlayerState.READY) } if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) { - Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.mPlayer.currentPosition.toString()) + Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.currentPlayer.currentPosition) playerNotificationService.sendClientMetadata(PlayerState.BUFFERING) } if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) { @@ -53,13 +57,13 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) : } if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) { - Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED") + Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") } if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { - Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED") + Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") } if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) { - Log.d(tag, "EVENT IS PLAYING CHANGED") + Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}") if (player.isPlaying) { if (lastPauseTime > 0) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 5f461459..a7581750 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -22,19 +22,15 @@ import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.media.MediaManager import com.audiobookshelf.app.server.ApiHandler -import com.getcapacitor.JSObject import com.google.android.exoplayer2.* import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.ext.cast.CastPlayer import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator import com.google.android.exoplayer2.source.MediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource -import com.google.android.exoplayer2.ui.PlayerControlView import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.upstream.* -import okhttp3.OkHttpClient import java.util.* import kotlin.concurrent.schedule @@ -56,6 +52,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun onSleepTimerSet(sleepTimeRemaining: Int) fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) fun onPlaybackFailed(errorMessage:String) + fun onMediaPlayerChanged(mediaPlayer:String) } private val tag = "PlayerService" @@ -68,6 +65,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { private lateinit var playerNotificationManager: PlayerNotificationManager private lateinit var mediaSession: MediaSessionCompat private lateinit var transportControls:MediaControllerCompat.TransportControls + lateinit var mediaManager: MediaManager lateinit var apiHandler: ApiHandler @@ -76,7 +74,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { var castPlayer:CastPlayer? = null lateinit var sleepTimerManager:SleepTimerManager - lateinit var castManager:CastManager lateinit var mediaProgressSyncer:MediaProgressSyncer private var notificationId = 10; @@ -144,6 +141,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { override fun onDestroy() { playerNotificationManager.setPlayer(null) mPlayer.release() + castPlayer?.release() mediaSession.release() mediaProgressSyncer.reset() Log.d(tag, "onDestroy") @@ -190,9 +188,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { // Initialize sleep timer sleepTimerManager = SleepTimerManager(this) - // Initialize Cast Manager - castManager = CastManager(this) - // Initialize Media Progress Syncer mediaProgressSyncer = MediaProgressSyncer(this, apiHandler) @@ -282,7 +277,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { User callable methods */ fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean) { + playbackSession.mediaPlayer = getMediaPlayer() + + if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) { + Log.w(tag, "Cannot cast local media item - switching player") + currentPlaybackSession = null + switchToPlayer(false) + playbackSession.mediaPlayer = getMediaPlayer() + } + + if (playbackSession.mediaPlayer == "cast-player") { + // If cast-player is the first player to be used + mediaSessionConnector.setPlayer(castPlayer) + playerNotificationManager.setPlayer(castPlayer) + } + currentPlaybackSession = playbackSession + Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}") clientEventEmitter?.onPlaybackSession(playbackSession) @@ -309,39 +320,45 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } mPlayer.setMediaSource(mediaSource) + + // Add remaining media items if multi-track + if (mediaItems.size > 1) { + currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size)) + Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}") + + var currentTrackIndex = playbackSession.getCurrentTrackIndex() + var currentTrackTime = playbackSession.getCurrentTrackTimeMs() + Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime") + currentPlayer.seekTo(currentTrackIndex, currentTrackTime) + } else { + currentPlayer.seekTo(playbackSession.currentTimeMs) + } + + Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}") + currentPlayer.playWhenReady = playWhenReady + currentPlayer.setPlaybackSpeed(1f) // TODO: Playback speed should come from settings + currentPlayer.prepare() + } else if (castPlayer != null) { - castPlayer?.addMediaItem(mediaItems[0]) // TODO: Media items never actually get added, not sure what is going on.... - Log.d(tag, "Cast Player ADDED MEDIA ITEM ${castPlayer?.currentMediaItem} | ${castPlayer?.duration} | ${castPlayer?.mediaItemCount}") - } - - // Add remaining media items if multi-track - if (mediaItems.size > 1) { - currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size)) - Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}") - var currentTrackIndex = playbackSession.getCurrentTrackIndex() var currentTrackTime = playbackSession.getCurrentTrackTimeMs() - Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime") - currentPlayer.seekTo(currentTrackIndex, currentTrackTime) - } else { - currentPlayer.seekTo(playbackSession.currentTimeMs) - } + var mediaType = playbackSession.mediaType + Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType") - Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}") - currentPlayer.playWhenReady = playWhenReady - currentPlayer.setPlaybackSpeed(1f) // TODO: Playback speed should come from settings - currentPlayer.prepare() + castPlayer?.load(mediaItems, currentTrackIndex, currentTrackTime, playWhenReady, 1f, mediaType) + } } fun handlePlayerPlaybackError(errorMessage:String) { // On error and was attempting to direct play - fallback to transcode currentPlaybackSession?.let { playbackSession -> if (playbackSession.isDirectPlay) { - Log.d(tag, "Fallback to transcode") + var mediaPlayer = getMediaPlayer() + Log.d(tag, "Fallback to transcode $mediaPlayer") var libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play var episodeId = playbackSession.episodeId - apiHandler.playLibraryItem(libraryItemId, episodeId, true) { + apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) { Handler(Looper.getMainLooper()).post() { preparePlayer(it, true) } @@ -354,17 +371,48 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } fun switchToPlayer(useCastPlayer: Boolean) { + var wasPlaying = currentPlayer.isPlaying + if (useCastPlayer) { + if (currentPlayer == castPlayer) { + Log.d(tag, "switchToPlayer: Already using Cast Player " + castPlayer?.deviceInfo) + return + } else { + Log.d(tag, "switchToPlayer: Switching to cast player from exo player stop exo player") + mPlayer.stop() + } + } else { + if (currentPlayer == mPlayer) { + Log.d(tag, "switchToPlayer: Already using Exo Player " + mPlayer.deviceInfo) + return + } else if (castPlayer != null) { + Log.d(tag, "switchToPlayer: Switching to exo player from cast player stop cast player") + castPlayer?.stop() + } + } + currentPlayer = if (useCastPlayer) { Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo) mediaSessionConnector.setPlayer(castPlayer) + playerNotificationManager.setPlayer(castPlayer) castPlayer as CastPlayer } else { Log.d(tag, "switchToPlayer: Using ExoPlayer") mediaSessionConnector.setPlayer(mPlayer) + playerNotificationManager.setPlayer(mPlayer) mPlayer } + + clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer()) + + if (currentPlaybackSession == null) { + Log.d(tag, "switchToPlayer: No Current playback session") + } + currentPlaybackSession?.let { Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}") + if (wasPlaying) { // media is paused when switching players + clientEventEmitter?.onPlayingUpdate(false) + } preparePlayer(it, false) } } @@ -441,6 +489,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } fun seekPlayer(time: Long) { + Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time") if (currentPlayer.mediaItemCount > 1) { currentPlaybackSession?.currentTime = time / 1000.0 var newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 @@ -452,11 +501,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { } fun seekForward(amount: Long) { - currentPlayer.seekTo(mPlayer.currentPosition + amount) + currentPlayer.seekTo(currentPlayer.currentPosition + amount) } fun seekBackward(amount: Long) { - currentPlayer.seekTo(mPlayer.currentPosition - amount) + currentPlayer.seekTo(currentPlayer.currentPosition - amount) } fun setPlaybackSpeed(speed: Float) { @@ -465,6 +514,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { fun closePlayback() { currentPlayer.clearMediaItems() + currentPlayer.stop() currentPlaybackSession = null clientEventEmitter?.onPlaybackClosed() PlayerListener.lastPauseTime = 0 @@ -475,6 +525,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() { clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState)) } + fun getMediaPlayer():String { + return if(currentPlayer == castPlayer) "cast-player" else "exo-player" + } + // // MEDIA BROWSER STUFF (ANDROID AUTO) // diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 28cdd95d..2d62af52 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -15,6 +15,7 @@ import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.* import com.getcapacitor.annotation.CapacitorPlugin +import com.google.android.gms.cast.CastDevice import org.json.JSONObject @CapacitorPlugin(name = "AbsAudioPlayer") @@ -24,12 +25,18 @@ class AbsAudioPlayer : Plugin() { lateinit var mainActivity: MainActivity lateinit var apiHandler:ApiHandler + lateinit var castManager:CastManager lateinit var playerNotificationService: PlayerNotificationService + + private var isCastAvailable:Boolean = false + override fun load() { mainActivity = (activity as MainActivity) apiHandler = ApiHandler(mainActivity) + initCastManager() + var foregroundServiceReady : () -> Unit = { playerNotificationService = mainActivity.foregroundService @@ -72,17 +79,58 @@ class AbsAudioPlayer : Plugin() { override fun onPlaybackFailed(errorMessage: String) { emit("onPlaybackFailed", errorMessage) } + + override fun onMediaPlayerChanged(mediaPlayer:String) { + emit("onMediaPlayerChanged", mediaPlayer) + } }) } mainActivity.pluginCallback = foregroundServiceReady } fun emit(evtName: String, value: Any) { - var ret:JSObject = JSObject() + var ret = JSObject() ret.put("value", value) notifyListeners(evtName, ret) } + fun initCastManager() { + var connListener = object: CastManager.ChromecastListener() { + override fun onReceiverAvailableUpdate(available: Boolean) { + Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available") + isCastAvailable = available + emit("onCastAvailableUpdate", available) + } + + override fun onSessionRejoin(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionRejoin") + } + + override fun onMediaLoaded(jsonMedia: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onMediaLoaded") + } + + override fun onMediaUpdate(jsonMedia: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onMediaUpdate") + } + + override fun onSessionUpdate(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionUpdate") + } + + override fun onSessionEnd(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionEnd") + } + + override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) { + Log.d(tag, "ChromecastListener: CAST onMessageReceived") + } + } + + castManager = CastManager(mainActivity) + castManager.startRouteScan(connListener) + } + @PluginMethod fun prepareLibraryItem(call: PluginCall) { // Need to make sure the player service has been started @@ -122,7 +170,9 @@ class AbsAudioPlayer : Plugin() { return call.resolve(JSObject()) } } else { // Play library item from server - apiHandler.playLibraryItem(libraryItemId, episodeId, false) { + var mediaPlayer = playerNotificationService.getMediaPlayer() + + apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) { Handler(Looper.getMainLooper()).post() { Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}") @@ -268,9 +318,10 @@ class AbsAudioPlayer : Plugin() { @PluginMethod fun requestSession(call: PluginCall) { + // Need to make sure the player service has been started Log.d(tag, "CAST REQUEST SESSION PLUGIN") call.resolve() - playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() { + castManager.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() { override fun onError(errorCode: Int) { Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode") } @@ -284,4 +335,11 @@ class AbsAudioPlayer : Plugin() { } }) } + + @PluginMethod + fun getIsCastAvailable(call: PluginCall) { + var jsobj = JSObject() + jsobj.put("value", isCastAvailable) + call.resolve(jsobj) + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 3f2606c4..6d018106 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -151,9 +151,9 @@ class ApiHandler { } } - fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, cb: (PlaybackSession) -> Unit) { + fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) { var payload = JSObject() - payload.put("mediaPlayer", "exo-player") + payload.put("mediaPlayer", mediaPlayer) // Only if direct play fails do we force transcode if (!forceTranscode) payload.put("forceDirectPlay", true) diff --git a/android/build.gradle b/android/build.gradle index 479ef28e..5bba5e81 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -5,11 +5,12 @@ buildscript { repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.0.1' - classpath 'com.google.gms:google-services:4.3.5' +// classpath 'com.android.tools.build:gradle:4.0.2' + classpath 'com.android.tools.build:gradle:7.3.0-alpha08' + classpath 'com.google.gms:google-services:4.3.10' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong @@ -22,7 +23,7 @@ apply from: "variables.gradle" allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/android/gradle.properties b/android/gradle.properties index 0566c221..71d1afb1 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -9,7 +9,15 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx1536m +#org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Dfile.encoding=UTF-8 \ + --add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit @@ -22,3 +30,5 @@ org.gradle.jvmargs=-Xmx1536m android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true + +kapt.use.worker.api=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 186b7155..cc3c07e6 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Sun Apr 17 13:28:55 CDT 2022 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/android/variables.gradle b/android/variables.gradle index 0a287a3c..8f562165 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -1,34 +1,34 @@ ext { minSdkVersion = 24 - compileSdkVersion = 30 + compileSdkVersion = 31 targetSdkVersion = 30 androidxActivityVersion = '1.2.0' - androidxAppCompatVersion = '1.2.0' - androidxCoordinatorLayoutVersion = '1.1.0' + androidxAppCompatVersion = '1.4.1' + androidxCoordinatorLayoutVersion = '1.2.0' androidxCoreVersion = '1.6.0' androidPlayCore = '1.9.0' androidxFragmentVersion = '1.3.0' junitVersion = '4.13.1' androidxJunitVersion = '1.1.2' androidxEspressoCoreVersion = '3.3.0' - cordovaAndroidVersion = '7.0.0' + cordovaAndroidVersion = '10.1.1' androidx_app_compat_version = '1.2.0' androidx_car_version = '1.0.0-alpha7' - androidx_core_ktx_version = '1.6.0' - androidx_media_version = '1.0.1' + androidx_core_ktx_version = '1.7.0' + androidx_media_version = '1.5.0' androidx_preference_version = '1.1.1' androidx_test_runner_version = '1.3.0' arch_lifecycle_version = '2.2.0' constraint_layout_version = '2.0.1' espresso_version = '3.3.0' - exoplayer_version = '2.15.0' + exoplayer_version = '2.17.0' fragment_version = '1.2.5' glide_version = '4.11.0' gms_strict_version_matcher_version = '1.0.3' gradle_version = '3.1.4' gson_version = '2.8.5' junit_version = '4.13' - kotlin_version = '1.4.32' + kotlin_version = '1.5.30' kotlin_coroutines_version = '1.1.0' multidex_version = '1.0.3' play_services_auth_version = '18.1.0' diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue index 9c76e278..1d9d399e 100644 --- a/components/app/Appbar.vue +++ b/components/app/Appbar.vue @@ -17,6 +17,10 @@ +
+ cast +
+ search @@ -29,9 +33,14 @@ diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 6a776b52..c8daced2 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -5,7 +5,7 @@ expand_more
- cast + cast
@@ -109,7 +109,7 @@ export default { return { playbackSession: null, showChapterModal: false, - showCastBtn: false, + showCastBtn: true, showFullscreen: false, totalDuration: 0, currentPlaybackRate: 1, @@ -159,6 +159,12 @@ export default { } return this.showFullscreen ? 200 : 60 }, + isCasting() { + return this.mediaPlayer === 'cast-player' + }, + mediaPlayer() { + return this.playbackSession ? this.playbackSession.mediaPlayer : null + }, mediaType() { return this.playbackSession ? this.playbackSession.mediaType : null }, @@ -262,6 +268,11 @@ export default { }, castClick() { console.log('Cast Btn Click') + if (this.isLocalPlayMethod) { + this.$toast.warn('Cannot cast downloaded media items') + return + } + AbsAudioPlayer.requestSession() }, clickContainer() { diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 72444f9b..d6dd002a 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -10,6 +10,7 @@