Add proper image viewport when opening editor.

This commit is contained in:
Alex Hart
2026-08-11 16:41:20 -03:00
parent 53ab1d817a
commit 2028a82596
11 changed files with 527 additions and 56 deletions
+1
View File
@@ -67,6 +67,7 @@ dependencies {
// Media
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
// CameraX
implementation(libs.androidx.camera.core)
@@ -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()
@@ -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)
@@ -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<ChromeSlot, ChromeInsets>()
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<MediaChromeKind, ChromeInsets>()
private val frozenKinds = mutableSetOf<MediaChromeKind>()
/** 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)
)
)
}
}
@@ -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<VideoEditorFragment?>(null) }
AndroidFragment<VideoEditorFragment>(
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) {
@@ -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<Offset, Offset>? = 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<Offset, Offset>, current: Pair<Offset, Offset>) {
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
)
}
@@ -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. */
@@ -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)
}
}
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<!-- Local to this module so the content frame's id resolves here, which is what lets the editor round its corners. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<androidx.media3.ui.AspectRatioFrameLayout
android:id="@+id/exo_content_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center" />
</FrameLayout>
@@ -5,6 +5,7 @@
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
android:layout_width="match_parent"
@@ -13,7 +14,8 @@
<org.signal.video.VideoPlayer
android:id="@+id/video_player"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
app:playerLayoutId="@layout/mediasend_video_player" />
<org.signal.mediasend.screens.edit.video.VideoEditorPlayButtonLayout
android:id="@+id/video_editor_hud"
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<!-- Texture-backed: a SurfaceView cannot be clipped to rounded corners and flashes when resized. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:viewBindingIgnore="true">
<androidx.media3.ui.PlayerView
android:id="@+id/video_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:gravity="center"
app:player_layout_id="@layout/mediasend_exoplayer_content"
app:surface_type="texture_view" />
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:indeterminate="true"
android:indeterminateTint="@color/signal_colorOnSurfaceVariant" />
</FrameLayout>