Add the video duration and estimated size hint to the new media send flow.

This commit is contained in:
Greyson Parrelli
2026-07-31 18:36:15 +00:00
committed by Alex Hart
parent b2a20fa56e
commit ad8c2c2123
10 changed files with 207 additions and 39 deletions
@@ -249,6 +249,10 @@ object MediaSendV3Repository : MediaSendRepository {
return TranscodingConfigProvider.getMaxVideoDurationSeconds()
}
override fun getVideoTranscodingTiers(quality: SentMediaQuality): List<TranscodingConfig.QualityTier> {
return TranscodingConfigProvider.getConfigsForMediaQuality(quality)
}
override fun isVideoTranscodeAvailable(): Boolean {
return MediaConstraints.isVideoTranscodeAvailable()
}
@@ -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<TransientVideoTranscodingTiersParceler> List<TranscodingConfig.QualityTier> =
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<List<TranscodingConfig.QualityTier>> {
override fun create(parcel: Parcel): List<TranscodingConfig.QualityTier> {
return MediaSendDependencies.mediaSendRepository.getVideoTranscodingTiers(MediaSendDependencies.mediaSendRepository.sentMediaQuality)
}
override fun List<TranscodingConfig.QualityTier>.write(parcel: Parcel, flags: Int) = Unit
}
enum class ViewOnceToggleState(val code: Int) {
OFF(0),
ONCE(1);
@@ -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()
@@ -110,7 +110,8 @@ internal fun MediaSendNavigation(
MediaEditScreen(
state = state,
onEvent = viewModel::onMediaEditScreenEvent,
imageControllers = viewModel.imageControllers
imageControllers = viewModel.imageControllers,
mediaInputFactory = MediaSendDependencies.mediaInputFactory
)
}
@@ -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<TranscodingConfig.QualityTier>
/**
* Checks if video transcoding is available on this device.
*/
@@ -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)
)
@@ -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<VideoEditorFragment?>(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<TranscodingConfig.QualityTier>,
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
)
}
}
@@ -304,12 +304,12 @@ private fun DeleteBoxPreview() {
}
@Composable
internal fun rememberPreviewMedia(count: Int): List<Media> {
internal fun rememberPreviewMedia(count: Int, contentType: String = ContentTypeUtil.IMAGE_PNG): List<Media> {
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,
@@ -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<TranscodingConfig.QualityTier>,
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
)
}
}
@@ -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)
)
}