android: make top audio strip playback-driven and dismissible
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user