feat: polish chat media sizing and album editing
- size single photo and video bubbles by real aspect ratio - move private contact and blocked badges into the chat info sheet - add drag reorder, inline remove, and crop/rotate tools to album preview - tighten circle playback reset behavior at the end
This commit is contained in:
@@ -4,6 +4,8 @@ package ru.daemonlord.messenger.ui.chat
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Matrix
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -24,6 +26,7 @@ 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.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
@@ -101,6 +104,7 @@ import androidx.compose.animation.core.infiniteRepeatable
|
|||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -122,6 +126,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.compositeOver
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
@@ -1666,6 +1672,42 @@ private fun ChatScreen(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isPrivateChat && state.isCounterpartRelationshipResolved) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (state.isCounterpartContact) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.chat_private_info_contact_badge),
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.isCounterpartBlocked) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = MaterialTheme.colorScheme.error.copy(alpha = 0.16f),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.chat_private_info_blocked_badge),
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
@@ -3052,6 +3094,8 @@ private fun MediaBatchPreviewSheet(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentIndex = pagerState.currentPage.coerceIn(0, (mediaItems.size - 1).coerceAtLeast(0))
|
val currentIndex = pagerState.currentPage.coerceIn(0, (mediaItems.size - 1).coerceAtLeast(0))
|
||||||
val currentItem = mediaItems.getOrNull(currentIndex)
|
val currentItem = mediaItems.getOrNull(currentIndex)
|
||||||
|
var draggingIndex by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var dragOffsetPx by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
LaunchedEffect(mediaItems.size) {
|
LaunchedEffect(mediaItems.size) {
|
||||||
if (mediaItems.isEmpty()) {
|
if (mediaItems.isEmpty()) {
|
||||||
@@ -3081,31 +3125,31 @@ private fun MediaBatchPreviewSheet(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
IconButton(
|
if (currentItem != null && currentItem.isEditableStaticImage) {
|
||||||
onClick = {
|
IconButton(
|
||||||
if (currentIndex > 0) {
|
onClick = {
|
||||||
onUpdateItems(mediaItems.move(currentIndex, currentIndex - 1))
|
currentItem.rotateClockwise()?.let { updated ->
|
||||||
}
|
onUpdateItems(mediaItems.updatedAt(currentIndex, updated))
|
||||||
},
|
}
|
||||||
enabled = currentIndex > 0,
|
},
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.Filled.Edit,
|
||||||
contentDescription = stringResource(id = R.string.chat_media_preview_move_left),
|
contentDescription = stringResource(id = R.string.chat_media_preview_rotate),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (currentIndex < mediaItems.lastIndex) {
|
currentItem.cropCenterSquare()?.let { updated ->
|
||||||
onUpdateItems(mediaItems.move(currentIndex, currentIndex + 1))
|
onUpdateItems(mediaItems.updatedAt(currentIndex, updated))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = currentIndex < mediaItems.lastIndex,
|
) {
|
||||||
) {
|
Icon(
|
||||||
Icon(
|
imageVector = Icons.Filled.Image,
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
contentDescription = stringResource(id = R.string.chat_media_preview_crop_square),
|
||||||
contentDescription = stringResource(id = R.string.chat_media_preview_move_right),
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -3136,6 +3180,20 @@ private fun MediaBatchPreviewSheet(
|
|||||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)),
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color.Black.copy(alpha = 0.45f),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.chat_media_preview_count_badge, page + 1, mediaItems.size),
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (isVideo && previewUri != null) {
|
if (isVideo && previewUri != null) {
|
||||||
MediaBatchVideoPreview(previewUri = previewUri)
|
MediaBatchVideoPreview(previewUri = previewUri)
|
||||||
} else {
|
} else {
|
||||||
@@ -3149,10 +3207,17 @@ private fun MediaBatchPreviewSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.chat_media_preview_reorder_hint),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
items(mediaItems.size, key = { index -> mediaItems[index].fileName + index }) { index ->
|
items(mediaItems.size, key = { index -> mediaItems[index].fileName + index }) { index ->
|
||||||
val item = mediaItems[index]
|
val item = mediaItems[index]
|
||||||
val isSelected = index == currentIndex
|
val isSelected = index == currentIndex
|
||||||
|
val dragStepPx = with(LocalContext.current.resources.displayMetrics) { 88.dp.value * density }
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(14.dp),
|
shape = RoundedCornerShape(14.dp),
|
||||||
color = if (isSelected) {
|
color = if (isSelected) {
|
||||||
@@ -3168,12 +3233,57 @@ private fun MediaBatchPreviewSheet(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(72.dp)
|
.size(72.dp)
|
||||||
.clip(RoundedCornerShape(14.dp))
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = if (draggingIndex == index) dragOffsetPx else 0f
|
||||||
|
}
|
||||||
.clickable {
|
.clickable {
|
||||||
scope.launch { pagerState.animateScrollToPage(index) }
|
scope.launch { pagerState.animateScrollToPage(index) }
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(mediaItems, index) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDragStart = {
|
||||||
|
draggingIndex = index
|
||||||
|
dragOffsetPx = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
draggingIndex = null
|
||||||
|
dragOffsetPx = 0f
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
draggingIndex = null
|
||||||
|
dragOffsetPx = 0f
|
||||||
|
},
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
if (draggingIndex == null) draggingIndex = index
|
||||||
|
dragOffsetPx += dragAmount.x
|
||||||
|
val activeIndex = draggingIndex ?: index
|
||||||
|
when {
|
||||||
|
dragOffsetPx > dragStepPx / 2f && activeIndex < mediaItems.lastIndex -> {
|
||||||
|
onUpdateItems(mediaItems.move(activeIndex, activeIndex + 1))
|
||||||
|
draggingIndex = activeIndex + 1
|
||||||
|
dragOffsetPx -= dragStepPx
|
||||||
|
scope.launch {
|
||||||
|
pagerState.scrollToPage((currentIndex + 1).coerceAtMost(mediaItems.lastIndex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dragOffsetPx < -dragStepPx / 2f && activeIndex > 0 -> {
|
||||||
|
onUpdateItems(mediaItems.move(activeIndex, activeIndex - 1))
|
||||||
|
draggingIndex = activeIndex - 1
|
||||||
|
dragOffsetPx += dragStepPx
|
||||||
|
scope.launch {
|
||||||
|
pagerState.scrollToPage((currentIndex - 1).coerceAtLeast(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (item.mimeType.startsWith("video/", ignoreCase = true)) {
|
if (item.mimeType.startsWith("video/", ignoreCase = true)) {
|
||||||
@@ -3202,6 +3312,27 @@ private fun MediaBatchPreviewSheet(
|
|||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = Color.Black.copy(alpha = 0.52f),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(20.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable {
|
||||||
|
onUpdateItems(mediaItems.removeAt(index))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = stringResource(id = R.string.common_delete),
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3285,11 +3416,53 @@ private fun List<PickedMediaPayload>.move(fromIndex: Int, toIndex: Int): List<Pi
|
|||||||
return mutable.toList()
|
return mutable.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun List<PickedMediaPayload>.updatedAt(index: Int, item: PickedMediaPayload): List<PickedMediaPayload> {
|
||||||
|
if (index !in indices) return this
|
||||||
|
val mutable = toMutableList()
|
||||||
|
mutable[index] = item
|
||||||
|
return mutable.toList()
|
||||||
|
}
|
||||||
|
|
||||||
private fun List<PickedMediaPayload>.removeAt(index: Int): List<PickedMediaPayload> {
|
private fun List<PickedMediaPayload>.removeAt(index: Int): List<PickedMediaPayload> {
|
||||||
if (index !in indices) return this
|
if (index !in indices) return this
|
||||||
return filterIndexed { currentIndex, _ -> currentIndex != index }
|
return filterIndexed { currentIndex, _ -> currentIndex != index }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val PickedMediaPayload.isEditableStaticImage: Boolean
|
||||||
|
get() = mimeType.startsWith("image/", ignoreCase = true) && !mimeType.contains("gif", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun PickedMediaPayload.rotateClockwise(): PickedMediaPayload? {
|
||||||
|
if (!isEditableStaticImage) return this
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||||
|
val matrix = Matrix().apply { postRotate(90f) }
|
||||||
|
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||||
|
return rotated.toEditedImagePayload(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun PickedMediaPayload.cropCenterSquare(): PickedMediaPayload? {
|
||||||
|
if (!isEditableStaticImage) return this
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||||
|
val side = minOf(bitmap.width, bitmap.height)
|
||||||
|
val left = ((bitmap.width - side) / 2).coerceAtLeast(0)
|
||||||
|
val top = ((bitmap.height - side) / 2).coerceAtLeast(0)
|
||||||
|
val cropped = Bitmap.createBitmap(bitmap, left, top, side, side)
|
||||||
|
return cropped.toEditedImagePayload(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bitmap.toEditedImagePayload(originalFileName: String): PickedMediaPayload? {
|
||||||
|
val stream = java.io.ByteArrayOutputStream()
|
||||||
|
val ok = compress(Bitmap.CompressFormat.JPEG, 92, stream)
|
||||||
|
if (!ok) return null
|
||||||
|
val bytes = stream.toByteArray()
|
||||||
|
if (bytes.isEmpty()) return null
|
||||||
|
val baseName = originalFileName.substringBeforeLast('.').ifBlank { "photo_${System.currentTimeMillis()}" }
|
||||||
|
return PickedMediaPayload(
|
||||||
|
fileName = "${baseName}.jpg",
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
bytes = bytes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CameraCaptureDialog(
|
private fun CameraCaptureDialog(
|
||||||
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
||||||
@@ -3980,6 +4153,55 @@ private fun Uri.readMediaPayload(context: Context): PickedMediaPayload? {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberRemoteMediaAspectRatio(
|
||||||
|
url: String,
|
||||||
|
isVideo: Boolean,
|
||||||
|
fallback: Float = 1f,
|
||||||
|
): Float {
|
||||||
|
val aspectRatio = produceState(initialValue = fallback, url, isVideo) {
|
||||||
|
value = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
if (isVideo) {
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
retriever.setDataSource(url, emptyMap())
|
||||||
|
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull()
|
||||||
|
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull()
|
||||||
|
retriever.release()
|
||||||
|
if (width != null && height != null && height > 0f) width / height else fallback
|
||||||
|
} else {
|
||||||
|
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
java.net.URL(url).openStream().use { input ->
|
||||||
|
BitmapFactory.decodeStream(input, null, options)
|
||||||
|
}
|
||||||
|
val width = options.outWidth.toFloat()
|
||||||
|
val height = options.outHeight.toFloat()
|
||||||
|
if (width > 0f && height > 0f) width / height else fallback
|
||||||
|
}
|
||||||
|
}.getOrDefault(fallback)
|
||||||
|
}.coerceIn(0.6f, 1.8f)
|
||||||
|
}
|
||||||
|
return aspectRatio.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mediaPreviewSizeModifier(
|
||||||
|
ratio: Float,
|
||||||
|
maxWidth: Dp,
|
||||||
|
maxHeight: Dp,
|
||||||
|
): Modifier {
|
||||||
|
val boundedRatio = ratio.coerceIn(0.6f, 1.8f)
|
||||||
|
val maxFrameRatio = maxWidth.value / maxHeight.value
|
||||||
|
return if (boundedRatio >= maxFrameRatio) {
|
||||||
|
Modifier
|
||||||
|
.width(maxWidth)
|
||||||
|
.aspectRatio(boundedRatio)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
.height(maxHeight)
|
||||||
|
.aspectRatio(boundedRatio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
private fun MessageBubble(
|
private fun MessageBubble(
|
||||||
@@ -4211,6 +4433,11 @@ private fun MessageBubble(
|
|||||||
mediaBadgeLabel(fileType = "image/url", url = textUrl, context = context)
|
mediaBadgeLabel(fileType = "image/url", url = textUrl, context = context)
|
||||||
}
|
}
|
||||||
val openable = !isLegacyStickerMessage && badgeLabel == null
|
val openable = !isLegacyStickerMessage && badgeLabel == null
|
||||||
|
val legacyImageAspectRatio = if (isLegacyStickerMessage) {
|
||||||
|
1f
|
||||||
|
} else {
|
||||||
|
rememberRemoteMediaAspectRatio(url = textUrl, isVideo = false)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.then(
|
.then(
|
||||||
@@ -4218,9 +4445,11 @@ private fun MessageBubble(
|
|||||||
Modifier
|
Modifier
|
||||||
.size(176.dp)
|
.size(176.dp)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
mediaPreviewSizeModifier(
|
||||||
.widthIn(max = singleMediaMaxWidth)
|
ratio = legacyImageAspectRatio,
|
||||||
.heightIn(max = singleMediaMaxHeight)
|
maxWidth = singleMediaMaxWidth,
|
||||||
|
maxHeight = singleMediaMaxHeight,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
@@ -4279,6 +4508,8 @@ private fun MessageBubble(
|
|||||||
VideoAttachmentCard(
|
VideoAttachmentCard(
|
||||||
url = textUrl,
|
url = textUrl,
|
||||||
fileType = message.type,
|
fileType = message.type,
|
||||||
|
maxWidth = singleMediaMaxWidth,
|
||||||
|
maxHeight = singleMediaMaxHeight,
|
||||||
onOpenViewer = { onAttachmentVideoClick(textUrl) },
|
onOpenViewer = { onAttachmentVideoClick(textUrl) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4316,6 +4547,11 @@ private fun MessageBubble(
|
|||||||
mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl, context = context)
|
mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl, context = context)
|
||||||
}
|
}
|
||||||
val openable = !isStickerImage && badgeLabel == null
|
val openable = !isStickerImage && badgeLabel == null
|
||||||
|
val singleImageAspectRatio = if (isStickerImage) {
|
||||||
|
1f
|
||||||
|
} else {
|
||||||
|
rememberRemoteMediaAspectRatio(url = single.fileUrl, isVideo = false)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.then(
|
.then(
|
||||||
@@ -4323,9 +4559,11 @@ private fun MessageBubble(
|
|||||||
Modifier
|
Modifier
|
||||||
.size(176.dp)
|
.size(176.dp)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
mediaPreviewSizeModifier(
|
||||||
.widthIn(max = singleMediaMaxWidth)
|
ratio = singleImageAspectRatio,
|
||||||
.heightIn(max = singleMediaMaxHeight)
|
maxWidth = singleMediaMaxWidth,
|
||||||
|
maxHeight = singleMediaMaxHeight,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
@@ -4432,6 +4670,8 @@ private fun MessageBubble(
|
|||||||
VideoAttachmentCard(
|
VideoAttachmentCard(
|
||||||
url = attachment.fileUrl,
|
url = attachment.fileUrl,
|
||||||
fileType = attachment.fileType,
|
fileType = attachment.fileType,
|
||||||
|
maxWidth = singleMediaMaxWidth,
|
||||||
|
maxHeight = singleMediaMaxHeight,
|
||||||
onOpenViewer = { onAttachmentVideoClick(attachment.fileUrl) },
|
onOpenViewer = { onAttachmentVideoClick(attachment.fileUrl) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4518,9 +4758,12 @@ private fun MessageBubble(
|
|||||||
private fun VideoAttachmentCard(
|
private fun VideoAttachmentCard(
|
||||||
url: String,
|
url: String,
|
||||||
fileType: String,
|
fileType: String,
|
||||||
|
maxWidth: Dp,
|
||||||
|
maxHeight: Dp,
|
||||||
onOpenViewer: () -> Unit,
|
onOpenViewer: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val aspectRatio = rememberRemoteMediaAspectRatio(url = url, isVideo = true, fallback = 1f)
|
||||||
val previewRequest = remember(url) {
|
val previewRequest = remember(url) {
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(url)
|
.data(url)
|
||||||
@@ -4530,15 +4773,20 @@ private fun VideoAttachmentCard(
|
|||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.widthIn(max = maxWidth)
|
||||||
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
|
||||||
.clickable(onClick = onOpenViewer)
|
.clickable(onClick = onOpenViewer)
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.then(
|
||||||
.height(186.dp)
|
mediaPreviewSizeModifier(
|
||||||
|
ratio = aspectRatio,
|
||||||
|
maxWidth = maxWidth - 16.dp,
|
||||||
|
maxHeight = maxHeight,
|
||||||
|
),
|
||||||
|
)
|
||||||
.clip(RoundedCornerShape(10.dp)),
|
.clip(RoundedCornerShape(10.dp)),
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
@@ -4627,10 +4875,17 @@ private fun CircleVideoAttachmentPlayer(
|
|||||||
LaunchedEffect(playerState.isPlaying) {
|
LaunchedEffect(playerState.isPlaying) {
|
||||||
emitTopStrip(playerState.isPlaying)
|
emitTopStrip(playerState.isPlaying)
|
||||||
}
|
}
|
||||||
val progress = if (playerState.durationMs > 0L) {
|
LaunchedEffect(playerState.positionMs, playerState.isPlaying) {
|
||||||
(playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
|
if (!playerState.isPlaying && playerState.positionMs == 0L) {
|
||||||
} else {
|
emitTopStrip(false)
|
||||||
0f
|
}
|
||||||
|
}
|
||||||
|
val progress = remember(playerState.positionMs, playerState.durationMs, playerState.isPlaying) {
|
||||||
|
when {
|
||||||
|
playerState.durationMs <= 0L -> 0f
|
||||||
|
!playerState.isPlaying && playerState.positionMs <= 0L -> 0f
|
||||||
|
else -> (playerState.positionMs.toFloat() / playerState.durationMs.toFloat()).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val progressTrackColor = Color.White.copy(alpha = 0.22f)
|
val progressTrackColor = Color.White.copy(alpha = 0.22f)
|
||||||
val progressActiveColor = MaterialTheme.colorScheme.primary
|
val progressActiveColor = MaterialTheme.colorScheme.primary
|
||||||
|
|||||||
@@ -364,4 +364,10 @@
|
|||||||
<string name="chat_media_preview_caption_hint">Добавить подпись</string>
|
<string name="chat_media_preview_caption_hint">Добавить подпись</string>
|
||||||
<string name="chat_media_preview_move_left">Переместить влево</string>
|
<string name="chat_media_preview_move_left">Переместить влево</string>
|
||||||
<string name="chat_media_preview_move_right">Переместить вправо</string>
|
<string name="chat_media_preview_move_right">Переместить вправо</string>
|
||||||
|
<string name="chat_media_preview_crop_square">Обрезать в квадрат</string>
|
||||||
|
<string name="chat_media_preview_rotate">Повернуть</string>
|
||||||
|
<string name="chat_media_preview_count_badge">%1$d из %2$d</string>
|
||||||
|
<string name="chat_media_preview_reorder_hint">Удерживай и перетаскивай миниатюры для сортировки. Нажми X, чтобы удалить.</string>
|
||||||
|
<string name="chat_private_info_contact_badge">Контакт</string>
|
||||||
|
<string name="chat_private_info_blocked_badge">Заблокирован</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -364,4 +364,10 @@
|
|||||||
<string name="chat_media_preview_caption_hint">Add a caption</string>
|
<string name="chat_media_preview_caption_hint">Add a caption</string>
|
||||||
<string name="chat_media_preview_move_left">Move left</string>
|
<string name="chat_media_preview_move_left">Move left</string>
|
||||||
<string name="chat_media_preview_move_right">Move right</string>
|
<string name="chat_media_preview_move_right">Move right</string>
|
||||||
|
<string name="chat_media_preview_crop_square">Crop square</string>
|
||||||
|
<string name="chat_media_preview_rotate">Rotate</string>
|
||||||
|
<string name="chat_media_preview_count_badge">%1$d of %2$d</string>
|
||||||
|
<string name="chat_media_preview_reorder_hint">Hold and drag thumbnails to reorder. Tap X to remove.</string>
|
||||||
|
<string name="chat_private_info_contact_badge">Contact</string>
|
||||||
|
<string name="chat_private_info_blocked_badge">Blocked</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user