Чат, правки
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import chats.presentation.components.EmojiPicker
|
import chats.presentation.components.EmojiPicker
|
||||||
import chats.presentation.components.MessageBubble
|
import chats.presentation.components.MessageBubble
|
||||||
import core.presentation.components.AppAvatar
|
import core.presentation.components.AppAvatar
|
||||||
|
import core.presentation.components.AppMediaLightbox
|
||||||
import core.utils.VoiceRecorder
|
import core.utils.VoiceRecorder
|
||||||
import core.utils.copyUriToFile
|
import core.utils.copyUriToFile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -46,6 +47,9 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
var autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
|
var autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
|
||||||
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
|
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
|
|
||||||
|
var selectedMediaList by remember { mutableStateOf<List<chats.domain.model.Media>?>(null) }
|
||||||
|
var initialMediaIndex by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val playNextVoiceMessage = { currentId: String, speed: Float ->
|
val playNextVoiceMessage = { currentId: String, speed: Float ->
|
||||||
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
|
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
|
||||||
@@ -150,14 +154,27 @@ fun ChatDetailScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(state.messages) { message ->
|
val allChatMedia = state.messages.flatMap { msg ->
|
||||||
|
msg.media.filter {
|
||||||
|
it.type.startsWith("image") ||
|
||||||
|
it.type.startsWith("video") ||
|
||||||
|
it.filename?.endsWith(".gif", true) == true
|
||||||
|
}.map { it to msg.id }
|
||||||
|
}.reversed()
|
||||||
|
|
||||||
|
items(state.messages, key = { it.id }) { message ->
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
message = message,
|
message = message,
|
||||||
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
|
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
|
||||||
autoPlay = message.id == autoPlayingMessageId,
|
autoPlay = message.id == autoPlayingMessageId,
|
||||||
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
||||||
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
|
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
|
||||||
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) }
|
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) },
|
||||||
|
onMediaClick = { clickedMedia ->
|
||||||
|
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||||
|
selectedMediaList = allChatMedia.map { it.first }
|
||||||
|
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,5 +250,14 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Просмотрщик медиа
|
||||||
|
selectedMediaList?.let { list ->
|
||||||
|
AppMediaLightbox(
|
||||||
|
mediaList = list,
|
||||||
|
initialIndex = initialMediaIndex,
|
||||||
|
onClose = { selectedMediaList = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import ru.knot.messager.R
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -46,7 +48,8 @@ fun MessageBubble(
|
|||||||
onReactionClick: (String) -> Unit = {},
|
onReactionClick: (String) -> Unit = {},
|
||||||
autoPlay: Boolean = false,
|
autoPlay: Boolean = false,
|
||||||
initialPlaybackSpeed: Float = 1.0f,
|
initialPlaybackSpeed: Float = 1.0f,
|
||||||
onVoiceFinished: (Float) -> Unit = {}
|
onVoiceFinished: (Float) -> Unit = {},
|
||||||
|
onMediaClick: (chats.domain.model.Media) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showReactionPicker by remember { mutableStateOf(false) }
|
var showReactionPicker by remember { mutableStateOf(false) }
|
||||||
@@ -145,34 +148,40 @@ fun MessageBubble(
|
|||||||
if (mediaCount == 1) {
|
if (mediaCount == 1) {
|
||||||
val mediaItem = message.media[0]
|
val mediaItem = message.media[0]
|
||||||
when {
|
when {
|
||||||
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
|
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE || mediaItem.type.startsWith("image") -> {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = mediaItem.url,
|
model = mediaItem.url,
|
||||||
|
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.heightIn(max = 300.dp)
|
||||||
.clickable { lightboxMediaIndex = 0 },
|
.clip(RoundedCornerShape(8.dp))
|
||||||
contentScale = ContentScale.FillWidth
|
.clickable { onMediaClick(mediaItem) },
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
mediaItem.type.startsWith("video") || message.mediaType == MediaType.VIDEO -> {
|
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(200.dp)
|
.height(240.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(Color.Black)
|
|
||||||
.clickable { lightboxMediaIndex = 0 },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
AppVideoPlayer(
|
AppVideoPlayer(
|
||||||
url = mediaItem.url,
|
url = mediaItem.url,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
useController = false,
|
useController = false,
|
||||||
autoPlay = true,
|
isMuted = true,
|
||||||
isMuted = true
|
autoPlay = true
|
||||||
)
|
)
|
||||||
|
// Прозрачный слой поверх видео для клика
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Transparent)
|
||||||
|
.clickable { onMediaClick(mediaItem) }
|
||||||
|
)
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.VolumeOff,
|
imageVector = Icons.Default.VolumeOff,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -181,7 +190,7 @@ fun MessageBubble(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
|
mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp2", true) == true || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
|
||||||
AppAudioPlayer(
|
AppAudioPlayer(
|
||||||
url = mediaItem.url,
|
url = mediaItem.url,
|
||||||
name = mediaItem.filename,
|
name = mediaItem.filename,
|
||||||
@@ -201,7 +210,7 @@ fun MessageBubble(
|
|||||||
// Multi-media grid
|
// Multi-media grid
|
||||||
PhotoGrid(
|
PhotoGrid(
|
||||||
message.media,
|
message.media,
|
||||||
onMediaClick = { index -> lightboxMediaIndex = index },
|
onMediaClick = { index -> onMediaClick(message.media[index]) },
|
||||||
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
|
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.compose.animation.*
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||||
|
import androidx.compose.foundation.gestures.transformable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -18,6 +20,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@@ -60,11 +63,12 @@ fun AppMediaLightbox(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (media.type.startsWith("video")) {
|
if (media.type.startsWith("video")) {
|
||||||
|
val isCurrentPage = pagerState.currentPage == page
|
||||||
AppVideoPlayer(
|
AppVideoPlayer(
|
||||||
url = media.url,
|
url = media.url,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
useController = true,
|
useController = true,
|
||||||
autoPlay = true
|
autoPlay = isCurrentPage // Только если страница активна
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
@@ -72,21 +76,10 @@ fun AppMediaLightbox(
|
|||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = media.url,
|
model = media.url,
|
||||||
|
imageLoader = core.utils.CoilUtils.getGifImageLoader(LocalContext.current),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTransformGestures(
|
|
||||||
onGesture = { _, pan, zoom, _ ->
|
|
||||||
scale = (scale * zoom).coerceIn(1f, 5f)
|
|
||||||
if (scale > 1f) {
|
|
||||||
offset += pan
|
|
||||||
} else {
|
|
||||||
offset = androidx.compose.ui.geometry.Offset.Zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.graphicsLayer(
|
.graphicsLayer(
|
||||||
scaleX = scale,
|
scaleX = scale,
|
||||||
scaleY = scale,
|
scaleY = scale,
|
||||||
|
|||||||
@@ -89,21 +89,22 @@ fun AppVideoPlayer(
|
|||||||
factory = {
|
factory = {
|
||||||
PlayerView(it).apply {
|
PlayerView(it).apply {
|
||||||
player = exoPlayer
|
player = exoPlayer
|
||||||
this.useController = false // Use our custom UI
|
this.useController = false // We use our own UI if needed
|
||||||
this.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
setBackgroundColor(android.graphics.Color.BLACK)
|
setBackgroundColor(android.graphics.Color.BLACK)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize().clickable(
|
modifier = Modifier.fillMaxSize().clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null,
|
||||||
|
enabled = useController // Only clickable if we have controls
|
||||||
) {
|
) {
|
||||||
isControlsVisible = !isControlsVisible
|
isControlsVisible = !isControlsVisible
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isControlsVisible,
|
visible = isControlsVisible && useController,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut()
|
exit = fadeOut()
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package core.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.decode.GifDecoder
|
||||||
|
import coil.decode.ImageDecoderDecoder
|
||||||
|
|
||||||
|
object CoilUtils {
|
||||||
|
private var gifImageLoader: ImageLoader? = null
|
||||||
|
|
||||||
|
fun getGifImageLoader(context: Context): ImageLoader {
|
||||||
|
if (gifImageLoader == null) {
|
||||||
|
gifImageLoader = ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
return gifImageLoader!!
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user