Доработан мини-плеер голосовых: интеграция в чат, smooth UI, фикс баг с auto-play при смене скорости

This commit is contained in:
2026-04-14 13:53:01 +05:00
parent ce7f913de7
commit 4396611355
5 changed files with 243 additions and 134 deletions

View File

@@ -58,6 +58,12 @@ import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
import com.rosetta.messenger.ui.chats.components.VoicePlaybackCoordinator
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.ui.Alignment
import com.rosetta.messenger.ui.chats.ConnectionLogsScreen import com.rosetta.messenger.ui.chats.ConnectionLogsScreen
import com.rosetta.messenger.ui.chats.GroupInfoScreen import com.rosetta.messenger.ui.chats.GroupInfoScreen
import com.rosetta.messenger.ui.chats.GroupSetupScreen import com.rosetta.messenger.ui.chats.GroupSetupScreen

View File

@@ -102,6 +102,7 @@ import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.* import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.input.* import com.rosetta.messenger.ui.chats.input.*
@@ -441,11 +442,29 @@ fun ChatDetailScreen(
showEmojiPicker = false showEmojiPicker = false
} }
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager // 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat)
val hideKeyboardAndBack: () -> Unit = { val hideKeyboardAndBack: () -> Unit = {
hideInputOverlays() hideInputOverlays()
onBack() onBack()
} }
// 🔥 Поведение как у нативного Android back:
// сначала закрываем IME/emoji, и только следующим back выходим из чата.
val handleBackWithInputPriority: () -> Unit = {
val imeVisible =
androidx.core.view.ViewCompat.getRootWindowInsets(view)
?.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) == true
val hasInputOverlay =
showEmojiPicker ||
coordinator.isEmojiBoxVisible ||
coordinator.isKeyboardVisible ||
imeVisible
if (hasInputOverlay) {
hideInputOverlays()
} else {
onBack()
}
}
// Определяем это Saved Messages или обычный чат // Определяем это Saved Messages или обычный чат
val isSavedMessages = user.publicKey == currentUserPublicKey val isSavedMessages = user.publicKey == currentUserPublicKey
@@ -1344,7 +1363,7 @@ fun ChatDetailScreen(
if (isInChatSearchMode) { if (isInChatSearchMode) {
closeInChatSearch() closeInChatSearch()
} else { } else {
hideKeyboardAndBack() handleBackWithInputPriority()
} }
} }
@@ -1859,7 +1878,7 @@ fun ChatDetailScreen(
Box { Box {
IconButton( IconButton(
onClick = onClick =
hideKeyboardAndBack, handleBackWithInputPriority,
modifier = modifier =
Modifier.size( Modifier.size(
40.dp 40.dp
@@ -2305,6 +2324,26 @@ fun ChatDetailScreen(
avatarRepository = avatarRepository avatarRepository = avatarRepository
) )
} }
// Voice mini player — shown right under the chat header when audio is playing
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
if (!playingVoiceAttachmentId.isNullOrBlank()) {
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" }
val time = playingVoiceTimeLabel.trim()
val voiceTitle = if (time.isBlank()) sender else "$sender at $time"
VoiceTopMiniPlayer(
title = voiceTitle,
isDarkTheme = isDarkTheme,
isPlaying = isVoicePlaybackRunning,
speed = voicePlaybackSpeed,
onTogglePlay = { VoicePlaybackCoordinator.toggleCurrentPlayback() },
onCycleSpeed = { VoicePlaybackCoordinator.cycleSpeed() },
onClose = { VoicePlaybackCoordinator.stop() }
)
}
} // Закрытие Column topBar } // Закрытие Column topBar
}, },
containerColor = backgroundColor, // Фон всего чата containerColor = backgroundColor, // Фон всего чата

View File

@@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
@@ -5059,7 +5063,7 @@ private fun VoicePlaybackIndicatorSmall(
} }
} }
private fun formatVoiceSpeedLabel(speed: Float): String { fun formatVoiceSpeedLabel(speed: Float): String {
val normalized = (speed * 10f).roundToInt() / 10f val normalized = (speed * 10f).roundToInt() / 10f
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) { return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
"${normalized.toInt()}x" "${normalized.toInt()}x"
@@ -5069,7 +5073,7 @@ private fun formatVoiceSpeedLabel(speed: Float): String {
} }
@Composable @Composable
private fun VoiceTopMiniPlayer( fun VoiceTopMiniPlayer(
title: String, title: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isPlaying: Boolean, isPlaying: Boolean,
@@ -5078,33 +5082,36 @@ private fun VoiceTopMiniPlayer(
onCycleSpeed: () -> Unit, onCycleSpeed: () -> Unit,
onClose: () -> Unit onClose: () -> Unit
) { ) {
val containerColor = if (isDarkTheme) Color(0xFF203446) else Color(0xFFEAF4FF) // Match overall screen surface aesthetic — neutral elevated surface, no blue accent
val accentColor = if (isDarkTheme) Color(0xFF58AAFF) else Color(0xFF2481CC) val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color(0xFFF3F8FF) else Color(0xFF183047) val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA)
val secondaryColor = if (isDarkTheme) Color(0xFF9EB6CC) else Color(0xFF4F6F8A) val primaryIconColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val textColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
Column(modifier = Modifier.fillMaxWidth().background(containerColor)) {
Row( Row(
modifier = modifier = Modifier.fillMaxWidth()
Modifier.fillMaxWidth() .height(40.dp)
.height(36.dp)
.background(containerColor)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton( IconButton(
onClick = onTogglePlay, onClick = onTogglePlay,
modifier = Modifier.size(28.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = imageVector =
if (isPlaying) TablerIcons.PlayerPause if (isPlaying) Icons.Default.Pause
else TablerIcons.PlayerPlay, else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause voice" else "Play voice", contentDescription = if (isPlaying) "Pause voice" else "Play voice",
tint = accentColor, tint = primaryIconColor,
modifier = Modifier.size(18.dp) modifier = Modifier.size(22.dp)
) )
} }
Spacer(modifier = Modifier.width(4.dp))
AppleEmojiText( AppleEmojiText(
text = title, text = title,
fontSize = 14.sp, fontSize = 14.sp,
@@ -5117,36 +5124,39 @@ private fun VoiceTopMiniPlayer(
minHeightMultiplier = 1f minHeightMultiplier = 1f
) )
Spacer(modifier = Modifier.width(8.dp))
Box( Box(
modifier = modifier = Modifier.clip(RoundedCornerShape(8.dp))
Modifier.clip(RoundedCornerShape(8.dp)) .border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
.border(1.dp, accentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
.clickable { onCycleSpeed() } .clickable { onCycleSpeed() }
.padding(horizontal = 8.dp, vertical = 2.dp), .padding(horizontal = 8.dp, vertical = 3.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = formatVoiceSpeedLabel(speed), text = formatVoiceSpeedLabel(speed),
color = accentColor, color = secondaryColor,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
} }
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(4.dp))
IconButton( IconButton(
onClick = onClose, onClick = onClose,
modifier = Modifier.size(28.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = TablerIcons.X, imageVector = Icons.Default.Close,
contentDescription = "Close voice", contentDescription = "Close voice",
tint = secondaryColor, tint = secondaryColor,
modifier = Modifier.size(18.dp) modifier = Modifier.size(20.dp)
) )
} }
} }
Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor))
}
} }
@Composable @Composable

