Last active
March 16, 2022 15:48
-
-
Save jemshit/3fd38d3ba04556c99bd7d953f5c056f7 to your computer and use it in GitHub Desktop.
Android MediaPlayer has internal state of its own, but there is no get method for it. State diagram is here: https://developer.android.com/images/mediaplayer_state_diagram.gif. Invocation of method that is not allowed at current state results in exception. To avoid such exception and track states of MediaPlayer, MediaPlayerStateMachine is writte…
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.util.Log | |
fun emptyMPStateMachineLogger(tag: String, message: String) { | |
// NoOp | |
} | |
fun defaultMPStateMachineLogger(tag: String, message: String) { | |
Log.d(tag, message) | |
} | |
const val MP_STATE_MACHINE_LOG_TAG = "MediaPlayerStateMachine" | |
sealed class MediaPlayerState { | |
object Idle : MediaPlayerState() | |
object End : MediaPlayerState() | |
object Error : MediaPlayerState() | |
object Initialized : MediaPlayerState() | |
object Preparing : MediaPlayerState() | |
object Prepared : MediaPlayerState() | |
object Started : MediaPlayerState() | |
object Stopped : MediaPlayerState() | |
object Paused : MediaPlayerState() | |
object Completed : MediaPlayerState() | |
} | |
sealed class MediaPlayerEvent { | |
object OnRelease : MediaPlayerEvent() | |
object OnReset : MediaPlayerEvent() | |
object OnSetDataSource : MediaPlayerEvent() | |
object OnError : MediaPlayerEvent() | |
object OnPrepare : MediaPlayerEvent() | |
object OnPrepareAsync : MediaPlayerEvent() | |
object OnPrepared : MediaPlayerEvent() | |
object OnSeekTo : MediaPlayerEvent() | |
object OnStop : MediaPlayerEvent() | |
object OnStart : MediaPlayerEvent() | |
object OnPause : MediaPlayerEvent() | |
data class OnComplete(val looping: Boolean) : MediaPlayerEvent() | |
} | |
sealed class MediaPlayerAction { | |
object SetLooping : MediaPlayerAction() | |
object SetVolume : MediaPlayerAction() | |
object SetAudioAttributes : MediaPlayerAction() | |
} | |
class MediaPlayerStateMachine(private val logger: (String, String) -> Unit = ::defaultMPStateMachineLogger, | |
private val logTag: String = MP_STATE_MACHINE_LOG_TAG) { | |
var state: MediaPlayerState = MediaPlayerState.Idle | |
fun transition(event: MediaPlayerEvent, | |
afterTransition: (() -> Unit)? = null | |
): Boolean = synchronized(this) { | |
logger(logTag, "transitionRequest -> event:${event.javaClass.simpleName}, currentState:${state.javaClass.simpleName}") | |
var transitionSuccess = false | |
if (event is MediaPlayerEvent.OnRelease) { | |
// if old sate is end or error, it can not transition to anything | |
transitionSuccess = !isFinalState() | |
if (transitionSuccess) | |
state = MediaPlayerState.End | |
} else if (event is MediaPlayerEvent.OnError) { | |
// if old sate is end or error, it can not transition to anything | |
transitionSuccess = !isFinalState() | |
if (transitionSuccess) | |
state = MediaPlayerState.Error | |
} else if (event is MediaPlayerEvent.OnReset) { | |
transitionSuccess = !isFinalState() | |
if (transitionSuccess) | |
state = MediaPlayerState.Idle | |
} else { | |
when (state) { | |
is MediaPlayerState.Idle -> { | |
when (event) { | |
is MediaPlayerEvent.OnSetDataSource -> { | |
state = MediaPlayerState.Initialized | |
transitionSuccess = true | |
} | |
} | |
} | |
is MediaPlayerState.End -> { | |
// NoOp after End | |
} | |
is MediaPlayerState.Initialized -> { | |
when (event) { | |
is MediaPlayerEvent.OnPrepare -> { | |
state = MediaPlayerState.Prepared | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnPrepareAsync -> { | |
state = MediaPlayerState.Preparing | |
transitionSuccess = true | |
} | |
} | |
} | |
is MediaPlayerState.Preparing -> { | |
when (event) { | |
is MediaPlayerEvent.OnPrepared -> { | |
state = MediaPlayerState.Prepared | |
transitionSuccess = true | |
} | |
} | |
} | |
is MediaPlayerState.Prepared -> { | |
when (event) { | |
is MediaPlayerEvent.OnSeekTo -> { | |
state = MediaPlayerState.Prepared | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStop -> { | |
state = MediaPlayerState.Stopped | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStart -> { | |
state = MediaPlayerState.Started | |
transitionSuccess = true | |
} | |
} | |
} | |
is MediaPlayerState.Started -> { | |
when (event) { | |
is MediaPlayerEvent.OnStop -> { | |
state = MediaPlayerState.Stopped | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnSeekTo -> { | |
state = MediaPlayerState.Started | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStart -> { | |
state = MediaPlayerState.Started | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnComplete -> { | |
if (event.looping) | |
state = MediaPlayerState.Started | |
else | |
state = MediaPlayerState.Completed | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnPause -> { | |
state = MediaPlayerState.Paused | |
transitionSuccess = true | |
} | |
} | |
} | |
is MediaPlayerState.Stopped -> { | |
when (event) { | |
is MediaPlayerEvent.OnPrepare -> { | |
state = MediaPlayerState.Prepared | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnPrepareAsync -> { | |
state = MediaPlayerState.Preparing | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStop -> { | |
state = MediaPlayerState.Stopped | |
transitionSuccess = true | |
} | |
} | |
} | |
is MediaPlayerState.Paused -> { | |
when (event) { | |
is MediaPlayerEvent.OnSeekTo -> { | |
state = MediaPlayerState.Paused | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnPause -> { | |
state = MediaPlayerState.Paused | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStart -> { | |
state = MediaPlayerState.Started | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStop -> { | |
state = MediaPlayerState.Stopped | |
transitionSuccess = true | |
} | |
} | |
} | |
is MediaPlayerState.Completed -> { | |
when (event) { | |
is MediaPlayerEvent.OnSeekTo -> { | |
state = MediaPlayerState.Completed | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStart -> { | |
state = MediaPlayerState.Started | |
transitionSuccess = true | |
} | |
is MediaPlayerEvent.OnStop -> { | |
state = MediaPlayerState.Stopped | |
transitionSuccess = true | |
} | |
} | |
} | |
} | |
} | |
if (transitionSuccess) { | |
afterTransition?.invoke() | |
logger(logTag, "transitionSuccess <- toState:${state.javaClass.simpleName}") | |
} else { | |
logger(logTag, "transitionFailed <-") | |
} | |
return transitionSuccess | |
} | |
fun action(action: MediaPlayerAction, | |
afterAction: () -> Unit | |
) = synchronized(this) { | |
val actionAllowed = when (action) { | |
MediaPlayerAction.SetLooping, | |
MediaPlayerAction.SetAudioAttributes, | |
MediaPlayerAction.SetVolume -> { | |
!isFinalState() | |
&& state != MediaPlayerState.Idle | |
&& state != MediaPlayerState.Error | |
} | |
} | |
logger(logTag, "action: $actionAllowed; state:${state.javaClass.simpleName}") | |
if (actionAllowed) | |
afterAction.invoke() | |
} | |
private fun isFinalState(): Boolean { | |
return state is MediaPlayerState.End | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.annotation.SuppressLint | |
import android.content.Context | |
import android.media.AudioAttributes | |
import android.media.MediaPlayer | |
import android.media.MediaPlayer.SEEK_CLOSEST_SYNC | |
import android.net.Uri | |
import android.os.Build | |
import android.util.Log | |
import androidx.annotation.RequiresApi | |
import androidx.lifecycle.Lifecycle | |
import androidx.lifecycle.LifecycleObserver | |
import androidx.lifecycle.OnLifecycleEvent | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.isActive | |
import kotlinx.coroutines.withContext | |
import java.net.HttpCookie | |
fun emptyMPWrapperLogger(tag: String, message: String) { | |
// NoOp | |
} | |
fun defaultMPWrapperLogger(tag: String, message: String) { | |
Log.d(tag, message) | |
} | |
const val MP_WRAPPER_LOG_TAG = "MediaPlayerWrapper" | |
class MediaPlayerWrapper(private val useLifecycleObserver: Boolean, | |
private val playAfterPrepared: Boolean, | |
private val logger: (String, String) -> Unit = ::defaultMPWrapperLogger, | |
private val logTag: String = MP_WRAPPER_LOG_TAG | |
) : LifecycleObserver, | |
KoinComponent, | |
MediaPlayer.OnPreparedListener, | |
MediaPlayer.OnErrorListener, | |
MediaPlayer.OnCompletionListener { | |
private val mediaPlayer: MediaPlayer = MediaPlayer() | |
private val stateMachine = MediaPlayerStateMachine(logger = ::emptyMPStateMachineLogger, logTag = "AUTOPILOT MPStateMachine") | |
fun getState(): MediaPlayerState = stateMachine.state | |
private var lifecycleStopped = false | |
init { | |
mediaPlayer.setOnPreparedListener(this) | |
mediaPlayer.setOnErrorListener(this) | |
mediaPlayer.setOnCompletionListener(this) | |
} | |
// region Lifecycle callbacks | |
@OnLifecycleEvent(Lifecycle.Event.ON_START) | |
fun onStart() { | |
logger(logTag, "onStart -> useLifecycleObserver:$useLifecycleObserver") | |
lifecycleStopped = false | |
} | |
@OnLifecycleEvent(Lifecycle.Event.ON_STOP) | |
fun onStop() { | |
logger(logTag, "onStop -> useLifecycleObserver:$useLifecycleObserver") | |
lifecycleStopped = true | |
if (useLifecycleObserver) | |
pause() | |
} | |
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) | |
fun onDestroy() { | |
logger(logTag, "onDestroy -> useLifecycleObserver:$useLifecycleObserver") | |
lifecycleStopped = true | |
if (useLifecycleObserver) | |
release() | |
} | |
//endregion | |
//region MediaPlayer Callbacks | |
private val onPreparedListeners = mutableListOf<MediaPlayer.OnPreparedListener>() | |
private val onErrorListeners = mutableListOf<MediaPlayer.OnErrorListener>() | |
private val onCompleteListeners = mutableListOf<MediaPlayer.OnCompletionListener>() | |
fun setOnPreparedListener(listener: MediaPlayer.OnPreparedListener) = | |
onPreparedListeners.add(listener) | |
fun removeOnPreparedListener(listener: MediaPlayer.OnPreparedListener) = | |
onPreparedListeners.remove(listener) | |
fun setOnErrorListener(listener: MediaPlayer.OnErrorListener) = | |
onErrorListeners.add(listener) | |
fun removeOnErrorListener(listener: MediaPlayer.OnErrorListener) = | |
onErrorListeners.remove(listener) | |
fun setOnCompletionListener(listener: MediaPlayer.OnCompletionListener) = | |
onCompleteListeners.add(listener) | |
fun removeOnCompletionListener(listener: MediaPlayer.OnCompletionListener) = | |
onCompleteListeners.remove(listener) | |
override fun onPrepared(mp: MediaPlayer?) { | |
if (stateMachine.state != MediaPlayerState.Prepared) { | |
stateMachine.transition(MediaPlayerEvent.OnPrepared) { | |
logger(logTag, "onPrepared callback <- transition success, playAfterPrepared:$playAfterPrepared, lifecycleStopped:$lifecycleStopped") | |
onPreparedListeners.forEach { it.onPrepared(mp) } | |
if (playAfterPrepared && !lifecycleStopped) | |
start() | |
} | |
} else { | |
onPreparedListeners.forEach { it.onPrepared(mp) } | |
if (playAfterPrepared && !lifecycleStopped) | |
start() | |
} | |
} | |
override fun onError(mp: MediaPlayer?, what: Int, extra: Int): Boolean { | |
logger(logTag, "onError <- callback") | |
stateMachine.transition(MediaPlayerEvent.OnError) | |
onErrorListeners.forEach { it.onError(mp, what, extra) } | |
return true | |
} | |
override fun onCompletion(mp: MediaPlayer?) { | |
logger(logTag, "onCompletion <- callback, isLooping:${mediaPlayer.isLooping}") | |
stateMachine.transition(MediaPlayerEvent.OnComplete(mediaPlayer.isLooping)) | |
onCompleteListeners.forEach { it.onCompletion(mp) } | |
} | |
//endregion | |
// region Method delegations after StateMachine check | |
fun reset() { | |
stateMachine.transition(MediaPlayerEvent.OnReset) { | |
mediaPlayer.reset() | |
logger(logTag, "reset <- success") | |
} | |
} | |
fun release() { | |
stateMachine.transition(MediaPlayerEvent.OnRelease) { | |
mediaPlayer.release() | |
logger(logTag, "release <- success") | |
} | |
} | |
fun setDataSource(path: String) { | |
stateMachine.transition(MediaPlayerEvent.OnSetDataSource) { | |
mediaPlayer.setDataSource(path) | |
logger(logTag, "setDataSource <- success") | |
} | |
} | |
fun setDataSource(context: Context, uri: Uri) { | |
stateMachine.transition(MediaPlayerEvent.OnSetDataSource) { | |
mediaPlayer.setDataSource(context, uri) | |
logger(logTag, "setDataSource <- success, [uri:$uri]") | |
} | |
} | |
@RequiresApi(Build.VERSION_CODES.O) | |
fun setDataSource(context: Context, | |
uri: Uri, | |
headers: Map<String, String>) { | |
stateMachine.transition(MediaPlayerEvent.OnSetDataSource) { | |
mediaPlayer.setDataSource(context, uri, headers) | |
logger(logTag, "setDataSource <- success, [uri:$uri]") | |
} | |
} | |
@RequiresApi(Build.VERSION_CODES.O) | |
fun setDataSource(context: Context, | |
uri: Uri, | |
headers: Map<String, String>, | |
cookies: List<HttpCookie>) { | |
stateMachine.transition(MediaPlayerEvent.OnSetDataSource) { | |
mediaPlayer.setDataSource(context, uri, headers, cookies) | |
logger(logTag, "setDataSource <- success, [uri:$uri]") | |
} | |
} | |
fun prepare() { | |
stateMachine.transition(MediaPlayerEvent.OnPrepare) { | |
mediaPlayer.prepare() | |
logger(logTag, "prepare <- success") | |
} | |
} | |
/*suspend fun prepareSuspended() = withContext(dispatcherProvider.computation()) { | |
stateMachine.transition(MediaPlayerEvent.OnPrepare) { | |
mediaPlayer.prepare() | |
logger(logTag, "prepareSuspended <- success") | |
} | |
}*/ | |
fun prepareAsync() { | |
stateMachine.transition(MediaPlayerEvent.OnPrepareAsync) { | |
mediaPlayer.prepareAsync() | |
logger(logTag, "prepareAsync <- success") | |
} | |
} | |
fun seekTo(msec: Long, @SuppressLint("InlinedApi") mode: Int = SEEK_CLOSEST_SYNC) { | |
stateMachine.transition(MediaPlayerEvent.OnSeekTo) { | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
mediaPlayer.seekTo(msec, mode) | |
} else { | |
mediaPlayer.seekTo(msec.toInt()) | |
} | |
logger(logTag, "seekTo <- success, [msec:$msec, mode:$mode]") | |
} | |
} | |
fun start() { | |
stateMachine.transition(MediaPlayerEvent.OnStart) { | |
mediaPlayer.start() | |
logger(logTag, "start <- success") | |
} | |
} | |
fun stop() { | |
stateMachine.transition(MediaPlayerEvent.OnStop) { | |
mediaPlayer.stop() | |
logger(logTag, "stop <- success") | |
} | |
} | |
fun pause() { | |
stateMachine.transition(MediaPlayerEvent.OnPause) { | |
mediaPlayer.pause() | |
logger(logTag, "pause <- success") | |
} | |
} | |
fun setLooping(looping: Boolean) { | |
stateMachine.action(MediaPlayerAction.SetLooping) { | |
mediaPlayer.isLooping = looping | |
} | |
} | |
fun setVolume(left: Float, right: Float) { | |
stateMachine.action(MediaPlayerAction.SetVolume) { | |
mediaPlayer.setVolume(left, right) | |
} | |
} | |
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) | |
fun setAudioAttributes(attributes: Any) { | |
stateMachine.action(MediaPlayerAction.SetAudioAttributes) { | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) | |
mediaPlayer.setAudioAttributes(attributes as AudioAttributes) | |
} | |
} | |
//endregion | |
fun play() { | |
if (stateMachine.state == MediaPlayerState.Prepared | |
|| stateMachine.state == MediaPlayerState.Paused | |
|| stateMachine.state == MediaPlayerState.Completed) { | |
logger(logTag, "play -> state:${stateMachine.state.javaClass.simpleName}, start() called") | |
start() | |
} else if (stateMachine.state == MediaPlayerState.Initialized | |
|| stateMachine.state == MediaPlayerState.Stopped) { | |
logger(logTag, "play -> state:${stateMachine.state.javaClass.simpleName}, prepareAsync() called") | |
prepareAsync() | |
} | |
} | |
/*suspend fun seekToAndPlay(msec: Long, | |
@SuppressLint("InlinedApi") mode: Int = SEEK_CLOSEST_SYNC, | |
volume: Float | |
) = withContext(dispatcherProvider.computation()) { | |
fun _seekToAndPlay() { | |
seekTo(msec, mode) | |
setVolume(volume, volume) | |
start() | |
} | |
if (stateMachine.state == MediaPlayerState.Prepared | |
|| stateMachine.state == MediaPlayerState.Paused | |
|| stateMachine.state == MediaPlayerState.Completed) { | |
logger(logTag, "seekToAndPlay -> state:${stateMachine.state.javaClass.simpleName}, seekTo(), start() called. [msec:$msec, mode:$mode]") | |
_seekToAndPlay() | |
} else if (stateMachine.state == MediaPlayerState.Initialized | |
|| stateMachine.state == MediaPlayerState.Stopped) { | |
logger(logTag, "seekToAndPlay -> state:${stateMachine.state.javaClass.simpleName}, prepare() called") | |
prepare() | |
if (!lifecycleStopped && isActive) { | |
logger(logTag, "seekToAndPlay -> state:${stateMachine.state.javaClass.simpleName}, seekTo(), start() called. [msec:$msec, mode:$mode]") | |
_seekToAndPlay() | |
} | |
} else { | |
if (stateMachine.state == MediaPlayerState.Preparing) { | |
logger(logTag, "seekToAndPlay -> state:${stateMachine.state.javaClass.simpleName}, DELAYED") | |
delay(50L) | |
if (stateMachine.state == MediaPlayerState.Prepared) { | |
logger(logTag, "seekToAndPlay -> state:${stateMachine.state.javaClass.simpleName}, after delay and prepared, seekTo(), start() called. [msec:$msec, mode:$mode]") | |
_seekToAndPlay() | |
} else { | |
logger(logTag, "seekToAndPlay -> state:${stateMachine.state.javaClass.simpleName}, after delay, still NOT PREPARED!") | |
} | |
} else { | |
logger(logTag, "seekToAndPlay -> state:${stateMachine.state.javaClass.simpleName}, INVALID STATE!") | |
} | |
} | |
}*/ | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
You can replace your
MediaPlayer
withMediaPlayerWrapper
and most methods are delegate methods with same name. If you need to accessMediaPlayer
itself, useMediaPlayerWrapper.mediaPlayer
.It is suggested to create some
MyAudioManager
class and useMediaPlayerWrapper
in it. Other classes should call yourMyAudioManager
and be abstracted away fromMediaPlayer
implementation.MediaPlayerWrapper
isLifecycleObserver
, don't forget to bind it with lifecycle.