mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Add the video duration and estimated size hint to the new media send flow.
This commit is contained in:
committed by
Alex Hart
parent
b2a20fa56e
commit
ad8c2c2123
@@ -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)
|
||||
)
|
||||
|
||||
+85
-31
@@ -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,
|
||||
|
||||
+65
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user