react-native-video-trim 6.2.3 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,9 @@ import android.content.Context
4
4
  import android.content.pm.ActivityInfo
5
5
  import android.content.res.Configuration
6
6
  import android.graphics.Color
7
+ import android.graphics.Matrix
8
+ import android.graphics.RectF
9
+ import android.graphics.SurfaceTexture
7
10
  import android.graphics.drawable.GradientDrawable
8
11
  import android.media.MediaMetadataRetriever
9
12
  import android.media.MediaPlayer
@@ -17,15 +20,18 @@ import android.util.Log
17
20
  import android.util.TypedValue
18
21
  import android.view.GestureDetector
19
22
  import android.view.LayoutInflater
23
+ import android.view.Gravity
20
24
  import android.view.MotionEvent
25
+ import android.view.Surface
26
+ import android.view.TextureView
21
27
  import android.view.View
28
+ import android.view.animation.DecelerateInterpolator
22
29
  import android.widget.FrameLayout
23
30
  import android.widget.ImageView
24
31
  import android.widget.LinearLayout
25
32
  import android.widget.ProgressBar
26
33
  import android.widget.RelativeLayout
27
34
  import android.widget.TextView
28
- import android.widget.VideoView
29
35
 
30
36
  import androidx.appcompat.app.AlertDialog
31
37
 
@@ -64,11 +70,9 @@ class VideoTrimmerView(
64
70
  }
65
71
 
66
72
  private var mContext: ReactApplicationContext = context
67
- private lateinit var mVideoView: VideoView
73
+ private lateinit var mVideoView: TextureView
74
+ private var videoSurface: Surface? = null
68
75
 
69
- // mediaPlayer is used for both video/audio
70
- // the reason we use mediaPlayer for Video: https://stackoverflow.com/a/73361868/7569705
71
- // the videoPlayer is to solve the issue after manually seek -> hit play -> it starts from a position slightly before with the one we just sought to
72
76
  private var mediaPlayer: MediaPlayer? = null
73
77
  private lateinit var mPlayView: ImageView
74
78
  private lateinit var mThumbnailContainer: LinearLayout
