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.
- 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 +293 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +540 -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 +115 -37
- package/ios/CropOverlayView.swift +285 -0
- package/ios/VideoTrim.mm +2 -4
- package/ios/VideoTrim.swift +160 -31
- package/ios/VideoTrimmerViewController.swift +441 -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,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
|
-
|
|
433
|
-
|
|
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>
|