Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add waveform to voice message preview UI #1661

Merged
merged 4 commits into from
Oct 27, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ package io.element.android.features.messages.impl.voicemessages
* Resizes the given [0;1024] int list as per unstable MSC3246 spec
* to a [0;1] range float list to be used for waveform rendering.
*/
fun List<Int>.fromMSC3246range(): List<Float> = map { it / 1024f }
internal fun List<Int>.fromMSC3246range(): List<Float> = map { it / 1024f }

/**
* Resizes the given [0;1] float list to [0;1024] int list as per unstable MSC3246 spec.
*/
internal fun List<Float>.toMSC3246range(): List<Int> = map { (it * 1024).toInt() }
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.features.messages.impl.voicemessages.toMSC3246range
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
Expand All @@ -40,6 +41,8 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
Expand All @@ -66,6 +69,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
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 waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }

val onLifecycleEvent = { event: Lifecycle.Event ->
when (event) {
Expand Down Expand Up @@ -174,11 +178,11 @@ class VoiceMessageComposerPresenter @Inject constructor(
duration = state.elapsedTime,
level = state.level
)
is VoiceRecorderState.Finished -> if (isSending) {
VoiceMessageState.Sending
} else {
VoiceMessageState.Preview(isPlaying = isPlaying)
}
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
isSending = isSending,
isPlaying = isPlaying,
waveform = waveform,
)
else -> VoiceMessageState.Idle
},
showPermissionRationaleDialog = permissionState.showDialog,
Expand Down Expand Up @@ -227,7 +231,8 @@ class VoiceMessageComposerPresenter @Inject constructor(
}
}

/**
* Resizes the given [0;1] float list to [0;1024] int list as per unstable MSC3246 spec.
*/
private fun List<Float>.toMSC3246range(): List<Int> = map { (it * 1024).toInt() }
private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
(this as? VoiceRecorderState.Finished)
?.waveform
.orEmpty()
.toImmutableList()
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
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.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.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaSender
Expand All @@ -44,6 +44,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
Expand Down Expand Up @@ -216,7 +217,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))

val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
Expand All @@ -239,7 +240,7 @@ class VoiceMessageComposerPresenterTest {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))

val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
Expand All @@ -266,7 +267,7 @@ class VoiceMessageComposerPresenterTest {
}

val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Sending)
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
assertThat(analyticsService.trackedErrors).hasSize(0)
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
Expand All @@ -288,7 +289,7 @@ class VoiceMessageComposerPresenterTest {
val previewState = awaitItem()

previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Sending)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))

ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
Expand Down Expand Up @@ -452,18 +453,15 @@ class VoiceMessageComposerPresenterTest {
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
)

val onPauseState = when (val vmState = mostRecentState.voiceMessageState) {
VoiceMessageState.Idle,
VoiceMessageState.Sending -> {
mostRecentState
}
val onPauseState = when (val state = mostRecentState.voiceMessageState) {
VoiceMessageState.Idle -> mostRecentState
is VoiceMessageState.Recording -> {
// If recorder was active, it stops
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState())
}
}
is VoiceMessageState.Preview -> when(vmState.isPlaying) {
is VoiceMessageState.Preview -> when (state.isPlaying) {
// If the preview was playing, it pauses
true -> awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState())
Expand All @@ -476,13 +474,15 @@ class VoiceMessageComposerPresenterTest {
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY)
)

when (onPauseState.voiceMessageState) {
VoiceMessageState.Idle,
VoiceMessageState.Sending ->
when (val state = onPauseState.voiceMessageState) {
VoiceMessageState.Idle ->
ensureAllEventsConsumed()
is VoiceMessageState.Recording,
is VoiceMessageState.Preview ->
is VoiceMessageState.Recording ->
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
is VoiceMessageState.Preview -> when (state.isSending) {
true -> ensureAllEventsConsumed()
false -> assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
}
}
}

Expand Down Expand Up @@ -514,9 +514,12 @@ class VoiceMessageComposerPresenterTest {
}

