diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoSizeHint.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoSizeHint.kt index 945382e4fb..8a374b2f50 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoSizeHint.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoSizeHint.kt @@ -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( diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt index e98036e0ee..8a566d1e7f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt @@ -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( diff --git a/feature/media-send/src/main/java/org/signal/mediasend/util/DurationFormat.kt b/feature/media-send/src/main/java/org/signal/mediasend/util/DurationFormat.kt new file mode 100644 index 0000000000..9cb14faa11 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/util/DurationFormat.kt @@ -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) + } + } +}