-
Notifications
You must be signed in to change notification settings - Fork 528
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
* copied files over from branch audio-player * using coroutines to schedule seek bar updates * Updated to add correct locking and more checks * Updated comments * Added change data source function and change to AsyncResult * Added test cases for controller and observer * init now return LiveData, release removes observers * add more locking * Added more tests * Finished test cases * Updated comments * Updated test names * Fixed scheduling and error test cases * Added getPlayProgressLiveData and minor fixes to test cases
- Loading branch information
Showing
4 changed files
with
706 additions
and
1 deletion.
There are no files selected for viewing
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
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
223 changes: 223 additions & 0 deletions
223
domain/src/main/java/org/oppia/domain/audio/AudioPlayerController.kt
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,223 @@ | ||
package org.oppia.domain.audio | ||
|
||
import android.content.Context | ||
import android.media.MediaPlayer | ||
import android.net.Uri | ||
import androidx.annotation.VisibleForTesting | ||
import androidx.fragment.app.Fragment | ||
import androidx.lifecycle.LiveData | ||
import androidx.lifecycle.MutableLiveData | ||
import kotlinx.coroutines.CoroutineDispatcher | ||
import kotlinx.coroutines.CoroutineScope | ||
import kotlinx.coroutines.Job | ||
import kotlinx.coroutines.delay | ||
import kotlinx.coroutines.launch | ||
import org.oppia.util.data.AsyncResult | ||
import org.oppia.util.threading.BackgroundDispatcher | ||
import java.util.concurrent.TimeUnit | ||
import java.util.concurrent.locks.ReentrantLock | ||
import javax.inject.Inject | ||
import kotlin.concurrent.withLock | ||
|
||
/** | ||
* Controller which provides audio playing capabilities. | ||
* [initializeMediaPlayer] should be used to download a specific audio track. | ||
* [releaseMediaPlayer] should be used to clean up the controller's resources. | ||
* See documentation for both to understand how to use them correctly. | ||
*/ | ||
class AudioPlayerController @Inject constructor( | ||
private val context: Context, | ||
private val fragment: Fragment, | ||
@BackgroundDispatcher private val backgroundDispatcher: CoroutineDispatcher | ||
) { | ||
|
||
inner class AudioMutableLiveData : MutableLiveData<AsyncResult<PlayProgress>>() { | ||
override fun onActive() { | ||
super.onActive() | ||
audioLock.withLock { | ||
observerActive = true | ||
if (prepared && mediaPlayer.isPlaying) | ||
scheduleNextSeekBarUpdate() | ||
} | ||
} | ||
|
||
override fun onInactive() { | ||
super.onInactive() | ||
audioLock.withLock { | ||
observerActive = false | ||
stopUpdatingSeekBar() | ||
} | ||
} | ||
} | ||
|
||
/** Represents current state of internal Media Player. */ | ||
enum class PlayStatus { | ||
PREPARED, // mediaPlayer in "Prepared" state, ready to play(), pause(), seekTo(). | ||
PLAYING, // mediaPlayer in "Started" state, ready to pause(), seekTo(). | ||
PAUSED, // mediaPlayer in "Paused" state, ready to play(), seekTo(). | ||
COMPLETED // mediaPlayer in "PlaybackCompleted" state, ready to play(), seekTo(). | ||
} | ||
|
||
/** | ||
* [type]: See above. | ||
* [position]: Represents mediaPlayer's current position in playback. | ||
* [duration]: Represents duration of current audio. | ||
*/ | ||
class PlayProgress(val type: PlayStatus, val position: Int, val duration: Int) | ||
|
||
private val mediaPlayer: MediaPlayer by lazy { MediaPlayer() } | ||
private var playProgress: AudioMutableLiveData? = null | ||
private var nextUpdateJob: Job? = null | ||
private val audioLock = ReentrantLock() | ||
|
||
private var prepared = false | ||
private var observerActive = false | ||
private var mediaPlayerActive = false | ||
|
||
private val SEEKBAR_UPDATE_FREQUENCY = TimeUnit.SECONDS.toMillis(1) | ||
|
||
/** | ||
* Loads audio source from a URL and return LiveData to send updates. | ||
* This controller cannot already be initialized. | ||
*/ | ||
fun initializeMediaPlayer(url: String): LiveData<AsyncResult<PlayProgress>> { | ||
audioLock.withLock { | ||
check(!mediaPlayerActive) { "Media player has already been initialized" } | ||
mediaPlayer.reset() | ||
mediaPlayerActive = true | ||
setMediaPlayerListeners() | ||
prepareDataSource(url) | ||
} | ||
val progressLiveData = AudioMutableLiveData() | ||
playProgress = progressLiveData | ||
return progressLiveData | ||
} | ||
|
||
/** | ||
* Changes audio source to specified. | ||
* Stops sending seek bar updates and put MediaPlayer in preparing state. | ||
*/ | ||
fun changeDataSource(url: String) { | ||
audioLock.withLock { | ||
prepared = false | ||
stopUpdatingSeekBar() | ||
mediaPlayer.reset() | ||
prepareDataSource(url) | ||
} | ||
} | ||
|
||
private fun setMediaPlayerListeners() { | ||
mediaPlayer.setOnCompletionListener { | ||
stopUpdatingSeekBar() | ||
playProgress?.value = | ||
AsyncResult.success(PlayProgress(PlayStatus.COMPLETED, 0, mediaPlayer.duration)) | ||
} | ||
mediaPlayer.setOnPreparedListener { | ||
prepared = true | ||
playProgress?.value = | ||
AsyncResult.success(PlayProgress(PlayStatus.PREPARED, 0, it.duration)) | ||
} | ||
} | ||
|
||
private fun prepareDataSource(url: String) { | ||
mediaPlayer.setDataSource(context, Uri.parse(url)) | ||
mediaPlayer.prepareAsync() | ||
playProgress?.value = AsyncResult.pending() | ||
} | ||
|
||
/** | ||
* Puts MediaPlayer in started state and begins sending seek bar updates. | ||
* Controller must already have audio prepared. | ||
*/ | ||
fun play() { | ||
audioLock.withLock { | ||
check(prepared) { "Media Player not in a prepared state" } | ||
if (!mediaPlayer.isPlaying) { | ||
mediaPlayer.start() | ||
scheduleNextSeekBarUpdate() | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Puts MediaPlayer in paused state and stops sending seek bar updates. | ||
* Controller must already have audio prepared. | ||
*/ | ||
fun pause() { | ||
audioLock.withLock { | ||
check(prepared) { "Media Player not in a prepared state" } | ||
if (mediaPlayer.isPlaying) { | ||
playProgress?.value = | ||
AsyncResult.success( | ||
PlayProgress(PlayStatus.PAUSED, mediaPlayer.currentPosition, mediaPlayer.duration) | ||
) | ||
mediaPlayer.pause() | ||
stopUpdatingSeekBar() | ||
} | ||
} | ||
} | ||
|
||
private fun scheduleNextSeekBarUpdate() { | ||
audioLock.withLock { | ||
if (observerActive && prepared) { | ||
nextUpdateJob = CoroutineScope(backgroundDispatcher).launch { | ||
updateSeekBar() | ||
delay(SEEKBAR_UPDATE_FREQUENCY) | ||
scheduleNextSeekBarUpdate() | ||
} | ||
} | ||
} | ||
} | ||
|
||
private fun updateSeekBar() { | ||
audioLock.withLock { | ||
if (mediaPlayer.isPlaying) { | ||
playProgress?.postValue( | ||
AsyncResult.success( | ||
PlayProgress(PlayStatus.PLAYING, mediaPlayer.currentPosition, mediaPlayer.duration) | ||
) | ||
) | ||
} | ||
} | ||
} | ||
|
||
private fun stopUpdatingSeekBar() { | ||
audioLock.withLock { | ||
nextUpdateJob?.cancel() | ||
nextUpdateJob = null | ||
} | ||
} | ||
|
||
/** | ||
* Puts MediaPlayer in end state and releases resources. | ||
* Stop updating seek bar and removes all observers. | ||
* MediaPlayer must already be initialized. | ||
*/ | ||
fun releaseMediaPlayer() { | ||
audioLock.withLock { | ||
check(mediaPlayerActive) { "Media player has not been previously initialized" } | ||
mediaPlayerActive = false | ||
prepared = false | ||
mediaPlayer.release() | ||
stopUpdatingSeekBar() | ||
playProgress?.removeObservers(fragment) | ||
playProgress = null | ||
} | ||
} | ||
|
||
/** | ||
* Seek to specific position in MediaPlayer. | ||
* Controller must already have audio prepared. | ||
*/ | ||
fun seekTo(position: Int) { | ||
audioLock.withLock { | ||
check(prepared) { "Media Player not in a prepared state" } | ||
mediaPlayer.seekTo(position) | ||
} | ||
} | ||
|
||
fun getPlayProgressLiveData(): LiveData<AsyncResult<PlayProgress>>? = playProgress | ||
|
||
@VisibleForTesting(otherwise = VisibleForTesting.NONE) | ||
fun getTestMediaPlayer(): MediaPlayer = mediaPlayer | ||
} |
Oops, something went wrong.