Add voice message preview player (#1646)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
acd7aef6be
commit
a67410f573
17 changed files with 369 additions and 40 deletions
|
|
@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
|
|||
import io.element.android.libraries.textcomposer.model.Message
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
|
|
@ -83,6 +84,10 @@ internal fun MessageComposerView(
|
|||
voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||
}
|
||||
|
||||
val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent ->
|
||||
voiceMessageState.eventSink(VoiceMessageComposerEvents.PlayerEvent(event))
|
||||
}
|
||||
|
||||
TextComposer(
|
||||
modifier = modifier,
|
||||
state = state.richTextEditorState,
|
||||
|
|
@ -98,6 +103,7 @@ internal fun MessageComposerView(
|
|||
enableTextFormatting = enableTextFormatting,
|
||||
enableVoiceMessages = enableVoiceMessages,
|
||||
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
|
||||
onVoicePlayerEvent = onVoicePlayerEvent,
|
||||
onSendVoiceMessage = onSendVoiceMessage,
|
||||
onDeleteVoiceMessage = onDeleteVoiceMessage,
|
||||
onError = ::onError,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,15 @@ package io.element.android.features.messages.impl.voicemessages.composer
|
|||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
|
||||
sealed interface VoiceMessageComposerEvents {
|
||||
data class RecordButtonEvent(
|
||||
val pressEvent: PressEvent
|
||||
): VoiceMessageComposerEvents
|
||||
data class PlayerEvent(
|
||||
val playerEvent: VoiceMessagePlayerEvent,
|
||||
): VoiceMessageComposerEvents
|
||||
data object SendVoiceMessage: VoiceMessageComposerEvents
|
||||
data object DeleteVoiceMessage: VoiceMessageComposerEvents
|
||||
data object AcceptPermissionRationale: VoiceMessageComposerEvents
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A media player for the voice message composer.
|
||||
*
|
||||
* @param mediaPlayer The [MediaPlayer] to use.
|
||||
*/
|
||||
class VoiceMessageComposerPlayer @Inject constructor(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
) {
|
||||
private var lastPlayedMediaPath: String? = null
|
||||
private val curPlayingMediaId
|
||||
get() = mediaPlayer.state.value.mediaId
|
||||
|
||||
val state: Flow<State> = mediaPlayer.state.map { state ->
|
||||
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
|
||||
return@map State.NotPlaying
|
||||
}
|
||||
|
||||
State(
|
||||
isPlaying = state.isPlaying,
|
||||
currentPosition = state.currentPosition
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Start playing from the current position.
|
||||
*
|
||||
* @param mediaPath The path to the media to be played.
|
||||
* @param mimeType The mime type of the media file.
|
||||
*/
|
||||
fun play(mediaPath: String, mimeType: String) {
|
||||
if (mediaPath == curPlayingMediaId) {
|
||||
mediaPlayer.play()
|
||||
} else {
|
||||
lastPlayedMediaPath = mediaPath
|
||||
mediaPlayer.acquireControlAndPlay(
|
||||
uri = mediaPath,
|
||||
mediaId = mediaPath,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback.
|
||||
*/
|
||||
fun pause() {
|
||||
if (lastPlayedMediaPath == curPlayingMediaId) {
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
data class State(
|
||||
/**
|
||||
* Whether this player is currently playing.
|
||||
*/
|
||||
val isPlaying: Boolean,
|
||||
/**
|
||||
* The elapsed time of this player in milliseconds.
|
||||
*/
|
||||
val currentPosition: Long,
|
||||
) {
|
||||
companion object {
|
||||
val NotPlaying = State(
|
||||
isPlaying = false,
|
||||
currentPosition = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.composer
|
|||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -34,6 +35,7 @@ import io.element.android.libraries.mediaupload.api.MediaSender
|
|||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||
|
|
@ -50,6 +52,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
private val voiceRecorder: VoiceRecorder,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val mediaSender: MediaSender,
|
||||
private val player: VoiceMessageComposerPlayer,
|
||||
permissionsPresenterFactory: PermissionsPresenter.Factory
|
||||
) : Presenter<VoiceMessageComposerState> {
|
||||
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
|
||||
|
|
@ -61,11 +64,14 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
|
||||
val permissionState = permissionsPresenter.present()
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotPlaying)
|
||||
val isPlaying by remember(playerState.isPlaying) { derivedStateOf { playerState.isPlaying } }
|
||||
|
||||
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
appCoroutineScope.finishRecording()
|
||||
player.pause()
|
||||
}
|
||||
Lifecycle.Event.ON_DESTROY -> {
|
||||
appCoroutineScope.cancelRecording()
|
||||
|
|
@ -99,6 +105,25 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
val onPlayerEvent = { event: VoiceMessagePlayerEvent ->
|
||||
when (event) {
|
||||
VoiceMessagePlayerEvent.Play ->
|
||||
when (val recording = recorderState) {
|
||||
is VoiceRecorderState.Finished ->
|
||||
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 = {
|
||||
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
|
||||
|
|
@ -131,6 +156,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event ->
|
||||
when (event) {
|
||||
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
|
||||
is VoiceMessageComposerEvents.PlayerEvent -> onPlayerEvent(event.playerEvent)
|
||||
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
|
||||
onSendButtonPress()
|
||||
}
|
||||
|
|
@ -150,7 +176,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
|||
is VoiceRecorderState.Finished -> if (isSending) {
|
||||
VoiceMessageState.Sending
|
||||
} else {
|
||||
VoiceMessageState.Preview
|
||||
VoiceMessageState.Preview(isPlaying = isPlaying)
|
||||
}
|
||||
else -> VoiceMessageState.Idle
|
||||
},
|
||||
|
|
|
|||
|
|
@ -40,8 +40,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer
|
||||
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
||||
|
|
@ -633,6 +635,7 @@ class MessagesPresenterTest {
|
|||
FakeVoiceRecorder(),
|
||||
analyticsService,
|
||||
mediaSender,
|
||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
||||
permissionsPresenterFactory,
|
||||
)
|
||||
val timelinePresenter = TimelinePresenter(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.messages.voicemessages
|
||||
package io.element.android.features.messages.voicemessages.composer
|
||||
|
||||
import android.Manifest
|
||||
import androidx.lifecycle.Lifecycle
|
||||
|
|
@ -29,6 +29,8 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
|
|||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
|
||||
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
|
|
@ -37,6 +39,7 @@ import io.element.android.libraries.permissions.api.aPermissionsState
|
|||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
|
|
@ -123,7 +126,63 @@ class VoiceMessageComposerPresenterTest {
|
|||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - play recording before it is ready`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
val finalState = awaitItem().apply {
|
||||
this.eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
}
|
||||
|
||||
// Nothing should happen
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(RECORDING_DURATION, 0.2f))
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - play 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.Play))
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true))
|
||||
}
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - pause 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.Play))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false))
|
||||
}
|
||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
|
|
@ -202,7 +261,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState())
|
||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +291,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending)
|
||||
|
||||
ensureAllEventsConsumed()
|
||||
assertThat(previewState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
|
||||
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
|
||||
|
||||
mediaPreProcessor.givenAudioResult()
|
||||
|
|
@ -393,17 +452,24 @@ class VoiceMessageComposerPresenterTest {
|
|||
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
|
||||
)
|
||||
|
||||
val onPauseState = when (mostRecentState.voiceMessageState) {
|
||||
val onPauseState = when (val vmState = mostRecentState.voiceMessageState) {
|
||||
VoiceMessageState.Idle,
|
||||
VoiceMessageState.Preview,
|
||||
VoiceMessageState.Sending -> {
|
||||
mostRecentState
|
||||
}
|
||||
is VoiceMessageState.Recording -> {
|
||||
awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
|
||||
// If recorder was active, it stops
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState())
|
||||
}
|
||||
}
|
||||
is VoiceMessageState.Preview -> when(vmState.isPlaying) {
|
||||
// If the preview was playing, it pauses
|
||||
true -> awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState())
|
||||
}
|
||||
false -> mostRecentState
|
||||
}
|
||||
}
|
||||
|
||||
onPauseState.eventSink(
|
||||
|
|
@ -415,7 +481,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
VoiceMessageState.Sending ->
|
||||
ensureAllEventsConsumed()
|
||||
is VoiceMessageState.Recording,
|
||||
VoiceMessageState.Preview ->
|
||||
is VoiceMessageState.Preview ->
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
}
|
||||
}
|
||||
|
|
@ -428,6 +494,7 @@ class VoiceMessageComposerPresenterTest {
|
|||
voiceRecorder,
|
||||
analyticsService,
|
||||
mediaSender,
|
||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
||||
FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
)
|
||||
}
|
||||
|
|
@ -445,4 +512,11 @@ class VoiceMessageComposerPresenterTest {
|
|||
initialState = initialPermissionState
|
||||
)
|
||||
}
|
||||
|
||||
private fun aPreviewState(
|
||||
isPlaying: Boolean = false
|
||||
) = VoiceMessageState.Preview(
|
||||
isPlaying = isPlaying
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ class VoiceMessagePresenterTest {
|
|||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem().also {
|
||||
awaitItem().also {
|
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
|
||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
||||
Truth.assertThat(it.time).isEqualTo("1:01")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue