react-native-video-trim 7.1.0 → 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
@@ -285,11 +313,21 @@ class VideoTrimmerView(
285
313
  override fun onSurfaceTextureUpdated(st: SurfaceTexture) {}
286
314
  }
287
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).
288
320
  mVideoView.visibility = View.GONE
289
321
  audioBannerView.alpha = 0f
290
322
  audioBannerView.visibility = View.VISIBLE
291
323
  audioBannerView.animate().alpha(1f).setDuration(500).start()
292
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
+
293
331
  mediaPlayer = MediaPlayer()
294
332
  try {
295
333
  mediaPlayer!!.setDataSource(videoURI.toString())
@@ -436,6 +474,24 @@ class VideoTrimmerView(
436
474
  startShootVideoThumbs(mContext, VideoTrimmerUtil.MAX_COUNT_RANGE, 0, mDuration.toLong())
437
475
  }
438
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
+
439
495
  endTime = if (mMaxDuration < mDuration) mMaxDuration else mDuration.toLong()
440
496
  updateHandlePositions()
441
497
 
@@ -444,7 +500,10 @@ class VideoTrimmerView(
444
500
  saveBtn.visibility = View.VISIBLE
445
501
 
446
502
  if (jumpToPositionOnLoad > 0) {
447
- 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)
448
507
  }
449
508
 
450
509
  if (autoplay) {
@@ -456,6 +515,9 @@ class VideoTrimmerView(
456
515
  transformRow.visibility = View.VISIBLE
457
516
  transformRow.animate().alpha(1f).setDuration(250).start()
458
517
  updateUndoRedoButtons()
518
+ } else {
519
+ // Fade the waveform in to match the trimmer container animation.
520
+ waveformView?.animate()?.alpha(1f)?.setDuration(250)?.start()
459
521
  }
460
522
 
461
523
  mOnTrimVideoListener.onLoad(mDuration)
@@ -596,16 +658,34 @@ class VideoTrimmerView(
596
658
  mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
597
659
  }
598
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
+ */
599
674
  override fun onDestroy() {
600
675
  isGeneratingThumbnails = false
601
- BackgroundExecutor.cancelAll("", true)
676
+ isGeneratingWaveform = false
677
+ BackgroundExecutor.cancelAll("initial_thumbs", true)
602
678
  BackgroundExecutor.cancelAll("progressive_thumbs", true)
679
+ BackgroundExecutor.cancelAll("waveform_gen", true)
603
680
  UiThreadExecutor.cancelAll("")
604
681
  mContext.currentActivity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
605
682
  mTimingRunnable?.let { mTimingHandler.removeCallbacks(it) }
606
683
  zoomRunnable?.let { zoomWaitTimer.removeCallbacks(it) }
607
684
 
608
685
  cachedFullViewThumbnails.clear()
686
+ cachedFullWaveformAmplitudes = null
687
+ waveformView = null
688
+ cleanupLocalAudioFile()
609
689
 
610
690
  cropOverlay?.onCropBegan = null
611
691
  cropOverlay?.onCropEnded = null
@@ -622,17 +702,23 @@ class VideoTrimmerView(
622
702
  } catch (e: Exception) {
623
703
  e.printStackTrace()
624
704
  }
705
+ mediaMetadataRetriever = null
625
706
  }
626
707
 
627
708
  try {
709
+ mediaPlayer?.setOnPreparedListener(null)
710
+ mediaPlayer?.setOnCompletionListener(null)
711
+ mediaPlayer?.setOnErrorListener(null)
628
712
  mediaPlayer?.stop()
629
713
  mediaPlayer?.release()
630
714
  } catch (e: IllegalStateException) {
631
715
  e.printStackTrace()
632
716
  Log.d(TAG, "onDestroy mediaPlayer is already released")
633
717
  }
718
+ mediaPlayer = null
634
719
 
635
720
  try {
721
+ mVideoView.surfaceTextureListener = null
636
722
  videoSurface?.release()
637
723
  } catch (_: Exception) {}
638
724
  videoSurface = null
@@ -698,6 +784,22 @@ class VideoTrimmerView(
698
784
  ).toColorInt()
699
785
  handleIconColor = if (config.hasKey("handleIconColor")) config.getInt("handleIconColor") else (if (isLightTheme) Color.WHITE else Color.BLACK)
700
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
+ }
802
+
701
803
  applyTrimmerColor()
702
804
  applyThemeColors()
703
805
  }
