Merge branch 'video_crop' into 'master'

Crop videos

See merge request pixeldroid/PixelDroid!482
This commit is contained in:
Matthieu 2022-10-21 15:59:33 +00:00
commit 8afd3b88df
14 changed files with 1529 additions and 24 deletions

View File

@ -47,7 +47,6 @@ import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.roundToInt
import kotlin.collections.flatten
const val TAG = "Post Creation Activity"
@ -131,8 +130,13 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
uiState.newEncodingJobVideoStart?.let { videoStart ->
uiState.newEncodingJobVideoEnd?.let { videoEnd ->
uiState.newEncodingJobSpeedIndex?.let { speedIndex ->
startEncoding(position, muted, videoStart, videoEnd, speedIndex)
model.encodingStarted()
uiState.newEncodingJobVideoCrop?.let { crop ->
startEncoding(position, muted,
videoStart, videoEnd,
speedIndex, crop
)
model.encodingStarted()
}
}
}
}
@ -331,7 +335,8 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
muted: Boolean,
videoStart: Float?,
videoEnd: Float?,
speedIndex: Int
speedIndex: Int,
crop: VideoEditActivity.RelativeCropPosition
) {
val originalUri = model.getPhotoData().value!![position].imageUri
@ -352,29 +357,33 @@ class PostCreationActivity : BaseThemedWithoutBarActivity() {
val speed = VideoEditActivity.speedChoices[speedIndex]
//TODO also have audio when speed is changed?
val mutedString = if(muted || speedIndex != 1) "-an" else null
val startString: List<String?> = if(videoStart != null) listOf("-ss", "${videoStart/speed.toFloat()}") else listOf(null, null)
val endString: List<String?> = if(videoEnd != null) listOf("-to", "${videoEnd/speed.toFloat() - (videoStart ?: 0f)/speed.toFloat()}") else listOf(null, null)
val speedString: List<String?> = if(speedIndex!= 1)
listOf("-filter:v", "setpts=PTS/${speed}")
// iw and ih are variables for the original width and height values, FFmpeg will know them
val cropString = if(crop.notCropped()) "" else "crop=${crop.relativeWidth}*iw:${crop.relativeHeight}*ih:${crop.relativeX}*iw:${crop.relativeY}*ih"
val separator = if(speedIndex != 1 && !crop.notCropped()) "," else ""
val speedString = if(speedIndex != 1) "setpts=PTS/${speed}" else ""
val speedAndCropString: List<String?> = if(speedIndex!= 1 || !crop.notCropped())
listOf("-filter:v", speedString + separator + cropString)
// Stream copy is not compatible with filter, but when not filtering we can copy the stream without re-encoding
else listOf("-c", "copy")
// This should be set when re-encoding is required (otherwise it defaults to mpeg which then doesn't play)
val encodePreset: List<String?> = if(speedIndex != 1) listOf("-c:v", "libx264", "-preset", "ultrafast") else listOf(null, null, null, null)
val encodePreset: List<String?> = if(speedIndex != 1 && !crop.notCropped()) listOf("-c:v", "libx264", "-preset", "ultrafast") else listOf(null, null, null, null)
val session: FFmpegSession =
FFmpegKit.executeWithArgumentsAsync(listOfNotNull(
startString[0], startString[1],
"-i", ffmpegCompliantUri,
speedString[0], speedString[1],
speedAndCropString[0], speedAndCropString[1],
endString[0], endString[1],
mutedString, "-y",
encodePreset[0], encodePreset[1], encodePreset[2], encodePreset[3],
outputVideoPath
outputVideoPath,
).toTypedArray(),
//val session: FFmpegSession = FFmpegKit.executeAsync("$startString -i $inputSafePath $endString -c:v libvpx-vp9 -c:a copy -an -y $outputVideoPath",
{ session ->

View File

@ -24,6 +24,7 @@ import org.pixeldroid.app.MainActivity
import org.pixeldroid.app.R
import org.pixeldroid.app.postCreation.photoEdit.PhotoEditActivity
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity.RelativeCropPosition
import org.pixeldroid.app.utils.PixelDroidApplication
import org.pixeldroid.app.utils.api.objects.Attachment
import org.pixeldroid.app.utils.db.entities.InstanceDatabaseEntity
@ -61,6 +62,8 @@ data class PostCreationActivityUiState(
val newEncodingJobSpeedIndex: Int? = null,
val newEncodingJobVideoStart: Float? = null,
val newEncodingJobVideoEnd: Float? = null,
val newEncodingJobVideoCrop: RelativeCropPosition? = null,
)
class PostCreationViewModel(application: Application, clipdata: ClipData? = null, val instance: InstanceDatabaseEntity? = null) : AndroidViewModel(application) {
@ -372,6 +375,8 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
if(it == -1f) null else it
}
val videoCrop: RelativeCropPosition = data.getSerializableExtra(VideoEditActivity.VIDEO_CROP) as RelativeCropPosition
videoEncodeProgress = 0
sessionMap[position]?.let { FFmpegKit.cancel(it) }
_uiState.update { currentUiState ->
@ -380,7 +385,8 @@ class PostCreationViewModel(application: Application, clipdata: ClipData? = null
newEncodingJobMuted = muted,
newEncodingJobSpeedIndex = speedIndex,
newEncodingJobVideoStart = videoStart,
newEncodingJobVideoEnd = videoEnd
newEncodingJobVideoEnd = videoEnd,
newEncodingJobVideoCrop = videoCrop
)
}
}

View File

@ -578,6 +578,7 @@ class ImageCarousel(
null, null, null)
}
} else {
binding.encodeInfoText.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null)
binding.encodeProgress.visibility = VISIBLE
binding.encodeInfoCard.visibility = VISIBLE
binding.encodeProgress.progress = progress

View File

@ -3,14 +3,16 @@ package org.pixeldroid.app.postCreation.photoEdit
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.graphics.Color
import android.graphics.Rect
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.format.DateUtils
import android.util.Log
import android.util.TypedValue
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -18,12 +20,16 @@ import android.widget.FrameLayout
import android.widget.ImageView
import androidx.core.net.toUri
import androidx.core.os.HandlerCompat
import androidx.core.view.isVisible
import androidx.media.AudioAttributesCompat
import androidx.media2.common.MediaMetadata
import androidx.media2.common.UriMediaItem
import androidx.media2.player.MediaPlayer
import androidx.media2.player.MediaPlayer.PlayerCallback
import com.arthenica.ffmpegkit.*
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.FFprobeKit
import com.arthenica.ffmpegkit.MediaInformation
import com.arthenica.ffmpegkit.ReturnCode
import com.bumptech.glide.Glide
import com.google.android.material.slider.RangeSlider
import org.pixeldroid.app.R
@ -33,14 +39,35 @@ import org.pixeldroid.app.postCreation.carousel.dpToPx
import org.pixeldroid.app.utils.BaseThemedWithBarActivity
import org.pixeldroid.app.utils.ffmpegCompliantUri
import java.io.File
import java.io.Serializable
import kotlin.math.absoluteValue
class VideoEditActivity : BaseThemedWithBarActivity() {
data class RelativeCropPosition(
// Width of the selected part of the video, relative to the width of the video
val relativeWidth: Float = 1f,
// Height of the selected part of the video, relative to the height of the video
val relativeHeight: Float = 1f,
// Distance of left corner of selected part, relative to the width of the video
val relativeX: Float = 0f,
// Distance of top of selected part, relative to the height of the video
val relativeY: Float = 0f,
): Serializable {
fun notCropped(): Boolean =
(relativeWidth - 1f).absoluteValue < 0.001f
&& (relativeHeight - 1f).absoluteValue < 0.001f
&& relativeX.absoluteValue < 0.001f
&& relativeY.absoluteValue < 0.001f
}
private lateinit var mediaPlayer: MediaPlayer
private var videoPosition: Int = -1
//TODO react to change of playbackSpeed (when changed in the player itself)
private var cropRelativeDimensions: RelativeCropPosition = RelativeCropPosition()
private var speed: Int = 1
set(value) {
field = value
@ -72,15 +99,12 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
val resultHandler: Handler = HandlerCompat.createAsync(Looper.getMainLooper())
val uri = intent.getParcelableExtra<Uri>(PhotoEditActivity.PICTURE_URI)!!
videoPosition = intent.getIntExtra(PhotoEditActivity.PICTURE_POSITION, -1)
val inputVideoPath = ffmpegCompliantUri(uri)
val mediaInformation: MediaInformation? = FFprobeKit.getMediaInformation(inputVideoPath).mediaInformation
binding.muter.setOnClickListener {
binding.muter.isSelected = !binding.muter.isSelected
}
//Duration in seconds, or null
val duration: Float? = mediaInformation?.duration?.toFloatOrNull()
@ -121,6 +145,50 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
binding.muter.isSelected = !binding.muter.isSelected
}
binding.cropper.setOnClickListener {
showCropInterface(show = true, uri = uri)
}
binding.saveCropButton.setOnClickListener {
// This is the rectangle selected by the crop
val cropRect = binding.cropImageView.cropWindowRect
// This is the rectangle of the whole image
val fullImageRect: Rect = binding.cropImageView.getInitialCropWindowRect()
// x, y are coordinates of top left, in the ImageView
val x = cropRect.left - fullImageRect.left
val y = cropRect.top - fullImageRect.top
// width and height selected by the crop
val width = cropRect.width()
val height = cropRect.height()
// To avoid having to calculate the dimensions of the video here, we pass
// relative width, height and x, y back to be treated in FFmpeg
cropRelativeDimensions = RelativeCropPosition(
relativeWidth = width/fullImageRect.width(),
relativeHeight = height/fullImageRect.height(),
relativeX = x/fullImageRect.width(),
relativeY = y/fullImageRect.height()
)
// If a crop was saved, change the color of the crop button to give a visual indication
if(!cropRelativeDimensions.notCropped()){
val typedValue = TypedValue()
val color: Int = if (binding.checkMarkCropped.context.theme
.resolveAttribute(R.attr.colorOnPrimaryContainer, typedValue, true)
) typedValue.data else Color.TRANSPARENT
binding.cropper.drawable.setTint(color)
} else {
// Else reset the tint
binding.cropper.drawable.setTintList(null)
}
showCropInterface(show = false)
}
binding.videoView.setPlayer(mediaPlayer)
mediaPlayer.seekTo((binding.videoRangeSeekBar.values[1]*1000).toLong())
@ -188,7 +256,6 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when(item.itemId) {
android.R.id.home -> onBackPressed()
R.id.action_save -> {
returnWithValues()
}
@ -201,7 +268,9 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
}
override fun onBackPressed() {
if (noEdits()) super.onBackPressed()
if(binding.cropImageView.isVisible) {
showCropInterface(false)
} else if (noEdits()) super.onBackPressed()
else {
val builder = AlertDialog.Builder(this)
builder.apply {
@ -225,9 +294,37 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
val muted = binding.muter.isSelected
val speedUnchanged = speed == 1
return !muted && videoPositions && speedUnchanged
return !muted && videoPositions && speedUnchanged && cropRelativeDimensions.notCropped()
}
private fun showCropInterface(show: Boolean, uri: Uri? = null){
val visibilityOfOthers = if(show) View.GONE else View.VISIBLE
val visibilityOfCrop = if(show) View.VISIBLE else View.GONE
if(show) mediaPlayer.pause()
if(show) binding.cropSavedCard.visibility = View.GONE
else if(!cropRelativeDimensions.notCropped()) binding.cropSavedCard.visibility = View.VISIBLE
binding.muter.visibility = visibilityOfOthers
binding.speeder.visibility = visibilityOfOthers
binding.cropper.visibility = visibilityOfOthers
binding.videoRangeSeekBar.visibility = visibilityOfOthers
binding.videoView.visibility = visibilityOfOthers
binding.thumbnail1.visibility = visibilityOfOthers
binding.thumbnail2.visibility = visibilityOfOthers
binding.thumbnail3.visibility = visibilityOfOthers
binding.thumbnail4.visibility = visibilityOfOthers
binding.thumbnail5.visibility = visibilityOfOthers
binding.thumbnail6.visibility = visibilityOfOthers
binding.thumbnail7.visibility = visibilityOfOthers
binding.cropImageView.visibility = visibilityOfCrop
binding.saveCropButton.visibility = visibilityOfCrop
if(show && uri != null) binding.cropImageView.setImageUriAsync(uri, cropRelativeDimensions)
}
private fun returnWithValues() {
val intent = Intent(this, PostCreationActivity::class.java)
@ -238,6 +335,7 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
putExtra(MODIFIED, !noEdits())
putExtra(VIDEO_START, binding.videoRangeSeekBar.values.first())
putExtra(VIDEO_END, binding.videoRangeSeekBar.values[2])
putExtra(VIDEO_CROP, cropRelativeDimensions)
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
}
@ -248,6 +346,11 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
private fun resetControls() {
binding.videoRangeSeekBar.values = listOf(0f, binding.videoRangeSeekBar.valueTo/2, binding.videoRangeSeekBar.valueTo)
binding.muter.isSelected = false
binding.cropImageView.resetCropRect()
cropRelativeDimensions = RelativeCropPosition()
binding.cropper.drawable.setTintList(null)
binding.cropSavedCard.visibility = View.GONE
}
override fun onDestroy() {
@ -310,6 +413,7 @@ class VideoEditActivity : BaseThemedWithBarActivity() {
val speedChoices: List<Number> = listOf(0.5, 1, 2, 4, 8)
const val VIDEO_START = "VideoEditVideoStartTag"
const val VIDEO_END = "VideoEditVideoEndTag"
const val VIDEO_CROP = "VideoEditVideoCropTag"
const val MODIFIED = "VideoEditModifiedTag"
}
}

View File

@ -0,0 +1,105 @@
package org.pixeldroid.app.postCreation.photoEdit.cropper
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
import android.content.Context
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import androidx.core.graphics.toRect
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import org.pixeldroid.app.databinding.CropImageViewBinding
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity
/** Custom view that provides cropping capabilities to an image. */
class CropImageView @JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) :
FrameLayout(context!!, attrs) {
private val binding: CropImageViewBinding =
CropImageViewBinding.inflate(LayoutInflater.from(context), this, true)
init {
binding.CropOverlayView.setInitialAttributeValues()
}
/**
* Gets the crop window's position relative to the parent's view at screen.
*
* @return a Rect instance containing notCropped area boundaries of the source Bitmap
*/
val cropWindowRect: RectF
get() = binding.CropOverlayView.cropWindowRect
/** Reset crop window to initial rectangle. */
fun resetCropRect() {
binding.CropOverlayView.resetCropWindowRect()
}
fun getInitialCropWindowRect(): Rect = binding.CropOverlayView.initialCropWindowRect
/**
* Sets the image loaded from the given URI as the content of the CropImageView
*
* @param uri the URI to load the image from
*/
fun setImageUriAsync(uri: Uri, cropRelativeDimensions: VideoEditActivity.RelativeCropPosition) {
// either no existing task is working or we canceled it, need to load new URI
binding.CropOverlayView.initialCropWindowRect = Rect()
Glide.with(this).load(uri).fitCenter().listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
m: Any?,
t: Target<Drawable>?,
i: Boolean,
): Boolean {
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean,
): Boolean {
// Get width and height that the image will take on the screen
val drawnWidth = resource?.intrinsicWidth ?: width
val drawnHeight = resource?.intrinsicHeight ?: height
binding.CropOverlayView.initialCropWindowRect = RectF(
(width - drawnWidth) / 2f,
(height - drawnHeight) / 2f,
(width + drawnWidth) / 2f,
(height + drawnHeight) / 2f
).toRect()
binding.CropOverlayView.setCropWindowLimits(
drawnWidth.toFloat(),
drawnHeight.toFloat()
)
binding.CropOverlayView.invalidate()
binding.CropOverlayView.setBounds(width, height)
binding.CropOverlayView.resetCropOverlayView()
if (!cropRelativeDimensions.notCropped()) binding.CropOverlayView.setRecordedCropWindowRect(cropRelativeDimensions)
binding.CropOverlayView.visibility = VISIBLE
// Indicate to Glide that the image hasn't been set yet
return false
}
}).into(binding.ImageViewImage)
}
}

