diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1fde46bd7a..32e2a71cee 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -134,13 +134,6 @@
android:resource="@xml/file_paths"/>
-
-
-
-
-
-
@@ -177,9 +170,6 @@
android:exported="false"
android:name=".service.AudioService"
android:foregroundServiceType="mediaPlayback">
-
-
-
notificationIcon = bitmap })
}
- }
- private inner class MediaSessionCallback : MediaSessionCompat.Callback() {
- override fun onPlay() {
- processPlayRequest()
- }
+ // Initialize ExoPlayer
+ val player = makeOrResetExoPlayer()
- override fun onSkipToNext() {
- processSkipRequest()
- }
+ // Initialize MediaLibrarySession
- override fun onSkipToPrevious() {
- processRewindRequest()
- }
+ mediaLibrarySession = MediaLibrarySession.Builder(this, player, quranServiceCallback)
+ .build()
+ }
- override fun onPause() {
- processPauseRequest()
- }
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = mediaLibrarySession
- override fun onStop() {
- processStopRequest()
- }
- }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) {
// handle a crash that occurs where intent comes in as null
if (State.Stopped == state) {
- serviceHandler.removeCallbacksAndMessages(null)
+ stopUpdateAudioPositionJob()
stopSelf()
}
} else {
@@ -358,10 +318,9 @@ class AudioService : Service(), Player.Listener {
// go to the foreground as quickly as possible.
setUpAsForeground()
}
- val message = serviceHandler.obtainMessage(MSG_INCOMING, intent)
- serviceHandler.sendMessage(message)
+ handleIntent(intent)
}
- return START_NOT_STICKY
+ return super.onStartCommand(intent, flags, startId)
}
private fun handleIntent(intent: Intent) {
@@ -405,13 +364,11 @@ class AudioService : Service(), Player.Listener {
audioQueue = localAudioQueue.withUpdatedAudioRequest(playInfo)
if (playInfo.playbackSpeed != audioRequest?.playbackSpeed) {
processUpdatePlaybackSpeed(playInfo.playbackSpeed)
- serviceHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 200)
+ startUpdateAudioPositionJob(200)
}
audioRequest = playInfo
updateAudioPlaybackStatus()
}
- } else {
- MediaButtonReceiver.handleIntent(mediaSession, intent)
}
}
@@ -440,6 +397,21 @@ class AudioService : Service(), Player.Listener {
return -1
}
+ private fun startUpdateAudioPositionJob(delayMs: Long) {
+ stopUpdateAudioPositionJob()
+ updateAudioPositionJob = scope.launch {
+ delay(delayMs)
+ if (isActive) {
+ updateAudioPlayPosition()
+ }
+ }
+ }
+
+ private fun stopUpdateAudioPositionJob() {
+ updateAudioPositionJob?.cancel()
+ updateAudioPositionJob = null
+ }
+
private fun updateAudioPlayPosition() {
if (DEBUG_TIMINGS) {
Timber.d("updateAudioPlayPosition")
@@ -453,7 +425,6 @@ class AudioService : Service(), Player.Listener {
val ayah = localAudioQueue.getCurrentAyah()
val maxAyahs = quranInfo.getNumberOfAyahs(sura)
- setState(PlaybackStateCompat.STATE_PLAYING)
val pos = localPlayer.currentPosition
val currentAyahTime = gaplessSuraData.ayahTimings[ayah]
@@ -483,7 +454,7 @@ class AudioService : Service(), Player.Listener {
val ayahTime = gaplessSuraData.ayahTimings[ayah]
if (abs(pos - ayahTime) < 150) {
// shouldn't change ayahs if the delta is just 150ms...
- serviceHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 150)
+ startUpdateAudioPositionJob(150)
return
}
val success = localAudioQueue.playAt(sura, updatedAyah, false)
@@ -495,7 +466,7 @@ class AudioService : Service(), Player.Listener {
return
} else if (nextSura != sura || nextAyah != updatedAyah) {
// remove any messages currently in the queue
- serviceHandler.removeMessages(MSG_UPDATE_AUDIO_POS)
+ stopUpdateAudioPositionJob()
currentWord = null
// if the ayah hasn't changed, we're repeating the ayah,
@@ -524,7 +495,7 @@ class AudioService : Service(), Player.Listener {
val success = localAudioQueue.playAt(sura + 1, 1, false)
if (success && localAudioQueue.getCurrentSura() == sura) {
// remove any messages currently in the queue
- serviceHandler.removeMessages(MSG_UPDATE_AUDIO_POS)
+ stopUpdateAudioPositionJob()
// jump back to the ayah we should repeat and play it
val seekPos = getSeekPosition(false)
@@ -579,7 +550,7 @@ class AudioService : Service(), Player.Listener {
// schedule next the update
if (nextUpdateDelay != null) {
- serviceHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, nextUpdateDelay)
+ startUpdateAudioPositionJob(nextUpdateDelay)
} else if (maxAyahs >= updatedAyah + 1) {
val timeDelta = gaplessSuraData.ayahTimings[updatedAyah + 1] - localPlayer.currentPosition
val t = timeDelta.coerceIn(100, 10000)
@@ -590,9 +561,9 @@ class AudioService : Service(), Player.Listener {
t, tAccountingForSpeed, audioRequest?.playbackSpeed
)
}
- serviceHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, tAccountingForSpeed.toLong())
+ startUpdateAudioPositionJob(tAccountingForSpeed.toLong())
} else if (maxAyahs == updatedAyah) {
- serviceHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 150)
+ startUpdateAudioPositionJob(150)
}
// if we're on the last ayah, don't do anything - let the file
// complete on its own to avoid getCurrentPosition() bugs.
@@ -635,9 +606,8 @@ class AudioService : Service(), Player.Listener {
if (State.Playing == state) {
// Pause exo player and cancel the 'foreground service' state.
state = State.Paused
- serviceHandler.removeMessages(MSG_UPDATE_AUDIO_POS)
+ stopUpdateAudioPositionJob()
player?.pause()
- setState(PlaybackStateCompat.STATE_PAUSED)
// on jellybean and above, stay in the foreground and
// update the notification.
relaxResources(releaseExoPlayer = false, stopForeground = false)
@@ -646,7 +616,6 @@ class AudioService : Service(), Player.Listener {
} else if (State.Stopped == state) {
// if we get a pause while we're already stopped, it means we likely woke up because
// of AudioIntentReceiver, so just stop in this case.
- setState(PlaybackStateCompat.STATE_STOPPED)
updateAudioPlaybackStatus()
stopSelf()
}
@@ -654,7 +623,6 @@ class AudioService : Service(), Player.Listener {
private fun processRewindRequest() {
if (State.Playing == state || State.Paused == state) {
- setState(PlaybackStateCompat.STATE_REWINDING)
val localPlayer = player ?: return
val localAudioQueue = audioQueue ?: return
val localAudioRequest = audioRequest ?: return
@@ -698,7 +666,6 @@ class AudioService : Service(), Player.Listener {
return
}
if (State.Playing == state || State.Paused == state) {
- setState(PlaybackStateCompat.STATE_SKIPPING_TO_NEXT)
if (playerOverride) {
playAudio(false)
} else {
@@ -723,8 +690,7 @@ class AudioService : Service(), Player.Listener {
}
private fun processStopRequest(force: Boolean = false) {
- setState(PlaybackStateCompat.STATE_STOPPED)
- serviceHandler.removeMessages(MSG_UPDATE_AUDIO_POS)
+ stopUpdateAudioPositionJob()
currentWord = null
if (State.Preparing == state) {
shouldStop = true
@@ -738,7 +704,6 @@ class AudioService : Service(), Player.Listener {
relaxResources(releaseExoPlayer = true, stopForeground = true)
// service is no longer necessary. Will be started again if needed.
- serviceHandler.removeCallbacksAndMessages(null)
stopSelf()
}
}
@@ -746,25 +711,6 @@ class AudioService : Service(), Player.Listener {
private fun notifyAyahChanged() {
val localAudioRequest = audioRequest ?: return
updateAudioPlaybackStatus()
-
- val metadataBuilder = MediaMetadataCompat.Builder()
- .putString(MediaMetadataCompat.METADATA_KEY_TITLE, getTitle())
- .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, localAudioRequest.qari.name)
- val localPlayer = player
- if (localPlayer?.isPlaying == true) {
- metadataBuilder.putLong(
- MediaMetadataCompat.METADATA_KEY_DURATION,
- localPlayer.duration
- )
- }
-
- if (displayIcon != null) {
- metadataBuilder.putBitmap(
- MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON,
- displayIcon
- )
- }
- mediaSession.setMetadata(metadataBuilder.build())
}
private fun updateAudioPlaybackStatus() {
@@ -819,7 +765,6 @@ class AudioService : Service(), Player.Listener {
// nothing to do here ¯\_(ツ)_/¯
}
player = null
- mediaSession.isActive = false
}
}
@@ -850,7 +795,7 @@ class AudioService : Service(), Player.Listener {
if (audioRequest?.isGapless() == true && !playerOverride) {
Timber.d("configAndStartExoPlayer: restarting position updates")
- serviceHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 200)
+ startUpdateAudioPositionJob(200)
}
}
@@ -931,7 +876,6 @@ class AudioService : Service(), Player.Listener {
Timber.d("okay, we are preparing to play - streaming is: %b", isStreaming)
val localPlayer = makeOrResetExoPlayer()
- setState(PlaybackStateCompat.STATE_CONNECTING)
try {
val mediaUri = if (overrideResource != 0) {
// For raw resources, we need to create a URI pointing to the resource
@@ -970,57 +914,6 @@ class AudioService : Service(), Player.Listener {
}
}
- private fun setState(state: Int) {
- var position: Long = 0
- val localPlayer = player
- if (localPlayer != null && localPlayer.isPlaying) {
- position = localPlayer.currentPosition
- }
- val builder = PlaybackStateCompat.Builder()
- builder.setState(state, position, 1.0f)
-
- val actions = when (state) {
- PlaybackStateCompat.STATE_PLAYING -> {
- PlaybackStateCompat.ACTION_PAUSE or
- PlaybackStateCompat.ACTION_STOP or
- PlaybackStateCompat.ACTION_REWIND or
- PlaybackStateCompat.ACTION_FAST_FORWARD or
- PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
- PlaybackStateCompat.ACTION_SKIP_TO_NEXT
- }
- PlaybackStateCompat.STATE_PAUSED -> {
- PlaybackStateCompat.ACTION_PLAY or
- PlaybackStateCompat.ACTION_STOP or
- PlaybackStateCompat.ACTION_REWIND or
- PlaybackStateCompat.ACTION_FAST_FORWARD or
- PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
- PlaybackStateCompat.ACTION_SKIP_TO_NEXT
- }
- PlaybackStateCompat.STATE_STOPPED -> {
- PlaybackStateCompat.ACTION_PLAY
- }
- PlaybackStateCompat.STATE_CONNECTING -> {
- PlaybackStateCompat.ACTION_STOP
- }
- PlaybackStateCompat.STATE_REWINDING -> {
- PlaybackStateCompat.ACTION_STOP or
- PlaybackStateCompat.ACTION_REWIND or
- PlaybackStateCompat.ACTION_FAST_FORWARD or
- PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
- PlaybackStateCompat.ACTION_SKIP_TO_NEXT
- }
- PlaybackStateCompat.STATE_SKIPPING_TO_NEXT -> {
- PlaybackStateCompat.ACTION_STOP or
- PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or
- PlaybackStateCompat.ACTION_SKIP_TO_NEXT or
- PlaybackStateCompat.ACTION_FAST_FORWARD or
- PlaybackStateCompat.ACTION_REWIND
- }
- else -> { PlaybackStateCompat.ACTION_STOP }
- }
- builder.setActions(actions)
- mediaSession.setPlaybackState(builder.build())
- }
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
@@ -1042,7 +935,11 @@ class AudioService : Service(), Player.Listener {
Timber.d("Player idle")
if (state != State.Stopped) {
state = State.Stopped
+ stopUpdateAudioPositionJob()
+ currentWord = null
updateAudioPlaybackStatus()
+ relaxResources(releaseExoPlayer = true, stopForeground = true)
+ stopSelf()
}
}
}
@@ -1067,9 +964,16 @@ class AudioService : Service(), Player.Listener {
if (isPlaying && state == State.Paused) {
state = State.Playing
updateAudioPlaybackStatus()
+ // Restart position updates for gapless audio in case we were paused
+ // by audio focus loss or an external controller
+ if (audioRequest?.isGapless() == true && !playerOverride) {
+ startUpdateAudioPositionJob(200)
+ }
} else if (!isPlaying && state == State.Playing) {
- // Handle automatic pause (audio focus loss, etc.)
+ // Handle automatic pause (audio focus loss, external controller, etc.)
state = State.Paused
+ stopUpdateAudioPositionJob()
+ pauseNotification()
updateAudioPlaybackStatus()
}
}
@@ -1084,7 +988,7 @@ class AudioService : Service(), Player.Listener {
}
updateAudioPlaybackStatus()
Timber.d("onSeekComplete: restarting position updates")
- serviceHandler.sendEmptyMessageDelayed(MSG_UPDATE_AUDIO_POS, 200)
+ startUpdateAudioPositionJob(200)
}
private fun onPlayerBuffering() {
@@ -1204,6 +1108,7 @@ class AudioService : Service(), Player.Listener {
* (such as playing music), and must appear to the user as a notification.
* That's why we create the notification here.
*/
+ @OptIn(UnstableApi::class)
private fun setUpAsForeground() {
// clear the "downloading complete" notification (if it exists)
notificationManager.cancel(QuranDownloadNotifier.DOWNLOADING_COMPLETE_NOTIFICATION)
@@ -1244,7 +1149,7 @@ class AudioService : Service(), Player.Listener {
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1, 2)
- .setMediaSession(mediaSession.sessionToken)
+ .setMediaSession(MediaSessionCompat.Token.fromToken(mediaLibrarySession!!.platformToken))
)
didSetNotificationIconOnNotificationBuilder = icon != null
notificationBuilder = builder
@@ -1259,6 +1164,7 @@ class AudioService : Service(), Player.Listener {
isSetupAsForeground = true
}
+ @OptIn(UnstableApi::class)
private fun getPausedNotificationBuilder(): NotificationCompat.Builder {
val appContext = applicationContext
@@ -1291,7 +1197,7 @@ class AudioService : Service(), Player.Listener {
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1)
- .setMediaSession(mediaSession.sessionToken)
+ .setMediaSession(MediaSessionCompat.Token.fromToken(mediaLibrarySession!!.platformToken))
)
pausedNotificationBuilder = builder
builder
@@ -1327,23 +1233,19 @@ class AudioService : Service(), Player.Listener {
}
override fun onDestroy() {
- compositeDisposable.clear()
+ Timber.i("debug: destroying the service")
// Service is being killed, so make sure we release our resources
- serviceHandler.removeCallbacksAndMessages(null)
- serviceLooper.quitSafely()
+ compositeDisposable.clear()
state = State.Stopped
relaxResources(true, true)
- mediaSession.release()
timingRepository.clear()
scope.cancel()
+ mediaLibrarySession?.release()
+ mediaLibrarySession = null
+ stopUpdateAudioPositionJob()
super.onDestroy()
}
- override fun onBind(arg0: Intent): IBinder? {
- return null
- }
-
-
companion object {
// These are the Intent actions that we are prepared to handle.
const val ACTION_PLAYBACK = "com.quran.labs.androidquran.action.PLAYBACK"
@@ -1367,8 +1269,6 @@ class AudioService : Service(), Player.Listener {
// so user can pass in a serializable LegacyAudioRequest to the intent
const val EXTRA_PLAY_INFO = "com.quran.labs.androidquran.PLAY_INFO"
private const val NOTIFICATION_CHANNEL_ID = Constants.AUDIO_CHANNEL
- private const val MSG_INCOMING = 1
- private const val MSG_UPDATE_AUDIO_POS = 2
private const val DEBUG_TIMINGS = false
// 5 minutes
diff --git a/app/src/main/java/com/quran/labs/androidquran/service/QuranServiceCallback.kt b/app/src/main/java/com/quran/labs/androidquran/service/QuranServiceCallback.kt
new file mode 100644
index 0000000000..a5971176d9
--- /dev/null
+++ b/app/src/main/java/com/quran/labs/androidquran/service/QuranServiceCallback.kt
@@ -0,0 +1,15 @@
+package com.quran.labs.androidquran.service
+
+import androidx.media3.session.MediaLibraryService
+
+/**
+ * Callback for the [MediaLibraryService.MediaLibrarySession] to support media browsing
+ * from external controllers (Android Auto, Google Assistant, etc.).
+ *
+ * Note: In Media3, transport controls (play/pause/stop/skip) are handled by the
+ * [Player][androidx.media3.common.Player] directly and don't require callback overrides.
+ * This callback exists for library-specific operations like
+ * [onGetItem][MediaLibraryService.MediaLibrarySession.Callback.onGetItem],
+ * [onGetChildren][MediaLibraryService.MediaLibrarySession.Callback.onGetChildren], etc.
+ */
+class QuranServiceCallback : MediaLibraryService.MediaLibrarySession.Callback