android: add seek/pause controls for video and audio players
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<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(
|
||||
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,13 +1471,80 @@ fun ChatScreen(
|
||||
.weight(1f)
|
||||
.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 = extractFileName(videoUrl),
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showEmojiPicker) {
|
||||
@@ -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,7 +2499,6 @@ private fun AudioAttachmentPlayer(
|
||||
text = if (isVoice) "Voice" else "Audio",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
if (isVoice) {
|
||||
Button(
|
||||
onClick = {
|
||||
speedIndex = (speedIndex + 1) % speedOptions.size
|
||||
@@ -2411,17 +2513,28 @@ private fun AudioAttachmentPlayer(
|
||||
Text("${speedOptions[speedIndex]}x")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isVoice) {
|
||||
VoiceWaveform(
|
||||
waveform = waveform,
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user