View File

@ -0,0 +1,490 @@
package org.pixeldroid.app.postCreation.photoEdit.cropper
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
import android.content.Context
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.util.TypedValue
import android.view.MotionEvent
import android.view.View
import org.pixeldroid.app.postCreation.photoEdit.VideoEditActivity.RelativeCropPosition
import kotlin.math.max
import kotlin.math.min
/** A custom View representing the crop window and the shaded background outside the crop window. */
class CropOverlayView // endregion
@JvmOverloads constructor(context: Context?, attrs: AttributeSet? = null) : View(context, attrs) {
// region: Fields and Consts
/** Handler from crop window stuff, moving and knowing position. */
private val mCropWindowHandler = CropWindowHandler()
/** The Paint used to draw the white rectangle around the crop area. */
private var mBorderPaint: Paint? = null
/** The Paint used to draw the corners of the Border */
private var mBorderCornerPaint: Paint? = null
/** The Paint used to draw the guidelines within the crop area when pressed. */
private var mGuidelinePaint: Paint? = null
/** The bounding box around the Bitmap that we are cropping. */
private val mCalcBounds = RectF()
/** The bounding image view width used to know the crop overlay is at view edges. */
private var mViewWidth = 0
/** The bounding image view height used to know the crop overlay is at view edges. */
private var mViewHeight = 0
/** The Handle that is currently pressed; null if no Handle is pressed. */
private var mMoveHandler: CropWindowMoveHandler? = null
/** the initial crop window rectangle to set */
private val mInitialCropWindowRect = Rect()
/** Whether the Crop View has been initialized for the first time */
private var initializedCropWindow = false
/** Get the left/top/right/bottom coordinates of the crop window. */
/** Set the left/top/right/bottom coordinates of the crop window. */
var cropWindowRect: RectF
get() = mCropWindowHandler.rect
set(rect) {
mCropWindowHandler.rect = rect
}
/**
* Informs the CropOverlayView of the image's position relative to the ImageView. This is
* necessary to call in order to draw the crop window.
*
* @param viewWidth The bounding image view width.
* @param viewHeight The bounding image view height.
*/
fun setBounds(viewWidth: Int, viewHeight: Int) {
mViewWidth = viewWidth
mViewHeight = viewHeight
val cropRect = mCropWindowHandler.rect
if (cropRect.width() == 0f || cropRect.height() == 0f) {
initCropWindow()
}
}
/** Resets the crop overlay view. */
fun resetCropOverlayView() {
if (initializedCropWindow) {
cropWindowRect = RectF()
initCropWindow()
invalidate()
}
}
/**
* Set the max width/height and scale factor of the shown image to original image to scale the
* limits appropriately.
*/
fun setCropWindowLimits(maxWidth: Float, maxHeight: Float) {
mCropWindowHandler.setCropWindowLimits(maxWidth, maxHeight)
}
/** Get crop window initial rectangle. */
/** Set crop window initial rectangle to be used instead of default. */
var initialCropWindowRect: Rect
get() = mInitialCropWindowRect
set(rect) {
mInitialCropWindowRect.set(rect)
if (initializedCropWindow) {
initCropWindow()
invalidate()
}
}
fun setRecordedCropWindowRect(relativeCropPosition: RelativeCropPosition) {
val rect = RectF(
mInitialCropWindowRect.left + relativeCropPosition.relativeX * mInitialCropWindowRect.width(),
mInitialCropWindowRect.top + relativeCropPosition.relativeY * mInitialCropWindowRect.height(),
relativeCropPosition.relativeWidth * mInitialCropWindowRect.width() + mInitialCropWindowRect.left + relativeCropPosition.relativeX * mInitialCropWindowRect.width(),
relativeCropPosition.relativeHeight * mInitialCropWindowRect.height() + mInitialCropWindowRect.top + relativeCropPosition.relativeY * mInitialCropWindowRect.height()
)
mCropWindowHandler.rect = rect
}
/** Reset crop window to initial rectangle. */
fun resetCropWindowRect() {
if (initializedCropWindow) {
initCropWindow()
invalidate()
}
}
/**
* Sets all initial values, but does not call initCropWindow to reset the views.<br></br>
* Used once at the very start to initialize the attributes.
*/
fun setInitialAttributeValues() {
val dm = Resources.getSystem().displayMetrics
mBorderPaint = getNewPaintOfThickness(
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3f, dm),
Color.argb(170, 255, 255, 255)
)
mBorderCornerPaint = getNewPaintOfThickness(
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 2f, dm),
Color.WHITE
)
mGuidelinePaint = getNewPaintOfThickness(
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1f, dm),
Color.argb(170, 255, 255, 255)
)
}
// region: Private methods
/**
* Set the initial crop window size and position. This is dependent on the size and position of
* the image being cropped.
*/
private fun initCropWindow() {
val rect = RectF()
// Tells the attribute functions the crop window has already been initialized
initializedCropWindow = true
if (mInitialCropWindowRect.width() > 0 && mInitialCropWindowRect.height() > 0) {
// Get crop window position relative to the displayed image.
rect.left = mInitialCropWindowRect.left.toFloat()
rect.top = mInitialCropWindowRect.top.toFloat()
rect.right = rect.left + mInitialCropWindowRect.width()
rect.bottom = rect.top + mInitialCropWindowRect.height()
}
fixCropWindowRectByRules(rect)
mCropWindowHandler.rect = rect
}
/** Fix the given rect to fit into bitmap rect and follow min, max and aspect ratio rules. */
private fun fixCropWindowRectByRules(rect: RectF) {
if (rect.width() < mCropWindowHandler.minCropWidth) {
val adj = (mCropWindowHandler.minCropWidth - rect.width()) / 2
rect.left -= adj
rect.right += adj
}
if (rect.height() < mCropWindowHandler.minCropHeight) {
val adj = (mCropWindowHandler.minCropHeight - rect.height()) / 2
rect.top -= adj
rect.bottom += adj
}
if (rect.width() > mCropWindowHandler.maxCropWidth) {
val adj = (rect.width() - mCropWindowHandler.maxCropWidth) / 2
rect.left += adj
rect.right -= adj
}
if (rect.height() > mCropWindowHandler.maxCropHeight) {
val adj = (rect.height() - mCropWindowHandler.maxCropHeight) / 2
rect.top += adj
rect.bottom -= adj
}
setBounds()
if (mCalcBounds.width() > 0 && mCalcBounds.height() > 0) {
val leftLimit = max(mCalcBounds.left, 0f)
val topLimit = max(mCalcBounds.top, 0f)
val rightLimit = min(mCalcBounds.right, width.toFloat())
val bottomLimit = min(mCalcBounds.bottom, height.toFloat())
if (rect.left < leftLimit) {
rect.left = leftLimit
}
if (rect.top < topLimit) {
rect.top = topLimit
}
if (rect.right > rightLimit) {
rect.right = rightLimit
}
if (rect.bottom > bottomLimit) {
rect.bottom = bottomLimit
}
}
}
/**
* Draw crop overview by drawing background over image not in the cropping area, then borders and
* guidelines.
*/
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Draw translucent background for the notCropped area.
drawBackground(canvas)
if (mCropWindowHandler.showGuidelines()) {
// Determines whether guidelines should be drawn or not
if (mMoveHandler != null) {
// Draw only when resizing
drawGuidelines(canvas)
}
}
drawBorders(canvas)
drawCorners(canvas)
}
/** Draw shadow background over the image not including the crop area. */
private fun drawBackground(canvas: Canvas) {
val rect = mCropWindowHandler.rect
val background = getNewPaint(Color.argb(119, 0, 0, 0))
canvas.drawRect(
mInitialCropWindowRect.left.toFloat(),
mInitialCropWindowRect.top.toFloat(),
rect.left,
mInitialCropWindowRect.bottom.toFloat(),
background
)
canvas.drawRect(
rect.left,
rect.bottom,
mInitialCropWindowRect.right.toFloat(),
mInitialCropWindowRect.bottom.toFloat(),
background
)
canvas.drawRect(
rect.right,
mInitialCropWindowRect.top.toFloat(),
mInitialCropWindowRect.right.toFloat(),
rect.bottom,
background
)
canvas.drawRect(
rect.left,
mInitialCropWindowRect.top.toFloat(),
rect.right,
rect.top,
background
)
}
/**
* Draw 2 vertical and 2 horizontal guidelines inside the cropping area to split it into 9 equal
* parts.
*/
private fun drawGuidelines(canvas: Canvas) {
if (mGuidelinePaint != null) {
val sw: Float = if (mBorderPaint != null) mBorderPaint!!.strokeWidth else 0f
val rect = mCropWindowHandler.rect
rect.inset(sw, sw)
val oneThirdCropWidth = rect.width() / 3
val oneThirdCropHeight = rect.height() / 3
// Draw vertical guidelines.
val x1 = rect.left + oneThirdCropWidth
val x2 = rect.right - oneThirdCropWidth
canvas.drawLine(x1, rect.top, x1, rect.bottom, mGuidelinePaint!!)
canvas.drawLine(x2, rect.top, x2, rect.bottom, mGuidelinePaint!!)
// Draw horizontal guidelines.
val y1 = rect.top + oneThirdCropHeight
val y2 = rect.bottom - oneThirdCropHeight
canvas.drawLine(rect.left, y1, rect.right, y1, mGuidelinePaint!!)
canvas.drawLine(rect.left, y2, rect.right, y2, mGuidelinePaint!!)
}
}
/** Draw borders of the crop area. */
private fun drawBorders(canvas: Canvas) {
if (mBorderPaint != null) {
val w = mBorderPaint!!.strokeWidth
val rect = mCropWindowHandler.rect
// Make the rectangle a bit smaller to accommodate for the border
rect.inset(w / 2, w / 2)
// Draw rectangle crop window border.
canvas.drawRect(rect, mBorderPaint!!)
}
}
/** Draw the corner of crop overlay. */
private fun drawCorners(canvas: Canvas) {
val dm = Resources.getSystem().displayMetrics
if (mBorderCornerPaint != null) {
val lineWidth: Float = if (mBorderPaint != null) mBorderPaint!!.strokeWidth else 0f
val cornerWidth = mBorderCornerPaint!!.strokeWidth
// The corners should be a bit offset from the borders
val w = (cornerWidth / 2
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5f, dm))
val rect = mCropWindowHandler.rect
rect.inset(w, w)
val cornerOffset = (cornerWidth - lineWidth) / 2
val cornerExtension = cornerWidth / 2 + cornerOffset
/* the length of the border corner to draw */
val mBorderCornerLength =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14f, dm)
// Top left
canvas.drawLine(
rect.left - cornerOffset,
rect.top - cornerExtension,
rect.left - cornerOffset,
rect.top + mBorderCornerLength,
mBorderCornerPaint!!
)
canvas.drawLine(
rect.left - cornerExtension,
rect.top - cornerOffset,
rect.left + mBorderCornerLength,
rect.top - cornerOffset,
mBorderCornerPaint!!
)
// Top right
canvas.drawLine(
rect.right + cornerOffset,
rect.top - cornerExtension,
rect.right + cornerOffset,
rect.top + mBorderCornerLength,
mBorderCornerPaint!!
)
canvas.drawLine(
rect.right + cornerExtension,
rect.top - cornerOffset,
rect.right - mBorderCornerLength,
rect.top - cornerOffset,
mBorderCornerPaint!!
)
// Bottom left
canvas.drawLine(
rect.left - cornerOffset,
rect.bottom + cornerExtension,
rect.left - cornerOffset,
rect.bottom - mBorderCornerLength,
mBorderCornerPaint!!
)
canvas.drawLine(
rect.left - cornerExtension,
rect.bottom + cornerOffset,
rect.left + mBorderCornerLength,
rect.bottom + cornerOffset,
mBorderCornerPaint!!
)
// Bottom left
canvas.drawLine(
rect.right + cornerOffset,
rect.bottom + cornerExtension,
rect.right + cornerOffset,
rect.bottom - mBorderCornerLength,
mBorderCornerPaint!!
)
canvas.drawLine(
rect.right + cornerExtension,
rect.bottom + cornerOffset,
rect.right - mBorderCornerLength,
rect.bottom + cornerOffset,
mBorderCornerPaint!!
)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
// If this View is not enabled, don't allow for touch interactions.
return if (isEnabled) {
/* Boolean to see if multi touch is enabled for the crop rectangle */
when (event.action) {
MotionEvent.ACTION_DOWN -> {
onActionDown(event.x, event.y)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent.requestDisallowInterceptTouchEvent(false)
onActionUp()
true
}
MotionEvent.ACTION_MOVE -> {
onActionMove(event.x, event.y)
parent.requestDisallowInterceptTouchEvent(true)
true
}
else -> false
}
} else {
false
}
}
/**
* On press down start crop window movement depending on the location of the press.<br></br>
* if press is far from crop window then no move handler is returned (null).
*/
private fun onActionDown(x: Float, y: Float) {
val dm = Resources.getSystem().displayMetrics
mMoveHandler = mCropWindowHandler.getMoveHandler(
x,
y,
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24f, dm)
)
if (mMoveHandler != null) {
invalidate()
}
}
/** Clear move handler starting in [.onActionDown] if exists. */
private fun onActionUp() {
if (mMoveHandler != null) {
mMoveHandler = null
invalidate()
}
}
/**
* Handle move of crop window using the move handler created in [.onActionDown].<br></br>
* The move handler will do the proper move/resize of the crop window.
*/
private fun onActionMove(x: Float, y: Float) {
if (mMoveHandler != null) {
val rect = mCropWindowHandler.rect
setBounds()
val dm = Resources.getSystem().displayMetrics
val snapRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3f, dm)
mMoveHandler!!.move(
rect,
x,
y,
mCalcBounds,
mViewWidth,
mViewHeight,
snapRadius
)
mCropWindowHandler.rect = rect
invalidate()
}
}
/**
* Calculate the bounding rectangle for current crop window
* The bounds rectangle is the bitmap rectangle
*/
private fun setBounds() {
mCalcBounds.set(mInitialCropWindowRect)
}
companion object {
/** Creates the Paint object for drawing. */
private fun getNewPaint(color: Int): Paint {
val paint = Paint()
paint.color = color
return paint
}
/** Creates the Paint object for given thickness and color */
private fun getNewPaintOfThickness(thickness: Float, color: Int): Paint {
val borderPaint = Paint()
borderPaint.color = color
borderPaint.strokeWidth = thickness
borderPaint.style = Paint.Style.STROKE
borderPaint.isAntiAlias = true
return borderPaint
}
}
}

