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 6686ea3..0c83d25 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 @@ -211,7 +211,9 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.Crop import androidx.media3.effect.Presentation +import androidx.media3.effect.ScaleAndRotateTransformation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.AspectRatioFrameLayout @@ -2627,6 +2629,11 @@ private enum class CameraFinalizeAction { Cancel, } +private data class VideoFrameInfo( + val width: Int, + val height: Int, +) + @Composable private fun CameraCaptureDialog( lifecycleOwner: androidx.lifecycle.LifecycleOwner, @@ -2652,6 +2659,10 @@ private fun CameraCaptureDialog( var durationMs by remember(mode) { mutableStateOf(0L) } var finalizeAction by remember(mode) { mutableStateOf(null) } + SideEffect { + previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f + } + LaunchedEffect(isRecording) { while (isRecording) { delay(100L) @@ -2922,10 +2933,15 @@ private fun CircleVideoRecorderDialog( var durationMs by remember { mutableStateOf(0L) } var finalizeAction by remember { mutableStateOf(null) } var autoStartPending by remember { mutableStateOf(true) } + var recordedLensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_FRONT) } val recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f) val progressTrackColor = Color.White.copy(alpha = 0.22f) val progressActiveColor = Color.White + SideEffect { + previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f + } + DisposableEffect(lifecycleOwner, previewView, lensFacing) { val cameraProviderFuture = ProcessCameraProvider.getInstance(context) val listener = Runnable { @@ -2994,6 +3010,7 @@ private fun CircleVideoRecorderDialog( activeFile = file durationMs = 0L isLocked = false + recordedLensFacing = lensFacing val output = FileOutputOptions.Builder(file).build() var pending = capture.output.prepareRecording(context, output) if ( @@ -3024,7 +3041,11 @@ private fun CircleVideoRecorderDialog( isProcessing = true scope.launch { val squaredFile = runCatching { - transcodeCircleVideoToSquare(context = context, inputFile = completedFile) + transcodeCircleVideoToSquare( + context = context, + inputFile = completedFile, + mirrorHorizontally = recordedLensFacing == CameraSelector.LENS_FACING_FRONT, + ) }.getOrNull() val sourceFile = squaredFile ?: completedFile val bytes = runCatching { sourceFile.readBytes() }.getOrNull() @@ -4777,19 +4798,36 @@ private fun resolveRemoteAudioDurationMs(url: String): Int? { private suspend fun transcodeCircleVideoToSquare( context: Context, inputFile: File, + mirrorHorizontally: Boolean, ): File = suspendCancellableCoroutine { continuation -> val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4") + val frameInfo = readVideoFrameInfo(inputFile) + val squareCrop = frameInfo?.toCenteredSquareCrop() val transformer = Transformer.Builder(context).build() + val videoEffects = buildList { + if (mirrorHorizontally) { + add( + ScaleAndRotateTransformation.Builder() + .setScale(-1f, 1f) + .build(), + ) + } + if (squareCrop != null) { + add(squareCrop) + } else { + add( + Presentation.createForAspectRatio( + 1f, + Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP, + ), + ) + } + } val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(Uri.fromFile(inputFile))) .setEffects( Effects( emptyList(), - listOf( - Presentation.createForAspectRatio( - 1f, - Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP, - ), - ), + videoEffects, ), ) .build() @@ -4821,6 +4859,54 @@ private suspend fun transcodeCircleVideoToSquare( transformer.start(editedMediaItem, outputFile.absolutePath) } +private fun readVideoFrameInfo(inputFile: File): VideoFrameInfo? { + return runCatching { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(inputFile.absolutePath) + val rawWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() + val rawHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() + val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toIntOrNull() ?: 0 + val width = rawWidth ?: return@runCatching null + val height = rawHeight ?: return@runCatching null + if (rotation == 90 || rotation == 270) { + VideoFrameInfo(width = height, height = width) + } else { + VideoFrameInfo(width = width, height = height) + } + } finally { + retriever.release() + } + }.getOrNull() +} + +private fun VideoFrameInfo.toCenteredSquareCrop(): Crop { + return if (width > height) { + val horizontalHalfSpan = height.toFloat() / width.toFloat() + Crop( + -horizontalHalfSpan, + horizontalHalfSpan, + -1f, + 1f, + ) + } else if (height > width) { + val verticalHalfSpan = width.toFloat() / height.toFloat() + Crop( + -1f, + 1f, + -verticalHalfSpan, + verticalHalfSpan, + ) + } else { + Crop( + -1f, + 1f, + -1f, + 1f, + ) + } +} + private fun extractFileName(url: String): String { return runCatching { val path = java.net.URI(url).path.orEmpty() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt index 9f7fe37..6c2306e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt @@ -2,6 +2,8 @@ package ru.daemonlord.messenger.ui.chat import androidx.compose.foundation.text.ClickableText import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler @@ -72,12 +74,26 @@ fun FormattedMessageText( modifier: Modifier = Modifier, ) { val uriHandler = LocalUriHandler.current - val annotated = buildFormattedMessage(text = text, baseColor = color) + val revealedSpoilers = androidx.compose.runtime.remember(text) { mutableStateListOf() } + val annotated = buildFormattedMessage( + text = text, + baseColor = color, + revealedSpoilers = revealedSpoilers.toSet(), + ) ClickableText( text = annotated, style = style, modifier = modifier, onClick = { offset -> + val spoiler = annotated + .getStringAnnotations(tag = "spoiler", start = offset, end = offset) + .firstOrNull() + if (spoiler != null) { + if (!revealedSpoilers.contains(spoiler.item)) { + revealedSpoilers.add(spoiler.item) + } + return@ClickableText + } annotated .getStringAnnotations(tag = "url", start = offset, end = offset) .firstOrNull() @@ -89,7 +105,11 @@ fun FormattedMessageText( ) } -private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedString { +private fun buildFormattedMessage( + text: String, + baseColor: Color, + revealedSpoilers: Set, +): AnnotatedString { val codeStyle = SpanStyle( fontFamily = FontFamily.Monospace, background = Color(0x332A2A2A), @@ -138,6 +158,7 @@ private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedStri tokens = tokens, codeStyle = codeStyle, linkStyle = linkStyle, + revealedSpoilers = revealedSpoilers, ) } else { appendInlineFormatted( @@ -145,6 +166,7 @@ private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedStri tokens = tokens, codeStyle = codeStyle, linkStyle = linkStyle, + revealedSpoilers = revealedSpoilers, ) } if (lineIndex != lines.lastIndex) append('\n') @@ -158,6 +180,7 @@ private fun AnnotatedString.Builder.appendInlineFormatted( tokens: List, codeStyle: SpanStyle, linkStyle: SpanStyle, + revealedSpoilers: Set, ) { var i = 0 while (i < source.length) { @@ -172,6 +195,7 @@ private fun AnnotatedString.Builder.appendInlineFormatted( tokens = tokens, codeStyle = codeStyle, linkStyle = linkStyle, + revealedSpoilers = revealedSpoilers, ) addStyle(linkStyle, start, length) addStringAnnotation(tag = "url", annotation = href, start = start, end = length) @@ -218,8 +242,22 @@ private fun AnnotatedString.Builder.appendInlineFormatted( tokens = tokens, codeStyle = codeStyle, linkStyle = linkStyle, + revealedSpoilers = revealedSpoilers, ) - addStyle(token.style, rangeStart, length) + if (token.marker == "||") { + val spoilerId = "$rangeStart:${length}:${inner.hashCode()}" + if (!revealedSpoilers.contains(spoilerId)) { + addStyle(token.style, rangeStart, length) + addStringAnnotation( + tag = "spoiler", + annotation = spoilerId, + start = rangeStart, + end = length, + ) + } + } else { + addStyle(token.style, rangeStart, length) + } i = end + token.marker.length matched = true break diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 65b305f..5f3edee 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -1099,8 +1099,9 @@ function renderMessageContent( : ""; if (messageType === "image" || messageType === "video" || messageType === "circle_video") { + const isCircleVideo = messageType === "circle_video"; const mediaItems = attachments - .filter((item) => item.message_type !== "circle_video") + .filter((item) => (isCircleVideo ? item.message_type === "circle_video" : item.message_type !== "circle_video")) .filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/")) .map((item) => ({ url: item.file_url, @@ -1114,7 +1115,6 @@ function renderMessageContent( } if (mediaItems.length === 1) { const item = mediaItems[0]; - const isCircleVideo = messageType === "circle_video"; const blockViewerOpen = isStickerOrGifMedia(item.url); return (