react-native-video-trim 7.0.1 → 7.1.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 +67 -9
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +1 -1
- package/android/src/main/java/com/videotrim/widgets/AudioWaveformView.kt +92 -0
- package/android/src/main/java/com/videotrim/widgets/CropOverlayView.kt +10 -24
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +606 -32
- package/ios/AudioWaveformView.swift +75 -0
- package/ios/CropOverlayView.swift +7 -11
- package/ios/VideoTrim.mm +30 -0
- package/ios/VideoTrim.swift +7 -4
- package/ios/VideoTrimmer.swift +322 -12
- package/ios/VideoTrimmerViewController.swift +114 -44
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +16 -4
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +15 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +15 -0
- package/src/index.tsx +35 -4
|
@@ -8,6 +8,9 @@ import android.graphics.Matrix
|
|
|
8
8
|
import android.graphics.RectF
|
|
9
9
|
import android.graphics.SurfaceTexture
|
|
10
10
|
import android.graphics.drawable.GradientDrawable
|
|
11
|
+
import android.media.MediaCodec
|
|
12
|
+
import android.media.MediaExtractor
|
|
13
|
+
import android.media.MediaFormat
|
|
11
14
|
import android.media.MediaMetadataRetriever
|
|
12
15
|
import android.media.MediaPlayer
|
|
13
16
|
import android.net.Uri
|
|
@@ -125,6 +128,31 @@ class VideoTrimmerView(
|
|
|
125
128
|
@Volatile
|
|
126
129
|
private var isGeneratingThumbnails = false
|
|
127
130
|
|
|
131
|
+
// region Audio waveform state
|
|
132
|
+
//
|
|
133
|
+
// For audio files the trimmer replaces the thumbnail track with a waveform.
|
|
134
|
+
// Amplitudes are extracted via MediaExtractor + MediaCodec (hardware-accelerated
|
|
135
|
+
// PCM decode) and rendered by AudioWaveformView as rounded-rect bars.
|
|
136
|
+
//
|
|
137
|
+
// Remote files are first downloaded to a local cache file so that both the
|
|
138
|
+
// initial extraction and any zoom-level re-extractions can read from disk
|
|
139
|
+
// instead of re-streaming over the network each time.
|
|
140
|
+
private var waveformView: AudioWaveformView? = null
|
|
141
|
+
/** Full-view (non-zoomed) amplitudes, cached so we can restore instantly on zoom-out. */
|
|
142
|
+
private var cachedFullWaveformAmplitudes: FloatArray? = null
|
|
143
|
+
@Volatile
|
|
144
|
+
private var isGeneratingWaveform = false
|
|
145
|
+
/** Local file path used for waveform extraction (populated after downloading remote audio). */
|
|
146
|
+
private var localAudioFilePath: String? = null
|
|
147
|
+
@Volatile
|
|
148
|
+
private var isDownloadingAudio = false
|
|
149
|
+
private var waveformBarColor = Color.WHITE
|
|
150
|
+
private var waveformBgColor = Color.parseColor("#3478F6")
|
|
151
|
+
private var waveformBarWidthDp = 3f
|
|
152
|
+
private var waveformBarGapDp = 2f
|
|
153
|
+
private var waveformBarCornerRadiusDp = 1.5f
|
|
154
|
+
// endregion
|
|
155
|
+
|
|
128
156
|
private var mediaMetadataRetriever: MediaMetadataRetriever? = null
|
|
129
157
|
private val retrieverLock = Object()
|
|
130
158
|
@Volatile private var retrieverReleased = false
|
|
@@ -181,6 +209,9 @@ class VideoTrimmerView(
|
|
|
181
209
|
|
|
182
210
|
private var trimmerColor = context.getString(R.string.trim_color).toColorInt()
|
|
183
211
|
private var handleIconColor = Color.BLACK
|
|
212
|
+
private var isLightTheme = false
|
|
213
|
+
private val iconColor: Int get() = if (isLightTheme) Color.BLACK else Color.WHITE
|
|
214
|
+
private val dimmedIconColor: Int get() = if (isLightTheme) Color.argb(128, 0, 0, 0) else Color.argb(128, 255, 255, 255)
|
|
184
215
|
private lateinit var leadingChevron: ImageView
|
|
185
216
|
private lateinit var trailingChevron: ImageView
|
|
186
217
|
|
|
@@ -266,18 +297,37 @@ class VideoTrimmerView(
|
|
|
266
297
|
}
|
|
267
298
|
mVideoView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
|
268
299
|
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
|
|
269
|
-
|
|
300
|
+
val mp = mediaPlayer
|
|
301
|
+
if (mp != null) {
|
|
302
|
+
videoSurface?.release()
|
|
303
|
+
videoSurface = Surface(st)
|
|
304
|
+
mp.setSurface(videoSurface)
|
|
305
|
+
} else {
|
|
306
|
+
setupVideoPlayer(st, videoURI)
|
|
307
|
+
}
|
|
270
308
|
}
|
|
271
309
|
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {}
|
|
272
|
-
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean
|
|
310
|
+
override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean {
|
|
311
|
+
return false
|
|
312
|
+
}
|
|
273
313
|
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
|
|
274
314
|
}
|
|
275
315
|
} else {
|
|
316
|
+
// Audio path: hide video surface, show the audio banner with a fade-in,
|
|
317
|
+
// and kick off waveform generation *in parallel* with MediaPlayer.prepareAsync()
|
|
318
|
+
// so the waveform starts appearing as soon as possible (the download / decode
|
|
319
|
+
// runs on a background thread while MediaPlayer streams independently).
|
|
276
320
|
mVideoView.visibility = View.GONE
|
|
277
321
|
audioBannerView.alpha = 0f
|
|
278
322
|
audioBannerView.visibility = View.VISIBLE
|
|
279
323
|
audioBannerView.animate().alpha(1f).setDuration(500).start()
|
|
280
324
|
|
|
325
|
+
VideoTrimmerUtil.SCREEN_WIDTH_FULL = getScreenWidthInPortraitMode()
|
|
326
|
+
VideoTrimmerUtil.VIDEO_FRAMES_WIDTH = VideoTrimmerUtil.SCREEN_WIDTH_FULL - RECYCLER_VIEW_PADDING * 2
|
|
327
|
+
// endMs = 0 means "unknown duration"; extractAmplitudes will fall back
|
|
328
|
+
// to the track's KEY_DURATION or a sensible default.
|
|
329
|
+
startWaveformGeneration(0, 0)
|
|
330
|
+
|
|
281
331
|
mediaPlayer = MediaPlayer()
|
|
282
332
|
try {
|
|
283
333
|
mediaPlayer!!.setDataSource(videoURI.toString())
|
|
@@ -424,6 +474,24 @@ class VideoTrimmerView(
|
|
|
424
474
|
startShootVideoThumbs(mContext, VideoTrimmerUtil.MAX_COUNT_RANGE, 0, mDuration.toLong())
|
|
425
475
|
}
|
|
426
476
|
|
|
477
|
+
if (!isVideoType) {
|
|
478
|
+
// Waveform generation was started in initByURI, but the view is only
|
|
479
|
+
// created here (after MediaPlayer is ready) so the background color
|
|
480
|
+
// doesn't flash before the trimmer container is visible.
|
|
481
|
+
VideoTrimmerUtil.SCREEN_WIDTH_FULL = getScreenWidthInPortraitMode()
|
|
482
|
+
VideoTrimmerUtil.VIDEO_FRAMES_WIDTH = VideoTrimmerUtil.SCREEN_WIDTH_FULL - RECYCLER_VIEW_PADDING * 2
|
|
483
|
+
if (waveformView == null) {
|
|
484
|
+
setupWaveformView()
|
|
485
|
+
}
|
|
486
|
+
// If the background generation already finished, apply cached data immediately.
|
|
487
|
+
cachedFullWaveformAmplitudes?.let { waveformView?.setAmplitudes(it) }
|
|
488
|
+
// If generation hasn't started yet (e.g. failed the first time), retry
|
|
489
|
+
// now that we know the actual duration.
|
|
490
|
+
if (!isGeneratingWaveform && cachedFullWaveformAmplitudes == null) {
|
|
491
|
+
startWaveformGeneration(0, mDuration.toLong())
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
427
495
|
endTime = if (mMaxDuration < mDuration) mMaxDuration else mDuration.toLong()
|
|
428
496
|
updateHandlePositions()
|
|
429
497
|
|
|
@@ -432,7 +500,10 @@ class VideoTrimmerView(
|
|
|
432
500
|
saveBtn.visibility = View.VISIBLE
|
|
433
501
|
|
|
434
502
|
if (jumpToPositionOnLoad > 0) {
|
|
435
|
-
|
|
503
|
+
// Clamp to endTime so that jumpToPositionOnLoad > maxDuration doesn't
|
|
504
|
+
// cause an immediate pause (the old bug where autoplay appeared broken).
|
|
505
|
+
val clampedJump = jumpToPositionOnLoad.coerceAtMost(endTime)
|
|
506
|
+
seekTo(if (clampedJump > mDuration) mDuration.toLong() else clampedJump, true)
|
|
436
507
|
}
|
|
437
508
|
|
|
438
509
|
if (autoplay) {
|
|
@@ -444,6 +515,9 @@ class VideoTrimmerView(
|
|
|
444
515
|
transformRow.visibility = View.VISIBLE
|
|
445
516
|
transformRow.animate().alpha(1f).setDuration(250).start()
|
|
446
517
|
updateUndoRedoButtons()
|
|
518
|
+
} else {
|
|
519
|
+
// Fade the waveform in to match the trimmer container animation.
|
|
520
|
+
waveformView?.animate()?.alpha(1f)?.setDuration(250)?.start()
|
|
447
521
|
}
|
|
448
522
|
|
|
449
523
|
mOnTrimVideoListener.onLoad(mDuration)
|
|
@@ -525,9 +599,9 @@ class VideoTrimmerView(
|
|
|
525
599
|
undoBtn.setOnClickListener { onUndoTapped() }
|
|
526
600
|
redoBtn.setOnClickListener { onRedoTapped() }
|
|
527
601
|
|
|
528
|
-
cropBtn.setColorFilter(
|
|
529
|
-
undoBtn.setColorFilter(
|
|
530
|
-
redoBtn.setColorFilter(
|
|
602
|
+
cropBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
603
|
+
undoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
604
|
+
redoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
531
605
|
}
|
|
532
606
|
|
|
533
607
|
fun onSaveClicked() {
|
|
@@ -584,16 +658,34 @@ class VideoTrimmerView(
|
|
|
584
658
|
mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
|
585
659
|
}
|
|
586
660
|
|
|
661
|
+
/**
|
|
662
|
+
* Comprehensive cleanup when the editor is dismissed.
|
|
663
|
+
*
|
|
664
|
+
* The user can close the editor at any time — even immediately after opening —
|
|
665
|
+
* so every background task and media resource must be released here:
|
|
666
|
+
* • Flags (isGeneratingThumbnails / isGeneratingWaveform) are set to false first
|
|
667
|
+
* so that in-flight background loops exit early on the next iteration.
|
|
668
|
+
* • Named BackgroundExecutor tasks are cancelled to interrupt pending work.
|
|
669
|
+
* • MediaPlayer listeners are nulled *before* stop()/release() to prevent
|
|
670
|
+
* callbacks firing on a half-torn-down view.
|
|
671
|
+
* • The downloaded local audio cache file is deleted.
|
|
672
|
+
* • SurfaceTexture listener is cleared to avoid stale references.
|
|
673
|
+
*/
|
|
587
674
|
override fun onDestroy() {
|
|
588
675
|
isGeneratingThumbnails = false
|
|
589
|
-
|
|
676
|
+
isGeneratingWaveform = false
|
|
677
|
+
BackgroundExecutor.cancelAll("initial_thumbs", true)
|
|
590
678
|
BackgroundExecutor.cancelAll("progressive_thumbs", true)
|
|
679
|
+
BackgroundExecutor.cancelAll("waveform_gen", true)
|
|
591
680
|
UiThreadExecutor.cancelAll("")
|
|
592
681
|
mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
|
593
682
|
mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
|
|
594
683
|
zoomRunnable?.let { zoomWaitTimer.removeCallbacks(it) }
|
|
595
684
|
|
|
596
685
|
cachedFullViewThumbnails.clear()
|
|
686
|
+
cachedFullWaveformAmplitudes = null
|
|
687
|
+
waveformView = null
|
|
688
|
+
cleanupLocalAudioFile()
|
|
597
689
|
|
|
598
690
|
cropOverlay?.onCropBegan = null
|
|
599
691
|
cropOverlay?.onCropEnded = null
|
|
@@ -610,17 +702,23 @@ class VideoTrimmerView(
|
|
|
610
702
|
} catch (e: Exception) {
|
|
611
703
|
e.printStackTrace()
|
|
612
704
|
}
|
|
705
|
+
mediaMetadataRetriever = null
|
|
613
706
|
}
|
|
614
707
|
|
|
615
708
|
try {
|
|
709
|
+
mediaPlayer?.setOnPreparedListener(null)
|
|
710
|
+
mediaPlayer?.setOnCompletionListener(null)
|
|
711
|
+
mediaPlayer?.setOnErrorListener(null)
|
|
616
712
|
mediaPlayer?.stop()
|
|
617
713
|
mediaPlayer?.release()
|
|
618
714
|
} catch (e: IllegalStateException) {
|
|
619
715
|
e.printStackTrace()
|
|
620
716
|
Log.d(TAG, "onDestroy mediaPlayer is already released")
|
|
621
717
|
}
|
|
718
|
+
mediaPlayer = null
|
|
622
719
|
|
|
623
720
|
try {
|
|
721
|
+
mVideoView.surfaceTextureListener = null
|
|
624
722
|
videoSurface?.release()
|
|
625
723
|
} catch (_: Exception) {}
|
|
626
724
|
videoSurface = null
|
|
@@ -642,6 +740,8 @@ class VideoTrimmerView(
|
|
|
642
740
|
mMinDuration = maxOf(1000L, config.getDouble("minDuration").toLong())
|
|
643
741
|
}
|
|
644
742
|
|
|
743
|
+
isLightTheme = config.hasKey("theme") && config.getString("theme") == "light"
|
|
744
|
+
|
|
645
745
|
cancelBtn.text = config.getString("cancelButtonText")
|
|
646
746
|
saveBtn.text = config.getString("saveButtonText")
|
|
647
747
|
isVideoType = config.hasKey("type") && config.getString("type") == "video"
|
|
@@ -682,9 +782,26 @@ class VideoTrimmerView(
|
|
|
682
782
|
trimmerColor = if (config.hasKey("trimmerColor")) config.getInt("trimmerColor") else context.getString(
|
|
683
783
|
R.string.trim_color
|
|
684
784
|
).toColorInt()
|
|
685
|
-
handleIconColor = if (config.hasKey("handleIconColor")) config.getInt("handleIconColor") else Color.BLACK
|
|
785
|
+
handleIconColor = if (config.hasKey("handleIconColor")) config.getInt("handleIconColor") else (if (isLightTheme) Color.WHITE else Color.BLACK)
|
|
786
|
+
|
|
787
|
+
if (config.hasKey("waveformColor")) {
|
|
788
|
+
waveformBarColor = config.getInt("waveformColor")
|
|
789
|
+
}
|
|
790
|
+
if (config.hasKey("waveformBackgroundColor")) {
|
|
791
|
+
waveformBgColor = config.getInt("waveformBackgroundColor")
|
|
792
|
+
}
|
|
793
|
+
if (config.hasKey("waveformBarWidth") && config.getDouble("waveformBarWidth") > 0) {
|
|
794
|
+
waveformBarWidthDp = config.getDouble("waveformBarWidth").toFloat()
|
|
795
|
+
}
|
|
796
|
+
if (config.hasKey("waveformBarGap") && config.getDouble("waveformBarGap") >= 0) {
|
|
797
|
+
waveformBarGapDp = config.getDouble("waveformBarGap").toFloat()
|
|
798
|
+
}
|
|
799
|
+
if (config.hasKey("waveformBarCornerRadius") && config.getDouble("waveformBarCornerRadius") >= 0) {
|
|
800
|
+
waveformBarCornerRadiusDp = config.getDouble("waveformBarCornerRadius").toFloat()
|
|
801
|
+
}
|
|
686
802
|
|
|
687
803
|
applyTrimmerColor()
|
|
804
|
+
applyThemeColors()
|
|
688
805
|
}
|
|
689
806
|
|
|
690
807
|
private fun applyTrimmerColor() {
|
|
@@ -713,6 +830,39 @@ class VideoTrimmerView(
|
|
|
713
830
|
trailingChevron.setColorFilter(handleIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
714
831
|
}
|
|
715
832
|
|
|
833
|
+
private fun applyThemeColors() {
|
|
834
|
+
val bgColor = if (isLightTheme) Color.WHITE else Color.BLACK
|
|
835
|
+
val textColor = if (isLightTheme) Color.BLACK else Color.WHITE
|
|
836
|
+
val overlayColor = if (isLightTheme) Color.argb(153, 255, 255, 255) else Color.argb(191, 0, 0, 0)
|
|
837
|
+
|
|
838
|
+
// Backgrounds
|
|
839
|
+
(findViewById<View>(R.id.layout) as? RelativeLayout)?.setBackgroundColor(bgColor)
|
|
840
|
+
headerView.setBackgroundColor(bgColor)
|
|
841
|
+
transformRow.setBackgroundColor(bgColor)
|
|
842
|
+
videoContainer.setBackgroundColor(bgColor)
|
|
843
|
+
|
|
844
|
+
// Text colors
|
|
845
|
+
cancelBtn.setTextColor(iconColor)
|
|
846
|
+
startTimeText.setTextColor(textColor)
|
|
847
|
+
currentTimeText.setTextColor(textColor)
|
|
848
|
+
endTimeText.setTextColor(textColor)
|
|
849
|
+
|
|
850
|
+
// Play icon
|
|
851
|
+
mPlayView.setColorFilter(iconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
852
|
+
|
|
853
|
+
// Transform toolbar icons
|
|
854
|
+
flipBtn.setColorFilter(iconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
855
|
+
rotateBtn.setColorFilter(iconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
856
|
+
cropBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
857
|
+
undoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
858
|
+
redoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
859
|
+
|
|
860
|
+
// Overlays
|
|
861
|
+
leadingOverlay.setBackgroundColor(overlayColor)
|
|
862
|
+
trailingOverlay.setBackgroundColor(overlayColor)
|
|
863
|
+
|
|
864
|
+
}
|
|
865
|
+
|
|
716
866
|
private fun dpToPx(dp: Int): Int {
|
|
717
867
|
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp.toFloat(), resources.displayMetrics).toInt()
|
|
718
868
|
}
|
|
@@ -828,7 +978,7 @@ class VideoTrimmerView(
|
|
|
828
978
|
}
|
|
829
979
|
|
|
830
980
|
private fun onTrimmerContainerPanned(event: MotionEvent) {
|
|
831
|
-
var newX = event.rawX
|
|
981
|
+
var newX = rawXToLocalX(event.rawX)
|
|
832
982
|
var didClamp = false
|
|
833
983
|
|
|
834
984
|
val leftBoundary = leadingHandle.x + leadingHandle.width
|
|
@@ -922,12 +1072,17 @@ class VideoTrimmerView(
|
|
|
922
1072
|
if (draggingDisabled) return@setOnTouchListener false
|
|
923
1073
|
|
|
924
1074
|
var didClamp = false
|
|
925
|
-
var newX = event.rawX - view.width.toFloat() / 2
|
|
1075
|
+
var newX = rawXToLocalX(event.rawX) - view.width.toFloat() / 2
|
|
926
1076
|
|
|
927
1077
|
if (isLeading) {
|
|
1078
|
+
val unclamped = newX
|
|
928
1079
|
newX = maxOf(0f, minOf(newX, trailingHandle.x - view.width))
|
|
1080
|
+
if (unclamped < 0f) didClamp = true
|
|
929
1081
|
} else {
|
|
930
|
-
|
|
1082
|
+
val unclamped = newX
|
|
1083
|
+
val maxX = trimmerContainerBg.width.toFloat() + view.width
|
|
1084
|
+
newX = minOf(maxX, maxOf(newX, leadingHandle.x + view.width))
|
|
1085
|
+
if (unclamped > maxX) didClamp = true
|
|
931
1086
|
}
|
|
932
1087
|
|
|
933
1088
|
view.x = newX
|
|
@@ -1071,7 +1226,8 @@ class VideoTrimmerView(
|
|
|
1071
1226
|
|
|
1072
1227
|
private fun playHapticFeedback(isLight: Boolean) {
|
|
1073
1228
|
if (vibrator != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && enableHapticFeedback) {
|
|
1074
|
-
|
|
1229
|
+
val amplitude = if (isLight) 30 else 80
|
|
1230
|
+
vibrator!!.vibrate(VibrationEffect.createOneShot(if (isLight) 8L else 15L, amplitude))
|
|
1075
1231
|
}
|
|
1076
1232
|
}
|
|
1077
1233
|
|
|
@@ -1095,9 +1251,15 @@ class VideoTrimmerView(
|
|
|
1095
1251
|
stopZoomWaitTimer()
|
|
1096
1252
|
if (isZoomedIn) {
|
|
1097
1253
|
isGeneratingThumbnails = false
|
|
1254
|
+
isGeneratingWaveform = false
|
|
1098
1255
|
BackgroundExecutor.cancelAll("progressive_thumbs", true)
|
|
1256
|
+
BackgroundExecutor.cancelAll("waveform_gen", true)
|
|
1099
1257
|
isZoomedIn = false
|
|
1100
|
-
|
|
1258
|
+
if (isVideoType) {
|
|
1259
|
+
restoreCachedThumbnails()
|
|
1260
|
+
} else {
|
|
1261
|
+
restoreCachedWaveform()
|
|
1262
|
+
}
|
|
1101
1263
|
animateZoomTransition {
|
|
1102
1264
|
updateHandlePositions()
|
|
1103
1265
|
updateCurrentTime(true)
|
|
@@ -1135,7 +1297,14 @@ class VideoTrimmerView(
|
|
|
1135
1297
|
|
|
1136
1298
|
isZoomedIn = true
|
|
1137
1299
|
|
|
1138
|
-
|
|
1300
|
+
if (isVideoType) {
|
|
1301
|
+
startProgressiveThumbnailGeneration()
|
|
1302
|
+
} else {
|
|
1303
|
+
if (cachedFullWaveformAmplitudes == null) {
|
|
1304
|
+
cachedFullWaveformAmplitudes = waveformView?.amplitudes?.copyOf()
|
|
1305
|
+
}
|
|
1306
|
+
startProgressiveWaveformGeneration()
|
|
1307
|
+
}
|
|
1139
1308
|
updateHandlePositionsForZoom(currentLeadingX, currentTrailingX)
|
|
1140
1309
|
playHapticFeedback(true)
|
|
1141
1310
|
}
|
|
@@ -1280,6 +1449,20 @@ class VideoTrimmerView(
|
|
|
1280
1449
|
}
|
|
1281
1450
|
}
|
|
1282
1451
|
|
|
1452
|
+
/**
|
|
1453
|
+
* Convert a screen-absolute X (from [MotionEvent.getRawX]) to a coordinate
|
|
1454
|
+
* relative to [trimmerContainerWrapper].
|
|
1455
|
+
*
|
|
1456
|
+
* Touch events report rawX in screen-space, but the trimmer's handle/indicator
|
|
1457
|
+
* positions are in the container's local coordinate space. Without this
|
|
1458
|
+
* conversion the playhead lands to the right of the actual finger position.
|
|
1459
|
+
*/
|
|
1460
|
+
private fun rawXToLocalX(rawX: Float): Float {
|
|
1461
|
+
val location = IntArray(2)
|
|
1462
|
+
trimmerContainerWrapper.getLocationOnScreen(location)
|
|
1463
|
+
return rawX - location[0]
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1283
1466
|
private fun positionForTime(time: Long): Float {
|
|
1284
1467
|
return if (isZoomedIn) {
|
|
1285
1468
|
if (zoomedInRangeDuration <= 0) return 0f
|
|
@@ -1311,6 +1494,371 @@ class VideoTrimmerView(
|
|
|
1311
1494
|
}
|
|
1312
1495
|
}
|
|
1313
1496
|
|
|
1497
|
+
// region Waveform — audio-only bar visualization
|
|
1498
|
+
//
|
|
1499
|
+
// Lifecycle:
|
|
1500
|
+
// 1. initByURI starts background download + extraction (startWaveformGeneration).
|
|
1501
|
+
// 2. mediaPrepared creates the AudioWaveformView and applies any cached data.
|
|
1502
|
+
// 3. On zoom-in the visible time range changes; startProgressiveWaveformGeneration
|
|
1503
|
+
// re-extracts just that sub-range at higher resolution.
|
|
1504
|
+
// 4. On zoom-out the cached full-view amplitudes are restored instantly.
|
|
1505
|
+
// 5. onDestroy cancels all in-flight work and deletes the local cache file.
|
|
1506
|
+
|
|
1507
|
+
/** Create the AudioWaveformView and add it to the thumbnail container.
|
|
1508
|
+
* Starts with alpha=0; faded in later in mediaPrepared(). */
|
|
1509
|
+
private fun setupWaveformView() {
|
|
1510
|
+
val density = resources.displayMetrics.density
|
|
1511
|
+
val view = AudioWaveformView(context)
|
|
1512
|
+
view.barColor = waveformBarColor
|
|
1513
|
+
view.setBackgroundColor(waveformBgColor)
|
|
1514
|
+
view.barWidthPx = waveformBarWidthDp * density
|
|
1515
|
+
view.barGapPx = waveformBarGapDp * density
|
|
1516
|
+
view.barCornerRadiusPx = waveformBarCornerRadiusDp * density
|
|
1517
|
+
view.layoutParams = LinearLayout.LayoutParams(
|
|
1518
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
1519
|
+
LinearLayout.LayoutParams.MATCH_PARENT
|
|
1520
|
+
)
|
|
1521
|
+
view.alpha = 0f
|
|
1522
|
+
mThumbnailContainer.removeAllViews()
|
|
1523
|
+
mThumbnailContainer.addView(view)
|
|
1524
|
+
waveformView = view
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Generate waveform amplitudes for the full (non-zoomed) view.
|
|
1529
|
+
*
|
|
1530
|
+
* Called from initByURI (with endMs=0 meaning unknown) to start work as
|
|
1531
|
+
* early as possible, and again from mediaPrepared if the first attempt
|
|
1532
|
+
* didn't produce data (e.g. the duration wasn't known yet).
|
|
1533
|
+
*
|
|
1534
|
+
* For remote URLs the first call triggers [resolveLocalAudioPath], which
|
|
1535
|
+
* downloads the audio to a cache file. Subsequent calls (zoom) reuse
|
|
1536
|
+
* that cached file for instant re-extraction without network I/O.
|
|
1537
|
+
*
|
|
1538
|
+
* Progressive updates are sent to the UI via [onProgress] so the user
|
|
1539
|
+
* sees bars filling in as they are decoded, rather than waiting for the
|
|
1540
|
+
* entire file to finish.
|
|
1541
|
+
*/
|
|
1542
|
+
private fun startWaveformGeneration(startMs: Long, endMs: Long) {
|
|
1543
|
+
if (isGeneratingWaveform) return
|
|
1544
|
+
isGeneratingWaveform = true
|
|
1545
|
+
|
|
1546
|
+
val sourceUri = mSourceUri?.toString() ?: return
|
|
1547
|
+
val density = resources.displayMetrics.density
|
|
1548
|
+
val containerWidth = mThumbnailContainer.width - mThumbnailContainer.paddingLeft - mThumbnailContainer.paddingRight
|
|
1549
|
+
val effectiveWidth = if (containerWidth > 0) containerWidth else VideoTrimmerUtil.VIDEO_FRAMES_WIDTH
|
|
1550
|
+
val step = waveformBarWidthDp * density + waveformBarGapDp * density
|
|
1551
|
+
val barCount = maxOf(1, (effectiveWidth / step).toInt())
|
|
1552
|
+
|
|
1553
|
+
BackgroundExecutor.execute(object : BackgroundExecutor.Task("waveform_gen", 0L, "") {
|
|
1554
|
+
override fun execute() {
|
|
1555
|
+
try {
|
|
1556
|
+
val effectiveUri = resolveLocalAudioPath(sourceUri) ?: return
|
|
1557
|
+
|
|
1558
|
+
val amplitudes = extractAmplitudes(effectiveUri, startMs, endMs, barCount) { intermediate ->
|
|
1559
|
+
runOnUiThread {
|
|
1560
|
+
if (isGeneratingWaveform) waveformView?.setAmplitudes(intermediate)
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (!isGeneratingWaveform) return
|
|
1565
|
+
|
|
1566
|
+
cachedFullWaveformAmplitudes = amplitudes.copyOf()
|
|
1567
|
+
|
|
1568
|
+
runOnUiThread {
|
|
1569
|
+
waveformView?.setAmplitudes(amplitudes)
|
|
1570
|
+
}
|
|
1571
|
+
} catch (e: Exception) {
|
|
1572
|
+
Log.e(TAG, "Error generating waveform", e)
|
|
1573
|
+
} finally {
|
|
1574
|
+
isGeneratingWaveform = false
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
})
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/** Convert accumulated RMS² values to [0, 1] amplitudes normalised against the peak bar. */
|
|
1581
|
+
private fun normalizeAmplitudes(sumSquares: DoubleArray, sampleCounts: IntArray, barCount: Int): FloatArray {
|
|
1582
|
+
val amplitudes = FloatArray(barCount)
|
|
1583
|
+
var maxAmp = 0f
|
|
1584
|
+
for (i in 0 until barCount) {
|
|
1585
|
+
if (sampleCounts[i] > 0) {
|
|
1586
|
+
amplitudes[i] = kotlin.math.sqrt(sumSquares[i] / sampleCounts[i]).toFloat()
|
|
1587
|
+
if (amplitudes[i] > maxAmp) maxAmp = amplitudes[i]
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
if (maxAmp > 0f) {
|
|
1591
|
+
for (i in amplitudes.indices) {
|
|
1592
|
+
amplitudes[i] = (amplitudes[i] / maxAmp).coerceIn(0f, 1f)
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return amplitudes
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* Decode the audio track in [sourceUri] and compute RMS amplitude per bar.
|
|
1600
|
+
*
|
|
1601
|
+
* Pipeline:
|
|
1602
|
+
* MediaExtractor (demux compressed packets)
|
|
1603
|
+
* → MediaCodec (hardware-decode to 16-bit PCM)
|
|
1604
|
+
* → on-the-fly RMS accumulation into per-bar buckets
|
|
1605
|
+
*
|
|
1606
|
+
* Each output buffer's presentation timestamp determines which bar bucket
|
|
1607
|
+
* receives its samples. The result is normalised so the loudest bar = 1.0.
|
|
1608
|
+
*
|
|
1609
|
+
* [onProgress] fires periodically so the UI can show bars filling in
|
|
1610
|
+
* incrementally. The first update fires after 5 % of bars are filled
|
|
1611
|
+
* (firstUpdateThreshold) and subsequent updates every 20 % (regularUpdateInterval).
|
|
1612
|
+
*
|
|
1613
|
+
* The codec is wrapped in its own try/finally to guarantee release even
|
|
1614
|
+
* if an exception occurs mid-decode (e.g. editor closed).
|
|
1615
|
+
*
|
|
1616
|
+
* @param startMs start of the time range to extract (ms).
|
|
1617
|
+
* @param endMs end of the time range (ms); 0 = use track/fallback duration.
|
|
1618
|
+
* @param barCount number of output amplitude bars.
|
|
1619
|
+
*/
|
|
1620
|
+
private fun extractAmplitudes(
|
|
1621
|
+
sourceUri: String, startMs: Long, endMs: Long, barCount: Int,
|
|
1622
|
+
onProgress: ((FloatArray) -> Unit)? = null
|
|
1623
|
+
): FloatArray {
|
|
1624
|
+
val extractor = MediaExtractor()
|
|
1625
|
+
try {
|
|
1626
|
+
if (sourceUri.startsWith("http://") || sourceUri.startsWith("https://")) {
|
|
1627
|
+
extractor.setDataSource(sourceUri, HashMap())
|
|
1628
|
+
} else {
|
|
1629
|
+
extractor.setDataSource(sourceUri)
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
var audioTrackIndex = -1
|
|
1633
|
+
var audioFormat: MediaFormat? = null
|
|
1634
|
+
for (i in 0 until extractor.trackCount) {
|
|
1635
|
+
val format = extractor.getTrackFormat(i)
|
|
1636
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
|
1637
|
+
if (mime.startsWith("audio/")) {
|
|
1638
|
+
audioTrackIndex = i
|
|
1639
|
+
audioFormat = format
|
|
1640
|
+
break
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
if (audioTrackIndex < 0 || audioFormat == null) return FloatArray(0)
|
|
1644
|
+
|
|
1645
|
+
extractor.selectTrack(audioTrackIndex)
|
|
1646
|
+
extractor.seekTo(startMs * 1000, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
|
1647
|
+
|
|
1648
|
+
val mime = audioFormat.getString(MediaFormat.KEY_MIME) ?: return FloatArray(0)
|
|
1649
|
+
val codec = MediaCodec.createDecoderByType(mime)
|
|
1650
|
+
try {
|
|
1651
|
+
codec.configure(audioFormat, null, null, 0)
|
|
1652
|
+
codec.start()
|
|
1653
|
+
|
|
1654
|
+
val startUs = startMs * 1000L
|
|
1655
|
+
// containsKey guard for API < 29 compatibility (getLong(key, default) requires API 29)
|
|
1656
|
+
val trackDurationUs = if (audioFormat.containsKey(MediaFormat.KEY_DURATION)) audioFormat.getLong(MediaFormat.KEY_DURATION) else 0L
|
|
1657
|
+
val endUs = when {
|
|
1658
|
+
endMs > 0 -> endMs * 1000L
|
|
1659
|
+
trackDurationUs > 0 -> trackDurationUs
|
|
1660
|
+
else -> Long.MAX_VALUE
|
|
1661
|
+
}
|
|
1662
|
+
val totalDurationUs = maxOf(1L, if (endUs == Long.MAX_VALUE) {
|
|
1663
|
+
if (trackDurationUs > 0) trackDurationUs - startUs else 60_000_000L
|
|
1664
|
+
} else {
|
|
1665
|
+
endUs - startUs
|
|
1666
|
+
})
|
|
1667
|
+
val barDurationUs = totalDurationUs.toDouble() / barCount
|
|
1668
|
+
|
|
1669
|
+
val sumSquares = DoubleArray(barCount)
|
|
1670
|
+
val sampleCounts = IntArray(barCount)
|
|
1671
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
1672
|
+
var inputDone = false
|
|
1673
|
+
var outputDone = false
|
|
1674
|
+
val timeoutUs = 10_000L
|
|
1675
|
+
var highestFilledBar = -1
|
|
1676
|
+
// Show the first partial waveform early (5% of bars) for perceived speed
|
|
1677
|
+
val firstUpdateThreshold = maxOf(1, barCount / 20)
|
|
1678
|
+
val regularUpdateInterval = maxOf(1, barCount / 5)
|
|
1679
|
+
var lastUpdateBar = -1
|
|
1680
|
+
|
|
1681
|
+
while (!outputDone && isGeneratingWaveform) {
|
|
1682
|
+
if (!inputDone) {
|
|
1683
|
+
val inputIndex = codec.dequeueInputBuffer(timeoutUs)
|
|
1684
|
+
if (inputIndex >= 0) {
|
|
1685
|
+
val inputBuffer = codec.getInputBuffer(inputIndex) ?: continue
|
|
1686
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
1687
|
+
if (sampleSize < 0 || extractor.sampleTime > endUs) {
|
|
1688
|
+
codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
1689
|
+
inputDone = true
|
|
1690
|
+
} else {
|
|
1691
|
+
codec.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0)
|
|
1692
|
+
extractor.advance()
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
val outputIndex = codec.dequeueOutputBuffer(bufferInfo, timeoutUs)
|
|
1698
|
+
if (outputIndex >= 0) {
|
|
1699
|
+
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
|
1700
|
+
outputDone = true
|
|
1701
|
+
}
|
|
1702
|
+
val outputBuffer = codec.getOutputBuffer(outputIndex)
|
|
1703
|
+
if (outputBuffer != null && bufferInfo.size > 0) {
|
|
1704
|
+
// Map this buffer's timestamp to the corresponding bar bucket
|
|
1705
|
+
val bufferTimeUs = bufferInfo.presentationTimeUs - startUs
|
|
1706
|
+
val barIndex = (bufferTimeUs / barDurationUs).toInt().coerceIn(0, barCount - 1)
|
|
1707
|
+
|
|
1708
|
+
outputBuffer.position(bufferInfo.offset)
|
|
1709
|
+
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
|
|
1710
|
+
// PCM 16-bit: each sample is 2 bytes (Short)
|
|
1711
|
+
val shortCount = bufferInfo.size / 2
|
|
1712
|
+
for (i in 0 until shortCount) {
|
|
1713
|
+
val sample = outputBuffer.short.toFloat() / Short.MAX_VALUE
|
|
1714
|
+
sumSquares[barIndex] += (sample * sample).toDouble()
|
|
1715
|
+
sampleCounts[barIndex]++
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
if (barIndex > highestFilledBar) highestFilledBar = barIndex
|
|
1719
|
+
|
|
1720
|
+
if (onProgress != null) {
|
|
1721
|
+
val interval = if (lastUpdateBar < 0) firstUpdateThreshold else regularUpdateInterval
|
|
1722
|
+
if (highestFilledBar - maxOf(0, lastUpdateBar) >= interval) {
|
|
1723
|
+
lastUpdateBar = highestFilledBar
|
|
1724
|
+
onProgress(normalizeAmplitudes(sumSquares, sampleCounts, barCount))
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
codec.releaseOutputBuffer(outputIndex, false)
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
return normalizeAmplitudes(sumSquares, sampleCounts, barCount)
|
|
1733
|
+
} finally {
|
|
1734
|
+
try { codec.stop() } catch (_: Exception) {}
|
|
1735
|
+
codec.release()
|
|
1736
|
+
}
|
|
1737
|
+
} finally {
|
|
1738
|
+
extractor.release()
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* Re-extract amplitudes for the currently visible (zoomed-in) time range.
|
|
1744
|
+
*
|
|
1745
|
+
* Uses the already-downloaded local file if available, so this is a pure
|
|
1746
|
+
* disk-read + decode operation with no network latency.
|
|
1747
|
+
*/
|
|
1748
|
+
private fun startProgressiveWaveformGeneration() {
|
|
1749
|
+
if (isGeneratingWaveform) return
|
|
1750
|
+
isGeneratingWaveform = true
|
|
1751
|
+
|
|
1752
|
+
val sourceUri = mSourceUri?.toString() ?: return
|
|
1753
|
+
val density = resources.displayMetrics.density
|
|
1754
|
+
val containerWidth = mThumbnailContainer.width - mThumbnailContainer.paddingLeft - mThumbnailContainer.paddingRight
|
|
1755
|
+
val effectiveWidth = if (containerWidth > 0) containerWidth else VideoTrimmerUtil.VIDEO_FRAMES_WIDTH
|
|
1756
|
+
val step = waveformBarWidthDp * density + waveformBarGapDp * density
|
|
1757
|
+
val barCount = maxOf(1, (effectiveWidth / step).toInt())
|
|
1758
|
+
|
|
1759
|
+
val visibleStart = if (isZoomedIn) zoomedInRangeStart else 0L
|
|
1760
|
+
val visibleEnd = if (isZoomedIn) zoomedInRangeStart + zoomedInRangeDuration else mDuration.toLong()
|
|
1761
|
+
|
|
1762
|
+
BackgroundExecutor.execute(object : BackgroundExecutor.Task("waveform_gen", 0L, "") {
|
|
1763
|
+
override fun execute() {
|
|
1764
|
+
try {
|
|
1765
|
+
val effectiveUri = localAudioFilePath ?: sourceUri
|
|
1766
|
+
|
|
1767
|
+
val amplitudes = extractAmplitudes(effectiveUri, visibleStart, visibleEnd, barCount) { intermediate ->
|
|
1768
|
+
runOnUiThread {
|
|
1769
|
+
if (isGeneratingWaveform && isZoomedIn) waveformView?.setAmplitudes(intermediate)
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
if (!isGeneratingWaveform || !isZoomedIn) return
|
|
1774
|
+
|
|
1775
|
+
runOnUiThread {
|
|
1776
|
+
waveformView?.setAmplitudes(amplitudes)
|
|
1777
|
+
}
|
|
1778
|
+
} catch (e: Exception) {
|
|
1779
|
+
Log.e(TAG, "Error generating zoomed waveform", e)
|
|
1780
|
+
} finally {
|
|
1781
|
+
isGeneratingWaveform = false
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
})
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/** Instantly restore the full-view waveform from cache on zoom-out. */
|
|
1788
|
+
private fun restoreCachedWaveform() {
|
|
1789
|
+
cachedFullWaveformAmplitudes?.let { cached ->
|
|
1790
|
+
waveformView?.setAmplitudes(cached)
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* Ensure the audio source is available as a local file.
|
|
1796
|
+
*
|
|
1797
|
+
* For local URIs this is a no-op. For remote (http/https) URIs the file
|
|
1798
|
+
* is downloaded once to [Context.getCacheDir] and the path is cached in
|
|
1799
|
+
* [localAudioFilePath] for all subsequent reads (zoom re-extractions).
|
|
1800
|
+
*
|
|
1801
|
+
* Uses a `.tmp` extension because Android's MediaExtractor probes file
|
|
1802
|
+
* content to determine the codec, unlike iOS's AVURLAsset which relies
|
|
1803
|
+
* on the file extension.
|
|
1804
|
+
*
|
|
1805
|
+
* Returns null if a download is already in progress or if the editor
|
|
1806
|
+
* was closed mid-download (checked via [isGeneratingWaveform]).
|
|
1807
|
+
*/
|
|
1808
|
+
private fun resolveLocalAudioPath(sourceUri: String): String? {
|
|
1809
|
+
if (!sourceUri.startsWith("http://") && !sourceUri.startsWith("https://")) {
|
|
1810
|
+
return sourceUri
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
localAudioFilePath?.let { return it }
|
|
1814
|
+
|
|
1815
|
+
if (isDownloadingAudio) return null
|
|
1816
|
+
isDownloadingAudio = true
|
|
1817
|
+
|
|
1818
|
+
try {
|
|
1819
|
+
val url = java.net.URL(sourceUri)
|
|
1820
|
+
val connection = url.openConnection() as java.net.HttpURLConnection
|
|
1821
|
+
connection.connectTimeout = 30_000
|
|
1822
|
+
connection.readTimeout = 30_000
|
|
1823
|
+
connection.instanceFollowRedirects = true
|
|
1824
|
+
connection.connect()
|
|
1825
|
+
|
|
1826
|
+
val destFile = java.io.File(context.cacheDir, "waveform_${System.currentTimeMillis()}.tmp")
|
|
1827
|
+
connection.inputStream.use { input ->
|
|
1828
|
+
java.io.FileOutputStream(destFile).use { output ->
|
|
1829
|
+
val buffer = ByteArray(8192)
|
|
1830
|
+
var bytesRead: Int
|
|
1831
|
+
while (input.read(buffer).also { bytesRead = it } != -1) {
|
|
1832
|
+
if (!isGeneratingWaveform) {
|
|
1833
|
+
destFile.delete()
|
|
1834
|
+
return null
|
|
1835
|
+
}
|
|
1836
|
+
output.write(buffer, 0, bytesRead)
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
connection.disconnect()
|
|
1841
|
+
|
|
1842
|
+
localAudioFilePath = destFile.absolutePath
|
|
1843
|
+
return destFile.absolutePath
|
|
1844
|
+
} catch (e: Exception) {
|
|
1845
|
+
Log.e(TAG, "Failed to download audio for waveform", e)
|
|
1846
|
+
return sourceUri
|
|
1847
|
+
} finally {
|
|
1848
|
+
isDownloadingAudio = false
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** Delete the temporary local audio file created by [resolveLocalAudioPath]. */
|
|
1853
|
+
private fun cleanupLocalAudioFile() {
|
|
1854
|
+
localAudioFilePath?.let {
|
|
1855
|
+
try { java.io.File(it).delete() } catch (_: Exception) {}
|
|
1856
|
+
localAudioFilePath = null
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// endregion
|
|
1861
|
+
|
|
1314
1862
|
// region Transform
|
|
1315
1863
|
|
|
1316
1864
|
private fun containerContentWidth(): Float =
|
|
@@ -1320,7 +1868,7 @@ class VideoTrimmerView(
|
|
|
1320
1868
|
(videoContainer.height - videoContainer.paddingTop - videoContainer.paddingBottom).toFloat()
|
|
1321
1869
|
|
|
1322
1870
|
private fun bracketOverflow(): Int =
|
|
1323
|
-
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,
|
|
1871
|
+
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, resources.displayMetrics).toInt()
|
|
1324
1872
|
|
|
1325
1873
|
private fun onFlipTapped() {
|
|
1326
1874
|
pushUndo()
|
|
@@ -1329,7 +1877,12 @@ class VideoTrimmerView(
|
|
|
1329
1877
|
val fitScale = if (rotationCount % 2 != 0) {
|
|
1330
1878
|
val cw = containerContentWidth()
|
|
1331
1879
|
val ch = containerContentHeight()
|
|
1332
|
-
|
|
1880
|
+
val vw = mVideoView.width.toFloat()
|
|
1881
|
+
val vh = mVideoView.height.toFloat()
|
|
1882
|
+
val margin = bracketOverflow().toFloat()
|
|
1883
|
+
val availW = cw - 2 * margin
|
|
1884
|
+
val availH = ch - 2 * margin
|
|
1885
|
+
if (availW > 0 && availH > 0 && vw > 0 && vh > 0) minOf(availW / vh, availH / vw) else 1f
|
|
1333
1886
|
} else {
|
|
1334
1887
|
1f
|
|
1335
1888
|
}
|
|
@@ -1402,12 +1955,24 @@ class VideoTrimmerView(
|
|
|
1402
1955
|
if (cw <= 0 || ch <= 0) return
|
|
1403
1956
|
|
|
1404
1957
|
val fitScale = if (rotationCount % 2 != 0) {
|
|
1405
|
-
|
|
1958
|
+
val vw = mVideoView.width.toFloat()
|
|
1959
|
+
val vh = mVideoView.height.toFloat()
|
|
1960
|
+
val margin = bracketOverflow().toFloat()
|
|
1961
|
+
val availW = cw - 2 * margin
|
|
1962
|
+
val availH = ch - 2 * margin
|
|
1963
|
+
if (vw > 0 && vh > 0 && availW > 0 && availH > 0) minOf(availW / vh, availH / vw) else 1f
|
|
1406
1964
|
} else {
|
|
1407
1965
|
1f
|
|
1408
1966
|
}
|
|
1409
1967
|
val flipMul = if (isFlipped) -1f else 1f
|
|
1410
1968
|
|
|
1969
|
+
if (isCropActive) {
|
|
1970
|
+
cropOverlay?.animate()
|
|
1971
|
+
?.alpha(0f)
|
|
1972
|
+
?.setDuration(125)
|
|
1973
|
+
?.start()
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1411
1976
|
mVideoView.animate()
|
|
1412
1977
|
.scaleX(flipMul * fitScale)
|
|
1413
1978
|
.scaleY(fitScale)
|
|
@@ -1418,6 +1983,10 @@ class VideoTrimmerView(
|
|
|
1418
1983
|
if (resetCrop && isCropActive) {
|
|
1419
1984
|
updateCropAllowedRect()
|
|
1420
1985
|
cropOverlay?.resetCrop()
|
|
1986
|
+
cropOverlay?.animate()
|
|
1987
|
+
?.alpha(1f)
|
|
1988
|
+
?.setDuration(125)
|
|
1989
|
+
?.start()
|
|
1421
1990
|
}
|
|
1422
1991
|
}
|
|
1423
1992
|
.start()
|
|
@@ -1430,7 +1999,7 @@ class VideoTrimmerView(
|
|
|
1430
1999
|
private fun onCropTapped() {
|
|
1431
2000
|
isCropActive = !isCropActive
|
|
1432
2001
|
cropBtn.setColorFilter(
|
|
1433
|
-
if (isCropActive)
|
|
2002
|
+
if (isCropActive) iconColor else dimmedIconColor,
|
|
1434
2003
|
android.graphics.PorterDuff.Mode.SRC_IN
|
|
1435
2004
|
)
|
|
1436
2005
|
playHapticFeedback(true)
|
|
@@ -1440,6 +2009,7 @@ class VideoTrimmerView(
|
|
|
1440
2009
|
private fun showCropOverlay() {
|
|
1441
2010
|
hideCropOverlayImmediate()
|
|
1442
2011
|
val overlay = CropOverlayView(mContext)
|
|
2012
|
+
overlay.isLightTheme = isLightTheme
|
|
1443
2013
|
overlay.layoutParams = FrameLayout.LayoutParams(
|
|
1444
2014
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
1445
2015
|
FrameLayout.LayoutParams.MATCH_PARENT
|
|
@@ -1475,6 +2045,7 @@ class VideoTrimmerView(
|
|
|
1475
2045
|
private fun showCropOverlayImmediate() {
|
|
1476
2046
|
hideCropOverlayImmediate()
|
|
1477
2047
|
val overlay = CropOverlayView(mContext)
|
|
2048
|
+
overlay.isLightTheme = isLightTheme
|
|
1478
2049
|
overlay.layoutParams = FrameLayout.LayoutParams(
|
|
1479
2050
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
|
1480
2051
|
FrameLayout.LayoutParams.MATCH_PARENT
|
|
@@ -1519,8 +2090,11 @@ class VideoTrimmerView(
|
|
|
1519
2090
|
|
|
1520
2091
|
val pivotX = cw / 2f
|
|
1521
2092
|
val pivotY = ch / 2f
|
|
2093
|
+
val margin = bracketOverflow().toFloat()
|
|
2094
|
+
val availW = cw - 2 * margin
|
|
2095
|
+
val availH = ch - 2 * margin
|
|
1522
2096
|
val fitScale = if (rotationCount % 2 != 0) {
|
|
1523
|
-
minOf(
|
|
2097
|
+
if (tvW > 0 && tvH > 0 && availW > 0 && availH > 0) minOf(availW / tvH, availH / tvW) else 1f
|
|
1524
2098
|
} else {
|
|
1525
2099
|
1f
|
|
1526
2100
|
}
|
|
@@ -1600,7 +2174,7 @@ class VideoTrimmerView(
|
|
|
1600
2174
|
private fun onUndoTapped() {
|
|
1601
2175
|
if (undoStack.isEmpty()) return
|
|
1602
2176
|
redoStack.add(currentSnapshot())
|
|
1603
|
-
val snap = undoStack.
|
|
2177
|
+
val snap = undoStack.removeAt(undoStack.lastIndex)
|
|
1604
2178
|
applySnapshot(snap)
|
|
1605
2179
|
updateUndoRedoButtons()
|
|
1606
2180
|
}
|
|
@@ -1608,7 +2182,7 @@ class VideoTrimmerView(
|
|
|
1608
2182
|
private fun onRedoTapped() {
|
|
1609
2183
|
if (redoStack.isEmpty()) return
|
|
1610
2184
|
undoStack.add(currentSnapshot())
|
|
1611
|
-
val snap = redoStack.
|
|
2185
|
+
val snap = redoStack.removeAt(redoStack.lastIndex)
|
|
1612
2186
|
applySnapshot(snap)
|
|
1613
2187
|
updateUndoRedoButtons()
|
|
1614
2188
|
}
|
|
@@ -1623,8 +2197,13 @@ class VideoTrimmerView(
|
|
|
1623
2197
|
|
|
1624
2198
|
val cw = containerContentWidth()
|
|
1625
2199
|
val ch = containerContentHeight()
|
|
1626
|
-
val
|
|
1627
|
-
|
|
2200
|
+
val vw = mVideoView.width.toFloat()
|
|
2201
|
+
val vh = mVideoView.height.toFloat()
|
|
2202
|
+
val margin = bracketOverflow().toFloat()
|
|
2203
|
+
val availW = cw - 2 * margin
|
|
2204
|
+
val availH = ch - 2 * margin
|
|
2205
|
+
val fitScale = if (rotationCount % 2 != 0 && availW > 0 && availH > 0 && vw > 0 && vh > 0) {
|
|
2206
|
+
minOf(availW / vh, availH / vw)
|
|
1628
2207
|
} else {
|
|
1629
2208
|
1f
|
|
1630
2209
|
}
|
|
@@ -1634,17 +2213,14 @@ class VideoTrimmerView(
|
|
|
1634
2213
|
val onComplete = Runnable {
|
|
1635
2214
|
if (snap.isCropActive) {
|
|
1636
2215
|
isCropActive = true
|
|
1637
|
-
cropBtn.setColorFilter(
|
|
2216
|
+
cropBtn.setColorFilter(iconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
1638
2217
|
showCropOverlayImmediate()
|
|
1639
2218
|
updateCropAllowedRect()
|
|
1640
2219
|
val norm = snap.cropNormalized
|
|
1641
2220
|
if (norm != null) setCropFromNormalized(norm) else cropOverlay?.resetCrop()
|
|
1642
2221
|
} else {
|
|
1643
2222
|
isCropActive = false
|
|
1644
|
-
cropBtn.setColorFilter(
|
|
1645
|
-
Color.argb(128, 255, 255, 255),
|
|
1646
|
-
android.graphics.PorterDuff.Mode.SRC_IN
|
|
1647
|
-
)
|
|
2223
|
+
cropBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
1648
2224
|
hideCropOverlayImmediate()
|
|
1649
2225
|
}
|
|
1650
2226
|
}
|
|
@@ -1698,16 +2274,14 @@ class VideoTrimmerView(
|
|
|
1698
2274
|
}
|
|
1699
2275
|
|
|
1700
2276
|
private fun updateUndoRedoButtons() {
|
|
1701
|
-
val dimmed = Color.argb(128, 255, 255, 255)
|
|
1702
|
-
val active = Color.WHITE
|
|
1703
2277
|
undoBtn.isEnabled = undoStack.isNotEmpty()
|
|
1704
2278
|
undoBtn.setColorFilter(
|
|
1705
|
-
if (undoStack.isNotEmpty())
|
|
2279
|
+
if (undoStack.isNotEmpty()) iconColor else dimmedIconColor,
|
|
1706
2280
|
android.graphics.PorterDuff.Mode.SRC_IN
|
|
1707
2281
|
)
|
|
1708
2282
|
redoBtn.isEnabled = redoStack.isNotEmpty()
|
|
1709
2283
|
redoBtn.setColorFilter(
|
|
1710
|
-
if (redoStack.isNotEmpty())
|
|
2284
|
+
if (redoStack.isNotEmpty()) iconColor else dimmedIconColor,
|
|
1711
2285
|
android.graphics.PorterDuff.Mode.SRC_IN
|
|
1712
2286
|
)
|
|
1713
2287
|
}
|