android: add hold-to-record voice flow with lock cancel and audio focus
This commit is contained in:
@@ -392,3 +392,15 @@
|
|||||||
- Reworked Settings/Profile screens from placeholders to editable account management screens.
|
- Reworked Settings/Profile screens from placeholders to editable account management screens.
|
||||||
- Added avatar upload with center square crop (`1:1`) before upload.
|
- Added avatar upload with center square crop (`1:1`) before upload.
|
||||||
- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close).
|
- Upgraded message attachment rendering with in-bubble multi-image gallery and unified attachment context actions (open/copy/close).
|
||||||
|
|
||||||
|
### Step 66 - Voice recording controls + global audio focus
|
||||||
|
- Added microphone permission (`RECORD_AUDIO`) and in-chat voice recording flow based on press-and-hold gesture.
|
||||||
|
- Implemented Telegram-like gesture controls for voice button:
|
||||||
|
- hold to record,
|
||||||
|
- slide up to lock recording,
|
||||||
|
- slide left to cancel recording.
|
||||||
|
- Added minimum voice length validation (`>= 1s`) before sending.
|
||||||
|
- Integrated voice message sending via existing media upload path (`audio/mp4` attachment).
|
||||||
|
- Added process-wide audio focus coordinator to enforce single active audio source:
|
||||||
|
- attachment player pauses when another source starts,
|
||||||
|
- recording requests focus and stops competing playback.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MessengerApplication"
|
android:name=".MessengerApplication"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package ru.daemonlord.messenger.core.audio
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
object AppAudioFocusCoordinator {
|
||||||
|
private val _activeSourceId = MutableStateFlow<String?>(null)
|
||||||
|
val activeSourceId: StateFlow<String?> = _activeSourceId.asStateFlow()
|
||||||
|
|
||||||
|
fun request(sourceId: String) {
|
||||||
|
_activeSourceId.value = sourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release(sourceId: String) {
|
||||||
|
if (_activeSourceId.value == sourceId) {
|
||||||
|
_activeSourceId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package ru.daemonlord.messenger.ui.chat
|
package ru.daemonlord.messenger.ui.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.MediaPlayer
|
import android.media.MediaPlayer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -11,6 +13,8 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -54,15 +58,22 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
|
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoute(
|
fun ChatRoute(
|
||||||
@@ -71,6 +82,26 @@ fun ChatRoute(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val voiceRecorder = remember(context) { VoiceRecorder(context) }
|
||||||
|
var hasAudioPermission by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.RECORD_AUDIO,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val audioPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { granted ->
|
||||||
|
hasAudioPermission = granted
|
||||||
|
}
|
||||||
|
DisposableEffect(voiceRecorder) {
|
||||||
|
onDispose {
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
AppAudioFocusCoordinator.release("voice-recording")
|
||||||
|
}
|
||||||
|
}
|
||||||
val pickMediaLauncher = rememberLauncherForActivityResult(
|
val pickMediaLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetContent(),
|
contract = ActivityResultContracts.GetContent(),
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@@ -101,6 +132,43 @@ fun ChatRoute(
|
|||||||
onCancelComposeAction = viewModel::onCancelComposeAction,
|
onCancelComposeAction = viewModel::onCancelComposeAction,
|
||||||
onLoadMore = viewModel::loadMore,
|
onLoadMore = viewModel::loadMore,
|
||||||
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
||||||
|
onVoiceRecordStart = {
|
||||||
|
if (!hasAudioPermission) {
|
||||||
|
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
} else {
|
||||||
|
val started = voiceRecorder.start()
|
||||||
|
if (started) {
|
||||||
|
AppAudioFocusCoordinator.request("voice-recording")
|
||||||
|
viewModel.onVoiceRecordStarted()
|
||||||
|
} else {
|
||||||
|
viewModel.onVoiceRecordCancelled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onVoiceRecordTick = {
|
||||||
|
viewModel.onVoiceRecordTick(voiceRecorder.elapsedMillis())
|
||||||
|
},
|
||||||
|
onVoiceRecordLock = viewModel::onVoiceRecordLocked,
|
||||||
|
onVoiceRecordCancel = {
|
||||||
|
voiceRecorder.cancel()
|
||||||
|
AppAudioFocusCoordinator.release("voice-recording")
|
||||||
|
viewModel.onVoiceRecordCancelled()
|
||||||
|
},
|
||||||
|
onVoiceRecordSend = {
|
||||||
|
val duration = voiceRecorder.elapsedMillis()
|
||||||
|
val bytes = voiceRecorder.stopAndReadBytes()
|
||||||
|
AppAudioFocusCoordinator.release("voice-recording")
|
||||||
|
if (bytes != null && bytes.isNotEmpty()) {
|
||||||
|
viewModel.onVoiceRecordFinish(
|
||||||
|
fileName = "voice_${System.currentTimeMillis()}.m4a",
|
||||||
|
mimeType = "audio/mp4",
|
||||||
|
bytes = bytes,
|
||||||
|
durationMs = duration,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
viewModel.onVoiceRecordCancelled()
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +192,11 @@ fun ChatScreen(
|
|||||||
onCancelComposeAction: () -> Unit,
|
onCancelComposeAction: () -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit,
|
||||||
onPickMedia: () -> Unit,
|
onPickMedia: () -> Unit,
|
||||||
|
onVoiceRecordStart: () -> Unit,
|
||||||
|
onVoiceRecordTick: () -> Unit,
|
||||||
|
onVoiceRecordLock: () -> Unit,
|
||||||
|
onVoiceRecordCancel: () -> Unit,
|
||||||
|
onVoiceRecordSend: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val allImageUrls = remember(state.messages) {
|
val allImageUrls = remember(state.messages) {
|
||||||
state.messages
|
state.messages
|
||||||
@@ -136,6 +209,14 @@ fun ChatScreen(
|
|||||||
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
|
var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
|
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
|
||||||
var actionMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
|
var actionMenuMessage by remember { mutableStateOf<MessageItem?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(state.isRecordingVoice) {
|
||||||
|
if (!state.isRecordingVoice) return@LaunchedEffect
|
||||||
|
while (state.isRecordingVoice) {
|
||||||
|
onVoiceRecordTick()
|
||||||
|
delay(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -483,7 +564,7 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = onPickMedia,
|
onClick = onPickMedia,
|
||||||
enabled = state.canSendMessages && !state.isUploadingMedia,
|
enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice,
|
||||||
) {
|
) {
|
||||||
Text(if (state.isUploadingMedia) "..." else "\uD83D\uDCCE")
|
Text(if (state.isUploadingMedia) "..." else "\uD83D\uDCCE")
|
||||||
}
|
}
|
||||||
@@ -491,11 +572,47 @@ fun ChatScreen(
|
|||||||
!state.isSending &&
|
!state.isSending &&
|
||||||
!state.isUploadingMedia &&
|
!state.isUploadingMedia &&
|
||||||
state.inputText.isNotBlank()
|
state.inputText.isNotBlank()
|
||||||
Button(
|
if (canSend) {
|
||||||
onClick = { if (canSend) onSendClick() },
|
Button(
|
||||||
enabled = state.canSendMessages && !state.isUploadingMedia,
|
onClick = onSendClick,
|
||||||
|
enabled = state.canSendMessages && !state.isUploadingMedia,
|
||||||
|
) {
|
||||||
|
Text("\u27A4")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VoiceHoldToRecordButton(
|
||||||
|
enabled = state.canSendMessages && !state.isUploadingMedia,
|
||||||
|
isRecording = state.isRecordingVoice,
|
||||||
|
isLocked = state.isVoiceLocked,
|
||||||
|
onStart = onVoiceRecordStart,
|
||||||
|
onLock = onVoiceRecordLock,
|
||||||
|
onCancel = onVoiceRecordCancel,
|
||||||
|
onRelease = onVoiceRecordSend,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.isRecordingVoice) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(if (canSend) "\u27A4" else "\uD83C\uDFA4")
|
Text(
|
||||||
|
text = "Voice ${formatDuration(state.voiceRecordingDurationMs.toInt())}",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.voiceRecordingHint ?: "",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
if (state.isVoiceLocked) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Button(onClick = onVoiceRecordCancel) { Text("Cancel") }
|
||||||
|
Button(onClick = onVoiceRecordSend) { Text("Send") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -967,11 +1084,21 @@ private fun AudioAttachmentPlayer(url: String) {
|
|||||||
setOnCompletionListener {
|
setOnCompletionListener {
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
positionMs = durationMs
|
positionMs = durationMs
|
||||||
|
AppAudioFocusCoordinator.release("player:$url")
|
||||||
}
|
}
|
||||||
setDataSource(url)
|
setDataSource(url)
|
||||||
prepareAsync()
|
prepareAsync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
AppAudioFocusCoordinator.activeSourceId.collectLatest { activeId ->
|
||||||
|
val currentId = "player:$url"
|
||||||
|
if (activeId != null && activeId != currentId && isPlaying) {
|
||||||
|
runCatching { mediaPlayer.pause() }
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
LaunchedEffect(isPlaying, isPrepared) {
|
LaunchedEffect(isPlaying, isPrepared) {
|
||||||
if (!isPlaying || !isPrepared) return@LaunchedEffect
|
if (!isPlaying || !isPrepared) return@LaunchedEffect
|
||||||
while (isPlaying) {
|
while (isPlaying) {
|
||||||
@@ -984,6 +1111,7 @@ private fun AudioAttachmentPlayer(url: String) {
|
|||||||
runCatching {
|
runCatching {
|
||||||
mediaPlayer.stop()
|
mediaPlayer.stop()
|
||||||
}
|
}
|
||||||
|
AppAudioFocusCoordinator.release("player:$url")
|
||||||
mediaPlayer.release()
|
mediaPlayer.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1004,7 +1132,9 @@ private fun AudioAttachmentPlayer(url: String) {
|
|||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
mediaPlayer.pause()
|
mediaPlayer.pause()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
AppAudioFocusCoordinator.release("player:$url")
|
||||||
} else {
|
} else {
|
||||||
|
AppAudioFocusCoordinator.request("player:$url")
|
||||||
mediaPlayer.start()
|
mediaPlayer.start()
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
}
|
}
|
||||||
@@ -1034,6 +1164,61 @@ private fun AudioAttachmentPlayer(url: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceHoldToRecordButton(
|
||||||
|
enabled: Boolean,
|
||||||
|
isRecording: Boolean,
|
||||||
|
isLocked: Boolean,
|
||||||
|
onStart: () -> Unit,
|
||||||
|
onLock: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onRelease: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.pointerInput(enabled, isRecording, isLocked) {
|
||||||
|
if (!enabled || isLocked) return@pointerInput
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown()
|
||||||
|
onStart()
|
||||||
|
var cancelledBySlide = false
|
||||||
|
var lockedBySlide = false
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val change = event.changes.first()
|
||||||
|
val delta = change.position - down.position
|
||||||
|
if (!lockedBySlide && delta.y < -120f) {
|
||||||
|
lockedBySlide = true
|
||||||
|
onLock()
|
||||||
|
}
|
||||||
|
if (!lockedBySlide && delta.x < -150f) {
|
||||||
|
cancelledBySlide = true
|
||||||
|
onCancel()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!change.pressed) {
|
||||||
|
if (!cancelledBySlide && !lockedBySlide) {
|
||||||
|
onRelease()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (change.positionChange() != androidx.compose.ui.geometry.Offset.Zero) {
|
||||||
|
change.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {},
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Text("\uD83C\uDFA4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun formatDuration(ms: Int): String {
|
private fun formatDuration(ms: Int): String {
|
||||||
if (ms <= 0) return "0:00"
|
if (ms <= 0) return "0:00"
|
||||||
val total = ms / 1000
|
val total = ms / 1000
|
||||||
|
|||||||
@@ -79,6 +79,79 @@ class ChatViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(inputText = value) }
|
_uiState.update { it.copy(inputText = value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onVoiceRecordStarted() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isRecordingVoice = true,
|
||||||
|
isVoiceLocked = false,
|
||||||
|
voiceRecordingDurationMs = 0L,
|
||||||
|
voiceRecordingHint = "Slide up to lock, slide left to cancel",
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onVoiceRecordTick(durationMs: Long) {
|
||||||
|
_uiState.update {
|
||||||
|
if (!it.isRecordingVoice) it else it.copy(voiceRecordingDurationMs = durationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onVoiceRecordLocked() {
|
||||||
|
_uiState.update {
|
||||||
|
if (!it.isRecordingVoice) it else {
|
||||||
|
it.copy(
|
||||||
|
isVoiceLocked = true,
|
||||||
|
voiceRecordingHint = "Recording locked",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onVoiceRecordCancelled() {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isRecordingVoice = false,
|
||||||
|
isVoiceLocked = false,
|
||||||
|
voiceRecordingDurationMs = 0L,
|
||||||
|
voiceRecordingHint = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onVoiceRecordFinish(
|
||||||
|
fileName: String,
|
||||||
|
mimeType: String,
|
||||||
|
bytes: ByteArray,
|
||||||
|
durationMs: Long,
|
||||||
|
) {
|
||||||
|
if (durationMs < MIN_VOICE_DURATION_MS) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isRecordingVoice = false,
|
||||||
|
isVoiceLocked = false,
|
||||||
|
voiceRecordingDurationMs = 0L,
|
||||||
|
voiceRecordingHint = null,
|
||||||
|
errorMessage = "Voice message is too short.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isRecordingVoice = false,
|
||||||
|
isVoiceLocked = false,
|
||||||
|
voiceRecordingDurationMs = 0L,
|
||||||
|
voiceRecordingHint = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onMediaPicked(
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = mimeType,
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun onSelectMessage(message: MessageItem?) {
|
fun onSelectMessage(message: MessageItem?) {
|
||||||
if (message == null) {
|
if (message == null) {
|
||||||
onClearSelection()
|
onClearSelection()
|
||||||
@@ -606,5 +679,6 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MESSAGES_PAGE_SIZE = 50
|
const val MESSAGES_PAGE_SIZE = 50
|
||||||
|
const val MIN_VOICE_DURATION_MS = 1_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ data class MessageUiState(
|
|||||||
val isForwarding: Boolean = false,
|
val isForwarding: Boolean = false,
|
||||||
val canSendMessages: Boolean = true,
|
val canSendMessages: Boolean = true,
|
||||||
val sendRestrictionText: String? = null,
|
val sendRestrictionText: String? = null,
|
||||||
|
val isRecordingVoice: Boolean = false,
|
||||||
|
val isVoiceLocked: Boolean = false,
|
||||||
|
val voiceRecordingDurationMs: Long = 0L,
|
||||||
|
val voiceRecordingHint: String? = null,
|
||||||
val actionState: MessageActionState = MessageActionState(),
|
val actionState: MessageActionState = MessageActionState(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package ru.daemonlord.messenger.ui.chat.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class VoiceRecorder(private val context: Context) {
|
||||||
|
private var recorder: MediaRecorder? = null
|
||||||
|
private var outputFile: File? = null
|
||||||
|
private var startedAtMillis: Long = 0L
|
||||||
|
|
||||||
|
fun start(): Boolean {
|
||||||
|
return runCatching {
|
||||||
|
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
|
||||||
|
val mediaRecorder = MediaRecorder(context).apply {
|
||||||
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
|
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
|
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
setAudioEncodingBitRate(64_000)
|
||||||
|
setAudioSamplingRate(44_100)
|
||||||
|
setOutputFile(file.absolutePath)
|
||||||
|
prepare()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
recorder = mediaRecorder
|
||||||
|
outputFile = file
|
||||||
|
startedAtMillis = System.currentTimeMillis()
|
||||||
|
true
|
||||||
|
}.getOrElse {
|
||||||
|
releaseInternal(deleteFile = true)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
|
||||||
|
if (startedAtMillis <= 0L) return 0L
|
||||||
|
return (nowMillis - startedAtMillis).coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopAndReadBytes(): ByteArray? {
|
||||||
|
val file = outputFile ?: return null
|
||||||
|
val success = runCatching {
|
||||||
|
recorder?.stop()
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
releaseInternal(deleteFile = false)
|
||||||
|
if (!success || !file.exists()) {
|
||||||
|
file.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
val bytes = file.readBytes()
|
||||||
|
file.delete()
|
||||||
|
bytes
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
releaseInternal(deleteFile = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseInternal(deleteFile: Boolean) {
|
||||||
|
runCatching { recorder?.reset() }
|
||||||
|
runCatching { recorder?.release() }
|
||||||
|
recorder = null
|
||||||
|
startedAtMillis = 0L
|
||||||
|
if (deleteFile) {
|
||||||
|
outputFile?.delete()
|
||||||
|
}
|
||||||
|
outputFile = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,11 +67,11 @@
|
|||||||
- [ ] Circle video playback (view-only при необходимости)
|
- [ ] Circle video playback (view-only при необходимости)
|
||||||
|
|
||||||
## 9. Запись голосовых
|
## 9. Запись голосовых
|
||||||
- [ ] Hold-to-record
|
- [x] Hold-to-record
|
||||||
- [ ] Slide up to lock
|
- [x] Slide up to lock
|
||||||
- [ ] Slide left to cancel
|
- [x] Slide left to cancel
|
||||||
- [ ] Минимальная длина записи (>=1s)
|
- [x] Минимальная длина записи (>=1s)
|
||||||
- [ ] Единый global audio focus (1 источник звука)
|
- [x] Единый global audio focus (1 источник звука)
|
||||||
|
|
||||||
## 10. Группы/каналы
|
## 10. Группы/каналы
|
||||||
- [ ] Create group/channel
|
- [ ] Create group/channel
|
||||||
|
|||||||
Reference in New Issue
Block a user