react-native-video-trim 6.2.3 → 7.0.0

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,52 @@ 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 containerW = videoContainer.width
324
+ val containerH = videoContainer.height
325
+ if (containerW <= 0 || containerH <= 0) return@post
326
+
327
+ val videoAR = vw.toFloat() / vh
328
+ val containerAR = containerW.toFloat() / containerH
329
+ val newW: Int
330
+ val newH: Int
331
+ if (videoAR > containerAR) {
332
+ newW = containerW
333
+ newH = (containerW / videoAR).toInt()
334
+ } else {
335
+ newH = containerH
336
+ newW = (containerH * videoAR).toInt()
337
+ }
338
+ mVideoView.layoutParams = FrameLayout.LayoutParams(newW, newH, Gravity.CENTER)
339
+ }
340
+ }
341
+
257
342
  private fun onFailToLoadMedia(mp: MediaPlayer, what: Int, extra: Int): Boolean {
258
343
  mediaFailed()
259
344
  mOnTrimVideoListener.onError("Error loading media file. Please try again.", ErrorCode.FAIL_TO_LOAD_MEDIA)
@@ -308,6 +393,8 @@ class VideoTrimmerView(
308
393
  mMaxDuration = mMaxDuration.coerceAtMost(mDuration.toLong())
309
394
 
310
395
  if (isVideoType) {
396
+ updateVideoViewSize()
397
+
311
398
  mediaMetadataRetriever = MediaMetadataUtil.getMediaMetadataRetriever(mSourceUri.toString())
312
399
  if (mediaMetadataRetriever == null) {
313
400
  mOnTrimVideoListener.onError("Error when retrieving video info. Please try again.", ErrorCode.FAIL_TO_GET_VIDEO_INFO)
@@ -347,6 +434,13 @@ class VideoTrimmerView(
347
434
  playOrPause()
348
435
  }
349
436
 
437
+ if (isVideoType) {
438
+ transformRow.alpha = 0f
439
+ transformRow.visibility = View.VISIBLE
440
+ transformRow.animate().alpha(1f).setDuration(250).start()
441
+ updateUndoRedoButtons()
442
+ }
443
+
350
444
  mOnTrimVideoListener.onLoad(mDuration)
351
445
  ignoreSystemGestureForView(trimmerView)
352
446
  }
@@ -419,18 +513,37 @@ class VideoTrimmerView(
419
513
  mPlayView.setOnClickListener { playOrPause() }
420
514
  setHandleTouchListener(leadingHandle, true)
421
515
  setHandleTouchListener(trailingHandle, false)
516
+
517
+ flipBtn.setOnClickListener { onFlipTapped() }
518
+ rotateBtn.setOnClickListener { onRotateTapped() }
519
+ cropBtn.setOnClickListener { onCropTapped() }
520
+ undoBtn.setOnClickListener { onUndoTapped() }
521
+ redoBtn.setOnClickListener { onRedoTapped() }
422
522
  }
423
523
 
424
524
  fun onSaveClicked() {
425
525
  onMediaPause()
526
+ val vw = mediaPlayer?.videoWidth ?: 0
527
+ val vh = mediaPlayer?.videoHeight ?: 0
528
+ val bitrate = synchronized(retrieverLock) {
529
+ if (retrieverReleased) 0L
530
+ else mediaMetadataRetriever
531
+ ?.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
532
+ ?.toLongOrNull() ?: 0L
533
+ }
426
534
  ffmpegSession = VideoTrimmerUtil.trim(
427
535
  mSourceUri.toString(),
428
536
  StorageUtil.getOutputPath(mContext, mOutputExt),
429
537
  mDuration,
430
538
  startTime,
431
539
  endTime,
432
- enableRotation,
433
- rotationAngle,
540
+ rotationCount,
541
+ isFlipped,
542
+ getCropNormalizedRect(),
543
+ vw,
544
+ vh,
545
+ bitrate,
546
+ enablePreciseTrimming,
434
547
  mOnTrimVideoListener
435
548
  )
436
549
  }
@@ -473,6 +586,14 @@ class VideoTrimmerView(
473
586
 
474
587
  cachedFullViewThumbnails.clear()
475
588
 
589
+ cropOverlay?.onCropBegan = null
590
+ cropOverlay?.onCropEnded = null
591
+ cropOverlay?.onCropChanged = null
592
+ cropOverlay = null
593
+ undoStack.clear()
594
+ redoStack.clear()
595
+ cumulativeRotationDeg = 0f
596
+
476
597
  synchronized(retrieverLock) {
477
598
  retrieverReleased = true
478
599
  try {
@@ -489,6 +610,11 @@ class VideoTrimmerView(
489
610
  e.printStackTrace()
490
611
  Log.d(TAG, "onDestroy mediaPlayer is already released")
491
612
  }
613
+
614
+ try {
615
+ videoSurface?.release()
616
+ } catch (_: Exception) {}
617
+ videoSurface = null
492
618
  }
493
619
 
494
620
  private fun getScreenWidthInPortraitMode(): Int {
@@ -517,6 +643,7 @@ class VideoTrimmerView(
517
643
  mOutputExt = "wav"
518
644
  }
519
645
  enableHapticFeedback = config.hasKey("enableHapticFeedback") && config.getBoolean("enableHapticFeedback")
646
+ enablePreciseTrimming = config.hasKey("enablePreciseTrimming") && config.getBoolean("enablePreciseTrimming")
520
647
  autoplay = config.hasKey("autoplay") && config.getBoolean("autoplay")
521
648
 
522
649
  if (config.hasKey("jumpToPositionOnLoad") && config.getDouble("jumpToPositionOnLoad") > 0) {
@@ -537,8 +664,6 @@ class VideoTrimmerView(
537
664
  alertOnFailTitle = if (config.hasKey("alertOnFailTitle")) config.getString("alertOnFailTitle") ?: "Error" else "Error"
538
665
  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
666
  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
667
 
543
668
  if (config.hasKey("zoomOnWaitingDuration") && config.getDouble("zoomOnWaitingDuration") > 0) {
544
669
  zoomOnWaitingDuration = config.getDouble("zoomOnWaitingDuration").toLong()
@@ -1176,4 +1301,398 @@ class VideoTrimmerView(
1176
1301
  mThumbnailContainer.addView(restoredView)
1177
1302
  }
1178
1303
  }
1304
+
1305
+ // region Transform
1306
+
1307
+ private fun onFlipTapped() {
1308
+ pushUndo()
1309
+ isFlipped = !isFlipped
1310
+ val newCumDeg = -cumulativeRotationDeg
1311
+ val fitScale = if (rotationCount % 2 != 0) {
1312
+ val cw = videoContainer.width.toFloat()
1313
+ val ch = videoContainer.height.toFloat()
1314
+ if (cw > 0 && ch > 0) minOf(cw / ch, ch / cw) else 1f
1315
+ } else {
1316
+ 1f
1317
+ }
1318
+ val targetSx = (if (isFlipped) -1f else 1f) * fitScale
1319
+ val oddRotation = rotationCount % 2 != 0
1320
+
1321
+ mVideoView.animate().cancel()
1322
+ if (oddRotation) {
1323
+ mVideoView.animate()
1324
+ .scaleY(0f)
1325
+ .setDuration(125)
1326
+ .setInterpolator(DecelerateInterpolator())
1327
+ .withEndAction {
1328
+ cumulativeRotationDeg = newCumDeg
1329
+ mVideoView.rotation = newCumDeg
1330
+ mVideoView.scaleX = targetSx
1331
+ mVideoView.animate()
1332
+ .scaleY(fitScale)
1333
+ .setDuration(125)
1334
+ .setInterpolator(DecelerateInterpolator())
1335
+ .withEndAction {
1336
+ if (isCropActive) {
1337
+ updateCropAllowedRect()
1338
+ cropOverlay?.resetCrop()
1339
+ }
1340
+ }
1341
+ .start()
1342
+ }
1343
+ .start()
1344
+ } else {
1345
+ mVideoView.animate()
1346
+ .scaleX(0f)
1347
+ .setDuration(125)
1348
+ .setInterpolator(DecelerateInterpolator())
1349
+ .withEndAction {
1350
+ cumulativeRotationDeg = newCumDeg
1351
+ mVideoView.rotation = newCumDeg
1352
+ mVideoView.animate()
1353
+ .scaleX(targetSx)
1354
+ .setDuration(125)
1355
+ .setInterpolator(DecelerateInterpolator())
1356
+ .withEndAction {
1357
+ if (isCropActive) {
1358
+ updateCropAllowedRect()
1359
+ cropOverlay?.resetCrop()
1360
+ }
1361
+ }
1362
+ .start()
1363
+ }
1364
+ .start()
1365
+ }
1366
+ playHapticFeedback(true)
1367
+ }
1368
+
1369
+ private fun onRotateTapped() {
1370
+ pushUndo()
1371
+ if (isFlipped) {
1372
+ rotationCount = (rotationCount - 1 + 4) % 4
1373
+ } else {
1374
+ rotationCount = (rotationCount + 1) % 4
1375
+ }
1376
+ cumulativeRotationDeg -= 90f
1377
+ updateVideoTransform(resetCrop = true)
1378
+ playHapticFeedback(true)
1379
+ }
1380
+
1381
+ private fun updateVideoTransform(resetCrop: Boolean = false) {
1382
+ val containerW = videoContainer.width.toFloat()
1383
+ val containerH = videoContainer.height.toFloat()
1384
+ if (containerW <= 0 || containerH <= 0) return
1385
+
1386
+ val fitScale = if (rotationCount % 2 != 0) {
1387
+ minOf(containerW / containerH, containerH / containerW)
1388
+ } else {
1389
+ 1f
1390
+ }
1391
+ val flipMul = if (isFlipped) -1f else 1f
1392
+
1393
+ mVideoView.animate()
1394
+ .scaleX(flipMul * fitScale)
1395
+ .scaleY(fitScale)
1396
+ .rotation(cumulativeRotationDeg)
1397
+ .setDuration(250)
1398
+ .setInterpolator(DecelerateInterpolator())
1399
+ .withEndAction {
1400
+ if (resetCrop && isCropActive) {
1401
+ updateCropAllowedRect()
1402
+ cropOverlay?.resetCrop()
1403
+ }
1404
+ }
1405
+ .start()
1406
+ }
1407
+
1408
+ // endregion
1409
+
1410
+ // region Crop
1411
+
1412
+ private fun onCropTapped() {
1413
+ isCropActive = !isCropActive
1414
+ cropBtn.setColorFilter(
1415
+ if (isCropActive) Color.WHITE else Color.argb(128, 255, 255, 255),
1416
+ android.graphics.PorterDuff.Mode.SRC_IN
1417
+ )
1418
+ playHapticFeedback(true)
1419
+ if (isCropActive) showCropOverlay() else hideCropOverlay()
1420
+ }
1421
+
1422
+ private fun showCropOverlay() {
1423
+ hideCropOverlayImmediate()
1424
+ val overlay = CropOverlayView(mContext)
1425
+ overlay.layoutParams = FrameLayout.LayoutParams(
1426
+ FrameLayout.LayoutParams.MATCH_PARENT,
1427
+ FrameLayout.LayoutParams.MATCH_PARENT
1428
+ )
1429
+ overlay.alpha = 0f
1430
+ overlay.onCropBegan = { preCropSnapshot = currentSnapshot() }
1431
+ overlay.onCropEnded = {
1432
+ val before = preCropSnapshot
1433
+ preCropSnapshot = null
1434
+ if (before != null && before != currentSnapshot()) {
1435
+ undoStack.add(before)
1436
+ redoStack.clear()
1437
+ updateUndoRedoButtons()
1438
+ }
1439
+ }
1440
+ videoContainer.addView(overlay)
1441
+ cropOverlay = overlay
1442
+ videoContainer.post {
1443
+ updateCropAllowedRect()
1444
+ overlay.resetCrop()
1445
+ overlay.animate().alpha(1f).setDuration(200).start()
1446
+ }
1447
+ }
1448
+
1449
+ private fun hideCropOverlay() {
1450
+ val overlay = cropOverlay ?: return
1451
+ overlay.animate().alpha(0f).setDuration(200).withEndAction {
1452
+ videoContainer.removeView(overlay)
1453
+ }.start()
1454
+ cropOverlay = null
1455
+ }
1456
+
1457
+ private fun showCropOverlayImmediate() {
1458
+ hideCropOverlayImmediate()
1459
+ val overlay = CropOverlayView(mContext)
1460
+ overlay.layoutParams = FrameLayout.LayoutParams(
1461
+ FrameLayout.LayoutParams.MATCH_PARENT,
1462
+ FrameLayout.LayoutParams.MATCH_PARENT
1463
+ )
1464
+ overlay.onCropBegan = { preCropSnapshot = currentSnapshot() }
1465
+ overlay.onCropEnded = {
1466
+ val before = preCropSnapshot
1467
+ preCropSnapshot = null
1468
+ if (before != null && before != currentSnapshot()) {
1469
+ undoStack.add(before)
1470
+ redoStack.clear()
1471
+ updateUndoRedoButtons()
1472
+ }
1473
+ }
1474
+ videoContainer.addView(overlay)
1475
+ cropOverlay = overlay
1476
+ updateCropAllowedRect()
1477
+ }
1478
+
1479
+ private fun hideCropOverlayImmediate() {
1480
+ val overlay = cropOverlay ?: return
1481
+ videoContainer.removeView(overlay)
1482
+ cropOverlay = null
1483
+ }
1484
+
1485
+ private fun updateCropAllowedRect() {
1486
+ cropOverlay?.allowedRect = getVideoDisplayRectInContainer()
1487
+ }
1488
+
1489
+ private fun getVideoDisplayRectInContainer(): RectF {
1490
+ val containerW = videoContainer.width.toFloat()
1491
+ val containerH = videoContainer.height.toFloat()
1492
+ if (containerW <= 0 || containerH <= 0) return RectF()
1493
+
1494
+ val tvW = mVideoView.width.toFloat()
1495
+ val tvH = mVideoView.height.toFloat()
1496
+ if (tvW <= 0 || tvH <= 0) return RectF()
1497
+
1498
+ val tvX = (containerW - tvW) / 2f
1499
+ val tvY = (containerH - tvH) / 2f
1500
+ val videoRect = RectF(tvX, tvY, tvX + tvW, tvY + tvH)
1501
+
1502
+ val pivotX = containerW / 2f
1503
+ val pivotY = containerH / 2f
1504
+ val fitScale = if (rotationCount % 2 != 0) {
1505
+ minOf(containerW / containerH, containerH / containerW)
1506
+ } else {
1507
+ 1f
1508
+ }
1509
+ val flipMul = if (isFlipped) -1f else 1f
1510
+ val geoRotation = if (isFlipped) rotationCount * 90f else -rotationCount * 90f
1511
+ val matrix = Matrix()
1512
+ matrix.setScale(flipMul * fitScale, fitScale, pivotX, pivotY)
1513
+ matrix.postRotate(geoRotation, pivotX, pivotY)
1514
+
1515
+ val pts = floatArrayOf(
1516
+ videoRect.left, videoRect.top,
1517
+ videoRect.right, videoRect.top,
1518
+ videoRect.right, videoRect.bottom,
1519
+ videoRect.left, videoRect.bottom
1520
+ )
1521
+ matrix.mapPoints(pts)
1522
+
1523
+ var minX = pts[0]; var minY = pts[1]
1524
+ var maxX = pts[0]; var maxY = pts[1]
1525
+ for (i in 1 until 4) {
1526
+ minX = minOf(minX, pts[i * 2])
1527
+ minY = minOf(minY, pts[i * 2 + 1])
1528
+ maxX = maxOf(maxX, pts[i * 2])
1529
+ maxY = maxOf(maxY, pts[i * 2 + 1])
1530
+ }
1531
+ return RectF(minX, minY, maxX, maxY)
1532
+ }
1533
+
1534
+ fun getCropNormalizedRect(): RectF? {
1535
+ if (!isCropActive) return null
1536
+ val overlay = cropOverlay ?: return null
1537
+ val cr = overlay.cropRect
1538
+ val allowed = overlay.allowedRect
1539
+ if (cr.isEmpty || allowed.isEmpty) return null
1540
+ if (allowed.width() <= 0 || allowed.height() <= 0) return null
1541
+
1542
+ val normX = (cr.left - allowed.left) / allowed.width()
1543
+ val normY = (cr.top - allowed.top) / allowed.height()
1544
+ val normW = cr.width() / allowed.width()
1545
+ val normH = cr.height() / allowed.height()
1546
+
1547
+ if (normX < 0.01f && normY < 0.01f && normW > 0.98f && normH > 0.98f) return null
1548
+ return RectF(normX, normY, normX + normW, normY + normH)
1549
+ }
1550
+
1551
+ private fun setCropFromNormalized(norm: RectF) {
1552
+ val overlay = cropOverlay ?: return
1553
+ val allowed = overlay.allowedRect
1554
+ if (allowed.isEmpty) return
1555
+ val x = allowed.left + norm.left * allowed.width()
1556
+ val y = allowed.top + norm.top * allowed.height()
1557
+ val w = norm.width() * allowed.width()
1558
+ val h = norm.height() * allowed.height()
1559
+ overlay.cropRect = RectF(x, y, x + w, y + h)
1560
+ }
1561
+
1562
+ // endregion
1563
+
1564
+ // region Undo / Redo
1565
+
1566
+ private fun currentSnapshot(): TransformSnapshot {
1567
+ return TransformSnapshot(
1568
+ rotationCount = rotationCount,
1569
+ isFlipped = isFlipped,
1570
+ isCropActive = isCropActive,
1571
+ cropNormalized = getCropNormalizedRect(),
1572
+ cumulativeRotationDeg = cumulativeRotationDeg
1573
+ )
1574
+ }
1575
+
1576
+ private fun pushUndo() {
1577
+ undoStack.add(currentSnapshot())
1578
+ redoStack.clear()
1579
+ updateUndoRedoButtons()
1580
+ }
1581
+
1582
+ private fun onUndoTapped() {
1583
+ if (undoStack.isEmpty()) return
1584
+ redoStack.add(currentSnapshot())
1585
+ val snap = undoStack.removeLast()
1586
+ applySnapshot(snap)
1587
+ updateUndoRedoButtons()
1588
+ }
1589
+
1590
+ private fun onRedoTapped() {
1591
+ if (redoStack.isEmpty()) return
1592
+ undoStack.add(currentSnapshot())
1593
+ val snap = redoStack.removeLast()
1594
+ applySnapshot(snap)
1595
+ updateUndoRedoButtons()
1596
+ }
1597
+
1598
+ private fun applySnapshot(snap: TransformSnapshot) {
1599
+ val flipChanging = isFlipped != snap.isFlipped
1600
+ val prevRotationCount = rotationCount
1601
+
1602
+ rotationCount = snap.rotationCount
1603
+ isFlipped = snap.isFlipped
1604
+ cumulativeRotationDeg = snap.cumulativeRotationDeg
1605
+
1606
+ val containerW = videoContainer.width.toFloat()
1607
+ val containerH = videoContainer.height.toFloat()
1608
+ val fitScale = if (rotationCount % 2 != 0 && containerW > 0 && containerH > 0) {
1609
+ minOf(containerW / containerH, containerH / containerW)
1610
+ } else {
1611
+ 1f
1612
+ }
1613
+ val flipMul = if (isFlipped) -1f else 1f
1614
+ val targetSx = flipMul * fitScale
1615
+
1616
+ val onComplete = Runnable {
1617
+ if (snap.isCropActive) {
1618
+ isCropActive = true
1619
+ cropBtn.setColorFilter(Color.WHITE, android.graphics.PorterDuff.Mode.SRC_IN)
1620
+ showCropOverlayImmediate()
1621
+ updateCropAllowedRect()
1622
+ val norm = snap.cropNormalized
1623
+ if (norm != null) setCropFromNormalized(norm) else cropOverlay?.resetCrop()
1624
+ } else {
1625
+ isCropActive = false
1626
+ cropBtn.setColorFilter(
1627
+ Color.argb(128, 255, 255, 255),
1628
+ android.graphics.PorterDuff.Mode.SRC_IN
1629
+ )
1630
+ hideCropOverlayImmediate()
1631
+ }
1632
+ }
1633
+
1634
+ mVideoView.animate().cancel()
1635
+
1636
+ if (flipChanging) {
1637
+ val oddRotation = prevRotationCount % 2 != 0
1638
+ if (oddRotation) {
1639
+ mVideoView.animate()
1640
+ .scaleY(0f)
1641
+ .setDuration(125)
1642
+ .setInterpolator(DecelerateInterpolator())
1643
+ .withEndAction {
1644
+ mVideoView.rotation = cumulativeRotationDeg
1645
+ mVideoView.scaleX = targetSx
1646
+ mVideoView.animate()
1647
+ .scaleY(fitScale)
1648
+ .setDuration(125)
1649
+ .setInterpolator(DecelerateInterpolator())
1650
+ .withEndAction { onComplete.run() }
1651
+ .start()
1652
+ }
1653
+ .start()
1654
+ } else {
1655
+ mVideoView.animate()
1656
+ .scaleX(0f)
1657
+ .setDuration(125)
1658
+ .setInterpolator(DecelerateInterpolator())
1659
+ .withEndAction {
1660
+ mVideoView.rotation = cumulativeRotationDeg
1661
+ mVideoView.animate()
1662
+ .scaleX(targetSx)
1663
+ .setDuration(125)
1664
+ .setInterpolator(DecelerateInterpolator())
1665
+ .withEndAction { onComplete.run() }
1666
+ .start()
1667
+ }
1668
+ .start()
1669
+ }
1670
+ } else {
1671
+ mVideoView.animate()
1672
+ .scaleX(targetSx)
1673
+ .scaleY(fitScale)
1674
+ .rotation(cumulativeRotationDeg)
1675
+ .setDuration(250)
1676
+ .setInterpolator(DecelerateInterpolator())
1677
+ .withEndAction { onComplete.run() }
1678
+ .start()
1679
+ }
1680
+ }
1681
+
1682
+ private fun updateUndoRedoButtons() {
1683
+ val dimmed = Color.argb(128, 255, 255, 255)
1684
+ val active = Color.WHITE
1685
+ undoBtn.isEnabled = undoStack.isNotEmpty()
1686
+ undoBtn.setColorFilter(
1687
+ if (undoStack.isNotEmpty()) active else dimmed,
1688
+ android.graphics.PorterDuff.Mode.SRC_IN
1689
+ )
1690
+ redoBtn.isEnabled = redoStack.isNotEmpty()
1691
+ redoBtn.setColorFilter(
1692
+ if (redoStack.isNotEmpty()) active else dimmed,
1693
+ android.graphics.PorterDuff.Mode.SRC_IN
1694
+ )
1695
+ }
1696
+
1697
+ // endregion
1179
1698
  }
@@ -0,0 +1,19 @@
1
+ <vector xmlns:android="http://schemas.android.com/apk/res/android"
2
+ android:width="23.232dp"
3
+ android:height="24.688dp"
4
+ android:viewportWidth="23.232"
5
+ android:viewportHeight="24.688">
6
+ <path
7
+ android:fillColor="#FF000000"
8
+ android:pathData="M0,0h23.232v24.688h-23.232z"
9
+ android:strokeAlpha="0"
10
+ android:fillAlpha="0"/>
11
+ <path
12
+ android:pathData="M1.475,21.236L9.189,21.236C10.342,21.236 10.869,20.718 10.869,19.556L10.869,7.886C10.869,7.027 10.234,6.568 9.541,6.568C9.072,6.568 8.613,6.782 8.301,7.232L0.303,18.911C0.088,19.224 0,19.585 0,19.917C0,20.63 0.469,21.236 1.475,21.236ZM2.09,19.644C1.963,19.644 1.943,19.536 2.002,19.448L9.102,8.706C9.16,8.618 9.268,8.648 9.268,8.755L9.268,19.361C9.268,19.585 9.209,19.644 8.975,19.644ZM21.396,21.236C22.412,21.236 22.871,20.63 22.871,19.917C22.871,19.585 22.783,19.224 22.568,18.911L14.58,7.232C14.258,6.782 13.809,6.568 13.34,6.568C12.646,6.568 12.012,7.027 12.012,7.886L12.012,19.556C12.012,20.718 12.529,21.236 13.691,21.236ZM20.781,19.644L13.896,19.644C13.672,19.644 13.613,19.585 13.613,19.361L13.613,8.755C13.613,8.648 13.721,8.618 13.769,8.706L20.869,19.448C20.938,19.536 20.918,19.644 20.781,19.644Z"
13
+ android:fillColor="#ffffff"
14
+ android:fillAlpha="0.85"/>
15
+ <path
16
+ android:pathData="M17.373,0.708L17.373,4.614C17.373,5.298 18.027,5.542 18.565,5.132L21.182,3.179C21.533,2.905 21.523,2.398 21.182,2.144L18.565,0.2C18.027,-0.21 17.373,0.034 17.373,0.708ZM5.508,4.614L5.508,0.708C5.508,0.034 4.854,-0.21 4.316,0.2L1.699,2.144C1.338,2.407 1.338,2.905 1.699,3.179L4.316,5.132C4.834,5.523 5.508,5.318 5.508,4.614ZM18.691,3.237C18.994,3.237 19.258,2.984 19.258,2.661C19.258,2.359 18.994,2.105 18.691,2.105L4.18,2.105C3.867,2.105 3.613,2.359 3.613,2.661C3.613,2.984 3.867,3.237 4.18,3.237Z"
17
+ android:fillColor="#ffffff"
18
+ android:fillAlpha="0.85"/>
19
+ </vector>