Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 60 additions & 60 deletions app/src/main/java/com/quran/labs/androidquran/service/AudioService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,13 @@ package com.quran.labs.androidquran.service

import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.ComponentName
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Process
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
Expand All @@ -39,6 +33,8 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.metadata.MetadataOutput
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.video.VideoRendererEventListener
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import com.quran.data.core.QuranInfo
import com.quran.labs.androidquran.QuranApplication
import com.quran.labs.androidquran.R
Expand All @@ -63,9 +59,12 @@ import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
Expand All @@ -78,7 +77,9 @@ import kotlin.math.abs
* (which come from our main activity, [PagerActivity], which signal
* the service to perform specific operations: Play, Pause, Rewind, Skip, etc.
*/
class AudioService : Service(), Player.Listener {
class AudioService : MediaLibraryService(), Player.Listener {

private var mediaLibrarySession: MediaLibrarySession? = null

// our exo player
private var player: ExoPlayer? = null
Expand Down Expand Up @@ -122,9 +123,6 @@ class AudioService : Service(), Player.Listener {
private lateinit var notificationManager: NotificationManager
private lateinit var mediaSession: MediaSessionCompat

private lateinit var serviceLooper: Looper
private lateinit var serviceHandler: ServiceHandler

private var notificationBuilder: NotificationCompat.Builder? = null
private var pausedNotificationBuilder: NotificationCompat.Builder? = null
private var didSetNotificationIconOnNotificationBuilder = false
Expand All @@ -138,7 +136,9 @@ class AudioService : Service(), Player.Listener {
private var gaplessSuraData: SuraTimings = SuraTimings.EMPTY
private var currentWord: Int? = null
private val compositeDisposable = CompositeDisposable()
private lateinit var scope: CoroutineScope
internal lateinit var scope: CoroutineScope
private val quranServiceCallback = QuranServiceCallback()
private var updateAudioPositionJob: Job? = null

@Inject
lateinit var quranInfo: QuranInfo
Expand All @@ -155,17 +155,6 @@ class AudioService : Service(), Player.Listener {
@Inject
lateinit var timingRepository: TimingRepository

private inner class ServiceHandler(looper: Looper) : Handler(looper) {
override fun handleMessage(msg: Message) {
if (msg.what == MSG_INCOMING && msg.obj != null) {
val intent = msg.obj as Intent
handleIntent(intent)
} else if (msg.what == MSG_UPDATE_AUDIO_POS) {
updateAudioPlayPosition()
}
}
}

/**
* Makes sure the ExoPlayer exists and has been reset. This will make
* the ExoPlayer if needed, or reset the existing player if one
Expand Down Expand Up @@ -217,17 +206,9 @@ class AudioService : Service(), Player.Listener {
}

override fun onCreate() {
super.onCreate()
Timber.i("debug: Creating service")
val thread = HandlerThread(
"AyahAudioService",
Process.THREAD_PRIORITY_BACKGROUND
)
thread.start()

// Get the HandlerThread's Looper and use it for our Handler
serviceLooper = thread.looper
serviceHandler = ServiceHandler(serviceLooper)
scope = CoroutineScope(serviceHandler.asCoroutineDispatcher() + SupervisorJob())
scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())

val appContext = applicationContext
(appContext as QuranApplication).applicationComponent.inject(this)
Expand All @@ -237,7 +218,7 @@ class AudioService : Service(), Player.Listener {

val receiver = ComponentName(this, MediaButtonReceiver::class.java)
mediaSession = MediaSessionCompat(appContext, "QuranMediaSession", receiver, null)
mediaSession.setCallback(MediaSessionCallback(), serviceHandler)
mediaSession.setCallback(MediaSessionCallback(), null)
val channelName = getString(R.string.notification_channel_audio)
setupNotificationChannel(
notificationManager, NOTIFICATION_CHANNEL_ID, channelName
Expand All @@ -261,8 +242,18 @@ class AudioService : Service(), Player.Listener {
.subscribeOn(Schedulers.io())
.subscribe { bitmap: Bitmap? -> notificationIcon = bitmap })
}

// Initialize ExoPlayer
val player = makeOrResetExoPlayer()

// Initialize MediaLibrarySession

mediaLibrarySession = MediaLibrarySession.Builder(this, player, quranServiceCallback)
.build()
}

override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = mediaLibrarySession

private inner class MediaSessionCallback : MediaSessionCompat.Callback() {
override fun onPlay() {
processPlayRequest()
Expand All @@ -289,7 +280,7 @@ class AudioService : Service(), Player.Listener {
if (intent == null) {
// handle a crash that occurs where intent comes in as null
if (State.Stopped == state) {
serviceHandler.removeCallbacksAndMessages(null)
stopUpdateAudioPositionJob()
stopSelf()
}
} else {
Expand All @@ -298,10 +289,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) {
Expand Down Expand Up @@ -345,7 +335,7 @@ 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()
Expand Down Expand Up @@ -380,6 +370,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")
Expand Down Expand Up @@ -423,7 +428,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)
Expand All @@ -435,7 +440,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,
Expand Down Expand Up @@ -464,7 +469,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)
Expand Down Expand Up @@ -519,7 +524,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)
Expand All @@ -530,9 +535,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.
Expand Down Expand Up @@ -575,7 +580,7 @@ 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
Expand Down Expand Up @@ -664,7 +669,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
Expand All @@ -678,7 +683,7 @@ class AudioService : Service(), Player.Listener {
relaxResources(releaseExoPlayer = true, stopForeground = true)

// service is no longer necessary. Will be started again if needed.
serviceHandler.removeCallbacksAndMessages(null)
stopUpdateAudioPositionJob()
stopSelf()
}
}
Expand Down Expand Up @@ -790,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)
}
}

Expand Down Expand Up @@ -1022,7 +1027,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() {
Expand Down Expand Up @@ -1279,23 +1284,20 @@ 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"
Expand All @@ -1319,8 +1321,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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.quran.labs.androidquran.service

import androidx.media3.session.MediaLibraryService

/**
* Empty callback implementation, to be extended to support media items and allow controller requests
* from other apps (like android auto or google assistant)
*/
class QuranServiceCallback : MediaLibraryService.MediaLibrarySession.Callback
Loading