Skip to content

Instantly share code, notes, and snippets.

@iscomad
Last active April 16, 2018 05:40
Show Gist options
  • Save iscomad/83d758ecff906a78d8372f545318a721 to your computer and use it in GitHub Desktop.
Save iscomad/83d758ecff906a78d8372f545318a721 to your computer and use it in GitHub Desktop.
Quran.kz audio player feature
package kz.sdu.qurankz.audioplayer
import android.net.Uri
/**
* An interface for working with audio player.
*
* Created by Isco on 2/24/18.
* You'll Never Walk Alone
*/
interface AudioPlayer {
fun prepare(uri: Uri)
fun prepare(srcList: List<Uri>)
fun play()
fun pause()
fun stop()
fun skipToPrevious()
fun skipToNext()
fun restart()
fun release()
fun setListener(listener: AudioPlayerListener)
fun getCurrentPosition(): Int
fun isPlaying(): Boolean
interface AudioPlayerListener {
fun onPlayerError(exception: Exception)
fun onPlayEnded()
fun onLoaded()
fun onTrackChanged()
}
}
package kz.sdu.qurankz.audioplayer
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.support.v4.app.NotificationManagerCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaButtonReceiver
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import kz.sdu.qurankz.activity.QuranActivity
import kz.sdu.qurankz.model.AyatModel
import kz.sdu.qurankz.util.QuranSQLiteOpenHelper
private val TAG = AudioPlayerService::class.java.simpleName
private const val NOTIFICATION_ID = 404
/**
* A service that provides an ability to play an audio in background.
*
* Created by Isco on 1/21/18.
* You'll Never Walk Alone
*/
class AudioPlayerService : Service() {
lateinit var mediaSession: MediaSessionCompat
private val binder = AudioServiceBinder(this)
private lateinit var audioPlayer: AudioPlayer
private lateinit var sqlHelper: QuranSQLiteOpenHelper
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
private var audioFocusRequested = false
private var playbackNowAuthorized = false
private var resumeOnFocusGain = false
private var currentPlaybackState = PlaybackStateCompat.STATE_STOPPED
private var mediaRepository: MediaRepository? = null
private lateinit var notificationProvider: MediaStyleNotificationProvider
private val metadataBuilder = MediaMetadataCompat.Builder()
private val stateBuilder = PlaybackStateCompat.Builder().setActions(
PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_STOP
or PlaybackStateCompat.ACTION_PAUSE
or PlaybackStateCompat.ACTION_PLAY_PAUSE
or PlaybackStateCompat.ACTION_SKIP_TO_NEXT
or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
private val becomingNoisyReceiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) {
mediaSessionCallback.onPause()
}
}
}
private val mediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() {
override fun onPlay() {
startService(Intent(applicationContext, AudioPlayerService::class.java))
val trackPosition = audioPlayer.getCurrentPosition()
val track = mediaRepository?.tracks?.get(trackPosition) ?: return
updateMetadataFromTrack(track)
if (!requestAudioFocus()) return
registerReceiver(becomingNoisyReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
resumeOnFocusGain = false
mediaSession.isActive = true
audioPlayer.play()
mediaSession.setPlaybackState(
stateBuilder.setState(
PlaybackStateCompat.STATE_PLAYING,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
1F
).build()
)
currentPlaybackState = PlaybackStateCompat.STATE_PLAYING
refreshNotificationAndForegroundStatus(currentPlaybackState)
}
override fun onPause() {
if (audioPlayer.isPlaying()) {
audioPlayer.pause()
unregisterReceiver(becomingNoisyReceiver)
}
mediaSession.setPlaybackState(
stateBuilder.setState(
PlaybackStateCompat.STATE_PAUSED,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
1F
).build()
)
currentPlaybackState = PlaybackStateCompat.STATE_PAUSED
resumeOnFocusGain = false
refreshNotificationAndForegroundStatus(currentPlaybackState)
}
override fun onStop() {
if (audioPlayer.isPlaying()) {
audioPlayer.stop()
unregisterReceiver(becomingNoisyReceiver)
}
abandonAudioFocus()
mediaSession.isActive = false
mediaSession.setPlaybackState(
stateBuilder.setState(
PlaybackStateCompat.STATE_STOPPED,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
1F
).build()
)
currentPlaybackState = PlaybackStateCompat.STATE_STOPPED
refreshNotificationAndForegroundStatus(currentPlaybackState)
stopSelf()
}
override fun onSkipToNext() {
val nextPosition = audioPlayer.getCurrentPosition() + 1
val tracks = mediaRepository?.tracks ?: return
if (nextPosition >= tracks.size) return
val track = tracks[nextPosition]
updateMetadataFromTrack(track)
refreshNotificationAndForegroundStatus(currentPlaybackState)
audioPlayer.skipToNext()
}
override fun onSkipToPrevious() {
val prevPosition = audioPlayer.getCurrentPosition() - 1
if (prevPosition < 0) return
val track = mediaRepository?.tracks?.get(prevPosition) ?: return
updateMetadataFromTrack(track)
refreshNotificationAndForegroundStatus(currentPlaybackState)
audioPlayer.skipToPrevious()
}
override fun onCustomAction(action: String?, extras: Bundle?) {
if (action == null || extras == null) return
when (action) {
CUSTOM_ACTION_PREPARE -> {
val fromAyat = AyatModel().apply {
surahId = extras.getInt(EXTRA_FROM_SURA)
ayatNumber = extras.getInt(EXTRA_FROM_AYAT)
}
val toAyat = AyatModel().apply {
surahId = extras.getInt(EXTRA_TO_SURA)
ayatNumber = extras.getInt(EXTRA_TO_AYAT)
}
val sourceUrl = extras.getString(EXTRA_SOURCE_URL)
mediaRepository = QuranMediaUriRepository(sqlHelper, fromAyat, toAyat, sourceUrl)
audioPlayer.prepare(mediaRepository!!.tracks.map { it.uri })
}
}
}
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
if (resumeOnFocusGain) {
mediaSessionCallback.onPlay()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
if (audioPlayer.isPlaying()) {
mediaSessionCallback.onPause()
resumeOnFocusGain = true
}
}
else -> {
mediaSessionCallback.onPause()
resumeOnFocusGain = false
}
}
}
override fun onCreate() {
super.onCreate()
sqlHelper = QuranSQLiteOpenHelper(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setOnAudioFocusChangeListener(audioFocusChangeListener)
.setAcceptsDelayedFocusGain(false)
.setWillPauseWhenDucked(true)
.setAudioAttributes(audioAttributes)
.build()
}
notificationProvider = MediaStyleNotificationProvider(this)
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
initMediaSession()
initAudioPlayer()
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
MediaButtonReceiver.handleIntent(mediaSession, intent)
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
audioPlayer.release()
mediaSession.release()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
private fun initMediaSession() {
mediaSession = MediaSessionCompat(this, TAG)
mediaSession.setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
)
mediaSession.setCallback(mediaSessionCallback)
val intent = Intent(applicationContext, QuranActivity::class.java)
mediaSession.setSessionActivity(PendingIntent.getActivity(applicationContext, 0, intent, 0))
}
private fun initAudioPlayer() {
audioPlayer = ExoAudioPlayer(baseContext)
audioPlayer.setListener(object : AudioPlayer.AudioPlayerListener {
override fun onPlayerError(exception: Exception) {
Log.e(TAG, exception.localizedMessage, exception)
}
override fun onPlayEnded() {
abandonAudioFocus()
mediaSession.isActive = false
mediaSession.setPlaybackState(
stateBuilder.setState(
PlaybackStateCompat.STATE_STOPPED,
PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN,
1F
).build()
)
currentPlaybackState = PlaybackStateCompat.STATE_STOPPED
refreshNotificationAndForegroundStatus(currentPlaybackState)
}
override fun onLoaded() {
Log.i(TAG, "player loading finished.")
}
override fun onTrackChanged() {
val position = audioPlayer.getCurrentPosition()
val tracks = mediaRepository?.tracks ?: return
if (position < 0 || position >= tracks.size) return
val track = tracks[position]
updateMetadataFromTrack(track)
refreshNotificationAndForegroundStatus(currentPlaybackState)
}
})
}
private fun updateMetadataFromTrack(track: MediaRepository.Track) {
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, track.title)
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, track.album)
mediaSession.setMetadata(metadataBuilder.build())
}
private fun refreshNotificationAndForegroundStatus(playbackState: Int) {
when (playbackState) {
PlaybackStateCompat.STATE_PLAYING -> {
startForeground(
NOTIFICATION_ID,
notificationProvider.getNotification(mediaSession, playbackState)
)
}
PlaybackStateCompat.STATE_PAUSED -> {
// На паузе мы перестаем быть foreground, однако оставляем уведомление,
// чтобы пользователь мог нажать play
NotificationManagerCompat.from(this)
.notify(
NOTIFICATION_ID,
notificationProvider.getNotification(mediaSession, playbackState)
)
stopForeground(false)
}
else -> {
// Все, можно прятать уведомление
stopForeground(true)
}
}
}
private fun requestAudioFocus(): Boolean {
if (!audioFocusRequested) {
audioFocusRequested = true
val audioFocusResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioManager?.requestAudioFocus(audioFocusRequest)
} else {
@Suppress("DEPRECATION")
audioManager?.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
}
playbackNowAuthorized = audioFocusResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
return playbackNowAuthorized
}
private fun abandonAudioFocus() {
if (audioFocusRequested) {
audioFocusRequested = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioManager?.abandonAudioFocusRequest(audioFocusRequest)
} else {
@Suppress("DEPRECATION")
audioManager?.abandonAudioFocus(audioFocusChangeListener)
}
}
}
}
package kz.sdu.qurankz.audioplayer
import android.os.Binder
import android.support.v4.media.session.MediaSessionCompat
/**
* A service binder class of [AudioPlayerService]
*
* Created by Isco on 1/22/18.
* You'll Never Walk Alone
*/
class AudioServiceBinder(private val service: AudioPlayerService) : Binder() {
fun getMediaSessionToken(): MediaSessionCompat.Token = service.mediaSession.sessionToken
}
package kz.sdu.qurankz.audioplayer
import android.content.Context
import android.net.Uri
import com.google.android.exoplayer2.ExoPlaybackException
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.ExoPlayerFactory
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.Timeline
import com.google.android.exoplayer2.source.ConcatenatingMediaSource
import com.google.android.exoplayer2.source.ExtractorMediaSource
import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
import com.google.android.exoplayer2.trackselection.TrackSelector
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory
import com.google.android.exoplayer2.util.Util
/**
* Default implementation of [AudioPlayer] via ExoPlayer.
*
* Created by Isco on 2/24/18.
* You'll Never Walk Alone
*/
class ExoAudioPlayer(context: Context) : AudioPlayer, Player.EventListener {
private val player: ExoPlayer
private val mediaSourceFactory: ExtractorMediaSource.Factory
private var listener: AudioPlayer.AudioPlayerListener? = null
private var currentIndex = 0
init {
val trackSelector: TrackSelector = DefaultTrackSelector()
val userAgent = Util.getUserAgent(context, context.applicationInfo.name)
val dataSourceFactory = DefaultDataSourceFactory(context, userAgent, null)
mediaSourceFactory = ExtractorMediaSource.Factory(dataSourceFactory)
player = ExoPlayerFactory.newSimpleInstance(context, trackSelector)
player.addListener(this)
}
override fun prepare(uri: Uri) {
player.prepare(mediaSourceFactory.createMediaSource(uri))
}
override fun prepare(srcList: List<Uri>) {
val mediaSources = mutableListOf<ExtractorMediaSource>()
srcList.forEach { mediaSources.add(mediaSourceFactory.createMediaSource(it)) }
val concatenatingMediaSource = ConcatenatingMediaSource(*mediaSources.toTypedArray())
player.prepare(concatenatingMediaSource)
}
override fun play() {
player.playWhenReady = true
}
override fun pause() {
player.playWhenReady = false
}
override fun stop() {
player.apply {
playWhenReady = false
seekTo(0)
}
}
override fun skipToPrevious() {
player.apply { seekTo(previousWindowIndex, 0) }
}
override fun skipToNext() {
player.apply { seekTo(nextWindowIndex, 0) }
}
override fun restart() {
player.seekTo(0)
}
override fun release() {
player.release()
}
override fun setListener(listener: AudioPlayer.AudioPlayerListener) {
this.listener = listener
}
override fun getCurrentPosition(): Int = player.currentWindowIndex
override fun isPlaying(): Boolean = player.playWhenReady
//region Exo player listener
override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters?) {
}
override fun onSeekProcessed() {
}
override fun onTracksChanged(trackGroups: TrackGroupArray?, trackSelections: TrackSelectionArray?) {
listener?.onTrackChanged()
}
override fun onPlayerError(error: ExoPlaybackException?) {
listener?.onPlayerError(error ?: IllegalStateException("Unknown ExoPlayer exception"))
}
override fun onLoadingChanged(isLoading: Boolean) {
if (!isLoading) {
listener?.onLoaded()
}
}
override fun onPositionDiscontinuity(reason: Int) {
val newIndex = player.currentWindowIndex
if (currentIndex != newIndex) {
listener?.onTrackChanged()
currentIndex = newIndex
}
}
override fun onRepeatModeChanged(repeatMode: Int) {
}
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
}
override fun onTimelineChanged(timeline: Timeline?, manifest: Any?) {
}
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
listener?.onPlayEnded()
}
}
//endregion
}
package kz.sdu.qurankz.audioplayer
import android.net.Uri
/**
* A repository interface for audio tracks [Track]
*
* Created by Isco on 2/28/18.
* You'll Never Walk Alone
*/
interface MediaRepository {
val tracks: List<Track>
data class Track(
val title: String,
val album: String,
val uri: Uri
)
}
package kz.sdu.qurankz.audioplayer
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.support.v4.app.NotificationCompat
import android.support.v4.content.ContextCompat
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaButtonReceiver
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import kz.sdu.qurankz.R
private const val NOTIFICATION_DEFAULT_CHANNEL_ID = "default_channel"
/**
* A helper class that provides a media-styled notification with audio controller buttons.
*
* Created by Isco on 2/28/18.
* You'll Never Walk Alone
*/
class MediaStyleNotificationProvider(private val context: Context) {
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel = NotificationChannel(
NOTIFICATION_DEFAULT_CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
)
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(notificationChannel)
}
}
fun getNotification(mediaSession: MediaSessionCompat, playbackState: Int): Notification {
val builder = createNotificationBuilder(mediaSession)
builder.addAction(
NotificationCompat.Action(
R.drawable.ic_skip_previous_white_24px,
context.getString(R.string.notification_previous),
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
)
)
)
if (playbackState == PlaybackStateCompat.STATE_PLAYING) {
builder.addAction(
NotificationCompat.Action(
R.drawable.ic_pause_circle_filled_white_24px,
context.getString(R.string.notification_pause),
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_PLAY_PAUSE
)
)
)
} else {
builder.addAction(
NotificationCompat.Action(
R.drawable.ic_play_circle_filled_white_24px,
context.getString(R.string.notification_play),
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_PLAY_PAUSE
)
)
)
}
builder.addAction(
NotificationCompat.Action(
R.drawable.ic_skip_next_white_24px,
context.getString(R.string.notification_next),
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_SKIP_TO_NEXT
)
)
)
builder.setStyle(android.support.v4.media.app.NotificationCompat.MediaStyle()
// В компактном варианте показывать Action с данным порядковым номером.
// В нашем случае это play/pause.
.setShowActionsInCompactView(1)
// Отображать крестик в углу уведомления для его закрытия.
// Это связано с тем, что для API < 21 из-за ошибки во фреймворке
// пользователь не мог смахнуть уведомление foreground-сервиса
// даже после вызова stopForeground(false).
// Так что это костыль.
// На API >= 21 крестик не отображается, там просто смахиваем уведомление.
.setShowCancelButton(true)
.setCancelButtonIntent(
MediaButtonReceiver.buildMediaButtonPendingIntent(
context,
PlaybackStateCompat.ACTION_STOP
)
)
// Передаем токен. Это важно для Android Wear. Если токен не передать,
// кнопка на Android Wear будет отображаться, но не будет ничего делать
.setMediaSession(mediaSession.sessionToken))
builder.setSmallIcon(R.mipmap.ic_launcher)
builder.color = ContextCompat.getColor(context, R.color.primary)
builder.setShowWhen(false)
// Это важно. Без этой строчки уведомления не отображаются на Android Wear
// и криво отображаются на самом телефоне.
builder.priority = NotificationCompat.PRIORITY_HIGH
builder.setOnlyAlertOnce(true)
return builder.build()
}
private fun createNotificationBuilder(mediaSession: MediaSessionCompat): NotificationCompat.Builder {
val controller: MediaControllerCompat = mediaSession.controller
val mediaMetadata: MediaMetadataCompat = controller.metadata
val description: MediaDescriptionCompat = mediaMetadata.description
val builder: NotificationCompat.Builder = NotificationCompat.Builder(context, NOTIFICATION_DEFAULT_CHANNEL_ID)
builder.setContentTitle(description.title)
.setContentText(description.subtitle)
.setSubText(description.description)
.setLargeIcon(description.iconBitmap)
.setContentIntent(controller.sessionActivity)
.setDeleteIntent(
MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_STOP)
)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
return builder
}
}
package kz.sdu.qurankz.audioplayer
import android.net.Uri
import kz.sdu.qurankz.model.AyatModel
import kz.sdu.qurankz.util.QuranSQLiteOpenHelper
/**
* An implementation of [MediaRepository] that provides a data to load parts of Quran from the given source
*
* Created by Isco on 3/1/18.
* You'll Never Walk Alone
*/
class QuranMediaUriRepository(
sqlHelper: QuranSQLiteOpenHelper,
fromAyat: AyatModel,
toAyat: AyatModel,
sourceUrl: String
) : MediaRepository {
override val tracks: List<MediaRepository.Track>
init {
val surahList = sqlHelper.getSurahInRange(fromAyat.surahId, toAyat.surahId)
val mutableList = mutableListOf<MediaRepository.Track>()
surahList.forEachIndexed { index, item ->
var fromIndex = 1
var toIndex = item.ayaNumber
if (index == 0) {
fromIndex = fromAyat.ayatNumber
}
if (index == surahList.size - 1) {
toIndex = toAyat.ayatNumber
}
if (item.id != 1L && fromIndex == 1 && toIndex > 1) {
// HARDCODE: we are adding an extra ayat, the base one
fromIndex = 0
}
(fromIndex..toIndex).mapTo(mutableList) {
MediaRepository.Track(
"$it-аят", // TODO eliminate the hardcode
item.nameKazakh,
Uri.parse(getAudioFileUrl(sourceUrl, item.id.toInt(), it))
)
}
}
tracks = mutableList
}
private fun getAudioFileUrl(sourceUrl: String, surahId: Int, ayatId: Int): String {
return String.format(sourceUrl + "mp3/%03d%03d.mp3", surahId, ayatId)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment