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.PlaybackParameters
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.effect.Crop
|
||||||
import androidx.media3.effect.Presentation
|
import androidx.media3.effect.Presentation
|
||||||
|
import androidx.media3.effect.ScaleAndRotateTransformation
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.DefaultTimeBar
|
import androidx.media3.ui.DefaultTimeBar
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
@@ -2627,6 +2629,11 @@ private enum class CameraFinalizeAction {
|
|||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class VideoFrameInfo(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CameraCaptureDialog(
|
private fun CameraCaptureDialog(
|
||||||
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
lifecycleOwner: androidx.lifecycle.LifecycleOwner,
|
||||||
@@ -2652,6 +2659,10 @@ private fun CameraCaptureDialog(
|
|||||||
var durationMs by remember(mode) { mutableStateOf(0L) }
|
var durationMs by remember(mode) { mutableStateOf(0L) }
|
||||||
var finalizeAction by remember(mode) { mutableStateOf<CameraFinalizeAction?>(null) }
|
var finalizeAction by remember(mode) { mutableStateOf<CameraFinalizeAction?>(null) }
|
||||||
|
|
||||||
|
SideEffect {
|
||||||
|
previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(isRecording) {
|
LaunchedEffect(isRecording) {
|
||||||
while (isRecording) {
|
while (isRecording) {
|
||||||
delay(100L)
|
delay(100L)
|
||||||
@@ -2922,10 +2933,15 @@ private fun CircleVideoRecorderDialog(
|
|||||||
var durationMs by remember { mutableStateOf(0L) }
|
var durationMs by remember { mutableStateOf(0L) }
|
||||||
var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
var finalizeAction by remember { mutableStateOf<CircleFinalizeAction?>(null) }
|
||||||
var autoStartPending by remember { mutableStateOf(true) }
|
var autoStartPending by remember { mutableStateOf(true) }
|
||||||
|
var recordedLensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_FRONT) }
|
||||||
val recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f)
|
val recordingProgress = (durationMs / 60_000f).coerceIn(0f, 1f)
|
||||||
val progressTrackColor = Color.White.copy(alpha = 0.22f)
|
val progressTrackColor = Color.White.copy(alpha = 0.22f)
|
||||||
val progressActiveColor = Color.White
|
val progressActiveColor = Color.White
|
||||||
|
|
||||||
|
SideEffect {
|
||||||
|
previewView.scaleX = if (lensFacing == CameraSelector.LENS_FACING_FRONT) -1f else 1f
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner, previewView, lensFacing) {
|
DisposableEffect(lifecycleOwner, previewView, lensFacing) {
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
val listener = Runnable {
|
val listener = Runnable {
|
||||||
@@ -2994,6 +3010,7 @@ private fun CircleVideoRecorderDialog(
|
|||||||
activeFile = file
|
activeFile = file
|
||||||
durationMs = 0L
|
durationMs = 0L
|
||||||
isLocked = false
|
isLocked = false
|
||||||
|
recordedLensFacing = lensFacing
|
||||||
val output = FileOutputOptions.Builder(file).build()
|
val output = FileOutputOptions.Builder(file).build()
|
||||||
var pending = capture.output.prepareRecording(context, output)
|
var pending = capture.output.prepareRecording(context, output)
|
||||||
if (
|
if (
|
||||||
@@ -3024,7 +3041,11 @@ private fun CircleVideoRecorderDialog(
|
|||||||
isProcessing = true
|
isProcessing = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val squaredFile = runCatching {
|
val squaredFile = runCatching {
|
||||||
transcodeCircleVideoToSquare(context = context, inputFile = completedFile)
|
transcodeCircleVideoToSquare(
|
||||||
|
context = context,
|
||||||
|
inputFile = completedFile,
|
||||||
|
mirrorHorizontally = recordedLensFacing == CameraSelector.LENS_FACING_FRONT,
|
||||||
|
)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
val sourceFile = squaredFile ?: completedFile
|
val sourceFile = squaredFile ?: completedFile
|
||||||
val bytes = runCatching { sourceFile.readBytes() }.getOrNull()
|
val bytes = runCatching { sourceFile.readBytes() }.getOrNull()
|
||||||
@@ -4777,19 +4798,36 @@ private fun resolveRemoteAudioDurationMs(url: String): Int? {
|
|||||||
private suspend fun transcodeCircleVideoToSquare(
|
private suspend fun transcodeCircleVideoToSquare(
|
||||||
context: Context,
|
context: Context,
|
||||||
inputFile: File,
|
inputFile: File,
|
||||||
|
mirrorHorizontally: Boolean,
|
||||||
): File = suspendCancellableCoroutine { continuation ->
|
): File = suspendCancellableCoroutine { continuation ->
|
||||||
val outputFile = createTempCaptureFile(context = context, prefix = "circle_square_", suffix = ".mp4")
|
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 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)))
|
val editedMediaItem = EditedMediaItem.Builder(MediaItem.fromUri(Uri.fromFile(inputFile)))
|
||||||
.setEffects(
|
.setEffects(
|
||||||
Effects(
|
Effects(
|
||||||
emptyList(),
|
emptyList(),
|
||||||
listOf(
|
videoEffects,
|
||||||
Presentation.createForAspectRatio(
|
|
||||||
1f,
|
|
||||||
Presentation.LAYOUT_SCALE_TO_FIT_WITH_CROP,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
@@ -4821,6 +4859,54 @@ private suspend fun transcodeCircleVideoToSquare(
|
|||||||
transformer.start(editedMediaItem, outputFile.absolutePath)
|
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 {
|
private fun extractFileName(url: String): String {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val path = java.net.URI(url).path.orEmpty()
|
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.foundation.text.ClickableText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
@@ -72,12 +74,26 @@ fun FormattedMessageText(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
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(
|
ClickableText(
|
||||||
text = annotated,
|
text = annotated,
|
||||||
style = style,
|
style = style,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = { offset ->
|
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
|
annotated
|
||||||
.getStringAnnotations(tag = "url", start = offset, end = offset)
|
.getStringAnnotations(tag = "url", start = offset, end = offset)
|
||||||
.firstOrNull()
|
.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(
|
val codeStyle = SpanStyle(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
background = Color(0x332A2A2A),
|
background = Color(0x332A2A2A),
|
||||||
@@ -138,6 +158,7 @@ private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedStri
|
|||||||
tokens = tokens,
|
tokens = tokens,
|
||||||
codeStyle = codeStyle,
|
codeStyle = codeStyle,
|
||||||
linkStyle = linkStyle,
|
linkStyle = linkStyle,
|
||||||
|
revealedSpoilers = revealedSpoilers,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
appendInlineFormatted(
|
appendInlineFormatted(
|
||||||
@@ -145,6 +166,7 @@ private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedStri
|
|||||||
tokens = tokens,
|
tokens = tokens,
|
||||||
codeStyle = codeStyle,
|
codeStyle = codeStyle,
|
||||||
linkStyle = linkStyle,
|
linkStyle = linkStyle,
|
||||||
|
revealedSpoilers = revealedSpoilers,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (lineIndex != lines.lastIndex) append('\n')
|
if (lineIndex != lines.lastIndex) append('\n')
|
||||||
@@ -158,6 +180,7 @@ private fun AnnotatedString.Builder.appendInlineFormatted(
|
|||||||
tokens: List<InlineToken>,
|
tokens: List<InlineToken>,
|
||||||
codeStyle: SpanStyle,
|
codeStyle: SpanStyle,
|
||||||
linkStyle: SpanStyle,
|
linkStyle: SpanStyle,
|
||||||
|
revealedSpoilers: Set<String>,
|
||||||
) {
|
) {
|
||||||
var i = 0
|
var i = 0
|
||||||
while (i < source.length) {
|
while (i < source.length) {
|
||||||
@@ -172,6 +195,7 @@ private fun AnnotatedString.Builder.appendInlineFormatted(
|
|||||||
tokens = tokens,
|
tokens = tokens,
|
||||||
codeStyle = codeStyle,
|
codeStyle = codeStyle,
|
||||||
linkStyle = linkStyle,
|
linkStyle = linkStyle,
|
||||||
|
revealedSpoilers = revealedSpoilers,
|
||||||
)
|
)
|
||||||
addStyle(linkStyle, start, length)
|
addStyle(linkStyle, start, length)
|
||||||
addStringAnnotation(tag = "url", annotation = href, start = start, end = length)
|
addStringAnnotation(tag = "url", annotation = href, start = start, end = length)
|
||||||
@@ -218,8 +242,22 @@ private fun AnnotatedString.Builder.appendInlineFormatted(
|
|||||||
tokens = tokens,
|
tokens = tokens,
|
||||||
codeStyle = codeStyle,
|
codeStyle = codeStyle,
|
||||||
linkStyle = linkStyle,
|
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
|
i = end + token.marker.length
|
||||||
matched = true
|
matched = true
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1099,8 +1099,9 @@ function renderMessageContent(
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
|
if (messageType === "image" || messageType === "video" || messageType === "circle_video") {
|
||||||
|
const isCircleVideo = messageType === "circle_video";
|
||||||
const mediaItems = attachments
|
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/"))
|
.filter((item) => item.file_type.startsWith("image/") || item.file_type.startsWith("video/"))
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
url: item.file_url,
|
url: item.file_url,
|
||||||
@@ -1114,7 +1115,6 @@ function renderMessageContent(
|
|||||||
}
|
}
|
||||||
if (mediaItems.length === 1) {
|
if (mediaItems.length === 1) {
|
||||||
const item = mediaItems[0];
|
const item = mediaItems[0];
|
||||||
const isCircleVideo = messageType === "circle_video";
|
|
||||||
const blockViewerOpen = isStickerOrGifMedia(item.url);
|
const blockViewerOpen = isStickerOrGifMedia(item.url);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
Reference in New Issue
Block a user