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, - switched to Telegram-like rounded input container look,
- emoji/attach/send buttons now use circular tinted surfaces, - emoji/attach/send buttons now use circular tinted surfaces,
- text input moved to filled style with hidden indicator lines. - 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 viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(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 actionMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
var pendingDeleteForAll by remember { mutableStateOf(false) } var pendingDeleteForAll by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
@@ -292,9 +293,6 @@ fun ChatScreen(
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp
val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) }
val topAudioStrip = remember(state.messages, dismissedTopAudioMessageId) {
findTopAudioStrip(state.messages, dismissedTopAudioMessageId)
}
LaunchedEffect(state.isRecordingVoice) { LaunchedEffect(state.isRecordingVoice) {
if (!state.isRecordingVoice) return@LaunchedEffect if (!state.isRecordingVoice) return@LaunchedEffect
@@ -557,7 +555,8 @@ fun ChatScreen(
} }
} }
} }
if (topAudioStrip != null) { val strip = topAudioStrip
if (strip != null) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -581,12 +580,12 @@ fun ChatScreen(
} }
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = topAudioStrip.title, text = strip.title,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = 1, maxLines = 1,
) )
Text( Text(
text = topAudioStrip.subtitle, text = strip.subtitle,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -602,7 +601,12 @@ fun ChatScreen(
style = MaterialTheme.typography.labelSmall, 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") Icon(imageVector = Icons.Filled.Close, contentDescription = "Hide top audio")
} }
} }
@@ -636,6 +640,22 @@ fun ChatScreen(
val idx = allImageUrls.indexOf(imageUrl) val idx = allImageUrls.indexOf(imageUrl)
viewerImageIndex = if (idx >= 0) idx else null 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 = { onClick = {
if (state.actionState.mode == MessageSelectionMode.MULTI) { if (state.actionState.mode == MessageSelectionMode.MULTI) {
onToggleMessageMultiSelection(message) onToggleMessageMultiSelection(message)
@@ -1286,6 +1306,9 @@ private fun MessageBubble(
isInlineHighlighted: Boolean, isInlineHighlighted: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>, reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onAttachmentImageClick: (String) -> Unit, onAttachmentImageClick: (String) -> Unit,
onAudioPlaybackChanged: (TopAudioStrip) -> Unit,
forceStopAudioSourceId: String?,
onForceStopAudioSourceHandled: (String) -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
) { ) {
@@ -1484,6 +1507,17 @@ private fun MessageBubble(
url = attachment.fileUrl, url = attachment.fileUrl,
waveform = message.attachmentWaveform, waveform = message.attachmentWaveform,
isVoice = message.type.contains("voice", ignoreCase = true), 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, url: String,
waveform: List<Int>?, waveform: List<Int>?,
isVoice: Boolean, isVoice: Boolean,
playbackTitle: String,
playbackSubtitle: String,
messageId: Long,
onPlaybackChanged: (TopAudioStrip) -> Unit,
forceStopAudioSourceId: String?,
onForceStopAudioSourceHandled: (String) -> Unit,
) { ) {
var isPlaying by remember(url) { mutableStateOf(false) } var isPlaying by remember(url) { mutableStateOf(false) }
var isPrepared by remember(url) { mutableStateOf(false) } var isPrepared by remember(url) { mutableStateOf(false) }
@@ -1662,6 +1702,15 @@ private fun AudioAttachmentPlayer(
runCatching { player.seekTo(0) } runCatching { player.seekTo(0) }
positionMs = 0 positionMs = 0
AppAudioFocusCoordinator.release("player:$url") AppAudioFocusCoordinator.release("player:$url")
onPlaybackChanged(
TopAudioStrip(
messageId = messageId,
sourceId = "player:$url",
title = playbackTitle,
subtitle = playbackSubtitle,
isPlaying = false,
)
)
} }
setDataSource(url) setDataSource(url)
prepareAsync() prepareAsync()
@@ -1673,9 +1722,37 @@ private fun AudioAttachmentPlayer(
if (activeId != null && activeId != currentId && isPlaying) { if (activeId != null && activeId != currentId && isPlaying) {
runCatching { mediaPlayer.pause() } runCatching { mediaPlayer.pause() }
isPlaying = false 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) { LaunchedEffect(isPlaying, isPrepared) {
if (!isPlaying || !isPrepared) return@LaunchedEffect if (!isPlaying || !isPrepared) return@LaunchedEffect
while (isPlaying) { while (isPlaying) {
@@ -1696,6 +1773,15 @@ private fun AudioAttachmentPlayer(
mediaPlayer.stop() mediaPlayer.stop()
} }
AppAudioFocusCoordinator.release("player:$url") AppAudioFocusCoordinator.release("player:$url")
onPlaybackChanged(
TopAudioStrip(
messageId = messageId,
sourceId = "player:$url",
title = playbackTitle,
subtitle = playbackSubtitle,
isPlaying = false,
)
)
mediaPlayer.release() mediaPlayer.release()
} }
} }
@@ -1717,6 +1803,15 @@ private fun AudioAttachmentPlayer(
mediaPlayer.pause() mediaPlayer.pause()
isPlaying = false isPlaying = false
AppAudioFocusCoordinator.release("player:$url") AppAudioFocusCoordinator.release("player:$url")
onPlaybackChanged(
TopAudioStrip(
messageId = messageId,
sourceId = "player:$url",
title = playbackTitle,
subtitle = playbackSubtitle,
isPlaying = false,
)
)
} else { } else {
if (durationMs > 0 && positionMs >= durationMs - 200) { if (durationMs > 0 && positionMs >= durationMs - 200) {
runCatching { mediaPlayer.seekTo(0) } runCatching { mediaPlayer.seekTo(0) }
@@ -1728,6 +1823,15 @@ private fun AudioAttachmentPlayer(
AppAudioFocusCoordinator.request("player:$url") AppAudioFocusCoordinator.request("player:$url")
mediaPlayer.start() mediaPlayer.start()
isPlaying = true isPlaying = true
onPlaybackChanged(
TopAudioStrip(
messageId = messageId,
sourceId = "player:$url",
title = playbackTitle,
subtitle = playbackSubtitle,
isPlaying = true,
)
)
} }
}, },
enabled = isPrepared, enabled = isPrepared,
@@ -2133,35 +2237,8 @@ private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntr
private data class TopAudioStrip( private data class TopAudioStrip(
val messageId: Long, val messageId: Long,
val sourceId: String,
val title: String, val title: String,
val subtitle: 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,
)
}