react-native-video-trim 6.2.2 → 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.
Files changed (29) hide show
  1. package/README.md +34 -9
  2. package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +146 -119
  3. package/android/src/main/java/com/videotrim/enums/ErrorCode.kt +2 -1
  4. package/android/src/main/java/com/videotrim/utils/MediaMetadataUtil.kt +6 -2
  5. package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +5 -1
  6. package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +99 -26
  7. package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +293 -0
  8. package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +556 -28
  9. package/android/src/main/res/drawable/arrow_trianglehead_left_and_right_righttriangle_left_righttriangle_right.xml +19 -0
  10. package/android/src/main/res/drawable/arrow_uturn_backward.xml +15 -0
  11. package/android/src/main/res/drawable/arrow_uturn_forward.xml +15 -0
  12. package/android/src/main/res/drawable/crop.xml +15 -0
  13. package/android/src/main/res/drawable/rotate_left.xml +19 -0
  14. package/android/src/main/res/layout/video_trimmer_view.xml +115 -37
  15. package/android/src/main/res/xml/file_paths.xml +1 -1
  16. package/ios/CropOverlayView.swift +285 -0
  17. package/ios/VideoTrim.mm +2 -4
  18. package/ios/VideoTrim.swift +198 -61
  19. package/ios/VideoTrimmer.swift +2 -4
  20. package/ios/VideoTrimmerViewController.swift +478 -56
  21. package/lib/module/NativeVideoTrim.js.map +1 -1
  22. package/lib/module/index.js +1 -2
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/typescript/src/NativeVideoTrim.d.ts +10 -4
  25. package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
  26. package/lib/typescript/src/index.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/NativeVideoTrim.ts +10 -4
  29. package/src/index.tsx +1 -2
