Доработан мини-плеер голосовых: интеграция в чат, smooth UI, фикс баг с auto-play при смене скорости
This commit is contained in:
@@ -58,6 +58,12 @@ import com.rosetta.messenger.ui.auth.AuthFlow
|
||||
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
|
||||
import com.rosetta.messenger.ui.chats.ChatDetailScreen
|
||||
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.GroupInfoScreen
|
||||
import com.rosetta.messenger.ui.chats.GroupSetupScreen
|
||||
|
||||
@@ -102,6 +102,7 @@ import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||
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.MultiImageEditorScreen
|
||||
import com.rosetta.messenger.ui.chats.input.*
|
||||
@@ -441,11 +442,29 @@ fun ChatDetailScreen(
|
||||
showEmojiPicker = false
|
||||
}
|
||||
|
||||
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
||||
// 🔥 Принудительное закрытие экрана (используется в explicit actions вроде Delete chat)
|
||||
val hideKeyboardAndBack: () -> Unit = {
|
||||
hideInputOverlays()
|
||||
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 или обычный чат
|
||||
val isSavedMessages = user.publicKey == currentUserPublicKey
|
||||
@@ -1344,7 +1363,7 @@ fun ChatDetailScreen(
|
||||
if (isInChatSearchMode) {
|
||||
closeInChatSearch()
|
||||
} else {
|
||||
hideKeyboardAndBack()
|
||||
handleBackWithInputPriority()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1859,7 +1878,7 @@ fun ChatDetailScreen(
|
||||
Box {
|
||||
IconButton(
|
||||
onClick =
|
||||
hideKeyboardAndBack,
|
||||
handleBackWithInputPriority,
|
||||
modifier =
|
||||
Modifier.size(
|
||||
40.dp
|
||||
@@ -2305,6 +2324,26 @@ fun ChatDetailScreen(
|
||||
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
|
||||
},
|
||||
containerColor = backgroundColor, // Фон всего чата
|
||||
|
||||
@@ -14,6 +14,10 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.runtime.*
|
||||
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
|
||||
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
|
||||
"${normalized.toInt()}x"
|
||||
@@ -5069,7 +5073,7 @@ private fun formatVoiceSpeedLabel(speed: Float): String {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTopMiniPlayer(
|
||||
fun VoiceTopMiniPlayer(
|
||||
title: String,
|
||||
isDarkTheme: Boolean,
|
||||
isPlaying: Boolean,
|
||||
@@ -5078,74 +5082,80 @@ private fun VoiceTopMiniPlayer(
|
||||
onCycleSpeed: () -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val containerColor = if (isDarkTheme) Color(0xFF203446) else Color(0xFFEAF4FF)
|
||||
val accentColor = if (isDarkTheme) Color(0xFF58AAFF) else Color(0xFF2481CC)
|
||||
val textColor = if (isDarkTheme) Color(0xFFF3F8FF) else Color(0xFF183047)
|
||||
val secondaryColor = if (isDarkTheme) Color(0xFF9EB6CC) else Color(0xFF4F6F8A)
|
||||
// Match overall screen surface aesthetic — neutral elevated surface, no blue accent
|
||||
val containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2C) else Color(0xFFE5E5EA)
|
||||
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)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(containerColor)
|
||||
Column(modifier = Modifier.fillMaxWidth().background(containerColor)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.height(40.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onTogglePlay,
|
||||
modifier = Modifier.size(28.dp)
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isPlaying) TablerIcons.PlayerPause
|
||||
else TablerIcons.PlayerPlay,
|
||||
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
IconButton(
|
||||
onClick = onTogglePlay,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (isPlaying) Icons.Default.Pause
|
||||
else Icons.Default.PlayArrow,
|
||||
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
|
||||
tint = primaryIconColor,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
AppleEmojiText(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.weight(1f),
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
}
|
||||
|
||||
AppleEmojiText(
|
||||
text = title,
|
||||
fontSize = 14.sp,
|
||||
color = textColor,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.weight(1f),
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, accentColor.copy(alpha = 0.55f), RoundedCornerShape(8.dp))
|
||||
Box(
|
||||
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||
.border(1.dp, secondaryColor.copy(alpha = 0.4f), RoundedCornerShape(8.dp))
|
||||
.clickable { onCycleSpeed() }
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = formatVoiceSpeedLabel(speed),
|
||||
color = accentColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = formatVoiceSpeedLabel(speed),
|
||||
color = secondaryColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.X,
|
||||
contentDescription = "Close voice",
|
||||
tint = secondaryColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close voice",
|
||||
tint = secondaryColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
@@ -144,12 +145,24 @@ private fun decodeBase64Payload(data: String): ByteArray? {
|
||||
private fun decodeHexPayload(data: String): ByteArray? {
|
||||
val raw = data.trim().removePrefix("0x")
|
||||
if (raw.isBlank() || raw.length % 2 != 0) return null
|
||||
if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null
|
||||
return runCatching {
|
||||
ByteArray(raw.length / 2) { index ->
|
||||
raw.substring(index * 2, index * 2 + 2).toInt(16).toByte()
|
||||
fun nibble(ch: Char): Int =
|
||||
when (ch) {
|
||||
in '0'..'9' -> ch.code - '0'.code
|
||||
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? {
|
||||
@@ -280,7 +293,12 @@ object VoicePlaybackCoordinator {
|
||||
val normalized =
|
||||
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
|
||||
_playbackSpeed.value = normalized
|
||||
player?.let { applyPlaybackSpeed(it) }
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
|
||||
@@ -2125,9 +2143,10 @@ private fun ensureVoiceAudioFile(
|
||||
attachmentId: String,
|
||||
payload: String
|
||||
): File? {
|
||||
val bytes = decodeVoicePayload(payload) ?: return null
|
||||
val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
|
||||
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
|
||||
return file
|
||||
}
|
||||
@@ -2149,10 +2168,16 @@ private fun VoiceAttachment(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
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 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 (previewDurationSecRaw, previewWavesRaw) =
|
||||
@@ -2167,12 +2192,19 @@ private fun VoiceAttachment(
|
||||
|
||||
var payload by
|
||||
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
|
||||
remember(attachment.id, attachment.blob, attachment.transportTag) {
|
||||
mutableStateOf(
|
||||
when {
|
||||
cachedAudioPath != null -> DownloadStatus.DOWNLOADED
|
||||
attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
|
||||
attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
|
||||
else -> DownloadStatus.ERROR
|
||||
@@ -2180,7 +2212,6 @@ private fun VoiceAttachment(
|
||||
)
|
||||
}
|
||||
var errorText by remember { mutableStateOf("") }
|
||||
var audioFilePath by remember(attachment.id) { mutableStateOf<String?>(null) }
|
||||
|
||||
val effectiveDurationSec =
|
||||
remember(isPlaying, playbackDurationMs, previewDurationSec) {
|
||||
@@ -2217,24 +2248,6 @@ private fun VoiceAttachment(
|
||||
.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@{
|
||||
if (attachment.transportTag.isBlank()) {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
@@ -2258,17 +2271,30 @@ private fun VoiceAttachment(
|
||||
errorText = "Failed to decrypt"
|
||||
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 =
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
.getOrDefault(false)
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
.getOrDefault(false)
|
||||
}
|
||||
payload = decrypted
|
||||
if (!saved) {
|
||||
// Не блокируем UI, но оставляем маркер в логе.
|
||||
@@ -2286,22 +2312,30 @@ private fun VoiceAttachment(
|
||||
val file = audioFilePath?.let { File(it) }
|
||||
if (file == null || !file.exists()) {
|
||||
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) {
|
||||
audioFilePath = prepared.absolutePath
|
||||
VoicePlaybackCoordinator.toggle(
|
||||
attachmentId = attachment.id,
|
||||
sourceFile = prepared,
|
||||
dialogKey = dialogPublicKey,
|
||||
senderLabel = playbackSenderLabel,
|
||||
playedAtLabel = playbackTimeLabel
|
||||
) { message ->
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
VoicePlaybackCoordinator.toggle(
|
||||
attachmentId = attachment.id,
|
||||
sourceFile = prepared,
|
||||
dialogKey = dialogPublicKey,
|
||||
senderLabel = playbackSenderLabel,
|
||||
playedAtLabel = playbackTimeLabel
|
||||
) { message ->
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = message
|
||||
}
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = message
|
||||
errorText = "Cannot decode voice"
|
||||
}
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
errorText = "Cannot decode voice"
|
||||
}
|
||||
} else {
|
||||
triggerDownload()
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.Videocam
|
||||
import androidx.compose.animation.*
|
||||
@@ -2387,7 +2388,7 @@ fun MessageInputBar(
|
||||
.background(recordingPanelColor),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Delete button — Telegram: 44×44dp, Lottie trash icon
|
||||
// Delete button — Telegram-style trash action
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(recordingActionButtonBaseSize)
|
||||
@@ -2401,7 +2402,7 @@ fun MessageInputBar(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Delete recording",
|
||||
tint = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D),
|
||||
modifier = Modifier.size(20.dp)
|
||||
@@ -2419,38 +2420,57 @@ fun MessageInputBar(
|
||||
}
|
||||
} else {
|
||||
// ── 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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(recordingPanelColor)
|
||||
.padding(start = 13.dp),
|
||||
.padding(start = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Blink dot + timer
|
||||
Row(
|
||||
// Left slot (same anchor as attach icon in normal input):
|
||||
// morphs from recording dot to trash while user cancels.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.graphicsLayer {
|
||||
alpha = recordUiAlpha * (1f - cancelAnimProgress)
|
||||
alpha = recordUiAlpha
|
||||
translationX = with(density) { recordUiShift.toPx() }
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
TelegramVoiceDeleteIndicator(
|
||||
cancelProgress = cancelAnimProgress,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = formatVoiceRecordTimer(voiceElapsedMs),
|
||||
color = recordingTextColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
cancelProgress = leftDeleteProgress,
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
|
||||
Text(
|
||||
text = formatVoiceRecordTimer(voiceElapsedMs),
|
||||
color = recordingTextColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f)
|
||||
translationX = with(density) { recordUiShift.toPx() }
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
// Slide to cancel
|
||||
SlideToCancel(
|
||||
@@ -2459,7 +2479,7 @@ fun MessageInputBar(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.graphicsLayer {
|
||||
alpha = recordUiAlpha * (1f - cancelAnimProgress)
|
||||
alpha = recordUiAlpha * (1f - leftDeleteProgress)
|
||||
translationX = with(density) { recordUiShift.toPx() }
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user