diff --git a/feature/media-send/build.gradle.kts b/feature/media-send/build.gradle.kts index e24b5da154..de3eedfc12 100644 --- a/feature/media-send/build.gradle.kts +++ b/feature/media-send/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { // Media implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui) // CameraX implementation(libs.androidx.camera.core) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/MediaSendMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/MediaSendMetrics.kt index 32a6cb946d..4d45b53e50 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/MediaSendMetrics.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/MediaSendMetrics.kt @@ -21,6 +21,8 @@ object MediaSendMetrics { val SelectedMediaPreviewSize = DpSize(44.dp, 44.dp) val SelectedMediaPreviewShape = RoundedCornerShape(8.dp) + val MediaProjectionGutter = 16.dp + val ControlEnterTransition: EnterTransition = fadeIn() val ControlExitTransition: ExitTransition = fadeOut() diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt index 67985c598d..066e220113 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt @@ -384,6 +384,8 @@ internal class ImageController( } fun enterCropMode() { + // Two fingers belong to the crop from here, so there would be no way back out of a zoom. + imageEditorState.clearZoom() editorModel.startCrop() initialDialScale = editorModel.mainImage?.localScaleX ?: 1f transitionTo(Mode.CROP) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditChromeInsets.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditChromeInsets.kt new file mode 100644 index 0000000000..881e4e471e --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditChromeInsets.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.screens.edit + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import org.signal.mediasend.EditorState + +internal enum class ChromeEdge { TOP, BOTTOM, LEFT, RIGHT } + +internal enum class ChromeSlot { TOP_BAND, BOTTOM, SIDE_RAIL } + +internal enum class MediaChromeKind { VIDEO_TRIM, DOCUMENT, IMAGE } + +/** In pixels. */ +@Immutable +internal data class ChromeInsets( + val left: Float = 0f, + val top: Float = 0f, + val right: Float = 0f, + val bottom: Float = 0f +) { + fun expandedBy(amount: Float): ChromeInsets { + return ChromeInsets(left = left + amount, top = top + amount, right = right + amount, bottom = bottom + amount) + } + + /** Vertical is left asymmetric on purpose, so the media fills the space between the bars. */ + fun mirroredHorizontally(): ChromeInsets { + val horizontal = maxOf(left, right) + return copy(left = horizontal, right = horizontal) + } +} + +/** + * [measured] follows the chrome live; media is fit into the settled value instead, taken once from the resting layout + * and then frozen so an editor mode's bars overlap the media rather than moving it. + */ +@Stable +internal class MediaEditChromeInsetsState { + + var rootCoordinates: LayoutCoordinates? by mutableStateOf(null) + + private val reported = mutableStateMapOf() + + val measured: ChromeInsets by derivedStateOf { + reported.values.fold(ChromeInsets()) { total, next -> + ChromeInsets( + left = maxOf(total.left, next.left), + top = maxOf(total.top, next.top), + right = maxOf(total.right, next.right), + bottom = maxOf(total.bottom, next.bottom) + ) + } + } + + private val settledByKind = mutableStateMapOf() + private val frozenKinds = mutableSetOf() + + /** Falls back to the image baseline, which the other kinds add to or subtract from. */ + fun settledFor(kind: MediaChromeKind): ChromeInsets? { + return settledByKind[kind] ?: settledByKind[MediaChromeKind.IMAGE] + } + + fun settle(kind: MediaChromeKind, insets: ChromeInsets) { + if (kind !in frozenKinds) { + settledByKind[kind] = insets + } + } + + fun freeze(kind: MediaChromeKind) { + frozenKinds += kind + } + + fun isFrozen(kind: MediaChromeKind): Boolean = kind in frozenKinds + + fun thaw() { + frozenKinds.clear() + } + + fun report(slot: ChromeSlot, insets: ChromeInsets) { + reported[slot] = insets + } + + fun clear(slot: ChromeSlot) { + reported.remove(slot) + } +} + +internal fun EditorState?.chromeKind(): MediaChromeKind = when (this) { + is EditorState.VideoTrim -> MediaChromeKind.VIDEO_TRIM + is EditorState.Document -> MediaChromeKind.DOCUMENT + else -> MediaChromeKind.IMAGE +} + +internal fun MediaEditChromeInsetsState.contentInsetsFor(kind: MediaChromeKind, gutter: Float): ChromeInsets { + return (settledFor(kind) ?: ChromeInsets()).mirroredHorizontally().expandedBy(gutter) +} + +/** Goes last in the chain, so padding anchoring the control to a screen edge is not read as part of the control. */ +internal fun Modifier.reportChromeInset(state: MediaEditChromeInsetsState, slot: ChromeSlot, edge: ChromeEdge): Modifier { + return onGloballyPositioned { coordinates -> + val root = state.rootCoordinates?.takeIf { it.isAttached } ?: return@onGloballyPositioned + + // A control animated away still measures, at zero size in a corner. + if (coordinates.size.width == 0 || coordinates.size.height == 0) { + state.report(slot, ChromeInsets()) + return@onGloballyPositioned + } + + val bounds = root.localBoundingBoxOf(coordinates, clipBounds = false) + val insets = when (edge) { + ChromeEdge.TOP -> ChromeInsets(top = bounds.bottom) + ChromeEdge.BOTTOM -> ChromeInsets(bottom = root.size.height - bounds.top) + ChromeEdge.LEFT -> ChromeInsets(left = bounds.right) + ChromeEdge.RIGHT -> ChromeInsets(right = root.size.width - bounds.left) + } + + state.report( + slot, + ChromeInsets( + left = insets.left.coerceAtLeast(0f), + top = insets.top.coerceAtLeast(0f), + right = insets.right.coerceAtLeast(0f), + bottom = insets.bottom.coerceAtLeast(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 ef34dd0f4a..94d13df77f 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 @@ -8,11 +8,13 @@ package org.signal.mediasend.screens.edit import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.SnapPosition import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -37,14 +39,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.fragment.compose.AndroidFragment import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.LocalChatColorProvider @@ -81,6 +92,10 @@ 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 +import kotlin.time.Duration.Companion.milliseconds + +/** After this, a kind's projection is fixed. */ +private val CHROME_SETTLE_WINDOW = 500.milliseconds @Composable internal fun MediaEditScreen( @@ -132,7 +147,15 @@ internal fun MediaEditScreen( onEvent(MediaEditScreenEvents.NavigateBack) } - Box(modifier = Modifier.fillMaxSize()) { + val chromeInsets = remember { MediaEditChromeInsetsState() } + var rootSize by remember { mutableStateOf(IntSize.Zero) } + + Box( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { chromeInsets.rootCoordinates = it } + .onSizeChanged { rootSize = it } + ) { val isSmallWindowBreakpoint = rememberWindowBreakpoint() is WindowBreakpoint.Small val videoEditorViewModel = rememberVideoEditorViewModel() @@ -159,6 +182,34 @@ internal fun MediaEditScreen( // chrome themselves, and clearing the screen would hide what they adjust. val isDragging = imageController?.imageEditorState?.isGestureActive == true || isVideoInteracting + val isAtRest = !isImageEditing && !isVideoInteracting + val isAtRestState by rememberUpdatedState(isAtRest) + val focusedChromeKind by rememberUpdatedState(focusedEditorState.chromeKind()) + LaunchedEffect(chromeInsets, rootSize) { + chromeInsets.thaw() + + val restingChrome = snapshotFlow { if (isAtRestState) chromeInsets.measured else null } + .filterNotNull() + .filter { it != ChromeInsets() } + .distinctUntilChanged() + + snapshotFlow { if (isAtRestState) focusedChromeKind else null } + .filterNotNull() + .distinctUntilChanged() + .collectLatest { kind -> + if (chromeInsets.isFrozen(kind)) return@collectLatest + + chromeInsets.settle(kind, restingChrome.first()) + + withTimeoutOrNull(CHROME_SETTLE_WINDOW) { + restingChrome.collect { chromeInsets.settle(kind, it) } + } + chromeInsets.freeze(kind) + } + } + + val gutter = with(LocalDensity.current) { MediaSendMetrics.MediaProjectionGutter.toPx() } + HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), @@ -166,10 +217,18 @@ internal fun MediaEditScreen( userScrollEnabled = !isInteracting ) { index -> val uri = state.selectedMedia[index].uri - when (val editorState = state.editorStateMap[uri]) { + val editorState = state.editorStateMap[uri] + + // This page's own kind, so a swiped-away video's trim bar does not go on padding an image. + val pageInsets = chromeInsets.contentInsetsFor(editorState.chromeKind(), gutter) + val pagePadding = animatedPagePadding(pageInsets) + + when (editorState) { is EditorState.Image -> { + // Padded via the editor viewport, not the layout, so the canvas stays full-bleed. ImageEditor( controller = imageControllers.getOrCreate(uri, editorState.model), + contentInsets = pageInsets, modifier = Modifier.fillMaxSize() ) } @@ -177,7 +236,9 @@ internal fun MediaEditScreen( is EditorState.Document -> { DocumentPage( document = editorState, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .padding(pagePadding) ) } @@ -187,7 +248,9 @@ internal fun MediaEditScreen( model = DecryptableUri(uri), scaleType = GlideImageScaleType.FIT_CENTER, contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .padding(pagePadding) ) } } @@ -206,8 +269,10 @@ internal fun MediaEditScreen( var videoEditorFragment by remember(media.uri) { mutableStateOf(null) } AndroidFragment( - modifier = Modifier.fillMaxSize(), - arguments = VideoEditorFragment.arguments(media.uri, maxAttachmentSize = 0L, isVideoGif = media.isVideoGif) + modifier = Modifier + .fillMaxSize() + .padding(pagePadding), + arguments = VideoEditorFragment.arguments(media.uri, maxAttachmentSize = 0L, isVideoGif = media.isVideoGif, width = media.width, height = media.height) ) { fragment -> videoEditorFragment = fragment } @@ -285,6 +350,7 @@ internal fun MediaEditScreen( .padding(bottom = 10.dp) .navigationBarsPadding() .then(if (isTextEditing) Modifier.imePadding() else Modifier) + .reportChromeInset(chromeInsets, ChromeSlot.BOTTOM, ChromeEdge.BOTTOM) ) { Column( verticalArrangement = spacedBy(20.dp), @@ -389,58 +455,73 @@ internal fun MediaEditScreen( } if (!isSmallWindowBreakpoint) { - MediaToolbar( - focusedUri = focusedUri, - focusedEditorState = focusedEditorState, - state = state, - onEvent = onEvent, - imageController = imageController, - isTextEditing = isTextEditing, - isDragging = isDragging, + DisposableEffect(Unit) { + onDispose { chromeInsets.clear(ChromeSlot.SIDE_RAIL) } + } + + // Wrapped so the slot keeps reporting, at zero size, when MediaToolbar composes nothing. + Box( modifier = Modifier .align(Alignment.CenterEnd) - ) + .reportChromeInset(chromeInsets, ChromeSlot.SIDE_RAIL, ChromeEdge.RIGHT) + ) { + MediaToolbar( + focusedUri = focusedUri, + focusedEditorState = focusedEditorState, + state = state, + onEvent = onEvent, + imageController = imageController, + isTextEditing = isTextEditing, + isDragging = isDragging + ) + } } val displayNameState = state.recipientId?.let { LocalDisplayNameProvider.current(it.id) } ?: remember { mutableStateOf(null) } val displayName: String? by displayNameState - MediaEditControl( - faded = isDragging || isImageEditing, + // One band, so the media has a single top edge to clear. + Box( modifier = Modifier .align(Alignment.TopCenter) - .padding(top = 10.dp) + .fillMaxWidth() .systemBarsPadding() + .reportChromeInset(chromeInsets, ChromeSlot.TOP_BAND, ChromeEdge.TOP) ) { - if (imageController?.isUserBlurring == true) { - DrawAnywhereToBlurPill() - } else { - MediaEditSummaryPill( - displayName = displayName, - selectedMedia = state.selectedMedia, - selectedPage = pagerState.currentPage - ) + MediaEditControl( + faded = isDragging || isImageEditing, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 10.dp) + ) { + if (imageController?.isUserBlurring == true) { + DrawAnywhereToBlurPill() + } else { + MediaEditSummaryPill( + displayName = displayName, + selectedMedia = state.selectedMedia, + selectedPage = pagerState.currentPage + ) + } } + + ImageEditorUndoRedoButtons( + imageEditorController = imageController, + isDragging = isDragging, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 12.dp, start = 16.dp) + ) + + ImageEditorClearAllButton( + imageEditorController = imageController, + isDragging = isDragging, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 12.dp, end = 16.dp) + ) } - ImageEditorUndoRedoButtons( - imageEditorController = imageController, - isDragging = isDragging, - modifier = Modifier - .align(Alignment.TopStart) - .padding(top = 12.dp, start = 16.dp) - .systemBarsPadding() - ) - - ImageEditorClearAllButton( - imageEditorController = imageController, - isDragging = isDragging, - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 12.dp, end = 16.dp) - .systemBarsPadding() - ) - if (state.isSavingMedia) { MediaEditScreenDialogs.SavingToStorageProgressDialog() } @@ -577,6 +658,17 @@ private fun VideoTrimTimeline( } } +/** Eases the one correction a page gets when its kind is first measured. */ +@Composable +private fun animatedPagePadding(insets: ChromeInsets): PaddingValues { + val density = LocalDensity.current + val horizontal by animateDpAsState(with(density) { insets.left.toDp() }, label = "pageInsetHorizontal") + val top by animateDpAsState(with(density) { insets.top.toDp() }, label = "pageInsetTop") + val bottom by animateDpAsState(with(density) { insets.bottom.toDp() }, label = "pageInsetBottom") + + return PaddingValues(start = horizontal, top = top, end = horizontal, bottom = bottom) +} + @Composable private fun rememberVideoEditorViewModel(): VideoEditorViewModel { return if (LocalInspectionMode.current) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt index 55b36fd5a5..5518ab58fb 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged @@ -44,11 +45,13 @@ import androidx.compose.ui.unit.dp import org.signal.imageeditor.core.ImageEditorTouchHandler import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.renderers.MultiLineTextRenderer +import org.signal.mediasend.screens.edit.ChromeInsets import org.signal.mediasend.screens.edit.ImageController @Composable internal fun ImageEditor( controller: ImageController, + contentInsets: ChromeInsets, modifier: Modifier = Modifier ) { val context = LocalContext.current @@ -63,6 +66,10 @@ internal fun ImageEditor( onDispose { state.detach() } } + LaunchedEffect(state, contentInsets) { + state.setContentInsets(contentInsets) + } + LaunchedEffect(controller.isDraggedElementOverTrash) { if (controller.isDraggedElementOverTrash) { hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) @@ -74,7 +81,7 @@ internal fun ImageEditor( modifier = Modifier .matchParentSize() .clipToBounds() - .onSizeChanged { state.updateViewMatrix(it.width.toFloat(), it.height.toFloat()) } + .onSizeChanged { state.setCanvasSize(it.width.toFloat(), it.height.toFloat()) } .imageEditorPointerInput(state, controller) ) { state.revision @@ -173,8 +180,15 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller controller.onEntityDown(hitElement) } - // In NONE mode with nothing hit, let the pager handle the gesture - if (controller.mode == ImageController.Mode.NONE && !touchHandler.hasActiveSession()) { + // Crop is excluded: two fingers there scale the image inside the crop frame. + val canZoomCanvas = !touchHandler.hasActiveSession() && controller.mode != ImageController.Mode.CROP + if (canZoomCanvas && !awaitSecondPointer()) { + // In NONE mode the pager took the swipe; anywhere else it was a tap on nothing, which still deselects. + if (controller.mode != ImageController.Mode.NONE) { + touchHandler.onUp(state.editorModel) + lastTapElement = null + controller.onEntitySingleTap(null) + } return@awaitEachGesture } @@ -186,6 +200,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller var draggedElement: EditorElement? = null var droppedOnTrash = false var didPinch = false + var zoomPointers: Pair? = null try { while (true) { @@ -231,7 +246,16 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller break } - if (currentCount == 2 && previousPointerCount < 2) { + if (canZoomCanvas) { + // Deliberately outside didPinch: zooming is not an edit and must not mark the model dirty. + zoomPointers = if (currentCount == 2) { + val current = currentPressed[0].position to currentPressed[1].position + zoomPointers?.let { state.zoomBetween(it, current) } + current + } else { + null + } + } else if (currentCount == 2 && previousPointerCount < 2) { didPinch = true val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last() val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1) @@ -249,7 +273,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller val position = currentPressed.first().position if (inDrag) { controller.onDragMoved(draggedElement, touchHandler.checkTrashIntersect(state.editorModel, position.toPointF())) - } else if (currentCount == 1 && !touchHandler.isDrawingSession() && (position - down.position).getDistanceSquared() > MAX_MOVE_SQUARED_BEFORE_DRAG) { + } else if (!canZoomCanvas && currentCount == 1 && !touchHandler.isDrawingSession() && (position - down.position).getDistanceSquared() > MAX_MOVE_SQUARED_BEFORE_DRAG) { inDrag = true draggedElement = touchHandler.getSelected() controller.onDragStarted(draggedElement) @@ -270,3 +294,34 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller } private fun Offset.toPointF(): PointF = PointF(x, y) + +/** Consumes nothing while waiting, so a one-finger swipe still reaches the pager. */ +private suspend fun AwaitPointerEventScope.awaitSecondPointer(): Boolean { + while (true) { + val event = awaitPointerEvent() + + if (event.changes.any { it.isConsumed }) return false + + val pressed = event.changes.count { it.pressed } + if (pressed == 0) return false + if (pressed >= 2) return true + } +} + +private fun ImageEditorState.zoomBetween(previous: Pair, current: Pair) { + val previousSpread = (previous.first - previous.second).getDistance() + val currentSpread = (current.first - current.second).getDistance() + if (previousSpread <= 0f || currentSpread <= 0f) return + + val previousMidpoint = (previous.first + previous.second) / 2f + val currentMidpoint = (current.first + current.second) / 2f + val pan = currentMidpoint - previousMidpoint + + zoomBy( + focusX = currentMidpoint.x, + focusY = currentMidpoint.y, + scaleFactor = currentSpread / previousSpread, + panX = pan.x, + panY = pan.y + ) +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt index dac7faa55c..1d8d35bd7b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt @@ -23,6 +23,13 @@ import org.signal.imageeditor.core.Renderer import org.signal.imageeditor.core.RendererContext import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel +import org.signal.mediasend.screens.edit.ChromeInsets + +/** In model units. */ +private const val MIN_CONTENT_VIEW_PORT = 100f + +private const val MIN_ZOOM = 1f +private const val MAX_ZOOM = 8f /** * Compose-observable wrapper around [EditorModel]. @@ -61,8 +68,18 @@ internal class ImageEditorState( val visibleViewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM) private val viewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM) + private val contentViewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM) private val screen: RectF = RectF() + private val fitMatrix: Matrix = Matrix() + private var zoomScale: Float = 1f + private var zoomTranslateX: Float = 0f + private var zoomTranslateY: Float = 0f + + private var canvasWidth: Float = 0f + private var canvasHeight: Float = 0f + private var contentInsets: ChromeInsets = ChromeInsets() + private var rendererContext: RendererContext? = null private val rendererReady = RendererContext.Ready { renderer: Renderer, cropMatrix: Matrix?, size: Point? -> @@ -98,9 +115,66 @@ internal class ImageEditorState( editorModel.setUndoRedoStackListener(null) } - /** Recomputes the view matrix to map the editor's coordinate space to the given pixel dimensions. */ - fun updateViewMatrix(width: Float, height: Float) { - screen.set(0f, 0f, width, height) + fun setCanvasSize(width: Float, height: Float) { + if (width == canvasWidth && height == canvasHeight) return + + canvasWidth = width + canvasHeight = height + + clearZoom() + updateViewMatrix() + } + + /** Layered onto [viewMatrix] after the fit rather than pushed into the model, so it is not undoable or exported. */ + fun zoomBy(focusX: Float, focusY: Float, scaleFactor: Float, panX: Float, panY: Float) { + val scaled = (zoomScale * scaleFactor).coerceIn(MIN_ZOOM, MAX_ZOOM) + val applied = scaled / zoomScale + + // Pin what is under the midpoint of the fingers while the scale changes, then track the fingers. + zoomTranslateX = focusX - (focusX - zoomTranslateX) * applied + panX + zoomTranslateY = focusY - (focusY - zoomTranslateY) * applied + panY + zoomScale = scaled + + constrainZoom() + applyZoom() + } + + fun clearZoom() { + if (zoomScale == 1f && zoomTranslateX == 0f && zoomTranslateY == 0f) return + + zoomScale = 1f + zoomTranslateX = 0f + zoomTranslateY = 0f + applyZoom() + } + + private fun constrainZoom() { + zoomTranslateX = zoomTranslateX.coerceIn(canvasWidth - canvasWidth * zoomScale, 0f) + zoomTranslateY = zoomTranslateY.coerceIn(canvasHeight - canvasHeight * zoomScale, 0f) + } + + private fun applyZoom() { + viewMatrix.set(fitMatrix) + viewMatrix.postScale(zoomScale, zoomScale) + viewMatrix.postTranslate(zoomTranslateX, zoomTranslateY) + revision++ + } + + fun setContentInsets(insets: ChromeInsets) { + if (insets == contentInsets) return + + contentInsets = insets + updateViewMatrix() + } + + /** + * Unlike the view-based editor this was ported from, the rect handed to the model is inset by [contentInsets] while + * the matrix still spans the full canvas, so transformed content can still reach the screen edges. + */ + private fun updateViewMatrix() { + if (canvasWidth <= 0f || canvasHeight <= 0f) return + + screen.set(0f, 0f, canvasWidth, canvasHeight) viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL) val values = FloatArray(9) @@ -118,8 +192,26 @@ internal class ImageEditorState( visibleViewPort.set(tempViewPort) viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER) - editorModel.setVisibleViewPort(visibleViewPort) - revision++ + + // Viewport and screen share an aspect ratio by construction, so one scale covers both axes. + val pixelsToModel = if (screen.width() > 0f) visibleViewPort.width() / screen.width() else 0f + contentViewPort.set( + visibleViewPort.left + contentInsets.left * pixelsToModel, + visibleViewPort.top + contentInsets.top * pixelsToModel, + visibleViewPort.right - contentInsets.right * pixelsToModel, + visibleViewPort.bottom - contentInsets.bottom * pixelsToModel + ) + + // Chrome plus keyboard can exceed the screen on a short device. + if (contentViewPort.width() < MIN_CONTENT_VIEW_PORT || contentViewPort.height() < MIN_CONTENT_VIEW_PORT) { + contentViewPort.set(visibleViewPort) + } + + editorModel.setVisibleViewPort(contentViewPort) + + fitMatrix.set(viewMatrix) + constrainZoom() + applyZoom() } /** Returns a cached [RendererContext], recreating it only when the canvas instance changes. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt index 73739f4963..ff9521095f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt @@ -5,6 +5,7 @@ package org.signal.mediasend.screens.edit.video +import android.graphics.Outline import android.net.Uri import android.os.Build import android.os.Bundle @@ -13,12 +14,15 @@ import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewOutlineProvider import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.AspectRatioFrameLayout import kotlinx.coroutines.launch import org.signal.core.util.Throttler import org.signal.core.util.getParcelableCompat @@ -29,6 +33,7 @@ import org.signal.video.VideoPlayer import kotlin.time.Duration.Companion.microseconds import kotlin.time.Duration.Companion.milliseconds +@OptIn(UnstableApi::class) class VideoEditorFragment : Fragment() { private val videoScanThrottle = Throttler(150) private val handler = Handler(Looper.getMainLooper()) @@ -70,6 +75,8 @@ class VideoEditorFragment : Fragment() { player = view.findViewById(R.id.video_player) hud = view.findViewById(R.id.video_editor_hud) + shapeContentFrame(view) + uri = requireArguments().getParcelableCompat(KEY_URI, Uri::class.java)!! isVideoGif = requireArguments().getBoolean(KEY_IS_VIDEO_GIF) maxSend = requireArguments().getLong(KEY_MAX_SEND) @@ -263,6 +270,25 @@ class VideoEditorFragment : Fragment() { } } + /** The content frame tracks the video's rectangle, but fills everything it is given until the player reports a size. */ + private fun shapeContentFrame(view: View) { + val contentFrame: AspectRatioFrameLayout = view.findViewById(R.id.exo_content_frame) ?: return + val radius = VIDEO_CORNER_RADIUS_DP * resources.displayMetrics.density + + contentFrame.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(outlined: View, outline: Outline) { + outline.setRoundRect(0, 0, outlined.width, outlined.height, radius) + } + } + contentFrame.clipToOutline = true + + val width = requireArguments().getInt(KEY_WIDTH) + val height = requireArguments().getInt(KEY_HEIGHT) + if (width > 0 && height > 0) { + contentFrame.setAspectRatio(width / height.toFloat()) + } + } + private fun onSeek(position: Long, dragComplete: Boolean) { if (dragComplete) { videoScanThrottle.clear() @@ -280,15 +306,21 @@ class VideoEditorFragment : Fragment() { private val IS_VIDEO_TRANSCODE_AVAILABLE = Build.VERSION.SDK_INT >= 26 + private const val VIDEO_CORNER_RADIUS_DP = 12f + private const val KEY_URI = "uri" private const val KEY_MAX_SEND = "max_send_size" private const val KEY_IS_VIDEO_GIF = "is_video_gif" + private const val KEY_WIDTH = "width" + private const val KEY_HEIGHT = "height" - fun arguments(uri: Uri, maxAttachmentSize: Long, isVideoGif: Boolean): Bundle { + fun arguments(uri: Uri, maxAttachmentSize: Long, isVideoGif: Boolean, width: Int = 0, height: Int = 0): Bundle { return Bundle().apply { putParcelable(KEY_URI, uri) putLong(KEY_MAX_SEND, maxAttachmentSize) putBoolean(KEY_IS_VIDEO_GIF, isVideoGif) + putInt(KEY_WIDTH, width) + putInt(KEY_HEIGHT, height) } } diff --git a/feature/media-send/src/main/res/layout/mediasend_exoplayer_content.xml b/feature/media-send/src/main/res/layout/mediasend_exoplayer_content.xml new file mode 100644 index 0000000000..c340e82e81 --- /dev/null +++ b/feature/media-send/src/main/res/layout/mediasend_exoplayer_content.xml @@ -0,0 +1,20 @@ + + + + + + + + + \ No newline at end of file diff --git a/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml b/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml index 611169687e..257d5f83f6 100644 --- a/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml +++ b/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml @@ -5,6 +5,7 @@ --> + android:layout_height="match_parent" + app:playerLayoutId="@layout/mediasend_video_player" /> + + + + + + + + + +