From 296cd7ca143d15b452dc96d13c8c86b864520bde Mon Sep 17 00:00:00 2001 From: David Langley Date: Wed, 25 Oct 2023 22:12:01 +0100 Subject: [PATCH] Add custom waveform with cursor and nice gesture support. --- .../messages/impl/mediaplayer/MediaPlayer.kt | 3 + .../components/event/TimelineItemVoiceView.kt | 19 +- .../timeline/VoiceMessageStateProvider.kt | 8 +- .../timeline/WaveformPlaybackView.kt | 214 ++++++++++++++++++ 4 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformPlaybackView.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt index 71acc9bb3c..0055496f43 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt @@ -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() { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index e30993fe5b..b0b287f9dc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -44,6 +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.Waveform +import io.element.android.features.messages.impl.voicemessages.timeline.WaveformPlaybackView import io.element.android.features.messages.impl.voicemessages.timeline.WaveformProgressIndicator import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -92,14 +94,23 @@ 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)) }, ) +// WaveformProgressIndicator( +// modifier = Modifier +// .height(34.dp) +// .weight(1f), +// progress = state.progress, +// amplitudes = content.waveform, +// onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) } +// ) Spacer(Modifier.width(extraPadding.getDpSize())) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt index ca40ec8cd7..faa605382c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt @@ -35,7 +35,13 @@ open class VoiceMessageStateProvider : PreviewParameterProvider +) { + companion object { + private val dataRange = 0..1024 + } + + fun normalisedData(maxSamplesCount: Int): ImmutableList { + if(maxSamplesCount <= 0) { + return persistentListOf() + } + + // Filter the data to keep only the expected number of samples + val result = if (data.size > maxSamplesCount) { + (0.. + 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() + } +} +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, +) { + var seekProgress = remember { mutableStateOf(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( + showCursor = false, + playbackProgress = 0.5f, + waveform = Waveform(persistentListOf()), + ) + WaveformPlaybackView( + 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( + showCursor = true, + playbackProgress = 0.5f, + waveform = Waveform(List(1024) { it }.toPersistentList()), + ) + } +}