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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:256a21f9da287b5330fccf8de408137a39de1a73cefc5428e45cf391147fa249
|
||||
size 10955
|
||||
oid sha256:0ae485c12f93649418e661ed6f4a3f1393c3e1c703e8fce84e29ff9c4607ca7b
|
||||
size 8699
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9d92dac54bc06086757108838beb2e4f2067fdc0525a1a71c3f92b7787fce128
|
||||
size 10261
|
||||
oid sha256:2afaaa054ed17809447352d245a287854afee0d05c0efd7b00341daf733dc07b
|
||||
size 6623
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:463aea9b16f9907af3acdc02d4c32e9f048b4a547e1079cd265e5138c759b461
|
||||
size 17917
|
||||
oid sha256:8eed5b8511637961df60be133688df984560cb84b333a9377e859446dfac2e04
|
||||
size 18036
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:240ae68a39f03df1cd46645d5fb5163c2fdb95ebe8083f8b18f72c118d5a2d25
|
||||
size 17030
|
||||
oid sha256:14bcc3395316251b2e25a23f95b53030bf2e2ebe0623ef7e113330a5fd2f853d
|
||||
size 15852
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue