Доработан мини-плеер голосовых: интеграция в чат, 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.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

View File

@@ -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, // Фон всего чата

View File

@@ -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,33 +5082,36 @@ 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)
Column(modifier = Modifier.fillMaxWidth().background(containerColor)) {
Row(
modifier =
Modifier.fillMaxWidth()
.height(36.dp)
.background(containerColor)
modifier = Modifier.fillMaxWidth()
.height(40.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onTogglePlay,
modifier = Modifier.size(28.dp)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector =
if (isPlaying) TablerIcons.PlayerPause
else TablerIcons.PlayerPlay,
if (isPlaying) Icons.Default.Pause
else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause voice" else "Play voice",
tint = accentColor,
modifier = Modifier.size(18.dp)
tint = primaryIconColor,
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
AppleEmojiText(
text = title,
fontSize = 14.sp,
@@ -5117,36 +5124,39 @@ private fun VoiceTopMiniPlayer(
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))
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),
.padding(horizontal = 8.dp, vertical = 3.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoiceSpeedLabel(speed),
color = accentColor,
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)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = TablerIcons.X,
imageVector = Icons.Default.Close,
contentDescription = "Close voice",
tint = secondaryColor,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(20.dp)
)
}
}
Box(modifier = Modifier.fillMaxWidth().height(0.5.dp).background(dividerColor))
}
}
@Composable

View File

@@ -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,8 +293,13 @@ object VoicePlaybackCoordinator {
val normalized =
speedSteps.minByOrNull { kotlin.math.abs(it - speed) } ?: speedSteps.first()
_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) }
}
}
private fun applyPlaybackSpeed(mediaPlayer: MediaPlayer) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
@@ -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,7 +2271,19 @@ 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 =
withContext(Dispatchers.IO) {
runCatching {
AttachmentFileManager.saveAttachment(
context = context,
@@ -2269,6 +2294,7 @@ private fun VoiceAttachment(
)
}
.getOrDefault(false)
}
payload = decrypted
if (!saved) {
// Не блокируем UI, но оставляем маркер в логе.
@@ -2286,9 +2312,16 @@ 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
downloadStatus = DownloadStatus.DOWNLOADED
VoicePlaybackCoordinator.toggle(
attachmentId = attachment.id,
sourceFile = prepared,
@@ -2303,6 +2336,7 @@ private fun VoiceAttachment(
downloadStatus = DownloadStatus.ERROR
errorText = "Cannot decode voice"
}
}
} else {
triggerDownload()
}

View File

@@ -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
cancelProgress = leftDeleteProgress,
isDarkTheme = isDarkTheme,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(6.dp))
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text = formatVoiceRecordTimer(voiceElapsedMs),
color = recordingTextColor,
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
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() }
}
)