android: make top audio strip playback-driven and dismissible
Some checks failed
Android CI / android (push) Failing after 5m3s
Android Release / release (push) Failing after 5m9s
CI / test (push) Failing after 3m0s

This commit is contained in:
Codex
2026-03-10 01:51:51 +03:00
parent 55af1f78b6
commit 47365bba57
2 changed files with 122 additions and 38 deletions

View File

@@ -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.

View File

@@ -278,7 +278,8 @@ fun ChatScreen(
}
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
var dismissedTopAudioMessageId by remember { mutableStateOf<Long?>(null) }
var topAudioStrip by remember { mutableStateOf<TopAudioStrip?>(null) }
var forceStopAudioSourceId by remember { mutableStateOf<String?>(null) }
var actionMenuMessage by remember { mutableStateOf<MessageItem?>(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<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
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<Int>?,
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<MessageItem>): List<ChatInfoEntr
private data class TopAudioStrip(
val messageId: Long,
val sourceId: String,
val title: String,
val subtitle: String,
val isPlaying: Boolean,
)
private fun findTopAudioStrip(
messages: List<MessageItem>,
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,
)
}