From 0208fbc5ccbb92e72987234e46cf8b0391500106 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 08:58:15 +0300 Subject: [PATCH] android: add seek/pause controls for video and audio players --- android/CHANGELOG.md | 9 + .../messenger/ui/chat/ChatScreen.kt | 167 +++++++++++++++--- 2 files changed, 149 insertions(+), 27 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index f4fef69..0dc4bdc 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -974,3 +974,12 @@ - formatting toolbar is hidden while recording is active. - Prevented controls collision for locked-recording actions: - `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. 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 ef60fe0..56c6d9a 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 @@ -49,7 +49,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Slider import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.DropdownMenu 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.Search 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.Info import androidx.compose.material.icons.filled.Image @@ -1397,6 +1398,30 @@ fun ChatScreen( } if (viewerVideoUrl != null) { val videoUrl = viewerVideoUrl.orEmpty() + var videoViewRef by remember(videoUrl) { mutableStateOf(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( modifier = Modifier .fillMaxSize() @@ -1421,14 +1446,23 @@ fun ChatScreen( factory = { context -> VideoView(context).apply { setVideoPath(videoUrl) + videoViewRef = this setOnPreparedListener { player -> - player.isLooping = true + player.isLooping = false + videoPrepared = true + videoDurationMs = runCatching { duration }.getOrDefault(player.duration.coerceAtLeast(0)) start() + videoPlaying = true + } + setOnCompletionListener { + videoPlaying = false + videoPositionMs = videoDurationMs } } }, update = { view -> - if (!view.isPlaying) { + videoViewRef = view + if (videoPlaying && videoPrepared && !isVideoSeeking && !view.isPlaying) { runCatching { view.start() } } }, @@ -1437,12 +1471,79 @@ fun ChatScreen( .weight(1f) .padding(horizontal = 10.dp), ) - Text( - text = extractFileName(videoUrl), - modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - ) + 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 = extractFileName(videoUrl), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + 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), + ) + } + } } } } @@ -2239,6 +2340,8 @@ private fun AudioAttachmentPlayer( var positionMs by remember(url) { mutableStateOf(0) } val speedOptions = listOf(1f, 1.5f, 2f) 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) { MediaPlayer().apply { setAudioAttributes( @@ -2307,8 +2410,8 @@ private fun AudioAttachmentPlayer( ) onForceStopAudioSourceHandled(currentId) } - LaunchedEffect(isPlaying, isPrepared) { - if (!isPlaying || !isPrepared) return@LaunchedEffect + LaunchedEffect(isPlaying, isPrepared, isSeeking) { + if (!isPlaying || !isPrepared || isSeeking) return@LaunchedEffect while (isPlaying) { positionMs = runCatching { mediaPlayer.currentPosition }.getOrDefault(positionMs) delay(250) @@ -2396,20 +2499,18 @@ private fun AudioAttachmentPlayer( 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]) - } + Button( + onClick = { + speedIndex = (speedIndex + 1) % speedOptions.size + if (isPrepared) { + runCatching { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) } - }, - enabled = isPrepared, - ) { - Text("${speedOptions[speedIndex]}x") - } + } + }, + enabled = isPrepared, + ) { + Text("${speedOptions[speedIndex]}x") } } if (isVoice) { @@ -2418,10 +2519,22 @@ private fun AudioAttachmentPlayer( progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f), ) } - LinearProgressIndicator( - progress = { - if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + val progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + Slider( + 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(), ) Row(