From 47365bba57a3b76c2932979195bde7e6444850ae Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 01:51:51 +0300 Subject: [PATCH] android: make top audio strip playback-driven and dismissible --- android/CHANGELOG.md | 7 + .../messenger/ui/chat/ChatScreen.kt | 153 +++++++++++++----- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index b5f6f62..6f59436 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -911,3 +911,10 @@ - switched to Telegram-like rounded input container look, - emoji/attach/send buttons now use circular tinted surfaces, - text input moved to filled style with hidden indicator lines. + +### Step 127 - Top audio strip behavior fix (playback-driven) +- Reworked top audio strip logic to be playback-driven instead of always-on: + - strip appears only when user starts audio/voice playback, + - strip switches to the currently playing file, + - strip auto-hides when playback stops. +- Added close (`X`) behavior that hides the strip and force-stops the currently playing source. 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 1779188..0a19bb6 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 @@ -278,7 +278,8 @@ fun ChatScreen( } var viewerImageIndex by remember { mutableStateOf(null) } var dismissedPinnedMessageId by remember { mutableStateOf(null) } - var dismissedTopAudioMessageId by remember { mutableStateOf(null) } + var topAudioStrip by remember { mutableStateOf(null) } + var forceStopAudioSourceId by remember { mutableStateOf(null) } var actionMenuMessage by remember { mutableStateOf(null) } var pendingDeleteForAll by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } @@ -292,9 +293,6 @@ fun ChatScreen( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } - val topAudioStrip = remember(state.messages, dismissedTopAudioMessageId) { - findTopAudioStrip(state.messages, dismissedTopAudioMessageId) - } LaunchedEffect(state.isRecordingVoice) { if (!state.isRecordingVoice) return@LaunchedEffect @@ -557,7 +555,8 @@ fun ChatScreen( } } } - if (topAudioStrip != null) { + val strip = topAudioStrip + if (strip != null) { Row( modifier = Modifier .fillMaxWidth() @@ -581,12 +580,12 @@ fun ChatScreen( } Column(modifier = Modifier.weight(1f)) { Text( - text = topAudioStrip.title, + text = strip.title, style = MaterialTheme.typography.labelMedium, maxLines = 1, ) Text( - text = topAudioStrip.subtitle, + text = strip.subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -602,7 +601,12 @@ fun ChatScreen( style = MaterialTheme.typography.labelSmall, ) } - IconButton(onClick = { dismissedTopAudioMessageId = topAudioStrip.messageId }) { + IconButton( + onClick = { + forceStopAudioSourceId = strip.sourceId + topAudioStrip = null + }, + ) { Icon(imageVector = Icons.Filled.Close, contentDescription = "Hide top audio") } } @@ -627,7 +631,7 @@ fun ChatScreen( items(state.messages, key = { it.id }) { message -> val isSelected = state.actionState.selectedMessageIds.contains(message.id) MessageBubble( - message = message, + message = message, isSelected = isSelected, isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, isInlineHighlighted = state.highlightedMessageId == message.id, @@ -636,6 +640,22 @@ fun ChatScreen( val idx = allImageUrls.indexOf(imageUrl) viewerImageIndex = if (idx >= 0) idx else null }, + onAudioPlaybackChanged = { playback -> + if (playback.isPlaying) { + topAudioStrip = playback + if (forceStopAudioSourceId == playback.sourceId) { + forceStopAudioSourceId = null + } + } else if (topAudioStrip?.sourceId == playback.sourceId) { + topAudioStrip = null + } + }, + forceStopAudioSourceId = forceStopAudioSourceId, + onForceStopAudioSourceHandled = { sourceId -> + if (forceStopAudioSourceId == sourceId) { + forceStopAudioSourceId = null + } + }, onClick = { if (state.actionState.mode == MessageSelectionMode.MULTI) { onToggleMessageMultiSelection(message) @@ -1286,6 +1306,9 @@ private fun MessageBubble( isInlineHighlighted: Boolean, reactions: List, onAttachmentImageClick: (String) -> Unit, + onAudioPlaybackChanged: (TopAudioStrip) -> Unit, + forceStopAudioSourceId: String?, + onForceStopAudioSourceHandled: (String) -> Unit, onClick: () -> Unit, onLongPress: () -> Unit, ) { @@ -1484,6 +1507,17 @@ private fun MessageBubble( url = attachment.fileUrl, waveform = message.attachmentWaveform, isVoice = message.type.contains("voice", ignoreCase = true), + playbackTitle = message.senderDisplayName?.ifBlank { null } + ?: extractFileName(attachment.fileUrl), + playbackSubtitle = if (message.type.contains("voice", ignoreCase = true)) { + "Voice message • ${formatMessageTime(message.createdAt)}" + } else { + "Audio • ${formatMessageTime(message.createdAt)}" + }, + messageId = message.id, + onPlaybackChanged = onAudioPlaybackChanged, + forceStopAudioSourceId = forceStopAudioSourceId, + onForceStopAudioSourceHandled = onForceStopAudioSourceHandled, ) } @@ -1638,6 +1672,12 @@ private fun AudioAttachmentPlayer( url: String, waveform: List?, isVoice: Boolean, + playbackTitle: String, + playbackSubtitle: String, + messageId: Long, + onPlaybackChanged: (TopAudioStrip) -> Unit, + forceStopAudioSourceId: String?, + onForceStopAudioSourceHandled: (String) -> Unit, ) { var isPlaying by remember(url) { mutableStateOf(false) } var isPrepared by remember(url) { mutableStateOf(false) } @@ -1662,6 +1702,15 @@ 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, + ) + ) } setDataSource(url) prepareAsync() @@ -1673,9 +1722,37 @@ 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, + ) + ) } } } + LaunchedEffect(forceStopAudioSourceId) { + val currentId = "player:$url" + if (forceStopAudioSourceId != currentId) return@LaunchedEffect + runCatching { mediaPlayer.pause() } + runCatching { mediaPlayer.seekTo(0) } + positionMs = 0 + isPlaying = false + AppAudioFocusCoordinator.release(currentId) + onPlaybackChanged( + TopAudioStrip( + messageId = messageId, + sourceId = currentId, + title = playbackTitle, + subtitle = playbackSubtitle, + isPlaying = false, + ) + ) + onForceStopAudioSourceHandled(currentId) + } LaunchedEffect(isPlaying, isPrepared) { if (!isPlaying || !isPrepared) return@LaunchedEffect while (isPlaying) { @@ -1696,6 +1773,15 @@ private fun AudioAttachmentPlayer( mediaPlayer.stop() } AppAudioFocusCoordinator.release("player:$url") + onPlaybackChanged( + TopAudioStrip( + messageId = messageId, + sourceId = "player:$url", + title = playbackTitle, + subtitle = playbackSubtitle, + isPlaying = false, + ) + ) mediaPlayer.release() } } @@ -1717,6 +1803,15 @@ private fun AudioAttachmentPlayer( mediaPlayer.pause() isPlaying = false AppAudioFocusCoordinator.release("player:$url") + onPlaybackChanged( + TopAudioStrip( + messageId = messageId, + sourceId = "player:$url", + title = playbackTitle, + subtitle = playbackSubtitle, + isPlaying = false, + ) + ) } else { if (durationMs > 0 && positionMs >= durationMs - 200) { runCatching { mediaPlayer.seekTo(0) } @@ -1728,6 +1823,15 @@ private fun AudioAttachmentPlayer( AppAudioFocusCoordinator.request("player:$url") mediaPlayer.start() isPlaying = true + onPlaybackChanged( + TopAudioStrip( + messageId = messageId, + sourceId = "player:$url", + title = playbackTitle, + subtitle = playbackSubtitle, + isPlaying = true, + ) + ) } }, enabled = isPrepared, @@ -2133,35 +2237,8 @@ private fun buildChatInfoEntries(messages: List): List, - dismissedMessageId: Long?, -): TopAudioStrip? { - val candidate = messages - .asReversed() - .firstOrNull { message -> - if (dismissedMessageId == message.id) return@firstOrNull false - val isVoice = message.type.contains("voice", ignoreCase = true) - val isAudio = message.type.contains("audio", ignoreCase = true) - isVoice || isAudio || message.attachments.any { - it.fileType.lowercase(Locale.getDefault()).startsWith("audio/") - } - } ?: return null - val title = candidate.senderDisplayName?.ifBlank { null } - ?: candidate.text?.takeIf { it.isNotBlank() }?.take(36) - ?: "Audio" - val subtitle = if (candidate.type.contains("voice", ignoreCase = true)) { - "Voice message • ${formatMessageTime(candidate.createdAt)}" - } else { - "Audio • ${formatMessageTime(candidate.createdAt)}" - } - return TopAudioStrip( - messageId = candidate.id, - title = title, - subtitle = subtitle, - ) -}