@@ -96,8 +100,6 @@ class VideoTrimmerView(
96
100
 
97
101
  private var startTime = 0L
98
102
  private var endTime = 0L
99
- private var enableRotation = false
100
- private var rotationAngle = 0.0
101
103
  private var zoomOnWaitingDuration = 5000L
102
104
 
103
105
  private var vibrator: Vibrator? = null
@@ -135,6 +137,7 @@ class VideoTrimmerView(
135
137
 
136
138
  private var mOutputExt = "mp4"
137
139
  private var enableHapticFeedback = true
140
+ private var enablePreciseTrimming = false
138
141
  private var autoplay = false
139
142
  private var jumpToPositionOnLoad = 0L
140
143
  private lateinit var headerView: FrameLayout
@@ -146,6 +149,34 @@ class VideoTrimmerView(
146
149
  private var alertOnFailCloseText = "Close"
147
150
  private var currentSelectedhandle: View? = null
148
151
 
152
+ // Transform state
153
+ var rotationCount = 0
154
+ private set
155
+ var isFlipped = false
156
+ private set
157
+ private var isCropActive = false
158
+ private var cumulativeRotationDeg = 0f
159
+
160
+ private data class TransformSnapshot(
161
+ val rotationCount: Int,
162
+ val isFlipped: Boolean,
163
+ val isCropActive: Boolean,
164
+ val cropNormalized: RectF?,
165
+ val cumulativeRotationDeg: Float
166
+ )
167
+ private val undoStack = mutableListOf<TransformSnapshot>()
168
+ private val redoStack = mutableListOf<TransformSnapshot>()
169
+ private var preCropSnapshot: TransformSnapshot? = null
170
+
171
+ private lateinit var transformRow: LinearLayout
172
+ private lateinit var flipBtn: ImageView
173
+ private lateinit var rotateBtn: ImageView
174
+ private lateinit var cropBtn: ImageView
175
+ private lateinit var undoBtn: ImageView
176
+ private lateinit var redoBtn: ImageView
177
+ private lateinit var videoContainer: FrameLayout
178
+ private var cropOverlay: CropOverlayView? = null
179
+
149
180
  private lateinit var trimmerView: RelativeLayout
150
181
 
151
182
  private var trimmerColor = context.getString(R.string.trim_color).toColorInt()
@@ -216,23 +247,31 @@ class VideoTrimmerView(
216
247
 
217
248
  leadingChevron = findViewById(R.id.leadingChevron)
218
249
  trailingChevron = findViewById(R.id.trailingChevron)
250
+
251
+ transformRow = findViewById(R.id.transformRow)
252
+ flipBtn = findViewById(R.id.flipBtn)
253
+ rotateBtn = findViewById(R.id.rotateBtn)
254
+ cropBtn = findViewById(R.id.cropBtn)
255
+ undoBtn = findViewById(R.id.undoBtn)
256
+ redoBtn = findViewById(R.id.redoBtn)
257
+ videoContainer = findViewById(R.id.videoContainer)
219
258
  }
220
259
 
221
260
  fun initByURI(videoURI: Uri) {
222
261
  mSourceUri = videoURI
223
262
 
224
263
  if (isVideoType) {
225
- mVideoView.setVideoURI(videoURI)
226
- mVideoView.requestFocus()
227
-
228
- mVideoView.setOnPreparedListener { mp ->
229
- mp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT)
230
- mediaPlayer = mp
231
- mediaPrepared()
264
+ if (mVideoView.isAvailable) {
265
+ setupVideoPlayer(mVideoView.surfaceTexture!!, videoURI)
266
+ }
267
+ mVideoView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
268
+ override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
269
+ setupVideoPlayer(st, videoURI)
270
+ }
271
+ override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {}
272
+ override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean = true
273
+ override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
232
274
  }
233
-
234
- mVideoView.setOnErrorListener { mp, what, extra -> onFailToLoadMedia(mp, what, extra) }
235
- mVideoView.setOnCompletionListener { mediaCompleted() }
236
275
  } else {
237
276
  mVideoView.visibility = View.GONE
238
277
  audioBannerView.alpha = 0f
@@ -254,6 +293,57 @@ class VideoTrimmerView(
254
293
  }
255
294
  }
256
295
 
296
+ private fun setupVideoPlayer(surfaceTexture: SurfaceTexture, videoURI: Uri) {
297
+ if (mediaPlayer != null) return
298
+ val mp = MediaPlayer()
299
+ try {
300
+ videoSurface = Surface(surfaceTexture)
301
+ mp.setSurface(videoSurface)
302
+ mp.setDataSource(mContext, videoURI)
303
+ mp.setOnPreparedListener {
304
+ mediaPlayer = mp
305
+ mediaPrepared()
306
+ }
307
+ mp.setOnErrorListener { mpCb, what, extra -> onFailToLoadMedia(mpCb, what, extra) }
308
+ mp.setOnCompletionListener { mediaCompleted() }
309
+ mp.prepareAsync()
310
+ } catch (e: Exception) {
311
+ e.printStackTrace()
312
+ mediaFailed()
313
+ mOnTrimVideoListener.onError("Error initializing video player.", ErrorCode.FAIL_TO_LOAD_MEDIA)
314
+ }
315
+ }
316
+
317
+ private fun updateVideoViewSize() {
318
+ val vw = mediaPlayer?.videoWidth ?: return
319
+ val vh = mediaPlayer?.videoHeight ?: return
320
+ if (vw <= 0 || vh <= 0) return
321
+
322
+ videoContainer.post {
323
+ val cw = containerContentWidth().toInt()
324
+ val ch = containerContentHeight().toInt()
325
+ if (cw <= 0 || ch <= 0) return@post
326
+
327
+ val margin = bracketOverflow()
328
+ val availW = cw - 2 * margin
329
+ val availH = ch - 2 * margin
330
+ if (availW <= 0 || availH <= 0) return@post
331
+
332
+ val videoAR = vw.toFloat() / vh
333
+ val containerAR = availW.toFloat() / availH
334
+ val newW: Int
335
+ val newH: Int
336
+ if (videoAR > containerAR) {
337
+ newW = availW
338
+ newH = (availW / videoAR).toInt()
339
+ } else {
340
+ newH = availH
341
+ newW = (availH * videoAR).toInt()
342
+ }
343
+ mVideoView.layoutParams = FrameLayout.LayoutParams(newW, newH, Gravity.CENTER)
344
+ }
345
+ }
346
+
257
347
  private fun onFailToLoadMedia(mp: MediaPlayer, what: Int, extra: Int): Boolean {
258
348
  mediaFailed()
259
349
  mOnTrimVideoListener.onError("Error loading media file. Please try again.", ErrorCode.FAIL_TO_LOAD_MEDIA)
@@ -308,6 +398,8 @@ class VideoTrimmerView(
308
398
  mMaxDuration = mMaxDuration.coerceAtMost(mDuration.toLong())
309
399
 
310
400
  if (isVideoType) {
401
+ updateVideoViewSize()
402
+
311
403
  mediaMetadataRetriever = MediaMetadataUtil.getMediaMetadataRetriever(mSourceUri.toString())
312
404
  if (mediaMetadataRetriever == null) {
313
405
  mOnTrimVideoListener.onError("Error when retrieving video info. Please try again.", ErrorCode.FAIL_TO_GET_VIDEO_INFO)
@@ -347,6 +439,13 @@ class VideoTrimmerView(
347
439
  playOrPause()
348
440
  }
349
441
 
442
+ if (isVideoType) {
443
+ transformRow.alpha = 0f
444
+ transformRow.visibility = View.VISIBLE
445
+ transformRow.animate().alpha(1f).setDuration(250).start()
446
+ updateUndoRedoButtons()
447
+ }
448
+
350
449
  mOnTrimVideoListener.onLoad(mDuration)
351
450
  ignoreSystemGestureForView(trimmerView)
352
451
  }
@@ -419,18 +518,41 @@ class VideoTrimmerView(
419
518
  mPlayView.setOnClickListener { playOrPause() }
420
519
  setHandleTouchListener(leadingHandle, true)
421
520
  setHandleTouchListener(trailingHandle, false)
521
+
522
+ flipBtn.setOnClickListener { onFlipTapped() }
523
+ rotateBtn.setOnClickListener { onRotateTapped() }
524
+ cropBtn.setOnClickListener { onCropTapped() }
525
+ undoBtn.setOnClickListener { onUndoTapped() }
526
+ redoBtn.setOnClickListener { onRedoTapped() }
527
+
528
+ cropBtn.setColorFilter(Color.argb(128, 255, 255, 255), android.graphics.PorterDuff.Mode.SRC_IN)
529
+ undoBtn.setColorFilter(Color.argb(128, 255, 255, 255), android.graphics.PorterDuff.Mode.SRC_IN)
530
+ redoBtn.setColorFilter(Color.argb(128, 255, 255, 255), android.graphics.PorterDuff.Mode.SRC_IN)
422
531
  }
423
532
 
424
533
  fun onSaveClicked() {
425
534
  onMediaPause()
535
+ val vw = mediaPlayer?.videoWidth ?: 0
536
+ val vh = mediaPlayer?.videoHeight ?: 0
537
+ val bitrate = synchronized(retrieverLock) {
538
+ if (retrieverReleased) 0L
539
+ else mediaMetadataRetriever
540
+ ?.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
541
+ ?.toLongOrNull() ?: 0L
542
+ }
426
543
  ffmpegSession = VideoTrimmerUtil.trim(
427
544
  mSourceUri.toString(),
428
545
  StorageUtil.getOutputPath(mContext, mOutputExt),
429
546
  mDuration,
430
547
  startTime,
431
548
  endTime,
432
- enableRotation,
433
- rotationAngle,
549
+ rotationCount,
550
+ isFlipped,
551
+ getCropNormalizedRect(),
552
+ vw,
553
+ vh,
554
+ bitrate,
555
+ enablePreciseTrimming,
434
556
  mOnTrimVideoListener
435
557
  )
436
558
  }
@@ -473,6 +595,14 @@ class VideoTrimmerView(
473
595
 
474
596
  cachedFullViewThumbnails.clear()
475
597
 
598
+ cropOverlay?.onCropBegan = null
599
+ cropOverlay?.onCropEnded = null
600
+ cropOverlay?.onCropChanged = null
601
+ cropOverlay = null
602
+ undoStack.clear()
603
+ redoStack.clear()
604
+ cumulativeRotationDeg = 0f
605
+
476
606
  synchronized(retrieverLock) {
477
607
  retrieverReleased = true
478
608
  try {
@@ -489,6 +619,11 @@ class VideoTrimmerView(
489
619
  e.printStackTrace()
490
620
  Log.d(TAG, "onDestroy mediaPlayer is already released")
491
621
  }
622
+
623
+ try {
624
+ videoSurface?.release()
625
+ } catch (_: Exception) {}
626
+ videoSurface = null
492
627
  }
493
628
 
494
629
  private fun getScreenWidthInPortraitMode(): Int {
@@ -517,6 +652,7 @@ class VideoTrimmerView(
517
652
  mOutputExt = "wav"
518
653
  }
519
654
  enableHapticFeedback = config.hasKey("enableHapticFeedback") && config.getBoolean("enableHapticFeedback")
655
+ enablePreciseTrimming = config.hasKey("enablePreciseTrimming") && config.getBoolean("enablePreciseTrimming")
520
656
  autoplay = config.hasKey("autoplay") && config.getBoolean("autoplay")
521
657
 
522
658
  if (config.hasKey("jumpToPositionOnLoad") && config.getDouble("jumpToPositionOnLoad") > 0) {
@@ -537,8 +673,6 @@ class VideoTrimmerView(
537
673
  alertOnFailTitle = if (config.hasKey("alertOnFailTitle")) config.getString("alertOnFailTitle") ?: "Error" else "Error"
538
674
  alertOnFailMessage = if (config.hasKey("alertOnFailMessage")) config.getString("alertOnFailMessage") ?: "Fail to load media. Possibly invalid file or no network connection" else "Fail to load media. Possibly invalid file or no network connection"
539
675
  alertOnFailCloseText = if (config.hasKey("alertOnFailCloseText")) config.getString("alertOnFailCloseText") ?: "Close" else "Close"
540
- enableRotation = config.hasKey("enableRotation") && config.getBoolean("enableRotation")
541
- rotationAngle = if (config.hasKey("rotationAngle")) config.getDouble("rotationAngle") else 0.0
542
676
 
543
677
  if (config.hasKey("zoomOnWaitingDuration") && config.getDouble("zoomOnWaitingDuration") > 0) {
544
678
  zoomOnWaitingDuration = config.getDouble("zoomOnWaitingDuration").toLong()
@@ -1176,4 +1310,407 @@ class VideoTrimmerView(
1176
1310
  mThumbnailContainer.addView(restoredView)
1177
1311
  }
1178
1312
  }
1313
+
1314
+ // region Transform
1315
+
1316
+ private fun containerContentWidth(): Float =
1317
+ (videoContainer.width - videoContainer.paddingLeft - videoContainer.paddingRight).toFloat()
1318
+
1319
+ private fun containerContentHeight(): Float =
1320
+ (videoContainer.height - videoContainer.paddingTop - videoContainer.paddingBottom).toFloat()
1321
+
1322
+ private fun bracketOverflow(): Int =
1323
+ TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, resources.displayMetrics).toInt()
1324
+
1325
+ private fun onFlipTapped() {
1326
+ pushUndo()
1327
+ isFlipped = !isFlipped
1328
+ val newCumDeg = -cumulativeRotationDeg
1329
+ val fitScale = if (rotationCount % 2 != 0) {
1330
+ val cw = containerContentWidth()
1331
+ val ch = containerContentHeight()
1332
+ if (cw > 0 && ch > 0) minOf(cw / ch, ch / cw) else 1f
1333
+ } else {
1334
+ 1f
1335
+ }
1336
+ val targetSx = (if (isFlipped) -1f else 1f) * fitScale
1337
+ val oddRotation = rotationCount % 2 != 0
1338
+
1339
+ mVideoView.animate().cancel()
1340
+ if (oddRotation) {
1341
+ mVideoView.animate()
1342
+ .scaleY(0f)
1343
+ .setDuration(125)
1344
+ .setInterpolator(DecelerateInterpolator())
1345
+ .withEndAction {
1346
+ cumulativeRotationDeg = newCumDeg
1347
+ mVideoView.rotation = newCumDeg
1348
+ mVideoView.scaleX = targetSx
1349
+ mVideoView.animate()
1350
+ .scaleY(fitScale)
1351
+ .setDuration(125)
1352
+ .setInterpolator(DecelerateInterpolator())
1353
+ .withEndAction {
1354
+ if (isCropActive) {
1355
+ updateCropAllowedRect()
1356
+ cropOverlay?.resetCrop()
1357
+ }
1358
+ }
1359
+ .start()
1360
+ }
1361
+ .start()
1362
+ } else {
1363
+ mVideoView.animate()
1364
+ .scaleX(0f)
1365
+ .setDuration(125)
1366
+ .setInterpolator(DecelerateInterpolator())
1367
+ .withEndAction {
1368
+ cumulativeRotationDeg = newCumDeg
1369
+ mVideoView.rotation = newCumDeg
1370
+ mVideoView.animate()
1371
+ .scaleX(targetSx)
1372
+ .setDuration(125)
1373
+ .setInterpolator(DecelerateInterpolator())
1374
+ .withEndAction {
1375
+ if (isCropActive) {
1376
+ updateCropAllowedRect()
1377
+ cropOverlay?.resetCrop()
1378
+ }
1379
+ }
1380
+ .start()
1381
+ }
1382
+ .start()
1383
+ }
1384
+ playHapticFeedback(true)
1385
+ }
1386
+
1387
+ private fun onRotateTapped() {
1388
+ pushUndo()
1389
+ if (isFlipped) {
1390
+ rotationCount = (rotationCount - 1 + 4) % 4
1391
+ } else {
1392
+ rotationCount = (rotationCount + 1) % 4
1393
+ }
1394
+ cumulativeRotationDeg -= 90f
1395
+ updateVideoTransform(resetCrop = true)
1396
+ playHapticFeedback(true)
1397
+ }
1398
+
1399
+ private fun updateVideoTransform(resetCrop: Boolean = false) {
1400
+ val cw = containerContentWidth()
1401
+ val ch = containerContentHeight()
1402
+ if (cw <= 0 || ch <= 0) return
1403
+
1404
+ val fitScale = if (rotationCount % 2 != 0) {
1405
+ minOf(cw / ch, ch / cw)
1406
+ } else {
1407
+ 1f
1408
+ }
1409
+ val flipMul = if (isFlipped) -1f else 1f
1410
+
1411
+ mVideoView.animate()
1412
+ .scaleX(flipMul * fitScale)
1413
+ .scaleY(fitScale)
1414
+ .rotation(cumulativeRotationDeg)
1415
+ .setDuration(250)
1416
+ .setInterpolator(DecelerateInterpolator())
1417
+ .withEndAction {
1418
+ if (resetCrop && isCropActive) {
1419
+ updateCropAllowedRect()
1420
+ cropOverlay?.resetCrop()
1421
+ }
1422
+ }
1423
+ .start()
1424
+ }
1425
+
1426
+ // endregion
1427
+
1428
+ // region Crop
1429
+
1430
+ private fun onCropTapped() {
1431
+ isCropActive = !isCropActive
1432
+ cropBtn.setColorFilter(
1433
+ if (isCropActive) Color.WHITE else Color.argb(128, 255, 255, 255),
1434
+ android.graphics.PorterDuff.Mode.SRC_IN
1435
+ )
1436
+ playHapticFeedback(true)
1437
+ if (isCropActive) showCropOverlay() else hideCropOverlay()
1438
+ }
1439
+
1440
+ private fun showCropOverlay() {
1441
+ hideCropOverlayImmediate()
1442
+ val overlay = CropOverlayView(mContext)
1443
+ overlay.layoutParams = FrameLayout.LayoutParams(
1444
+ FrameLayout.LayoutParams.MATCH_PARENT,
1445
+ FrameLayout.LayoutParams.MATCH_PARENT
1446
+ )
1447
+ overlay.alpha = 0f
1448
+ overlay.onCropBegan = { preCropSnapshot = currentSnapshot() }
1449
+ overlay.onCropEnded = {
1450
+ val before = preCropSnapshot
1451
+ preCropSnapshot = null
1452
+ if (before != null && before != currentSnapshot()) {
1453
+ undoStack.add(before)
1454
+ redoStack.clear()
1455
+ updateUndoRedoButtons()
1456
+ }
1457
+ }
1458
+ videoContainer.addView(overlay)
1459
+ cropOverlay = overlay
1460
+ videoContainer.post {
1461
+ updateCropAllowedRect()
1462
+ overlay.resetCrop()
1463
+ overlay.animate().alpha(1f).setDuration(200).start()
1464
+ }
1465
+ }
1466
+
1467
+ private fun hideCropOverlay() {
1468
+ val overlay = cropOverlay ?: return
1469
+ overlay.animate().alpha(0f).setDuration(200).withEndAction {
1470
+ videoContainer.removeView(overlay)
1471
+ }.start()
1472
+ cropOverlay = null
1473
+ }
1474
+
1475
+ private fun showCropOverlayImmediate() {
1476
+ hideCropOverlayImmediate()
1477
+ val overlay = CropOverlayView(mContext)
1478
+ overlay.layoutParams = FrameLayout.LayoutParams(
1479
+ FrameLayout.LayoutParams.MATCH_PARENT,
1480
+ FrameLayout.LayoutParams.MATCH_PARENT
1481
+ )
1482
+ overlay.onCropBegan = { preCropSnapshot = currentSnapshot() }
1483
+ overlay.onCropEnded = {
1484
+ val before = preCropSnapshot
1485
+ preCropSnapshot = null
1486
+ if (before != null && before != currentSnapshot()) {
1487
+ undoStack.add(before)
1488
+ redoStack.clear()
1489
+ updateUndoRedoButtons()
1490
+ }
1491
+ }
1492
+ videoContainer.addView(overlay)
1493
+ cropOverlay = overlay
1494
+ updateCropAllowedRect()
1495
+ }
1496
+
1497
+ private fun hideCropOverlayImmediate() {
1498
+ val overlay = cropOverlay ?: return
1499
+ videoContainer.removeView(overlay)
1500
+ cropOverlay = null
1501
+ }
1502
+
1503
+ private fun updateCropAllowedRect() {
1504
+ cropOverlay?.allowedRect = getVideoDisplayRectInContainer()
1505
+ }
1506
+
1507
+ private fun getVideoDisplayRectInContainer(): RectF {
1508
+ val cw = containerContentWidth()
1509
+ val ch = containerContentHeight()
1510
+ if (cw <= 0 || ch <= 0) return RectF()
1511
+
1512
+ val tvW = mVideoView.width.toFloat()
1513
+ val tvH = mVideoView.height.toFloat()
1514
+ if (tvW <= 0 || tvH <= 0) return RectF()
1515
+
1516
+ val tvX = (cw - tvW) / 2f
1517
+ val tvY = (ch - tvH) / 2f
1518
+ val videoRect = RectF(tvX, tvY, tvX + tvW, tvY + tvH)
1519
+
1520
+ val pivotX = cw / 2f
1521
+ val pivotY = ch / 2f
1522
+ val fitScale = if (rotationCount % 2 != 0) {
1523
+ minOf(cw / ch, ch / cw)
1524
+ } else {
1525
+ 1f
1526
+ }
1527
+ val flipMul = if (isFlipped) -1f else 1f
1528
+ val geoRotation = if (isFlipped) rotationCount * 90f else -rotationCount * 90f
1529
+ val matrix = Matrix()
1530
+ matrix.setScale(flipMul * fitScale, fitScale, pivotX, pivotY)
1531
+ matrix.postRotate(geoRotation, pivotX, pivotY)
1532
+
1533
+ val pts = floatArrayOf(
1534
+ videoRect.left, videoRect.top,
1535
+ videoRect.right, videoRect.top,
1536
+ videoRect.right, videoRect.bottom,
1537
+ videoRect.left, videoRect.bottom
1538
+ )
1539
+ matrix.mapPoints(pts)
1540
+
1541
+ var minX = pts[0]; var minY = pts[1]
1542
+ var maxX = pts[0]; var maxY = pts[1]
1543
+ for (i in 1 until 4) {
1544
+ minX = minOf(minX, pts[i * 2])
1545
+ minY = minOf(minY, pts[i * 2 + 1])
1546
+ maxX = maxOf(maxX, pts[i * 2])
1547
+ maxY = maxOf(maxY, pts[i * 2 + 1])
1548
+ }
1549
+ return RectF(minX, minY, maxX, maxY)
1550
+ }
1551
+
1552
+ fun getCropNormalizedRect(): RectF? {
1553
+ if (!isCropActive) return null
1554
+ val overlay = cropOverlay ?: return null
1555
+ val cr = overlay.cropRect
1556
+ val allowed = overlay.allowedRect
1557
+ if (cr.isEmpty || allowed.isEmpty) return null
1558
+ if (allowed.width() <= 0 || allowed.height() <= 0) return null
1559
+
1560
+ val normX = (cr.left - allowed.left) / allowed.width()
1561
+ val normY = (cr.top - allowed.top) / allowed.height()
1562
+ val normW = cr.width() / allowed.width()
1563
+ val normH = cr.height() / allowed.height()
1564
+
1565
+ if (normX < 0.01f && normY < 0.01f && normW > 0.98f && normH > 0.98f) return null
1566
+ return RectF(normX, normY, normX + normW, normY + normH)
1567
+ }
1568
+
1569
+ private fun setCropFromNormalized(norm: RectF) {
1570
+ val overlay = cropOverlay ?: return
1571
+ val allowed = overlay.allowedRect
1572
+ if (allowed.isEmpty) return
1573
+ val x = allowed.left + norm.left * allowed.width()
1574
+ val y = allowed.top + norm.top * allowed.height()
1575
+ val w = norm.width() * allowed.width()
1576
+ val h = norm.height() * allowed.height()
1577
+ overlay.cropRect = RectF(x, y, x + w, y + h)
1578
+ }
1579
+
1580
+ // endregion
1581
+
1582
+ // region Undo / Redo
1583
+
1584
+ private fun currentSnapshot(): TransformSnapshot {
1585
+ return TransformSnapshot(
1586
+ rotationCount = rotationCount,
1587
+ isFlipped = isFlipped,
1588
+ isCropActive = isCropActive,
1589
+ cropNormalized = getCropNormalizedRect(),
1590
+ cumulativeRotationDeg = cumulativeRotationDeg
1591
+ )
1592
+ }
1593
+
1594
+ private fun pushUndo() {
1595
+ undoStack.add(currentSnapshot())
1596
+ redoStack.clear()
1597
+ updateUndoRedoButtons()
1598
+ }
1599
+
1600
+ private fun onUndoTapped() {
1601
+ if (undoStack.isEmpty()) return
1602
+ redoStack.add(currentSnapshot())
1603
+ val snap = undoStack.removeLast()
1604
+ applySnapshot(snap)
1605
+ updateUndoRedoButtons()
1606
+ }
1607
+
1608
+ private fun onRedoTapped() {
1609
+ if (redoStack.isEmpty()) return
1610
+ undoStack.add(currentSnapshot())
1611
+ val snap = redoStack.removeLast()
1612
+ applySnapshot(snap)
1613
+ updateUndoRedoButtons()
1614
+ }
1615
+
1616
+ private fun applySnapshot(snap: TransformSnapshot) {
1617
+ val flipChanging = isFlipped != snap.isFlipped
1618
+ val prevRotationCount = rotationCount
1619
+
1620
+ rotationCount = snap.rotationCount
1621
+ isFlipped = snap.isFlipped
1622
+ cumulativeRotationDeg = snap.cumulativeRotationDeg
1623
+
1624
+ val cw = containerContentWidth()
1625
+ val ch = containerContentHeight()
1626
+ val fitScale = if (rotationCount % 2 != 0 && cw > 0 && ch > 0) {
1627
+ minOf(cw / ch, ch / cw)
1628
+ } else {
1629
+ 1f
1630
+ }
1631
+ val flipMul = if (isFlipped) -1f else 1f
1632
+ val targetSx = flipMul * fitScale
1633
+
1634
+ val onComplete = Runnable {
1635
+ if (snap.isCropActive) {
1636
+ isCropActive = true
1637
+ cropBtn.setColorFilter(Color.WHITE, android.graphics.PorterDuff.Mode.SRC_IN)
1638
+ showCropOverlayImmediate()
1639
+ updateCropAllowedRect()
1640
+ val norm = snap.cropNormalized
1641
+ if (norm != null) setCropFromNormalized(norm) else cropOverlay?.resetCrop()
1642
+ } else {
1643
+ isCropActive = false
1644
+ cropBtn.setColorFilter(
1645
+ Color.argb(128, 255, 255, 255),
1646
+ android.graphics.PorterDuff.Mode.SRC_IN
1647
+ )
1648
+ hideCropOverlayImmediate()
1649
+ }
1650
+ }
1651
+
1652
+ mVideoView.animate().cancel()
1653
+
1654
+ if (flipChanging) {
1655
+ val oddRotation = prevRotationCount % 2 != 0
1656
+ if (oddRotation) {
1657
+ mVideoView.animate()
1658
+ .scaleY(0f)
1659
+ .setDuration(125)
1660
+ .setInterpolator(DecelerateInterpolator())
1661
+ .withEndAction {
1662
+ mVideoView.rotation = cumulativeRotationDeg
1663
+ mVideoView.scaleX = targetSx
1664
+ mVideoView.animate()
1665
+ .scaleY(fitScale)
1666
+ .setDuration(125)
1667
+ .setInterpolator(DecelerateInterpolator())
1668
+ .withEndAction { onComplete.run() }
1669
+ .start()
1670
+ }
1671
+ .start()
1672
+ } else {
1673
+ mVideoView.animate()
1674
+ .scaleX(0f)
1675
+ .setDuration(125)
1676
+ .setInterpolator(DecelerateInterpolator())
1677
+ .withEndAction {
1678
+ mVideoView.rotation = cumulativeRotationDeg
1679
+ mVideoView.animate()
1680
+ .scaleX(targetSx)
1681
+ .setDuration(125)
1682
+ .setInterpolator(DecelerateInterpolator())
1683
+ .withEndAction { onComplete.run() }
1684
+ .start()
1685
+ }
1686
+ .start()
1687
+ }
1688
+ } else {
1689
+ mVideoView.animate()
1690
+ .scaleX(targetSx)
1691
+ .scaleY(fitScale)
1692
+ .rotation(cumulativeRotationDeg)
1693
+ .setDuration(250)
1694
+ .setInterpolator(DecelerateInterpolator())
1695
+ .withEndAction { onComplete.run() }
1696
+ .start()
1697
+ }
1698
+ }
1699
+
1700
+ private fun updateUndoRedoButtons() {
1701
+ val dimmed = Color.argb(128, 255, 255, 255)
1702
+ val active = Color.WHITE
1703
+ undoBtn.isEnabled = undoStack.isNotEmpty()
1704
+ undoBtn.setColorFilter(
1705
+ if (undoStack.isNotEmpty()) active else dimmed,
1706
+ android.graphics.PorterDuff.Mode.SRC_IN
1707
+ )
1708
+ redoBtn.isEnabled = redoStack.isNotEmpty()
1709
+ redoBtn.setColorFilter(
1710
+ if (redoStack.isNotEmpty()) active else dimmed,
1711
+ android.graphics.PorterDuff.Mode.SRC_IN
1712
+ )
1713
+ }
1714
+
1715
+ // endregion
1179
1716
  }