android: add voice waveform/speed and circle video playback
Some checks failed
Android CI / android (push) Failing after 4m14s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:44:25 +03:00
parent 45918d65cb
commit f6851d2af9
3 changed files with 120 additions and 9 deletions

View File

@@ -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.

View File

@@ -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<Int>?,
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<Int>?,
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,

View File

@@ -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