View File

@ -0,0 +1,269 @@
package org.pixeldroid.app.postCreation.photoEdit.cropper
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
import android.content.res.Resources
import android.graphics.RectF
import android.util.TypedValue
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/** Handler from crop window stuff, moving and knowing position. */
internal class CropWindowHandler {
/** The 4 edges of the crop window defining its coordinates and size */
private val mEdges = RectF()
/**
* Rectangle used to return the edges rectangle without ability to change it and without
* creating new all the time.
*/
private val mGetEdges = RectF()
/** Maximum width in pixels that the crop window can CURRENTLY get. */
private var mMaxCropWindowWidth = 0f
/** Maximum height in pixels that the crop window can CURRENTLY get. */
private var mMaxCropWindowHeight = 0f
/** The left/top/right/bottom coordinates of the crop window. */
var rect: RectF
get() {
mGetEdges.set(mEdges)
return mGetEdges
}
set(rect) {
mEdges.set(rect)
}
/** Minimum width in pixels that the crop window can get. */
val minCropWidth: Float
get() {
val dm = Resources.getSystem().displayMetrics
val mMinCropResultWidth = 40f
return max(
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42f, dm).toInt().toFloat(),
mMinCropResultWidth
)
}
/** Minimum height in pixels that the crop window can get. */
val minCropHeight: Float
get() {
val dm = Resources.getSystem().displayMetrics
val mMinCropResultHeight = 40f
return max(
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 42f, dm).toInt().toFloat(),
mMinCropResultHeight
)
}
/** Maximum width in pixels that the crop window can get. */
val maxCropWidth: Float
get() {
val mMaxCropResultWidth = 99999f
return min(mMaxCropWindowWidth, mMaxCropResultWidth)
}
/** Maximum height in pixels that the crop window can get. */
val maxCropHeight: Float
get() {
val mMaxCropResultHeight = 99999f
return min(mMaxCropWindowHeight, mMaxCropResultHeight)
}
/**
* Set the max width/height of the shown image to original image to scale the limits appropriately
*/
fun setCropWindowLimits(maxWidth: Float, maxHeight: Float) {
mMaxCropWindowWidth = maxWidth
mMaxCropWindowHeight = maxHeight
}
/**
* Indicates whether the crop window is small enough that the guidelines should be shown. Public
* because this function is also used to determine if the center handle should be focused.
*
* @return boolean Whether the guidelines should be shown or not
*/
fun showGuidelines(): Boolean {
return !(mEdges.width() < 100 || mEdges.height() < 100)
}
/**
* Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
* box, and the touch radius.
*
* @param x the x-coordinate of the touch point
* @param y the y-coordinate of the touch point
* @param targetRadius the target radius in pixels
* @return the Handle that was pressed; null if no Handle was pressed
*/
fun getMoveHandler(x: Float, y: Float, targetRadius: Float): CropWindowMoveHandler? {
val type = getRectanglePressedMoveType(x, y, targetRadius)
return if (type != null) CropWindowMoveHandler(type, this, x, y) else null
}
// region: Private methods
/**
* Determines which, if any, of the handles are pressed given the touch coordinates, the bounding
* box, and the touch radius.
*
* @param x the x-coordinate of the touch point
* @param y the y-coordinate of the touch point
* @param targetRadius the target radius in pixels
* @return the Handle that was pressed; null if no Handle was pressed
*/
private fun getRectanglePressedMoveType(
x: Float, y: Float, targetRadius: Float
): CropWindowMoveHandler.Type? {
var moveType: CropWindowMoveHandler.Type? = null
// Note: corner-handles take precedence, then side-handles, then center.
if (isInCornerTargetZone(x, y, mEdges.left, mEdges.top, targetRadius)) {
moveType = CropWindowMoveHandler.Type.TOP_LEFT
} else if (isInCornerTargetZone(
x, y, mEdges.right, mEdges.top, targetRadius
)
) {
moveType = CropWindowMoveHandler.Type.TOP_RIGHT
} else if (isInCornerTargetZone(
x, y, mEdges.left, mEdges.bottom, targetRadius
)
) {
moveType = CropWindowMoveHandler.Type.BOTTOM_LEFT
} else if (isInCornerTargetZone(
x, y, mEdges.right, mEdges.bottom, targetRadius
)
) {
moveType = CropWindowMoveHandler.Type.BOTTOM_RIGHT
} else if (isInCenterTargetZone(
x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom
)
&& focusCenter()
) {
moveType = CropWindowMoveHandler.Type.CENTER
} else if (isInHorizontalTargetZone(
x, y, mEdges.left, mEdges.right, mEdges.top, targetRadius
)
) {
moveType = CropWindowMoveHandler.Type.TOP
} else if (isInHorizontalTargetZone(
x, y, mEdges.left, mEdges.right, mEdges.bottom, targetRadius
)
) {
moveType = CropWindowMoveHandler.Type.BOTTOM
} else if (isInVerticalTargetZone(
x, y, mEdges.left, mEdges.top, mEdges.bottom, targetRadius
)
) {
moveType = CropWindowMoveHandler.Type.LEFT
} else if (isInVerticalTargetZone(
x, y, mEdges.right, mEdges.top, mEdges.bottom, targetRadius
)
) {
moveType = CropWindowMoveHandler.Type.RIGHT
} else if (isInCenterTargetZone(
x, y, mEdges.left, mEdges.top, mEdges.right, mEdges.bottom
)
&& !focusCenter()
) {
moveType = CropWindowMoveHandler.Type.CENTER
}
return moveType
}
/**
* Determines if the cropper should focus on the center handle or the side handles. If it is a
* small image, focus on the center handle so the user can move it. If it is a large image, focus
* on the side handles so user can grab them. Corresponds to the appearance of the
* RuleOfThirdsGuidelines.
*
* @return true if it is small enough such that it should focus on the center; less than
* show_guidelines limit
*/
private fun focusCenter(): Boolean = !showGuidelines()
// endregion
companion object {
/**
* Determines if the specified coordinate is in the target touch zone for a corner handle.
*
* @param x the x-coordinate of the touch point
* @param y the y-coordinate of the touch point
* @param handleX the x-coordinate of the corner handle
* @param handleY the y-coordinate of the corner handle
* @param targetRadius the target radius in pixels
* @return true if the touch point is in the target touch zone; false otherwise
*/
private fun isInCornerTargetZone(
x: Float, y: Float, handleX: Float, handleY: Float, targetRadius: Float
): Boolean {
return abs(x - handleX) <= targetRadius && abs(y - handleY) <= targetRadius
}
/**
* Determines if the specified coordinate is in the target touch zone for a horizontal bar handle.
*
* @param x the x-coordinate of the touch point
* @param y the y-coordinate of the touch point
* @param handleXStart the left x-coordinate of the horizontal bar handle
* @param handleXEnd the right x-coordinate of the horizontal bar handle
* @param handleY the y-coordinate of the horizontal bar handle
* @param targetRadius the target radius in pixels
* @return true if the touch point is in the target touch zone; false otherwise
*/
private fun isInHorizontalTargetZone(
x: Float,
y: Float,
handleXStart: Float,
handleXEnd: Float,
handleY: Float,
targetRadius: Float
): Boolean {
return x > handleXStart && x < handleXEnd && abs(y - handleY) <= targetRadius
}
/**
* Determines if the specified coordinate is in the target touch zone for a vertical bar handle.
*
* @param x the x-coordinate of the touch point
* @param y the y-coordinate of the touch point
* @param handleX the x-coordinate of the vertical bar handle
* @param handleYStart the top y-coordinate of the vertical bar handle
* @param handleYEnd the bottom y-coordinate of the vertical bar handle
* @param targetRadius the target radius in pixels
* @return true if the touch point is in the target touch zone; false otherwise
*/
private fun isInVerticalTargetZone(
x: Float,
y: Float,
handleX: Float,
handleYStart: Float,
handleYEnd: Float,
targetRadius: Float
): Boolean {
return abs(x - handleX) <= targetRadius && y > handleYStart && y < handleYEnd
}
/**
* Determines if the specified coordinate falls anywhere inside the given bounds.
*
* @param x the x-coordinate of the touch point
* @param y the y-coordinate of the touch point
* @param left the x-coordinate of the left bound
* @param top the y-coordinate of the top bound
* @param right the x-coordinate of the right bound
* @param bottom the y-coordinate of the bottom bound
* @return true if the touch point is inside the bounding rectangle; false otherwise
*/
private fun isInCenterTargetZone(
x: Float, y: Float, left: Float, top: Float, right: Float, bottom: Float
): Boolean {
return x > left && x < right && y > top && y < bottom
}
}
}

