Add media duration to videos in v3 media select screen.

This commit is contained in:
Alex Hart
2026-08-10 16:32:55 -04:00
committed by Greyson Parrelli
parent d40b4382e9
commit 9dc19c885a
3 changed files with 84 additions and 24 deletions
@@ -14,9 +14,9 @@ import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.bytes
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.util.formatAsClock
import org.thoughtcrime.securesms.video.TranscodingConfig
import org.thoughtcrime.securesms.video.TranscodingQuality
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -39,10 +39,9 @@ internal fun VideoSizeHint(
// A trim drag emits a flood of recompositions, and formatting is the only work that has to follow each one.
val text = remember(transcodingTiers, duration) {
val seconds = duration.inWholeSeconds
val byteCountEstimate = TranscodingQuality.createFromQualityTiers(transcodingTiers, duration.inWholeMilliseconds).byteCountEstimate
String.format(Locale.getDefault(), "%d:%02d • %s", seconds / 60, seconds % 60, byteCountEstimate.bytes.toUnitString())
"${duration.formatAsClock()}${byteCountEstimate.bytes.toUnitString()}"
}
Text(
@@ -58,6 +58,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
@@ -94,11 +95,14 @@ import org.signal.core.ui.compose.list.rememberReorderBuffer
import org.signal.core.ui.compose.list.rememberReorderableListState
import org.signal.core.ui.compose.list.reorderableList
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.ContentTypeUtil
import org.signal.glide.compose.GlideImage
import org.signal.mediasend.R
import org.signal.mediasend.screens.MediaSendMetrics
import org.signal.mediasend.screens.edit.rememberPreviewMedia
import org.signal.mediasend.test.TestTags
import org.signal.mediasend.util.formatAsClock
import kotlin.time.Duration.Companion.milliseconds
import org.signal.core.ui.permissions.Permissions as PermissionsUtil
/** How many empty tiles stand in for the gallery we are not allowed to show. Matches the v2 gallery. */
@@ -568,8 +572,12 @@ private fun MediaTile(
}
)
val outerCornerClip by animateDpAsState(
if (selectionIndex >= 0) 0.dp else 2.dp
)
val cornerClip by animateDpAsState(
if (selectionIndex >= 0) 12.dp else 0.dp
if (selectionIndex >= 0) 12.dp else 2.dp
)
// Square regardless of what is in it. The thumbnail emits nothing until it has loaded, so a tile sized by its content
@@ -578,33 +586,37 @@ private fun MediaTile(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(color = MaterialTheme.colorScheme.surfaceVariant)
.background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(outerCornerClip))
.clickable(
onClick = { onEvent(MediaSelectScreenEvents.MediaClick(media)) },
onClickLabel = media.fileName,
role = Role.Button
)
) {
if (LocalInspectionMode.current) {
Box(
modifier = Modifier
.scale(scale)
.background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(cornerClip))
.fillMaxWidth()
.aspectRatio(1f)
)
} else {
val width = maxWidth
val height = maxHeight
val width = maxWidth
val height = maxHeight
GlideImage(
model = media.uri,
imageSize = DpSize(width, height),
modifier = Modifier
.aspectRatio(1f)
.scale(scale)
.clip(RoundedCornerShape(cornerClip))
)
Box(
modifier = Modifier
.aspectRatio(1f)
.scale(scale)
.clip(RoundedCornerShape(cornerClip))
) {
if (LocalInspectionMode.current) {
Box(
modifier = Modifier
.background(color = Previews.rememberRandomColor())
.fillMaxWidth()
)
} else {
GlideImage(
model = media.uri,
imageSize = DpSize(width, height),
modifier = Modifier.aspectRatio(1f)
)
}
MediaTileVideoOverlay(media)
}
}
@@ -630,6 +642,32 @@ private fun MediaTile(
}
}
@Composable
private fun MediaTileVideoOverlay(
media: Media
) {
if (ContentTypeUtil.isVideo(media.contentType) && !media.isVideoGif) {
Box(
contentAlignment = Alignment.TopEnd,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
0f to Color.Black.copy(alpha = 0.4f),
1f to Color.Transparent
)
)
.padding(top = 8.dp, end = 8.dp, bottom = 26.dp)
) {
Text(
text = remember(media.duration) { media.duration.milliseconds.formatAsClock() },
style = MaterialTheme.typography.labelLarge,
color = SignalTheme.colors.colorOnCustom
)
}
}
}
@Composable
private fun NextButton(mediaSelectionCount: Int, recipientChatColor: Color? = null, onClick: () -> Unit) {
Buttons.MediumTonal(
@@ -0,0 +1,23 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.util
import java.util.Locale
import kotlin.time.Duration
/**
* Renders a media duration as a clock, e.g. "0:04" or "1:02:03". Hours are dropped below an hour so the
* short clips that dominate the send flow read as minutes and seconds.
*/
internal fun Duration.formatAsClock(): String {
return toComponents { hours, minutes, seconds, _ ->
if (hours > 0) {
String.format(Locale.getDefault(), "%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format(Locale.getDefault(), "%d:%02d", minutes, seconds)
}
}
}