android: add seek/pause controls for video and audio players
Some checks failed
Android CI / android (push) Failing after 5m15s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 08:58:15 +03:00
parent 22ee59fd74
commit 0208fbc5cc
2 changed files with 149 additions and 27 deletions

View File

@@ -974,3 +974,12 @@
- formatting toolbar is hidden while recording is active. - formatting toolbar is hidden while recording is active.
- Prevented controls collision for locked-recording actions: - Prevented controls collision for locked-recording actions:
- `Cancel/Send` now render on a separate row in locked state. - `Cancel/Send` now render on a separate row in locked state.
### Step 133 - Video/audio player controls upgrade
- Upgraded fullscreen video viewer controls:
- play/pause button,
- seek slider (scrubbing),
- current time / total duration labels.
- Upgraded attachment audio player behavior (voice + audio):
- added seek slider for manual rewind/fast-forward,
- unified speed toggle for both `voice` and `audio` playback.

View File

@@ -49,7 +49,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Slider
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -110,6 +110,7 @@ import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Wallpaper import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
@@ -1397,6 +1398,30 @@ fun ChatScreen(
} }
if (viewerVideoUrl != null) { if (viewerVideoUrl != null) {
val videoUrl = viewerVideoUrl.orEmpty() val videoUrl = viewerVideoUrl.orEmpty()
var videoViewRef by remember(videoUrl) { mutableStateOf<VideoView?>(null) }
var videoPrepared by remember(videoUrl) { mutableStateOf(false) }
var videoDurationMs by remember(videoUrl) { mutableStateOf(0) }
var videoPositionMs by remember(videoUrl) { mutableStateOf(0) }
var videoPlaying by remember(videoUrl) { mutableStateOf(true) }
var isVideoSeeking by remember(videoUrl) { mutableStateOf(false) }
var videoSeekFraction by remember(videoUrl) { mutableStateOf(0f) }
DisposableEffect(videoUrl) {
onDispose {
runCatching { videoViewRef?.stopPlayback() }
videoViewRef = null
}
}
LaunchedEffect(videoPlaying, videoPrepared, isVideoSeeking, videoUrl) {
if (!videoPlaying || !videoPrepared || isVideoSeeking) return@LaunchedEffect
while (videoPlaying && viewerVideoUrl == videoUrl && !isVideoSeeking) {
videoPositionMs = runCatching { videoViewRef?.currentPosition ?: videoPositionMs }
.getOrDefault(videoPositionMs)
delay(250)
}
}
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -1421,14 +1446,23 @@ fun ChatScreen(
factory = { context -> factory = { context ->
VideoView(context).apply { VideoView(context).apply {
setVideoPath(videoUrl) setVideoPath(videoUrl)
videoViewRef = this
setOnPreparedListener { player -> setOnPreparedListener { player ->
player.isLooping = true player.isLooping = false
videoPrepared = true
videoDurationMs = runCatching { duration }.getOrDefault(player.duration.coerceAtLeast(0))
start() start()
videoPlaying = true
}
setOnCompletionListener {
videoPlaying = false
videoPositionMs = videoDurationMs
} }
} }
}, },
update = { view -> update = { view ->
if (!view.isPlaying) { videoViewRef = view
if (videoPlaying && videoPrepared && !isVideoSeeking && !view.isPlaying) {
runCatching { view.start() } runCatching { view.start() }
} }
}, },
@@ -1437,13 +1471,80 @@ fun ChatScreen(
.weight(1f) .weight(1f)
.padding(horizontal = 10.dp), .padding(horizontal = 10.dp),
) )
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
val view = videoViewRef ?: return@IconButton
if (videoPlaying) {
runCatching { view.pause() }
videoPlaying = false
} else {
runCatching { view.start() }
videoPlaying = true
}
},
enabled = videoPrepared,
) {
Icon(
imageVector = if (videoPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (videoPlaying) "Pause video" else "Play video",
tint = Color.White,
)
}
Text( Text(
text = extractFileName(videoUrl), text = extractFileName(videoUrl),
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.White, color = Color.White,
maxLines = 1,
) )
} }
val videoProgress = if (videoDurationMs <= 0) 0f else {
(videoPositionMs.toFloat() / videoDurationMs.toFloat()).coerceIn(0f, 1f)
}
Slider(
value = if (isVideoSeeking) videoSeekFraction else videoProgress,
onValueChange = { fraction ->
isVideoSeeking = true
videoSeekFraction = fraction.coerceIn(0f, 1f)
},
onValueChangeFinished = {
val targetMs = (videoDurationMs * videoSeekFraction).roundToInt()
runCatching { videoViewRef?.seekTo(targetMs) }
videoPositionMs = targetMs
isVideoSeeking = false
},
enabled = videoPrepared && videoDurationMs > 0,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatDuration(videoPositionMs),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.85f),
)
Text(
text = if (videoDurationMs > 0) formatDuration(videoDurationMs) else "--:--",
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.85f),
)
}
}
}
} }
} }
if (showEmojiPicker) { if (showEmojiPicker) {
@@ -2239,6 +2340,8 @@ private fun AudioAttachmentPlayer(
var positionMs by remember(url) { mutableStateOf(0) } var positionMs by remember(url) { mutableStateOf(0) }
val speedOptions = listOf(1f, 1.5f, 2f) val speedOptions = listOf(1f, 1.5f, 2f)
var speedIndex by remember(url) { mutableStateOf(0) } var speedIndex by remember(url) { mutableStateOf(0) }
var isSeeking by remember(url) { mutableStateOf(false) }
var seekFraction by remember(url) { mutableStateOf(0f) }
val mediaPlayer = remember(url) { val mediaPlayer = remember(url) {
MediaPlayer().apply { MediaPlayer().apply {
setAudioAttributes( setAudioAttributes(
@@ -2307,8 +2410,8 @@ private fun AudioAttachmentPlayer(
) )
onForceStopAudioSourceHandled(currentId) onForceStopAudioSourceHandled(currentId)
} }
LaunchedEffect(isPlaying, isPrepared) { LaunchedEffect(isPlaying, isPrepared, isSeeking) {
if (!isPlaying || !isPrepared) return@LaunchedEffect if (!isPlaying || !isPrepared || isSeeking) return@LaunchedEffect
while (isPlaying) { while (isPlaying) {
positionMs = runCatching { mediaPlayer.currentPosition }.getOrDefault(positionMs) positionMs = runCatching { mediaPlayer.currentPosition }.getOrDefault(positionMs)
delay(250) delay(250)
@@ -2396,7 +2499,6 @@ private fun AudioAttachmentPlayer(
text = if (isVoice) "Voice" else "Audio", text = if (isVoice) "Voice" else "Audio",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
if (isVoice) {
Button( Button(
onClick = { onClick = {
speedIndex = (speedIndex + 1) % speedOptions.size speedIndex = (speedIndex + 1) % speedOptions.size
@@ -2411,17 +2513,28 @@ private fun AudioAttachmentPlayer(
Text("${speedOptions[speedIndex]}x") Text("${speedOptions[speedIndex]}x")
} }
} }
}
if (isVoice) { if (isVoice) {
VoiceWaveform( VoiceWaveform(
waveform = waveform, waveform = waveform,
progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f), progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f),
) )
} }
LinearProgressIndicator( val progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
progress = { Slider(
if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) value = if (isSeeking) seekFraction else progress,
onValueChange = { fraction ->
isSeeking = true
seekFraction = fraction.coerceIn(0f, 1f)
}, },
onValueChangeFinished = {
if (durationMs > 0) {
val targetMs = (durationMs * seekFraction).roundToInt()
runCatching { mediaPlayer.seekTo(targetMs) }
positionMs = targetMs
}
isSeeking = false
},
enabled = isPrepared && durationMs > 0,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Row( Row(