From f6851d2af9e4c68d59b30abd770378a71feff170 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 16:44:25 +0300 Subject: [PATCH] android: add voice waveform/speed and circle video playback --- android/CHANGELOG.md | 6 + .../messenger/ui/chat/ChatScreen.kt | 119 ++++++++++++++++-- docs/android-checklist.md | 4 +- 3 files changed, 120 insertions(+), 9 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 34e281f..c12417a 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -444,3 +444,9 @@ - Added destructive action confirmation via `AlertDialog` before delete actions. - Reduced gesture conflicts by removing attachment-level long-press handlers that collided with message selection gestures. - Improved voice hold gesture reliability by handling consumed pointer down events (`requireUnconsumed = false`). + +### Step 71 - Voice playback waveform/speed + circle video playback +- Added voice-focused audio playback mode with waveform rendering in message bubbles. +- Added playback speed switch for voice messages (`1.0x -> 1.5x -> 2.0x`). +- Added view-only circle video renderer for `video_note` messages with looped playback. +- Kept regular audio/video attachment rendering for non-voice/non-circle media unchanged. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index c62f113..2084b10 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -7,6 +7,7 @@ import android.media.AudioAttributes import android.media.MediaPlayer import android.net.Uri import android.provider.OpenableColumns +import android.widget.VideoView import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -29,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -61,6 +63,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing import androidx.compose.ui.input.pointer.pointerInput @@ -1029,15 +1032,23 @@ private fun MessageBubble( when { fileType.startsWith("video/") -> { Box { - VideoAttachmentCard( - url = attachment.fileUrl, - fileType = attachment.fileType, - ) + if (message.type.contains("video_note", ignoreCase = true)) { + CircleVideoAttachmentPlayer(url = attachment.fileUrl) + } else { + VideoAttachmentCard( + url = attachment.fileUrl, + fileType = attachment.fileType, + ) + } } } fileType.startsWith("audio/") -> { Box { - AudioAttachmentPlayer(url = attachment.fileUrl) + AudioAttachmentPlayer( + url = attachment.fileUrl, + waveform = message.attachmentWaveform, + isVoice = message.type.contains("voice", ignoreCase = true), + ) } } else -> { @@ -1099,6 +1110,40 @@ private fun VideoAttachmentCard( } } +@Composable +private fun CircleVideoAttachmentPlayer(url: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + AndroidView( + factory = { context -> + VideoView(context).apply { + setVideoPath(url) + setOnPreparedListener { player -> + player.isLooping = true + player.setVolume(0f, 0f) + start() + } + } + }, + modifier = Modifier + .size(220.dp) + .clip(CircleShape), + update = { view -> + if (!view.isPlaying) { + runCatching { view.start() } + } + }, + ) + Text("Circle video", style = MaterialTheme.typography.labelSmall) + } +} + @Composable private fun FileAttachmentRow( fileUrl: String, @@ -1149,11 +1194,17 @@ private fun formatMessageTime(createdAt: String): String { } @Composable -private fun AudioAttachmentPlayer(url: String) { +private fun AudioAttachmentPlayer( + url: String, + waveform: List?, + isVoice: Boolean, +) { var isPlaying by remember(url) { mutableStateOf(false) } var isPrepared by remember(url) { mutableStateOf(false) } var durationMs by remember(url) { mutableStateOf(0) } var positionMs by remember(url) { mutableStateOf(0) } + val speedOptions = listOf(1f, 1.5f, 2f) + var speedIndex by remember(url) { mutableStateOf(0) } val mediaPlayer = remember(url) { MediaPlayer().apply { setAudioAttributes( @@ -1219,6 +1270,9 @@ private fun AudioAttachmentPlayer(url: String) { isPlaying = false AppAudioFocusCoordinator.release("player:$url") } else { + runCatching { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) + } AppAudioFocusCoordinator.request("player:$url") mediaPlayer.start() isPlaying = true @@ -1229,9 +1283,30 @@ private fun AudioAttachmentPlayer(url: String) { Text(if (isPlaying) "Pause" else "Play") } Text( - text = "Audio", + text = if (isVoice) "Voice" else "Audio", style = MaterialTheme.typography.labelSmall, ) + if (isVoice) { + Button( + onClick = { + speedIndex = (speedIndex + 1) % speedOptions.size + if (isPrepared) { + runCatching { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) + } + } + }, + enabled = isPrepared, + ) { + Text("${speedOptions[speedIndex]}x") + } + } + } + if (isVoice) { + VoiceWaveform( + waveform = waveform, + progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f), + ) } LinearProgressIndicator( progress = { @@ -1249,6 +1324,36 @@ private fun AudioAttachmentPlayer(url: String) { } } +@Composable +private fun VoiceWaveform( + waveform: List?, + progress: Float, +) { + val source = waveform?.takeIf { it.isNotEmpty() } ?: List(28) { index -> + ((index % 7) + 1) * 6 + } + val playedBars = (source.size * progress).roundToInt().coerceIn(0, source.size) + Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { + source.forEachIndexed { index, value -> + val normalized = (value.coerceAtLeast(4).coerceAtMost(100)).toFloat() + val barHeight = (12f + normalized / 4f).dp + Box( + modifier = Modifier + .weight(1f) + .height(barHeight) + .clip(RoundedCornerShape(4.dp)) + .background( + if (index < playedBars) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) + } + ), + ) + } + } +} + @Composable private fun VoiceHoldToRecordButton( enabled: Boolean, diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 636dc1c..b3877e3 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -62,9 +62,9 @@ - [x] Галерея в сообщении (multi media) - [x] Media viewer (zoom/swipe/download) - [x] Единое контекстное меню для медиа -- [ ] Voice playback waveform + speed +- [x] Voice playback waveform + speed - [x] Audio player UI (не как voice) -- [ ] Circle video playback (view-only при необходимости) +- [x] Circle video playback (view-only при необходимости) ## 9. Запись голосовых - [x] Hold-to-record