From ad8c2c21230bb3b39ba951f8dd6eaf69adecab8a Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 31 Jul 2026 18:36:15 +0000 Subject: [PATCH] Add the video duration and estimated size hint to the new media send flow. --- .../mediasend/v3/MediaSendV3Repository.kt | 4 + .../signal/mediasend/MediaSendFlowState.kt | 19 +++ .../mediasend/MediaSendFlowViewModel.kt | 8 +- .../signal/mediasend/MediaSendNavigation.kt | 3 +- .../signal/mediasend/MediaSendRepository.kt | 7 ++ .../mediasend/PreviewMediaConstraints.kt | 14 +++ .../mediasend/screens/edit/MediaEditScreen.kt | 116 +++++++++++++----- .../mediasend/screens/edit/ThumbnailRow.kt | 4 +- .../screens/edit/video/VideoSizeHint.kt | 65 ++++++++++ .../screens/edit/video/VideoTrimBar.kt | 6 +- 10 files changed, 207 insertions(+), 39 deletions(-) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoSizeHint.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index 3b9db95400..f0d9447dc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -249,6 +249,10 @@ object MediaSendV3Repository : MediaSendRepository { return TranscodingConfigProvider.getMaxVideoDurationSeconds() } + override fun getVideoTranscodingTiers(quality: SentMediaQuality): List { + return TranscodingConfigProvider.getConfigsForMediaQuality(quality) + } + override fun isVideoTranscodeAvailable(): Boolean { return MediaConstraints.isVideoTranscodeAvailable() } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt index b2d0405425..0227569562 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt @@ -17,6 +17,7 @@ import org.signal.core.models.parcelers.NullableCharSequenceParceler import org.signal.core.util.ContentTypeUtil import org.signal.mediasend.screens.edit.image.BrushWidths import org.signal.mediasend.screens.edit.video.VideoTrimData +import org.thoughtcrime.securesms.video.TranscodingConfig import kotlin.time.Duration /** @@ -126,6 +127,13 @@ data class MediaSendFlowState( val storyMaxVideoDuration: Duration = MediaSendDependencies.mediaSendRepository.storyMaxVideoDuration, + /** + * The transcoding tiers that apply at [sentMediaQuality], which an estimate of a video's upload size is derived from. + * Re-read whenever the quality changes. + */ + val videoTranscodingTiers: @WriteWith List = + MediaSendDependencies.mediaSendRepository.getVideoTranscodingTiers(sentMediaQuality), + /** * The image editor's per-tool brush widths. Seeded from storage and written back as the user adjusts them. */ @@ -170,6 +178,17 @@ data class MediaSendFlowState( override fun SentMediaQuality.write(parcel: Parcel, flags: Int) = Unit } + /** + * Derived from the quality the repository restores, so it is re-read alongside it rather than saved. + */ + private object TransientVideoTranscodingTiersParceler : Parceler> { + override fun create(parcel: Parcel): List { + return MediaSendDependencies.mediaSendRepository.getVideoTranscodingTiers(MediaSendDependencies.mediaSendRepository.sentMediaQuality) + } + + override fun List.write(parcel: Parcel, flags: Int) = Unit + } + enum class ViewOnceToggleState(val code: Int) { OFF(0), ONCE(1); diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt index 4f92813232..554f6064bf 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt @@ -792,7 +792,13 @@ class MediaSendFlowViewModel( val snapshot = state.value if (snapshot.sentMediaQuality == sentMediaQuality) return - updateState { copy(sentMediaQuality = sentMediaQuality, isPreUploadEnabled = false) } + updateState { + copy( + sentMediaQuality = sentMediaQuality, + videoTranscodingTiers = repository.getVideoTranscodingTiers(sentMediaQuality), + isPreUploadEnabled = false + ) + } repository.sentMediaQuality = sentMediaQuality preUploadController.cancelAllUploads() diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt index 62d90a1230..ab10dbf59b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt @@ -110,7 +110,8 @@ internal fun MediaSendNavigation( MediaEditScreen( state = state, onEvent = viewModel::onMediaEditScreenEvent, - imageControllers = viewModel.imageControllers + imageControllers = viewModel.imageControllers, + mediaInputFactory = MediaSendDependencies.mediaInputFactory ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index 9ef297cfd8..a6293769d6 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -14,6 +14,7 @@ import org.signal.core.models.media.MediaFolder import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.preupload.PreUploadResult import org.signal.mediasend.screens.edit.image.BrushWidths +import org.thoughtcrime.securesms.video.TranscodingConfig import java.io.InputStream import kotlin.time.Duration @@ -98,6 +99,12 @@ interface MediaSendRepository { */ fun getMaxVideoRecordDurationSeconds(): Int + /** + * The transcoding quality tiers that apply when sending video at [quality]. These describe what the transcoder will + * target, and are what an estimate of a video's upload size is derived from. + */ + fun getVideoTranscodingTiers(quality: SentMediaQuality): List + /** * Checks if video transcoding is available on this device. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt b/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt index 4e90a4f43c..ae82446990 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt @@ -5,10 +5,14 @@ package org.signal.mediasend +import android.content.Context +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import org.signal.mediasend.screens.edit.image.BrushWidths import org.thoughtcrime.securesms.video.TranscodingConfig +import org.thoughtcrime.securesms.video.interfaces.MediaInput +import org.thoughtcrime.securesms.video.interfaces.MediaInputFactory import kotlin.time.Duration.Companion.seconds object PreviewMediaConstraints : MediaConstraints() { @@ -33,11 +37,21 @@ object PreviewMediaConstraints : MediaConstraints() { override fun getMaxAttachmentSize(): Long = 0L } +/** + * Stands in for the real factory in previews, which have no video to decode. Consumers skip decoding under inspection, + * so nothing should ever ask this for an input. + */ +object PreviewMediaInputFactory : MediaInputFactory { + override fun createForUri(context: Context, uri: Uri): MediaInput = throw UnsupportedOperationException() +} + @Composable internal fun rememberPreviewState() = remember { MediaSendFlowState( mediaConstraints = PreviewMediaConstraints, + sentMediaQuality = SentMediaQuality.STANDARD, storyMaxVideoDuration = 30.seconds, + videoTranscodingTiers = emptyList(), storiesEnabled = true, brushWidths = BrushWidths(0f, 0f, 0f) ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt index 2df063f13f..a447a2d966 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding @@ -45,14 +46,16 @@ import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.LocalChatColorProvider import org.signal.core.ui.compose.LocalDisplayNameProvider import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.horizontalGutters import org.signal.core.ui.rememberWindowBreakpoint +import org.signal.core.util.ContentTypeUtil import org.signal.glide.compose.GlideImage import org.signal.glide.compose.GlideImageScaleType import org.signal.glide.decryptableuri.DecryptableUri import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.EditorState -import org.signal.mediasend.MediaSendDependencies import org.signal.mediasend.MediaSendFlowState +import org.signal.mediasend.PreviewMediaInputFactory import org.signal.mediasend.rememberPreviewState import org.signal.mediasend.screens.MediaSendMetrics import org.signal.mediasend.screens.edit.document.DocumentPage @@ -67,13 +70,18 @@ import org.signal.mediasend.screens.edit.image.ImageEditorUndoRedoButtons import org.signal.mediasend.screens.edit.image.RotationDial import org.signal.mediasend.screens.edit.video.VideoEditorFragment import org.signal.mediasend.screens.edit.video.VideoEditorViewModel +import org.signal.mediasend.screens.edit.video.VideoSizeHint import org.signal.mediasend.screens.edit.video.VideoTrimBar +import org.signal.mediasend.screens.edit.video.VideoTrimData +import org.thoughtcrime.securesms.video.TranscodingConfig +import org.thoughtcrime.securesms.video.interfaces.MediaInputFactory @Composable internal fun MediaEditScreen( state: MediaSendFlowState, onEvent: (MediaEditScreenEvents) -> Unit, - imageControllers: ImageController.Container + imageControllers: ImageController.Container, + mediaInputFactory: MediaInputFactory ) { val scope = rememberCoroutineScope() @@ -161,6 +169,11 @@ internal fun MediaEditScreen( } is EditorState.VideoTrim, EditorState.VideoGif -> { + if (LocalInspectionMode.current) { + Box(modifier = Modifier.fillMaxSize().background(color = Color.Red)) + return@HorizontalPager + } + val media = state.selectedMedia[index] var videoEditorFragment by remember(media.uri) { mutableStateOf(null) } @@ -253,6 +266,8 @@ internal fun MediaEditScreen( VideoTrimTimeline( videoUri = focusedUri, editorState = focusedEditorState, + transcodingTiers = state.videoTranscodingTiers, + mediaInputFactory = mediaInputFactory, videoEditorViewModel = videoEditorViewModel, onInteractingChange = { isVideoInteracting = it }, onEvent = onEvent @@ -456,13 +471,16 @@ private fun MediaToolbar( } /** - * Trim/scrub timeline for the focused video. Drag state is reported through [onInteractingChange] so the rest of the - * stack can get out of the way, and seeks are translated into player commands rather than screen events. + * Trim/scrub timeline for the focused video, with the resulting duration and estimated upload size beneath it. Drag + * state is reported through [onInteractingChange] so the rest of the stack can get out of the way, and seeks are + * translated into player commands rather than screen events. */ @Composable private fun VideoTrimTimeline( videoUri: Uri, editorState: EditorState.VideoTrim, + transcodingTiers: List, + mediaInputFactory: MediaInputFactory, videoEditorViewModel: VideoEditorViewModel, onInteractingChange: (Boolean) -> Unit, onEvent: (MediaEditScreenEvents) -> Unit @@ -475,35 +493,49 @@ private fun VideoTrimTimeline( } } - VideoTrimBar( - videoUri = videoUri, - mediaInputFactory = MediaSendDependencies.mediaInputFactory, - videoTrimData = editorState.videoTrimData, - maxSelectableDurationUs = editorState.maxDurationUs, - playbackPositionUs = playbackPositionUs, - onEvent = { event -> - when (event) { - is MediaEditScreenEvents.VideoTrimChanged -> { - onInteractingChange(!event.editingComplete) - onEvent(event) - } + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.fillMaxWidth() + ) { + VideoTrimBar( + videoUri = videoUri, + mediaInputFactory = mediaInputFactory, + videoTrimData = editorState.videoTrimData, + maxSelectableDurationUs = editorState.maxDurationUs, + playbackPositionUs = playbackPositionUs, + onEvent = { event -> + when (event) { + is MediaEditScreenEvents.VideoTrimChanged -> { + onInteractingChange(!event.editingComplete) + onEvent(event) + } - is MediaEditScreenEvents.VideoSeek -> { - onInteractingChange(!event.editingComplete) - videoEditorViewModel.sendCommand( - videoUri, - if (event.editingComplete) { - VideoEditorViewModel.Command.EndPositionDrag(event.positionUs) - } else { - VideoEditorViewModel.Command.PositionDrag(event.positionUs) - } - ) - } + is MediaEditScreenEvents.VideoSeek -> { + onInteractingChange(!event.editingComplete) + videoEditorViewModel.sendCommand( + videoUri, + if (event.editingComplete) { + VideoEditorViewModel.Command.EndPositionDrag(event.positionUs) + } else { + VideoEditorViewModel.Command.PositionDrag(event.positionUs) + } + ) + } - else -> onEvent(event) + else -> onEvent(event) + } } - } - ) + ) + + // Gutters to match the bar's, so the hint's end lines up with the end of the timeline. + VideoSizeHint( + transcodingTiers = transcodingTiers, + duration = editorState.videoTrimData.getDuration(), + modifier = Modifier + .horizontalGutters() + .padding(top = 4.dp) + ) + } } @Composable @@ -530,7 +562,29 @@ private fun MediaEditScreenPreview() { ) ), onEvent = {}, - imageControllers = remember { ImageController.Container() } + imageControllers = remember { ImageController.Container() }, + mediaInputFactory = PreviewMediaInputFactory + ) + } +} + +@AllDevicePreviews +@Composable +private fun MediaEditScreenVideoPreview() { + val selectedMedia = rememberPreviewMedia(10, contentType = ContentTypeUtil.VIDEO_MP4) + + Previews.Preview { + MediaEditScreen( + state = rememberPreviewState().copy( + selectedMedia = selectedMedia, + focusedMedia = selectedMedia.first(), + editorStateMap = mutableMapOf( + selectedMedia.first().uri to EditorState.VideoTrim(VideoTrimData()) + ) + ), + onEvent = {}, + imageControllers = remember { ImageController.Container() }, + mediaInputFactory = PreviewMediaInputFactory ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ThumbnailRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ThumbnailRow.kt index 917fc60e14..2e0cc9416b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ThumbnailRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ThumbnailRow.kt @@ -304,12 +304,12 @@ private fun DeleteBoxPreview() { } @Composable -internal fun rememberPreviewMedia(count: Int): List { +internal fun rememberPreviewMedia(count: Int, contentType: String = ContentTypeUtil.IMAGE_PNG): List { return remember(count) { (0 until count).map { Media( uri = "https://example.com/image$it.png".toUri(), - contentType = ContentTypeUtil.IMAGE_PNG, + contentType = contentType, width = 100, height = 100, duration = 0, 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 new file mode 100644 index 0000000000..945382e4fb --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoSizeHint.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.screens.edit.video + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +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.thoughtcrime.securesms.video.TranscodingConfig +import org.thoughtcrime.securesms.video.TranscodingQuality +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * How long the trimmed video is and how large we expect its upload to be, e.g. "0:04 • 399 KB". + * + * The size is what the transcoder targets for a clip of [duration] under [transcodingTiers], so it tracks both the trim + * handles and the sent-media quality the tiers came from. Renders nothing when the device cannot transcode, since the + * video is then uploaded as-is and a transcode target would not describe it. + */ +@Composable +internal fun VideoSizeHint( + transcodingTiers: List, + duration: Duration, + modifier: Modifier = Modifier +) { + if (!MediaConstraints.isVideoTranscodeAvailable()) { + return + } + + // 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()) + } + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier + ) +} + +@DayNightPreviews +@Composable +private fun VideoSizeHintPreview() { + Previews.Preview { + VideoSizeHint( + transcodingTiers = emptyList(), + duration = 64.seconds + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimBar.kt index d11c01b6e5..e305478505 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimBar.kt @@ -5,7 +5,6 @@ package org.signal.mediasend.screens.edit.video -import android.content.Context import android.net.Uri import android.view.LayoutInflater import androidx.compose.foundation.layout.fillMaxWidth @@ -25,6 +24,7 @@ import androidx.compose.ui.viewinterop.AndroidView import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.horizontalGutters import org.signal.core.util.logging.Log +import org.signal.mediasend.PreviewMediaInputFactory import org.signal.mediasend.R import org.signal.mediasend.screens.edit.MediaEditScreenEvents import org.thoughtcrime.securesms.video.interfaces.MediaInputFactory @@ -144,9 +144,7 @@ fun VideoTrimBarPreview() { Previews.Preview { VideoTrimBar( videoUri = Uri.EMPTY, - mediaInputFactory = object : MediaInputFactory { - override fun createForUri(context: Context, uri: Uri) = throw UnsupportedOperationException() - }, + mediaInputFactory = PreviewMediaInputFactory, videoTrimData = VideoTrimData(isDurationEdited = false) ) }