PlayerAndroid/app/src/main/java/code/name/monkey/retromusic/service/CrossFadePlayer.kt

359 lines
10 KiB
Kotlin

package code.name.monkey.retromusic.service
import android.animation.Animator
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.audiofx.AudioEffect
import android.net.Uri
import android.os.PowerManager
import android.widget.Toast
import code.name.monkey.retromusic.R
import code.name.monkey.retromusic.helper.MusicPlayerRemote
import code.name.monkey.retromusic.service.AudioFader.Companion.createFadeAnimator
import code.name.monkey.retromusic.service.playback.Playback
import code.name.monkey.retromusic.service.playback.Playback.PlaybackCallbacks
import code.name.monkey.retromusic.util.MusicUtil
import code.name.monkey.retromusic.util.PreferenceUtil
import kotlinx.coroutines.*
/** @author Prathamesh M */
/*
* To make Crossfade work we need two MediaPlayer's
* Basically, we switch back and forth between those two mp's
* e.g. When song is about to end (Reaches Crossfade duration) we let current mediaplayer
* play but with decreasing volume and start the player with the next song with increasing volume
* and vice versa for upcoming song and so on.
*/
class CrossFadePlayer(val context: Context) : Playback, MediaPlayer.OnCompletionListener,
MediaPlayer.OnErrorListener {
private var currentPlayer: CurrentPlayer = CurrentPlayer.NOT_SET
private var player1 = MediaPlayer()
private var player2 = MediaPlayer()
private var durationListener = DurationListener()
private var mIsInitialized = false
private var hasDataSource: Boolean = false /* Whether first player has DataSource */
private var fadeInAnimator: Animator? = null
private var fadeOutAnimator: Animator? = null
private var callbacks: PlaybackCallbacks? = null
private var crossFadeDuration = PreferenceUtil.crossFadeDuration
init {
player1.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK)
player2.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK)
currentPlayer = CurrentPlayer.PLAYER_ONE
}
override fun start(): Boolean {
durationListener.start()
return try {
getCurrentPlayer()?.start()
true
} catch (e: IllegalStateException) {
e.printStackTrace()
false
}
}
override fun release() {
getCurrentPlayer()?.release()
getNextPlayer()?.release()
durationListener.stop()
}
override fun setCallbacks(callbacks: PlaybackCallbacks) {
this.callbacks = callbacks
}
override fun stop() {
getCurrentPlayer()?.reset()
mIsInitialized = false
}
override fun pause(): Boolean {
durationListener.stop()
cancelFade()
getCurrentPlayer()?.let {
if (it.isPlaying) {
it.pause()
}
}
getNextPlayer()?.let {
if (it.isPlaying) {
it.pause()
}
}
return true
}
override fun seek(whereto: Int): Int {
cancelFade()
getNextPlayer()?.stop()
return try {
getCurrentPlayer()?.seekTo(whereto)
whereto
} catch (e: java.lang.IllegalStateException) {
e.printStackTrace()
-1
}
}
override fun setVolume(vol: Float): Boolean {
cancelFade()
return try {
getCurrentPlayer()?.setVolume(vol, vol)
true
} catch (e: IllegalStateException) {
e.printStackTrace()
false
}
}
override val isInitialized: Boolean
get() = mIsInitialized
override val isPlaying: Boolean
get() = mIsInitialized && getCurrentPlayer()?.isPlaying == true
// This has to run when queue is changed or song is changed manually by user
fun sourceChangedByUser() {
hasDataSource = false
cancelFade()
getCurrentPlayer()?.apply {
if (isPlaying) stop()
}
getNextPlayer()?.apply {
if (isPlaying) stop()
}
}
override fun setDataSource(path: String): Boolean {
cancelFade()
mIsInitialized = false
/* We've already set DataSource if initialized is true in setNextDataSource */
if (!hasDataSource) {
getCurrentPlayer()?.let { mIsInitialized = setDataSourceImpl(it, path) }
hasDataSource = true
} else {
mIsInitialized = true
}
return mIsInitialized
}
override fun setNextDataSource(path: String?) {}
/**
* @param player The {@link MediaPlayer} to use
* @param path The path of the file, or the http/rtsp URL of the stream you want to play
* @return True if the <code>player</code> has been prepared and is ready to play, false otherwise
*/
private fun setDataSourceImpl(
player: MediaPlayer,
path: String
): Boolean {
player.reset()
player.setOnPreparedListener(null)
try {
if (path.startsWith("content://")) {
player.setDataSource(context, Uri.parse(path))
} else {
player.setDataSource(path)
}
player.setAudioAttributes(
AudioAttributes.Builder().setLegacyStreamType(AudioManager.STREAM_MUSIC).build()
)
player.prepareAsync()
} catch (e: Exception) {
e.printStackTrace()
return false
}
player.setOnCompletionListener(this)
player.setOnErrorListener(this)
val intent = Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION)
intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId)
intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName)
intent.putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC)
context.sendBroadcast(intent)
return true
}
override fun setAudioSessionId(sessionId: Int): Boolean {
return try {
getCurrentPlayer()?.audioSessionId = sessionId
true
} catch (e: IllegalArgumentException) {
e.printStackTrace()
false
} catch (e: IllegalStateException) {
e.printStackTrace()
false
}
}
override val audioSessionId: Int
get() = getCurrentPlayer()?.audioSessionId!!
/**
* Gets the duration of the file.
*
* @return The duration in milliseconds
*/
override fun duration(): Int {
return if (!mIsInitialized) {
-1
} else try {
getCurrentPlayer()?.duration!!
} catch (e: IllegalStateException) {
e.printStackTrace()
-1
}
}
/**
* Gets the current position in audio.
* @return The position in milliseconds
*/
override fun position(): Int {
return if (!mIsInitialized) {
-1
} else try {
getCurrentPlayer()?.currentPosition!!
} catch (e: IllegalStateException) {
e.printStackTrace()
-1
}
}
override fun onCompletion(mp: MediaPlayer?) {
if (mp == getCurrentPlayer()) {
callbacks?.onTrackEnded()
}
}
private fun getCurrentPlayer(): MediaPlayer? {
return when (currentPlayer) {
CurrentPlayer.PLAYER_ONE -> {
player1
}
CurrentPlayer.PLAYER_TWO -> {
player2
}
CurrentPlayer.NOT_SET -> {
null
}
}
}
private fun getNextPlayer(): MediaPlayer? {
return when (currentPlayer) {
CurrentPlayer.PLAYER_ONE -> {
player2
}
CurrentPlayer.PLAYER_TWO -> {
player1
}
CurrentPlayer.NOT_SET -> {
null
}
}
}
private fun fadeIn(mediaPlayer: MediaPlayer) {
fadeInAnimator = createFadeAnimator(true, mediaPlayer) {
fadeInAnimator = null
durationListener.start()
}
fadeInAnimator?.start()
}
private fun fadeOut(mediaPlayer: MediaPlayer) {
fadeOutAnimator = createFadeAnimator(false, mediaPlayer) {
fadeOutAnimator = null
mediaPlayer.stop()
}
fadeOutAnimator?.start()
}
private fun cancelFade() {
fadeInAnimator = null
fadeOutAnimator = null
}
override fun onError(mp: MediaPlayer?, what: Int, extra: Int): Boolean {
mIsInitialized = false
mp?.release()
player1 = MediaPlayer()
player2 = MediaPlayer()
mIsInitialized = true
mp?.setWakeMode(context, PowerManager.PARTIAL_WAKE_LOCK)
Toast.makeText(
context,
context.resources.getString(R.string.unplayable_file),
Toast.LENGTH_SHORT
)
.show()
return false
}
enum class CurrentPlayer {
PLAYER_ONE,
PLAYER_TWO,
NOT_SET
}
inner class DurationListener : CoroutineScope by crossFadeScope() {
private var job: Job? = null
fun start() {
job?.cancel()
job = launch {
while (true) {
delay(250)
onDurationUpdated(position(), duration())
}
}
}
fun stop() {
job?.cancel()
}
}
fun onDurationUpdated(progress: Int, total: Int) {
if (total > 0 && (total - progress).div(1000) == crossFadeDuration) {
getNextPlayer()?.let { player ->
val nextSong = MusicPlayerRemote.nextSong
if (nextSong != null) {
setDataSourceImpl(player, MusicUtil.getSongFileUri(nextSong.id).toString())
// Switch to other player / Crossfade only if next song exists
switchPlayer()
}
}
}
}
private fun switchPlayer() {
getNextPlayer()?.start()
getCurrentPlayer()?.let { fadeOut(it) }
getNextPlayer()?.let { fadeIn(it) }
currentPlayer =
if (currentPlayer == CurrentPlayer.PLAYER_ONE || currentPlayer == CurrentPlayer.NOT_SET) {
CurrentPlayer.PLAYER_TWO
} else {
CurrentPlayer.PLAYER_ONE
}
callbacks?.onTrackEndedWithCrossfade()
}
override fun setCrossFadeDuration(duration: Int) {
crossFadeDuration = duration
}
}
internal fun crossFadeScope(): CoroutineScope = CoroutineScope(Job() + Dispatchers.Main)