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.
|
- 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.
|
- 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`).
|
- 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.media.MediaPlayer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.widget.VideoView
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
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.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.unit.dp
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
@@ -1029,15 +1032,23 @@ private fun MessageBubble(
|
|||||||
when {
|
when {
|
||||||
fileType.startsWith("video/") -> {
|
fileType.startsWith("video/") -> {
|
||||||
Box {
|
Box {
|
||||||
|
if (message.type.contains("video_note", ignoreCase = true)) {
|
||||||
|
CircleVideoAttachmentPlayer(url = attachment.fileUrl)
|
||||||
|
} else {
|
||||||
VideoAttachmentCard(
|
VideoAttachmentCard(
|
||||||
url = attachment.fileUrl,
|
url = attachment.fileUrl,
|
||||||
fileType = attachment.fileType,
|
fileType = attachment.fileType,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fileType.startsWith("audio/") -> {
|
fileType.startsWith("audio/") -> {
|
||||||
Box {
|
Box {
|
||||||
AudioAttachmentPlayer(url = attachment.fileUrl)
|
AudioAttachmentPlayer(
|
||||||
|
url = attachment.fileUrl,
|
||||||
|
waveform = message.attachmentWaveform,
|
||||||
|
isVoice = message.type.contains("voice", ignoreCase = true),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
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
|
@Composable
|
||||||
private fun FileAttachmentRow(
|
private fun FileAttachmentRow(
|
||||||
fileUrl: String,
|
fileUrl: String,
|
||||||
@@ -1149,11 +1194,17 @@ private fun formatMessageTime(createdAt: String): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AudioAttachmentPlayer(url: String) {
|
private fun AudioAttachmentPlayer(
|
||||||
|
url: String,
|
||||||
|
waveform: List<Int>?,
|
||||||
|
isVoice: Boolean,
|
||||||
|
) {
|
||||||
var isPlaying by remember(url) { mutableStateOf(false) }
|
var isPlaying by remember(url) { mutableStateOf(false) }
|
||||||
var isPrepared by remember(url) { mutableStateOf(false) }
|
var isPrepared by remember(url) { mutableStateOf(false) }
|
||||||
var durationMs by remember(url) { mutableStateOf(0) }
|
var durationMs by remember(url) { mutableStateOf(0) }
|
||||||
var positionMs 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) {
|
val mediaPlayer = remember(url) {
|
||||||
MediaPlayer().apply {
|
MediaPlayer().apply {
|
||||||
setAudioAttributes(
|
setAudioAttributes(
|
||||||
@@ -1219,6 +1270,9 @@ private fun AudioAttachmentPlayer(url: String) {
|
|||||||
isPlaying = false
|
isPlaying = false
|
||||||
AppAudioFocusCoordinator.release("player:$url")
|
AppAudioFocusCoordinator.release("player:$url")
|
||||||
} else {
|
} else {
|
||||||
|
runCatching {
|
||||||
|
mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex])
|
||||||
|
}
|
||||||
AppAudioFocusCoordinator.request("player:$url")
|
AppAudioFocusCoordinator.request("player:$url")
|
||||||
mediaPlayer.start()
|
mediaPlayer.start()
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
@@ -1229,9 +1283,30 @@ private fun AudioAttachmentPlayer(url: String) {
|
|||||||
Text(if (isPlaying) "Pause" else "Play")
|
Text(if (isPlaying) "Pause" else "Play")
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Audio",
|
text = if (isVoice) "Voice" else "Audio",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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(
|
LinearProgressIndicator(
|
||||||
progress = {
|
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
|
@Composable
|
||||||
private fun VoiceHoldToRecordButton(
|
private fun VoiceHoldToRecordButton(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
|
|||||||
@@ -62,9 +62,9 @@
|
|||||||
- [x] Галерея в сообщении (multi media)
|
- [x] Галерея в сообщении (multi media)
|
||||||
- [x] Media viewer (zoom/swipe/download)
|
- [x] Media viewer (zoom/swipe/download)
|
||||||
- [x] Единое контекстное меню для медиа
|
- [x] Единое контекстное меню для медиа
|
||||||
- [ ] Voice playback waveform + speed
|
- [x] Voice playback waveform + speed
|
||||||
- [x] Audio player UI (не как voice)
|
- [x] Audio player UI (не как voice)
|
||||||
- [ ] Circle video playback (view-only при необходимости)
|
- [x] Circle video playback (view-only при необходимости)
|
||||||
|
|
||||||
## 9. Запись голосовых
|
## 9. Запись голосовых
|
||||||
- [x] Hold-to-record
|
- [x] Hold-to-record
|
||||||
|
|||||||
Reference in New Issue
Block a user