private fun aPreviewState(
isPlaying: Boolean = false
isPlaying: Boolean = false,
isSending: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview(
isPlaying = isPlaying
isPlaying = isPlaying,
isSending = isSending,
waveform = waveform.toImmutableList(),
)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* 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.designsystem.components.media

import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlin.random.Random

object FakeWaveformFactory {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me the codebase tends to prefer top level function for these things: fun aFakeWaveform()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll fix in a follow up

private val random = Random(seed = 2)
/**
* Generate a waveform for testing purposes.
*
* The waveform is a list of floats between 0 and 1.
*
* @param length The length of the waveform.
*/
fun createFakeWaveform(length: Int = 1000): ImmutableList<Float> =
List(length) { random.nextFloat() }
.toPersistentList()
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ import kotlin.math.roundToInt

private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F

/**
* A view that displays a waveform and a cursor to indicate the current playback progress.
*
* @param playbackProgress The current playback progress, between 0 and 1.
* @param showCursor Whether to show the cursor or not.
* @param waveform The waveform to display. Use [FakeWaveformFactory] to generate a fake waveform.
* @param modifier The modifier to be applied to the view.
* @param onSeek Callback when the user seeks the waveform. Called with a value between 0 and 1.
* @param brush The brush to use to draw the waveform.
* @param progressBrush The brush to use to draw the progress.
* @param cursorBrush The brush to use to draw the cursor.
* @param lineWidth The width of the waveform lines.
* @param linePadding The padding between waveform lines.
* @param minimumGraphAmplitude The minimum amplitude to display, regardless of waveform data.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun WaveformPlaybackView(
Expand All @@ -78,7 +93,7 @@ fun WaveformPlaybackView(
}
}
val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation")
val amplitudeDisplayCount by remember(canvasSize) {
val amplitudeDisplayCount by remember(canvasSize, lineWidth, linePadding) {
derivedStateOf {
(canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.media.FakeWaveformFactory.createFakeWaveform
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleUp
Expand Down Expand Up @@ -118,6 +119,10 @@ fun TextComposer(
onVoicePlayerEvent(VoiceMessagePlayerEvent.Pause)
}

val onSeekVoiceMessage = { position: Float ->
onVoicePlayerEvent(VoiceMessagePlayerEvent.Seek(position))
}

val layoutModifier = modifier
.fillMaxSize()
.height(IntrinsicSize.Min)
Expand Down Expand Up @@ -180,8 +185,10 @@ fun TextComposer(
when (voiceMessageState) {
VoiceMessageState.Idle,
is VoiceMessageState.Recording -> recordVoiceButton
is VoiceMessageState.Preview -> sendVoiceButton
is VoiceMessageState.Sending -> uploadVoiceProgress
is VoiceMessageState.Preview -> when(voiceMessageState.isSending) {
true -> uploadVoiceProgress
false -> sendVoiceButton
}
}
else ->
sendButton
Expand All @@ -191,17 +198,12 @@ fun TextComposer(
when (voiceMessageState) {
is VoiceMessageState.Preview ->
VoiceMessagePreview(
isInteractive = true,
isInteractive = !voiceMessageState.isSending,
isPlaying = voiceMessageState.isPlaying,
waveform = voiceMessageState.waveform,
onPlayClick = onPlayVoiceMessageClicked,
onPauseClick = onPauseVoiceMessageClicked
)
VoiceMessageState.Sending ->
VoiceMessagePreview(
isInteractive = false,
isPlaying = false,
onPlayClick = onPlayVoiceMessageClicked,
onPauseClick = onPauseVoiceMessageClicked
onPauseClick = onPauseVoiceMessageClicked,
onSeek = onSeekVoiceMessage,
)
is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
Expand All @@ -210,13 +212,9 @@ fun TextComposer(
}

val voiceDeleteButton = @Composable {
val enabled = when (voiceMessageState) {
is VoiceMessageState.Preview -> true
VoiceMessageState.Sending,
is VoiceMessageState.Recording,
VoiceMessageState.Idle -> false
if(voiceMessageState is VoiceMessageState.Preview) {
VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage)
}
VoiceMessageDeleteButton(enabled = enabled, onClick = onDeleteVoiceMessage)
}

if (showTextFormatting) {
Expand Down Expand Up @@ -267,7 +265,7 @@ private fun StandardLayout(
verticalAlignment = Alignment.Bottom,
) {
if (enableVoiceMessages && voiceMessageState !is VoiceMessageState.Idle) {
if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Sending) {
if (voiceMessageState is VoiceMessageState.Preview) {
Box(
modifier = Modifier
.padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp)
Expand Down Expand Up @@ -800,11 +798,11 @@ internal fun TextComposerVoicePreview() = ElementPreview {
PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5f))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview(isPlaying = false))
VoicePreview(voiceMessageState = VoiceMessageState.Preview(isSending = false, isPlaying = false, waveform = createFakeWaveform()))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview(isPlaying = true))
VoicePreview(voiceMessageState = VoiceMessageState.Preview(isSending = false, isPlaying = true, waveform = createFakeWaveform()))
}, {
VoicePreview(voiceMessageState = VoiceMessageState.Sending)
VoicePreview(voiceMessageState = VoiceMessageState.Preview(isSending = true, isPlaying = false, waveform = createFakeWaveform()))
}))
}

Expand Down
Loading
Loading