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 5e0d9bcada..6500176e73 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 @@ -40,6 +40,7 @@ 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.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -172,7 +173,7 @@ class VoiceMessageComposerPresenter @Inject constructor( voiceMessageState = when (val state = recorderState) { is VoiceRecorderState.Recording -> VoiceMessageState.Recording( duration = state.elapsedTime, - level = state.level + levels = state.levels.toPersistentList() ) is VoiceRecorderState.Finished -> if (isSending) { VoiceMessageState.Sending diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt index c448ca1a84..f7e3263287 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt @@ -18,12 +18,13 @@ package io.element.android.features.messages.impl.voicemessages.composer import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.textcomposer.model.VoiceMessageState +import kotlinx.collections.immutable.toPersistentList import kotlin.time.Duration.Companion.seconds internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5f)), + aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = aWaveformLevels)), ) } @@ -35,3 +36,7 @@ internal fun aVoiceMessageComposerState( showPermissionRationaleDialog = showPermissionRationaleDialog, eventSink = {}, ) + +internal var aWaveformLevels = List(100) { it.toFloat() / 200 }.toPersistentList() + + diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/DrawScopeWaveformExtensions.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/DrawScopeWaveformExtensions.kt new file mode 100644 index 0000000000..9e09d97471 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/DrawScopeWaveformExtensions.kt @@ -0,0 +1,57 @@ +/* + * 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 androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList +import kotlin.math.max + +fun DrawScope.drawWaveform( + waveformData: ImmutableList, + canvasSize: DpSize, + brush: Brush, + minimumGraphAmplitude: Float = 2F, + lineWidth: Dp = 2.dp, + linePadding: Dp = 2.dp, +) { + val centerY = canvasSize.height.toPx() / 2 + val cornerRadius = lineWidth / 2 + waveformData.forEachIndexed { index, amplitude -> + val drawingAmplitude = max(minimumGraphAmplitude, amplitude * (canvasSize.height.toPx() - 2)) + drawRoundRect( + brush = brush, + topLeft = Offset( + x = index * (linePadding + lineWidth).toPx(), + y = centerY - drawingAmplitude / 2 + ), + size = Size( + width = lineWidth.toPx(), + height = drawingAmplitude + ), + cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()), + style = Fill + ) + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt index d9d15c1c3b..5cf92bf28a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -48,7 +48,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.theme.ElementTheme import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList -import kotlin.math.max private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F @OptIn(ExperimentalComposeUiApi::class) @@ -64,7 +63,6 @@ fun WaveformPlaybackView( cursorBrush: Brush = SolidColor(ElementTheme.colors.iconAccentTertiary), lineWidth: Dp = 2.dp, linePadding: Dp = 2.dp, - minimumGraphAmplitude: Float = 2F, ) { val seekProgress = remember { mutableStateOf(null) } var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) } @@ -121,22 +119,13 @@ fun WaveformPlaybackView( canvasSizePx = size val centerY = canvasSize.height.toPx() / 2 val cornerRadius = lineWidth / 2 - normalizedWaveformData.forEachIndexed { index, amplitude -> - val drawingAmplitude = max(minimumGraphAmplitude, amplitude * (canvasSize.height.toPx() - 2)) - drawRoundRect( - brush = brush, - topLeft = Offset( - x = index * (linePadding + lineWidth).toPx(), - y = centerY - drawingAmplitude / 2 - ), - size = Size( - width = lineWidth.toPx(), - height = drawingAmplitude - ), - cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()), - style = Fill - ) - } + drawWaveform( + waveformData = normalizedWaveformData, + canvasSize = canvasSize, + brush = brush, + lineWidth = lineWidth, + linePadding = linePadding + ) drawRect( brush = progressBrush, size = Size( 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 73c1764de2..8432700f3d 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 @@ -82,6 +82,7 @@ import io.element.android.wysiwyg.compose.RichTextEditor import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import kotlin.time.Duration.Companion.seconds @Composable @@ -204,7 +205,7 @@ fun TextComposer( onPauseClick = onPauseVoiceMessageClicked ) is VoiceMessageState.Recording -> - VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration) + VoiceMessageRecording(voiceMessageState.levels, voiceMessageState.duration) VoiceMessageState.Idle -> {} } } @@ -798,7 +799,7 @@ internal fun TextComposerVoicePreview() = ElementPreview { enableVoiceMessages = true, ) PreviewColumn(items = persistentListOf({ - VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5f)) + VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, List(100) { it.toFloat() / 200 }.toPersistentList())) }, { VoicePreview(voiceMessageState = VoiceMessageState.Preview(isPlaying = false)) }, { diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/LiveWaveformView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/LiveWaveformView.kt new file mode 100644 index 0000000000..aa4a6cc749 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/LiveWaveformView.kt @@ -0,0 +1,106 @@ +/* + * 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.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.components.media.drawWaveform +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.theme.ElementTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList + +private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F +private val waveFormHeight = 26.dp +@Composable +fun LiveWaveformView( + levels: ImmutableList, + modifier: Modifier = Modifier, + brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary), + lineWidth: Dp = 2.dp, + linePadding: Dp = 2.dp, +) { + var canvasSize by remember { mutableStateOf(DpSize(0.dp, 0.dp)) } + + val canvasWidth by remember(levels, lineWidth, linePadding) { + derivedStateOf { + levels.size * (lineWidth.value + linePadding.value) + } + } + var width by remember { mutableIntStateOf(0) } + + Box(contentAlignment = Alignment.CenterEnd, + modifier = modifier + .fillMaxWidth() + .height(waveFormHeight) + .onSizeChanged { width = it.width } + ) { + Canvas( + modifier = Modifier + .width(Dp(canvasWidth)) + .graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA) + .then(modifier) + ) { + canvasSize = DpSize(Dp(canvasWidth), size.height.toDp()) + val countThatFitsWidth = (width.toFloat() / (lineWidth.toPx() + linePadding.toPx())).toInt() + drawWaveform( + waveformData = levels.takeLast(countThatFitsWidth).toPersistentList(), + canvasSize = canvasSize, + brush = brush, + lineWidth = lineWidth, + linePadding = linePadding, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun LiveWaveformViewPreview() = ElementPreview { + Column { + + LiveWaveformView( + levels = List(100) { it.toFloat() / 200 }.toPersistentList(), + modifier = Modifier.height(34.dp), + ) + LiveWaveformView( + levels = List(40) { it.toFloat() / 40 }.toPersistentList(), + modifier = Modifier.height(34.dp), + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt index 22244cd58b..29993a0ec6 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecording.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn @@ -37,12 +36,14 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.ui.utils.time.formatShort +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @Composable internal fun VoiceMessageRecording( - level: Float, + levels: ImmutableList, duration: Duration, modifier: Modifier = Modifier, ) { @@ -70,28 +71,11 @@ internal fun VoiceMessageRecording( Spacer(Modifier.size(20.dp)) - // TODO Replace with waveform UI - DebugAudioLevel( - modifier = Modifier.weight(1f), level = level - ) - } -} - -@Composable -private fun DebugAudioLevel( - level: Float, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .height(26.dp) - ) { - Box( + LiveWaveformView( modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxWidth(level) - .background(ElementTheme.colors.iconQuaternary, shape = MaterialTheme.shapes.small) - .fillMaxHeight() + .height(34.dp) + .weight(1f), + levels = levels ) } } @@ -108,5 +92,5 @@ private fun RedRecordingDot( @PreviewsDayNight @Composable internal fun VoiceMessageRecordingPreview() = ElementPreview { - VoiceMessageRecording(0.5f, 0.seconds) + VoiceMessageRecording(List(100) { it.toFloat() / 200 }.toPersistentList(), 0.seconds) } 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 012f655ad2..e5edd2c760 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 @@ -16,6 +16,7 @@ package io.element.android.libraries.textcomposer.model +import kotlinx.collections.immutable.ImmutableList import kotlin.time.Duration sealed class VoiceMessageState { @@ -27,6 +28,6 @@ sealed class VoiceMessageState { data object Sending: VoiceMessageState() data class Recording( val duration: Duration, - val level: Float, + val levels: ImmutableList, ): VoiceMessageState() } diff --git a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt index c168e3d5fe..6f7ac54f5e 100644 --- a/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt +++ b/libraries/voicerecorder/api/src/main/kotlin/io/element/android/libraries/voicerecorder/api/VoiceRecorderState.kt @@ -29,9 +29,9 @@ sealed class VoiceRecorderState { * The recorder is currently recording. * * @property elapsedTime The elapsed time since the recording started. - * @property level The current audio level of the recording as a fraction of 1. + * @property levels The current audio levels of the recording as a fraction of 1. */ - data class Recording(val elapsedTime: Duration, val level: Float) : VoiceRecorderState() + data class Recording(val elapsedTime: Duration, val levels: List) : VoiceRecorderState() /** * The recorder has finished recording. diff --git a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt index e1481083f9..2590cf64e0 100644 --- a/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt +++ b/libraries/voicerecorder/impl/src/main/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImpl.kt @@ -96,13 +96,13 @@ class VoiceRecorderImpl @Inject constructor( when (audio) { is Audio.Data -> { val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer) - _state.emit(VoiceRecorderState.Recording(elapsedTime, audioLevel)) levels.add(audioLevel) + _state.emit(VoiceRecorderState.Recording(elapsedTime, levels)) encoder.encode(audio.buffer, audio.readSize) } is Audio.Error -> { Timber.e("Voice message error: code=${audio.audioRecordErrorCode}") - _state.emit(VoiceRecorderState.Recording(elapsedTime, 0.0f)) + _state.emit(VoiceRecorderState.Recording(elapsedTime, listOf())) } } }