Browse Source

Use the same Android media interface for channels and PM

master
evgeny 1 day ago
parent
commit
fef800ef7a
  1. 151
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  2. 171
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt

151
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -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)
}
}

171
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt

@ -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)
}
}

Loading…
Cancel
Save