Use the same Android media interface for channels and PM
This commit is contained in:
1 parent
1cd366c0e1
commit
fef800ef7a
2 files changed
+49
-273
No files matched your search
@@ -56,7 +56,9 @@ import com.utun.chat.data.NativeLib
|
||||
import com.utun.chat.data.RadioAudioEngine
|
||||
import com.utun.chat.data.RadioOverlayService
|
||||
import com.utun.chat.ui.components.InputBar
|
||||
import com.utun.chat.ui.components.MessageBubble
|
||||
import com.utun.chat.ui.components.ConversationMessages
|
||||
import com.utun.chat.ui.components.ConversationComposer
|
||||
import com.utun.chat.data.ConversationTarget
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -79,13 +81,7 @@ fun ChatScreen(
|
||||
onRecordVideo: () -> Unit = {},
|
||||
onCapturePhoto: () -> Unit = {}
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val isVideoMode by viewModel.isVideoMode.collectAsState()
|
||||
val listState = rememberLazyListState()
|
||||
val isRecording by viewModel.isRecording.collectAsState()
|
||||
val isRecordingLocked by viewModel.isRecordingLocked.collectAsState()
|
||||
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
|
||||
var recordingLevel by remember { mutableStateOf(0f) }
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() }
|
||||
@@ -100,34 +96,6 @@ fun ChatScreen(
|
||||
/* звук сообщений канала */
|
||||
val soundOn by viewModel.soundOn.collectAsState()
|
||||
|
||||
/* attach menu */
|
||||
var showAttachMenu by remember { mutableStateOf(false) }
|
||||
|
||||
/* pending attachment state */
|
||||
var pendingAttachUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var pendingAttachName by remember { mutableStateOf("") }
|
||||
var pendingAttachSize by remember { mutableStateOf(0L) }
|
||||
|
||||
/* file picker */
|
||||
val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri != null) {
|
||||
val cr: ContentResolver = context.contentResolver
|
||||
var name = "file"
|
||||
var size = 0L
|
||||
cr.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val nameIdx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (nameIdx >= 0) name = cursor.getString(nameIdx) ?: name
|
||||
if (sizeIdx >= 0) size = cursor.getLong(sizeIdx)
|
||||
}
|
||||
}
|
||||
pendingAttachUri = uri
|
||||
pendingAttachName = name
|
||||
pendingAttachSize = size
|
||||
}
|
||||
}
|
||||
|
||||
/* save dialog for downloaded files */
|
||||
var pendingSaveFilePath by remember { mutableStateOf<String?>(null) }
|
||||
val saveFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { saveUri ->
|
||||
@@ -142,16 +110,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/* Poll peak level during recording */
|
||||
LaunchedEffect(isRecording) {
|
||||
if (isRecording) {
|
||||
while (true) {
|
||||
recordingLevel = NativeLib.voiceGetPeakLevel()
|
||||
delay(80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val playbackState by viewModel.playbackState.collectAsState()
|
||||
|
||||
/* своё сообщение — подтягиваемся вниз (в reverseLayout индекс 0 — это низ) */
|
||||
@@ -206,27 +164,15 @@ fun ChatScreen(
|
||||
(windowPosition.x + coordinates.size.width / 2f + screenOrigin[0] - windowOrigin[0]).toInt(),
|
||||
(windowPosition.y + pttCenterOffset + screenOrigin[1] - windowOrigin[1]).toInt())
|
||||
}) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true
|
||||
) {
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
items(messages.reversed()) { msg ->
|
||||
MessageBubble(msg, playbackState = playbackState,
|
||||
onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) },
|
||||
onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) },
|
||||
onDownloadFile = { msg -> viewModel.downloadAttachment(msg) },
|
||||
onOpenFile = { msg ->
|
||||
if (msg.filePath.isNotEmpty()) {
|
||||
pendingSaveFilePath = msg.filePath
|
||||
saveFileLauncher.launch(msg.text) /* suggest original filename */
|
||||
}
|
||||
},
|
||||
onOpenImage = { msg -> onViewPhoto(msg) },
|
||||
onPlayVideo = { msg -> onPlayVideo(msg) })
|
||||
}
|
||||
}
|
||||
ConversationMessages(
|
||||
target = ConversationTarget.Channel(channel.id), messages = messages, state = listState,
|
||||
playbackState = playbackState, modifier = Modifier.fillMaxSize(),
|
||||
onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) },
|
||||
onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) },
|
||||
onDownloadFile = { viewModel.downloadAttachment(it) },
|
||||
onOpenFile = { if (it.filePath.isNotEmpty()) { pendingSaveFilePath = it.filePath; saveFileLauncher.launch(it.text) } },
|
||||
onOpenImage = onViewPhoto, onPlayVideo = onPlayVideo
|
||||
)
|
||||
if (radioActive && !overlayShown) {
|
||||
RadioPttButton(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
@@ -236,78 +182,7 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
InputBar(
|
||||
text = text,
|
||||
onTextChange = { text = it },
|
||||
onSend = {
|
||||
val attachUri = pendingAttachUri
|
||||
val attachName = pendingAttachName
|
||||
pendingAttachUri = null
|
||||
pendingAttachName = ""
|
||||
pendingAttachSize = 0L
|
||||
if (attachUri != null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val cachePath = viewModel.copyUriToCache(context, attachUri, attachName)
|
||||
if (cachePath != null) {
|
||||
viewModel.sendAttachment(cachePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.isNotBlank()) {
|
||||
onSendMessage(text)
|
||||
text = ""
|
||||
}
|
||||
},
|
||||
enabled = true,
|
||||
isRecording = isRecording,
|
||||
isRecordingLocked = isRecordingLocked,
|
||||
recordingDurationMs = recordingDurationMs,
|
||||
recordingLevel = recordingLevel,
|
||||
onPttPressed = { viewModel.startVoiceRecording() },
|
||||
onPttReleased = {
|
||||
if (!viewModel.isRecordingLocked.value) viewModel.sendVoiceMessage()
|
||||
},
|
||||
onPttLock = { viewModel.lockVoiceRecording() },
|
||||
onVoiceSend = { viewModel.sendVoiceMessage() },
|
||||
onVoiceCancel = {
|
||||
viewModel.cancelVoiceRecording()
|
||||
recordingLevel = 0f
|
||||
},
|
||||
onAttachClicked = { showAttachMenu = true },
|
||||
pendingAttachmentName = pendingAttachName,
|
||||
pendingAttachmentSize = pendingAttachSize,
|
||||
onClearAttachment = {
|
||||
pendingAttachUri = null
|
||||
pendingAttachName = ""
|
||||
pendingAttachSize = 0L
|
||||
},
|
||||
isVideoMode = isVideoMode,
|
||||
onToggleMode = { viewModel.toggleVideoMode() },
|
||||
onVideoRecordOpen = onRecordVideo,
|
||||
modifier = Modifier.navigationBarsPadding()
|
||||
)
|
||||
|
||||
if (showAttachMenu) {
|
||||
ModalBottomSheet(onDismissRequest = { showAttachMenu = false }) {
|
||||
ListItem(
|
||||
headlineContent = { Text("Выбрать файл") },
|
||||
leadingContent = { Text("\uD83D\uDCCE", fontSize = 20.sp) },
|
||||
modifier = Modifier.clickable {
|
||||
showAttachMenu = false
|
||||
filePickerLauncher.launch("*/*")
|
||||
}
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Сфотографировать") },
|
||||
leadingContent = { Text("\uD83D\uDCF7", fontSize = 20.sp) },
|
||||
modifier = Modifier.clickable {
|
||||
showAttachMenu = false
|
||||
onCapturePhoto()
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
ConversationComposer(ConversationTarget.Channel(channel.id), viewModel, onSendMessage, onRecordVideo, onCapturePhoto)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,165 +1,66 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.utun.chat.data.DmConversation
|
||||
import com.utun.chat.data.Message
|
||||
import com.utun.chat.ui.components.MessageBubble
|
||||
import com.utun.chat.data.*
|
||||
import com.utun.chat.ui.components.ConversationComposer
|
||||
import com.utun.chat.ui.components.ConversationMessages
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** PM отличается заголовком и доставкой; ввод, вложения и сообщения общие с каналом. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DmChatScreen(
|
||||
conv: DmConversation,
|
||||
messages: List<Message>,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onSendFile: (String, String, String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var text by remember(conv.convId) { mutableStateOf("") }
|
||||
var fileError by remember(conv.convId) { mutableStateOf("") }
|
||||
var preparing by remember(conv.convId) { mutableStateOf(false) }
|
||||
fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatViewModel,
|
||||
onSendMessage: (String) -> Unit, onBack: () -> Unit,
|
||||
onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) {
|
||||
val target = ConversationTarget.Pm(conv.convId)
|
||||
val playback by viewModel.playbackState.collectAsState()
|
||||
val state = rememberLazyListState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var savePath by remember { mutableStateOf<String?>(null) }
|
||||
val saveFile = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
|
||||
val path = savePath
|
||||
savePath = null
|
||||
var pathToSave by remember { mutableStateOf<String?>(null) }
|
||||
var error by remember(conv.convId) { mutableStateOf("") }
|
||||
val save = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri ->
|
||||
val path = pathToSave
|
||||
pathToSave = null
|
||||
if (uri != null && path != null) scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { output -> File(path).inputStream().use { it.copyTo(output) } }
|
||||
?: error("Не удалось открыть файл для записи")
|
||||
context.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
File(path).inputStream().use { it.copyTo(output) }
|
||||
} ?: throw IllegalStateException("Не удалось открыть файл")
|
||||
}
|
||||
} catch (e: Exception) { Log.e("utun-gui", "PM file save failed", e); fileError = "Не удалось сохранить файл" }
|
||||
}
|
||||
}
|
||||
val attachFile = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri != null) {
|
||||
val targetConv = conv.convId
|
||||
scope.launch {
|
||||
preparing = true; fileError = ""
|
||||
try {
|
||||
val attachment = withContext(Dispatchers.IO) {
|
||||
val name = context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
|
||||
if (it.moveToFirst()) it.getString(0) else null
|
||||
} ?: "attachment.bin"
|
||||
val temp = File.createTempFile("pm-upload-", ".file", context.cacheDir)
|
||||
try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
temp.outputStream().use { output ->
|
||||
val buffer = ByteArray(65536)
|
||||
var size = 0L
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
size += count
|
||||
check(size <= 1024L * 1024 * 1024) { "Файл больше 1 ГиБ" }
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
} ?: error("Не удалось открыть вложение")
|
||||
temp.absolutePath to name
|
||||
} catch (e: Exception) { temp.delete(); throw e }
|
||||
}
|
||||
onSendFile(targetConv, attachment.first, attachment.second)
|
||||
} catch (e: Exception) { Log.e("utun-gui", "PM attachment failed", e); fileError = e.message ?: "Ошибка вложения" }
|
||||
finally { preparing = false }
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = "Не удалось сохранить файл"
|
||||
}
|
||||
}
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.lastOrNull()?.isOutgoing == true) {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
if (messages.lastOrNull()?.isOutgoing == true) state.scrollToItem(0)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().imePadding()) {
|
||||
TopAppBar(
|
||||
title = { Text(conv.displayName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
}
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
state = listState,
|
||||
reverseLayout = true
|
||||
) {
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
items(messages.reversed()) { msg ->
|
||||
MessageBubble(msg, onOpenFile = { savePath = it.filePath; saveFile.launch(it.text) })
|
||||
}
|
||||
}
|
||||
if (fileError.isNotEmpty()) Text(fileError, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
|
||||
if (preparing) Text("Подготовка вложения…", modifier = Modifier.padding(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.navigationBarsPadding(),
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { attachFile.launch(arrayOf("*/*")) }, enabled = !preparing) {
|
||||
Icon(Icons.Default.AttachFile, "Прикрепить файл")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Сообщение") },
|
||||
maxLines = 4
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(onClick = {
|
||||
if (text.isNotBlank()) {
|
||||
onSendMessage(text)
|
||||
text = ""
|
||||
}
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, "Send", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Column(Modifier.fillMaxSize().imePadding()) {
|
||||
TopAppBar(title = { Text(conv.displayName) }, navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Назад") }
|
||||
})
|
||||
ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(),
|
||||
onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) },
|
||||
onSeekVoice = { path, position, duration -> viewModel.seekVoice(path, position, duration) },
|
||||
onOpenFile = { if (it.filePath.isNotEmpty()) { pathToSave = it.filePath; save.launch(it.text) } },
|
||||
onPlayVideo = onPlayVideo)
|
||||
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
|
||||
ConversationComposer(target, viewModel, onSendMessage, onRecordVideo)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user