Доработан мини-плеер голосовых: интеграция в чат, 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.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
|
||||||
|
|||||||
@@ -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, // Фон всего чата
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user