From 27fba86915b6caa367f6e2de800b4982ace65daf Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 21:03:03 +0300 Subject: [PATCH] fix(android): make top audio strip controls functional --- .../messenger/ui/chat/ChatScreen.kt | 169 ++++++++++++------ 1 file changed, 110 insertions(+), 59 deletions(-) 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 5314920..8e55029 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 @@ -317,6 +317,8 @@ fun ChatScreen( var dismissedPinnedMessageId by remember { mutableStateOf(null) } var topAudioStrip by remember { mutableStateOf(null) } var forceStopAudioSourceId by remember { mutableStateOf(null) } + var forceToggleAudioSourceId by remember { mutableStateOf(null) } + var forceCycleSpeedAudioSourceId by remember { mutableStateOf(null) } var actionMenuMessage by remember { mutableStateOf(null) } var pendingDeleteForAll by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } @@ -662,12 +664,15 @@ fun ChatScreen( Surface( shape = CircleShape, color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - modifier = Modifier.size(30.dp), + modifier = Modifier + .size(30.dp) + .clip(CircleShape) + .clickable { forceToggleAudioSourceId = strip.sourceId }, ) { Box(contentAlignment = Alignment.Center) { Icon( - imageVector = Icons.Filled.PlayArrow, - contentDescription = "Play top audio", + imageVector = if (strip.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (strip.isPlaying) "Pause top audio" else "Play top audio", tint = MaterialTheme.colorScheme.primary, ) } @@ -688,9 +693,12 @@ fun ChatScreen( Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f), + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { forceCycleSpeedAudioSourceId = strip.sourceId }, ) { Text( - text = "1x", + text = strip.speedLabel, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall, ) @@ -698,6 +706,8 @@ fun ChatScreen( IconButton( onClick = { forceStopAudioSourceId = strip.sourceId + forceToggleAudioSourceId = null + forceCycleSpeedAudioSourceId = null topAudioStrip = null }, ) { @@ -762,11 +772,23 @@ fun ChatScreen( } }, forceStopAudioSourceId = forceStopAudioSourceId, + forceToggleAudioSourceId = forceToggleAudioSourceId, + forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId, onForceStopAudioSourceHandled = { sourceId -> if (forceStopAudioSourceId == sourceId) { forceStopAudioSourceId = null } }, + onForceToggleAudioSourceHandled = { sourceId -> + if (forceToggleAudioSourceId == sourceId) { + forceToggleAudioSourceId = null + } + }, + onForceCycleSpeedAudioSourceHandled = { sourceId -> + if (forceCycleSpeedAudioSourceId == sourceId) { + forceCycleSpeedAudioSourceId = null + } + }, onClick = { if (state.actionState.mode == MessageSelectionMode.MULTI) { onToggleMessageMultiSelection(message) @@ -1789,7 +1811,11 @@ private fun MessageBubble( onAttachmentVideoClick: (String) -> Unit, onAudioPlaybackChanged: (TopAudioStrip) -> Unit, forceStopAudioSourceId: String?, + forceToggleAudioSourceId: String?, + forceCycleSpeedAudioSourceId: String?, onForceStopAudioSourceHandled: (String) -> Unit, + onForceToggleAudioSourceHandled: (String) -> Unit, + onForceCycleSpeedAudioSourceHandled: (String) -> Unit, onClick: () -> Unit, onLongPress: () -> Unit, ) { @@ -2009,7 +2035,11 @@ private fun MessageBubble( messageId = message.id, onPlaybackChanged = onAudioPlaybackChanged, forceStopAudioSourceId = forceStopAudioSourceId, + forceToggleAudioSourceId = forceToggleAudioSourceId, + forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId, onForceStopAudioSourceHandled = onForceStopAudioSourceHandled, + onForceToggleAudioSourceHandled = onForceToggleAudioSourceHandled, + onForceCycleSpeedAudioSourceHandled = onForceCycleSpeedAudioSourceHandled, ) } @@ -2484,7 +2514,11 @@ private fun AudioAttachmentPlayer( messageId: Long, onPlaybackChanged: (TopAudioStrip) -> Unit, forceStopAudioSourceId: String?, + forceToggleAudioSourceId: String?, + forceCycleSpeedAudioSourceId: String?, onForceStopAudioSourceHandled: (String) -> Unit, + onForceToggleAudioSourceHandled: (String) -> Unit, + onForceCycleSpeedAudioSourceHandled: (String) -> Unit, ) { var isPlaying by remember(url) { mutableStateOf(false) } var isPrepared by remember(url) { mutableStateOf(false) } @@ -2494,6 +2528,18 @@ private fun AudioAttachmentPlayer( var speedIndex by remember(url) { mutableStateOf(0) } var isSeeking by remember(url) { mutableStateOf(false) } var seekFraction by remember(url) { mutableStateOf(0f) } + fun emitTopStrip(playing: Boolean) { + onPlaybackChanged( + TopAudioStrip( + messageId = messageId, + sourceId = "player:$url", + title = playbackTitle, + subtitle = playbackSubtitle, + isPlaying = playing, + speedLabel = formatAudioSpeed(speedOptions[speedIndex]), + ) + ) + } val mediaPlayer = remember(url) { MediaPlayer().apply { setAudioAttributes( @@ -2511,15 +2557,7 @@ private fun AudioAttachmentPlayer( runCatching { player.seekTo(0) } positionMs = 0 AppAudioFocusCoordinator.release("player:$url") - onPlaybackChanged( - TopAudioStrip( - messageId = messageId, - sourceId = "player:$url", - title = playbackTitle, - subtitle = playbackSubtitle, - isPlaying = false, - ) - ) + emitTopStrip(false) } setDataSource(url) prepareAsync() @@ -2531,15 +2569,7 @@ private fun AudioAttachmentPlayer( if (activeId != null && activeId != currentId && isPlaying) { runCatching { mediaPlayer.pause() } isPlaying = false - onPlaybackChanged( - TopAudioStrip( - messageId = messageId, - sourceId = currentId, - title = playbackTitle, - subtitle = playbackSubtitle, - isPlaying = false, - ) - ) + emitTopStrip(false) } } } @@ -2551,17 +2581,48 @@ private fun AudioAttachmentPlayer( positionMs = 0 isPlaying = false AppAudioFocusCoordinator.release(currentId) - onPlaybackChanged( - TopAudioStrip( - messageId = messageId, - sourceId = currentId, - title = playbackTitle, - subtitle = playbackSubtitle, - isPlaying = false, - ) - ) + emitTopStrip(false) onForceStopAudioSourceHandled(currentId) } + LaunchedEffect(forceToggleAudioSourceId) { + val currentId = "player:$url" + if (forceToggleAudioSourceId != currentId) return@LaunchedEffect + if (!isPrepared) { + onForceToggleAudioSourceHandled(currentId) + return@LaunchedEffect + } + if (isPlaying) { + runCatching { mediaPlayer.pause() } + isPlaying = false + AppAudioFocusCoordinator.release(currentId) + emitTopStrip(false) + } else { + if (durationMs > 0 && positionMs >= durationMs - 200) { + runCatching { mediaPlayer.seekTo(0) } + positionMs = 0 + } + runCatching { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) + } + AppAudioFocusCoordinator.request(currentId) + runCatching { mediaPlayer.start() } + isPlaying = true + emitTopStrip(true) + } + onForceToggleAudioSourceHandled(currentId) + } + LaunchedEffect(forceCycleSpeedAudioSourceId) { + val currentId = "player:$url" + if (forceCycleSpeedAudioSourceId != currentId) return@LaunchedEffect + speedIndex = (speedIndex + 1) % speedOptions.size + if (isPrepared) { + runCatching { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) + } + } + emitTopStrip(isPlaying) + onForceCycleSpeedAudioSourceHandled(currentId) + } LaunchedEffect(isPlaying, isPrepared, isSeeking) { if (!isPlaying || !isPrepared || isSeeking) return@LaunchedEffect while (isPlaying) { @@ -2582,15 +2643,7 @@ private fun AudioAttachmentPlayer( mediaPlayer.stop() } AppAudioFocusCoordinator.release("player:$url") - onPlaybackChanged( - TopAudioStrip( - messageId = messageId, - sourceId = "player:$url", - title = playbackTitle, - subtitle = playbackSubtitle, - isPlaying = false, - ) - ) + emitTopStrip(false) mediaPlayer.release() } } @@ -2612,15 +2665,7 @@ private fun AudioAttachmentPlayer( mediaPlayer.pause() isPlaying = false AppAudioFocusCoordinator.release("player:$url") - onPlaybackChanged( - TopAudioStrip( - messageId = messageId, - sourceId = "player:$url", - title = playbackTitle, - subtitle = playbackSubtitle, - isPlaying = false, - ) - ) + emitTopStrip(false) } else { if (durationMs > 0 && positionMs >= durationMs - 200) { runCatching { mediaPlayer.seekTo(0) } @@ -2632,15 +2677,7 @@ private fun AudioAttachmentPlayer( AppAudioFocusCoordinator.request("player:$url") mediaPlayer.start() isPlaying = true - onPlaybackChanged( - TopAudioStrip( - messageId = messageId, - sourceId = "player:$url", - title = playbackTitle, - subtitle = playbackSubtitle, - isPlaying = true, - ) - ) + emitTopStrip(true) } }, enabled = isPrepared, @@ -2659,10 +2696,11 @@ private fun AudioAttachmentPlayer( mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) } } + emitTopStrip(isPlaying) }, enabled = isPrepared, ) { - Text("${speedOptions[speedIndex]}x") + Text(formatAudioSpeed(speedOptions[speedIndex])) } } if (isVoice) { @@ -2794,6 +2832,14 @@ private fun formatDuration(ms: Int): String { return "$min:${sec.toString().padStart(2, '0')}" } +private fun formatAudioSpeed(speed: Float): String { + return if (speed % 1f == 0f) { + "${speed.toInt()}x" + } else { + "${speed}x" + } +} + private fun resolveRemoteAudioDurationMs(url: String): Int? { return runCatching { val retriever = MediaMetadataRetriever() @@ -3008,7 +3054,11 @@ private fun ChatInfoTabContent( messageId = entry.sourceMessageId ?: entry.title.hashCode().toLong(), onPlaybackChanged = {}, forceStopAudioSourceId = null, + forceToggleAudioSourceId = null, + forceCycleSpeedAudioSourceId = null, onForceStopAudioSourceHandled = {}, + onForceToggleAudioSourceHandled = {}, + onForceCycleSpeedAudioSourceHandled = {}, ) } } @@ -3152,4 +3202,5 @@ private data class TopAudioStrip( val title: String, val subtitle: String, val isPlaying: Boolean, + val speedLabel: String = "1x", )