Browse Source

Add voice message preview player (#1646)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
pull/1653/head
jonnyandrew 11 months ago committed by GitHub
parent
commit
a67410f573
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
  2. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt
  3. 93
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt
  4. 28
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
  5. 3
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
  6. 92
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
  7. 2
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt
  8. 32
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
  9. 81
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt
  10. 26
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt
  11. 4
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt
  12. 9
      libraries/textcomposer/impl/src/main/res/drawable/ic_pause.xml
  13. 9
      libraries/textcomposer/impl/src/main/res/drawable/ic_play.xml
  14. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-D-15_15_null,NEXUS_5,1.0,en].png
  15. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-N-15_16_null,NEXUS_5,1.0,en].png
  16. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png
  17. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png

6
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt

@ -33,6 +33,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview @@ -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( @@ -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( @@ -98,6 +103,7 @@ internal fun MessageComposerView(
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
onVoicePlayerEvent = onVoicePlayerEvent,
onSendVoiceMessage = onSendVoiceMessage,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onError = ::onError,

4
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt

@ -18,11 +18,15 @@ package io.element.android.features.messages.impl.voicemessages.composer @@ -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

93
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt

@ -0,0 +1,93 @@ @@ -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,
)
}
}
}

28
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.voicemessages.composer @@ -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 @@ -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( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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
},

3
features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt

