Merge pull request #1641 from vector-im/langleyd/custom_waveform
Add custom waveform with cursor and nice gesture support.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user