Enable seeking a recorded voice message (#1758)
This commit is contained in:
parent
941c196dcf
commit
7a4adf3e28
8 changed files with 261 additions and 95 deletions
|
|
@ -17,96 +17,233 @@
|
||||||
package io.element.android.features.messages.impl.voicemessages.composer
|
package io.element.android.features.messages.impl.voicemessages.composer
|
||||||
|
|
||||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.scan
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A media player for the voice message composer.
|
* A media player for the voice message composer.
|
||||||
*
|
*
|
||||||
* @param mediaPlayer The [MediaPlayer] to use.
|
* @param mediaPlayer The [MediaPlayer] to use.
|
||||||
|
* @param coroutineScope
|
||||||
*/
|
*/
|
||||||
class VoiceMessageComposerPlayer @Inject constructor(
|
class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
private val mediaPlayer: MediaPlayer,
|
private val mediaPlayer: MediaPlayer,
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
private var lastPlayedMediaPath: String? = null
|
companion object {
|
||||||
private val curPlayingMediaId
|
const val MIME_TYPE = "audio/ogg"
|
||||||
get() = mediaPlayer.state.value.mediaId
|
}
|
||||||
|
|
||||||
val state: Flow<State> = mediaPlayer.state.map { state ->
|
private var mediaPath: String? = null
|
||||||
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
|
|
||||||
return@map State.NotLoaded
|
private var seekJob: Job? = null
|
||||||
|
private val seekingTo = MutableStateFlow<Float?>(null)
|
||||||
|
|
||||||
|
val state: Flow<State> = combine(mediaPlayer.state, seekingTo) { state, seekingTo ->
|
||||||
|
state to seekingTo
|
||||||
|
}.scan(InternalState.NotLoaded) { prevState, (state, seekingTo) ->
|
||||||
|
if (mediaPath == null || mediaPath != state.mediaId) {
|
||||||
|
return@scan InternalState.NotLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
State(
|
InternalState(
|
||||||
isPlaying = state.isPlaying,
|
playState = calcPlayState(prevState.playState, seekingTo, state),
|
||||||
currentPosition = state.currentPosition,
|
currentPosition = state.currentPosition,
|
||||||
duration = state.duration ?: 0L,
|
duration = state.duration,
|
||||||
|
seekingTo = seekingTo,
|
||||||
|
)
|
||||||
|
}.map {
|
||||||
|
State(
|
||||||
|
playState = it.playState,
|
||||||
|
currentPosition = it.currentPosition,
|
||||||
|
progress = calcProgress(it),
|
||||||
)
|
)
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the voice message to be played.
|
||||||
|
*/
|
||||||
|
suspend fun setMedia(mediaPath: String) {
|
||||||
|
this.mediaPath = mediaPath
|
||||||
|
mediaPlayer.setMedia(
|
||||||
|
uri = mediaPath,
|
||||||
|
mediaId = mediaPath,
|
||||||
|
mimeType = MIME_TYPE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start playing from the current position.
|
* Start playing from the current position.
|
||||||
*
|
*
|
||||||
* @param mediaPath The path to the media to be played.
|
* Call [setMedia] before calling this method.
|
||||||
* @param mimeType The mime type of the media file.
|
|
||||||
*/
|
*/
|
||||||
suspend fun play(mediaPath: String, mimeType: String) {
|
suspend fun play() {
|
||||||
if (mediaPath == curPlayingMediaId) {
|
val mediaPath = this.mediaPath
|
||||||
mediaPlayer.play()
|
if (mediaPath == null) {
|
||||||
} else {
|
Timber.e("Set media before playing")
|
||||||
lastPlayedMediaPath = mediaPath
|
return
|
||||||
mediaPlayer.setMedia(
|
|
||||||
uri = mediaPath,
|
|
||||||
mediaId = mediaPath,
|
|
||||||
mimeType = mimeType,
|
|
||||||
)
|
|
||||||
mediaPlayer.play()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mediaPlayer.ensureMediaReady(mediaPath)
|
||||||
|
|
||||||
|
mediaPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pause playback.
|
* Pause playback.
|
||||||
*/
|
*/
|
||||||
fun pause() {
|
fun pause() {
|
||||||
if (lastPlayedMediaPath == curPlayingMediaId) {
|
if (mediaPath == mediaPlayer.state.value.mediaId) {
|
||||||
mediaPlayer.pause()
|
mediaPlayer.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to a given position in the current media.
|
||||||
|
*
|
||||||
|
* Call [setMedia] before calling this method.
|
||||||
|
*
|
||||||
|
* @param position The position to seek to between 0 and 1.
|
||||||
|
*/
|
||||||
|
suspend fun seek(position: Float) {
|
||||||
|
val mediaPath = this.mediaPath
|
||||||
|
if (mediaPath == null) {
|
||||||
|
Timber.e("Set media before seeking")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seekJob?.cancelAndJoin()
|
||||||
|
seekingTo.value = position
|
||||||
|
seekJob = coroutineScope.launch {
|
||||||
|
val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
|
||||||
|
val duration = mediaState.duration ?: return@launch
|
||||||
|
val positionMs = (duration * position).toLong()
|
||||||
|
mediaPlayer.seekTo(positionMs)
|
||||||
|
}.apply {
|
||||||
|
invokeOnCompletion {
|
||||||
|
seekingTo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MediaPlayer.ensureMediaReady(mediaPath: String): MediaPlayer.State {
|
||||||
|
val state = state.value
|
||||||
|
if (state.mediaId == mediaPath && state.isReady) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
return setMedia(
|
||||||
|
uri = mediaPath,
|
||||||
|
mediaId = mediaPath,
|
||||||
|
mimeType = MIME_TYPE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calcPlayState(prevPlayState: PlayState, seekingTo: Float?, state: MediaPlayer.State): PlayState {
|
||||||
|
if (state.mediaId == null || state.mediaId != mediaPath) {
|
||||||
|
return PlayState.Stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we were stopped and the player didn't start playing or seeking, we are still stopped.
|
||||||
|
if (prevPlayState == PlayState.Stopped && !state.isPlaying && seekingTo == null) {
|
||||||
|
return PlayState.Stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (state.isPlaying) {
|
||||||
|
PlayState.Playing
|
||||||
|
} else {
|
||||||
|
PlayState.Paused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calcProgress(state: InternalState): Float {
|
||||||
|
if (state.seekingTo != null) {
|
||||||
|
return state.seekingTo
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.playState == PlayState.Stopped) {
|
||||||
|
return 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.duration == null) {
|
||||||
|
return 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
return (state.currentPosition.toFloat() / state.duration.toFloat())
|
||||||
|
.coerceAtMost(1f) // Current position may exceed reported duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property playState Whether this player is currently playing. See [PlayState].
|
||||||
|
* @property currentPosition The elapsed time of this player in milliseconds.
|
||||||
|
* @property progress The progress of this player between 0 and 1.
|
||||||
|
*/
|
||||||
data class State(
|
data class State(
|
||||||
|
val playState: PlayState,
|
||||||
|
val currentPosition: Long,
|
||||||
|
val progress: Float,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Initial = State(
|
||||||
|
playState = PlayState.Stopped,
|
||||||
|
currentPosition = 0L,
|
||||||
|
progress = 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Whether this player is currently playing.
|
* Whether this player is currently playing.
|
||||||
*/
|
*/
|
||||||
val isPlaying: Boolean,
|
val isPlaying get() = this.playState == PlayState.Playing
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The elapsed time of this player in milliseconds.
|
* Whether this player is currently stopped.
|
||||||
*/
|
*/
|
||||||
|
val isStopped get() = this.playState == PlayState.Stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum class PlayState {
|
||||||
|
/**
|
||||||
|
* The player is stopped, i.e. it has just been initialised.
|
||||||
|
*/
|
||||||
|
Stopped,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player is playing.
|
||||||
|
*/
|
||||||
|
Playing,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player has been paused. The player can also enter the paused state after seeking to a position.
|
||||||
|
*/
|
||||||
|
Paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class InternalState(
|
||||||
|
val playState: PlayState,
|
||||||
val currentPosition: Long,
|
val currentPosition: Long,
|
||||||
/**
|
val duration: Long?,
|
||||||
* The duration of this player in milliseconds.
|
val seekingTo: Float?,
|
||||||
*/
|
|
||||||
val duration: Long,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val NotLoaded = State(
|
val NotLoaded = InternalState(
|
||||||
isPlaying = false,
|
playState = PlayState.Stopped,
|
||||||
currentPosition = 0L,
|
currentPosition = 0L,
|
||||||
duration = 0L,
|
duration = null,
|
||||||
|
seekingTo = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isLoaded get() = this != NotLoaded
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The progress of this player between 0 and 1.
|
|
||||||
*/
|
|
||||||
val progress: Float =
|
|
||||||
if (duration == 0L)
|
|
||||||
0f
|
|
||||||
else
|
|
||||||
(currentPosition.toFloat() / duration.toFloat())
|
|
||||||
.coerceAtMost(1f) // Current position may exceed reported duration
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.voicemessages.composer
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -69,13 +70,17 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
override fun present(): VoiceMessageComposerState {
|
override fun present(): VoiceMessageComposerState {
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
|
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
|
||||||
|
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial)
|
||||||
val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } }
|
val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } }
|
||||||
|
|
||||||
val permissionState = permissionsPresenter.present()
|
val permissionState = permissionsPresenter.present()
|
||||||
var isSending by remember { mutableStateOf(false) }
|
var isSending by remember { mutableStateOf(false) }
|
||||||
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded)
|
|
||||||
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
LaunchedEffect(recorderState) {
|
||||||
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
val recording = recorderState as? VoiceRecorderState.Finished
|
||||||
|
?: return@LaunchedEffect
|
||||||
|
player.setMedia(recording.file.path)
|
||||||
|
}
|
||||||
|
|
||||||
val onLifecycleEvent = { event: Lifecycle.Event ->
|
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
@ -115,27 +120,15 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val onPlayerEvent = { event: VoiceMessagePlayerEvent ->
|
val onPlayerEvent = { event: VoiceMessagePlayerEvent -> localCoroutineScope.launch {
|
||||||
when (event) {
|
localCoroutineScope.launch {
|
||||||
VoiceMessagePlayerEvent.Play ->
|
when (event) {
|
||||||
when (val recording = recorderState) {
|
VoiceMessagePlayerEvent.Play -> player.play()
|
||||||
is VoiceRecorderState.Finished ->
|
VoiceMessagePlayerEvent.Pause -> player.pause()
|
||||||
localCoroutineScope.launch {
|
is VoiceMessagePlayerEvent.Seek -> player.seek(event.position)
|
||||||
player.play(
|
|
||||||
mediaPath = recording.file.path,
|
|
||||||
mimeType = recording.mimeType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> Timber.e("Voice message player event received but no file to play")
|
|
||||||
}
|
|
||||||
VoiceMessagePlayerEvent.Pause -> {
|
|
||||||
player.pause()
|
|
||||||
}
|
|
||||||
is VoiceMessagePlayerEvent.Seek -> {
|
|
||||||
// TODO implement seeking
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} }
|
||||||
|
|
||||||
val onAcceptPermissionsRationale = {
|
val onAcceptPermissionsRationale = {
|
||||||
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
|
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
|
||||||
|
|
@ -189,16 +182,14 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
voiceMessageState = when (val state = recorderState) {
|
voiceMessageState = when (val state = recorderState) {
|
||||||
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
|
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
|
||||||
duration = state.elapsedTime,
|
duration = state.elapsedTime,
|
||||||
levels = state.levels.toPersistentList()
|
levels = state.levels.toPersistentList(),
|
||||||
)
|
|
||||||
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
|
|
||||||
isSending = isSending,
|
|
||||||
isPlaying = playerState.isPlaying,
|
|
||||||
showCursor = playerState.isLoaded && !isSending,
|
|
||||||
playbackProgress = playerState.progress,
|
|
||||||
time = playerTime,
|
|
||||||
waveform = waveform,
|
|
||||||
)
|
)
|
||||||
|
is VoiceRecorderState.Finished ->
|
||||||
|
previewState(
|
||||||
|
playerState = playerState,
|
||||||
|
recorderState = recorderState,
|
||||||
|
isSending = isSending
|
||||||
|
)
|
||||||
else -> VoiceMessageState.Idle
|
else -> VoiceMessageState.Idle
|
||||||
},
|
},
|
||||||
showPermissionRationaleDialog = permissionState.showDialog,
|
showPermissionRationaleDialog = permissionState.showDialog,
|
||||||
|
|
@ -207,6 +198,26 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun previewState(
|
||||||
|
playerState: VoiceMessageComposerPlayer.State,
|
||||||
|
recorderState: VoiceRecorderState,
|
||||||
|
isSending: Boolean,
|
||||||
|
): VoiceMessageState {
|
||||||
|
val showCursor by remember(playerState.isStopped, isSending) { derivedStateOf { !playerState.isStopped && !isSending }}
|
||||||
|
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
||||||
|
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
||||||
|
|
||||||
|
return VoiceMessageState.Preview(
|
||||||
|
isSending = isSending,
|
||||||
|
isPlaying = playerState.isPlaying,
|
||||||
|
showCursor = showCursor,
|
||||||
|
playbackProgress = playerState.progress,
|
||||||
|
time = playerTime,
|
||||||
|
waveform = waveform,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.startRecording() = launch {
|
private fun CoroutineScope.startRecording() = launch {
|
||||||
try {
|
try {
|
||||||
voiceRecorder.startRecord()
|
voiceRecorder.startRecord()
|
||||||
|
|
@ -248,7 +259,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AnalyticsService.captureComposerEvent() =
|
private fun AnalyticsService.captureComposerEvent() =
|
||||||
analyticsService.capture(
|
capture(
|
||||||
Composer(
|
Composer(
|
||||||
inThread = messageComposerContext.composerMode.inThread,
|
inThread = messageComposerContext.composerMode.inThread,
|
||||||
isEditing = messageComposerContext.composerMode.isEditing,
|
isEditing = messageComposerContext.composerMode.isEditing,
|
||||||
|
|
@ -273,7 +284,7 @@ private fun displayTime(
|
||||||
playerState: VoiceMessageComposerPlayer.State,
|
playerState: VoiceMessageComposerPlayer.State,
|
||||||
recording: VoiceRecorderState
|
recording: VoiceRecorderState
|
||||||
): Duration = when {
|
): Duration = when {
|
||||||
playerState.isLoaded ->
|
!playerState.isStopped ->
|
||||||
playerState.currentPosition.milliseconds
|
playerState.currentPosition.milliseconds
|
||||||
recording is VoiceRecorderState.Finished ->
|
recording is VoiceRecorderState.Finished ->
|
||||||
recording.duration
|
recording.duration
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ class DefaultVoiceMessagePlayer(
|
||||||
mediaPlayer.setMedia(
|
mediaPlayer.setMedia(
|
||||||
uri = mediaFile.path,
|
uri = mediaFile.path,
|
||||||
mediaId = eventId.value,
|
mediaId = eventId.value,
|
||||||
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||||
)
|
)
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -641,7 +641,7 @@ class MessagesPresenterTest {
|
||||||
FakeVoiceRecorder(),
|
FakeVoiceRecorder(),
|
||||||
analyticsService,
|
analyticsService,
|
||||||
mediaSender,
|
mediaSender,
|
||||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
|
||||||
messageComposerContext = FakeMessageComposerContext(),
|
messageComposerContext = FakeMessageComposerContext(),
|
||||||
permissionsPresenterFactory,
|
permissionsPresenterFactory,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
|
|
@ -195,7 +195,6 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) }
|
|
||||||
val finalState = awaitItem().also {
|
val finalState = awaitItem().also {
|
||||||
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +213,6 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
skipItems(1) // Loaded state
|
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
|
||||||
val finalState = awaitItem().also {
|
val finalState = awaitItem().also {
|
||||||
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
|
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
|
||||||
|
|
@ -225,6 +223,33 @@ class VoiceMessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - seek recording`() = runTest {
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
|
||||||
|
awaitItem().apply {
|
||||||
|
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
|
||||||
|
}
|
||||||
|
awaitItem().apply {
|
||||||
|
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
|
||||||
|
eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
|
||||||
|
}
|
||||||
|
awaitItem().apply {
|
||||||
|
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
|
||||||
|
}
|
||||||
|
val finalState = awaitItem().apply {
|
||||||
|
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - delete recording`() = runTest {
|
fun `present - delete recording`() = runTest {
|
||||||
val presenter = createVoiceMessageComposerPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
|
@ -252,7 +277,6 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
skipItems(1) // Loaded state
|
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||||
awaitItem().apply {
|
awaitItem().apply {
|
||||||
assertThat(voiceMessageState).isEqualTo(aPausedState())
|
assertThat(voiceMessageState).isEqualTo(aPausedState())
|
||||||
|
|
@ -324,9 +348,9 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
skipItems(1) // Loaded state
|
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
|
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
|
||||||
|
skipItems(1) // Duplicate sending state
|
||||||
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
|
@ -603,7 +627,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
voiceRecorder,
|
voiceRecorder,
|
||||||
analyticsService,
|
analyticsService,
|
||||||
mediaSender,
|
mediaSender,
|
||||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
|
||||||
messageComposerContext = messageComposerContext,
|
messageComposerContext = messageComposerContext,
|
||||||
FakePermissionsPresenterFactory(permissionsPresenter),
|
FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
)
|
)
|
||||||
|
|
@ -639,14 +663,6 @@ class VoiceMessageComposerPresenterTest {
|
||||||
waveform = waveform.toImmutableList(),
|
waveform = waveform.toImmutableList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun aLoadedState() =
|
|
||||||
aPreviewState(
|
|
||||||
isPlaying = false,
|
|
||||||
playbackProgress = 0.0f,
|
|
||||||
showCursor = true,
|
|
||||||
time = 0.seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun aPlayingState() =
|
private fun aPlayingState() =
|
||||||
aPreviewState(
|
aPreviewState(
|
||||||
isPlaying = true,
|
isPlaying = true,
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ internal fun VoiceMessagePreview(
|
||||||
playbackProgress = playbackProgress,
|
playbackProgress = playbackProgress,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
seekEnabled = false, // TODO enable seeking
|
seekEnabled = true,
|
||||||
onSeek = onSeek,
|
onSeek = onSeek,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-compose-library")
|
||||||
alias(libs.plugins.anvil)
|
alias(libs.plugins.anvil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,11 @@
|
||||||
|
|
||||||
package io.element.android.libraries.voicerecorder.api
|
package io.element.android.libraries.voicerecorder.api
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface VoiceRecorderState {
|
sealed interface VoiceRecorderState {
|
||||||
/**
|
/**
|
||||||
* The recorder is idle and not recording.
|
* The recorder is idle and not recording.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue