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.
- package/README.md +34 -9
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +38 -13
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +77 -13
- package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +305 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +558 -21
- package/android/src/main/res/drawable/arrow_trianglehead_left_and_right_righttriangle_left_righttriangle_right.xml +19 -0
- package/android/src/main/res/drawable/arrow_uturn_backward.xml +15 -0
- package/android/src/main/res/drawable/arrow_uturn_forward.xml +15 -0
- package/android/src/main/res/drawable/crop.xml +15 -0
- package/android/src/main/res/drawable/rotate_left.xml +19 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +117 -37
- package/ios/CropOverlayView.swift +287 -0
- package/ios/VideoTrim.mm +2 -4
- package/ios/VideoTrim.swift +160 -31
- package/ios/VideoTrimmerViewController.swift +442 -22
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +1 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +10 -4
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +10 -4
- 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:
|
|
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.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
mVideoView.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
}
|