View File

@ -0,0 +1,405 @@
package org.pixeldroid.app.postCreation.photoEdit.cropper
// Simplified version of https://github.com/ArthurHub/Android-Image-Cropper , which is
// licensed under the Apache License, Version 2.0. The modifications made to it for PixelDroid
// are under licensed under the GPLv3 or later, just like the rest of the PixelDroid project
import android.graphics.PointF
import android.graphics.RectF
/**
* Handler to update crop window edges by the move type - Horizontal, Vertical, Corner or Center.
*/
internal class CropWindowMoveHandler(
/** The type of crop window move that is handled. */
private val mType: Type,
cropWindowHandler: CropWindowHandler, touchX: Float, touchY: Float
) {
/** Minimum width in pixels that the crop window can get. */
private val mMinCropWidth: Float
/** Minimum width in pixels that the crop window can get. */
private val mMinCropHeight: Float
/** Maximum height in pixels that the crop window can get. */
private val mMaxCropWidth: Float
/** Maximum height in pixels that the crop window can get. */
private val mMaxCropHeight: Float
/**
* Holds the x and y offset between the exact touch location and the exact handle location that is
* activated. There may be an offset because we allow for some leeway (specified by mHandleRadius)
* in activating a handle. However, we want to maintain these offset values while the handle is
* being dragged so that the handle doesn't jump.
*/
private val mTouchOffset = PointF()
init {
mMinCropWidth = cropWindowHandler.minCropWidth
mMinCropHeight = cropWindowHandler.minCropHeight
mMaxCropWidth = cropWindowHandler.maxCropWidth
mMaxCropHeight = cropWindowHandler.maxCropHeight
calculateTouchOffset(cropWindowHandler.rect, touchX, touchY)
}
/**
* Updates the crop window by change in the touch location.
* Move type handled by this instance, as initialized in creation, affects how the change in
* touch location changes the crop window position and size.
* After the crop window position/size is changed by touch move it may result in values that
* violate constraints: outside the bounds of the shown bitmap, smaller/larger than min/max size or
* mismatch in aspect ratio. So a series of fixes is executed on "secondary" edges to adjust it
* by the "primary" edge movement.
* Primary is the edge directly affected by move type, secondary is the other edge.
* The crop window is changed by directly setting the Edge coordinates.
*
* @param x the new x-coordinate of this handle
* @param y the new y-coordinate of this handle
* @param bounds the bounding rectangle of the image
* @param viewWidth The bounding image view width used to know the crop overlay is at view edges.
* @param viewHeight The bounding image view height used to know the crop overlay is at view
* edges.
* @param snapMargin the maximum distance (in pixels) at which the crop window should snap to the
* image
*/
fun move(
rect: RectF,
x: Float,
y: Float,
bounds: RectF,
viewWidth: Int,
viewHeight: Int,
snapMargin: Float
) {
// Adjust the coordinates for the finger position's offset (i.e. the
// distance from the initial touch to the precise handle location).
// We want to maintain the initial touch's distance to the pressed
// handle so that the crop window size does not "jump".
val adjX = x + mTouchOffset.x
val adjY = y + mTouchOffset.y
if (mType == Type.CENTER) {
moveCenter(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin)
} else {
changeSize(rect, adjX, adjY, bounds, viewWidth, viewHeight, snapMargin)
}
}
// region: Private methods
/**
* Calculates the offset of the touch point from the precise location of the specified handle.<br></br>
* Save these values in a member variable since we want to maintain this offset as we drag the
* handle.
*/
private fun calculateTouchOffset(rect: RectF, touchX: Float, touchY: Float) {
var touchOffsetX = 0f
var touchOffsetY = 0f
when (mType) {
Type.TOP_LEFT -> {
touchOffsetX = rect.left - touchX
touchOffsetY = rect.top - touchY
}
Type.TOP_RIGHT -> {
touchOffsetX = rect.right - touchX
touchOffsetY = rect.top - touchY
}
Type.BOTTOM_LEFT -> {
touchOffsetX = rect.left - touchX
touchOffsetY = rect.bottom - touchY
}
Type.BOTTOM_RIGHT -> {
touchOffsetX = rect.right - touchX
touchOffsetY = rect.bottom - touchY
}
Type.LEFT -> {
touchOffsetX = rect.left - touchX
touchOffsetY = 0f
}
Type.TOP -> {
touchOffsetX = 0f
touchOffsetY = rect.top - touchY
}
Type.RIGHT -> {
touchOffsetX = rect.right - touchX
touchOffsetY = 0f
}
Type.BOTTOM -> {
touchOffsetX = 0f
touchOffsetY = rect.bottom - touchY
}
Type.CENTER -> {
touchOffsetX = rect.centerX() - touchX
touchOffsetY = rect.centerY() - touchY
}
}
mTouchOffset.x = touchOffsetX
mTouchOffset.y = touchOffsetY
}
/** Center move only changes the position of the crop window without changing the size. */
private fun moveCenter(
rect: RectF,
x: Float,
y: Float,
bounds: RectF,
viewWidth: Int,
viewHeight: Int,
snapRadius: Float
) {
var dx = x - rect.centerX()
var dy = y - rect.centerY()
if (rect.left + dx < 0 || rect.right + dx > viewWidth || rect.left + dx < bounds.left || rect.right + dx > bounds.right) {
dx /= 1.05f
mTouchOffset.x -= dx / 2
}
if (rect.top + dy < 0 || rect.bottom + dy > viewHeight || rect.top + dy < bounds.top || rect.bottom + dy > bounds.bottom) {
dy /= 1.05f
mTouchOffset.y -= dy / 2
}
rect.offset(dx, dy)
snapEdgesToBounds(rect, bounds, snapRadius)
}
/**
* Change the size of the crop window on the required edge (or edges in the case of a corner)
*/
private fun changeSize(
rect: RectF,
x: Float,
y: Float,
bounds: RectF,
viewWidth: Int,
viewHeight: Int,
snapMargin: Float
) {
when (mType) {
Type.TOP_LEFT -> {
adjustTop(rect, y, bounds, snapMargin)
adjustLeft(rect, x, bounds, snapMargin)
}
Type.TOP_RIGHT -> {
adjustTop(rect, y, bounds, snapMargin)
adjustRight(rect, x, bounds, viewWidth, snapMargin)
}
Type.BOTTOM_LEFT -> {
adjustBottom(rect, y, bounds, viewHeight, snapMargin)
adjustLeft(rect, x, bounds, snapMargin)
}
Type.BOTTOM_RIGHT -> {
adjustBottom(rect, y, bounds, viewHeight, snapMargin)
adjustRight(rect, x, bounds, viewWidth, snapMargin)
}
Type.LEFT -> adjustLeft(rect, x, bounds, snapMargin)
Type.TOP -> adjustTop(rect, y, bounds, snapMargin)
Type.RIGHT -> adjustRight(rect, x, bounds, viewWidth, snapMargin)
Type.BOTTOM -> adjustBottom(rect, y, bounds, viewHeight, snapMargin)
else -> {}
}
}
/** Check if edges have gone out of bounds (including snap margin), and fix if needed. */
private fun snapEdgesToBounds(edges: RectF, bounds: RectF, margin: Float) {
if (edges.left < bounds.left + margin) {
edges.offset(bounds.left - edges.left, 0f)
}
if (edges.top < bounds.top + margin) {
edges.offset(0f, bounds.top - edges.top)
}
if (edges.right > bounds.right - margin) {
edges.offset(bounds.right - edges.right, 0f)
}
if (edges.bottom > bounds.bottom - margin) {
edges.offset(0f, bounds.bottom - edges.bottom)
}
}
/**
* Get the resulting x-position of the left edge of the crop window given the handle's position
* and the image's bounding box and snap radius.
*
* @param left the position that the left edge is dragged to
* @param bounds the bounding box of the image that is being notCropped
* @param snapMargin the snap distance to the image edge (in pixels)
*/
private fun adjustLeft(
rect: RectF,
left: Float,
bounds: RectF,
snapMargin: Float
) {
var newLeft = left
if (newLeft < 0) {
newLeft /= 1.05f
mTouchOffset.x -= newLeft / 1.1f
}
if (newLeft < bounds.left) {
mTouchOffset.x -= (newLeft - bounds.left) / 2f
}
if (newLeft - bounds.left < snapMargin) {
newLeft = bounds.left
}
// Checks if the window is too small horizontally
if (rect.right - newLeft < mMinCropWidth) {
newLeft = rect.right - mMinCropWidth
}
// Checks if the window is too large horizontally
if (rect.right - newLeft > mMaxCropWidth) {
newLeft = rect.right - mMaxCropWidth
}
if (newLeft - bounds.left < snapMargin) {
newLeft = bounds.left
}
rect.left = newLeft
}
/**
* Get the resulting x-position of the right edge of the crop window given the handle's position
* and the image's bounding box and snap radius.
*
* @param right the position that the right edge is dragged to
* @param bounds the bounding box of the image that is being notCropped
* @param viewWidth
* @param snapMargin the snap distance to the image edge (in pixels)
*/
private fun adjustRight(
rect: RectF,
right: Float,
bounds: RectF,
viewWidth: Int,
snapMargin: Float
) {
var newRight = right
if (newRight > viewWidth) {
newRight = viewWidth + (newRight - viewWidth) / 1.05f
mTouchOffset.x -= (newRight - viewWidth) / 1.1f
}
if (newRight > bounds.right) {
mTouchOffset.x -= (newRight - bounds.right) / 2f
}
// If close to the edge
if (bounds.right - newRight < snapMargin) {
newRight = bounds.right
}
// Checks if the window is too small horizontally
if (newRight - rect.left < mMinCropWidth) {
newRight = rect.left + mMinCropWidth
}
// Checks if the window is too large horizontally
if (newRight - rect.left > mMaxCropWidth) {
newRight = rect.left + mMaxCropWidth
}
// If close to the edge
if (bounds.right - newRight < snapMargin) {
newRight = bounds.right
}
rect.right = newRight
}
/**
* Get the resulting y-position of the top edge of the crop window given the handle's position and
* the image's bounding box and snap radius.
*
* @param top the x-position that the top edge is dragged to
* @param bounds the bounding box of the image that is being notCropped
* @param snapMargin the snap distance to the image edge (in pixels)
*/
private fun adjustTop(
rect: RectF,
top: Float,
bounds: RectF,
snapMargin: Float
) {
var newTop = top
if (newTop < 0) {
newTop /= 1.05f
mTouchOffset.y -= newTop / 1.1f
}
if (newTop < bounds.top) {
mTouchOffset.y -= (newTop - bounds.top) / 2f
}
if (newTop - bounds.top < snapMargin) {
newTop = bounds.top
}
// Checks if the window is too small vertically
if (rect.bottom - newTop < mMinCropHeight) {
newTop = rect.bottom - mMinCropHeight
}
// Checks if the window is too large vertically
if (rect.bottom - newTop > mMaxCropHeight) {
newTop = rect.bottom - mMaxCropHeight
}
if (newTop - bounds.top < snapMargin) {
newTop = bounds.top
}
rect.top = newTop
}
/**
* Get the resulting y-position of the bottom edge of the crop window given the handle's position
* and the image's bounding box and snap radius.
*
* @param bottom the position that the bottom edge is dragged to
* @param bounds the bounding box of the image that is being notCropped
* @param viewHeight
* @param snapMargin the snap distance to the image edge (in pixels)
*/
private fun adjustBottom(
rect: RectF,
bottom: Float,
bounds: RectF,
viewHeight: Int,
snapMargin: Float
) {
var newBottom = bottom
if (newBottom > viewHeight) {
newBottom = viewHeight + (newBottom - viewHeight) / 1.05f
mTouchOffset.y -= (newBottom - viewHeight) / 1.1f
}
if (newBottom > bounds.bottom) {
mTouchOffset.y -= (newBottom - bounds.bottom) / 2f
}
if (bounds.bottom - newBottom < snapMargin) {
newBottom = bounds.bottom
}
// Checks if the window is too small vertically
if (newBottom - rect.top < mMinCropHeight) {
newBottom = rect.top + mMinCropHeight
}
// Checks if the window is too small vertically
if (newBottom - rect.top > mMaxCropHeight) {
newBottom = rect.top + mMaxCropHeight
}
if (bounds.bottom - newBottom < snapMargin) {
newBottom = bounds.bottom
}
rect.bottom = newBottom
}
// endregion
/** The type of crop window move that is handled. */
enum class Type {
TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT, LEFT, TOP, RIGHT, BOTTOM, CENTER
}
}