@@ -876,7 +978,7 @@ class VideoTrimmerView(
876
978
  }
877
979
 
878
980
  private fun onTrimmerContainerPanned(event: MotionEvent) {
879
- var newX = event.rawX
981
+ var newX = rawXToLocalX(event.rawX)
880
982
  var didClamp = false
881
983
 
882
984
  val leftBoundary = leadingHandle.x + leadingHandle.width
@@ -970,7 +1072,7 @@ class VideoTrimmerView(
970
1072
  if (draggingDisabled) return@setOnTouchListener false
971
1073
 
972
1074
  var didClamp = false
973
- var newX = event.rawX - view.width.toFloat() / 2
1075
+ var newX = rawXToLocalX(event.rawX) - view.width.toFloat() / 2
974
1076
 
975
1077
  if (isLeading) {
976
1078
  val unclamped = newX
@@ -1149,9 +1251,15 @@ class VideoTrimmerView(
1149
1251
  stopZoomWaitTimer()
1150
1252
  if (isZoomedIn) {
1151
1253
  isGeneratingThumbnails = false
1254
+ isGeneratingWaveform = false
1152
1255
  BackgroundExecutor.cancelAll("progressive_thumbs", true)
1256
+ BackgroundExecutor.cancelAll("waveform_gen", true)
1153
1257
  isZoomedIn = false
1154
- restoreCachedThumbnails()
1258
+ if (isVideoType) {
1259
+ restoreCachedThumbnails()
1260
+ } else {
1261
+ restoreCachedWaveform()
1262
+ }
1155
1263
  animateZoomTransition {
1156
1264
  updateHandlePositions()
1157
1265
  updateCurrentTime(true)
@@ -1189,7 +1297,14 @@ class VideoTrimmerView(
1189
1297
 
1190
1298
  isZoomedIn = true
1191
1299
 
1192
- startProgressiveThumbnailGeneration()
1300
+ if (isVideoType) {
1301
+ startProgressiveThumbnailGeneration()
1302
+ } else {
1303
+ if (cachedFullWaveformAmplitudes == null) {
1304
+ cachedFullWaveformAmplitudes = waveformView?.amplitudes?.copyOf()
1305
+ }
1306
+ startProgressiveWaveformGeneration()
1307
+ }
1193
1308
  updateHandlePositionsForZoom(currentLeadingX, currentTrailingX)
1194
1309
  playHapticFeedback(true)
1195
1310
  }
@@ -1334,6 +1449,20 @@ class VideoTrimmerView(
1334
1449
  }
1335
1450
  }
1336
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
+
1337
1466
  private fun positionForTime(time: Long): Float {
1338
1467
  return if (isZoomedIn) {
1339
1468
  if (zoomedInRangeDuration <= 0) return 0f
@@ -1365,6 +1494,371 @@ class VideoTrimmerView(
1365
1494
  }
1366
1495
  }
1367
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
+
1368
1862
  // region Transform
1369
1863
 
1370
1864
  private fun containerContentWidth(): Float =
@@ -1680,7 +2174,7 @@ class VideoTrimmerView(
1680
2174
  private fun onUndoTapped() {
1681
2175
  if (undoStack.isEmpty()) return
1682
2176
  redoStack.add(currentSnapshot())
1683
- val snap = undoStack.removeLast()
2177
+ val snap = undoStack.removeAt(undoStack.lastIndex)
1684
2178
  applySnapshot(snap)
1685
2179
  updateUndoRedoButtons()
1686
2180
  }
@@ -1688,7 +2182,7 @@ class VideoTrimmerView(
1688
2182
  private fun onRedoTapped() {
1689
2183
  if (redoStack.isEmpty()) return
1690
2184
  undoStack.add(currentSnapshot())
1691
- val snap = redoStack.removeLast()
2185
+ val snap = redoStack.removeAt(redoStack.lastIndex)
1692
2186
  applySnapshot(snap)
1693
2187
  updateUndoRedoButtons()
1694
2188
  }