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.
@@ -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
- setupVideoPlayer(st, videoURI)
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 = true
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
- seekTo(if (jumpToPositionOnLoad > mDuration) mDuration.toLong() else jumpToPositionOnLoad, true)
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(Color.argb(128, 255, 255, 255), android.graphics.PorterDuff.Mode.SRC_IN)
529
- undoBtn.setColorFilter(Color.argb(128, 255, 255, 255), android.graphics.PorterDuff.Mode.SRC_IN)
530
- redoBtn.setColorFilter(Color.argb(128, 255, 255, 255), android.graphics.PorterDuff.Mode.SRC_IN)
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
- BackgroundExecutor.cancelAll("", true)
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
- newX = minOf(trimmerContainerBg.width.toFloat() + view.width, maxOf(newX, leadingHandle.x + view.width))
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
- vibrator!!.vibrate(VibrationEffect.createOneShot(if (isLight) 10L else 25L, VibrationEffect.DEFAULT_AMPLITUDE))
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
- restoreCachedThumbnails()
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
- startProgressiveThumbnailGeneration()
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, 4f, resources.displayMetrics).toInt()
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
- if (cw > 0 && ch > 0) minOf(cw / ch, ch / cw) else 1f
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
- minOf(cw / ch, ch / cw)
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) Color.WHITE else Color.argb(128, 255, 255, 255),
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(cw / ch, ch / cw)
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.removeLast()
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.removeLast()
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 fitScale = if (rotationCount % 2 != 0 && cw > 0 && ch > 0) {
1627
- minOf(cw / ch, ch / cw)
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(Color.WHITE, android.graphics.PorterDuff.Mode.SRC_IN)
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()) active else dimmed,
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()) active else dimmed,
2284
+ if (redoStack.isNotEmpty()) iconColor else dimmedIconColor,
1711
2285
  android.graphics.PorterDuff.Mode.SRC_IN
1712
2286
  )
1713
2287
  }