chatgui-android: съёмка фото — JPEG, выбор разрешения, отправка аттачем, просмотр с зумом
This commit is contained in:
1 parent
c94acd892e
commit
ca16b46404
18 files changed
+890
-6
No files matched your search
@@ -29,7 +29,7 @@ import com.utun.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord }
|
||||
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -62,6 +62,7 @@ class MainActivity : ComponentActivity() {
|
||||
var joinAutoLink by remember { mutableStateOf<String?>(null) }
|
||||
var inviteChannelId by remember { mutableStateOf<String?>(null) }
|
||||
var videoMessage by remember { mutableStateOf<Message?>(null) }
|
||||
var photoMessage by remember { mutableStateOf<Message?>(null) }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -79,6 +80,7 @@ class MainActivity : ComponentActivity() {
|
||||
when (screen) {
|
||||
Screen.MemberList -> screen = Screen.Chat
|
||||
Screen.VideoRecord -> screen = Screen.Chat
|
||||
Screen.PhotoRecord -> screen = Screen.Chat
|
||||
else -> screen = Screen.ChannelList
|
||||
}
|
||||
}
|
||||
@@ -87,6 +89,10 @@ class MainActivity : ComponentActivity() {
|
||||
videoMessage = null
|
||||
}
|
||||
|
||||
BackHandler(enabled = photoMessage != null) {
|
||||
photoMessage = null
|
||||
}
|
||||
|
||||
val connected = NativeLib.isReady()
|
||||
|
||||
/* Subscribe native logs only when LogScreen is open */
|
||||
@@ -127,7 +133,9 @@ class MainActivity : ComponentActivity() {
|
||||
onInvite = { showInvite(channel) },
|
||||
onBack = { screen = Screen.ChannelList },
|
||||
onPlayVideo = { msg -> videoMessage = msg },
|
||||
onRecordVideo = { screen = Screen.VideoRecord }
|
||||
onViewPhoto = { msg -> photoMessage = msg },
|
||||
onRecordVideo = { screen = Screen.VideoRecord },
|
||||
onCapturePhoto = { screen = Screen.PhotoRecord }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -168,6 +176,18 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
}
|
||||
Screen.PhotoRecord -> {
|
||||
val channel = vm.currentChannel.collectAsState().value
|
||||
if (channel != null) {
|
||||
PhotoRecordScreen(
|
||||
onSend = { path, width, height ->
|
||||
vm.sendPhotoMessage(path, width, height)
|
||||
screen = Screen.Chat
|
||||
},
|
||||
onClose = { screen = Screen.Chat }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
|
||||
|
||||
@@ -176,6 +196,12 @@ class MainActivity : ComponentActivity() {
|
||||
VideoPlayerScreen(filePath = msg.filePath, onClose = { videoMessage = null })
|
||||
}
|
||||
}
|
||||
|
||||
photoMessage?.let { msg ->
|
||||
if (msg.filePath.isNotEmpty()) {
|
||||
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Show join dialog after QR scan returns */
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.graphics.ImageFormat
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraMetadata
|
||||
import android.util.Size
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
@@ -9,6 +11,11 @@ import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
import kotlin.math.sqrt
|
||||
|
||||
data class PhotoResolution(val width: Int, val height: Int) {
|
||||
val label: String get() = "${width}×${height}"
|
||||
val area: Long get() = width.toLong() * height.toLong()
|
||||
}
|
||||
|
||||
enum class LensType { SELFIE, MAIN, ULTRA_WIDE, MACRO, TELE }
|
||||
|
||||
data class LensOption(
|
||||
@@ -106,4 +113,35 @@ object CameraLensManager {
|
||||
if (diag <= 0f) return focal
|
||||
return focal * 43.27f / diag
|
||||
}
|
||||
|
||||
/* Варианты разрешения фото: 640×480 … native (≈4 штуки, зависят от камеры) */
|
||||
fun photoResolutions(provider: ProcessCameraProvider, cameraId: String): List<PhotoResolution> {
|
||||
val info = provider.availableCameraInfos.firstOrNull {
|
||||
Camera2CameraInfo.from(it).cameraId == cameraId
|
||||
}
|
||||
if (info == null) {
|
||||
LogManager.addLog("WARN", "Lens", "photoResolutions: camera $cameraId not found")
|
||||
return emptyList()
|
||||
}
|
||||
val c2 = Camera2CameraInfo.from(info)
|
||||
val map = c2.getCameraCharacteristic(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
|
||||
val jpegSizes = map?.getOutputSizes(ImageFormat.JPEG) ?: emptyArray<Size>()
|
||||
if (jpegSizes.isEmpty()) {
|
||||
LogManager.addLog("WARN", "Lens", "photoResolutions: no JPEG sizes for $cameraId")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val native = jpegSizes.maxByOrNull { it.width.toLong() * it.height.toLong() } ?: jpegSizes[0]
|
||||
val candidates = listOf(
|
||||
PhotoResolution(640, 480),
|
||||
PhotoResolution(1280, 720),
|
||||
PhotoResolution(1920, 1080),
|
||||
PhotoResolution(native.width, native.height)
|
||||
)
|
||||
val result = candidates
|
||||
.filter { it.area <= native.width.toLong() * native.height.toLong() }
|
||||
.distinctBy { it.label }
|
||||
LogManager.addLog("INFO", "Lens", "photoResolutions $cameraId: ${result.joinToString { it.label }}")
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
|
||||
val isOutgoing: Boolean = false, val contentType: String = "text/plain",
|
||||
val filePath: String = "", val waveform: List<Float> = emptyList(),
|
||||
val voiceDurationMs: Int = 0, val fileSize: Long = 0,
|
||||
val imageWidth: Int = 0, val imageHeight: Int = 0,
|
||||
val downloadState: String = "" /* "", "downloading", "downloaded", "error" */)
|
||||
|
||||
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
|
||||
@@ -309,7 +310,7 @@ class ChatRepository {
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (contentType == "application/octet-stream" || contentType.startsWith("video/")) {
|
||||
if (contentType == "application/octet-stream" || contentType.startsWith("video/") || contentType.startsWith("image/")) {
|
||||
try {
|
||||
val pipeIdx = text.indexOf('|')
|
||||
if (pipeIdx > 0) {
|
||||
@@ -321,6 +322,19 @@ class ChatRepository {
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
var imageWidth = 0
|
||||
var imageHeight = 0
|
||||
if (contentType.startsWith("image/")) {
|
||||
try {
|
||||
for (part in text.substringAfter('|').split("|")) {
|
||||
when {
|
||||
part.startsWith("w=") -> imageWidth = part.removePrefix("w=").toIntOrNull() ?: 0
|
||||
part.startsWith("h=") -> imageHeight = part.removePrefix("h=").toIntOrNull() ?: 0
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
list.add(Message(
|
||||
id = obj.getLong("id"),
|
||||
author = obj.getString("author"),
|
||||
@@ -332,6 +346,8 @@ class ChatRepository {
|
||||
waveform = waveform,
|
||||
voiceDurationMs = voiceDurationMs,
|
||||
fileSize = fileSize,
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
downloadState = downloadState
|
||||
))
|
||||
}
|
||||
@@ -343,6 +359,8 @@ class ChatRepository {
|
||||
fun sendAttachment(channelId: String, filePath: String) = NativeLib.attachmentSend(channelId, filePath)
|
||||
fun sendVideo(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int) =
|
||||
NativeLib.videoSend(channelId, filePath, durationMs, width, height)
|
||||
fun sendPhoto(channelId: String, filePath: String, width: Int, height: Int) =
|
||||
NativeLib.photoSend(channelId, filePath, width, height)
|
||||
fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId)
|
||||
fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex)
|
||||
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) =
|
||||
|
||||
@@ -123,6 +123,10 @@ object NativeLib {
|
||||
fun videoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean =
|
||||
nativeVideoSend(channelId, filePath, durationMs, width, height)
|
||||
|
||||
/* ── Photo message ── */
|
||||
fun photoSend(channelId: String, filePath: String, width: Int, height: Int): Boolean =
|
||||
nativePhotoSend(channelId, filePath, width, height)
|
||||
|
||||
/* ── Voice playback ── */
|
||||
fun voiceDecodeOpen(filePath: String, outInfo: IntArray): Long = nativeVoiceDecodeOpen(filePath, outInfo)
|
||||
fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
|
||||
@@ -194,6 +198,9 @@ object NativeLib {
|
||||
/* ── Video message JNI ── */
|
||||
private external fun nativeVideoSend(channelId: String, filePath: String, durationMs: Int, width: Int, height: Int): Boolean
|
||||
|
||||
/* ── Photo message JNI ── */
|
||||
private external fun nativePhotoSend(channelId: String, filePath: String, width: Int, height: Int): Boolean
|
||||
|
||||
/* ── Voice playback JNI ── */
|
||||
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
|
||||
private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import android.media.ExifInterface
|
||||
|
||||
object PhotoUtil {
|
||||
|
||||
private fun orientation(path: String): Int = try {
|
||||
ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
} catch (_: Exception) {
|
||||
ExifInterface.ORIENTATION_NORMAL
|
||||
}
|
||||
|
||||
private fun isRotated90(orientation: Int): Boolean = when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_ROTATE_270,
|
||||
ExifInterface.ORIENTATION_TRANSPOSE, ExifInterface.ORIENTATION_TRANSVERSE -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun orientedDimensions(path: String): Pair<Int, Int> {
|
||||
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, opts)
|
||||
val w = opts.outWidth
|
||||
val h = opts.outHeight
|
||||
if (w <= 0 || h <= 0) return Pair(0, 0)
|
||||
return if (isRotated90(orientation(path))) Pair(h, w) else Pair(w, h)
|
||||
}
|
||||
|
||||
fun decodeOriented(path: String, inSampleSize: Int): Bitmap? {
|
||||
val o = orientation(path)
|
||||
val opts = BitmapFactory.Options().apply { this.inSampleSize = inSampleSize }
|
||||
val raw = try { BitmapFactory.decodeFile(path, opts) } catch (_: Exception) { null } ?: return null
|
||||
val matrix = Matrix()
|
||||
when (o) {
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> { matrix.setRotate(180f); matrix.postScale(-1f, 1f) }
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> { matrix.setRotate(90f); matrix.postScale(-1f, 1f) }
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> { matrix.setRotate(-90f); matrix.postScale(-1f, 1f) }
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
|
||||
else -> return raw
|
||||
}
|
||||
val rotated = try {
|
||||
Bitmap.createBitmap(raw, 0, 0, raw.width, raw.height, matrix, true)
|
||||
} catch (_: Exception) { raw }
|
||||
if (rotated !== raw) raw.recycle()
|
||||
return rotated
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.Message
|
||||
import com.utun.chat.data.NativeLib
|
||||
import com.utun.chat.data.PhotoUtil
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -48,6 +49,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
onSeekVoice: (String, Int, Int) -> Unit = { _, _, _ -> },
|
||||
onDownloadFile: (Message) -> Unit = {},
|
||||
onOpenFile: (Message) -> Unit = {},
|
||||
onOpenImage: (Message) -> Unit = {},
|
||||
onPlayVideo: (Message) -> Unit = {}) {
|
||||
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
|
||||
val textColor = if (message.isOutgoing) Color.White else Color.Black
|
||||
@@ -57,6 +59,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
|
||||
val isVoice = message.contentType == "audio/opus"
|
||||
val isVideo = isVideoMessage(message)
|
||||
val isImage = isImageMessage(message)
|
||||
val isMedia = message.contentType.isNotEmpty() && !isVoice && message.contentType != "text/plain"
|
||||
val isActive = isVoice && playbackState.filePath == message.filePath
|
||||
|
||||
@@ -77,6 +80,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
when {
|
||||
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice, onDownloadFile)
|
||||
isVideo -> VideoBubble(message, textColor, onDownloadFile, onPlayVideo)
|
||||
isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage)
|
||||
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
|
||||
else -> {
|
||||
Text(message.text, fontSize = 17.sp, color = textColor)
|
||||
@@ -253,6 +257,8 @@ private fun isVideoMessage(message: Message): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isImageMessage(message: Message): Boolean = message.contentType.startsWith("image/")
|
||||
|
||||
private data class VideoMeta(val thumb: Bitmap?, val durationMs: Int)
|
||||
|
||||
private fun extractVideoMeta(path: String): VideoMeta {
|
||||
@@ -337,6 +343,67 @@ private fun VideoBubble(message: Message, textColor: Color,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageBubble(message: Message, textColor: Color,
|
||||
onDownload: (Message) -> Unit = {},
|
||||
onOpen: (Message) -> Unit = {}) {
|
||||
val downloading = message.downloadState == "downloading"
|
||||
val downloaded = message.filePath.isNotEmpty()
|
||||
val isError = message.downloadState == "error"
|
||||
|
||||
var bmp by remember(message.filePath) { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(message.filePath) {
|
||||
if (message.filePath.isNotEmpty()) {
|
||||
bmp = withContext(Dispatchers.IO) {
|
||||
try { PhotoUtil.decodeOriented(message.filePath, 2) } catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val resText = if (message.imageWidth > 0 || message.imageHeight > 0)
|
||||
"${message.imageWidth}×${message.imageHeight} · " else ""
|
||||
|
||||
Column(
|
||||
modifier = Modifier.clickable {
|
||||
when {
|
||||
downloading -> {}
|
||||
downloaded -> onOpen(message)
|
||||
else -> onDownload(message)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(width = 200.dp, height = 200.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF1E1E23))
|
||||
) {
|
||||
bmp?.let { Image(it.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) }
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Box(modifier = Modifier.size(44.dp).background(Color(0x88000000), CircleShape), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
when { downloading -> "\u23F3"; isError -> "\u267B"; !downloaded -> "\u2B07"; else -> "" },
|
||||
fontSize = 18.sp, color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
resText + formatFileSize(message.fileSize) + " · " + when {
|
||||
downloading -> "Downloading..."
|
||||
isError -> "Failed, tap to retry"
|
||||
!downloaded -> "Tap to download"
|
||||
else -> "Tap to open"
|
||||
},
|
||||
fontSize = 12.sp, color = textColor.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
if (bytes <= 0) return ""
|
||||
if (bytes < 1024) return "${bytes}B"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
private const val MAX_ZOOM = 4f
|
||||
|
||||
private fun clampOffset(scale: Float, o: Offset, cW: Float, cH: Float): Offset {
|
||||
val mx = ((cW * scale - cW) / 2f).coerceAtLeast(0f)
|
||||
val my = ((cH * scale - cH) / 2f).coerceAtLeast(0f)
|
||||
return Offset(o.x.coerceIn(-mx, mx), o.y.coerceIn(-my, my))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZoomableImage(bitmap: Bitmap, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Fit) {
|
||||
var zoomScale by remember(bitmap) { mutableStateOf(1f) }
|
||||
var zoomOffset by remember(bitmap) { mutableStateOf(Offset.Zero) }
|
||||
|
||||
BoxWithConstraints(modifier) {
|
||||
val density = LocalDensity.current
|
||||
val cW = with(density) { maxWidth.toPx() }
|
||||
val cH = with(density) { maxHeight.toPx() }
|
||||
|
||||
LaunchedEffect(cW, cH) {
|
||||
zoomOffset = clampOffset(zoomScale, zoomOffset, cW, cH)
|
||||
}
|
||||
|
||||
Image(
|
||||
bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
contentScale = contentScale,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
scaleX = zoomScale
|
||||
scaleY = zoomScale
|
||||
translationX = zoomOffset.x
|
||||
translationY = zoomOffset.y
|
||||
}
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { centroid, pan, zoom, _ ->
|
||||
val old = zoomScale
|
||||
val new = (zoomScale * zoom).coerceIn(1f, MAX_ZOOM)
|
||||
val z = new / old
|
||||
val rel = centroid - Offset(cW / 2f, cH / 2f)
|
||||
val raw = Offset(
|
||||
zoomOffset.x * z + rel.x * (1f - z) + pan.x,
|
||||
zoomOffset.y * z + rel.y * (1f - z) + pan.y
|
||||
)
|
||||
zoomScale = new
|
||||
zoomOffset = clampOffset(new, raw, cW, cH)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -18,6 +19,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.Message
|
||||
import com.utun.chat.data.NativeLib
|
||||
@@ -41,7 +43,9 @@ fun ChatScreen(
|
||||
onInvite: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onPlayVideo: (Message) -> Unit = {},
|
||||
onRecordVideo: () -> Unit = {}
|
||||
onViewPhoto: (Message) -> Unit = {},
|
||||
onRecordVideo: () -> Unit = {},
|
||||
onCapturePhoto: () -> Unit = {}
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
val isVideoMode by viewModel.isVideoMode.collectAsState()
|
||||
@@ -53,6 +57,9 @@ fun ChatScreen(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
/* attach menu */
|
||||
var showAttachMenu by remember { mutableStateOf(false) }
|
||||
|
||||
/* pending attachment state */
|
||||
var pendingAttachUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var pendingAttachName by remember { mutableStateOf("") }
|
||||
@@ -136,6 +143,7 @@ fun ChatScreen(
|
||||
saveFileLauncher.launch(msg.text) /* suggest original filename */
|
||||
}
|
||||
},
|
||||
onOpenImage = { msg -> onViewPhoto(msg) },
|
||||
onPlayVideo = { msg -> onPlayVideo(msg) })
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
@@ -177,7 +185,7 @@ fun ChatScreen(
|
||||
viewModel.cancelVoiceRecording()
|
||||
recordingLevel = 0f
|
||||
},
|
||||
onAttachClicked = { filePickerLauncher.launch("*/*") },
|
||||
onAttachClicked = { showAttachMenu = true },
|
||||
pendingAttachmentName = pendingAttachName,
|
||||
pendingAttachmentSize = pendingAttachSize,
|
||||
onClearAttachment = {
|
||||
@@ -190,5 +198,27 @@ fun ChatScreen(
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Size
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
import androidx.camera.core.resolutionselector.ResolutionStrategy
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.utun.chat.data.CameraLensManager
|
||||
import com.utun.chat.data.LensOption
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.PhotoResolution
|
||||
import com.utun.chat.data.PhotoUtil
|
||||
import com.utun.chat.ui.components.ZoomableImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private enum class PhotoPhase { IDLE, CAPTURING, CAPTURED }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PhotoRecordScreen(
|
||||
onSend: (String, Int, Int) -> Unit,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
|
||||
|
||||
var hasCamera by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val camLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { hasCamera = it }
|
||||
|
||||
LaunchedEffect(Unit) { if (!hasCamera) camLauncher.launch(Manifest.permission.CAMERA) }
|
||||
|
||||
val providerRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||
var lenses by remember { mutableStateOf<List<LensOption>>(emptyList()) }
|
||||
var selectedLens by remember { mutableStateOf<LensOption?>(null) }
|
||||
var resolutions by remember { mutableStateOf<List<PhotoResolution>>(emptyList()) }
|
||||
var selectedResolution by remember { mutableStateOf<PhotoResolution?>(null) }
|
||||
|
||||
val imageCapture = remember(selectedResolution) {
|
||||
val builder = ImageCapture.Builder()
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||
selectedResolution?.let { r ->
|
||||
val selector = ResolutionSelector.Builder()
|
||||
.setResolutionStrategy(ResolutionStrategy(
|
||||
Size(r.width, r.height),
|
||||
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER))
|
||||
.build()
|
||||
builder.setResolutionSelector(selector)
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
val preview = remember { Preview.Builder().build() }
|
||||
val currentPreview by rememberUpdatedState(preview)
|
||||
val currentImageCapture by rememberUpdatedState(imageCapture)
|
||||
|
||||
var phase by remember { mutableStateOf(PhotoPhase.IDLE) }
|
||||
var outputFile by remember { mutableStateOf<File?>(null) }
|
||||
var photoWidth by remember { mutableStateOf(0) }
|
||||
var photoHeight by remember { mutableStateOf(0) }
|
||||
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var boundCamera by remember { mutableStateOf<Camera?>(null) }
|
||||
var torchOn by remember { mutableStateOf(false) }
|
||||
|
||||
fun bindCamera(selector: CameraSelector) {
|
||||
val provider = providerRef.value ?: return
|
||||
try {
|
||||
provider.unbindAll()
|
||||
boundCamera = provider.bindToLifecycle(lifecycleOwner, selector, currentPreview, currentImageCapture)
|
||||
torchOn = false
|
||||
LogManager.addLog("INFO", "PhotoRecord", "camera bound")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "PhotoRecord", "bind failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun selectLens(lens: LensOption) {
|
||||
selectedLens = lens
|
||||
val provider = providerRef.value
|
||||
val list = if (provider != null) CameraLensManager.photoResolutions(provider, lens.cameraId) else emptyList()
|
||||
resolutions = list
|
||||
selectedResolution = list.lastOrNull()
|
||||
bindCamera(lens.selector())
|
||||
}
|
||||
|
||||
fun readDimensions(file: File): Pair<Int, Int> = PhotoUtil.orientedDimensions(file.absolutePath)
|
||||
|
||||
fun capture() {
|
||||
if (phase != PhotoPhase.IDLE) return
|
||||
val file = File(context.cacheDir, "photo_msg_${System.currentTimeMillis()}.jpg")
|
||||
outputFile = file
|
||||
phase = PhotoPhase.CAPTURING
|
||||
try {
|
||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||
imageCapture.takePicture(options, cameraExecutor, object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(results: ImageCapture.OutputFileResults) {
|
||||
val dims = readDimensions(file)
|
||||
photoWidth = dims.first
|
||||
photoHeight = dims.second
|
||||
LogManager.addLog("INFO", "PhotoRecord", "captured: ${file.absolutePath} (${file.length()} bytes) ${dims.first}x${dims.second}")
|
||||
phase = PhotoPhase.CAPTURED
|
||||
}
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
LogManager.addLog("ERROR", "PhotoRecord", "capture error: ${exception.message}")
|
||||
file.delete()
|
||||
outputFile = null
|
||||
phase = PhotoPhase.IDLE
|
||||
}
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "PhotoRecord", "capture failed: ${e.message}")
|
||||
phase = PhotoPhase.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
fun discardAndReset() {
|
||||
outputFile?.delete()
|
||||
outputFile = null
|
||||
previewBitmap = null
|
||||
photoWidth = 0
|
||||
photoHeight = 0
|
||||
phase = PhotoPhase.IDLE
|
||||
LogManager.addLog("INFO", "PhotoRecord", "discarded")
|
||||
}
|
||||
|
||||
/* load downsampled preview when captured */
|
||||
LaunchedEffect(phase) {
|
||||
if (phase == PhotoPhase.CAPTURED) {
|
||||
val file = outputFile ?: return@LaunchedEffect
|
||||
previewBitmap = withContext(Dispatchers.IO) { PhotoUtil.decodeOriented(file.absolutePath, 4) }
|
||||
}
|
||||
}
|
||||
|
||||
/* rebind camera after resolution change (imageCapture is rebuilt) */
|
||||
LaunchedEffect(selectedResolution) {
|
||||
if (providerRef.value != null) {
|
||||
selectedLens?.let { bindCamera(it.selector()) }
|
||||
LogManager.addLog("INFO", "PhotoRecord", "resolution switched to ${selectedResolution?.label ?: "full"}")
|
||||
}
|
||||
}
|
||||
|
||||
/* init provider + enumerate lenses + bind front */
|
||||
DisposableEffect(hasCamera) {
|
||||
if (hasCamera) {
|
||||
val future = ProcessCameraProvider.getInstance(context)
|
||||
future.addListener({
|
||||
try {
|
||||
val provider = future.get()
|
||||
providerRef.value = provider
|
||||
val list = CameraLensManager.enumerate(provider)
|
||||
lenses = list
|
||||
val default = list.firstOrNull { it.type.name == "SELFIE" } ?: list.firstOrNull()
|
||||
if (default != null) {
|
||||
selectLens(default)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "PhotoRecord", "provider init failed: ${e.message}")
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
onDispose {
|
||||
providerRef.value?.unbindAll()
|
||||
providerRef.value = null
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Фото") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, "Закрыть") }
|
||||
},
|
||||
actions = {
|
||||
if (boundCamera?.cameraInfo?.hasFlashUnit() == true) {
|
||||
IconButton(onClick = {
|
||||
val next = !torchOn
|
||||
try { boundCamera?.cameraControl?.enableTorch(next) } catch (_: Exception) {}
|
||||
torchOn = next
|
||||
}) {
|
||||
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, "Подсветка", tint = if (torchOn) Color(0xFFFFD600) else Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().background(Color.Black).padding(padding)) {
|
||||
if (!hasCamera) {
|
||||
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Нужно разрешение камеры", color = Color.White, fontSize = 16.sp)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text("Камера") }
|
||||
}
|
||||
} else if (phase == PhotoPhase.CAPTURED) {
|
||||
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
previewBitmap?.let { ZoomableImage(it, Modifier.fillMaxSize()) }
|
||||
}
|
||||
Text(
|
||||
"${photoWidth}×${photoHeight}",
|
||||
color = Color.White.copy(alpha = 0.7f), fontSize = 13.sp
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).also { pv ->
|
||||
pv.scaleType = PreviewView.ScaleType.FIT_CENTER
|
||||
preview.surfaceProvider = pv.surfaceProvider
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
/* lens chips */
|
||||
if (phase == PhotoPhase.IDLE) {
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
lenses.forEach { lens ->
|
||||
val selected = selectedLens?.cameraId == lens.cameraId
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
|
||||
modifier = Modifier.clickable { selectLens(lens) }
|
||||
) {
|
||||
Text(
|
||||
lens.label,
|
||||
color = if (selected) Color.Black else Color.White,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
resolutions.forEach { r ->
|
||||
val selected = r == selectedResolution
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (selected) Color(0xFFFFD600) else Color(0x66000000),
|
||||
modifier = Modifier.clickable { selectedResolution = r }
|
||||
) {
|
||||
Text(
|
||||
r.label,
|
||||
color = if (selected) Color.Black else Color.White,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* bottom controls */
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth()
|
||||
.navigationBarsPadding().padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (phase == PhotoPhase.CAPTURED) {
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp)
|
||||
.background(Color(0x66000000), CircleShape)
|
||||
.clickable { discardAndReset() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, "Переснять", tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
Spacer(Modifier.width(28.dp))
|
||||
Box(
|
||||
modifier = Modifier.size(52.dp)
|
||||
.background(Color(0xFF4CAF50), CircleShape)
|
||||
.clickable {
|
||||
outputFile?.let { f -> if (f.length() > 0) onSend(f.absolutePath, photoWidth, photoHeight) else onClose() }
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, "Отправить", tint = Color.White, modifier = Modifier.size(26.dp))
|
||||
}
|
||||
} else {
|
||||
/* shutter */
|
||||
Box(
|
||||
modifier = Modifier.size(76.dp)
|
||||
.background(Color.White.copy(alpha = 0.9f), CircleShape)
|
||||
.clickable(enabled = phase == PhotoPhase.IDLE) { capture() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(60.dp)
|
||||
.background(if (phase == PhotoPhase.CAPTURING) Color(0x88FFFFFF) else Color.White, CircleShape)
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.PhotoUtil
|
||||
import com.utun.chat.ui.components.ZoomableImage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val MAX_DECODE_DIM = 4096
|
||||
|
||||
private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun decodeBitmap(path: String): Bitmap? {
|
||||
val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
android.graphics.BitmapFactory.decodeFile(path, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
var sample = 1
|
||||
while ((bounds.outWidth / sample) > MAX_DECODE_DIM || (bounds.outHeight / sample) > MAX_DECODE_DIM) sample *= 2
|
||||
return PhotoUtil.decodeOriented(path, sample)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PhotoViewerScreen(filePath: String, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var bmp by remember(filePath) { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
/* ── immersive: hide system bars ── */
|
||||
DisposableEffect(Unit) {
|
||||
val act = context.findActivity()
|
||||
val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) }
|
||||
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
controller?.hide(WindowInsetsCompat.Type.systemBars())
|
||||
onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) }
|
||||
}
|
||||
|
||||
LaunchedEffect(filePath) {
|
||||
bmp = withContext(Dispatchers.IO) { decodeBitmap(filePath) }
|
||||
if (bmp == null) LogManager.addLog("ERROR", "PhotoViewer", "decode failed: $filePath")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(Color.Black)
|
||||
) {
|
||||
bmp?.let { ZoomableImage(it, Modifier.fillMaxSize()) }
|
||||
|
||||
/* close button (top-right) */
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.background(Color(0xAA000000))
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Filled.Close, "Закрыть", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -260,7 +260,10 @@ fun VideoRecordScreen(
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
PreviewView(ctx).also { pv -> preview.surfaceProvider = pv.surfaceProvider }
|
||||
PreviewView(ctx).also { pv ->
|
||||
pv.scaleType = PreviewView.ScaleType.FIT_CENTER
|
||||
preview.surfaceProvider = pv.surfaceProvider
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
@@ -643,6 +643,18 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPhotoMessage(filePath: String, width: Int, height: Int) {
|
||||
val r = repo ?: return
|
||||
val ch = _currentChannel.value ?: return
|
||||
LogManager.addLog("INFO", "VM", "sendPhotoMessage ch=${ch.id} ${width}x${height}")
|
||||
r.sendPhoto(ch.id, filePath, width, height)
|
||||
viewModelScope.launch {
|
||||
delay(500)
|
||||
refreshMessages(ch.id)
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAttachment(msg: Message) {
|
||||
val ch = _currentChannel.value ?: return
|
||||
NativeLib.attachmentDownload(ch.id, msg.id)
|
||||
|
||||
Reference in new issue
Block a user