fix: improve circle video rendering and spoiler interaction
Some checks failed
Android CI / android (push) Failing after 4m32s
Android Release / release (push) Failing after 4m38s
CI / test (push) Failing after 2m33s

fix: center-crop circle videos and unmirror front camera capture\n\nfix: reveal spoiler text on tap in Android chat messages\nfix: render circle_video correctly on web instead of [empty]
This commit is contained in:
2026-04-05 14:20:49 +03:00
parent d2e0969fd5
commit 2dcd1ba129
3 changed files with 136 additions and 12 deletions

View File

@@ -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<CameraFinalizeAction?>(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<CircleFinalizeAction?>(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()

View File

@@ -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<String>() }
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<String>,
): 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<InlineToken>,
codeStyle: SpanStyle,
linkStyle: SpanStyle,
revealedSpoilers: Set<String>,
) {
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

View File

@@ -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 (
<div className="space-y-1.5">