@ -40,8 +40,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -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 { @@ -633,6 +635,7 @@ class MessagesPresenterTest {
FakeVoiceRecorder(),
analyticsService,
mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
permissionsPresenterFactory,
)
val timelinePresenter = TimelinePresenter(

92
features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt → features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt

@ -16,7 +16,7 @@ @@ -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 @@ -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 @@ -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 { @@ -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 { @@ -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 { @@ -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,16 +452,23 @@ class VoiceMessageComposerPresenterTest { @@ -393,16 +452,23 @@ 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
}
}
@ -415,7 +481,7 @@ class VoiceMessageComposerPresenterTest { @@ -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 { @@ -428,6 +494,7 @@ class VoiceMessageComposerPresenterTest {
voiceRecorder,
analyticsService,
mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
FakePermissionsPresenterFactory(permissionsPresenter),
)
}
@ -445,4 +512,11 @@ class VoiceMessageComposerPresenterTest { @@ -445,4 +512,11 @@ class VoiceMessageComposerPresenterTest {
initialState = initialPermissionState
)
}
private fun aPreviewState(
isPlaying: Boolean = false
) = VoiceMessageState.Preview(
isPlaying = isPlaying
)
}

2
features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt

@ -145,7 +145,7 @@ class VoiceMessagePresenterTest { @@ -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")

32
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt

@ -74,6 +74,7 @@ import io.element.android.libraries.textcomposer.components.textInputRoundedCorn @@ -74,6 +74,7 @@ import io.element.android.libraries.textcomposer.components.textInputRoundedCorn
import io.element.android.libraries.textcomposer.model.Message
import io.element.android.libraries.textcomposer.model.MessageComposerMode
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.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@ -99,6 +100,7 @@ fun TextComposer( @@ -99,6 +100,7 @@ fun TextComposer(
onAddAttachment: () -> Unit = {},
onDismissTextFormatting: () -> Unit = {},
onVoiceRecordButtonEvent: (PressEvent) -> Unit = {},
onVoicePlayerEvent: (VoiceMessagePlayerEvent) -> Unit = {},
onSendVoiceMessage: () -> Unit = {},
onDeleteVoiceMessage: () -> Unit = {},
onError: (Throwable) -> Unit = {},
@ -108,6 +110,14 @@ fun TextComposer( @@ -108,6 +110,14 @@ fun TextComposer(
onSendMessage(Message(html = html, markdown = state.messageMarkdown))
}
val onPlayVoiceMessageClicked = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Play)
}
val onPauseVoiceMessageClicked = {
onVoicePlayerEvent(VoiceMessagePlayerEvent.Pause)
}
val layoutModifier = modifier
.fillMaxSize()
.height(IntrinsicSize.Min)
@ -179,10 +189,20 @@ fun TextComposer( @@ -179,10 +189,20 @@ fun TextComposer(
val voiceRecording = @Composable {
when (voiceMessageState) {
VoiceMessageState.Preview ->
VoiceMessagePreview(isInteractive = true)
is VoiceMessageState.Preview ->
VoiceMessagePreview(
isInteractive = true,
isPlaying = voiceMessageState.isPlaying,
onPlayClick = onPlayVoiceMessageClicked,
onPauseClick = onPauseVoiceMessageClicked
)
VoiceMessageState.Sending ->
VoiceMessagePreview(isInteractive = false)
VoiceMessagePreview(
isInteractive = false,
isPlaying = false,
onPlayClick = onPlayVoiceMessageClicked,
onPauseClick = onPauseVoiceMessageClicked
)
is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
VoiceMessageState.Idle -> {}
@ -191,7 +211,7 @@ fun TextComposer( @@ -191,7 +211,7 @@ fun TextComposer(
val voiceDeleteButton = @Composable {
val enabled = when (voiceMessageState) {
VoiceMessageState.Preview -> true
is VoiceMessageState.Preview -> true
VoiceMessageState.Sending,
is VoiceMessageState.Recording,
VoiceMessageState.Idle -> false
@ -780,7 +800,9 @@ internal fun TextComposerVoicePreview() = ElementPreview { @@ -780,7 +800,9 @@ internal fun TextComposerVoicePreview() = ElementPreview {
PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5f))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview)
VoicePreview(voiceMessageState = VoiceMessageState.Preview(isPlaying = false))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview(isPlaying = true))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Sending)
}))

81
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt

@ -17,25 +17,36 @@ @@ -17,25 +17,36 @@
package io.element.android.libraries.textcomposer.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.text.applyScaleUp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.textcomposer.R
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun VoiceMessagePreview(
isInteractive: Boolean,
isPlaying: Boolean,
modifier: Modifier = Modifier,
onPlayClick: () -> Unit = {},
onPauseClick: () -> Unit = {}
) {
Row(
modifier = modifier
@ -44,28 +55,72 @@ internal fun VoiceMessagePreview( @@ -44,28 +55,72 @@ internal fun VoiceMessagePreview(
color = ElementTheme.colors.bgSubtleSecondary,
shape = MaterialTheme.shapes.medium,
)
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp)
.padding(start = 8.dp, end = 20.dp, top = 6.dp, bottom = 6.dp)
.heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// TODO Replace with recording preview UI
Text(
text = "Finished recording", // Not localized because it is a placeholder
color = if (isInteractive) {
ElementTheme.colors.textSecondary
if (isPlaying) {
PlayerButton(
type = PlayerButtonType.Pause,
onClick = onPauseClick,
enabled = isInteractive,
)
} else {
ElementTheme.colors.textDisabled
},
style = ElementTheme.typography.fontBodySmMedium
PlayerButton(
type = PlayerButtonType.Play,
onClick = onPlayClick,
enabled = isInteractive
)
}
// TODO Add recording preview UI
}
}
private enum class PlayerButtonType {
Play, Pause
}
@Composable
private fun PlayerButton(
type: PlayerButtonType,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = modifier
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
.size(30.dp.applyScaleUp())
) {
when (type) {
PlayerButtonType.Play -> PlayIcon()
PlayerButtonType.Pause -> PauseIcon()
}
}
}
@Composable
private fun PauseIcon() = Icon(
resourceId = R.drawable.ic_pause,
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
@Composable
private fun PlayIcon() = Icon(
resourceId = R.drawable.ic_play,
contentDescription = stringResource(id = CommonStrings.a11y_play),
)
@PreviewsDayNight
@Composable
internal fun VoiceMessagePreviewPreview() = ElementPreview {
Column {
VoiceMessagePreview(isInteractive = true)
VoiceMessagePreview(isInteractive = false)
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
VoiceMessagePreview(isInteractive = true, isPlaying = true)
VoiceMessagePreview(isInteractive = true, isPlaying = false)
VoiceMessagePreview(isInteractive = false, isPlaying = false)
}
}

26
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
/*
* 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.libraries.textcomposer.model
sealed class VoiceMessagePlayerEvent {
data object Play: VoiceMessagePlayerEvent()
data object Pause: VoiceMessagePlayerEvent()
data class Seek(
val position: Float
): VoiceMessagePlayerEvent()
}

4
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt

@ -21,7 +21,9 @@ import kotlin.time.Duration @@ -21,7 +21,9 @@ import kotlin.time.Duration
sealed class VoiceMessageState {
data object Idle: VoiceMessageState()
data object Preview: VoiceMessageState()
data class Preview(
val isPlaying: Boolean,
): VoiceMessageState()
data object Sending: VoiceMessageState()
data class Recording(
val duration: Duration,

9
libraries/textcomposer/impl/src/main/res/drawable/ic_pause.xml

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M13.25,15.25C12.837,15.25 12.484,15.103 12.191,14.809C11.897,14.516 11.75,14.163 11.75,13.75V6.25C11.75,5.838 11.897,5.484 12.191,5.191C12.484,4.897 12.837,4.75 13.25,4.75H13.75C14.163,4.75 14.516,4.897 14.809,5.191C15.103,5.484 15.25,5.838 15.25,6.25V13.75C15.25,14.163 15.103,14.516 14.809,14.809C14.516,15.103 14.163,15.25 13.75,15.25H13.25ZM6.25,15.25C5.838,15.25 5.484,15.103 5.191,14.809C4.897,14.516 4.75,14.163 4.75,13.75V6.25C4.75,5.838 4.897,5.484 5.191,5.191C5.484,4.897 5.838,4.75 6.25,4.75H6.75C7.162,4.75 7.516,4.897 7.809,5.191C8.103,5.484 8.25,5.838 8.25,6.25V13.75C8.25,14.163 8.103,14.516 7.809,14.809C7.516,15.103 7.162,15.25 6.75,15.25H6.25Z"
android:fillColor="#656D77"/>
</vector>

9
libraries/textcomposer/impl/src/main/res/drawable/ic_play.xml

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M8.042,14.958C7.75,15.153 7.451,15.167 7.146,15C6.84,14.833 6.688,14.569 6.688,14.208V5.75C6.688,5.389 6.84,5.125 7.146,4.958C7.451,4.792 7.75,4.806 8.042,5L14.688,9.25C14.965,9.417 15.104,9.66 15.104,9.979C15.104,10.299 14.965,10.542 14.688,10.708L8.042,14.958Z"
android:fillColor="#656D77"/>
</vector>

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-D-15_15_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-N-15_16_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save