fix: improve circle video rendering and spoiler interaction
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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user