Add voice message preview player (#1646)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew 2023-10-26 16:33:58 +01:00 committed by GitHub
parent acd7aef6be
commit a67410f573
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 369 additions and 40 deletions

View file

@ -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,

View file

@ -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

View file

@ -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,
)
}
}
}

View file

@ -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
},

View file

@ -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(

View file

@ -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
)
}

View file

@ -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")

View file

@ -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)
}))

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:256a21f9da287b5330fccf8de408137a39de1a73cefc5428e45cf391147fa249
size 10955
oid sha256:0ae485c12f93649418e661ed6f4a3f1393c3e1c703e8fce84e29ff9c4607ca7b
size 8699

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d92dac54bc06086757108838beb2e4f2067fdc0525a1a71c3f92b7787fce128
size 10261
oid sha256:2afaaa054ed17809447352d245a287854afee0d05c0efd7b00341daf733dc07b
size 6623

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:463aea9b16f9907af3acdc02d4c32e9f048b4a547e1079cd265e5138c759b461
size 17917
oid sha256:8eed5b8511637961df60be133688df984560cb84b333a9377e859446dfac2e04
size 18036

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:240ae68a39f03df1cd46645d5fb5163c2fdb95ebe8083f8b18f72c118d5a2d25
size 17030
oid sha256:14bcc3395316251b2e25a23f95b53030bf2e2ebe0623ef7e113330a5fd2f853d
size 15852