diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index e28a413..2a101cf 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 5954663..200f1b7 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index 0b6f5b8..f67fedc 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 4f8ef11..e41221d 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index edb116b..b745ddf 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index ec5f104..bef2ac7 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index c5aa896..f00f540 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index 26ffbf4..d46e6fb 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import chats.presentation.components.EmojiPicker import chats.presentation.components.MessageBubble import core.presentation.components.AppAvatar +import core.presentation.components.AppMediaLightbox import core.utils.VoiceRecorder import core.utils.copyUriToFile import java.io.File @@ -46,6 +47,9 @@ fun ChatDetailScreen( var autoPlayingMessageId by remember { mutableStateOf(null) } var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) } + + var selectedMediaList by remember { mutableStateOf?>(null) } + var initialMediaIndex by remember { mutableIntStateOf(0) } val playNextVoiceMessage = { currentId: String, speed: Float -> val currentIndex = state.messages.indexOfFirst { it.id == currentId } @@ -150,14 +154,27 @@ fun ChatDetailScreen( contentPadding = PaddingValues(16.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( message = message, isCurrentUser = message.senderId == viewModel.getCurrentUserId(), autoPlay = message.id == autoPlayingMessageId, initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f, 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 } + ) + } } } diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index 92d9667..a2a0ea5 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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.dp import androidx.compose.ui.unit.sp @@ -46,7 +48,8 @@ fun MessageBubble( onReactionClick: (String) -> Unit = {}, autoPlay: Boolean = false, initialPlaybackSpeed: Float = 1.0f, - onVoiceFinished: (Float) -> Unit = {} + onVoiceFinished: (Float) -> Unit = {}, + onMediaClick: (chats.domain.model.Media) -> Unit = {} ) { val context = LocalContext.current var showReactionPicker by remember { mutableStateOf(false) } @@ -145,34 +148,40 @@ fun MessageBubble( if (mediaCount == 1) { val mediaItem = message.media[0] when { - mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> { + mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE || mediaItem.type.startsWith("image") -> { AsyncImage( model = mediaItem.url, + imageLoader = core.utils.CoilUtils.getGifImageLoader(context), contentDescription = null, modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { lightboxMediaIndex = 0 }, - contentScale = ContentScale.FillWidth + .heightIn(max = 300.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { onMediaClick(mediaItem) }, + contentScale = ContentScale.Crop ) } - mediaItem.type.startsWith("video") || message.mediaType == MediaType.VIDEO -> { + message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> { Box( modifier = Modifier .fillMaxWidth() - .height(200.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color.Black) - .clickable { lightboxMediaIndex = 0 }, - contentAlignment = Alignment.Center + .height(240.dp) + .clip(RoundedCornerShape(8.dp)) ) { AppVideoPlayer( url = mediaItem.url, modifier = Modifier.fillMaxSize(), useController = false, - autoPlay = true, - isMuted = true + isMuted = true, + autoPlay = true ) + // Прозрачный слой поверх видео для клика + Box(modifier = Modifier + .fillMaxSize() + .background(Color.Transparent) + .clickable { onMediaClick(mediaItem) } + ) + Icon( imageVector = Icons.Default.VolumeOff, 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( url = mediaItem.url, name = mediaItem.filename, @@ -201,7 +210,7 @@ fun MessageBubble( // Multi-media grid PhotoGrid( message.media, - onMediaClick = { index -> lightboxMediaIndex = index }, + onMediaClick = { index -> onMediaClick(message.media[index]) }, modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp)) ) } diff --git a/client-mobile/core/presentation/components/AppMediaLightbox.kt b/client-mobile/core/presentation/components/AppMediaLightbox.kt index d52da33..248a13d 100644 --- a/client-mobile/core/presentation/components/AppMediaLightbox.kt +++ b/client-mobile/core/presentation/components/AppMediaLightbox.kt @@ -4,6 +4,8 @@ import androidx.compose.animation.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.shape.CircleShape 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.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -60,11 +63,12 @@ fun AppMediaLightbox( contentAlignment = Alignment.Center ) { if (media.type.startsWith("video")) { + val isCurrentPage = pagerState.currentPage == page AppVideoPlayer( url = media.url, modifier = Modifier.fillMaxSize(), useController = true, - autoPlay = true + autoPlay = isCurrentPage // Только если страница активна ) } else { var scale by remember { mutableFloatStateOf(1f) } @@ -72,21 +76,10 @@ fun AppMediaLightbox( AsyncImage( model = media.url, + imageLoader = core.utils.CoilUtils.getGifImageLoader(LocalContext.current), contentDescription = null, modifier = Modifier .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( scaleX = scale, scaleY = scale, diff --git a/client-mobile/core/presentation/components/AppVideoPlayer.kt b/client-mobile/core/presentation/components/AppVideoPlayer.kt index cf252d6..9d2659e 100644 --- a/client-mobile/core/presentation/components/AppVideoPlayer.kt +++ b/client-mobile/core/presentation/components/AppVideoPlayer.kt @@ -89,21 +89,22 @@ fun AppVideoPlayer( factory = { PlayerView(it).apply { player = exoPlayer - this.useController = false // Use our custom UI - this.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + this.useController = false // We use our own UI if needed + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT setBackgroundColor(android.graphics.Color.BLACK) } }, modifier = Modifier.fillMaxSize().clickable( interactionSource = remember { MutableInteractionSource() }, - indication = null + indication = null, + enabled = useController // Only clickable if we have controls ) { isControlsVisible = !isControlsVisible } ) AnimatedVisibility( - visible = isControlsVisible, + visible = isControlsVisible && useController, enter = fadeIn(), exit = fadeOut() ) { diff --git a/client-mobile/core/utils/CoilUtils.kt b/client-mobile/core/utils/CoilUtils.kt new file mode 100644 index 0000000..ee4e4c1 --- /dev/null +++ b/client-mobile/core/utils/CoilUtils.kt @@ -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!! + } +}