diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 9ae3d42..093ed08 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -1958,318 +1958,362 @@ fun ChatScreen( } } if (viewerVideoUrl != null) { - val videoUrl = viewerVideoUrl.orEmpty() - var videoViewRef by remember(videoUrl) { mutableStateOf(null) } - var videoPrepared by remember(videoUrl) { mutableStateOf(false) } - var videoDurationMs by remember(videoUrl) { mutableStateOf(0) } - var videoPositionMs by remember(videoUrl) { mutableStateOf(0) } - var videoPlaying by remember(videoUrl) { mutableStateOf(true) } - var isVideoSeeking by remember(videoUrl) { mutableStateOf(false) } - var videoSeekFraction by remember(videoUrl) { mutableStateOf(0f) } + VideoViewerOverlay( + videoUrl = viewerVideoUrl.orEmpty(), + onDismiss = { viewerVideoUrl = null }, + ) + } + if (showEmojiPicker) { + EmojiPickerSheet( + sheetState = actionSheetState, + emojiPickerTab = emojiPickerTab, + onEmojiPickerTabChange = { emojiPickerTab = it }, + gifSearchQuery = gifSearchQuery, + onGifSearchQueryChange = { gifSearchQuery = it }, + giphySearchItems = giphySearchItems, + isGiphyLoading = isGiphyLoading, + giphyErrorMessage = giphyErrorMessage, + onDismiss = { showEmojiPicker = false }, + onEmojiSelected = { emoji -> + composerValue = composerValue.insertAtCursor(emoji) + onInputChanged(composerValue.text) + }, + onSendPresetMediaUrl = onSendPresetMediaUrl, + onSendRemoteMedia = onSendRemoteMedia, + ) + } + } +} - DisposableEffect(videoUrl) { - onDispose { - runCatching { videoViewRef?.stopPlayback() } - videoViewRef = null - } - } +@Composable +private fun VideoViewerOverlay( + videoUrl: String, + onDismiss: () -> Unit, +) { + var videoViewRef by remember(videoUrl) { mutableStateOf(null) } + var videoPrepared by remember(videoUrl) { mutableStateOf(false) } + var videoDurationMs by remember(videoUrl) { mutableStateOf(0) } + var videoPositionMs by remember(videoUrl) { mutableStateOf(0) } + var videoPlaying by remember(videoUrl) { mutableStateOf(true) } + var isVideoSeeking by remember(videoUrl) { mutableStateOf(false) } + var videoSeekFraction by remember(videoUrl) { mutableStateOf(0f) } - LaunchedEffect(videoPlaying, videoPrepared, isVideoSeeking, videoUrl) { - if (!videoPlaying || !videoPrepared || isVideoSeeking) return@LaunchedEffect - while (videoPlaying && viewerVideoUrl == videoUrl && !isVideoSeeking) { - videoPositionMs = runCatching { videoViewRef?.currentPosition ?: videoPositionMs } - .getOrDefault(videoPositionMs) - delay(250) - } - } + DisposableEffect(videoUrl) { + onDispose { + runCatching { videoViewRef?.stopPlayback() } + videoViewRef = null + } + } - Surface( + LaunchedEffect(videoPlaying, videoPrepared, isVideoSeeking, videoUrl) { + if (!videoPlaying || !videoPrepared || isVideoSeeking) return@LaunchedEffect + while (videoPlaying && !isVideoSeeking) { + videoPositionMs = runCatching { videoViewRef?.currentPosition ?: videoPositionMs } + .getOrDefault(videoPositionMs) + delay(250) + } + } + + Surface( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.78f)), + color = Color.Black.copy(alpha = 0.9f), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.78f)), - color = Color.Black.copy(alpha = 0.9f), + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.End, ) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceBetween, + IconButton(onClick = onDismiss) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "Close video viewer") + } + } + AndroidView( + factory = { context -> + VideoView(context).apply { + setVideoPath(videoUrl) + videoViewRef = this + setOnPreparedListener { player -> + player.isLooping = false + videoPrepared = true + videoDurationMs = runCatching { duration }.getOrDefault(player.duration.coerceAtLeast(0)) + start() + videoPlaying = true + } + setOnCompletionListener { + videoPlaying = false + videoPositionMs = videoDurationMs + } + } + }, + update = { view -> + videoViewRef = view + if (videoPlaying && videoPrepared && !isVideoSeeking && !view.isPlaying) { + runCatching { view.start() } + } + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 10.dp), + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - IconButton(onClick = { viewerVideoUrl = null }) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "Close video viewer") - } - } - AndroidView( - factory = { context -> - VideoView(context).apply { - setVideoPath(videoUrl) - videoViewRef = this - setOnPreparedListener { player -> - player.isLooping = false - videoPrepared = true - videoDurationMs = runCatching { duration }.getOrDefault(player.duration.coerceAtLeast(0)) - start() - videoPlaying = true - } - setOnCompletionListener { - videoPlaying = false - videoPositionMs = videoDurationMs - } - } - }, - update = { view -> - videoViewRef = view - if (videoPlaying && videoPrepared && !isVideoSeeking && !view.isPlaying) { + IconButton( + onClick = { + val view = videoViewRef ?: return@IconButton + if (videoPlaying) { + runCatching { view.pause() } + videoPlaying = false + } else { runCatching { view.start() } + videoPlaying = true } }, - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = 10.dp), - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + enabled = videoPrepared, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - onClick = { - val view = videoViewRef ?: return@IconButton - if (videoPlaying) { - runCatching { view.pause() } - videoPlaying = false - } else { - runCatching { view.start() } - videoPlaying = true - } - }, - enabled = videoPrepared, - ) { - Icon( - imageVector = if (videoPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - contentDescription = if (videoPlaying) "Pause video" else "Play video", - tint = Color.White, - ) - } - Text( - text = extractFileName(videoUrl), - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyMedium, - color = Color.White, - maxLines = 1, - ) - } - - val videoProgress = if (videoDurationMs <= 0) 0f else { - (videoPositionMs.toFloat() / videoDurationMs.toFloat()).coerceIn(0f, 1f) - } - Slider( - value = if (isVideoSeeking) videoSeekFraction else videoProgress, - onValueChange = { fraction -> - isVideoSeeking = true - videoSeekFraction = fraction.coerceIn(0f, 1f) - }, - onValueChangeFinished = { - val targetMs = (videoDurationMs * videoSeekFraction).roundToInt() - runCatching { videoViewRef?.seekTo(targetMs) } - videoPositionMs = targetMs - isVideoSeeking = false - }, - enabled = videoPrepared && videoDurationMs > 0, + Icon( + imageVector = if (videoPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (videoPlaying) "Pause video" else "Play video", + tint = Color.White, ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatDuration(videoPositionMs), - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.85f), - ) - Text( - text = if (videoDurationMs > 0) formatDuration(videoDurationMs) else "--:--", - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.85f), - ) - } } + Text( + text = extractFileName(videoUrl), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + maxLines = 1, + ) + } + + val videoProgress = if (videoDurationMs <= 0) 0f else { + (videoPositionMs.toFloat() / videoDurationMs.toFloat()).coerceIn(0f, 1f) + } + Slider( + value = if (isVideoSeeking) videoSeekFraction else videoProgress, + onValueChange = { fraction -> + isVideoSeeking = true + videoSeekFraction = fraction.coerceIn(0f, 1f) + }, + onValueChangeFinished = { + val targetMs = (videoDurationMs * videoSeekFraction).roundToInt() + runCatching { videoViewRef?.seekTo(targetMs) } + videoPositionMs = targetMs + isVideoSeeking = false + }, + enabled = videoPrepared && videoDurationMs > 0, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatDuration(videoPositionMs), + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.85f), + ) + Text( + text = if (videoDurationMs > 0) formatDuration(videoDurationMs) else "--:--", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.85f), + ) } } } - if (showEmojiPicker) { - ModalBottomSheet( - onDismissRequest = { if (!isPickerSending) showEmojiPicker = false }, - sheetState = actionSheetState, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ComposerPickerTab.entries.forEach { tab -> - val selected = emojiPickerTab == tab + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EmojiPickerSheet( + sheetState: androidx.compose.material3.SheetState, + emojiPickerTab: ComposerPickerTab, + onEmojiPickerTabChange: (ComposerPickerTab) -> Unit, + gifSearchQuery: String, + onGifSearchQueryChange: (String) -> Unit, + giphySearchItems: List, + isGiphyLoading: Boolean, + giphyErrorMessage: String?, + onDismiss: () -> Unit, + onEmojiSelected: (String) -> Unit, + onSendPresetMediaUrl: (String) -> Unit, + onSendRemoteMedia: (String, String, ByteArray) -> Unit, +) { + val scope = rememberCoroutineScope() + var isPickerSending by remember { mutableStateOf(false) } + ModalBottomSheet( + onDismissRequest = { if (!isPickerSending) onDismiss() }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ComposerPickerTab.entries.forEach { tab -> + val selected = emojiPickerTab == tab + Surface( + shape = RoundedCornerShape(16.dp), + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.22f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.52f) + }, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable { onEmojiPickerTabChange(tab) }, + ) { + Text( + text = stringResource(id = tab.titleRes), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + when (emojiPickerTab) { + ComposerPickerTab.Emoji -> { + val emojiSet = listOf( + "😀", "😁", "😂", "🤣", "😊", "😍", "😘", "😎", + "🤔", "🙃", "😴", "😡", "🥳", "😭", "👍", "👎", + "🔥", "❤️", "💔", "🙏", "🎉", "🤝", "💯", "✅", + ) + LazyVerticalGrid( + columns = GridCells.Fixed(8), + modifier = Modifier + .fillMaxWidth() + .height(240.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(emojiSet) { emoji -> Surface( - shape = RoundedCornerShape(16.dp), - color = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.22f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.52f) - }, + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .clickable { emojiPickerTab = tab }, + .clip(RoundedCornerShape(10.dp)) + .clickable { onEmojiSelected(emoji) }, ) { - Text( - text = stringResource(id = tab.titleRes), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - when (emojiPickerTab) { - ComposerPickerTab.Emoji -> { - val emojiSet = listOf( - "😀", "😁", "😂", "🤣", "😊", "😍", "😘", "😎", - "🤔", "🙃", "😴", "😡", "🥳", "😭", "👍", "👎", - "🔥", "❤️", "💔", "🙏", "🎉", "🤝", "💯", "✅", - ) - LazyVerticalGrid( - columns = GridCells.Fixed(8), - modifier = Modifier - .fillMaxWidth() - .height(240.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(emojiSet) { emoji -> - Surface( - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .clickable { - composerValue = composerValue.insertAtCursor(emoji) - onInputChanged(composerValue.text) - }, - ) { - Box(modifier = Modifier.padding(vertical = 8.dp), contentAlignment = Alignment.Center) { - Text(text = emoji) - } - } + Box(modifier = Modifier.padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + Text(text = emoji) } } } + } + } - ComposerPickerTab.Gif, - ComposerPickerTab.Sticker, - -> { - val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) { - if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems - } else { - defaultStickerItems - } - Column( + ComposerPickerTab.Gif, + ComposerPickerTab.Sticker, + -> { + val remoteItems = if (emojiPickerTab == ComposerPickerTab.Gif) { + if (gifSearchQuery.isBlank()) defaultGifItems else giphySearchItems + } else { + defaultStickerItems + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (emojiPickerTab == ComposerPickerTab.Gif) { + OutlinedTextField( + value = gifSearchQuery, + onValueChange = onGifSearchQueryChange, modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (emojiPickerTab == ComposerPickerTab.Gif) { - OutlinedTextField( - value = gifSearchQuery, - onValueChange = { gifSearchQuery = it }, + singleLine = true, + placeholder = { Text(stringResource(id = R.string.chat_search_gifs)) }, + ) + when { + isGiphyLoading -> { + Row( modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text(stringResource(id = R.string.chat_search_gifs)) }, - ) - when { - isGiphyLoading -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } - } - - !giphyErrorMessage.isNullOrBlank() -> { - Text( - text = giphyErrorMessage.orEmpty(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } } - LazyVerticalGrid( - columns = GridCells.Fixed(3), + + !giphyErrorMessage.isNullOrBlank() -> { + Text( + text = giphyErrorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + LazyVerticalGrid( + columns = GridCells.Fixed(3), + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(remoteItems) { remote -> + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), modifier = Modifier - .fillMaxWidth() - .height(320.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(remoteItems) { remote -> - Surface( - shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .clickable(enabled = !isPickerSending) { - scope.launch { - isPickerSending = true - if (emojiPickerTab == ComposerPickerTab.Gif || emojiPickerTab == ComposerPickerTab.Sticker) { - onSendPresetMediaUrl(remote.url) - showEmojiPicker = false - } else { - val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) - if (payload != null) { - val stickerAdjustedName = "sticker_${payload.fileName}" - onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes) - showEmojiPicker = false - } - } - isPickerSending = false + .clip(RoundedCornerShape(10.dp)) + .clickable(enabled = !isPickerSending) { + scope.launch { + isPickerSending = true + if (emojiPickerTab == ComposerPickerTab.Gif || emojiPickerTab == ComposerPickerTab.Sticker) { + onSendPresetMediaUrl(remote.url) + onDismiss() + } else { + val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) + if (payload != null) { + val stickerAdjustedName = "sticker_${payload.fileName}" + onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes) + onDismiss() } - }, - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - AsyncImage( - model = remote.url, - contentDescription = remote.title, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - contentScale = ContentScale.Crop, - ) + } + isPickerSending = false } - } + }, + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AsyncImage( + model = remote.url, + contentDescription = remote.title, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + contentScale = ContentScale.Crop, + ) } } } } } - if (isPickerSending) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } - } + } + } + if (isPickerSending) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) } } }