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.
|
- 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.
|
||||||
|
|||||||
@@ -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,12 +1471,79 @@ fun ChatScreen(
|
|||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(horizontal = 10.dp),
|
.padding(horizontal = 10.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Column(
|
||||||
text = extractFileName(videoUrl),
|
modifier = Modifier
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
.fillMaxWidth()
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
color = Color.White,
|
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) }
|
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,20 +2499,18 @@ 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
|
if (isPrepared) {
|
||||||
if (isPrepared) {
|
runCatching {
|
||||||
runCatching {
|
mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex])
|
||||||
mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
enabled = isPrepared,
|
},
|
||||||
) {
|
enabled = isPrepared,
|
||||||
Text("${speedOptions[speedIndex]}x")
|
) {
|
||||||
}
|
Text("${speedOptions[speedIndex]}x")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isVoice) {
|
if (isVoice) {
|
||||||
@@ -2418,10 +2519,22 @@ private fun AudioAttachmentPlayer(
|
|||||||
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user