View File

@@ -85,6 +85,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@@ -144,12 +145,24 @@ private fun decodeBase64Payload(data: String): ByteArray? {
private fun decodeHexPayload(data: String): ByteArray? { private fun decodeHexPayload(data: String): ByteArray? {
val raw = data.trim().removePrefix("0x") val raw = data.trim().removePrefix("0x")
if (raw.isBlank() || raw.length % 2 != 0) return null if (raw.isBlank() || raw.length % 2 != 0) return null
if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null fun nibble(ch: Char): Int =
return runCatching { when (ch) {
ByteArray(raw.length / 2) { index -> in '0'..'9' -> ch.code - '0'.code
raw.substring(index * 2, index * 2 + 2).toInt(16).toByte() in 'a'..'f' -> ch.code - 'a'.code + 10
in 'A'..'F' -> ch.code - 'A'.code + 10
else -> -1
} }
}.getOrNull() val out = ByteArray(raw.length / 2)
var outIndex = 0
var index = 0
while (index < raw.length) {
val hi = nibble(raw[index])
val lo = nibble(raw[index + 1])
if (hi < 0 || lo < 0) return null
out[outIndex++] = ((hi shl 4) or lo).toByte()
index += 2
}
return out
} }
private fun decodeVoicePayload(data: String): ByteArray? { private fun decodeVoicePayload(data: String): ByteArray? {
@@ -280,8 +293,13 @@ object VoicePlaybackCoordinator {
val normalized = val normalized =
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first() speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
_playbackSpeed.value = normalized _playbackSpeed.value = normalized
// Only apply to the player if it's currently playing — otherwise setting
// playbackParams auto-resumes a paused MediaPlayer (Android quirk).
// The new speed will be applied on the next resume() call.
if (_isPlaying.value) {
player?.let { applyPlaybackSpeed(it) } player?.let { applyPlaybackSpeed(it) }
} }
}
private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) { private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
@@ -2125,9 +2143,10 @@ private fun ensureVoiceAudioFile(
attachmentId: String, attachmentId: String,
payload: String payload: String
): File? { ): File? {
val bytes = decodeVoicePayload(payload) ?: return null
val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() } val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
val file = File(directory, "$attachmentId.webm") val file = File(directory, "$attachmentId.webm")
if (file.exists() && file.length() > 0L) return file
val bytes = decodeVoicePayload(payload) ?: return null
runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
return file return file
} }
@@ -2149,10 +2168,16 @@ private fun VoiceAttachment(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() val activeAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState()
val playbackIsPlaying by VoicePlaybackCoordinator.isPlaying.collectAsState()
val isActiveTrack = activeAttachmentId == attachment.id val isActiveTrack = activeAttachmentId == attachment.id
val playbackPositionMs by
(if (isActiveTrack) VoicePlaybackCoordinator.positionMs else flowOf(0))
.collectAsState(initial = 0)
val playbackDurationMs by
(if (isActiveTrack) VoicePlaybackCoordinator.durationMs else flowOf(0))
.collectAsState(initial = 0)
val playbackIsPlaying by
(if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
.collectAsState(initial = false)
val isPlaying = isActiveTrack && playbackIsPlaying val isPlaying = isActiveTrack && playbackIsPlaying
val (previewDurationSecRaw, previewWavesRaw) = val (previewDurationSecRaw, previewWavesRaw) =
@@ -2167,12 +2192,19 @@ private fun VoiceAttachment(
var payload by var payload by
remember(attachment.id, attachment.blob) { remember(attachment.id, attachment.blob) {
mutableStateOf(attachment.blob.trim()) mutableStateOf(attachment.blob)
} }
val cachedAudioPath =
remember(attachment.id) {
val file = File(context.cacheDir, "voice_messages/${attachment.id}.webm")
file.takeIf { it.exists() && it.length() > 0L }?.absolutePath
}
var audioFilePath by remember(attachment.id) { mutableStateOf(cachedAudioPath) }
var downloadStatus by var downloadStatus by
remember(attachment.id, attachment.blob, attachment.transportTag) { remember(attachment.id, attachment.blob, attachment.transportTag) {
mutableStateOf( mutableStateOf(
when { when {
cachedAudioPath != null -> DownloadStatus.DOWNLOADED
attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
else -> DownloadStatus.ERROR else -> DownloadStatus.ERROR
@@ -2180,7 +2212,6 @@ private fun VoiceAttachment(
) )
} }
var errorText by remember { mutableStateOf("") } var errorText by remember { mutableStateOf("") }
var audioFilePath by remember(attachment.id) { mutableStateOf<String?>(null) }
val effectiveDurationSec = val effectiveDurationSec =
remember(isPlaying, playbackDurationMs, previewDurationSec) { remember(isPlaying, playbackDurationMs, previewDurationSec) {
@@ -2217,24 +2248,6 @@ private fun VoiceAttachment(
.getOrDefault("") .getOrDefault("")
} }
LaunchedEffect(payload, attachment.id) {
if (payload.isBlank()) return@LaunchedEffect
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
if (prepared != null) {
audioFilePath = prepared.absolutePath
if (downloadStatus != DownloadStatus.DOWNLOADING &&
downloadStatus != DownloadStatus.DECRYPTING
) {
downloadStatus = DownloadStatus.DOWNLOADED
}
if (errorText.isNotBlank()) errorText = ""
} else {
audioFilePath = null
downloadStatus = DownloadStatus.ERROR
if (errorText.isBlank()) errorText = "Cannot decode voice"
}
}
val triggerDownload: () -> Unit = download@{ val triggerDownload: () -> Unit = download@{
if (attachment.transportTag.isBlank()) { if (attachment.transportTag.isBlank()) {
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
@@ -2258,7 +2271,19 @@ private fun VoiceAttachment(
errorText = "Failed to decrypt" errorText = "Failed to decrypt"
return@launch return@launch
} }
downloadStatus = DownloadStatus.DECRYPTING
val prepared =
withContext(Dispatchers.IO) {
ensureVoiceAudioFile(context, attachment.id, decrypted)
}
if (prepared == null) {
downloadStatus = DownloadStatus.ERROR
errorText = "Cannot decode voice"
return@launch
}
audioFilePath = prepared.absolutePath
val saved = val saved =
withContext(Dispatchers.IO) {
runCatching { runCatching {
AttachmentFileManager.saveAttachment( AttachmentFileManager.saveAttachment(
context = context, context = context,
@@ -2269,6 +2294,7 @@ private fun VoiceAttachment(
) )
} }
.getOrDefault(false) .getOrDefault(false)
}
payload = decrypted payload = decrypted
if (!saved) { if (!saved) {
// Не блокируем UI, но оставляем маркер в логе. // Не блокируем UI, но оставляем маркер в логе.
@@ -2286,9 +2312,16 @@ private fun VoiceAttachment(
val file = audioFilePath?.let { File(it) } val file = audioFilePath?.let { File(it) }
if (file == null || !file.exists()) { if (file == null || !file.exists()) {
if (payload.isNotBlank()) { if (payload.isNotBlank()) {
val prepared = ensureVoiceAudioFile(context, attachment.id, payload) scope.launch {
downloadStatus = DownloadStatus.DECRYPTING
errorText = ""
val prepared =
withContext(Dispatchers.IO) {
ensureVoiceAudioFile(context, attachment.id, payload)
}
if (prepared != null) { if (prepared != null) {
audioFilePath = prepared.absolutePath audioFilePath = prepared.absolutePath
downloadStatus = DownloadStatus.DOWNLOADED
VoicePlaybackCoordinator.toggle( VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id, attachmentId = attachment.id,
sourceFile = prepared, sourceFile = prepared,
@@ -2303,6 +2336,7 @@ private fun VoiceAttachment(
downloadStatus = DownloadStatus.ERROR downloadStatus = DownloadStatus.ERROR
errorText = "Cannot decode voice" errorText = "Cannot decode voice"
} }
}
} else { } else {
triggerDownload() triggerDownload()
} }

View File

@@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Videocam
import androidx.compose.animation.* import androidx.compose.animation.*
@@ -2387,7 +2388,7 @@ fun MessageInputBar(
.background(recordingPanelColor), .background(recordingPanelColor),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Delete button — Telegram: 44×44dp, Lottie trash icon // Delete button — Telegram-style trash action
Box( Box(
modifier = Modifier modifier = Modifier
.size(recordingActionButtonBaseSize) .size(recordingActionButtonBaseSize)
@@ -2401,7 +2402,7 @@ fun MessageInputBar(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Delete,
contentDescription = "Delete recording", contentDescription = "Delete recording",
tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D), tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D),
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
@@ -2419,38 +2420,57 @@ fun MessageInputBar(
} }
} else { } else {
// ── RECORDING panel ── // ── RECORDING panel ──
// [dot][timer] [◀ Slide to cancel] // [attach-slot => dot/trash morph][timer] [◀ Slide to cancel]
val dragCancelProgress =
((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx)
.coerceIn(0f, 1f)
val leftDeleteProgress =
maxOf(
cancelAnimProgress,
FastOutSlowInEasing.transform(
(dragCancelProgress * 0.85f).coerceIn(0f, 1f)
)
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.background(recordingPanelColor) .background(recordingPanelColor)
.padding(start = 13.dp), .padding(start = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Blink dot + timer // Left slot (same anchor as attach icon in normal input):
Row( // morphs from recording dot to trash while user cancels.
Box(
modifier = Modifier modifier = Modifier
.size(40.dp)
.graphicsLayer { .graphicsLayer {
alpha = recordUiAlpha * (1f - cancelAnimProgress) alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() } translationX = with(density) { recordUiShift.toPx() }
}, },
verticalAlignment = Alignment.CenterVertically contentAlignment = Alignment.Center
) { ) {
TelegramVoiceDeleteIndicator( TelegramVoiceDeleteIndicator(
cancelProgress = cancelAnimProgress, cancelProgress = leftDeleteProgress,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme,
modifier = Modifier.size(28.dp)
) )
Spacer(modifier = Modifier.width(6.dp)) }
Spacer(modifier = Modifier.width(2.dp))
Text( Text(
text = formatVoiceRecordTimer(voiceElapsedMs), text = formatVoiceRecordTimer(voiceElapsedMs),
color = recordingTextColor, color = recordingTextColor,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) modifier = Modifier.graphicsLayer {
alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f)
translationX = with(density) { recordUiShift.toPx() }
} }
)
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(10.dp))
// Slide to cancel // Slide to cancel
SlideToCancel( SlideToCancel(
@@ -2459,7 +2479,7 @@ fun MessageInputBar(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.graphicsLayer { .graphicsLayer {
alpha = recordUiAlpha * (1f - cancelAnimProgress) alpha = recordUiAlpha * (1f - leftDeleteProgress)
translationX = with(density) { recordUiShift.toPx() } translationX = with(density) { recordUiShift.toPx() }
} }
) )