@@ -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
@@ -124,6 +126,8 @@ class VideoTrimmerView(
124
126
  private var isGeneratingThumbnails = false
125
127
 
126
128
  private var mediaMetadataRetriever: MediaMetadataRetriever? = null
129
+ private val retrieverLock = Object()
130
+ @Volatile private var retrieverReleased = false
127
131
  private lateinit var loadingIndicator: ProgressBar
128
132
  private lateinit var saveBtn: TextView
129
133
  private lateinit var cancelBtn: TextView
@@ -133,6 +137,7 @@ class VideoTrimmerView(
133
137
 
134
138
  private var mOutputExt = "mp4"
135
139
  private var enableHapticFeedback = true
140
+ private var enablePreciseTrimming = false
136
141
  private var autoplay = false
137
142
  private var jumpToPositionOnLoad = 0L
138
143
  private lateinit var headerView: FrameLayout
@@ -144,6 +149,34 @@ class VideoTrimmerView(
144
149
  private var alertOnFailCloseText = "Close"
145
150
  private var currentSelectedhandle: View? = null
146
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
+
147
180
  private lateinit var trimmerView: RelativeLayout
148
181
 
149
182
  private var trimmerColor = context.getString(R.string.trim_color).toColorInt()
@@ -158,7 +191,7 @@ class VideoTrimmerView(
158
191
  private fun init(context: ReactApplicationContext, config: ReadableMap?) {
159
192
  mContext = context
160
193
 
161
- context.currentActivity!!.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
194
+ context.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
162
195
  LayoutInflater.from(context).inflate(R.layout.video_trimmer_view, this, true)
163
196
  vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
164
197
 
@@ -214,23 +247,31 @@ class VideoTrimmerView(
214
247
 
215
248
  leadingChevron = findViewById(R.id.leadingChevron)
216
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)
217
258
  }
218
259
 
219
260
  fun initByURI(videoURI: Uri) {
220
261
  mSourceUri = videoURI
221
262
 
222
263
  if (isVideoType) {
223
- mVideoView.setVideoURI(videoURI)
224
- mVideoView.requestFocus()
225
-
226
- mVideoView.setOnPreparedListener { mp ->
227
- mp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT)
228
- mediaPlayer = mp
229
- 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) {}
230
274
  }
231
-
232
- mVideoView.setOnErrorListener { mp, what, extra -> onFailToLoadMedia(mp, what, extra) }
233
- mVideoView.setOnCompletionListener { mediaCompleted() }
234
275
  } else {
235
276
  mVideoView.visibility = View.GONE
236
277
  audioBannerView.alpha = 0f
@@ -252,11 +293,58 @@ class VideoTrimmerView(
252
293
  }
253
294
  }
254
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
+
255
342
  private fun onFailToLoadMedia(mp: MediaPlayer, what: Int, extra: Int): Boolean {
256
343
  mediaFailed()
257
344
  mOnTrimVideoListener.onError("Error loading media file. Please try again.", ErrorCode.FAIL_TO_LOAD_MEDIA)
258
345
  if (alertOnFailToLoad) {
259
- val builder = AlertDialog.Builder(mContext.currentActivity!!)
346
+ val activity = mContext.currentActivity ?: return true
347
+ val builder = AlertDialog.Builder(activity)
260
348
  builder.setMessage(alertOnFailMessage)
261
349
  builder.setTitle(alertOnFailTitle)
262
350
  builder.setCancelable(false)
@@ -305,6 +393,8 @@ class VideoTrimmerView(
305
393
  mMaxDuration = mMaxDuration.coerceAtMost(mDuration.toLong())
306
394
 
307
395
  if (isVideoType) {
396
+ updateVideoViewSize()
397
+
308
398
  mediaMetadataRetriever = MediaMetadataUtil.getMediaMetadataRetriever(mSourceUri.toString())
309
399
  if (mediaMetadataRetriever == null) {
310
400
  mOnTrimVideoListener.onError("Error when retrieving video info. Please try again.", ErrorCode.FAIL_TO_GET_VIDEO_INFO)
@@ -344,6 +434,13 @@ class VideoTrimmerView(
344
434
  playOrPause()
345
435
  }
346
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
+
347
444
  mOnTrimVideoListener.onLoad(mDuration)
348
445
  ignoreSystemGestureForView(trimmerView)
349
446
  }
@@ -416,18 +513,37 @@ class VideoTrimmerView(
416
513
  mPlayView.setOnClickListener { playOrPause() }
417
514
  setHandleTouchListener(leadingHandle, true)
418
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() }
419
522
  }
420
523
 
421
524
  fun onSaveClicked() {
422
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
+ }
423
534
  ffmpegSession = VideoTrimmerUtil.trim(
424
535
  mSourceUri.toString(),
425
536
  StorageUtil.getOutputPath(mContext, mOutputExt),
426
537
  mDuration,
427
538
  startTime,
428
539
  endTime,
429
- enableRotation,
430
- rotationAngle,
540
+ rotationCount,
541
+ isFlipped,
542
+ getCropNormalizedRect(),
543
+ vw,
544
+ vh,
545
+ bitrate,
546
+ enablePreciseTrimming,
431
547
  mOnTrimVideoListener
432
548
  )
433
549
  }
@@ -462,6 +578,7 @@ class VideoTrimmerView(
462
578
  override fun onDestroy() {
463
579
  isGeneratingThumbnails = false
464
580
  BackgroundExecutor.cancelAll("", true)
581
+ BackgroundExecutor.cancelAll("progressive_thumbs", true)
465
582
  UiThreadExecutor.cancelAll("")
466
583
  mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
467
584
  mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
@@ -469,10 +586,21 @@ class VideoTrimmerView(
469
586
 
470
587
  cachedFullViewThumbnails.clear()
471
588
 
472
- try {
473
- mediaMetadataRetriever?.release()
474
- } catch (e: Exception) {
475
- e.printStackTrace()
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
+
597
+ synchronized(retrieverLock) {
598
+ retrieverReleased = true
599
+ try {
600
+ mediaMetadataRetriever?.release()
601
+ } catch (e: Exception) {
602
+ e.printStackTrace()
603
+ }
476
604
  }
477
605
 
478
606
  try {
@@ -482,6 +610,11 @@ class VideoTrimmerView(
482
610
  e.printStackTrace()
483
611
  Log.d(TAG, "onDestroy mediaPlayer is already released")
484
612
  }
613
+
614
+ try {
615
+ videoSurface?.release()
616
+ } catch (_: Exception) {}
617
+ videoSurface = null
485
618
  }
486
619
 
487
620
  private fun getScreenWidthInPortraitMode(): Int {
@@ -510,6 +643,7 @@ class VideoTrimmerView(
510
643
  mOutputExt = "wav"
511
644
  }
512
645
  enableHapticFeedback = config.hasKey("enableHapticFeedback") && config.getBoolean("enableHapticFeedback")
646
+ enablePreciseTrimming = config.hasKey("enablePreciseTrimming") && config.getBoolean("enablePreciseTrimming")
513
647
  autoplay = config.hasKey("autoplay") && config.getBoolean("autoplay")
514
648
 
515
649
  if (config.hasKey("jumpToPositionOnLoad") && config.getDouble("jumpToPositionOnLoad") > 0) {
@@ -530,8 +664,6 @@ class VideoTrimmerView(
530
664
  alertOnFailTitle = if (config.hasKey("alertOnFailTitle")) config.getString("alertOnFailTitle") ?: "Error" else "Error"
531
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"
532
666
  alertOnFailCloseText = if (config.hasKey("alertOnFailCloseText")) config.getString("alertOnFailCloseText") ?: "Close" else "Close"
533
- enableRotation = config.hasKey("enableRotation") && config.getBoolean("enableRotation")
534
- rotationAngle = if (config.hasKey("rotationAngle")) config.getDouble("rotationAngle") else 0.0
535
667
 
536
668
  if (config.hasKey("zoomOnWaitingDuration") && config.getDouble("zoomOnWaitingDuration") > 0) {
537
669
  zoomOnWaitingDuration = config.getDouble("zoomOnWaitingDuration").toLong()
@@ -1069,7 +1201,9 @@ class VideoTrimmerView(
1069
1201
  val clampedTimeUs = maxOf(0L, minOf(timeUs, mDuration * 1000L))
1070
1202
 
1071
1203
  try {
1072
- val bitmap = mediaMetadataRetriever?.getFrameAtTime(clampedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
1204
+ val bitmap = synchronized(retrieverLock) {
1205
+ if (retrieverReleased) null else mediaMetadataRetriever?.getFrameAtTime(clampedTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
1206
+ }
1073
1207
  if (bitmap != null && isGeneratingThumbnails && isZoomedIn) {
1074
1208
  UiThreadExecutor.runTask("", {
1075
1209
  if (isZoomedIn && index < mThumbnailContainer.childCount) {
@@ -1167,4 +1301,398 @@ class VideoTrimmerView(
1167
1301
  mThumbnailContainer.addView(restoredView)
1168
1302
  }
1169
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
1170
1698
  }