react-native-video-trim 7.1.0 → 8.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 +287 -6
- package/android/src/main/java/com/videotrim/BaseVideoTrimModule.kt +488 -34
- package/android/src/main/java/com/videotrim/utils/StorageUtil.kt +95 -36
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +39 -17
- package/android/src/main/java/com/videotrim/widgets/AudioWaveformView.kt +92 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +579 -8
- package/android/src/main/res/drawable/speaker_slash_fill.xml +19 -0
- package/android/src/main/res/drawable/speaker_wave_2_fill.xml +23 -0
- package/android/src/main/res/layout/video_trimmer_view.xml +22 -0
- package/android/src/newarch/VideoTrimModule.kt +33 -0
- package/android/src/oldarch/VideoTrimModule.kt +41 -0
- package/android/src/oldarch/VideoTrimSpec.kt +17 -0
- package/ios/AudioWaveformView.swift +75 -0
- package/ios/VideoTrim.mm +180 -1
- package/ios/VideoTrim.swift +632 -39
- package/ios/VideoTrimmer.swift +300 -0
- package/ios/VideoTrimmerViewController.swift +129 -4
- package/lib/module/NativeVideoTrim.js +52 -0
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +155 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +158 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +65 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +171 -0
- package/src/index.tsx +211 -2
|
@@ -8,8 +8,12 @@ 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
|
|
16
|
+
import android.media.PlaybackParams
|
|
13
17
|
import android.net.Uri
|
|
14
18
|
import android.os.Build
|
|
15
19
|
import android.os.Handler
|
|
@@ -125,6 +129,31 @@ class VideoTrimmerView(
|
|
|
125
129
|
@Volatile
|
|
126
130
|
private var isGeneratingThumbnails = false
|
|
127
131
|
|
|
132
|
+
// region Audio waveform state
|
|
133
|
+
//
|
|
134
|
+
// For audio files the trimmer replaces the thumbnail track with a waveform.
|
|
135
|
+
// Amplitudes are extracted via MediaExtractor + MediaCodec (hardware-accelerated
|
|
136
|
+
// PCM decode) and rendered by AudioWaveformView as rounded-rect bars.
|
|
137
|
+
//
|
|
138
|
+
// Remote files are first downloaded to a local cache file so that both the
|
|
139
|
+
// initial extraction and any zoom-level re-extractions can read from disk
|
|
140
|
+
// instead of re-streaming over the network each time.
|
|
141
|
+
private var waveformView: AudioWaveformView? = null
|
|
142
|
+
/** Full-view (non-zoomed) amplitudes, cached so we can restore instantly on zoom-out. */
|
|
143
|
+
private var cachedFullWaveformAmplitudes: FloatArray? = null
|
|
144
|
+
@Volatile
|
|
145
|
+
private var isGeneratingWaveform = false
|
|
146
|
+
/** Local file path used for waveform extraction (populated after downloading remote audio). */
|
|
147
|
+
private var localAudioFilePath: String? = null
|
|
148
|
+
@Volatile
|
|
149
|
+
private var isDownloadingAudio = false
|
|
150
|
+
private var waveformBarColor = Color.WHITE
|
|
151
|
+
private var waveformBgColor = Color.parseColor("#3478F6")
|
|
152
|
+
private var waveformBarWidthDp = 3f
|
|
153
|
+
private var waveformBarGapDp = 2f
|
|
154
|
+
private var waveformBarCornerRadiusDp = 1.5f
|
|
155
|
+
// endregion
|
|
156
|
+
|
|
128
157
|
private var mediaMetadataRetriever: MediaMetadataRetriever? = null
|
|
129
158
|
private val retrieverLock = Object()
|
|
130
159
|
@Volatile private var retrieverReleased = false
|
|
@@ -174,9 +203,17 @@ class VideoTrimmerView(
|
|
|
174
203
|
private lateinit var cropBtn: ImageView
|
|
175
204
|
private lateinit var undoBtn: ImageView
|
|
176
205
|
private lateinit var redoBtn: ImageView
|
|
206
|
+
private lateinit var muteBtn: ImageView
|
|
207
|
+
private lateinit var speedBtn: TextView
|
|
177
208
|
private lateinit var videoContainer: FrameLayout
|
|
178
209
|
private var cropOverlay: CropOverlayView? = null
|
|
179
210
|
|
|
211
|
+
internal var isMuted = false
|
|
212
|
+
private set
|
|
213
|
+
private var configRemoveAudio = false
|
|
214
|
+
private var speed: Double = 1.0
|
|
215
|
+
private val speedOptions = doubleArrayOf(0.25, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0)
|
|
216
|
+
|
|
180
217
|
private lateinit var trimmerView: RelativeLayout
|
|
181
218
|
|
|
182
219
|
private var trimmerColor = context.getString(R.string.trim_color).toColorInt()
|
|
@@ -255,6 +292,8 @@ class VideoTrimmerView(
|
|
|
255
292
|
flipBtn = findViewById(R.id.flipBtn)
|
|
256
293
|
rotateBtn = findViewById(R.id.rotateBtn)
|
|
257
294
|
cropBtn = findViewById(R.id.cropBtn)
|
|
295
|
+
muteBtn = findViewById(R.id.muteBtn)
|
|
296
|
+
speedBtn = findViewById(R.id.speedBtn)
|
|
258
297
|
undoBtn = findViewById(R.id.undoBtn)
|
|
259
298
|
redoBtn = findViewById(R.id.redoBtn)
|
|
260
299
|
videoContainer = findViewById(R.id.videoContainer)
|
|
@@ -285,11 +324,21 @@ class VideoTrimmerView(
|
|
|
285
324
|
override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
|
|
286
325
|
}
|
|
287
326
|
} else {
|
|
327
|
+
// Audio path: hide video surface, show the audio banner with a fade-in,
|
|
328
|
+
// and kick off waveform generation *in parallel* with MediaPlayer.prepareAsync()
|
|
329
|
+
// so the waveform starts appearing as soon as possible (the download / decode
|
|
330
|
+
// runs on a background thread while MediaPlayer streams independently).
|
|
288
331
|
mVideoView.visibility = View.GONE
|
|
289
332
|
audioBannerView.alpha = 0f
|
|
290
333
|
audioBannerView.visibility = View.VISIBLE
|
|
291
334
|
audioBannerView.animate().alpha(1f).setDuration(500).start()
|
|
292
335
|
|
|
336
|
+
VideoTrimmerUtil.SCREEN_WIDTH_FULL = getScreenWidthInPortraitMode()
|
|
337
|
+
VideoTrimmerUtil.VIDEO_FRAMES_WIDTH = VideoTrimmerUtil.SCREEN_WIDTH_FULL - RECYCLER_VIEW_PADDING * 2
|
|
338
|
+
// endMs = 0 means "unknown duration"; extractAmplitudes will fall back
|
|
339
|
+
// to the track's KEY_DURATION or a sensible default.
|
|
340
|
+
startWaveformGeneration(0, 0)
|
|
341
|
+
|
|
293
342
|
mediaPlayer = MediaPlayer()
|
|
294
343
|
try {
|
|
295
344
|
mediaPlayer!!.setDataSource(videoURI.toString())
|
|
@@ -436,6 +485,24 @@ class VideoTrimmerView(
|
|
|
436
485
|
startShootVideoThumbs(mContext, VideoTrimmerUtil.MAX_COUNT_RANGE, 0, mDuration.toLong())
|
|
437
486
|
}
|
|
438
487
|
|
|
488
|
+
if (!isVideoType) {
|
|
489
|
+
// Waveform generation was started in initByURI, but the view is only
|
|
490
|
+
// created here (after MediaPlayer is ready) so the background color
|
|
491
|
+
// doesn't flash before the trimmer container is visible.
|
|
492
|
+
VideoTrimmerUtil.SCREEN_WIDTH_FULL = getScreenWidthInPortraitMode()
|
|
493
|
+
VideoTrimmerUtil.VIDEO_FRAMES_WIDTH = VideoTrimmerUtil.SCREEN_WIDTH_FULL - RECYCLER_VIEW_PADDING * 2
|
|
494
|
+
if (waveformView == null) {
|
|
495
|
+
setupWaveformView()
|
|
496
|
+
}
|
|
497
|
+
// If the background generation already finished, apply cached data immediately.
|
|
498
|
+
cachedFullWaveformAmplitudes?.let { waveformView?.setAmplitudes(it) }
|
|
499
|
+
// If generation hasn't started yet (e.g. failed the first time), retry
|
|
500
|
+
// now that we know the actual duration.
|
|
501
|
+
if (!isGeneratingWaveform && cachedFullWaveformAmplitudes == null) {
|
|
502
|
+
startWaveformGeneration(0, mDuration.toLong())
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
439
506
|
endTime = if (mMaxDuration < mDuration) mMaxDuration else mDuration.toLong()
|
|
440
507
|
updateHandlePositions()
|
|
441
508
|
|
|
@@ -444,18 +511,27 @@ class VideoTrimmerView(
|
|
|
444
511
|
saveBtn.visibility = View.VISIBLE
|
|
445
512
|
|
|
446
513
|
if (jumpToPositionOnLoad > 0) {
|
|
447
|
-
|
|
514
|
+
// Clamp to endTime so that jumpToPositionOnLoad > maxDuration doesn't
|
|
515
|
+
// cause an immediate pause (the old bug where autoplay appeared broken).
|
|
516
|
+
val clampedJump = jumpToPositionOnLoad.coerceAtMost(endTime)
|
|
517
|
+
seekTo(if (clampedJump > mDuration) mDuration.toLong() else clampedJump, true)
|
|
448
518
|
}
|
|
449
519
|
|
|
450
520
|
if (autoplay) {
|
|
451
521
|
playOrPause()
|
|
452
522
|
}
|
|
453
523
|
|
|
524
|
+
mediaPlayer?.setVolume(if (isMuted) 0f else 1f, if (isMuted) 0f else 1f)
|
|
525
|
+
applyPlaybackSpeed()
|
|
526
|
+
|
|
454
527
|
if (isVideoType) {
|
|
455
528
|
transformRow.alpha = 0f
|
|
456
529
|
transformRow.visibility = View.VISIBLE
|
|
457
530
|
transformRow.animate().alpha(1f).setDuration(250).start()
|
|
458
531
|
updateUndoRedoButtons()
|
|
532
|
+
} else {
|
|
533
|
+
// Fade the waveform in to match the trimmer container animation.
|
|
534
|
+
waveformView?.animate()?.alpha(1f)?.setDuration(250)?.start()
|
|
459
535
|
}
|
|
460
536
|
|
|
461
537
|
mOnTrimVideoListener.onLoad(mDuration)
|
|
@@ -506,11 +582,56 @@ class VideoTrimmerView(
|
|
|
506
582
|
seekTo(startTime, true)
|
|
507
583
|
}
|
|
508
584
|
player.start()
|
|
585
|
+
applyPlaybackSpeed()
|
|
509
586
|
startTimingRunnable()
|
|
510
587
|
}
|
|
511
588
|
setPlayPauseViewIcon(player.isPlaying)
|
|
512
589
|
}
|
|
513
590
|
|
|
591
|
+
private fun applyPlaybackSpeed() {
|
|
592
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
593
|
+
val player = mediaPlayer ?: return
|
|
594
|
+
val params = player.playbackParams ?: PlaybackParams()
|
|
595
|
+
player.playbackParams = params.setSpeed(speed.toFloat())
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private fun onMuteTapped() {
|
|
600
|
+
isMuted = !isMuted
|
|
601
|
+
muteBtn.setImageResource(if (isMuted) R.drawable.speaker_slash_fill else R.drawable.speaker_wave_2_fill)
|
|
602
|
+
mediaPlayer?.setVolume(if (isMuted) 0f else 1f, if (isMuted) 0f else 1f)
|
|
603
|
+
if (enableHapticFeedback) {
|
|
604
|
+
performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Uses Android's native PopupMenu anchored to the speed button for a platform-
|
|
609
|
+
// consistent speed selector (equivalent to iOS's UIMenu on iOS 14+).
|
|
610
|
+
private fun onSpeedTapped() {
|
|
611
|
+
val popup = android.widget.PopupMenu(context, speedBtn)
|
|
612
|
+
speedOptions.forEachIndexed { index, opt ->
|
|
613
|
+
val title = if (opt == 1.0) "Normal (1x)" else "${opt}x"
|
|
614
|
+
popup.menu.add(0, index, index, title)
|
|
615
|
+
}
|
|
616
|
+
popup.setOnMenuItemClickListener { item ->
|
|
617
|
+
setSpeed(speedOptions[item.itemId])
|
|
618
|
+
true
|
|
619
|
+
}
|
|
620
|
+
popup.show()
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private fun setSpeed(newSpeed: Double) {
|
|
624
|
+
speed = newSpeed
|
|
625
|
+
speedBtn.text = if (newSpeed == 1.0) "1x" else "${newSpeed}x"
|
|
626
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
627
|
+
mediaPlayer?.playbackParams = mediaPlayer?.playbackParams?.setSpeed(newSpeed.toFloat())
|
|
628
|
+
?: PlaybackParams().setSpeed(newSpeed.toFloat())
|
|
629
|
+
}
|
|
630
|
+
if (enableHapticFeedback) {
|
|
631
|
+
performHapticFeedback(android.view.HapticFeedbackConstants.VIRTUAL_KEY)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
514
635
|
fun onMediaPause() {
|
|
515
636
|
mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
|
|
516
637
|
val player = mediaPlayer ?: return
|
|
@@ -536,6 +657,8 @@ class VideoTrimmerView(
|
|
|
536
657
|
cropBtn.setOnClickListener { onCropTapped() }
|
|
537
658
|
undoBtn.setOnClickListener { onUndoTapped() }
|
|
538
659
|
redoBtn.setOnClickListener { onRedoTapped() }
|
|
660
|
+
muteBtn.setOnClickListener { onMuteTapped() }
|
|
661
|
+
speedBtn.setOnClickListener { onSpeedTapped() }
|
|
539
662
|
|
|
540
663
|
cropBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
541
664
|
undoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
@@ -552,6 +675,7 @@ class VideoTrimmerView(
|
|
|
552
675
|
?.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
|
553
676
|
?.toLongOrNull() ?: 0L
|
|
554
677
|
}
|
|
678
|
+
val effectiveRemoveAudio = isMuted || configRemoveAudio
|
|
555
679
|
ffmpegSession = VideoTrimmerUtil.trim(
|
|
556
680
|
mSourceUri.toString(),
|
|
557
681
|
StorageUtil.getOutputPath(mContext, mOutputExt),
|
|
@@ -565,6 +689,8 @@ class VideoTrimmerView(
|
|
|
565
689
|
vh,
|
|
566
690
|
bitrate,
|
|
567
691
|
enablePreciseTrimming,
|
|
692
|
+
effectiveRemoveAudio,
|
|
693
|
+
speed,
|
|
568
694
|
mOnTrimVideoListener
|
|
569
695
|
)
|
|
570
696
|
}
|
|
@@ -596,16 +722,34 @@ class VideoTrimmerView(
|
|
|
596
722
|
mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
|
597
723
|
}
|
|
598
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Comprehensive cleanup when the editor is dismissed.
|
|
727
|
+
*
|
|
728
|
+
* The user can close the editor at any time — even immediately after opening —
|
|
729
|
+
* so every background task and media resource must be released here:
|
|
730
|
+
* • Flags (isGeneratingThumbnails / isGeneratingWaveform) are set to false first
|
|
731
|
+
* so that in-flight background loops exit early on the next iteration.
|
|
732
|
+
* • Named BackgroundExecutor tasks are cancelled to interrupt pending work.
|
|
733
|
+
* • MediaPlayer listeners are nulled *before* stop()/release() to prevent
|
|
734
|
+
* callbacks firing on a half-torn-down view.
|
|
735
|
+
* • The downloaded local audio cache file is deleted.
|
|
736
|
+
* • SurfaceTexture listener is cleared to avoid stale references.
|
|
737
|
+
*/
|
|
599
738
|
override fun onDestroy() {
|
|
600
739
|
isGeneratingThumbnails = false
|
|
601
|
-
|
|
740
|
+
isGeneratingWaveform = false
|
|
741
|
+
BackgroundExecutor.cancelAll("initial_thumbs", true)
|
|
602
742
|
BackgroundExecutor.cancelAll("progressive_thumbs", true)
|
|
743
|
+
BackgroundExecutor.cancelAll("waveform_gen", true)
|
|
603
744
|
UiThreadExecutor.cancelAll("")
|
|
604
745
|
mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
|
605
746
|
mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
|
|
606
747
|
zoomRunnable?.let { zoomWaitTimer.removeCallbacks(it) }
|
|
607
748
|
|
|
608
749
|
cachedFullViewThumbnails.clear()
|
|
750
|
+
cachedFullWaveformAmplitudes = null
|
|
751
|
+
waveformView = null
|
|
752
|
+
cleanupLocalAudioFile()
|
|
609
753
|
|
|
610
754
|
cropOverlay?.onCropBegan = null
|
|
611
755
|
cropOverlay?.onCropEnded = null
|
|
@@ -622,17 +766,23 @@ class VideoTrimmerView(
|
|
|
622
766
|
} catch (e: Exception) {
|
|
623
767
|
e.printStackTrace()
|
|
624
768
|
}
|
|
769
|
+
mediaMetadataRetriever = null
|
|
625
770
|
}
|
|
626
771
|
|
|
627
772
|
try {
|
|
773
|
+
mediaPlayer?.setOnPreparedListener(null)
|
|
774
|
+
mediaPlayer?.setOnCompletionListener(null)
|
|
775
|
+
mediaPlayer?.setOnErrorListener(null)
|
|
628
776
|
mediaPlayer?.stop()
|
|
629
777
|
mediaPlayer?.release()
|
|
630
778
|
} catch (e: IllegalStateException) {
|
|
631
779
|
e.printStackTrace()
|
|
632
780
|
Log.d(TAG, "onDestroy mediaPlayer is already released")
|
|
633
781
|
}
|
|
782
|
+
mediaPlayer = null
|
|
634
783
|
|
|
635
784
|
try {
|
|
785
|
+
mVideoView.surfaceTextureListener = null
|
|
636
786
|
videoSurface?.release()
|
|
637
787
|
} catch (_: Exception) {}
|
|
638
788
|
videoSurface = null
|
|
@@ -669,6 +819,17 @@ class VideoTrimmerView(
|
|
|
669
819
|
enablePreciseTrimming = config.hasKey("enablePreciseTrimming") && config.getBoolean("enablePreciseTrimming")
|
|
670
820
|
autoplay = config.hasKey("autoplay") && config.getBoolean("autoplay")
|
|
671
821
|
|
|
822
|
+
if (config.hasKey("removeAudio")) {
|
|
823
|
+
configRemoveAudio = config.getBoolean("removeAudio")
|
|
824
|
+
isMuted = configRemoveAudio
|
|
825
|
+
}
|
|
826
|
+
if (config.hasKey("speed") && config.getDouble("speed") > 0) {
|
|
827
|
+
speed = config.getDouble("speed")
|
|
828
|
+
speedBtn.text = if (speed == 1.0) "1x" else "${speed}x"
|
|
829
|
+
}
|
|
830
|
+
muteBtn.setImageResource(if (isMuted) R.drawable.speaker_slash_fill else R.drawable.speaker_wave_2_fill)
|
|
831
|
+
muteBtn.visibility = if (isVideoType) View.VISIBLE else View.GONE
|
|
832
|
+
|
|
672
833
|
if (config.hasKey("jumpToPositionOnLoad") && config.getDouble("jumpToPositionOnLoad") > 0) {
|
|
673
834
|
jumpToPositionOnLoad = maxOf(0, (config.getDouble("jumpToPositionOnLoad") * 1000L).toLong())
|
|
674
835
|
}
|
|
@@ -698,6 +859,22 @@ class VideoTrimmerView(
|
|
|
698
859
|
).toColorInt()
|
|
699
860
|
handleIconColor = if (config.hasKey("handleIconColor")) config.getInt("handleIconColor") else (if (isLightTheme) Color.WHITE else Color.BLACK)
|
|
700
861
|
|
|
862
|
+
if (config.hasKey("waveformColor")) {
|
|
863
|
+
waveformBarColor = config.getInt("waveformColor")
|
|
864
|
+
}
|
|
865
|
+
if (config.hasKey("waveformBackgroundColor")) {
|
|
866
|
+
waveformBgColor = config.getInt("waveformBackgroundColor")
|
|
867
|
+
}
|
|
868
|
+
if (config.hasKey("waveformBarWidth") && config.getDouble("waveformBarWidth") > 0) {
|
|
869
|
+
waveformBarWidthDp = config.getDouble("waveformBarWidth").toFloat()
|
|
870
|
+
}
|
|
871
|
+
if (config.hasKey("waveformBarGap") && config.getDouble("waveformBarGap") >= 0) {
|
|
872
|
+
waveformBarGapDp = config.getDouble("waveformBarGap").toFloat()
|
|
873
|
+
}
|
|
874
|
+
if (config.hasKey("waveformBarCornerRadius") && config.getDouble("waveformBarCornerRadius") >= 0) {
|
|
875
|
+
waveformBarCornerRadiusDp = config.getDouble("waveformBarCornerRadius").toFloat()
|
|
876
|
+
}
|
|
877
|
+
|
|
701
878
|
applyTrimmerColor()
|
|
702
879
|
applyThemeColors()
|
|
703
880
|
}
|
|
@@ -754,6 +931,8 @@ class VideoTrimmerView(
|
|
|
754
931
|
cropBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
755
932
|
undoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
756
933
|
redoBtn.setColorFilter(dimmedIconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
934
|
+
muteBtn.setColorFilter(iconColor, android.graphics.PorterDuff.Mode.SRC_IN)
|
|
935
|
+
speedBtn.setTextColor(iconColor)
|
|
757
936
|
|
|
758
937
|
// Overlays
|
|
759
938
|
leadingOverlay.setBackgroundColor(overlayColor)
|
|
@@ -876,7 +1055,7 @@ class VideoTrimmerView(
|
|
|
876
1055
|
}
|
|
877
1056
|
|
|
878
1057
|
private fun onTrimmerContainerPanned(event: MotionEvent) {
|
|
879
|
-
var newX = event.rawX
|
|
1058
|
+
var newX = rawXToLocalX(event.rawX)
|
|
880
1059
|
var didClamp = false
|
|
881
1060
|
|
|
882
1061
|
val leftBoundary = leadingHandle.x + leadingHandle.width
|
|
@@ -970,7 +1149,7 @@ class VideoTrimmerView(
|
|
|
970
1149
|
if (draggingDisabled) return@setOnTouchListener false
|
|
971
1150
|
|
|
972
1151
|
var didClamp = false
|
|
973
|
-
var newX = event.rawX - view.width.toFloat() / 2
|
|
1152
|
+
var newX = rawXToLocalX(event.rawX) - view.width.toFloat() / 2
|
|
974
1153
|
|
|
975
1154
|
if (isLeading) {
|
|
976
1155
|
val unclamped = newX
|
|
@@ -1149,9 +1328,15 @@ class VideoTrimmerView(
|
|
|
1149
1328
|
stopZoomWaitTimer()
|
|
1150
1329
|
if (isZoomedIn) {
|
|
1151
1330
|
isGeneratingThumbnails = false
|
|
1331
|
+
isGeneratingWaveform = false
|
|
1152
1332
|
BackgroundExecutor.cancelAll("progressive_thumbs", true)
|
|
1333
|
+
BackgroundExecutor.cancelAll("waveform_gen", true)
|
|
1153
1334
|
isZoomedIn = false
|
|
1154
|
-
|
|
1335
|
+
if (isVideoType) {
|
|
1336
|
+
restoreCachedThumbnails()
|
|
1337
|
+
} else {
|
|
1338
|
+
restoreCachedWaveform()
|
|
1339
|
+
}
|
|
1155
1340
|
animateZoomTransition {
|
|
1156
1341
|
updateHandlePositions()
|
|
1157
1342
|
updateCurrentTime(true)
|
|
@@ -1189,7 +1374,14 @@ class VideoTrimmerView(
|
|
|
1189
1374
|
|
|
1190
1375
|
isZoomedIn = true
|
|
1191
1376
|
|
|
1192
|
-
|
|
1377
|
+
if (isVideoType) {
|
|
1378
|
+
startProgressiveThumbnailGeneration()
|
|
1379
|
+
} else {
|
|
1380
|
+
if (cachedFullWaveformAmplitudes == null) {
|
|
1381
|
+
cachedFullWaveformAmplitudes = waveformView?.amplitudes?.copyOf()
|
|
1382
|
+
}
|
|
1383
|
+
startProgressiveWaveformGeneration()
|
|
1384
|
+
}
|
|
1193
1385
|
updateHandlePositionsForZoom(currentLeadingX, currentTrailingX)
|
|
1194
1386
|
playHapticFeedback(true)
|
|
1195
1387
|
}
|
|
@@ -1334,6 +1526,20 @@ class VideoTrimmerView(
|
|
|
1334
1526
|
}
|
|
1335
1527
|
}
|
|
1336
1528
|
|
|
1529
|
+
/**
|
|
1530
|
+
* Convert a screen-absolute X (from [MotionEvent.getRawX]) to a coordinate
|
|
1531
|
+
* relative to [trimmerContainerWrapper].
|
|
1532
|
+
*
|
|
1533
|
+
* Touch events report rawX in screen-space, but the trimmer's handle/indicator
|
|
1534
|
+
* positions are in the container's local coordinate space. Without this
|
|
1535
|
+
* conversion the playhead lands to the right of the actual finger position.
|
|
1536
|
+
*/
|
|
1537
|
+
private fun rawXToLocalX(rawX: Float): Float {
|
|
1538
|
+
val location = IntArray(2)
|
|
1539
|
+
trimmerContainerWrapper.getLocationOnScreen(location)
|
|
1540
|
+
return rawX - location[0]
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1337
1543
|
private fun positionForTime(time: Long): Float {
|
|
1338
1544
|
return if (isZoomedIn) {
|
|
1339
1545
|
if (zoomedInRangeDuration <= 0) return 0f
|
|
@@ -1365,6 +1571,371 @@ class VideoTrimmerView(
|
|
|
1365
1571
|
}
|
|
1366
1572
|
}
|
|
1367
1573
|
|
|
1574
|
+
// region Waveform — audio-only bar visualization
|
|
1575
|
+
//
|
|
1576
|
+
// Lifecycle:
|
|
1577
|
+
// 1. initByURI starts background download + extraction (startWaveformGeneration).
|
|
1578
|
+
// 2. mediaPrepared creates the AudioWaveformView and applies any cached data.
|
|
1579
|
+
// 3. On zoom-in the visible time range changes; startProgressiveWaveformGeneration
|
|
1580
|
+
// re-extracts just that sub-range at higher resolution.
|
|
1581
|
+
// 4. On zoom-out the cached full-view amplitudes are restored instantly.
|
|
1582
|
+
// 5. onDestroy cancels all in-flight work and deletes the local cache file.
|
|
1583
|
+
|
|
1584
|
+
/** Create the AudioWaveformView and add it to the thumbnail container.
|
|
1585
|
+
* Starts with alpha=0; faded in later in mediaPrepared(). */
|
|
1586
|
+
private fun setupWaveformView() {
|
|
1587
|
+
val density = resources.displayMetrics.density
|
|
1588
|
+
val view = AudioWaveformView(context)
|
|
1589
|
+
view.barColor = waveformBarColor
|
|
1590
|
+
view.setBackgroundColor(waveformBgColor)
|
|
1591
|
+
view.barWidthPx = waveformBarWidthDp * density
|
|
1592
|
+
view.barGapPx = waveformBarGapDp * density
|
|
1593
|
+
view.barCornerRadiusPx = waveformBarCornerRadiusDp * density
|
|
1594
|
+
view.layoutParams = LinearLayout.LayoutParams(
|
|
1595
|
+
LinearLayout.LayoutParams.MATCH_PARENT,
|
|
1596
|
+
LinearLayout.LayoutParams.MATCH_PARENT
|
|
1597
|
+
)
|
|
1598
|
+
view.alpha = 0f
|
|
1599
|
+
mThumbnailContainer.removeAllViews()
|
|
1600
|
+
mThumbnailContainer.addView(view)
|
|
1601
|
+
waveformView = view
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* Generate waveform amplitudes for the full (non-zoomed) view.
|
|
1606
|
+
*
|
|
1607
|
+
* Called from initByURI (with endMs=0 meaning unknown) to start work as
|
|
1608
|
+
* early as possible, and again from mediaPrepared if the first attempt
|
|
1609
|
+
* didn't produce data (e.g. the duration wasn't known yet).
|
|
1610
|
+
*
|
|
1611
|
+
* For remote URLs the first call triggers [resolveLocalAudioPath], which
|
|
1612
|
+
* downloads the audio to a cache file. Subsequent calls (zoom) reuse
|
|
1613
|
+
* that cached file for instant re-extraction without network I/O.
|
|
1614
|
+
*
|
|
1615
|
+
* Progressive updates are sent to the UI via [onProgress] so the user
|
|
1616
|
+
* sees bars filling in as they are decoded, rather than waiting for the
|
|
1617
|
+
* entire file to finish.
|
|
1618
|
+
*/
|
|
1619
|
+
private fun startWaveformGeneration(startMs: Long, endMs: Long) {
|
|
1620
|
+
if (isGeneratingWaveform) return
|
|
1621
|
+
isGeneratingWaveform = true
|
|
1622
|
+
|
|
1623
|
+
val sourceUri = mSourceUri?.toString() ?: return
|
|
1624
|
+
val density = resources.displayMetrics.density
|
|
1625
|
+
val containerWidth = mThumbnailContainer.width - mThumbnailContainer.paddingLeft - mThumbnailContainer.paddingRight
|
|
1626
|
+
val effectiveWidth = if (containerWidth > 0) containerWidth else VideoTrimmerUtil.VIDEO_FRAMES_WIDTH
|
|
1627
|
+
val step = waveformBarWidthDp * density + waveformBarGapDp * density
|
|
1628
|
+
val barCount = maxOf(1, (effectiveWidth / step).toInt())
|
|
1629
|
+
|
|
1630
|
+
BackgroundExecutor.execute(object : BackgroundExecutor.Task("waveform_gen", 0L, "") {
|
|
1631
|
+
override fun execute() {
|
|
1632
|
+
try {
|
|
1633
|
+
val effectiveUri = resolveLocalAudioPath(sourceUri) ?: return
|
|
1634
|
+
|
|
1635
|
+
val amplitudes = extractAmplitudes(effectiveUri, startMs, endMs, barCount) { intermediate ->
|
|
1636
|
+
runOnUiThread {
|
|
1637
|
+
if (isGeneratingWaveform) waveformView?.setAmplitudes(intermediate)
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (!isGeneratingWaveform) return
|
|
1642
|
+
|
|
1643
|
+
cachedFullWaveformAmplitudes = amplitudes.copyOf()
|
|
1644
|
+
|
|
1645
|
+
runOnUiThread {
|
|
1646
|
+
waveformView?.setAmplitudes(amplitudes)
|
|
1647
|
+
}
|
|
1648
|
+
} catch (e: Exception) {
|
|
1649
|
+
Log.e(TAG, "Error generating waveform", e)
|
|
1650
|
+
} finally {
|
|
1651
|
+
isGeneratingWaveform = false
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
})
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** Convert accumulated RMS² values to [0, 1] amplitudes normalised against the peak bar. */
|
|
1658
|
+
private fun normalizeAmplitudes(sumSquares: DoubleArray, sampleCounts: IntArray, barCount: Int): FloatArray {
|
|
1659
|
+
val amplitudes = FloatArray(barCount)
|
|
1660
|
+
var maxAmp = 0f
|
|
1661
|
+
for (i in 0 until barCount) {
|
|
1662
|
+
if (sampleCounts[i] > 0) {
|
|
1663
|
+
amplitudes[i] = kotlin.math.sqrt(sumSquares[i] / sampleCounts[i]).toFloat()
|
|
1664
|
+
if (amplitudes[i] > maxAmp) maxAmp = amplitudes[i]
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
if (maxAmp > 0f) {
|
|
1668
|
+
for (i in amplitudes.indices) {
|
|
1669
|
+
amplitudes[i] = (amplitudes[i] / maxAmp).coerceIn(0f, 1f)
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return amplitudes
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Decode the audio track in [sourceUri] and compute RMS amplitude per bar.
|
|
1677
|
+
*
|
|
1678
|
+
* Pipeline:
|
|
1679
|
+
* MediaExtractor (demux compressed packets)
|
|
1680
|
+
* → MediaCodec (hardware-decode to 16-bit PCM)
|
|
1681
|
+
* → on-the-fly RMS accumulation into per-bar buckets
|
|
1682
|
+
*
|
|
1683
|
+
* Each output buffer's presentation timestamp determines which bar bucket
|
|
1684
|
+
* receives its samples. The result is normalised so the loudest bar = 1.0.
|
|
1685
|
+
*
|
|
1686
|
+
* [onProgress] fires periodically so the UI can show bars filling in
|
|
1687
|
+
* incrementally. The first update fires after 5 % of bars are filled
|
|
1688
|
+
* (firstUpdateThreshold) and subsequent updates every 20 % (regularUpdateInterval).
|
|
1689
|
+
*
|
|
1690
|
+
* The codec is wrapped in its own try/finally to guarantee release even
|
|
1691
|
+
* if an exception occurs mid-decode (e.g. editor closed).
|
|
1692
|
+
*
|
|
1693
|
+
* @param startMs start of the time range to extract (ms).
|
|
1694
|
+
* @param endMs end of the time range (ms); 0 = use track/fallback duration.
|
|
1695
|
+
* @param barCount number of output amplitude bars.
|
|
1696
|
+
*/
|
|
1697
|
+
private fun extractAmplitudes(
|
|
1698
|
+
sourceUri: String, startMs: Long, endMs: Long, barCount: Int,
|
|
1699
|
+
onProgress: ((FloatArray) -> Unit)? = null
|
|
1700
|
+
): FloatArray {
|
|
1701
|
+
val extractor = MediaExtractor()
|
|
1702
|
+
try {
|
|
1703
|
+
if (sourceUri.startsWith("http://") || sourceUri.startsWith("https://")) {
|
|
1704
|
+
extractor.setDataSource(sourceUri, HashMap())
|
|
1705
|
+
} else {
|
|
1706
|
+
extractor.setDataSource(sourceUri)
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
var audioTrackIndex = -1
|
|
1710
|
+
var audioFormat: MediaFormat? = null
|
|
1711
|
+
for (i in 0 until extractor.trackCount) {
|
|
1712
|
+
val format = extractor.getTrackFormat(i)
|
|
1713
|
+
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
|
1714
|
+
if (mime.startsWith("audio/")) {
|
|
1715
|
+
audioTrackIndex = i
|
|
1716
|
+
audioFormat = format
|
|
1717
|
+
break
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
if (audioTrackIndex < 0 || audioFormat == null) return FloatArray(0)
|
|
1721
|
+
|
|
1722
|
+
extractor.selectTrack(audioTrackIndex)
|
|
1723
|
+
extractor.seekTo(startMs * 1000, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
|
1724
|
+
|
|
1725
|
+
val mime = audioFormat.getString(MediaFormat.KEY_MIME) ?: return FloatArray(0)
|
|
1726
|
+
val codec = MediaCodec.createDecoderByType(mime)
|
|
1727
|
+
try {
|
|
1728
|
+
codec.configure(audioFormat, null, null, 0)
|
|
1729
|
+
codec.start()
|
|
1730
|
+
|
|
1731
|
+
val startUs = startMs * 1000L
|
|
1732
|
+
// containsKey guard for API < 29 compatibility (getLong(key, default) requires API 29)
|
|
1733
|
+
val trackDurationUs = if (audioFormat.containsKey(MediaFormat.KEY_DURATION)) audioFormat.getLong(MediaFormat.KEY_DURATION) else 0L
|
|
1734
|
+
val endUs = when {
|
|
1735
|
+
endMs > 0 -> endMs * 1000L
|
|
1736
|
+
trackDurationUs > 0 -> trackDurationUs
|
|
1737
|
+
else -> Long.MAX_VALUE
|
|
1738
|
+
}
|
|
1739
|
+
val totalDurationUs = maxOf(1L, if (endUs == Long.MAX_VALUE) {
|
|
1740
|
+
if (trackDurationUs > 0) trackDurationUs - startUs else 60_000_000L
|
|
1741
|
+
} else {
|
|
1742
|
+
endUs - startUs
|
|
1743
|
+
})
|
|
1744
|
+
val barDurationUs = totalDurationUs.toDouble() / barCount
|
|
1745
|
+
|
|
1746
|
+
val sumSquares = DoubleArray(barCount)
|
|
1747
|
+
val sampleCounts = IntArray(barCount)
|
|
1748
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
1749
|
+
var inputDone = false
|
|
1750
|
+
var outputDone = false
|
|
1751
|
+
val timeoutUs = 10_000L
|
|
1752
|
+
var highestFilledBar = -1
|
|
1753
|
+
// Show the first partial waveform early (5% of bars) for perceived speed
|
|
1754
|
+
val firstUpdateThreshold = maxOf(1, barCount / 20)
|
|
1755
|
+
val regularUpdateInterval = maxOf(1, barCount / 5)
|
|
1756
|
+
var lastUpdateBar = -1
|
|
1757
|
+
|
|
1758
|
+
while (!outputDone && isGeneratingWaveform) {
|
|
1759
|
+
if (!inputDone) {
|
|
1760
|
+
val inputIndex = codec.dequeueInputBuffer(timeoutUs)
|
|
1761
|
+
if (inputIndex >= 0) {
|
|
1762
|
+
val inputBuffer = codec.getInputBuffer(inputIndex) ?: continue
|
|
1763
|
+
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
|
1764
|
+
if (sampleSize < 0 || extractor.sampleTime > endUs) {
|
|
1765
|
+
codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
|
1766
|
+
inputDone = true
|
|
1767
|
+
} else {
|
|
1768
|
+
codec.queueInputBuffer(inputIndex, 0, sampleSize, extractor.sampleTime, 0)
|
|
1769
|
+
extractor.advance()
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
val outputIndex = codec.dequeueOutputBuffer(bufferInfo, timeoutUs)
|
|
1775
|
+
if (outputIndex >= 0) {
|
|
1776
|
+
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
|
1777
|
+
outputDone = true
|
|
1778
|
+
}
|
|
1779
|
+
val outputBuffer = codec.getOutputBuffer(outputIndex)
|
|
1780
|
+
if (outputBuffer != null && bufferInfo.size > 0) {
|
|
1781
|
+
// Map this buffer's timestamp to the corresponding bar bucket
|
|
1782
|
+
val bufferTimeUs = bufferInfo.presentationTimeUs - startUs
|
|
1783
|
+
val barIndex = (bufferTimeUs / barDurationUs).toInt().coerceIn(0, barCount - 1)
|
|
1784
|
+
|
|
1785
|
+
outputBuffer.position(bufferInfo.offset)
|
|
1786
|
+
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
|
|
1787
|
+
// PCM 16-bit: each sample is 2 bytes (Short)
|
|
1788
|
+
val shortCount = bufferInfo.size / 2
|
|
1789
|
+
for (i in 0 until shortCount) {
|
|
1790
|
+
val sample = outputBuffer.short.toFloat() / Short.MAX_VALUE
|
|
1791
|
+
sumSquares[barIndex] += (sample * sample).toDouble()
|
|
1792
|
+
sampleCounts[barIndex]++
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
if (barIndex > highestFilledBar) highestFilledBar = barIndex
|
|
1796
|
+
|
|
1797
|
+
if (onProgress != null) {
|
|
1798
|
+
val interval = if (lastUpdateBar < 0) firstUpdateThreshold else regularUpdateInterval
|
|
1799
|
+
if (highestFilledBar - maxOf(0, lastUpdateBar) >= interval) {
|
|
1800
|
+
lastUpdateBar = highestFilledBar
|
|
1801
|
+
onProgress(normalizeAmplitudes(sumSquares, sampleCounts, barCount))
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
codec.releaseOutputBuffer(outputIndex, false)
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
return normalizeAmplitudes(sumSquares, sampleCounts, barCount)
|
|
1810
|
+
} finally {
|
|
1811
|
+
try { codec.stop() } catch (_: Exception) {}
|
|
1812
|
+
codec.release()
|
|
1813
|
+
}
|
|
1814
|
+
} finally {
|
|
1815
|
+
extractor.release()
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* Re-extract amplitudes for the currently visible (zoomed-in) time range.
|
|
1821
|
+
*
|
|
1822
|
+
* Uses the already-downloaded local file if available, so this is a pure
|
|
1823
|
+
* disk-read + decode operation with no network latency.
|
|
1824
|
+
*/
|
|
1825
|
+
private fun startProgressiveWaveformGeneration() {
|
|
1826
|
+
if (isGeneratingWaveform) return
|
|
1827
|
+
isGeneratingWaveform = true
|
|
1828
|
+
|
|
1829
|
+
val sourceUri = mSourceUri?.toString() ?: return
|
|
1830
|
+
val density = resources.displayMetrics.density
|
|
1831
|
+
val containerWidth = mThumbnailContainer.width - mThumbnailContainer.paddingLeft - mThumbnailContainer.paddingRight
|
|
1832
|
+
val effectiveWidth = if (containerWidth > 0) containerWidth else VideoTrimmerUtil.VIDEO_FRAMES_WIDTH
|
|
1833
|
+
val step = waveformBarWidthDp * density + waveformBarGapDp * density
|
|
1834
|
+
val barCount = maxOf(1, (effectiveWidth / step).toInt())
|
|
1835
|
+
|
|
1836
|
+
val visibleStart = if (isZoomedIn) zoomedInRangeStart else 0L
|
|
1837
|
+
val visibleEnd = if (isZoomedIn) zoomedInRangeStart + zoomedInRangeDuration else mDuration.toLong()
|
|
1838
|
+
|
|
1839
|
+
BackgroundExecutor.execute(object : BackgroundExecutor.Task("waveform_gen", 0L, "") {
|
|
1840
|
+
override fun execute() {
|
|
1841
|
+
try {
|
|
1842
|
+
val effectiveUri = localAudioFilePath ?: sourceUri
|
|
1843
|
+
|
|
1844
|
+
val amplitudes = extractAmplitudes(effectiveUri, visibleStart, visibleEnd, barCount) { intermediate ->
|
|
1845
|
+
runOnUiThread {
|
|
1846
|
+
if (isGeneratingWaveform && isZoomedIn) waveformView?.setAmplitudes(intermediate)
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
if (!isGeneratingWaveform || !isZoomedIn) return
|
|
1851
|
+
|
|
1852
|
+
runOnUiThread {
|
|
1853
|
+
waveformView?.setAmplitudes(amplitudes)
|
|
1854
|
+
}
|
|
1855
|
+
} catch (e: Exception) {
|
|
1856
|
+
Log.e(TAG, "Error generating zoomed waveform", e)
|
|
1857
|
+
} finally {
|
|
1858
|
+
isGeneratingWaveform = false
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
})
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/** Instantly restore the full-view waveform from cache on zoom-out. */
|
|
1865
|
+
private fun restoreCachedWaveform() {
|
|
1866
|
+
cachedFullWaveformAmplitudes?.let { cached ->
|
|
1867
|
+
waveformView?.setAmplitudes(cached)
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Ensure the audio source is available as a local file.
|
|
1873
|
+
*
|
|
1874
|
+
* For local URIs this is a no-op. For remote (http/https) URIs the file
|
|
1875
|
+
* is downloaded once to [Context.getCacheDir] and the path is cached in
|
|
1876
|
+
* [localAudioFilePath] for all subsequent reads (zoom re-extractions).
|
|
1877
|
+
*
|
|
1878
|
+
* Uses a `.tmp` extension because Android's MediaExtractor probes file
|
|
1879
|
+
* content to determine the codec, unlike iOS's AVURLAsset which relies
|
|
1880
|
+
* on the file extension.
|
|
1881
|
+
*
|
|
1882
|
+
* Returns null if a download is already in progress or if the editor
|
|
1883
|
+
* was closed mid-download (checked via [isGeneratingWaveform]).
|
|
1884
|
+
*/
|
|
1885
|
+
private fun resolveLocalAudioPath(sourceUri: String): String? {
|
|
1886
|
+
if (!sourceUri.startsWith("http://") && !sourceUri.startsWith("https://")) {
|
|
1887
|
+
return sourceUri
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
localAudioFilePath?.let { return it }
|
|
1891
|
+
|
|
1892
|
+
if (isDownloadingAudio) return null
|
|
1893
|
+
isDownloadingAudio = true
|
|
1894
|
+
|
|
1895
|
+
try {
|
|
1896
|
+
val url = java.net.URL(sourceUri)
|
|
1897
|
+
val connection = url.openConnection() as java.net.HttpURLConnection
|
|
1898
|
+
connection.connectTimeout = 30_000
|
|
1899
|
+
connection.readTimeout = 30_000
|
|
1900
|
+
connection.instanceFollowRedirects = true
|
|
1901
|
+
connection.connect()
|
|
1902
|
+
|
|
1903
|
+
val destFile = java.io.File(context.cacheDir, "waveform_${System.currentTimeMillis()}.tmp")
|
|
1904
|
+
connection.inputStream.use { input ->
|
|
1905
|
+
java.io.FileOutputStream(destFile).use { output ->
|
|
1906
|
+
val buffer = ByteArray(8192)
|
|
1907
|
+
var bytesRead: Int
|
|
1908
|
+
while (input.read(buffer).also { bytesRead = it } != -1) {
|
|
1909
|
+
if (!isGeneratingWaveform) {
|
|
1910
|
+
destFile.delete()
|
|
1911
|
+
return null
|
|
1912
|
+
}
|
|
1913
|
+
output.write(buffer, 0, bytesRead)
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
connection.disconnect()
|
|
1918
|
+
|
|
1919
|
+
localAudioFilePath = destFile.absolutePath
|
|
1920
|
+
return destFile.absolutePath
|
|
1921
|
+
} catch (e: Exception) {
|
|
1922
|
+
Log.e(TAG, "Failed to download audio for waveform", e)
|
|
1923
|
+
return sourceUri
|
|
1924
|
+
} finally {
|
|
1925
|
+
isDownloadingAudio = false
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/** Delete the temporary local audio file created by [resolveLocalAudioPath]. */
|
|
1930
|
+
private fun cleanupLocalAudioFile() {
|
|
1931
|
+
localAudioFilePath?.let {
|
|
1932
|
+
try { java.io.File(it).delete() } catch (_: Exception) {}
|
|
1933
|
+
localAudioFilePath = null
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// endregion
|
|
1938
|
+
|
|
1368
1939
|
// region Transform
|
|
1369
1940
|
|
|
1370
1941
|
private fun containerContentWidth(): Float =
|
|
@@ -1680,7 +2251,7 @@ class VideoTrimmerView(
|
|
|
1680
2251
|
private fun onUndoTapped() {
|
|
1681
2252
|
if (undoStack.isEmpty()) return
|
|
1682
2253
|
redoStack.add(currentSnapshot())
|
|
1683
|
-
val snap = undoStack.
|
|
2254
|
+
val snap = undoStack.removeAt(undoStack.lastIndex)
|
|
1684
2255
|
applySnapshot(snap)
|
|
1685
2256
|
updateUndoRedoButtons()
|
|
1686
2257
|
}
|
|
@@ -1688,7 +2259,7 @@ class VideoTrimmerView(
|
|
|
1688
2259
|
private fun onRedoTapped() {
|
|
1689
2260
|
if (redoStack.isEmpty()) return
|
|
1690
2261
|
undoStack.add(currentSnapshot())
|
|
1691
|
-
val snap = redoStack.
|
|
2262
|
+
val snap = redoStack.removeAt(redoStack.lastIndex)
|
|
1692
2263
|
applySnapshot(snap)
|
|
1693
2264
|
updateUndoRedoButtons()
|
|
1694
2265
|
}
|