android: add voice waveform/speed and circle video playback
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user