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