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.
@@ -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
- seekTo(if (jumpToPositionOnLoad > mDuration) mDuration.toLong() else jumpToPositionOnLoad, true)
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
- BackgroundExecutor.cancelAll("", true)
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
- restoreCachedThumbnails()
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
- startProgressiveThumbnailGeneration()
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.removeLast()
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.removeLast()
2262
+ val snap = redoStack.removeAt(redoStack.lastIndex)
1692
2263
  applySnapshot(snap)
1693
2264
  updateUndoRedoButtons()
1694
2265
  }