Skip to content

Commit

Permalink
Fix #123 & #116: Functional AudioPlayerController (#149)
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
jamesxu0 authored Sep 27, 2019
1 parent 86b8e7f commit 3c1d32e
Show file tree
Hide file tree
Showing 4 changed files with 706 additions and 1 deletion.
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="org.oppia.app">

<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".application.OppiaApplication"
android:allowBackup="true"
Expand Down
2 changes: 2 additions & 0 deletions domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ dependencies {
'com.google.dagger:dagger:2.24',
'com.google.truth:truth:0.43',
'junit:junit:4.12',
"org.jetbrains.kotlin:kotlin-reflect:$kotlin_version",
"org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version",
'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2',
'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2',
'org.mockito:mockito-core:2.19.0',
Expand Down
223 changes: 223 additions & 0 deletions domain/src/main/java/org/oppia/domain/audio/AudioPlayerController.kt
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
}
Loading

0 comments on commit 3c1d32e

Please sign in to comment.