Simple live waveform implementation.
This commit is contained in:
parent
00d24ce4b1
commit
1389c9ed24
10 changed files with 195 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<VoiceMessageComposerState> {
|
||||
override val values: Sequence<VoiceMessageComposerState>
|
||||
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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Float>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Float?>(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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -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<Float>,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Float>,
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Float>,
|
||||
): VoiceMessageState()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Float>) : VoiceRecorderState()
|
||||
|
||||
/**
|
||||
* The recorder has finished recording.
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue