Merge pull request #1641 from vector-im/langleyd/custom_waveform

Add custom waveform with cursor and nice gesture support.
This commit is contained in:
David Langley
2023-10-26 13:52:47 +01:00
committed by GitHub
44 changed files with 319 additions and 174 deletions

View File

@@ -65,7 +65,6 @@ dependencies {
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings)
implementation(libs.audiowaveform)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

View File

@@ -174,6 +174,9 @@ class MediaPlayerImpl @Inject constructor(
override fun seekTo(positionMs: Long) {
player.seekTo(positionMs)
_state.update {
it.copy(currentPosition = player.currentPosition)
}
}
override fun close() {

View File

@@ -44,7 +44,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
import io.element.android.features.messages.impl.voicemessages.timeline.WaveformProgressIndicator
import io.element.android.features.messages.impl.voicemessages.timeline.Waveform
import io.element.android.features.messages.impl.voicemessages.timeline.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@@ -84,13 +85,14 @@ fun TimelineItemVoiceView(
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
WaveformProgressIndicator(
WaveformPlaybackView(
showCursor = state.button == VoiceMessageState.Button.Pause,
playbackProgress = state.progress,
waveform = Waveform(data = content.waveform),
modifier = Modifier
.height(34.dp)
.weight(1f),
progress = state.progress,
amplitudes = content.waveform,
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
)
Spacer(Modifier.width(extraPadding.getDpSize()))
}

View File

@@ -0,0 +1,50 @@
/*
* 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.timeline
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlin.math.roundToInt
data class Waveform (
val data: ImmutableList<Int>
) {
companion object {
private val dataRange = 0..1024
}
fun normalisedData(maxSamplesCount: Int): ImmutableList<Float> {
if(maxSamplesCount <= 0) {
return persistentListOf()
}
// Filter the data to keep only the expected number of samples
val result = if (data.size > maxSamplesCount) {
(0..<maxSamplesCount)
.map { index ->
val targetIndex = (index.toDouble() * (data.count().toDouble() / maxSamplesCount.toDouble())).roundToInt()
data[targetIndex]
}
} else {
data
}
// Normalize the sample in the allowed range
return result.map { it.toFloat() / dataRange.last.toFloat() }.toPersistentList()
}
}

View File

@@ -0,0 +1,189 @@
/*
* 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.timeline
import android.view.MotionEvent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.RequestDisallowInterceptTouchEvent
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
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.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)
@Composable
fun WaveformPlaybackView(
playbackProgress: Float,
showCursor: Boolean,
waveform: Waveform,
modifier: Modifier = Modifier,
onSeek: (progress: Float) -> Unit = {},
brush: Brush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush: Brush = SolidColor(ElementTheme.colors.iconSecondary),
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)) }
var canvasSizePx by remember { mutableStateOf(Size(0f, 0f)) }
val progress by remember(playbackProgress, seekProgress.value) {
derivedStateOf {
seekProgress.value ?: playbackProgress
}
}
val progressAnimated = animateFloatAsState(targetValue = progress, label = "progressAnimation")
val amplitudeDisplayCount by remember(canvasSize) {
derivedStateOf {
(canvasSize.width.value / (lineWidth.value + linePadding.value)).toInt()
}
}
val normalizedWaveformData by remember(amplitudeDisplayCount) {
derivedStateOf {
waveform.normalisedData(amplitudeDisplayCount)
}
}
val requestDisallowInterceptTouchEvent = remember { RequestDisallowInterceptTouchEvent() }
Canvas(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(alpha = DEFAULT_GRAPHICS_LAYER_ALPHA)
.pointerInteropFilter(requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent) {
return@pointerInteropFilter when (it.action) {
MotionEvent.ACTION_DOWN -> {
if (it.x in 0F..canvasSizePx.width) {
requestDisallowInterceptTouchEvent.invoke(true)
seekProgress.value = it.x / canvasSizePx.width
true
} else false
}
MotionEvent.ACTION_MOVE -> {
if (it.x in 0F..canvasSizePx.width) {
seekProgress.value = it.x / canvasSizePx.width
}
true
}
MotionEvent.ACTION_UP -> {
requestDisallowInterceptTouchEvent.invoke(false)
seekProgress.value?.let(onSeek)
seekProgress.value = null
true
}
else -> false
}
}
.then(modifier)
) {
canvasSize = size.toDpSize()
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
)
}
drawRect(
brush = progressBrush,
size = Size(
width = progressAnimated.value * canvasSize.width.toPx(),
height = canvasSize.height.toPx()
),
blendMode = BlendMode.SrcAtop
)
if(showCursor || seekProgress.value != null) {
drawRoundRect(
brush = cursorBrush,
topLeft = Offset(
x = progressAnimated.value * canvasSize.width.toPx(),
y = centerY - (canvasSize.height.toPx() - 2) / 2
),
size = Size(
width = lineWidth.toPx(),
height = canvasSize.height.toPx() - 2
),
cornerRadius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()),
style = Fill
)
}
}
}
@PreviewsDayNight
@Composable
internal fun WaveformPlaybackViewPreview() = ElementPreview {
Column{
WaveformPlaybackView(
modifier = Modifier.height(34.dp),
showCursor = false,
playbackProgress = 0.5f,
waveform = Waveform(persistentListOf()),
)
WaveformPlaybackView(
modifier = Modifier.height(34.dp),
showCursor = false,
playbackProgress = 0.5f,
waveform = Waveform(persistentListOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)),
)
WaveformPlaybackView(
modifier = Modifier.height(34.dp),
showCursor = true,
playbackProgress = 0.5f,
waveform = Waveform(List(1024) { it }.toPersistentList()),
)
}
}

View File

@@ -1,96 +0,0 @@
/*
* 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.timeline
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
import com.linc.audiowaveform.AudioWaveform
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.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
fun WaveformProgressIndicator(
progress: Float,
amplitudes: ImmutableList<Int>,
modifier: Modifier = Modifier,
onSeek: (progress: Float) -> Unit = {},
) {
var seekProgress: Float? by remember { mutableStateOf(null) }
val scaledAmplitudes = remember(amplitudes) { amplitudes.scaleAmplitudes() }
AudioWaveform(
modifier = modifier,
waveformBrush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush = SolidColor(ElementTheme.colors.iconSecondary),
onProgressChangeFinished = {
// This is to send just one onSeek callback after the user has finished seeking.
// Otherwise the AudioWaveform library would send multiple callbacks while the user is seeking.
val p = seekProgress!!
seekProgress = null
onSeek(p)
},
spikeWidth = 1.6.dp,
spikeRadius = 0.8.dp,
spikePadding = 3.dp,
progress = seekProgress ?: progress,
amplitudes = scaledAmplitudes,
onProgressChange = { seekProgress = it },
)
}
@PreviewsDayNight
@Composable
internal fun WaveformProgressIndicatorPreview() = ElementPreview {
Column {
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = persistentListOf(),
)
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = persistentListOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
)
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = List(1024) { it }.toPersistentList()
)
}
}
/**
* Scale amplitudes to fit in the waveform view.
*
* It seems amplitudes > 128 are clipped by the waveform library.
* Workaround for https://github.com/lincollincol/compose-audiowaveform/issues/22
*
* TODO Voice messages: Remove this workaround when the waveform library is fixed.
*/
private fun ImmutableList<Int>.scaleAmplitudes(): List<Int> {
val maxAmplitude = if (isEmpty()) 1 else maxOf { it }
val scalingFactor = 128 / maxAmplitude.toFloat()
return map { (it * scalingFactor).toInt() }
}

View File

@@ -166,7 +166,6 @@ maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
opusencoder = "io.element.android:opusencoder:1.1.0"
audiowaveform = "com.github.lincollincol:compose-audiowaveform:1.1.1"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"

View File

@@ -35,7 +35,6 @@ dependencyResolutionManagement {
content {
includeModule("com.github.UnifiedPush", "android-connector")
includeModule("com.github.matrix-org", "matrix-analytics-events")
includeModule("com.github.lincollincol", "compose-audiowaveform")
}
}
// To have immediate access to Rust SDK versions