diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index a2d8d9c571..0c71bdbec1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/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 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, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt index 384e249611..f80ee15d95 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt +++ b/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 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 diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt new file mode 100644 index 0000000000..976351fd62 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt @@ -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 = 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, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt index cbc5b7332f..51bc13847e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt +++ b/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 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 { 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 }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt index 190edd6173..6ab2e0d7f3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt +++ b/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 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( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt similarity index 82% rename from features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt rename to features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt index ce965f32dc..90ab3ae16e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt @@ -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,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 { 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 + ) + } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt index 0a6ca3b8c4..3239c1879e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt @@ -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") diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index abb81fc090..73c1764de2 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/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 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( onAddAttachment: () -> Unit = {}, onDismissTextFormatting: () -> Unit = {}, onVoiceRecordButtonEvent: (PressEvent) -> Unit = {}, + onVoicePlayerEvent: (VoiceMessagePlayerEvent) -> Unit = {}, onSendVoiceMessage: () -> Unit = {}, onDeleteVoiceMessage: () -> Unit = {}, onError: (Throwable) -> Unit = {}, @@ -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( 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( 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 { 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) })) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt index 2164f8cd6f..4d8521834c 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -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( 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 - } else { - ElementTheme.colors.textDisabled - }, - style = ElementTheme.typography.fontBodySmMedium - ) + if (isPlaying) { + PlayerButton( + type = PlayerButtonType.Pause, + onClick = onPauseClick, + enabled = isInteractive, + ) + } else { + 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) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt new file mode 100644 index 0000000000..7f827caef7 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessagePlayerEvent.kt @@ -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() +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt index fce53e2af4..012f655ad2 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/VoiceMessageState.kt @@ -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, diff --git a/libraries/textcomposer/impl/src/main/res/drawable/ic_pause.xml b/libraries/textcomposer/impl/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000000..bc6deee55a --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/textcomposer/impl/src/main/res/drawable/ic_play.xml b/libraries/textcomposer/impl/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000000..0735689244 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-D-15_15_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-D-15_15_null,NEXUS_5,1.0,en].png index 49fd301fe2..7fcecb3e63 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-D-15_15_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-D-15_15_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:256a21f9da287b5330fccf8de408137a39de1a73cefc5428e45cf391147fa249 -size 10955 +oid sha256:0ae485c12f93649418e661ed6f4a3f1393c3e1c703e8fce84e29ff9c4607ca7b +size 8699 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-N-15_16_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-N-15_16_null,NEXUS_5,1.0,en].png index 607afb4819..013ce61eb0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-N-15_16_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer.components_null_VoiceMessagePreview-N-15_16_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d92dac54bc06086757108838beb2e4f2067fdc0525a1a71c3f92b7787fce128 -size 10261 +oid sha256:2afaaa054ed17809447352d245a287854afee0d05c0efd7b00341daf733dc07b +size 6623 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png index 55bbca8ae9..1fc392e02d 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-D-4_4_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:463aea9b16f9907af3acdc02d4c32e9f048b4a547e1079cd265e5138c759b461 -size 17917 +oid sha256:8eed5b8511637961df60be133688df984560cb84b333a9377e859446dfac2e04 +size 18036 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png index 5e31ffc723..19603922a0 100644 --- a/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[l.textcomposer_null_TextComposerVoice-N-4_5_null,NEXUS_5,1.0,en].png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:240ae68a39f03df1cd46645d5fb5163c2fdb95ebe8083f8b18f72c118d5a2d25 -size 17030 +oid sha256:14bcc3395316251b2e25a23f95b53030bf2e2ebe0623ef7e113330a5fd2f853d +size 15852