View File

@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="?attr/colorOnBackground"
android:fillColor="@android:color/white"
android:pathData="M17,15h2V7c0,-1.1 -0.9,-2 -2,-2H9v2h8v8zM7,17V1H5v4H1v2h4v10c0,1.1 0.9,2 2,2h10v4h2v-4h4v-2H7z"/>
</vector>

View File

@ -79,6 +79,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="411dp"
android:tint="?attr/colorOnBackground"
android:src="@drawable/ic_crop_black_24dp"
app:layout_constraintLeft_toLeftOf="@+id/left_guideline"
app:layout_constraintRight_toRightOf="@+id/right_guideline"

View File

@ -19,6 +19,30 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.pixeldroid.app.postCreation.photoEdit.cropper.CropImageView
android:id="@+id/cropImageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:visibility="gone"/>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/save_crop_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_margin="16dp"
android:visibility="gone"
android:text="@string/save_crop"
android:contentDescription="@string/save_crop"
app:icon="@drawable/ic_crop_black_24dp"/>
<ImageView
android:id="@+id/muter"
android:layout_width="60dp"
@ -32,7 +56,6 @@
app:layout_constraintBottom_toTopOf="@+id/thumbnail1"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/speeder"
android:layout_width="60dp"
@ -46,6 +69,58 @@
app:layout_constraintBottom_toTopOf="@+id/thumbnail1"
app:layout_constraintStart_toEndOf="@+id/muter" />
<ImageButton
android:id="@+id/cropper"
android:layout_width="60dp"
android:layout_height="40dp"
android:layout_marginStart="16dp"
android:layout_marginBottom="48dp"
android:contentDescription="@string/video_crop"
android:background="?attr/selectableItemBackgroundBorderless"
android:padding="4dp"
android:src="@drawable/ic_crop_black_24dp"
app:layout_constraintBottom_toTopOf="@+id/thumbnail1"
app:layout_constraintStart_toEndOf="@+id/speeder" />
<com.google.android.material.card.MaterialCardView
android:visibility="invisible"
tools:visibility="visible"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="@+id/cropper"
app:layout_constraintEnd_toEndOf="@+id/cropper"
app:layout_constraintBottom_toTopOf="@+id/cropper"
android:id="@+id/cropSavedCard"
android:layout_marginBottom="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp">
<ImageView
android:id="@+id/checkMarkCropped"
android:importantForAccessibility="no"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/check_circle_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<TextView
android:id="@+id/encodeInfoText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@id/checkMarkCropped"
app:layout_constraintTop_toTopOf="@id/checkMarkCropped"
app:layout_constraintStart_toEndOf="@id/checkMarkCropped"
android:layout_marginEnd="8dp"
android:text="@string/crop_saved" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.slider.RangeSlider
android:id="@+id/videoRangeSeekBar"
android:layout_width="match_parent"

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<org.pixeldroid.app.postCreation.photoEdit.cropper.CropImageView
android:id="@+id/cropImageView"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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_height="match_parent"
android:layout_width="match_parent">
<org.pixeldroid.app.postCreation.photoEdit.cropper.CropOverlayView
android:id="@+id/CropOverlayView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:elevation="2dp"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/ImageView_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerInside"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -271,6 +271,9 @@ For more info about Pixelfed, you can check here: https://pixelfed.org"</string>
<string name="select_video_range">Select what to keep of the video</string>
<string name="mute_video">Mute video</string>
<string name="video_speed">Change video speed</string>
<string name="video_crop">Crop video</string>
<string name="save_crop">Save crop</string>
<string name="crop_saved">Crop saved</string>
<string name="still_encoding">One or more videos are still encoding. Wait for them to finish before uploading</string>
<string name="new_post_shortcut_long">Create new post</string>
<string name="new_post_shortcut_short">New post</string>