mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-14 01:03:28 +01:00
Add proper image viewport when opening editor.
This commit is contained in:
@@ -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)
|
||||
|
||||
+139
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+136
-44
@@ -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) {
|
||||
|
||||
+60
-5
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+97
-5
@@ -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. */
|
||||
|
||||
+33
-1
@@ -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>
|
||||
Reference in New Issue
Block a user