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.
- package/README.md +31 -5
- package/android/src/main/java/com/videotrim/utils/VideoTrimmerUtil.kt +1 -1
- package/android/src/main/java/com/videotrim/widgets/AudioWaveformView.kt +92 -0
- package/android/src/main/java/com/videotrim/widgets/VideoTrimmerView.kt +502 -8
- package/ios/AudioWaveformView.swift +75 -0
- package/ios/VideoTrim.mm +25 -0
- package/ios/VideoTrimmer.swift +300 -0
- package/ios/VideoTrimmerViewController.swift +33 -1
- package/lib/module/NativeVideoTrim.js.map +1 -1
- package/lib/module/index.js +13 -2
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeVideoTrim.d.ts +10 -0
- package/lib/typescript/src/NativeVideoTrim.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeVideoTrim.ts +10 -0
- package/src/index.tsx +28 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
2185
|
+
val snap = redoStack.removeAt(redoStack.lastIndex)
|
|
1692
2186
|
applySnapshot(snap)
|
|
1693
2187
|
updateUndoRedoButtons()
|
|
1694
2188
|
}
|